@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,278 @@
1
+ /**
2
+ * Pins steps.ts (20260913.01 slice 5): the IamPolicy chains' domain-local
3
+ * steps. The one with branching logic is ValidateGrantableRole (Q-OR-3 as
4
+ * refined; Q-S3-3; Q-S5-1): the wire refusal first (an unknown kind is
5
+ * `Unknown resource kind`, never a role sentence), then the three arms in
6
+ * order — the PROTO lists no role for the kind → the cloud's
7
+ * system-managed copy, byte-identical in every edition; the composed
8
+ * SCOPE lists none → UNIMPLEMENTED naming the editions that serve
9
+ * per-resource grants; the role is not in proto ∩ scope → the cloud's
10
+ * second copy listing the intersection in proto order. The intersection
11
+ * is what keeps a scope from widening the contract by accident.
12
+ *
13
+ * Grant and Revoke are proven for the one thing a step adds over the
14
+ * grant path: the result they leave under POLICY_RESULT_KEY (the revoke
15
+ * of an absent triple leaves the default instance, Java's contract).
16
+ */
17
+ import { Code, ConnectError } from "@connectrpc/connect";
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
21
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
22
+ import { IamPolicySpecSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
23
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
24
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
25
+
26
+ import { createLogger } from "../../../boot/logger.js";
27
+ import type { PolicyGrantScope } from "../../../extensions/policy-grant-scope.js";
28
+ import { trustedLocalIdentityFor } from "../../../pipeline/interceptors/auth.js";
29
+ import { RequestContext } from "../../../pipeline/request-context.js";
30
+ import {
31
+ PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE,
32
+ noGrantableRolesMessage,
33
+ policyIdFor,
34
+ principalNotGrantableMessage,
35
+ roleNotGrantableMessage,
36
+ unknownResourceKindMessage,
37
+ } from "../constants.js";
38
+ import { newIamPolicyGrantPath } from "../grant-path.js";
39
+ import { newOrganizationOnlyGrantScope } from "../grant-scope.js";
40
+ import {
41
+ POLICY_RESULT_KEY,
42
+ newGrantStep,
43
+ newRevokeStep,
44
+ newValidateGrantableRoleStep,
45
+ } from "../steps.js";
46
+ import { fakeIamPolicyStore, orgRole, triple } from "./support.js";
47
+
48
+ const caller = trustedLocalIdentityFor({
49
+ email: "operator@example.com",
50
+ displayName: "The Operator",
51
+ });
52
+ const silent = createLogger({ level: "error", pretty: false, write: () => {} });
53
+
54
+ function contextOf(
55
+ spec: IamPolicySpec,
56
+ ): RequestContext<typeof IamPolicySpecSchema> {
57
+ return new RequestContext(
58
+ IamPolicySpecSchema,
59
+ spec,
60
+ caller,
61
+ ApiResourceKind.iam_policy,
62
+ );
63
+ }
64
+
65
+ async function refusal(
66
+ run: () => void | Promise<unknown>,
67
+ ): Promise<ConnectError> {
68
+ try {
69
+ await run();
70
+ } catch (error) {
71
+ if (error instanceof ConnectError) return error;
72
+ throw error;
73
+ }
74
+ throw new Error("expected a refusal");
75
+ }
76
+
77
+ function scopeOf(
78
+ table: ReadonlyMap<ApiResourceKind, ReadonlyArray<IamRole>>,
79
+ ): PolicyGrantScope {
80
+ return { grantableRoles: (kind) => table.get(kind) ?? [] };
81
+ }
82
+
83
+ describe("ValidateGrantableRole", () => {
84
+ const organizationOnly = newValidateGrantableRoleStep(
85
+ newOrganizationOnlyGrantScope(),
86
+ );
87
+
88
+ it("admits a role the organization's kind_meta lists under the open-source scope", () => {
89
+ expect(
90
+ organizationOnly.execute(contextOf(orgRole("ida_bob", "viewer", "acme"))),
91
+ ).toBeUndefined();
92
+ });
93
+
94
+ it("refuses an unknown resource kind with the wire copy BEFORE any role arm", async () => {
95
+ const error = await refusal(() =>
96
+ organizationOnly.execute(
97
+ contextOf(
98
+ triple({ kind: "identity_account", id: "ida_bob" }, "viewer", {
99
+ kind: "nope",
100
+ id: "x",
101
+ }),
102
+ ),
103
+ ),
104
+ );
105
+ expect(error.code).toBe(Code.InvalidArgument);
106
+ expect(error.rawMessage).toBe(unknownResourceKindMessage("nope"));
107
+ });
108
+
109
+ it("arm 1: a kind whose proto lists no role is system-managed in every edition — even when a scope claims otherwise", async () => {
110
+ const widening = newValidateGrantableRoleStep(
111
+ scopeOf(new Map([[ApiResourceKind.identity_account, [IamRole.viewer]]])),
112
+ );
113
+ const error = await refusal(() =>
114
+ widening.execute(
115
+ contextOf(
116
+ triple({ kind: "identity_account", id: "ida_bob" }, "viewer", {
117
+ kind: "identity_account",
118
+ id: "ida_x",
119
+ }),
120
+ ),
121
+ ),
122
+ );
123
+ expect(error.code).toBe(Code.InvalidArgument);
124
+ expect(error.rawMessage).toBe(noGrantableRolesMessage("identity_account"));
125
+ });
126
+
127
+ it("arm 2: a kind the proto grants on but the scope excludes is UNIMPLEMENTED with the edition named", async () => {
128
+ const error = await refusal(() =>
129
+ organizationOnly.execute(
130
+ contextOf(
131
+ triple({ kind: "identity_account", id: "ida_bob" }, "viewer", {
132
+ kind: "agent",
133
+ id: "agt_1",
134
+ }),
135
+ ),
136
+ ),
137
+ );
138
+ expect(error.code).toBe(Code.Unimplemented);
139
+ expect(error.rawMessage).toBe(PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE);
140
+ });
141
+
142
+ it("arm 3: a role outside proto ∩ scope is refused with the cloud's copy, listing the intersection in proto order", async () => {
143
+ const error = await refusal(() =>
144
+ organizationOnly.execute(contextOf(orgRole("ida_bob", "editor", "acme"))),
145
+ );
146
+ expect(error.code).toBe(Code.InvalidArgument);
147
+ expect(error.rawMessage).toBe(
148
+ roleNotGrantableMessage("editor", "organization", [
149
+ "owner",
150
+ "admin",
151
+ "member",
152
+ "viewer",
153
+ ]),
154
+ );
155
+ });
156
+
157
+ it("arm 4: a principal that is not a person is refused — a role names an account; a structural link is bootstrapPolicy's (Q-S9-2)", async () => {
158
+ const error = await refusal(() =>
159
+ organizationOnly.execute(
160
+ contextOf(
161
+ triple({ kind: "organization", id: "other" }, "member", {
162
+ kind: "organization",
163
+ id: "acme",
164
+ }),
165
+ ),
166
+ ),
167
+ );
168
+ expect(error.code).toBe(Code.InvalidArgument);
169
+ expect(error.rawMessage).toBe(principalNotGrantableMessage("organization"));
170
+ });
171
+
172
+ it("arm 4 runs LAST: a wrong role on a non-person principal hears the role sentence, so every answer the cloud gives today is unchanged", async () => {
173
+ const error = await refusal(() =>
174
+ organizationOnly.execute(
175
+ contextOf(
176
+ triple({ kind: "organization", id: "other" }, "editor", {
177
+ kind: "organization",
178
+ id: "acme",
179
+ }),
180
+ ),
181
+ ),
182
+ );
183
+ expect(error.rawMessage).toBe(
184
+ roleNotGrantableMessage("editor", "organization", [
185
+ "owner",
186
+ "admin",
187
+ "member",
188
+ "viewer",
189
+ ]),
190
+ );
191
+ });
192
+
193
+ it("a scope can only NARROW the proto: a role it adds is clipped from the list", async () => {
194
+ const widening = newValidateGrantableRoleStep(
195
+ scopeOf(
196
+ new Map([
197
+ [
198
+ ApiResourceKind.organization,
199
+ [IamRole.participant, IamRole.viewer, IamRole.owner],
200
+ ],
201
+ ]),
202
+ ),
203
+ );
204
+ const error = await refusal(() =>
205
+ widening.execute(contextOf(orgRole("ida_bob", "participant", "acme"))),
206
+ );
207
+ expect(error.rawMessage).toBe(
208
+ roleNotGrantableMessage("participant", "organization", [
209
+ "owner",
210
+ "viewer",
211
+ ]),
212
+ );
213
+ expect(
214
+ widening.execute(contextOf(orgRole("ida_bob", "viewer", "acme"))),
215
+ ).toBeUndefined();
216
+ });
217
+
218
+ it("the unknown kind is total for the scope, never a throw from it (Q-S3-3)", async () => {
219
+ const throwing: PolicyGrantScope = {
220
+ grantableRoles: () => {
221
+ throw new Error("a scope must never be asked about an unknown kind");
222
+ },
223
+ };
224
+ const step = newValidateGrantableRoleStep(throwing);
225
+ const error = await refusal(() =>
226
+ step.execute(
227
+ contextOf(
228
+ triple({ kind: "identity_account", id: "x" }, "viewer", {
229
+ kind: "garbage",
230
+ id: "y",
231
+ }),
232
+ ),
233
+ ),
234
+ );
235
+ expect(error.rawMessage).toBe(unknownResourceKindMessage("garbage"));
236
+ });
237
+ });
238
+
239
+ describe("Grant and Revoke leave their result under POLICY_RESULT_KEY", () => {
240
+ function pathOverFreshStore() {
241
+ return newIamPolicyGrantPath({
242
+ policies: fakeIamPolicyStore(),
243
+ lifecycle: undefined,
244
+ logger: silent,
245
+ });
246
+ }
247
+
248
+ it("Grant leaves the granted row — the same row on the duplicate arm", async () => {
249
+ const path = pathOverFreshStore();
250
+ const spec = orgRole("ida_bob", "member", "acme");
251
+ const first = contextOf(spec);
252
+ await newGrantStep(path).execute(first);
253
+ const second = contextOf(spec);
254
+ await newGrantStep(path).execute(second);
255
+ expect((first.get(POLICY_RESULT_KEY) as IamPolicy).metadata?.id).toBe(
256
+ policyIdFor(spec),
257
+ );
258
+ expect((second.get(POLICY_RESULT_KEY) as IamPolicy).metadata?.id).toBe(
259
+ policyIdFor(spec),
260
+ );
261
+ });
262
+
263
+ it("Revoke leaves the revoked row, or the default instance when there was none (Java's idempotent delete)", async () => {
264
+ const path = pathOverFreshStore();
265
+ const spec = orgRole("ida_bob", "member", "acme");
266
+ await path.grant(spec, caller);
267
+ const revoked = contextOf(spec);
268
+ await newRevokeStep(path).execute(revoked);
269
+ expect((revoked.get(POLICY_RESULT_KEY) as IamPolicy).metadata?.id).toBe(
270
+ policyIdFor(spec),
271
+ );
272
+ const absent = contextOf(spec);
273
+ await newRevokeStep(path).execute(absent);
274
+ expect(
275
+ (absent.get(POLICY_RESULT_KEY) as IamPolicy).metadata?.id ?? "",
276
+ ).toBe("");
277
+ });
278
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Test fixtures for the IamPolicy domain: an in-memory IamPolicyStore with
3
+ * the primary-key semantics the OSS adapter has (a second save under a held
4
+ * id is DuplicatePolicyError), a recording lifecycle that captures the two
5
+ * policy hooks in the order they fire beside the store's writes (the shape
6
+ * the cloud's service.test.ts used to pin cloud#425), and spec builders in
7
+ * the contract's own vocabulary. Driver-backed fixtures live with the
8
+ * drivers (the sqlite and postgres __tests__/support modules under
9
+ * src/store).
10
+ */
11
+ import { create } from "@bufbuild/protobuf";
12
+
13
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
14
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
15
+ import { IamPolicySpecSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
16
+
17
+ import type {
18
+ PolicyGrantedEvent,
19
+ PolicyRevokedEvent,
20
+ ResourceAuthorizationLifecycle,
21
+ } from "../../../extensions/resource-authorization.js";
22
+ import { DuplicatePolicyError } from "../store.js";
23
+ import type { IamPolicyStore } from "../store.js";
24
+
25
+ /** `identity_account:<id>` holds `<relation>` on `organization:<org>` — the organization role. */
26
+ export function orgRole(
27
+ accountId: string,
28
+ relation: string,
29
+ orgId: string,
30
+ ): IamPolicySpec {
31
+ return create(IamPolicySpecSchema, {
32
+ principal: { kind: "identity_account", id: accountId },
33
+ relation,
34
+ resource: { kind: "organization", id: orgId },
35
+ });
36
+ }
37
+
38
+ /** Any triple, spelled out. */
39
+ export function triple(
40
+ principal: { kind: string; id: string; relation?: string },
41
+ relation: string,
42
+ resource: { kind: string; id: string },
43
+ ): IamPolicySpec {
44
+ return create(IamPolicySpecSchema, {
45
+ principal: {
46
+ kind: principal.kind,
47
+ id: principal.id,
48
+ relation: principal.relation ?? "",
49
+ },
50
+ relation,
51
+ resource: { kind: resource.kind, id: resource.id },
52
+ });
53
+ }
54
+
55
+ /** What a recording fixture saw, in order. */
56
+ export type RecordedEvent =
57
+ | { readonly kind: "row-save"; readonly id: string }
58
+ | { readonly kind: "row-delete"; readonly id: string }
59
+ | {
60
+ readonly kind: "granted";
61
+ readonly id: string;
62
+ readonly duplicate: boolean;
63
+ }
64
+ | {
65
+ readonly kind: "revoked";
66
+ readonly id: string | undefined;
67
+ readonly relation: string;
68
+ };
69
+
70
+ export interface FakeIamPolicyStore extends IamPolicyStore {
71
+ readonly rows: Map<string, IamPolicy>;
72
+ }
73
+
74
+ /** An in-memory store with the adapter's primary-key semantics; writes are recorded when a log is given. */
75
+ export function fakeIamPolicyStore(
76
+ recorded?: RecordedEvent[],
77
+ ): FakeIamPolicyStore {
78
+ const rows = new Map<string, IamPolicy>();
79
+ const all = (): IamPolicy[] => [...rows.values()];
80
+ const matches = (
81
+ policy: IamPolicy,
82
+ principalKind?: string,
83
+ principalId?: string,
84
+ resourceKind?: string,
85
+ resourceId?: string,
86
+ ): boolean =>
87
+ (principalKind === undefined ||
88
+ policy.spec?.principal?.kind === principalKind) &&
89
+ (principalId === undefined || policy.spec?.principal?.id === principalId) &&
90
+ (resourceKind === undefined ||
91
+ policy.spec?.resource?.kind === resourceKind) &&
92
+ (resourceId === undefined || policy.spec?.resource?.id === resourceId);
93
+ return {
94
+ rows,
95
+ async save(policy) {
96
+ const id = policy.metadata?.id ?? "";
97
+ if (rows.has(id)) {
98
+ throw new DuplicatePolicyError(`IAM policy '${id}' already exists`);
99
+ }
100
+ rows.set(id, policy);
101
+ recorded?.push({ kind: "row-save", id });
102
+ },
103
+ async deleteById(id) {
104
+ rows.delete(id);
105
+ recorded?.push({ kind: "row-delete", id });
106
+ },
107
+ async findById(id) {
108
+ return rows.get(id);
109
+ },
110
+ async findByPrincipal(principalKind, principalId) {
111
+ return all().filter((p) => matches(p, principalKind, principalId));
112
+ },
113
+ async findByResource(resourceKind, resourceId) {
114
+ return all().filter((p) =>
115
+ matches(p, undefined, undefined, resourceKind, resourceId),
116
+ );
117
+ },
118
+ async findByPrincipalAndResource(
119
+ principalKind,
120
+ principalId,
121
+ resourceKind,
122
+ resourceId,
123
+ ) {
124
+ return all().filter((p) =>
125
+ matches(p, principalKind, principalId, resourceKind, resourceId),
126
+ );
127
+ },
128
+ async findByResourceWithRelations(resourceKind, resourceId, relations) {
129
+ return all().filter(
130
+ (p) =>
131
+ matches(p, undefined, undefined, resourceKind, resourceId) &&
132
+ relations.includes(p.spec?.relation ?? ""),
133
+ );
134
+ },
135
+ async countDistinctPrincipalsByResource(
136
+ resourceKind,
137
+ resourceId,
138
+ principalKind,
139
+ relations,
140
+ ) {
141
+ const seen = new Set<string>();
142
+ for (const p of all()) {
143
+ if (
144
+ matches(p, principalKind, undefined, resourceKind, resourceId) &&
145
+ relations.includes(p.spec?.relation ?? "")
146
+ ) {
147
+ seen.add(`${p.spec?.principal?.kind}:${p.spec?.principal?.id}`);
148
+ }
149
+ }
150
+ return seen.size;
151
+ },
152
+ async findScopeTuple(resourceKind, resourceId) {
153
+ return all().find(
154
+ (p) =>
155
+ matches(p, undefined, undefined, resourceKind, resourceId) &&
156
+ !["identity_account", "team"].includes(
157
+ p.spec?.principal?.kind ?? "",
158
+ ) &&
159
+ !["owner", "creator"].includes(p.spec?.relation ?? ""),
160
+ );
161
+ },
162
+ };
163
+ }
164
+
165
+ /** A lifecycle that records the two policy hooks and can be told to fail either. */
166
+ export function recordingLifecycle(
167
+ recorded: RecordedEvent[],
168
+ faults: { readonly granted?: Error; readonly revoked?: Error } = {},
169
+ ): ResourceAuthorizationLifecycle {
170
+ return {
171
+ async onResourceCreated() {},
172
+ async onResourceDeleted() {},
173
+ async onVisibilityChanged() {},
174
+ async onPolicyGranted(event: PolicyGrantedEvent) {
175
+ if (faults.granted !== undefined) {
176
+ throw faults.granted;
177
+ }
178
+ recorded.push({
179
+ kind: "granted",
180
+ id: event.policy.metadata?.id ?? "",
181
+ duplicate: event.duplicate,
182
+ });
183
+ },
184
+ async onPolicyRevoked(event: PolicyRevokedEvent) {
185
+ if (faults.revoked !== undefined) {
186
+ throw faults.revoked;
187
+ }
188
+ recorded.push({
189
+ kind: "revoked",
190
+ id: event.policy?.metadata?.id,
191
+ relation: event.spec.relation,
192
+ });
193
+ },
194
+ };
195
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pins wire-refusals.ts (20260913.01 slice 5, Q-S5-2): the one place a
3
+ * kind string that came off the wire, or a whole triple, is turned into
4
+ * INVALID_ARGUMENT with the domain's byte-pinned copy. Three callers share
5
+ * it — the grant path, the ValidateGrantableRole step and the fourteen
6
+ * RPCs' controller — so the table is proven once here; grant-path.test.ts
7
+ * keeps its own arms and passes through the moved functions unchanged.
8
+ *
9
+ * Exactness is contract (Q-OR-9): the derived policy id hashes the spec's
10
+ * text, so a lenient match would let two spellings of one kind mint two
11
+ * rows for one grant.
12
+ */
13
+ import { Code, ConnectError } from "@connectrpc/connect";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
17
+
18
+ import {
19
+ malformedTripleMessage,
20
+ unknownPrincipalKindMessage,
21
+ unknownResourceKindMessage,
22
+ } from "../constants.js";
23
+ import {
24
+ requireKnownPrincipalKind,
25
+ requireKnownResourceKind,
26
+ requireWellFormedTriple,
27
+ } from "../wire-refusals.js";
28
+ import { orgRole, triple } from "./support.js";
29
+
30
+ function refusal(run: () => unknown): ConnectError {
31
+ try {
32
+ run();
33
+ } catch (error) {
34
+ if (error instanceof ConnectError) return error;
35
+ throw error;
36
+ }
37
+ throw new Error("expected a refusal");
38
+ }
39
+
40
+ describe("requireKnownResourceKind / requireKnownPrincipalKind", () => {
41
+ it("resolve exact enum member names", () => {
42
+ expect(requireKnownResourceKind("organization")).toBe(
43
+ ApiResourceKind.organization,
44
+ );
45
+ expect(requireKnownPrincipalKind("identity_account")).toBe(
46
+ ApiResourceKind.identity_account,
47
+ );
48
+ });
49
+
50
+ for (const garbage of [
51
+ "Organization",
52
+ "ORGANIZATION",
53
+ "",
54
+ "api_resource_kind_unknown",
55
+ "constructor",
56
+ "15",
57
+ ]) {
58
+ it(`refuse ${JSON.stringify(garbage)} as a resource kind with the cloud's copy`, () => {
59
+ const error = refusal(() => requireKnownResourceKind(garbage));
60
+ expect(error.code).toBe(Code.InvalidArgument);
61
+ expect(error.rawMessage).toBe(unknownResourceKindMessage(garbage));
62
+ });
63
+ }
64
+
65
+ it("refuse an unknown principal kind with the principal sentence", () => {
66
+ const error = refusal(() => requireKnownPrincipalKind("team-ish"));
67
+ expect(error.code).toBe(Code.InvalidArgument);
68
+ expect(error.rawMessage).toBe(unknownPrincipalKindMessage("team-ish"));
69
+ });
70
+ });
71
+
72
+ describe("requireWellFormedTriple", () => {
73
+ it("answers both refs and both resolved kinds for a well-formed triple", () => {
74
+ const admitted = requireWellFormedTriple(orgRole("ida_a", "admin", "acme"));
75
+ expect(admitted.resourceKind).toBe(ApiResourceKind.organization);
76
+ expect(admitted.principalKind).toBe(ApiResourceKind.identity_account);
77
+ expect(admitted.resource.id).toBe("acme");
78
+ expect(admitted.principal.id).toBe("ida_a");
79
+ });
80
+
81
+ it("refuses a delimiter in any field BEFORE either kind is resolved (Q-S2-1)", () => {
82
+ const error = refusal(() =>
83
+ requireWellFormedTriple(
84
+ triple({ kind: "garbage", id: "a#b" }, "viewer", {
85
+ kind: "garbage",
86
+ id: "x",
87
+ }),
88
+ ),
89
+ );
90
+ expect(error.code).toBe(Code.InvalidArgument);
91
+ expect(error.rawMessage).toBe(malformedTripleMessage("principal.id"));
92
+ });
93
+
94
+ it("refuses the resource kind before the principal kind — the cloud's order", () => {
95
+ const error = refusal(() =>
96
+ requireWellFormedTriple(
97
+ triple({ kind: "nope", id: "a" }, "viewer", {
98
+ kind: "also-nope",
99
+ id: "x",
100
+ }),
101
+ ),
102
+ );
103
+ expect(error.rawMessage).toBe(unknownResourceKindMessage("also-nope"));
104
+ });
105
+
106
+ it("refuses an unknown principal kind once the resource kind is known", () => {
107
+ const error = refusal(() =>
108
+ requireWellFormedTriple(
109
+ triple({ kind: "nope", id: "a" }, "viewer", {
110
+ kind: "organization",
111
+ id: "acme",
112
+ }),
113
+ ),
114
+ );
115
+ expect(error.rawMessage).toBe(unknownPrincipalKindMessage("nope"));
116
+ });
117
+ });