@stigmer/server 3.15.2-dev.20260913223433 → 3.15.3-dev.20260914100347

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.
Files changed (201) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +103 -9
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/iampolicy/access-lists.d.ts +27 -0
  5. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
  6. package/dist/domain/iampolicy/access-lists.js +124 -0
  7. package/dist/domain/iampolicy/access-lists.js.map +1 -0
  8. package/dist/domain/iampolicy/constants.d.ts +135 -0
  9. package/dist/domain/iampolicy/constants.d.ts.map +1 -0
  10. package/dist/domain/iampolicy/constants.js +203 -0
  11. package/dist/domain/iampolicy/constants.js.map +1 -0
  12. package/dist/domain/iampolicy/controller.d.ts +29 -0
  13. package/dist/domain/iampolicy/controller.d.ts.map +1 -0
  14. package/dist/domain/iampolicy/controller.js +451 -0
  15. package/dist/domain/iampolicy/controller.js.map +1 -0
  16. package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
  17. package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
  18. package/dist/domain/iampolicy/display-resolver.js +90 -0
  19. package/dist/domain/iampolicy/display-resolver.js.map +1 -0
  20. package/dist/domain/iampolicy/grant-path.d.ts +42 -0
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
  22. package/dist/domain/iampolicy/grant-path.js +183 -0
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -0
  24. package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
  25. package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
  26. package/dist/domain/iampolicy/grant-scope.js +34 -0
  27. package/dist/domain/iampolicy/grant-scope.js.map +1 -0
  28. package/dist/domain/iampolicy/membership.d.ts +111 -0
  29. package/dist/domain/iampolicy/membership.d.ts.map +1 -0
  30. package/dist/domain/iampolicy/membership.js +152 -0
  31. package/dist/domain/iampolicy/membership.js.map +1 -0
  32. package/dist/domain/iampolicy/permissions.d.ts +19 -0
  33. package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
  34. package/dist/domain/iampolicy/permissions.js +27 -0
  35. package/dist/domain/iampolicy/permissions.js.map +1 -0
  36. package/dist/domain/iampolicy/resource-store.d.ts +4 -0
  37. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
  38. package/dist/domain/iampolicy/resource-store.js +135 -0
  39. package/dist/domain/iampolicy/resource-store.js.map +1 -0
  40. package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
  41. package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
  42. package/dist/domain/iampolicy/role-lifecycle.js +106 -0
  43. package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
  44. package/dist/domain/iampolicy/roles.d.ts +11 -0
  45. package/dist/domain/iampolicy/roles.d.ts.map +1 -0
  46. package/dist/domain/iampolicy/roles.js +78 -0
  47. package/dist/domain/iampolicy/roles.js.map +1 -0
  48. package/dist/domain/iampolicy/specs.d.ts +7 -0
  49. package/dist/domain/iampolicy/specs.d.ts.map +1 -0
  50. package/dist/domain/iampolicy/specs.js +45 -0
  51. package/dist/domain/iampolicy/specs.js.map +1 -0
  52. package/dist/domain/iampolicy/steps.d.ts +18 -0
  53. package/dist/domain/iampolicy/steps.d.ts.map +1 -0
  54. package/dist/domain/iampolicy/steps.js +117 -0
  55. package/dist/domain/iampolicy/steps.js.map +1 -0
  56. package/dist/domain/iampolicy/store-contract.d.ts +8 -0
  57. package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
  58. package/dist/domain/iampolicy/store-contract.js +258 -0
  59. package/dist/domain/iampolicy/store-contract.js.map +1 -0
  60. package/dist/domain/iampolicy/store.d.ts +78 -0
  61. package/dist/domain/iampolicy/store.d.ts.map +1 -0
  62. package/dist/domain/iampolicy/store.js +13 -0
  63. package/dist/domain/iampolicy/store.js.map +1 -0
  64. package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
  65. package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
  66. package/dist/domain/iampolicy/wire-refusals.js +76 -0
  67. package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
  68. package/dist/domain/identityaccount/constants.d.ts +33 -0
  69. package/dist/domain/identityaccount/constants.d.ts.map +1 -1
  70. package/dist/domain/identityaccount/constants.js +2 -48
  71. package/dist/domain/identityaccount/constants.js.map +1 -1
  72. package/dist/domain/identityaccount/controller.d.ts +8 -1
  73. package/dist/domain/identityaccount/controller.d.ts.map +1 -1
  74. package/dist/domain/identityaccount/controller.js +54 -29
  75. package/dist/domain/identityaccount/controller.js.map +1 -1
  76. package/dist/domain/identityaccount/operator.d.ts.map +1 -1
  77. package/dist/domain/identityaccount/operator.js +2 -4
  78. package/dist/domain/identityaccount/operator.js.map +1 -1
  79. package/dist/domain/identityaccount/provisioning.d.ts +20 -1
  80. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
  81. package/dist/domain/identityaccount/provisioning.js +17 -3
  82. package/dist/domain/identityaccount/provisioning.js.map +1 -1
  83. package/dist/domain/identityaccount/resolve.d.ts +23 -0
  84. package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
  85. package/dist/domain/identityaccount/resolve.js +17 -0
  86. package/dist/domain/identityaccount/resolve.js.map +1 -1
  87. package/dist/domain/identityaccount/store-contract.d.ts +4 -18
  88. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
  89. package/dist/domain/identityaccount/store-contract.js +12 -47
  90. package/dist/domain/identityaccount/store-contract.js.map +1 -1
  91. package/dist/extensions/authorization-queries.d.ts +81 -0
  92. package/dist/extensions/authorization-queries.d.ts.map +1 -0
  93. package/dist/extensions/authorization-queries.js +2 -0
  94. package/dist/extensions/authorization-queries.js.map +1 -0
  95. package/dist/extensions/drivers.d.ts +49 -2
  96. package/dist/extensions/drivers.d.ts.map +1 -1
  97. package/dist/extensions/identity.d.ts +14 -0
  98. package/dist/extensions/identity.d.ts.map +1 -1
  99. package/dist/extensions/identity.js +18 -1
  100. package/dist/extensions/identity.js.map +1 -1
  101. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  102. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  103. package/dist/extensions/policy-grant-scope.js +2 -0
  104. package/dist/extensions/policy-grant-scope.js.map +1 -0
  105. package/dist/extensions/registry.d.ts +23 -1
  106. package/dist/extensions/registry.d.ts.map +1 -1
  107. package/dist/extensions/registry.js +30 -0
  108. package/dist/extensions/registry.js.map +1 -1
  109. package/dist/extensions/resource-authorization.d.ts +63 -0
  110. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  111. package/dist/index.d.ts +7 -1
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -0
  114. package/dist/index.js.map +1 -1
  115. package/dist/pipeline/apiresource-meta.d.ts +56 -1
  116. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  117. package/dist/pipeline/apiresource-meta.js +155 -1
  118. package/dist/pipeline/apiresource-meta.js.map +1 -1
  119. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  120. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  121. package/dist/pipeline/interceptors/auth.js +13 -1
  122. package/dist/pipeline/interceptors/auth.js.map +1 -1
  123. package/dist/pipeline/steps/authorize.d.ts +20 -5
  124. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  125. package/dist/pipeline/steps/authorize.js +37 -17
  126. package/dist/pipeline/steps/authorize.js.map +1 -1
  127. package/dist/pipeline/steps/defaults.d.ts +10 -0
  128. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  129. package/dist/pipeline/steps/defaults.js +39 -0
  130. package/dist/pipeline/steps/defaults.js.map +1 -1
  131. package/dist/store/port-contract.d.ts +61 -0
  132. package/dist/store/port-contract.d.ts.map +1 -0
  133. package/dist/store/port-contract.js +67 -0
  134. package/dist/store/port-contract.js.map +1 -0
  135. package/package.json +4 -4
  136. package/src/boot/compose.ts +107 -10
  137. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  138. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  139. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  140. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  141. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  142. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  143. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  144. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  145. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  146. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  147. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  148. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  149. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  150. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  151. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  152. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  153. package/src/domain/iampolicy/access-lists.ts +191 -0
  154. package/src/domain/iampolicy/constants.ts +243 -0
  155. package/src/domain/iampolicy/controller.ts +798 -0
  156. package/src/domain/iampolicy/display-resolver.ts +123 -0
  157. package/src/domain/iampolicy/grant-path.ts +270 -0
  158. package/src/domain/iampolicy/grant-scope.ts +36 -0
  159. package/src/domain/iampolicy/membership.ts +298 -0
  160. package/src/domain/iampolicy/permissions.ts +35 -0
  161. package/src/domain/iampolicy/resource-store.ts +186 -0
  162. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  163. package/src/domain/iampolicy/roles.ts +95 -0
  164. package/src/domain/iampolicy/specs.ts +53 -0
  165. package/src/domain/iampolicy/steps.ts +178 -0
  166. package/src/domain/iampolicy/store-contract.ts +450 -0
  167. package/src/domain/iampolicy/store.ts +103 -0
  168. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  169. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  170. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  171. package/src/domain/identityaccount/constants.ts +16 -31
  172. package/src/domain/identityaccount/controller.ts +65 -35
  173. package/src/domain/identityaccount/operator.ts +5 -5
  174. package/src/domain/identityaccount/provisioning.ts +43 -5
  175. package/src/domain/identityaccount/resolve.ts +43 -0
  176. package/src/domain/identityaccount/store-contract.ts +22 -70
  177. package/src/extensions/__tests__/composed-support.ts +87 -0
  178. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  179. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  180. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  181. package/src/extensions/__tests__/identity.test.ts +62 -0
  182. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  183. package/src/extensions/authorization-queries.ts +97 -0
  184. package/src/extensions/drivers.ts +49 -2
  185. package/src/extensions/identity.ts +21 -0
  186. package/src/extensions/policy-grant-scope.ts +50 -0
  187. package/src/extensions/registry.ts +62 -1
  188. package/src/extensions/resource-authorization.ts +65 -0
  189. package/src/index.ts +20 -0
  190. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  191. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  192. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  193. package/src/pipeline/apiresource-meta.ts +181 -1
  194. package/src/pipeline/interceptors/auth.ts +14 -1
  195. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  196. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  197. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  198. package/src/pipeline/steps/authorize.ts +46 -21
  199. package/src/pipeline/steps/defaults.ts +42 -0
  200. package/src/store/__tests__/port-contract.test.ts +123 -0
  201. 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
+ }