@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,238 @@
1
+ /**
2
+ * Pins access-lists.ts (20260913.01 slice 5): the row-driven answer to
3
+ * "who has access, with which roles" — the hierarchy walk over scope
4
+ * tuples (child → parent through the resource's one structural link, at
5
+ * most five steps, `platform` terminal) and the grouping of assignable-
6
+ * role rows by principal in first-seen order, enriched where the display
7
+ * resolver answers and rendered as kind/id with the id as its name
8
+ * everywhere else (the Java PrincipalEnricher fallback). Moved from the
9
+ * cloud's iam/policy/access-lists.ts; the arms below are the Java
10
+ * contract read from that file.
11
+ */
12
+ import { create } from "@bufbuild/protobuf";
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
16
+ import { ApiResourceRefViewSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
17
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
18
+
19
+ import { buildPrincipalAccessList, resolveHierarchy } from "../access-lists.js";
20
+ import type { PrincipalDisplayResolver } from "../access-lists.js";
21
+ import { policyIdFor } from "../constants.js";
22
+ import { fakeIamPolicyStore, orgRole, triple } from "./support.js";
23
+
24
+ function row(spec: IamPolicySpec) {
25
+ return create(IamPolicySchema, {
26
+ metadata: { id: policyIdFor(spec) },
27
+ spec,
28
+ });
29
+ }
30
+
31
+ /** `child` is scoped under `parent`: the structural link the walk follows. */
32
+ function scopeLink(
33
+ child: { kind: string; id: string },
34
+ parent: { kind: string; id: string },
35
+ ): IamPolicySpec {
36
+ return triple(parent, parent.kind, child);
37
+ }
38
+
39
+ const nobody: PrincipalDisplayResolver = {
40
+ resolveIdentityAccounts: () => Promise.resolve(new Map()),
41
+ };
42
+
43
+ describe("resolveHierarchy", () => {
44
+ it("is the resource alone when inherited grants are not asked for", async () => {
45
+ const store = fakeIamPolicyStore();
46
+ await store.save(
47
+ row(
48
+ scopeLink(
49
+ { kind: "agent", id: "a1" },
50
+ { kind: "organization", id: "acme" },
51
+ ),
52
+ ),
53
+ );
54
+ expect(await resolveHierarchy(store, "agent", "a1", false)).toEqual([
55
+ { kind: "agent", id: "a1" },
56
+ ]);
57
+ });
58
+
59
+ it("climbs the scope links child-first and stops where no link exists", async () => {
60
+ const store = fakeIamPolicyStore();
61
+ await store.save(
62
+ row(
63
+ scopeLink(
64
+ { kind: "agent", id: "a1" },
65
+ { kind: "organization", id: "acme" },
66
+ ),
67
+ ),
68
+ );
69
+ expect(await resolveHierarchy(store, "agent", "a1", true)).toEqual([
70
+ { kind: "agent", id: "a1" },
71
+ { kind: "organization", id: "acme" },
72
+ ]);
73
+ });
74
+
75
+ it("stops at platform, the terminal kind, even when platform carries a link", async () => {
76
+ const store = fakeIamPolicyStore();
77
+ await store.save(
78
+ row(
79
+ scopeLink(
80
+ { kind: "organization", id: "acme" },
81
+ { kind: "platform", id: "stigmer" },
82
+ ),
83
+ ),
84
+ );
85
+ await store.save(
86
+ row(
87
+ scopeLink(
88
+ { kind: "platform", id: "stigmer" },
89
+ { kind: "organization", id: "loop" },
90
+ ),
91
+ ),
92
+ );
93
+ expect(await resolveHierarchy(store, "organization", "acme", true)).toEqual(
94
+ [
95
+ { kind: "organization", id: "acme" },
96
+ { kind: "platform", id: "stigmer" },
97
+ ],
98
+ );
99
+ });
100
+
101
+ it("walks at most five steps — a cycle cannot spin it", async () => {
102
+ const store = fakeIamPolicyStore();
103
+ await store.save(
104
+ row(scopeLink({ kind: "agent", id: "a" }, { kind: "agent", id: "b" })),
105
+ );
106
+ await store.save(
107
+ row(scopeLink({ kind: "agent", id: "b" }, { kind: "agent", id: "a" })),
108
+ );
109
+ const levels = await resolveHierarchy(store, "agent", "a", true);
110
+ expect(levels).toHaveLength(6);
111
+ });
112
+
113
+ it("ignores rows whose principal is a person or whose relation is owner/creator — those are grants, not links", async () => {
114
+ const store = fakeIamPolicyStore();
115
+ await store.save(row(orgRole("ida_alice", "owner", "acme")));
116
+ await store.save(
117
+ row(
118
+ triple({ kind: "organization", id: "parent" }, "owner", {
119
+ kind: "organization",
120
+ id: "acme",
121
+ }),
122
+ ),
123
+ );
124
+ expect(await resolveHierarchy(store, "organization", "acme", true)).toEqual(
125
+ [{ kind: "organization", id: "acme" }],
126
+ );
127
+ });
128
+ });
129
+
130
+ describe("buildPrincipalAccessList", () => {
131
+ it("answers nothing for a resource with no assignable-role rows — structural rows never show", async () => {
132
+ const store = fakeIamPolicyStore();
133
+ await store.save(
134
+ row(
135
+ scopeLink(
136
+ { kind: "agent", id: "a1" },
137
+ { kind: "organization", id: "acme" },
138
+ ),
139
+ ),
140
+ );
141
+ expect(
142
+ await buildPrincipalAccessList(store, nobody, [
143
+ { kind: "agent", id: "a1" },
144
+ ]),
145
+ ).toEqual([]);
146
+ });
147
+
148
+ it("groups by principal in first-seen order, one RoleGrant per row, direct grants without an owner resource", async () => {
149
+ const store = fakeIamPolicyStore();
150
+ await store.save(row(orgRole("ida_bob", "member", "acme")));
151
+ await store.save(row(orgRole("ida_alice", "owner", "acme")));
152
+ await store.save(row(orgRole("ida_bob", "viewer", "acme")));
153
+ const entries = await buildPrincipalAccessList(store, nobody, [
154
+ { kind: "organization", id: "acme" },
155
+ ]);
156
+ expect(entries.map((e) => e.principal?.id)).toEqual([
157
+ "ida_bob",
158
+ "ida_alice",
159
+ ]);
160
+ const bob = entries[0];
161
+ expect(bob?.roles.map((g) => g.role?.code)).toEqual(["member", "viewer"]);
162
+ expect(
163
+ bob?.roles.every((g) => !g.isInherited && g.ownerResource === undefined),
164
+ ).toBe(true);
165
+ });
166
+
167
+ it("marks grants from a parent level as inherited and names the owning level", async () => {
168
+ const store = fakeIamPolicyStore();
169
+ await store.save(row(orgRole("ida_alice", "admin", "acme")));
170
+ const entries = await buildPrincipalAccessList(store, nobody, [
171
+ { kind: "agent", id: "a1" },
172
+ { kind: "organization", id: "acme" },
173
+ ]);
174
+ expect(entries).toHaveLength(1);
175
+ const grant = entries[0]?.roles[0];
176
+ expect(grant?.isInherited).toBe(true);
177
+ expect(grant?.ownerResource?.kind).toBe("organization");
178
+ expect(grant?.ownerResource?.id).toBe("acme");
179
+ expect(grant?.role?.code).toBe("admin");
180
+ });
181
+
182
+ it("renders an unresolved principal of ANY kind as kind/id with the id as its name", async () => {
183
+ const store = fakeIamPolicyStore();
184
+ await store.save(row(orgRole("ida_ghost", "viewer", "acme")));
185
+ await store.save(
186
+ row(
187
+ triple({ kind: "team", id: "tm_1" }, "viewer", {
188
+ kind: "organization",
189
+ id: "acme",
190
+ }),
191
+ ),
192
+ );
193
+ const entries = await buildPrincipalAccessList(store, nobody, [
194
+ { kind: "organization", id: "acme" },
195
+ ]);
196
+ expect(
197
+ entries.map((e) => [
198
+ e.principal?.kind,
199
+ e.principal?.id,
200
+ e.principal?.name,
201
+ ]),
202
+ ).toEqual([
203
+ ["identity_account", "ida_ghost", "ida_ghost"],
204
+ ["team", "tm_1", "tm_1"],
205
+ ]);
206
+ });
207
+
208
+ it("asks the resolver once for the identity-account ids and uses its view where it answers", async () => {
209
+ const store = fakeIamPolicyStore();
210
+ await store.save(row(orgRole("ida_alice", "owner", "acme")));
211
+ await store.save(row(orgRole("ida_bob", "member", "acme")));
212
+ const asked: string[][] = [];
213
+ const resolver: PrincipalDisplayResolver = {
214
+ resolveIdentityAccounts: (ids) => {
215
+ asked.push([...ids]);
216
+ return Promise.resolve(
217
+ new Map([
218
+ [
219
+ "ida_alice",
220
+ create(ApiResourceRefViewSchema, {
221
+ kind: "identity_account",
222
+ id: "ida_alice",
223
+ name: "Alice",
224
+ email: "alice@example.com",
225
+ }),
226
+ ],
227
+ ]),
228
+ );
229
+ },
230
+ };
231
+ const entries = await buildPrincipalAccessList(store, resolver, [
232
+ { kind: "organization", id: "acme" },
233
+ ]);
234
+ expect(asked).toEqual([["ida_alice", "ida_bob"]]);
235
+ expect(entries.map((e) => e.principal?.name)).toEqual(["Alice", "ida_bob"]);
236
+ expect(entries[0]?.principal?.email).toBe("alice@example.com");
237
+ });
238
+ });
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Pins the IamPolicy domain's constants (project 20260913.01,
3
+ * T01_0_plan.md §3a, T01_1_review.md Q-OR-9):
4
+ *
5
+ * - the policy-id derivation — `iamp_` + the top 130 bits of sha256 over
6
+ * the triple's canonical text as 26 lowercase Crockford-base32
7
+ * characters. The canonical text IS the tuple notation the contract's
8
+ * own comment documents (`principal.kind:principal.id#principal.relation
9
+ * @resource.kind:resource.id#relation`, spec.proto), so the natural key
10
+ * has one spelling and the primary key is the one home of "one row per
11
+ * triple". The golden vectors are wire-adjacent constants: a change
12
+ * re-addresses every policy open source ever wrote;
13
+ * - `BLUEPRINT_KINDS` — the kinds an admin authors, the legacy-creator
14
+ * rule's whole scan (Q-OR-6b); sessions and executions are personal
15
+ * and never appear here;
16
+ * - the byte-pinned copy moved from the cloud's handlers as-is, plus the
17
+ * new sentences (the two edition refusals, the unknown permission, the
18
+ * unknown principal kind, the malformed triple);
19
+ * - the canonical text's one weakness and its closure (slice 2 ruling
20
+ * Q-S2-1): `ApiResourceRef` fields carry no character pattern, so an
21
+ * id or relation holding `:`, `#` or `@` could spell another triple's
22
+ * text. `malformedTripleField` names the offending field and
23
+ * `policyIdFor` refuses to hash such a spec; the grant path turns the
24
+ * same check into INVALID_ARGUMENT before any read or write.
25
+ */
26
+ import { create } from "@bufbuild/protobuf";
27
+ import { describe, expect, it } from "vitest";
28
+
29
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
31
+ import { IamPolicySpecSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
32
+
33
+ import {
34
+ AUTHENTICATION_REQUIRED_MESSAGE,
35
+ AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
36
+ BLUEPRINT_KINDS,
37
+ IAM_POLICY_API_VERSION,
38
+ IAM_POLICY_KIND,
39
+ PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE,
40
+ POLICY_ID_PREFIX,
41
+ TRIPLE_DELIMITERS,
42
+ USER_GRANT_PRINCIPAL_KINDS,
43
+ canonicalTripleText,
44
+ malformedTripleField,
45
+ malformedTripleMessage,
46
+ noGrantableRolesMessage,
47
+ policyIdFor,
48
+ policyNotFoundMessage,
49
+ principalNotGrantableMessage,
50
+ roleNotGrantableMessage,
51
+ unknownPermissionMessage,
52
+ unknownPrincipalKindMessage,
53
+ unknownResourceKindMessage,
54
+ } from "../constants.js";
55
+
56
+ const CROCKFORD_ID = /^iamp_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
57
+
58
+ function spec(
59
+ principal: { kind: string; id: string; relation?: string },
60
+ relation: string,
61
+ resource: { kind: string; id: string },
62
+ ): IamPolicySpec {
63
+ return create(IamPolicySpecSchema, {
64
+ principal: {
65
+ kind: principal.kind,
66
+ id: principal.id,
67
+ relation: principal.relation ?? "",
68
+ },
69
+ relation,
70
+ resource: { kind: resource.kind, id: resource.id },
71
+ });
72
+ }
73
+
74
+ const ALICE = "ida_wtr3jcf281yfk9xx61kj59fsme";
75
+ const AGENT = "agt_01hzzzzzzzzzzzzzzzzzzzzzzz";
76
+
77
+ describe("policyIdFor — the derived policy id", () => {
78
+ it("pins the golden vectors (a change re-addresses every OSS policy)", () => {
79
+ expect(
80
+ policyIdFor(
81
+ spec({ kind: "identity_account", id: ALICE }, "admin", {
82
+ kind: "organization",
83
+ id: "acme",
84
+ }),
85
+ ),
86
+ ).toBe("iamp_vkbjb6nvqm77vtwnr2bj0x95f9");
87
+ expect(
88
+ policyIdFor(
89
+ spec({ kind: "identity_account", id: ALICE }, "member", {
90
+ kind: "organization",
91
+ id: "acme",
92
+ }),
93
+ ),
94
+ ).toBe("iamp_8excwectzw4xzrphy43zpar661");
95
+ // A structural link (the cloud's scope tuple) derives the same way: the
96
+ // id is the triple's, whatever the relation means.
97
+ expect(
98
+ policyIdFor(
99
+ spec({ kind: "organization", id: "acme" }, "organization", {
100
+ kind: "agent",
101
+ id: AGENT,
102
+ }),
103
+ ),
104
+ ).toBe("iamp_e8ak7be793avgz5tk4abmew2fp");
105
+ // The public-viewer wildcard principal is a legal triple.
106
+ expect(
107
+ policyIdFor(
108
+ spec({ kind: "identity_account", id: "*" }, "viewer", {
109
+ kind: "agent",
110
+ id: AGENT,
111
+ }),
112
+ ),
113
+ ).toBe("iamp_w5wqxkyrtbspjyp3jd6ce565q2");
114
+ });
115
+
116
+ it("has the shape of every minted id: prefix, underscore, 26 Crockford chars", () => {
117
+ expect(POLICY_ID_PREFIX).toBe("iamp");
118
+ expect(
119
+ policyIdFor(
120
+ spec({ kind: "identity_account", id: ALICE }, "viewer", {
121
+ kind: "organization",
122
+ id: "acme",
123
+ }),
124
+ ),
125
+ ).toMatch(CROCKFORD_ID);
126
+ });
127
+
128
+ it("changes with every axis of the triple, the principal's relation qualifier included", () => {
129
+ const base = spec({ kind: "identity_account", id: ALICE }, "admin", {
130
+ kind: "organization",
131
+ id: "acme",
132
+ });
133
+ const variants = [
134
+ spec({ kind: "team", id: ALICE }, "admin", {
135
+ kind: "organization",
136
+ id: "acme",
137
+ }),
138
+ spec({ kind: "identity_account", id: "ida_other" }, "admin", {
139
+ kind: "organization",
140
+ id: "acme",
141
+ }),
142
+ spec(
143
+ { kind: "identity_account", id: ALICE, relation: "member" },
144
+ "admin",
145
+ { kind: "organization", id: "acme" },
146
+ ),
147
+ spec({ kind: "identity_account", id: ALICE }, "member", {
148
+ kind: "organization",
149
+ id: "acme",
150
+ }),
151
+ spec({ kind: "identity_account", id: ALICE }, "admin", {
152
+ kind: "environment",
153
+ id: "acme",
154
+ }),
155
+ spec({ kind: "identity_account", id: ALICE }, "admin", {
156
+ kind: "organization",
157
+ id: "globex",
158
+ }),
159
+ ];
160
+ const ids = new Set([policyIdFor(base), ...variants.map(policyIdFor)]);
161
+ expect(ids.size).toBe(variants.length + 1);
162
+ });
163
+
164
+ it("refuses a spec missing either reference — a triple is the whole key", () => {
165
+ expect(() =>
166
+ policyIdFor(create(IamPolicySpecSchema, { relation: "admin" })),
167
+ ).toThrow("policy spec must carry principal and resource");
168
+ });
169
+
170
+ it("hashes exactly the proto's tuple notation, the principal's `#` present even when its relation is empty", () => {
171
+ expect(
172
+ canonicalTripleText(
173
+ spec({ kind: "identity_account", id: ALICE }, "admin", {
174
+ kind: "organization",
175
+ id: "acme",
176
+ }),
177
+ ),
178
+ ).toBe(`identity_account:${ALICE}#@organization:acme#admin`);
179
+ expect(
180
+ canonicalTripleText(
181
+ spec({ kind: "team", id: "tm_1", relation: "member" }, "viewer", {
182
+ kind: "agent",
183
+ id: AGENT,
184
+ }),
185
+ ),
186
+ ).toBe(`team:tm_1#member@agent:${AGENT}#viewer`);
187
+ });
188
+
189
+ it("refuses a triple whose text would be ambiguous — a delimiter inside any field (Q-S2-1)", () => {
190
+ // `a#b` with an empty qualifier and `a` with qualifier `b#` would spell
191
+ // one text; the refusal is what keeps one text one triple.
192
+ expect(() =>
193
+ policyIdFor(
194
+ spec({ kind: "identity_account", id: "a#b" }, "admin", {
195
+ kind: "organization",
196
+ id: "acme",
197
+ }),
198
+ ),
199
+ ).toThrow(malformedTripleMessage("principal.id"));
200
+ });
201
+ });
202
+
203
+ describe("malformedTripleField — the delimiter check the id and the writer share", () => {
204
+ it("names the delimiters the canonical text is built from", () => {
205
+ expect([...TRIPLE_DELIMITERS]).toEqual([":", "#", "@"]);
206
+ });
207
+
208
+ it("names the first field holding a delimiter, in the text's order", () => {
209
+ const base = {
210
+ principal: { kind: "identity_account", id: ALICE, relation: "" },
211
+ relation: "admin",
212
+ resource: { kind: "organization", id: "acme" },
213
+ };
214
+ const cases: ReadonlyArray<[IamPolicySpec, string]> = [
215
+ [
216
+ spec(
217
+ { ...base.principal, kind: "identity:account" },
218
+ base.relation,
219
+ base.resource,
220
+ ),
221
+ "principal.kind",
222
+ ],
223
+ [
224
+ spec(
225
+ { ...base.principal, id: `${ALICE}@x` },
226
+ base.relation,
227
+ base.resource,
228
+ ),
229
+ "principal.id",
230
+ ],
231
+ [
232
+ spec(
233
+ { ...base.principal, relation: "mem#ber" },
234
+ base.relation,
235
+ base.resource,
236
+ ),
237
+ "principal.relation",
238
+ ],
239
+ [
240
+ spec(base.principal, base.relation, {
241
+ ...base.resource,
242
+ kind: "org:anization",
243
+ }),
244
+ "resource.kind",
245
+ ],
246
+ [
247
+ spec(base.principal, base.relation, {
248
+ ...base.resource,
249
+ id: "acme#admin",
250
+ }),
251
+ "resource.id",
252
+ ],
253
+ [spec(base.principal, "ad@min", base.resource), "relation"],
254
+ ];
255
+ for (const [malformed, field] of cases) {
256
+ expect(malformedTripleField(malformed), field).toBe(field);
257
+ }
258
+ });
259
+
260
+ it("is undefined for a well-formed triple, the public wildcard principal included", () => {
261
+ expect(
262
+ malformedTripleField(
263
+ spec({ kind: "identity_account", id: ALICE }, "admin", {
264
+ kind: "organization",
265
+ id: "acme",
266
+ }),
267
+ ),
268
+ ).toBeUndefined();
269
+ expect(
270
+ malformedTripleField(
271
+ spec({ kind: "identity_account", id: "*" }, "viewer", {
272
+ kind: "agent",
273
+ id: AGENT,
274
+ }),
275
+ ),
276
+ ).toBeUndefined();
277
+ });
278
+ });
279
+
280
+ describe("the contract's identity strings", () => {
281
+ it("stamps the proto's apiVersion const, not the Java-era value (Q-OR-9)", () => {
282
+ expect(IAM_POLICY_API_VERSION).toBe("iam.stigmer.ai/v1");
283
+ expect(IAM_POLICY_KIND).toBe("IamPolicy");
284
+ });
285
+ });
286
+
287
+ describe("BLUEPRINT_KINDS — the legacy-creator rule's scan (Q-OR-6b)", () => {
288
+ it("is exactly the six kinds an admin authors, in registry order", () => {
289
+ expect([...BLUEPRINT_KINDS]).toEqual([
290
+ ApiResourceKind.agent,
291
+ ApiResourceKind.workflow,
292
+ ApiResourceKind.skill,
293
+ ApiResourceKind.mcp_server,
294
+ ApiResourceKind.environment,
295
+ ApiResourceKind.schedule,
296
+ ]);
297
+ });
298
+
299
+ it("never names a personal kind — those stay with their creator by DD-002 rule 2, no role needed", () => {
300
+ for (const personal of [
301
+ ApiResourceKind.session,
302
+ ApiResourceKind.agent_execution,
303
+ ApiResourceKind.workflow_execution,
304
+ ApiResourceKind.api_key,
305
+ ApiResourceKind.memory,
306
+ ]) {
307
+ expect(BLUEPRINT_KINDS).not.toContain(personal);
308
+ }
309
+ });
310
+ });
311
+
312
+ describe("USER_GRANT_PRINCIPAL_KINDS — who a person may grant a role to (Q-S9-2)", () => {
313
+ it("is exactly the identity account: a role names a person; `team` is not an ApiResourceKind, so no wire spec can name it", () => {
314
+ expect([...USER_GRANT_PRINCIPAL_KINDS]).toEqual([
315
+ ApiResourceKind.identity_account,
316
+ ]);
317
+ expect(ApiResourceKind).not.toHaveProperty("team");
318
+ });
319
+
320
+ it("names no kind the hierarchy walk would read as a structural parent", () => {
321
+ // The two sets meet by construction (resource-store.ts derives its
322
+ // non-structural exclusion from this one), so this pins the
323
+ // consequence: an organization, an agent or the platform can never
324
+ // be a person's grantee.
325
+ for (const structural of [
326
+ ApiResourceKind.organization,
327
+ ApiResourceKind.platform,
328
+ ApiResourceKind.agent,
329
+ ApiResourceKind.identity_provider,
330
+ ]) {
331
+ expect(USER_GRANT_PRINCIPAL_KINDS).not.toContain(structural);
332
+ }
333
+ });
334
+ });
335
+
336
+ describe("byte-pinned copy", () => {
337
+ it("moves the cloud's sentences as-is", () => {
338
+ expect(policyNotFoundMessage("iamp_x")).toBe(
339
+ "IAM policy not found: iamp_x",
340
+ );
341
+ expect(noGrantableRolesMessage("agent_execution")).toBe(
342
+ "No roles can be granted on resource kind 'agent_execution'. Role assignments for this resource kind are system-managed.",
343
+ );
344
+ expect(
345
+ roleNotGrantableMessage("editor", "organization", [
346
+ "owner",
347
+ "admin",
348
+ "member",
349
+ "viewer",
350
+ ]),
351
+ ).toBe(
352
+ "Role 'editor' cannot be granted on resource kind 'organization'. Grantable roles: [owner, admin, member, viewer]",
353
+ );
354
+ expect(AUTHENTICATION_REQUIRED_MESSAGE).toBe(
355
+ "Authentication required to check permissions",
356
+ );
357
+ expect(unknownResourceKindMessage("organisation")).toBe(
358
+ "Unknown resource kind: 'organisation'",
359
+ );
360
+ });
361
+
362
+ it("spells the two new writer refusals in the cloud sentence's shape", () => {
363
+ expect(unknownPrincipalKindMessage("team")).toBe(
364
+ "Unknown principal kind: 'team'",
365
+ );
366
+ expect(malformedTripleMessage("resource.id")).toBe(
367
+ "policy resource.id must not contain ':', '#' or '@'",
368
+ );
369
+ });
370
+
371
+ it("spells the principal refusal in the role sentence's shape, listing the grantable principal kinds", () => {
372
+ expect(principalNotGrantableMessage("organization")).toBe(
373
+ "Principal kind 'organization' cannot be granted a role. Grantable principal kinds: [identity_account]",
374
+ );
375
+ });
376
+
377
+ it("names the edition in the two new refusals, and quotes the unknown permission single-quoted", () => {
378
+ expect(PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE).toBe(
379
+ "per-resource access grants are served by the Enterprise and Cloud editions",
380
+ );
381
+ expect(AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE).toBe(
382
+ "authorization queries are served by the Enterprise and Cloud editions",
383
+ );
384
+ expect(unknownPermissionMessage("can_fly")).toBe(
385
+ "unknown permission 'can_fly'",
386
+ );
387
+ });
388
+ });