@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.
Files changed (217) 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/list-read-scope.d.ts +41 -4
  102. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  103. package/dist/extensions/list-read-scope.js +44 -7
  104. package/dist/extensions/list-read-scope.js.map +1 -1
  105. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  106. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  107. package/dist/extensions/policy-grant-scope.js +2 -0
  108. package/dist/extensions/policy-grant-scope.js.map +1 -0
  109. package/dist/extensions/registry.d.ts +23 -1
  110. package/dist/extensions/registry.d.ts.map +1 -1
  111. package/dist/extensions/registry.js +30 -0
  112. package/dist/extensions/registry.js.map +1 -1
  113. package/dist/extensions/resource-authorization.d.ts +63 -0
  114. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  115. package/dist/index.d.ts +7 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +2 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/pipeline/apiresource-meta.d.ts +77 -1
  120. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  121. package/dist/pipeline/apiresource-meta.js +183 -1
  122. package/dist/pipeline/apiresource-meta.js.map +1 -1
  123. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  124. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  125. package/dist/pipeline/interceptors/auth.js +13 -1
  126. package/dist/pipeline/interceptors/auth.js.map +1 -1
  127. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  128. package/dist/pipeline/steps/authorization-tuples.js +7 -26
  129. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  130. package/dist/pipeline/steps/authorize.d.ts +20 -5
  131. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  132. package/dist/pipeline/steps/authorize.js +37 -17
  133. package/dist/pipeline/steps/authorize.js.map +1 -1
  134. package/dist/pipeline/steps/defaults.d.ts +10 -0
  135. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  136. package/dist/pipeline/steps/defaults.js +39 -0
  137. package/dist/pipeline/steps/defaults.js.map +1 -1
  138. package/dist/pipeline/steps/shapes.d.ts +18 -0
  139. package/dist/pipeline/steps/shapes.d.ts.map +1 -1
  140. package/dist/pipeline/steps/shapes.js +29 -0
  141. package/dist/pipeline/steps/shapes.js.map +1 -1
  142. package/dist/store/port-contract.d.ts +61 -0
  143. package/dist/store/port-contract.d.ts.map +1 -0
  144. package/dist/store/port-contract.js +67 -0
  145. package/dist/store/port-contract.js.map +1 -0
  146. package/package.json +4 -4
  147. package/src/boot/compose.ts +107 -10
  148. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  149. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  150. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  151. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  152. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  153. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  154. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  155. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  156. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  157. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  158. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  159. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  160. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  161. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  162. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  163. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  164. package/src/domain/iampolicy/access-lists.ts +191 -0
  165. package/src/domain/iampolicy/constants.ts +243 -0
  166. package/src/domain/iampolicy/controller.ts +798 -0
  167. package/src/domain/iampolicy/display-resolver.ts +123 -0
  168. package/src/domain/iampolicy/grant-path.ts +270 -0
  169. package/src/domain/iampolicy/grant-scope.ts +36 -0
  170. package/src/domain/iampolicy/membership.ts +298 -0
  171. package/src/domain/iampolicy/permissions.ts +35 -0
  172. package/src/domain/iampolicy/resource-store.ts +186 -0
  173. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  174. package/src/domain/iampolicy/roles.ts +95 -0
  175. package/src/domain/iampolicy/specs.ts +53 -0
  176. package/src/domain/iampolicy/steps.ts +178 -0
  177. package/src/domain/iampolicy/store-contract.ts +450 -0
  178. package/src/domain/iampolicy/store.ts +103 -0
  179. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  180. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  181. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  182. package/src/domain/identityaccount/constants.ts +16 -31
  183. package/src/domain/identityaccount/controller.ts +65 -35
  184. package/src/domain/identityaccount/operator.ts +5 -5
  185. package/src/domain/identityaccount/provisioning.ts +43 -5
  186. package/src/domain/identityaccount/resolve.ts +43 -0
  187. package/src/domain/identityaccount/store-contract.ts +22 -70
  188. package/src/extensions/__tests__/composed-support.ts +87 -0
  189. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  190. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  191. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  192. package/src/extensions/__tests__/identity.test.ts +62 -0
  193. package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
  194. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  195. package/src/extensions/authorization-queries.ts +97 -0
  196. package/src/extensions/drivers.ts +49 -2
  197. package/src/extensions/identity.ts +21 -0
  198. package/src/extensions/list-read-scope.ts +87 -9
  199. package/src/extensions/policy-grant-scope.ts +50 -0
  200. package/src/extensions/registry.ts +62 -1
  201. package/src/extensions/resource-authorization.ts +65 -0
  202. package/src/index.ts +20 -0
  203. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  204. package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
  205. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  206. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  207. package/src/pipeline/apiresource-meta.ts +218 -1
  208. package/src/pipeline/interceptors/auth.ts +14 -1
  209. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  210. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  211. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  212. package/src/pipeline/steps/authorization-tuples.ts +7 -28
  213. package/src/pipeline/steps/authorize.ts +46 -21
  214. package/src/pipeline/steps/defaults.ts +42 -0
  215. package/src/pipeline/steps/shapes.ts +31 -0
  216. package/src/store/__tests__/port-contract.test.ts +123 -0
  217. package/src/store/port-contract.ts +102 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Pins the open-source membership rules (T01_0_plan.md §3a
3
+ * "membership.ts"; T01_1_review.md Q-OR-6b and Q-OR-6c) over a seeded
4
+ * generic store, a fake account port and the real grant path:
5
+ *
6
+ * onAccountCreated(account, caller), per organization the account holds
7
+ * no row on:
8
+ * - `owner` when the account's subject (its idp_id) or its id is the
9
+ * organization's own creator;
10
+ * - `admin` when it created any BLUEPRINT in that organization (and only
11
+ * a blueprint: a session's creator stays a member — DD-002 rule 2
12
+ * keeps personal things by created_by with no role);
13
+ * - `admin` when the organization has ZERO role rows and no PERSON other
14
+ * than this account created the organization or any blueprint in it
15
+ * (slice 4 ruling Q-S4-7: the founder's stamp counts) — the
16
+ * fresh-install and the trusted-local-turned-OIDC first caller. "Zero
17
+ * rows", never "no admin now": revoking every admin of a bootstrapped
18
+ * organization must not hand it to the next stranger;
19
+ * - `admin` when the account's email is STIGMER_OPERATOR_EMAIL (and that
20
+ * email is configured — an empty one matches nobody);
21
+ * - `member` otherwise;
22
+ * - nothing for a non-`user` caller class; nothing on an organization
23
+ * the account already holds a row on (idempotent by construction), so
24
+ * a run that faulted midway converges on the next.
25
+ *
26
+ * ensureOperatorOwnership(operator): `owner` on every organization that
27
+ * has NO owner row (Q-S4-3: a boot-time write never overrides a recorded
28
+ * human grant); a second boot writes nothing; the row's audit actor is
29
+ * the operator's account — id, email and display name — like every
30
+ * other trusted-local write.
31
+ *
32
+ * The creator stamps the rules read are `status.audit.spec_audit.created_by
33
+ * .id`, which for a legacy self-host is the raw issuer subject and for a
34
+ * provisioned caller is the account id (P1 gate Q2c; 2a handoff 2), so
35
+ * both spellings are seeded and both must match. A stamp is a PERSON when
36
+ * it is non-empty and not the unconfigured laptop's "system" placeholder;
37
+ * the rules classify stamps by shape and consult no account store.
38
+ *
39
+ * The rules read policy rows through the IamPolicyStore PORT (`policies`,
40
+ * the same instance the grant path writes through), never around it.
41
+ */
42
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
43
+
44
+ import { create } from "@bufbuild/protobuf";
45
+ import { TimestampSchema } from "@bufbuild/protobuf/wkt";
46
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
47
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
48
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
49
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
50
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
51
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
52
+ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
53
+
54
+ import type { CallerIdentity } from "../../../extensions/identity.js";
55
+ import { tempStore } from "../../../store/sqlite/__tests__/support.js";
56
+ import type { Store } from "../../../store/interface.js";
57
+ import { accountIdFor } from "../../identityaccount/constants.js";
58
+ import { BLUEPRINT_KINDS } from "../constants.js";
59
+ import { newIamPolicyGrantPath } from "../grant-path.js";
60
+ import type { IamPolicyGrantPath } from "../grant-path.js";
61
+ import { CREATOR_SCAN_SCHEMAS, newMembershipRules } from "../membership.js";
62
+ import type { MembershipRules } from "../membership.js";
63
+ import type { IamPolicyStore } from "../store.js";
64
+ import { fakeIamPolicyStore } from "./support.js";
65
+ import type { FakeIamPolicyStore } from "./support.js";
66
+
67
+ const OPERATOR_EMAIL = "operator@example.com";
68
+ const silentLogger = { debug() {}, info() {}, warn() {}, error() {} };
69
+
70
+ function userCaller(identityId: string, email?: string): CallerIdentity {
71
+ return {
72
+ identityId,
73
+ callerClass: "user",
74
+ issuer: "https://issuer.example.com",
75
+ rawToken: "",
76
+ email,
77
+ };
78
+ }
79
+
80
+ function account(sub: string, email: string): IdentityAccount {
81
+ return create(IdentityAccountSchema, {
82
+ apiVersion: "iam.stigmer.ai/v1",
83
+ kind: "IdentityAccount",
84
+ metadata: { id: accountIdFor(sub), name: email },
85
+ spec: {
86
+ idpId: sub,
87
+ email,
88
+ provisioningMode: IdentityAccountProvisioningMode.direct,
89
+ },
90
+ });
91
+ }
92
+
93
+ describe("membership rules", () => {
94
+ let temp: ReturnType<typeof tempStore>;
95
+ let store: Store;
96
+ let policies: FakeIamPolicyStore;
97
+ let grantPath: IamPolicyGrantPath;
98
+ let rules: MembershipRules;
99
+
100
+ /** Rules over the shared store and a policy port of the test's choosing (the grant path is built over the same port). */
101
+ function rulesOver(
102
+ port: IamPolicyStore,
103
+ operatorEmail: string = OPERATOR_EMAIL,
104
+ ): MembershipRules {
105
+ return newMembershipRules({
106
+ grantPath: newIamPolicyGrantPath({
107
+ policies: port,
108
+ lifecycle: undefined,
109
+ logger: silentLogger,
110
+ }),
111
+ policies: port,
112
+ store,
113
+ operatorEmail,
114
+ });
115
+ }
116
+
117
+ beforeEach(() => {
118
+ temp = tempStore();
119
+ store = temp.store;
120
+ policies = fakeIamPolicyStore();
121
+ grantPath = newIamPolicyGrantPath({
122
+ policies,
123
+ lifecycle: undefined,
124
+ logger: silentLogger,
125
+ });
126
+ rules = newMembershipRules({
127
+ grantPath,
128
+ policies,
129
+ store,
130
+ operatorEmail: OPERATOR_EMAIL,
131
+ });
132
+ });
133
+
134
+ afterEach(async () => {
135
+ await temp.cleanup();
136
+ });
137
+
138
+ /** Seeds an organization row whose creator stamp is `createdBy` (a subject or an account id). */
139
+ async function seedOrg(slug: string, createdBy: string): Promise<void> {
140
+ const org = create(OrganizationSchema, {
141
+ apiVersion: "tenancy.stigmer.ai/v1",
142
+ kind: "Organization",
143
+ metadata: { id: slug, slug, name: slug },
144
+ spec: { description: slug },
145
+ status: {
146
+ audit: {
147
+ specAudit: {
148
+ createdBy: { id: createdBy },
149
+ createdAt: create(TimestampSchema),
150
+ },
151
+ },
152
+ },
153
+ });
154
+ await store.saveResource(
155
+ ApiResourceKind.organization,
156
+ slug,
157
+ OrganizationSchema,
158
+ org,
159
+ );
160
+ }
161
+
162
+ async function seedAgent(
163
+ id: string,
164
+ org: string,
165
+ createdBy: string,
166
+ ): Promise<void> {
167
+ const agent = create(AgentSchema, {
168
+ apiVersion: "agentic.stigmer.ai/v1",
169
+ kind: "Agent",
170
+ metadata: { id, org, name: id, slug: id },
171
+ status: {
172
+ audit: {
173
+ specAudit: {
174
+ createdBy: { id: createdBy },
175
+ createdAt: create(TimestampSchema),
176
+ },
177
+ },
178
+ },
179
+ });
180
+ await store.saveResource(ApiResourceKind.agent, id, AgentSchema, agent);
181
+ }
182
+
183
+ async function seedSession(
184
+ id: string,
185
+ org: string,
186
+ createdBy: string,
187
+ ): Promise<void> {
188
+ const session = create(SessionSchema, {
189
+ apiVersion: "agentic.stigmer.ai/v1",
190
+ kind: "Session",
191
+ metadata: { id, org, name: id },
192
+ status: {
193
+ audit: {
194
+ specAudit: {
195
+ createdBy: { id: createdBy },
196
+ createdAt: create(TimestampSchema),
197
+ },
198
+ },
199
+ },
200
+ });
201
+ await store.saveResource(
202
+ ApiResourceKind.session,
203
+ id,
204
+ SessionSchema,
205
+ session,
206
+ );
207
+ }
208
+
209
+ function rolesOf(accountId: string): ReadonlyArray<string> {
210
+ return [...policies.rows.values()]
211
+ .filter((p) => p.spec?.principal?.id === accountId)
212
+ .map((p) => `${p.spec?.relation}@${p.spec?.resource?.id}`)
213
+ .sort();
214
+ }
215
+
216
+ describe("onAccountCreated", () => {
217
+ it("the organization's creator, stamped by raw subject before provisioning, becomes its owner", async () => {
218
+ await seedOrg("acme", "auth0|alice");
219
+ const alice = account("auth0|alice", "alice@example.com");
220
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
221
+ expect(rolesOf(alice.metadata!.id)).toEqual(["owner@acme"]);
222
+ });
223
+
224
+ it("the organization's creator, stamped by account id, becomes its owner too", async () => {
225
+ const alice = account("auth0|alice", "alice@example.com");
226
+ await seedOrg("acme", alice.metadata!.id);
227
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
228
+ expect(rolesOf(alice.metadata!.id)).toEqual(["owner@acme"]);
229
+ });
230
+
231
+ it("a blueprint's creator in someone else's organization becomes admin there; a session's creator does not", async () => {
232
+ await seedOrg("acme", "auth0|alice");
233
+ await seedAgent("agt_bob", "acme", "auth0|bob");
234
+ await seedOrg("globex", "auth0|alice");
235
+ await seedSession("ses_bob", "globex", "auth0|bob");
236
+ const alice = account("auth0|alice", "alice@example.com");
237
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
238
+ const bob = account("auth0|bob", "bob@example.com");
239
+
240
+ await rules.onAccountCreated(bob, userCaller(bob.metadata!.id));
241
+
242
+ expect(rolesOf(bob.metadata!.id)).toEqual([
243
+ "admin@acme",
244
+ "member@globex",
245
+ ]);
246
+ });
247
+
248
+ it("an organization with zero role rows and no other user-class blueprint creator makes the first caller admin (the trusted-local-turned-OIDC install)", async () => {
249
+ await seedOrg("acme", "system");
250
+ await seedAgent("agt_1", "acme", "system");
251
+ const carol = account("auth0|carol", "carol@example.com");
252
+
253
+ await rules.onAccountCreated(carol, userCaller(carol.metadata!.id));
254
+
255
+ expect(rolesOf(carol.metadata!.id)).toEqual(["admin@acme"]);
256
+ });
257
+
258
+ it("an organization whose rows were all revoked is NOT handed to the next stranger — zero rows, never no-admin-now", async () => {
259
+ await seedOrg("acme", "auth0|alice");
260
+ const alice = account("auth0|alice", "alice@example.com");
261
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
262
+ // The organization was bootstrapped; its owner is then revoked.
263
+ await grantPath.revokeOrgAccess(alice.metadata!.id, "acme");
264
+ expect(policies.rows.size).toBe(0);
265
+ // A second creator stamp keeps the "no other person created anything here" arm from firing on its own.
266
+ await seedAgent("agt_alice", "acme", alice.metadata!.id);
267
+ const mallory = account("auth0|mallory", "mallory@example.com");
268
+
269
+ await rules.onAccountCreated(mallory, userCaller(mallory.metadata!.id));
270
+
271
+ expect(rolesOf(mallory.metadata!.id)).toEqual(["member@acme"]);
272
+ });
273
+
274
+ it("the founder's own stamp counts as another person — a revoked founder's empty organization is NOT handed to the next stranger (Q-S4-7)", async () => {
275
+ await seedOrg("acme", "auth0|alice");
276
+ const alice = account("auth0|alice", "alice@example.com");
277
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
278
+ await grantPath.revokeOrgAccess(alice.metadata!.id, "acme");
279
+ expect(policies.rows.size).toBe(0);
280
+ // No blueprint at all: only the organization's own creator stamp stands between mallory and admin.
281
+ const mallory = account("auth0|mallory", "mallory@example.com");
282
+
283
+ await rules.onAccountCreated(mallory, userCaller(mallory.metadata!.id));
284
+
285
+ expect(rolesOf(mallory.metadata!.id)).toEqual(["member@acme"]);
286
+ });
287
+
288
+ it("an unconfigured operator email matches nobody — an account with an empty email is a member, never admin", async () => {
289
+ await seedOrg("acme", "auth0|alice");
290
+ const alice = account("auth0|alice", "alice@example.com");
291
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
292
+ const unconfigured = rulesOver(policies, "");
293
+ const nobody = account("auth0|nobody", "");
294
+
295
+ await unconfigured.onAccountCreated(
296
+ nobody,
297
+ userCaller(nobody.metadata!.id, ""),
298
+ );
299
+
300
+ expect(rolesOf(nobody.metadata!.id)).toEqual(["member@acme"]);
301
+ });
302
+
303
+ it("a store fault mid-scan propagates, and the next run converges with no duplicate row", async () => {
304
+ await seedOrg("acme", "auth0|alice");
305
+ await seedOrg("globex", "auth0|alice");
306
+ let faultsLeft = 1;
307
+ const healthy = fakeIamPolicyStore();
308
+ const flaky: IamPolicyStore = {
309
+ ...healthy,
310
+ async save(policy) {
311
+ // The first organization's row lands; the second save fails once.
312
+ if (healthy.rows.size === 1 && faultsLeft > 0) {
313
+ faultsLeft -= 1;
314
+ throw new Error("disk full");
315
+ }
316
+ await healthy.save(policy);
317
+ },
318
+ };
319
+ const flakyRules = rulesOver(flaky);
320
+ const dave = account("auth0|dave", "dave@example.com");
321
+
322
+ await expect(
323
+ flakyRules.onAccountCreated(dave, userCaller(dave.metadata!.id)),
324
+ ).rejects.toThrow("disk full");
325
+ expect(healthy.rows.size).toBe(1);
326
+
327
+ await flakyRules.onAccountCreated(dave, userCaller(dave.metadata!.id));
328
+
329
+ expect(
330
+ [...healthy.rows.values()]
331
+ .map((p) => `${p.spec?.relation}@${p.spec?.resource?.id}`)
332
+ .sort(),
333
+ ).toEqual(["member@acme", "member@globex"]);
334
+ });
335
+
336
+ it("the operator email becomes admin of every organization it does not own", async () => {
337
+ await seedOrg("acme", "auth0|alice");
338
+ await seedOrg("globex", "auth0|alice");
339
+ const alice = account("auth0|alice", "alice@example.com");
340
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
341
+ const operator = account("auth0|operator", OPERATOR_EMAIL);
342
+
343
+ await rules.onAccountCreated(
344
+ operator,
345
+ userCaller(operator.metadata!.id, OPERATOR_EMAIL),
346
+ );
347
+
348
+ expect(rolesOf(operator.metadata!.id)).toEqual([
349
+ "admin@acme",
350
+ "admin@globex",
351
+ ]);
352
+ });
353
+
354
+ it("everyone else is a member of every existing organization", async () => {
355
+ await seedOrg("acme", "auth0|alice");
356
+ await seedOrg("globex", "auth0|alice");
357
+ const alice = account("auth0|alice", "alice@example.com");
358
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
359
+ const dave = account("auth0|dave", "dave@example.com");
360
+
361
+ await rules.onAccountCreated(dave, userCaller(dave.metadata!.id));
362
+
363
+ expect(rolesOf(dave.metadata!.id)).toEqual([
364
+ "member@acme",
365
+ "member@globex",
366
+ ]);
367
+ });
368
+
369
+ it("with no organization yet, writes nothing — the creator rule runs when the organization is created", async () => {
370
+ const alice = account("auth0|alice", "alice@example.com");
371
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
372
+ expect(policies.rows.size).toBe(0);
373
+ });
374
+
375
+ it("never grants a runner, machine or internal caller a role", async () => {
376
+ await seedOrg("acme", "system");
377
+ const bot = account("@clients|bot", "");
378
+ for (const callerClass of ["runner", "machine", "internal"] as const) {
379
+ await rules.onAccountCreated(bot, {
380
+ ...userCaller(bot.metadata!.id),
381
+ callerClass,
382
+ });
383
+ }
384
+ expect(policies.rows.size).toBe(0);
385
+ });
386
+
387
+ it("skips an organization the account already holds a row on — a second run writes nothing new", async () => {
388
+ await seedOrg("acme", "auth0|alice");
389
+ const alice = account("auth0|alice", "alice@example.com");
390
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
391
+ const before = [...policies.rows.keys()];
392
+
393
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
394
+
395
+ expect([...policies.rows.keys()]).toEqual(before);
396
+ });
397
+ });
398
+
399
+ describe("ensureOperatorOwnership", () => {
400
+ it("makes the operator owner of every organization, and a second boot writes nothing", async () => {
401
+ await seedOrg("acme", "system");
402
+ await seedOrg("globex", OPERATOR_EMAIL);
403
+ const operator = account(`local|${OPERATOR_EMAIL}`, OPERATOR_EMAIL);
404
+ const operatorId = operator.metadata!.id;
405
+
406
+ await rules.ensureOperatorOwnership(operator);
407
+ const after = [...policies.rows.keys()].sort();
408
+ await rules.ensureOperatorOwnership(operator);
409
+
410
+ expect(rolesOf(operatorId)).toEqual(["owner@acme", "owner@globex"]);
411
+ expect([...policies.rows.keys()].sort()).toEqual(after);
412
+ });
413
+
414
+ it("leaves an organization that already has an owner alone", async () => {
415
+ await seedOrg("acme", "auth0|alice");
416
+ const alice = account("auth0|alice", "alice@example.com");
417
+ await rules.onAccountCreated(alice, userCaller(alice.metadata!.id));
418
+ const operator = account("local|system", "");
419
+
420
+ await rules.ensureOperatorOwnership(operator);
421
+
422
+ expect(rolesOf(operator.metadata!.id)).toEqual([]);
423
+ });
424
+
425
+ it("stamps the owner row's audit actor as the operator's account — id, email and display name, like every other trusted-local write", async () => {
426
+ await seedOrg("acme", "system");
427
+ const operator = account(`local|${OPERATOR_EMAIL}`, OPERATOR_EMAIL);
428
+ operator.metadata!.name = "The Operator";
429
+
430
+ await rules.ensureOperatorOwnership(operator);
431
+
432
+ const [row] = [...policies.rows.values()];
433
+ const actor = row?.status?.audit?.specAudit?.createdBy;
434
+ expect(actor?.id).toBe(operator.metadata!.id);
435
+ expect(actor?.email).toBe(OPERATOR_EMAIL);
436
+ expect(actor?.displayName).toBe("The Operator");
437
+ });
438
+ });
439
+
440
+ describe("the creator scan", () => {
441
+ it("decodes exactly the organization and BLUEPRINT_KINDS — a kind added to the constant without a schema fails here", () => {
442
+ expect([...CREATOR_SCAN_SCHEMAS.keys()].sort()).toEqual(
443
+ [ApiResourceKind.organization, ...BLUEPRINT_KINDS].sort(),
444
+ );
445
+ });
446
+ });
447
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pins permissions.ts (20260913.01 slice 5, Q-S5-3): the relation string a
3
+ * checkMyPermission caller sends is admitted only when it is an
4
+ * IamPermission member name — the Authorizer's AuthzCheck carries the
5
+ * enum, and the wire carries its name. The zero value is not a
6
+ * permission; a Map (never the TS enum's reverse index) keeps prototype
7
+ * keys and numeric spellings from resolving to anything (the
8
+ * kindsByEnumName precedent in pipeline/apiresource-meta.ts).
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import {
13
+ IamPermission,
14
+ IamPermissionSchema,
15
+ } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
16
+
17
+ import { permissionByEnumName } from "../permissions.js";
18
+
19
+ describe("permissionByEnumName", () => {
20
+ it("resolves every member but the zero value by its exact proto name", () => {
21
+ for (const value of IamPermissionSchema.values) {
22
+ if (value.number === IamPermission.unspecified) continue;
23
+ expect(permissionByEnumName(value.name), value.name).toBe(value.number);
24
+ }
25
+ });
26
+
27
+ for (const garbage of [
28
+ "unspecified",
29
+ "",
30
+ "CAN_VIEW",
31
+ "can-view",
32
+ "constructor",
33
+ "__proto__",
34
+ "4",
35
+ "admin",
36
+ ]) {
37
+ it(`answers undefined for ${JSON.stringify(garbage)}`, () => {
38
+ expect(permissionByEnumName(garbage)).toBeUndefined();
39
+ });
40
+ }
41
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Runs the IamPolicyStore port-contract kit (../store-contract.ts) over the
3
+ * OSS adapter (../resource-store.ts) on both drivers through the drivers'
4
+ * own fixtures (sqlite always; Postgres under TEST_DATABASE_URL), and pins
5
+ * the one behaviour that is the OSS adapter's rather than the port's
6
+ * (T01_1_review.md Q-OR-9): save refuses a policy whose id is not the
7
+ * triple's derived id, the invariant that makes "one row per triple" the
8
+ * primary key's job in open source.
9
+ *
10
+ * The kit's case list is pinned by name so a case cannot drop out unnoticed:
11
+ * the cloud driver's test iterates the same export over `cloud.iam_policy`
12
+ * and would silently prove less (the 2a A11 discipline). The corners A12
13
+ * taught are cases from the start: every relation on a pair; distinct
14
+ * principals, not rows; the scope-tuple exclusions verbatim; unknown-id
15
+ * delete a no-op; a held triple refused.
16
+ */
17
+ import { create } from "@bufbuild/protobuf";
18
+ import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
19
+
20
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
21
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
22
+ import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
23
+
24
+ import type { Store } from "../../../store/interface.js";
25
+ import { PostgresStore } from "../../../store/postgres/store.js";
26
+ import {
27
+ createTestDatabase,
28
+ testDatabaseAdminUrl,
29
+ type TestDatabase,
30
+ } from "../../../store/postgres/__tests__/support.js";
31
+ import { tempStore } from "../../../store/sqlite/__tests__/support.js";
32
+ import {
33
+ IAM_POLICY_API_VERSION,
34
+ IAM_POLICY_KIND,
35
+ policyIdFor,
36
+ } from "../constants.js";
37
+ import { newResourceIamPolicyStore } from "../resource-store.js";
38
+ import type { IamPolicyStoreContractFixture } from "../store-contract.js";
39
+ import { iamPolicyStoreContract } from "../store-contract.js";
40
+ import { orgRole } from "./support.js";
41
+
42
+ /** The kit's contract lines, pinned: a dropped or renamed case is a visible diff here. */
43
+ const CONTRACT_CASE_NAMES = [
44
+ "save then findById round-trips the policy",
45
+ "a second save under a held triple is DuplicatePolicyError, and the first row stands",
46
+ "findByPrincipalAndResource answers every relation on the pair and nothing on another",
47
+ "findByPrincipal and findByResource answer each side of a row",
48
+ "findByResourceWithRelations filters to the allowlist; an empty allowlist matches nothing",
49
+ "countDistinctPrincipalsByResource counts (kind, id) pairs, not rows, optionally by principal kind",
50
+ "findScopeTuple skips identity_account and team principals and owner and creator relations",
51
+ "deleteById removes the row; the triple is free to be granted again",
52
+ "deleteById of an unknown id resolves",
53
+ "a disconnected store is an infrastructure fault, never 'not found'",
54
+ "two concurrent saves of one triple end in one row and DuplicatePolicyErrors only",
55
+ ] as const;
56
+
57
+ interface OpenedStore {
58
+ readonly store: Store;
59
+ close(): Promise<void>;
60
+ }
61
+
62
+ interface DriverFixture {
63
+ readonly name: string;
64
+ readonly skip: boolean;
65
+ open(): Promise<OpenedStore>;
66
+ }
67
+
68
+ const sqliteFixture: DriverFixture = {
69
+ name: "sqlite",
70
+ skip: false,
71
+ async open() {
72
+ const temp = tempStore();
73
+ return { store: temp.store, close: () => temp.cleanup() };
74
+ },
75
+ };
76
+
77
+ let postgresDatabase: TestDatabase | undefined;
78
+ const postgresFixture: DriverFixture = {
79
+ name: "postgres",
80
+ skip: testDatabaseAdminUrl() === undefined,
81
+ async open() {
82
+ if (postgresDatabase === undefined) {
83
+ postgresDatabase = await createTestDatabase();
84
+ }
85
+ const store = await PostgresStore.open(postgresDatabase.databaseUrl);
86
+ await store.deleteResourcesByKind(ApiResourceKind.iam_policy);
87
+ return { store, close: () => store.close() };
88
+ },
89
+ };
90
+
91
+ afterAll(async () => {
92
+ await postgresDatabase?.drop();
93
+ });
94
+
95
+ describe.each([sqliteFixture, postgresFixture])(
96
+ "IamPolicyStore over the OSS adapter ($name)",
97
+ (fixture) => {
98
+ describe.skipIf(fixture.skip)("the port-contract kit", () => {
99
+ const cases = iamPolicyStoreContract(
100
+ async (): Promise<IamPolicyStoreContractFixture> => {
101
+ const opened = await fixture.open();
102
+ return {
103
+ store: newResourceIamPolicyStore(opened.store),
104
+ disconnect: () => opened.store.close(),
105
+ cleanup: () => opened.close(),
106
+ };
107
+ },
108
+ );
109
+
110
+ it("carries every contract line, by name", () => {
111
+ expect(cases.map((contractCase) => contractCase.name)).toEqual(
112
+ CONTRACT_CASE_NAMES,
113
+ );
114
+ });
115
+
116
+ for (const contractCase of cases) {
117
+ it(contractCase.name, contractCase.run);
118
+ }
119
+ });
120
+
121
+ describe.skipIf(fixture.skip)("the OSS adapter's own invariant", () => {
122
+ let opened: OpenedStore;
123
+
124
+ beforeEach(async () => {
125
+ opened = await fixture.open();
126
+ });
127
+
128
+ afterEach(async () => {
129
+ await opened.close();
130
+ });
131
+
132
+ it("refuses to save a policy whose id is not its triple's derived id", async () => {
133
+ const policies = newResourceIamPolicyStore(opened.store);
134
+ const spec = orgRole("ida_wtr3jcf281yfk9xx61kj59fsme", "admin", "acme");
135
+ const stray = create(IamPolicySchema, {
136
+ apiVersion: IAM_POLICY_API_VERSION,
137
+ kind: IAM_POLICY_KIND,
138
+ metadata: create(ApiResourceMetadataSchema, {
139
+ id: "iamp_01hzzzzzzzzzzzzzzzzzzzzzzz",
140
+ }),
141
+ spec,
142
+ });
143
+ await expect(policies.save(stray)).rejects.toThrow(
144
+ "policy id must be derived from its triple",
145
+ );
146
+ expect(
147
+ await opened.store.listResources(ApiResourceKind.iam_policy),
148
+ ).toHaveLength(0);
149
+
150
+ const derived = create(IamPolicySchema, {
151
+ apiVersion: IAM_POLICY_API_VERSION,
152
+ kind: IAM_POLICY_KIND,
153
+ metadata: create(ApiResourceMetadataSchema, {
154
+ id: policyIdFor(spec),
155
+ }),
156
+ spec,
157
+ });
158
+ await policies.save(derived);
159
+ expect((await policies.findById(policyIdFor(spec)))?.spec).toEqual(
160
+ spec,
161
+ );
162
+ });
163
+ });
164
+ },
165
+ );