@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +41 -4
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js +44 -7
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +77 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +183 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +7 -26
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/pipeline/steps/shapes.d.ts +18 -0
- package/dist/pipeline/steps/shapes.d.ts.map +1 -1
- package/dist/pipeline/steps/shapes.js +29 -0
- package/dist/pipeline/steps/shapes.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/list-read-scope.ts +87 -9
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +218 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorization-tuples.ts +7 -28
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/pipeline/steps/shapes.ts +31 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- package/src/store/port-contract.ts +102 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open-source membership rules (20260913.01, T01_0_plan.md §3a;
|
|
3
|
+
* T01_1_review.md Q-OR-6b, Q-OR-6c; slice 4 rulings Q-S4-2 to Q-S4-7):
|
|
4
|
+
* how organization roles come to exist on a self-host that has no
|
|
5
|
+
* administrator to grant them. Core code, installed only under the
|
|
6
|
+
* built-in authorization posture (boot/compose.ts `builtInAuthorization`:
|
|
7
|
+
* no unit registered an Authorizer); a composition with its own
|
|
8
|
+
* Authorizer has its own onboarding and never constructs this.
|
|
9
|
+
*
|
|
10
|
+
* Two moments:
|
|
11
|
+
*
|
|
12
|
+
* onAccountCreated(account, caller) — a person's FIRST provisioning, and
|
|
13
|
+
* only that (the identity-account domain's AccountCreatedHook, run by
|
|
14
|
+
* provisionMyAccount on the call whose `created` is true). For every
|
|
15
|
+
* organization the account holds no row on, in this order:
|
|
16
|
+
* 1. `owner` — the organization's creator stamp is this account (its
|
|
17
|
+
* subject or its id): the founder, who founded it
|
|
18
|
+
* idp-shaped before any row existed;
|
|
19
|
+
* 2. `admin` — this account created a blueprint in it (BLUEPRINT_KINDS,
|
|
20
|
+
* constants.ts: the kinds an admin authors; sessions and
|
|
21
|
+
* executions are personal and confer nothing, DD-002
|
|
22
|
+
* rule 2);
|
|
23
|
+
* 3. `admin` — the account's email is the configured operator email
|
|
24
|
+
* (an empty configuration matches nobody);
|
|
25
|
+
* 4. `admin` — the organization has ZERO role rows and no PERSON other
|
|
26
|
+
* than this account created it or any blueprint in it:
|
|
27
|
+
* the fresh install's first caller, and the
|
|
28
|
+
* trusted-local-turned-OIDC laptop whose stamps are all
|
|
29
|
+
* "system". "Zero rows", never "no admin now" — revoking
|
|
30
|
+
* every admin of a bootstrapped organization must not
|
|
31
|
+
* hand it to the next stranger (plan finding 2); and the
|
|
32
|
+
* founder's own stamp counts as a person (Q-S4-7), so a
|
|
33
|
+
* revoked founder's empty organization is not handed
|
|
34
|
+
* over either;
|
|
35
|
+
* 5. `member` — everyone else.
|
|
36
|
+
* Arms 3 and 4 both answer `admin`; 3 is checked first because it is a
|
|
37
|
+
* string compare and 4 reads the organization's rows. `user`-class
|
|
38
|
+
* callers only (Q-OR-6b): a runner, a machine or the in-process class
|
|
39
|
+
* never earns a role. Nothing on an organization the account already
|
|
40
|
+
* holds a row on, so a run that faulted midway converges on the next.
|
|
41
|
+
*
|
|
42
|
+
* ensureOperatorOwnership(operator) — the trusted-local boot ensure
|
|
43
|
+
* (Q-OR-6c): the laptop's operator becomes `owner` of every organization
|
|
44
|
+
* that has NO owner row. "No owner row", not "no row of this operator"
|
|
45
|
+
* (Q-S4-3): a boot-time write never overrides a human grant recorded
|
|
46
|
+
* under an earlier OIDC life of the same database, and the permissive
|
|
47
|
+
* Authorizer admits the operator regardless. A second boot writes
|
|
48
|
+
* nothing. The row is granted AS the operator — the trusted-local
|
|
49
|
+
* identity shape (empty issuer and token) carrying the account's id,
|
|
50
|
+
* email and name — so its audit actor reads like every other write on
|
|
51
|
+
* that laptop.
|
|
52
|
+
*
|
|
53
|
+
* What a creator stamp is. The rules read `status.audit.spec_audit
|
|
54
|
+
* .created_by.id` off organizations and blueprints. That stamp is the
|
|
55
|
+
* caller's identityId at the time: a provisioned caller's ACCOUNT id, an
|
|
56
|
+
* unprovisioned caller's raw issuer SUBJECT, and — under the trusted-local
|
|
57
|
+
* posture — the operator's EMAIL or the "system" placeholder
|
|
58
|
+
* (pipeline/interceptors/auth.ts trustedLocalIdentityFor). So "mine" is
|
|
59
|
+
* "equals my subject or my id" (an email stamp is nobody's by this rule;
|
|
60
|
+
* the operator is covered by arm 3 and by ensureOperatorOwnership), and a
|
|
61
|
+
* stamp is a PERSON when it is non-empty and not the "system" placeholder
|
|
62
|
+
* (SYSTEM_OPERATOR_IDENTITY_ID, the one home of that word — Q-S4-6). The
|
|
63
|
+
* rules classify stamps by shape and consult no account store (Q-S4-5).
|
|
64
|
+
*
|
|
65
|
+
* Reads. Policy rows through the IamPolicyStore PORT (`policies`, the
|
|
66
|
+
* same instance the grant path writes through) — never around it
|
|
67
|
+
* (Q-S4-5). Organizations and blueprints through the generic Store,
|
|
68
|
+
* decoded with each kind's own schema (CREATOR_SCAN_SCHEMAS; Q-S4-2: the
|
|
69
|
+
* envelope shares its field numbers but each status carries its audit
|
|
70
|
+
* under its own, so no generic decode exists). Cost, measured in the
|
|
71
|
+
* entry's execution record: one scan of each of seven kinds and one row
|
|
72
|
+
* read per organization, once per account creation.
|
|
73
|
+
*
|
|
74
|
+
* The window this design accepts (Q-S4-4). A store fault after the
|
|
75
|
+
* account row persisted fails the request INTERNAL (the controller's
|
|
76
|
+
* mapping) with the account in place; later calls answer `created:
|
|
77
|
+
* false`, so the rules never run again for that person. The writes ride
|
|
78
|
+
* the idempotent grant path, so a partial run leaves only correct rows,
|
|
79
|
+
* and the recovery is an administrator's grant — or, on a fresh install,
|
|
80
|
+
* arm 3 at the operator's own sign-in. An account created through the
|
|
81
|
+
* `create` RPC (the platform's own pipelines, cloud#393) never reaches
|
|
82
|
+
* this hook either: the rules are about a person signing in.
|
|
83
|
+
*/
|
|
84
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
85
|
+
import { fromBinary } from "@bufbuild/protobuf";
|
|
86
|
+
|
|
87
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
88
|
+
import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
|
|
89
|
+
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
90
|
+
import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
|
|
91
|
+
import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
92
|
+
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
93
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
94
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
95
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
96
|
+
import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
97
|
+
|
|
98
|
+
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
99
|
+
import { kindEnumName } from "../../pipeline/apiresource-meta.js";
|
|
100
|
+
import { SYSTEM_OPERATOR_IDENTITY_ID } from "../../pipeline/interceptors/auth.js";
|
|
101
|
+
import { auditOf } from "../../pipeline/steps/defaults.js";
|
|
102
|
+
import { metadataOf } from "../../pipeline/steps/shapes.js";
|
|
103
|
+
import type { Store } from "../../store/interface.js";
|
|
104
|
+
import type { AccountCreatedHook } from "../identityaccount/provisioning.js";
|
|
105
|
+
import { BLUEPRINT_KINDS } from "./constants.js";
|
|
106
|
+
import type { IamPolicyGrantPath } from "./grant-path.js";
|
|
107
|
+
import { organizationRole, relationOf } from "./specs.js";
|
|
108
|
+
import type { IamPolicyStore } from "./store.js";
|
|
109
|
+
|
|
110
|
+
export interface MembershipRulesDeps {
|
|
111
|
+
readonly grantPath: IamPolicyGrantPath;
|
|
112
|
+
/** The port the grant path writes through — read here for "holds a row", "zero rows", "has an owner". */
|
|
113
|
+
readonly policies: IamPolicyStore;
|
|
114
|
+
readonly store: Store;
|
|
115
|
+
/** STIGMER_OPERATOR_EMAIL as the operator seam holds it; "" when unconfigured. */
|
|
116
|
+
readonly operatorEmail: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface MembershipRules extends AccountCreatedHook {
|
|
120
|
+
/** The trusted-local boot ensure: `owner` wherever no owner row exists. */
|
|
121
|
+
ensureOperatorOwnership(operator: IdentityAccount): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The schemas the creator scan decodes with: the organization and every
|
|
126
|
+
* BLUEPRINT_KINDS member. Pinned equal to that set by the domain's tests,
|
|
127
|
+
* so a kind added to the constant without a schema fails loudly instead
|
|
128
|
+
* of being skipped.
|
|
129
|
+
*/
|
|
130
|
+
export const CREATOR_SCAN_SCHEMAS: ReadonlyMap<ApiResourceKind, DescMessage> =
|
|
131
|
+
new Map<ApiResourceKind, DescMessage>([
|
|
132
|
+
[ApiResourceKind.organization, OrganizationSchema],
|
|
133
|
+
[ApiResourceKind.agent, AgentSchema],
|
|
134
|
+
[ApiResourceKind.workflow, WorkflowSchema],
|
|
135
|
+
[ApiResourceKind.skill, SkillSchema],
|
|
136
|
+
[ApiResourceKind.mcp_server, McpServerSchema],
|
|
137
|
+
[ApiResourceKind.environment, EnvironmentSchema],
|
|
138
|
+
[ApiResourceKind.schedule, ScheduleSchema],
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const ORGANIZATION = kindEnumName(ApiResourceKind.organization);
|
|
142
|
+
const IDENTITY_ACCOUNT = kindEnumName(ApiResourceKind.identity_account);
|
|
143
|
+
|
|
144
|
+
/** What the scan keeps of a resource: its id, its organization (blueprints), its creator stamp. */
|
|
145
|
+
interface ScannedResource {
|
|
146
|
+
readonly id: string;
|
|
147
|
+
readonly org: string;
|
|
148
|
+
readonly createdBy: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A creator stamp that names a person: non-empty and not the unconfigured laptop's placeholder. */
|
|
152
|
+
function isPersonStamp(stamp: string): boolean {
|
|
153
|
+
return stamp !== "" && stamp !== SYSTEM_OPERATOR_IDENTITY_ID;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function newMembershipRules(deps: MembershipRulesDeps): MembershipRules {
|
|
157
|
+
const { grantPath, policies, store, operatorEmail } = deps;
|
|
158
|
+
|
|
159
|
+
async function scan(kind: ApiResourceKind): Promise<ScannedResource[]> {
|
|
160
|
+
const schema = CREATOR_SCAN_SCHEMAS.get(kind);
|
|
161
|
+
if (schema === undefined) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`no schema to decode ${kindEnumName(kind)} rows for the creator scan`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const rows = await store.listResources(kind);
|
|
167
|
+
return rows.map((bytes) => {
|
|
168
|
+
const resource = fromBinary(schema, bytes);
|
|
169
|
+
const metadata = metadataOf(resource);
|
|
170
|
+
return {
|
|
171
|
+
id: metadata?.id ?? "",
|
|
172
|
+
org: metadata?.org ?? "",
|
|
173
|
+
createdBy: auditOf(schema, resource)?.specAudit?.createdBy?.id ?? "",
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function scanBlueprints(): Promise<ScannedResource[]> {
|
|
179
|
+
const scanned: ScannedResource[] = [];
|
|
180
|
+
for (const kind of BLUEPRINT_KINDS) {
|
|
181
|
+
scanned.push(...(await scan(kind)));
|
|
182
|
+
}
|
|
183
|
+
return scanned;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The organizations `accountId` already holds any row on — the idempotency set. */
|
|
187
|
+
async function organizationsHeldBy(
|
|
188
|
+
accountId: string,
|
|
189
|
+
): Promise<ReadonlySet<string>> {
|
|
190
|
+
const rows = await policies.findByPrincipal(IDENTITY_ACCOUNT, accountId);
|
|
191
|
+
return new Set(
|
|
192
|
+
rows
|
|
193
|
+
.filter((row) => row.spec?.resource?.kind === ORGANIZATION)
|
|
194
|
+
.map((row) => row.spec?.resource?.id ?? ""),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
async onAccountCreated(account, caller): Promise<void> {
|
|
200
|
+
if (caller.callerClass !== "user") {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const accountId = account.metadata?.id ?? "";
|
|
204
|
+
if (accountId === "") {
|
|
205
|
+
throw new Error(
|
|
206
|
+
"membership rules: the provisioned account carries no id",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const organizations = await scan(ApiResourceKind.organization);
|
|
210
|
+
if (organizations.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const subject = account.spec?.idpId ?? "";
|
|
214
|
+
const email = account.spec?.email ?? "";
|
|
215
|
+
const isMine = (stamp: string): boolean =>
|
|
216
|
+
stamp !== "" && (stamp === subject || stamp === accountId);
|
|
217
|
+
const held = await organizationsHeldBy(accountId);
|
|
218
|
+
const blueprints = await scanBlueprints();
|
|
219
|
+
|
|
220
|
+
for (const organization of organizations) {
|
|
221
|
+
if (held.has(organization.id)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const inOrganization = blueprints.filter(
|
|
225
|
+
(blueprint) => blueprint.org === organization.id,
|
|
226
|
+
);
|
|
227
|
+
let role: IamRole;
|
|
228
|
+
if (isMine(organization.createdBy)) {
|
|
229
|
+
role = IamRole.owner;
|
|
230
|
+
} else if (inOrganization.some((b) => isMine(b.createdBy))) {
|
|
231
|
+
role = IamRole.admin;
|
|
232
|
+
} else if (operatorEmail !== "" && email === operatorEmail) {
|
|
233
|
+
role = IamRole.admin;
|
|
234
|
+
} else if (
|
|
235
|
+
![
|
|
236
|
+
organization.createdBy,
|
|
237
|
+
...inOrganization.map((b) => b.createdBy),
|
|
238
|
+
].some((stamp) => isPersonStamp(stamp) && !isMine(stamp)) &&
|
|
239
|
+
(await policies.findByResource(ORGANIZATION, organization.id))
|
|
240
|
+
.length === 0
|
|
241
|
+
) {
|
|
242
|
+
role = IamRole.admin;
|
|
243
|
+
} else {
|
|
244
|
+
role = IamRole.member;
|
|
245
|
+
}
|
|
246
|
+
await grantPath.grant(
|
|
247
|
+
organizationRole(accountId, role, organization.id),
|
|
248
|
+
caller,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
async ensureOperatorOwnership(operator): Promise<void> {
|
|
254
|
+
const accountId = operator.metadata?.id ?? "";
|
|
255
|
+
if (accountId === "") {
|
|
256
|
+
throw new Error("membership rules: the operator account carries no id");
|
|
257
|
+
}
|
|
258
|
+
const actor = operatorActor(operator, accountId);
|
|
259
|
+
for (const organization of await scan(ApiResourceKind.organization)) {
|
|
260
|
+
const owners = await policies.findByResourceWithRelations(
|
|
261
|
+
ORGANIZATION,
|
|
262
|
+
organization.id,
|
|
263
|
+
[relationOf(IamRole.owner)],
|
|
264
|
+
);
|
|
265
|
+
if (owners.length > 0) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
await grantPath.grant(
|
|
269
|
+
organizationRole(accountId, IamRole.owner, organization.id),
|
|
270
|
+
actor,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The operator acting as its account: the trusted-local identity shape
|
|
279
|
+
* (pipeline/interceptors/auth.ts trustedLocalIdentityFor — no issuer, no
|
|
280
|
+
* token; email and display name only when known) with the account id as
|
|
281
|
+
* the principal, so the row's audit actor is the account, named the way
|
|
282
|
+
* every other trusted-local write names it.
|
|
283
|
+
*/
|
|
284
|
+
function operatorActor(
|
|
285
|
+
operator: IdentityAccount,
|
|
286
|
+
accountId: string,
|
|
287
|
+
): CallerIdentity {
|
|
288
|
+
const email = operator.spec?.email ?? "";
|
|
289
|
+
const displayName = operator.metadata?.name ?? "";
|
|
290
|
+
return {
|
|
291
|
+
identityId: accountId,
|
|
292
|
+
callerClass: "user",
|
|
293
|
+
issuer: "",
|
|
294
|
+
rawToken: "",
|
|
295
|
+
...(email !== "" ? { email } : {}),
|
|
296
|
+
...(displayName !== "" ? { displayName } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission vocabulary as the wire spells it (20260913.01 slice 5,
|
|
3
|
+
* Q-S5-3): `checkMyPermission` carries its `relation` as a string — the
|
|
4
|
+
* FGA relation name, which is the `IamPermission` member name — while the
|
|
5
|
+
* composed Authorizer's `AuthzCheck` carries the enum. This is the one
|
|
6
|
+
* read from the first spelling to the second. A relation that names no
|
|
7
|
+
* permission is the caller's mistake (INVALID_ARGUMENT in the controller),
|
|
8
|
+
* never a permission nobody holds: the console asks with the ten strings
|
|
9
|
+
* `IamPermission` lists (claim check C4) and a typo should be heard.
|
|
10
|
+
*
|
|
11
|
+
* A Map over the descriptor's values, never the TS enum's reverse index
|
|
12
|
+
* (the `kindsByEnumName` doctrine in pipeline/apiresource-meta.ts): a
|
|
13
|
+
* prototype key or a number spelled as text must resolve to nothing. The
|
|
14
|
+
* zero value `unspecified` is not a permission and is left out.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
IamPermission,
|
|
18
|
+
IamPermissionSchema,
|
|
19
|
+
} from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
20
|
+
|
|
21
|
+
let byName: Map<string, IamPermission> | undefined;
|
|
22
|
+
|
|
23
|
+
/** The permission a wire relation names, or `undefined`. */
|
|
24
|
+
export function permissionByEnumName(
|
|
25
|
+
relation: string,
|
|
26
|
+
): IamPermission | undefined {
|
|
27
|
+
if (byName === undefined) {
|
|
28
|
+
byName = new Map(
|
|
29
|
+
IamPermissionSchema.values
|
|
30
|
+
.filter((value) => value.number !== IamPermission.unspecified)
|
|
31
|
+
.map((value) => [value.name, value.number as IamPermission]),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return byName.get(relation);
|
|
35
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open-source IamPolicyStore: the port (store.ts) over the generic
|
|
3
|
+
* Store, rows of the `resources` table by kind (20260913.01, T01_0_plan.md
|
|
4
|
+
* §3a, §4). The composition root installs it when no extension registers
|
|
5
|
+
* `drivers.iamPolicyStore`.
|
|
6
|
+
*
|
|
7
|
+
* Reads. `findById` is a PRIMARY-KEY read. Every other find decodes
|
|
8
|
+
* `listResources(iam_policy)` and filters in memory: the `resources` table
|
|
9
|
+
* has no secondary index and `findByField` is single-hit, so a scan is the
|
|
10
|
+
* honest shape, and the row count is members × organizations on a
|
|
11
|
+
* self-host — measured in the entry's execution record (§4), not assumed.
|
|
12
|
+
* The filters are the cloud store's WHERE clauses restated over the proto
|
|
13
|
+
* (store.ts names each), so a driver's test over either edition reads the
|
|
14
|
+
* same contract.
|
|
15
|
+
*
|
|
16
|
+
* Writes. `save` refuses a policy whose id is not its triple's derived id
|
|
17
|
+
* (constants.ts policyIdFor): open source has no legacy random ids, and a
|
|
18
|
+
* stray id would break the primary key's promise of one row per triple.
|
|
19
|
+
* It is then read-then-write refusing a held id with DuplicatePolicyError,
|
|
20
|
+
* because the generic Store's saveResource is an upsert with no
|
|
21
|
+
* create-only form — the 2a adapter's shape and its recorded residual
|
|
22
|
+
* window (two concurrent saves of one triple may both fulfil; the primary
|
|
23
|
+
* key still guarantees ONE row, which is the invariant that matters, and
|
|
24
|
+
* the grant path treats a same-triple winner as the duplicate arm either
|
|
25
|
+
* way). Conditional store writes are a platform-wide follow-up, not this
|
|
26
|
+
* adapter's to invent.
|
|
27
|
+
*
|
|
28
|
+
* Store faults follow the ratified mapping (domain/apikey/lookup.ts): a
|
|
29
|
+
* typed ResourceNotFoundError reads as `undefined`; anything else
|
|
30
|
+
* propagates — an outage must never read as "no grant".
|
|
31
|
+
*
|
|
32
|
+
* The port's contract is proven by store-contract.ts, run over this
|
|
33
|
+
* adapter on both drivers in __tests__/resource-store.test.ts, which also
|
|
34
|
+
* pins the derived-id refusal that is this adapter's own.
|
|
35
|
+
*/
|
|
36
|
+
import { fromBinary } from "@bufbuild/protobuf";
|
|
37
|
+
|
|
38
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
39
|
+
import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
40
|
+
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
41
|
+
|
|
42
|
+
import { kindEnumName } from "../../pipeline/apiresource-meta.js";
|
|
43
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
44
|
+
import type { Store } from "../../store/interface.js";
|
|
45
|
+
import { USER_GRANT_PRINCIPAL_KINDS, policyIdFor } from "./constants.js";
|
|
46
|
+
import { DuplicatePolicyError } from "./store.js";
|
|
47
|
+
import type { IamPolicyStore } from "./store.js";
|
|
48
|
+
|
|
49
|
+
const KIND = ApiResourceKind.iam_policy;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The cloud SQL's scope-tuple exclusions (store.ts): a scope tuple is a
|
|
53
|
+
* row whose principal is a RESOURCE and whose relation is structural. The
|
|
54
|
+
* principal half is derived from the user lane's grantee vocabulary
|
|
55
|
+
* (constants.ts USER_GRANT_PRINCIPAL_KINDS; Q-S9-2) so the writer's "who a
|
|
56
|
+
* person may grant to" and the reader's "what is a person, not a parent"
|
|
57
|
+
* are one definition; `team` stays for the cloud's Java-era rows (not an
|
|
58
|
+
* ApiResourceKind, so no wire spec names it).
|
|
59
|
+
*/
|
|
60
|
+
const NON_STRUCTURAL_PRINCIPAL_KINDS: ReadonlyArray<string> = [
|
|
61
|
+
...USER_GRANT_PRINCIPAL_KINDS.map((kind) => kindEnumName(kind)),
|
|
62
|
+
"team",
|
|
63
|
+
];
|
|
64
|
+
const NON_STRUCTURAL_RELATIONS: ReadonlyArray<string> = ["owner", "creator"];
|
|
65
|
+
|
|
66
|
+
export function newResourceIamPolicyStore(store: Store): IamPolicyStore {
|
|
67
|
+
async function readById(id: string): Promise<IamPolicy | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
return await store.getResource(KIND, id, IamPolicySchema);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof ResourceNotFoundError) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every row of the kind, decoded — the scan behind every non-id read. */
|
|
79
|
+
async function all(): Promise<ReadonlyArray<IamPolicy>> {
|
|
80
|
+
const rows = await store.listResources(KIND);
|
|
81
|
+
return rows.map((bytes) => fromBinary(IamPolicySchema, bytes));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function where(
|
|
85
|
+
predicate: (policy: IamPolicy) => boolean,
|
|
86
|
+
): Promise<ReadonlyArray<IamPolicy>> {
|
|
87
|
+
return (await all()).filter(predicate);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async save(policy): Promise<void> {
|
|
92
|
+
const id = policy.metadata?.id ?? "";
|
|
93
|
+
if (policy.spec === undefined || id !== policyIdFor(policy.spec)) {
|
|
94
|
+
throw new Error("policy id must be derived from its triple");
|
|
95
|
+
}
|
|
96
|
+
if ((await readById(id)) !== undefined) {
|
|
97
|
+
throw new DuplicatePolicyError(`IAM policy '${id}' already exists`);
|
|
98
|
+
}
|
|
99
|
+
await store.saveResource(KIND, id, IamPolicySchema, policy);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async deleteById(id): Promise<void> {
|
|
103
|
+
await store.deleteResource(KIND, id);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
findById: readById,
|
|
107
|
+
|
|
108
|
+
findByPrincipal(principalKind, principalId) {
|
|
109
|
+
return where((policy) => onPrincipal(policy, principalKind, principalId));
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
findByResource(resourceKind, resourceId) {
|
|
113
|
+
return where((policy) => onResource(policy, resourceKind, resourceId));
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
findByPrincipalAndResource(
|
|
117
|
+
principalKind,
|
|
118
|
+
principalId,
|
|
119
|
+
resourceKind,
|
|
120
|
+
resourceId,
|
|
121
|
+
) {
|
|
122
|
+
return where(
|
|
123
|
+
(policy) =>
|
|
124
|
+
onPrincipal(policy, principalKind, principalId) &&
|
|
125
|
+
onResource(policy, resourceKind, resourceId),
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
findByResourceWithRelations(resourceKind, resourceId, relations) {
|
|
130
|
+
return where(
|
|
131
|
+
(policy) =>
|
|
132
|
+
onResource(policy, resourceKind, resourceId) &&
|
|
133
|
+
relations.includes(relationOf(policy)),
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async countDistinctPrincipalsByResource(
|
|
138
|
+
resourceKind,
|
|
139
|
+
resourceId,
|
|
140
|
+
principalKind,
|
|
141
|
+
relations,
|
|
142
|
+
): Promise<number> {
|
|
143
|
+
const principals = new Set<string>();
|
|
144
|
+
for (const policy of await all()) {
|
|
145
|
+
if (
|
|
146
|
+
onResource(policy, resourceKind, resourceId) &&
|
|
147
|
+
relations.includes(relationOf(policy)) &&
|
|
148
|
+
(principalKind === undefined ||
|
|
149
|
+
policy.spec?.principal?.kind === principalKind)
|
|
150
|
+
) {
|
|
151
|
+
principals.add(
|
|
152
|
+
`${policy.spec?.principal?.kind ?? ""}:${policy.spec?.principal?.id ?? ""}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return principals.size;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async findScopeTuple(resourceKind, resourceId) {
|
|
160
|
+
return (await all()).find(
|
|
161
|
+
(policy) =>
|
|
162
|
+
onResource(policy, resourceKind, resourceId) &&
|
|
163
|
+
!NON_STRUCTURAL_PRINCIPAL_KINDS.includes(
|
|
164
|
+
policy.spec?.principal?.kind ?? "",
|
|
165
|
+
) &&
|
|
166
|
+
!NON_STRUCTURAL_RELATIONS.includes(relationOf(policy)),
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function onPrincipal(policy: IamPolicy, kind: string, id: string): boolean {
|
|
173
|
+
return (
|
|
174
|
+
policy.spec?.principal?.kind === kind && policy.spec?.principal?.id === id
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onResource(policy: IamPolicy, kind: string, id: string): boolean {
|
|
179
|
+
return (
|
|
180
|
+
policy.spec?.resource?.kind === kind && policy.spec?.resource?.id === id
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function relationOf(policy: IamPolicy): string {
|
|
185
|
+
return policy.spec?.relation ?? "";
|
|
186
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in role lifecycle (20260913.01, T01_0_plan.md §3a; T01_1_review.md
|
|
3
|
+
* Q-OR-6a): the ResourceAuthorizationLifecycle open source installs when
|
|
4
|
+
* no unit registers one, under the built-in authorization posture
|
|
5
|
+
* (boot/compose.ts `builtInAuthorization`). It is the ROW writer of that
|
|
6
|
+
* posture at the resource lifecycle points — the one arm that makes an
|
|
7
|
+
* organization's creator its owner without an administrator — and nothing
|
|
8
|
+
* more. The cloud's tuple driver plays the same part for tuples; a
|
|
9
|
+
* composition that registers one never sees this object.
|
|
10
|
+
*
|
|
11
|
+
* It is handed to the ORGANIZATION and IDENTITY-ACCOUNT controllers only
|
|
12
|
+
* (slice 4 ruling Q-S4-8), the two kinds whose events it reads. A
|
|
13
|
+
* lifecycle on a controller turns on the tuple steps' full event
|
|
14
|
+
* resolution for that kind, and resolution fails a request whose
|
|
15
|
+
* configured parent id is missing — Java parity the cloud relies on, and
|
|
16
|
+
* a state open source admits by contract for other kinds (a memory
|
|
17
|
+
* captured with no credential has no subject). Every other controller
|
|
18
|
+
* keeps the composed driver (undefined in open source) and its steps
|
|
19
|
+
* no-op before any resolution, byte-identical to before this entry.
|
|
20
|
+
*
|
|
21
|
+
* What it writes:
|
|
22
|
+
* - `organization` created with DIRECT attribution (the kind's
|
|
23
|
+
* owner_type in kind_meta — the event says so; the driver follows it
|
|
24
|
+
* exactly as the cloud's does) by a `user`-class caller that RESOLVES
|
|
25
|
+
* to an account: one `owner` row for that account. Only `organization`,
|
|
26
|
+
* because that row is what the Members page and entry 3's admin rule
|
|
27
|
+
* read; an owner row on an agent would be the per-resource grant the
|
|
28
|
+
* grant scope keeps Enterprise (Q-OR-3).
|
|
29
|
+
* - `organization` and `identity_account` deleted: every row naming the
|
|
30
|
+
* resource on either side goes, through the grant path's bidirectional
|
|
31
|
+
* cleanup — the delete chains' CleanupIamPolicies step calls this, so
|
|
32
|
+
* the rows die with the resource.
|
|
33
|
+
*
|
|
34
|
+
* Who the row is FOR, and who it says wrote it. The event's caller is the
|
|
35
|
+
* position-1 identity, which is an account id only when a verifier
|
|
36
|
+
* resolved it: under the trusted-local posture it is the operator's
|
|
37
|
+
* EMAIL (or "system"), and a composition verifier may stamp a raw
|
|
38
|
+
* subject. `accountForCaller` (domain/identityaccount/resolve.ts) is the
|
|
39
|
+
* domain's one statement of "the caller's account" — the two reads
|
|
40
|
+
* whoAmI makes — so this object and whoAmI cannot disagree (Q-S4-1). The
|
|
41
|
+
* row is then granted AS the account (`identityId` re-stamped, the
|
|
42
|
+
* `provisionMyAccount` idiom), so every policy row's `created_by.id` is
|
|
43
|
+
* an account id — never an email, never an idp subject. A caller that
|
|
44
|
+
* resolves to no account (an idp-shaped subject before provisioning)
|
|
45
|
+
* writes nothing: the membership rules heal a real person's ownership at
|
|
46
|
+
* first provisioning through the organization's creator stamp
|
|
47
|
+
* (membership.ts, Q-OR-6b), so the guard loses nobody. Non-`user` classes
|
|
48
|
+
* (`internal`, `runner`, a composition's own) write nothing by rule — the
|
|
49
|
+
* roles are people's (Q-OR-6b), and under trusted-local a daemon-origin
|
|
50
|
+
* caller carries the operator's fields and would otherwise resolve.
|
|
51
|
+
*
|
|
52
|
+
* Acyclic by construction: this object sits ON TOP of the grant path and
|
|
53
|
+
* defines neither policy hook. The grant path's own lifecycle is the
|
|
54
|
+
* composed driver (undefined in open source), never this object (S1
|
|
55
|
+
* finding B); the test pins both facts.
|
|
56
|
+
*
|
|
57
|
+
* Faults propagate. The create arm's throw fails the organization create
|
|
58
|
+
* (the step maps it INTERNAL; the row survives, a retry converges through
|
|
59
|
+
* the grant path's duplicate arm); the delete arm's throw is logged and
|
|
60
|
+
* the delete succeeds (the step's best-effort contract).
|
|
61
|
+
*/
|
|
62
|
+
import { create } from "@bufbuild/protobuf";
|
|
63
|
+
|
|
64
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
65
|
+
import { OwnerAttributionType } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/authorization_config_pb";
|
|
66
|
+
import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
67
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
68
|
+
|
|
69
|
+
import type {
|
|
70
|
+
ResourceAuthorizationLifecycle,
|
|
71
|
+
ResourceCreatedEvent,
|
|
72
|
+
ResourceDeletedEvent,
|
|
73
|
+
} from "../../extensions/resource-authorization.js";
|
|
74
|
+
import { kindEnumName } from "../../pipeline/apiresource-meta.js";
|
|
75
|
+
import { accountForCaller } from "../identityaccount/resolve.js";
|
|
76
|
+
import type { AccountsByCaller } from "../identityaccount/resolve.js";
|
|
77
|
+
import type { IamPolicyGrantPath } from "./grant-path.js";
|
|
78
|
+
import { organizationRole } from "./specs.js";
|
|
79
|
+
|
|
80
|
+
export interface BuiltInRoleLifecycleDeps {
|
|
81
|
+
readonly grantPath: IamPolicyGrantPath;
|
|
82
|
+
readonly accounts: AccountsByCaller;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The kinds whose rows die with the resource — the two sides an organization role names. */
|
|
86
|
+
const CLEANUP_KINDS: ReadonlySet<ApiResourceKind> = new Set([
|
|
87
|
+
ApiResourceKind.organization,
|
|
88
|
+
ApiResourceKind.identity_account,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export function newBuiltInRoleLifecycle(
|
|
92
|
+
deps: BuiltInRoleLifecycleDeps,
|
|
93
|
+
): ResourceAuthorizationLifecycle {
|
|
94
|
+
const { grantPath, accounts } = deps;
|
|
95
|
+
return {
|
|
96
|
+
async onResourceCreated(event: ResourceCreatedEvent): Promise<void> {
|
|
97
|
+
if (
|
|
98
|
+
event.kind !== ApiResourceKind.organization ||
|
|
99
|
+
event.ownerAttribution !== OwnerAttributionType.DIRECT ||
|
|
100
|
+
event.caller.callerClass !== "user"
|
|
101
|
+
) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const account = await accountForCaller(accounts, event.caller);
|
|
105
|
+
const accountId = account?.metadata?.id ?? "";
|
|
106
|
+
if (accountId === "") {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await grantPath.grant(
|
|
110
|
+
organizationRole(accountId, IamRole.owner, event.resourceId),
|
|
111
|
+
{ ...event.caller, identityId: accountId },
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async onResourceDeleted(event: ResourceDeletedEvent): Promise<void> {
|
|
116
|
+
if (!CLEANUP_KINDS.has(event.kind)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await grantPath.cleanupResource(
|
|
120
|
+
create(ApiResourceRefSchema, {
|
|
121
|
+
kind: kindEnumName(event.kind),
|
|
122
|
+
id: event.resourceId,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async onVisibilityChanged(): Promise<void> {
|
|
128
|
+
// Visibility is a tuple concern (org-viewer, public-viewer shapes);
|
|
129
|
+
// open source's rows record roles, not visibility.
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|