@stigmer/server 3.26.0 → 3.27.1

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 (121) hide show
  1. package/dist/authorization/evaluator.d.ts +4 -1
  2. package/dist/authorization/evaluator.d.ts.map +1 -1
  3. package/dist/authorization/evaluator.js +8 -0
  4. package/dist/authorization/evaluator.js.map +1 -1
  5. package/dist/authorization/model/agent.d.ts.map +1 -1
  6. package/dist/authorization/model/agent.js +4 -2
  7. package/dist/authorization/model/agent.js.map +1 -1
  8. package/dist/authorization/model/agent_channel.d.ts.map +1 -1
  9. package/dist/authorization/model/agent_channel.js +5 -4
  10. package/dist/authorization/model/agent_channel.js.map +1 -1
  11. package/dist/authorization/model/agent_instance.d.ts.map +1 -1
  12. package/dist/authorization/model/agent_instance.js +3 -2
  13. package/dist/authorization/model/agent_instance.js.map +1 -1
  14. package/dist/authorization/model/mcp_server.d.ts.map +1 -1
  15. package/dist/authorization/model/mcp_server.js +3 -2
  16. package/dist/authorization/model/mcp_server.js.map +1 -1
  17. package/dist/authorization/model/organization.d.ts.map +1 -1
  18. package/dist/authorization/model/organization.js +3 -0
  19. package/dist/authorization/model/organization.js.map +1 -1
  20. package/dist/authorization/model/plugin.d.ts.map +1 -1
  21. package/dist/authorization/model/plugin.js +4 -2
  22. package/dist/authorization/model/plugin.js.map +1 -1
  23. package/dist/authorization/model/rewrite.d.ts +25 -7
  24. package/dist/authorization/model/rewrite.d.ts.map +1 -1
  25. package/dist/authorization/model/rewrite.js +40 -1
  26. package/dist/authorization/model/rewrite.js.map +1 -1
  27. package/dist/authorization/model/schedule.d.ts.map +1 -1
  28. package/dist/authorization/model/schedule.js +8 -3
  29. package/dist/authorization/model/schedule.js.map +1 -1
  30. package/dist/authorization/model/skill.d.ts.map +1 -1
  31. package/dist/authorization/model/skill.js +3 -2
  32. package/dist/authorization/model/skill.js.map +1 -1
  33. package/dist/authorization/model/workflow.d.ts.map +1 -1
  34. package/dist/authorization/model/workflow.js +3 -2
  35. package/dist/authorization/model/workflow.js.map +1 -1
  36. package/dist/authorization/model/workflow_execution.d.ts.map +1 -1
  37. package/dist/authorization/model/workflow_execution.js +4 -3
  38. package/dist/authorization/model/workflow_execution.js.map +1 -1
  39. package/dist/authorization/model/workflow_instance.d.ts.map +1 -1
  40. package/dist/authorization/model/workflow_instance.js +5 -1
  41. package/dist/authorization/model/workflow_instance.js.map +1 -1
  42. package/dist/boot/compose.d.ts.map +1 -1
  43. package/dist/boot/compose.js +2 -0
  44. package/dist/boot/compose.js.map +1 -1
  45. package/dist/domain/iampolicy/access-lists.d.ts +5 -3
  46. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -1
  47. package/dist/domain/iampolicy/access-lists.js +72 -19
  48. package/dist/domain/iampolicy/access-lists.js.map +1 -1
  49. package/dist/domain/iampolicy/constants.d.ts +32 -6
  50. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  51. package/dist/domain/iampolicy/constants.js +41 -6
  52. package/dist/domain/iampolicy/constants.js.map +1 -1
  53. package/dist/domain/iampolicy/controller.d.ts +6 -0
  54. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  55. package/dist/domain/iampolicy/controller.js +16 -7
  56. package/dist/domain/iampolicy/controller.js.map +1 -1
  57. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -1
  58. package/dist/domain/iampolicy/resource-store.js +2 -6
  59. package/dist/domain/iampolicy/resource-store.js.map +1 -1
  60. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  61. package/dist/domain/iampolicy/steps.js +49 -7
  62. package/dist/domain/iampolicy/steps.js.map +1 -1
  63. package/dist/domain/workflow/registry/data/task-kind-registry.json +1 -0
  64. package/dist/extensions/drivers.d.ts +12 -0
  65. package/dist/extensions/drivers.d.ts.map +1 -1
  66. package/dist/extensions/gate-slots.d.ts +10 -1
  67. package/dist/extensions/gate-slots.d.ts.map +1 -1
  68. package/dist/extensions/gate-slots.js +1 -0
  69. package/dist/extensions/gate-slots.js.map +1 -1
  70. package/dist/extensions/principal-display.d.ts +34 -0
  71. package/dist/extensions/principal-display.d.ts.map +1 -0
  72. package/dist/extensions/principal-display.js +2 -0
  73. package/dist/extensions/principal-display.js.map +1 -0
  74. package/dist/extensions/registry.d.ts +6 -0
  75. package/dist/extensions/registry.d.ts.map +1 -1
  76. package/dist/extensions/registry.js +10 -0
  77. package/dist/extensions/registry.js.map +1 -1
  78. package/dist/index.d.ts +6 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +20 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/pipeline/apiresource-meta.d.ts +10 -0
  83. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  84. package/dist/pipeline/apiresource-meta.js +16 -0
  85. package/dist/pipeline/apiresource-meta.js.map +1 -1
  86. package/package.json +6 -6
  87. package/src/authorization/__tests__/evaluator.test.ts +127 -2
  88. package/src/authorization/evaluator.ts +12 -1
  89. package/src/authorization/model/__tests__/registry.test.ts +64 -2
  90. package/src/authorization/model/agent.ts +4 -1
  91. package/src/authorization/model/agent_channel.ts +11 -3
  92. package/src/authorization/model/agent_instance.ts +3 -1
  93. package/src/authorization/model/mcp_server.ts +3 -1
  94. package/src/authorization/model/organization.ts +3 -0
  95. package/src/authorization/model/plugin.ts +4 -1
  96. package/src/authorization/model/rewrite.ts +65 -8
  97. package/src/authorization/model/schedule.ts +11 -2
  98. package/src/authorization/model/skill.ts +3 -1
  99. package/src/authorization/model/workflow.ts +3 -1
  100. package/src/authorization/model/workflow_execution.ts +4 -2
  101. package/src/authorization/model/workflow_instance.ts +5 -0
  102. package/src/boot/compose.ts +2 -0
  103. package/src/domain/iampolicy/__tests__/access-lists.test.ts +120 -6
  104. package/src/domain/iampolicy/__tests__/constants.test.ts +26 -4
  105. package/src/domain/iampolicy/__tests__/controller.test.ts +55 -1
  106. package/src/domain/iampolicy/__tests__/grant-path.test.ts +3 -3
  107. package/src/domain/iampolicy/__tests__/steps.test.ts +123 -1
  108. package/src/domain/iampolicy/access-lists.ts +98 -23
  109. package/src/domain/iampolicy/constants.ts +50 -6
  110. package/src/domain/iampolicy/controller.ts +30 -7
  111. package/src/domain/iampolicy/resource-store.ts +3 -6
  112. package/src/domain/iampolicy/steps.ts +89 -7
  113. package/src/domain/workflow/registry/data/task-kind-registry.json +1 -0
  114. package/src/extensions/__tests__/iam-policy-points.test.ts +30 -0
  115. package/src/extensions/__tests__/registry.test.ts +3 -0
  116. package/src/extensions/drivers.ts +12 -0
  117. package/src/extensions/gate-slots.ts +10 -0
  118. package/src/extensions/principal-display.ts +37 -0
  119. package/src/extensions/registry.ts +19 -0
  120. package/src/index.ts +26 -0
  121. package/src/pipeline/apiresource-meta.ts +21 -0
@@ -7,15 +7,19 @@
7
7
  * resolver answers and rendered as kind/id with the id as its name
8
8
  * everywhere else (the Java PrincipalEnricher fallback). Moved from the
9
9
  * cloud's iam/policy/access-lists.ts; the arms below are the Java
10
- * contract read from that file.
10
+ * contract read from that file. A grantee that is not a person (a team)
11
+ * is named by the composed principal display, one call per kind, keyed by
12
+ * kind and id so one kind's view never names another kind's id.
11
13
  */
12
14
  import { create } from "@bufbuild/protobuf";
13
15
  import { describe, expect, it } from "vitest";
14
16
 
17
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
15
18
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
16
19
  import { ApiResourceRefViewSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
17
20
  import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
18
21
 
22
+ import type { PrincipalDisplay } from "../../../extensions/principal-display.js";
19
23
  import { buildPrincipalAccessList, resolveHierarchy } from "../access-lists.js";
20
24
  import type { PrincipalDisplayResolver } from "../access-lists.js";
21
25
  import { policyIdFor } from "../constants.js";
@@ -139,7 +143,7 @@ describe("buildPrincipalAccessList", () => {
139
143
  ),
140
144
  );
141
145
  expect(
142
- await buildPrincipalAccessList(store, nobody, [
146
+ await buildPrincipalAccessList(store, nobody, undefined, [
143
147
  { kind: "agent", id: "a1" },
144
148
  ]),
145
149
  ).toEqual([]);
@@ -150,7 +154,7 @@ describe("buildPrincipalAccessList", () => {
150
154
  await store.save(row(orgRole("ida_bob", "member", "acme")));
151
155
  await store.save(row(orgRole("ida_alice", "owner", "acme")));
152
156
  await store.save(row(orgRole("ida_bob", "viewer", "acme")));
153
- const entries = await buildPrincipalAccessList(store, nobody, [
157
+ const entries = await buildPrincipalAccessList(store, nobody, undefined, [
154
158
  { kind: "organization", id: "acme" },
155
159
  ]);
156
160
  expect(entries.map((e) => e.principal?.id)).toEqual([
@@ -167,7 +171,7 @@ describe("buildPrincipalAccessList", () => {
167
171
  it("marks grants from a parent level as inherited and names the owning level", async () => {
168
172
  const store = fakeIamPolicyStore();
169
173
  await store.save(row(orgRole("ida_alice", "admin", "acme")));
170
- const entries = await buildPrincipalAccessList(store, nobody, [
174
+ const entries = await buildPrincipalAccessList(store, nobody, undefined, [
171
175
  { kind: "agent", id: "a1" },
172
176
  { kind: "organization", id: "acme" },
173
177
  ]);
@@ -190,7 +194,7 @@ describe("buildPrincipalAccessList", () => {
190
194
  }),
191
195
  ),
192
196
  );
193
- const entries = await buildPrincipalAccessList(store, nobody, [
197
+ const entries = await buildPrincipalAccessList(store, nobody, undefined, [
194
198
  { kind: "organization", id: "acme" },
195
199
  ]);
196
200
  expect(
@@ -228,11 +232,121 @@ describe("buildPrincipalAccessList", () => {
228
232
  );
229
233
  },
230
234
  };
231
- const entries = await buildPrincipalAccessList(store, resolver, [
235
+ const entries = await buildPrincipalAccessList(store, resolver, undefined, [
232
236
  { kind: "organization", id: "acme" },
233
237
  ]);
234
238
  expect(asked).toEqual([["ida_alice", "ida_bob"]]);
235
239
  expect(entries.map((e) => e.principal?.name)).toEqual(["Alice", "ida_bob"]);
236
240
  expect(entries[0]?.principal?.email).toBe("alice@example.com");
237
241
  });
242
+
243
+ it("names a team grantee through the composed principal display, once per kind, and never lends one kind's view to another kind's id", async () => {
244
+ const store = fakeIamPolicyStore();
245
+ // A person and a team that share an id string: the views must not cross.
246
+ await store.save(row(orgRole("shared_1", "viewer", "acme")));
247
+ await store.save(
248
+ row(
249
+ triple({ kind: "team", id: "shared_1", relation: "member" }, "viewer", {
250
+ kind: "organization",
251
+ id: "acme",
252
+ }),
253
+ ),
254
+ );
255
+ await store.save(
256
+ row(
257
+ triple({ kind: "team", id: "tm_gone", relation: "member" }, "viewer", {
258
+ kind: "organization",
259
+ id: "acme",
260
+ }),
261
+ ),
262
+ );
263
+ const asked: Array<[ApiResourceKind, string[]]> = [];
264
+ const teams: PrincipalDisplay = {
265
+ resolve: (kind, ids) => {
266
+ asked.push([kind, [...ids]]);
267
+ return Promise.resolve(
268
+ new Map([
269
+ [
270
+ "shared_1",
271
+ create(ApiResourceRefViewSchema, {
272
+ kind: "team",
273
+ id: "shared_1",
274
+ name: "Site Reliability",
275
+ }),
276
+ ],
277
+ ]),
278
+ );
279
+ },
280
+ };
281
+ const entries = await buildPrincipalAccessList(store, nobody, teams, [
282
+ { kind: "organization", id: "acme" },
283
+ ]);
284
+ expect(asked).toEqual([[ApiResourceKind.team, ["shared_1", "tm_gone"]]]);
285
+ expect(entries.map((e) => [e.principal?.kind, e.principal?.name])).toEqual([
286
+ ["identity_account", "shared_1"],
287
+ ["team", "Site Reliability"],
288
+ // A team the display does not know (deleted since) keeps the fallback.
289
+ ["team", "tm_gone"],
290
+ ]);
291
+ });
292
+
293
+ it("names every grantee with the relation qualifier its rows hold, and never merges two qualifiers of one grantee", async () => {
294
+ const store = fakeIamPolicyStore();
295
+ const agent = { kind: "agent", id: "agt_1" };
296
+ await store.save(
297
+ row(
298
+ triple({ kind: "identity_account", id: "ida_alice" }, "viewer", agent),
299
+ ),
300
+ );
301
+ await store.save(
302
+ row(
303
+ triple(
304
+ { kind: "team", id: "tm_sre", relation: "member" },
305
+ "viewer",
306
+ agent,
307
+ ),
308
+ ),
309
+ );
310
+ // Not a shape the grant step admits today; the grouping must still
311
+ // keep a second qualifier apart so its revoke names the right row.
312
+ await store.save(
313
+ row(
314
+ triple(
315
+ { kind: "team", id: "tm_sre", relation: "maintainer" },
316
+ "viewer",
317
+ agent,
318
+ ),
319
+ ),
320
+ );
321
+ const teams: PrincipalDisplay = {
322
+ resolve: () =>
323
+ Promise.resolve(
324
+ new Map([
325
+ [
326
+ "tm_sre",
327
+ create(ApiResourceRefViewSchema, {
328
+ kind: "team",
329
+ id: "tm_sre",
330
+ name: "SRE",
331
+ }),
332
+ ],
333
+ ]),
334
+ ),
335
+ };
336
+ const entries = await buildPrincipalAccessList(store, nobody, teams, [
337
+ agent,
338
+ ]);
339
+ expect(
340
+ entries.map((e) => [
341
+ e.principal?.kind,
342
+ e.principal?.id,
343
+ e.principal?.relation,
344
+ e.principal?.name,
345
+ ]),
346
+ ).toEqual([
347
+ ["identity_account", "ida_alice", "", "ida_alice"],
348
+ ["team", "tm_sre", "member", "SRE"],
349
+ ["team", "tm_sre", "maintainer", "SRE"],
350
+ ]);
351
+ });
238
352
  });
@@ -15,7 +15,10 @@
15
15
  * and never appear here;
16
16
  * - the byte-pinned copy moved from the cloud's handlers as-is, plus the
17
17
  * new sentences (the two edition refusals, the unknown permission, the
18
- * unknown principal kind, the malformed triple);
18
+ * unknown principal kind, the malformed triple, the four principal
19
+ * refusals a team grantee brought);
20
+ * - `USER_GRANT_PRINCIPAL_KINDS` — a person and a team, and no kind the
21
+ * hierarchy walk would read as a structural parent;
19
22
  * - the canonical text's one weakness and its closure (slice 2 ruling
20
23
  * Q-S2-1): `ApiResourceRef` fields carry no character pattern, so an
21
24
  * id or relation holding `:`, `#` or `@` could spell another triple's
@@ -44,10 +47,14 @@ import {
44
47
  malformedTripleField,
45
48
  malformedTripleMessage,
46
49
  noGrantableRolesMessage,
50
+ personQualifierMessage,
47
51
  policyIdFor,
48
52
  policyNotFoundMessage,
49
53
  principalNotGrantableMessage,
50
54
  roleNotGrantableMessage,
55
+ teamNotGrantableMessage,
56
+ teamQualifierMessage,
57
+ teamRoleNotGrantableMessage,
51
58
  unknownPermissionMessage,
52
59
  unknownPrincipalKindMessage,
53
60
  unknownResourceKindMessage,
@@ -310,11 +317,11 @@ describe("BLUEPRINT_KINDS — the legacy-creator rule's scan (Q-OR-6b)", () => {
310
317
  });
311
318
 
312
319
  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", () => {
320
+ it("is exactly a person and a team of people — the one vocabulary the writer admits and the reader treats as no structural parent", () => {
314
321
  expect([...USER_GRANT_PRINCIPAL_KINDS]).toEqual([
315
322
  ApiResourceKind.identity_account,
323
+ ApiResourceKind.team,
316
324
  ]);
317
- expect(ApiResourceKind).not.toHaveProperty("team");
318
325
  });
319
326
 
320
327
  it("names no kind the hierarchy walk would read as a structural parent", () => {
@@ -370,7 +377,22 @@ describe("byte-pinned copy", () => {
370
377
 
371
378
  it("spells the principal refusal in the role sentence's shape, listing the grantable principal kinds", () => {
372
379
  expect(principalNotGrantableMessage("organization")).toBe(
373
- "Principal kind 'organization' cannot be granted a role. Grantable principal kinds: [identity_account]",
380
+ "Principal kind 'organization' cannot be granted a role. Grantable principal kinds: [identity_account, team]",
381
+ );
382
+ });
383
+
384
+ it("spells the four team-era principal refusals, each naming what to fix", () => {
385
+ expect(personQualifierMessage("member")).toBe(
386
+ "Principal kind 'identity_account' takes no relation; got 'member'",
387
+ );
388
+ expect(teamQualifierMessage("admin")).toBe(
389
+ "A team principal must name the relation 'member' (the team's members); got 'admin'",
390
+ );
391
+ expect(teamNotGrantableMessage("session")).toBe(
392
+ "A team cannot be granted access to resource kind 'session'.",
393
+ );
394
+ expect(teamRoleNotGrantableMessage("owner", "agent", ["viewer"])).toBe(
395
+ "Role 'owner' cannot be granted to a team on resource kind 'agent'. Roles a team can hold: [viewer]",
374
396
  );
375
397
  });
376
398
 
@@ -23,9 +23,12 @@
23
23
  * annotation-driven check; the wire-kind refusals on every lane;
24
24
  * - get loads before it authorizes (NOT_FOUND with no Authorizer call;
25
25
  * the row's kind and id as the target); delete authorizes before it
26
- * loads; create validates the role before it writes.
26
+ * loads; create validates the role before it writes, then runs the
27
+ * `iam-policy-create:pre-side-effect-gate` slot, whose refusal leaves
28
+ * no row; bootstrapPolicy never runs that slot.
27
29
  */
28
30
  import { create } from "@bufbuild/protobuf";
31
+ import type { DescMessage } from "@bufbuild/protobuf";
29
32
  import {
30
33
  Code,
31
34
  ConnectError,
@@ -59,6 +62,7 @@ import type {
59
62
  import type { CallerIdentity } from "../../../extensions/identity.js";
60
63
  import type { PolicyGrantScope } from "../../../extensions/policy-grant-scope.js";
61
64
  import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
65
+ import type { PipelineStep } from "../../../pipeline/pipeline.js";
62
66
  import {
63
67
  callerIdentityKey,
64
68
  trustedLocalIdentityFor,
@@ -186,6 +190,7 @@ async function harness(options: {
186
190
  engine?: boolean;
187
191
  edition?: ServerEdition;
188
192
  scope?: PolicyGrantScope;
193
+ createGate?: PipelineStep<DescMessage>;
189
194
  }): Promise<Harness> {
190
195
  const policies = fakeIamPolicyStore();
191
196
  const accounts = fakeIdentityAccountStore();
@@ -226,6 +231,17 @@ async function harness(options: {
226
231
  authorizer,
227
232
  grantScope: options.scope ?? newOrganizationOnlyGrantScope(),
228
233
  queries: engine,
234
+ principalDisplay: undefined,
235
+ gateSteps: new Map(
236
+ options.createGate === undefined
237
+ ? []
238
+ : [
239
+ [
240
+ "iam-policy-create:pre-side-effect-gate",
241
+ [options.createGate],
242
+ ],
243
+ ],
244
+ ),
229
245
  edition: options.edition ?? ServerEdition.oss,
230
246
  logger: silent,
231
247
  });
@@ -877,6 +893,44 @@ describe("the write lanes' order", () => {
877
893
  roleNotGrantableMessage("admin", "organization", ["viewer"]),
878
894
  );
879
895
  });
896
+
897
+ it("create runs the iam-policy-create gate slot after the role check and before the write; a refusing gate leaves no row", async () => {
898
+ const seen: string[] = [];
899
+ const refusingGate: PipelineStep<DescMessage> = {
900
+ name: "RefuseForTest",
901
+ execute() {
902
+ seen.push("gate");
903
+ throw new ConnectError("refused by the gate", Code.FailedPrecondition);
904
+ },
905
+ };
906
+ const h = await harness({ caller: alice, createGate: refusingGate });
907
+ // A role the step refuses never reaches the gate.
908
+ await refusal(() => h.command.create(orgRole(ALICE_ID, "editor", "acme")));
909
+ expect(seen).toEqual([]);
910
+ const error = await refusal(() =>
911
+ h.command.create(orgRole(ALICE_ID, "member", "acme")),
912
+ );
913
+ expect(error.code).toBe(Code.FailedPrecondition);
914
+ expect(error.rawMessage).toBe("refused by the gate");
915
+ expect(seen).toEqual(["gate"]);
916
+ expect(h.policies.rows.size).toBe(0);
917
+ });
918
+
919
+ it("bootstrapPolicy, the platform's structural lane, never runs the create gate slot", async () => {
920
+ const seen: string[] = [];
921
+ const h = await harness({
922
+ caller: internal,
923
+ createGate: {
924
+ name: "RecordForTest",
925
+ execute() {
926
+ seen.push("gate");
927
+ },
928
+ },
929
+ });
930
+ await h.command.bootstrapPolicy(orgRole(ALICE_ID, "member", "acme"));
931
+ expect(seen).toEqual([]);
932
+ expect(h.policies.rows.size).toBe(1);
933
+ });
880
934
  });
881
935
 
882
936
  describe("a wire kind string is refused BEFORE position 1 on every annotated lane (Q-S6-1)", () => {
@@ -272,7 +272,7 @@ describe("the one writer is the one gate: nothing is read, written or notified f
272
272
  "admin",
273
273
  { kind: "organisation", id: "acme" },
274
274
  );
275
- const unknownPrincipal = triple({ kind: "team", id: "tm_1" }, "viewer", {
275
+ const unknownPrincipal = triple({ kind: "group", id: "grp_1" }, "viewer", {
276
276
  kind: "agent",
277
277
  id: AGENT,
278
278
  });
@@ -280,7 +280,7 @@ describe("the one writer is the one gate: nothing is read, written or notified f
280
280
  kind: "organization",
281
281
  id: "acme#admin",
282
282
  });
283
- const bothUnknown = triple({ kind: "team", id: "tm_1" }, "admin", {
283
+ const bothUnknown = triple({ kind: "group", id: "grp_1" }, "admin", {
284
284
  kind: "organisation",
285
285
  id: "acme",
286
286
  });
@@ -290,7 +290,7 @@ describe("the one writer is the one gate: nothing is read, written or notified f
290
290
  const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
291
291
  const refusals: ReadonlyArray<[IamPolicySpec, string]> = [
292
292
  [unknownResource, unknownResourceKindMessage("organisation")],
293
- [unknownPrincipal, unknownPrincipalKindMessage("team")],
293
+ [unknownPrincipal, unknownPrincipalKindMessage("group")],
294
294
  [malformed, malformedTripleMessage("resource.id")],
295
295
  [bothUnknown, unknownResourceKindMessage("organisation")],
296
296
  ];
@@ -8,7 +8,10 @@
8
8
  * SCOPE lists none → UNIMPLEMENTED naming the editions that serve
9
9
  * per-resource grants; the role is not in proto ∩ scope → the cloud's
10
10
  * second copy listing the intersection in proto order. The intersection
11
- * is what keeps a scope from widening the contract by accident.
11
+ * is what keeps a scope from widening the contract by accident. A person
12
+ * names no relation qualifier; a team is granted only its kind's team
13
+ * roles (never ownership, never on a kind that lists none), always as its
14
+ * members, and nothing at all under open source's organization-only scope.
12
15
  *
13
16
  * Grant and Revoke are proven for the one thing a step adds over the
14
17
  * grant path: the result they leave under POLICY_RESULT_KEY (the revoke
@@ -30,9 +33,13 @@ import { RequestContext } from "../../../pipeline/request-context.js";
30
33
  import {
31
34
  PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE,
32
35
  noGrantableRolesMessage,
36
+ personQualifierMessage,
33
37
  policyIdFor,
34
38
  principalNotGrantableMessage,
35
39
  roleNotGrantableMessage,
40
+ teamNotGrantableMessage,
41
+ teamQualifierMessage,
42
+ teamRoleNotGrantableMessage,
36
43
  unknownResourceKindMessage,
37
44
  } from "../constants.js";
38
45
  import { newIamPolicyGrantPath } from "../grant-path.js";
@@ -190,6 +197,121 @@ describe("ValidateGrantableRole", () => {
190
197
  );
191
198
  });
192
199
 
200
+ it("a person names no relation qualifier: identity_account:<id>#<anything> names nobody and would be a tuple OpenFGA refuses", async () => {
201
+ const error = await refusal(() =>
202
+ organizationOnly.execute(
203
+ contextOf(
204
+ triple(
205
+ { kind: "identity_account", id: "ida_bob", relation: "member" },
206
+ "viewer",
207
+ { kind: "organization", id: "acme" },
208
+ ),
209
+ ),
210
+ ),
211
+ );
212
+ expect(error.code).toBe(Code.InvalidArgument);
213
+ expect(error.rawMessage).toBe(personQualifierMessage("member"));
214
+ });
215
+
216
+ describe("a team principal", () => {
217
+ const team = { kind: "team", id: "tm_sre", relation: "member" };
218
+ const perResource = newValidateGrantableRoleStep(
219
+ scopeOf(
220
+ new Map([
221
+ [ApiResourceKind.agent, [IamRole.owner, IamRole.viewer]],
222
+ [
223
+ ApiResourceKind.agent_channel,
224
+ [IamRole.owner, IamRole.viewer, IamRole.participant],
225
+ ],
226
+ [ApiResourceKind.session, [IamRole.owner, IamRole.viewer]],
227
+ [
228
+ ApiResourceKind.organization,
229
+ [IamRole.owner, IamRole.admin, IamRole.member, IamRole.viewer],
230
+ ],
231
+ ]),
232
+ ),
233
+ );
234
+
235
+ it("is granted a role its kind's team_grantable_roles lists, as the team's members", () => {
236
+ expect(
237
+ perResource.execute(
238
+ contextOf(triple(team, "viewer", { kind: "agent", id: "agt_1" })),
239
+ ),
240
+ ).toBeUndefined();
241
+ expect(
242
+ perResource.execute(
243
+ contextOf(
244
+ triple(team, "participant", {
245
+ kind: "agent_channel",
246
+ id: "ach_1",
247
+ }),
248
+ ),
249
+ ),
250
+ ).toBeUndefined();
251
+ });
252
+
253
+ it("is never granted ownership, though a person could be: a team holds only its kind's team roles", async () => {
254
+ const error = await refusal(() =>
255
+ perResource.execute(
256
+ contextOf(triple(team, "owner", { kind: "agent", id: "agt_1" })),
257
+ ),
258
+ );
259
+ expect(error.code).toBe(Code.InvalidArgument);
260
+ expect(error.rawMessage).toBe(
261
+ teamRoleNotGrantableMessage("owner", "agent", ["viewer"]),
262
+ );
263
+ });
264
+
265
+ it("is refused on a kind that lists no team role — a session stays personal, an organization role is no team's", async () => {
266
+ for (const [kind, id, role] of [
267
+ ["session", "ses_1", "viewer"],
268
+ ["organization", "acme", "member"],
269
+ ] as const) {
270
+ const error = await refusal(() =>
271
+ perResource.execute(contextOf(triple(team, role, { kind, id }))),
272
+ );
273
+ expect(error.code, kind).toBe(Code.InvalidArgument);
274
+ expect(error.rawMessage, kind).toBe(teamNotGrantableMessage(kind));
275
+ }
276
+ });
277
+
278
+ it("must name the team's members as its relation", async () => {
279
+ for (const relation of ["", "admin"]) {
280
+ const error = await refusal(() =>
281
+ perResource.execute(
282
+ contextOf(
283
+ triple({ ...team, relation }, "viewer", {
284
+ kind: "agent",
285
+ id: "agt_1",
286
+ }),
287
+ ),
288
+ ),
289
+ );
290
+ expect(error.code, relation).toBe(Code.InvalidArgument);
291
+ expect(error.rawMessage, relation).toBe(teamQualifierMessage(relation));
292
+ }
293
+ });
294
+
295
+ it("is granted nothing under open source's organization-only scope: per-resource grants are UNIMPLEMENTED, and the organization lists no team role", async () => {
296
+ const onAgent = await refusal(() =>
297
+ organizationOnly.execute(
298
+ contextOf(triple(team, "viewer", { kind: "agent", id: "agt_1" })),
299
+ ),
300
+ );
301
+ expect(onAgent.code).toBe(Code.Unimplemented);
302
+ const onOrganization = await refusal(() =>
303
+ organizationOnly.execute(
304
+ contextOf(
305
+ triple(team, "member", { kind: "organization", id: "acme" }),
306
+ ),
307
+ ),
308
+ );
309
+ expect(onOrganization.rawMessage).toBe(
310
+ teamNotGrantableMessage("organization"),
311
+ );
312
+ });
313
+ });
314
+
193
315
  it("a scope can only NARROW the proto: a role it adds is clipped from the list", async () => {
194
316
  const widening = newValidateGrantableRoleStep(
195
317
  scopeOf(
@@ -17,12 +17,20 @@
17
17
  *
18
18
  * Display enrichment is a seam (`PrincipalDisplayResolver`) because the
19
19
  * data lives in the identity-account domain; display-resolver.ts fills it
20
- * over that domain's PORT. A principal the resolver does not answer for —
21
- * any kind that is not an identity account, or an account whose row is
22
- * gone — renders as kind/id with the id standing in as the display name,
23
- * the Java `enrich()` fallback shape.
20
+ * over that domain's PORT. A grantee of any other kind (a team) is named by
21
+ * the edition's `drivers.principalDisplay` when one is composed
22
+ * (extensions/principal-display.ts). A principal nobody answers for — no
23
+ * driver, or an account or team whose row is gone — renders as kind/id
24
+ * with the id standing in as the display name, the Java `enrich()`
25
+ * fallback shape.
26
+ *
27
+ * Every entry names its grantee exactly as the rows hold it: kind, id AND
28
+ * the relation qualifier (`team:<id>#member`), and entries group by all
29
+ * three. A revoke matches the qualifier exactly (grant-path.ts
30
+ * `findByTriple`), so a list that dropped it would hand the console a
31
+ * grantee whose revoke finds no row and succeeds having deleted nothing.
24
32
  */
25
- import { create } from "@bufbuild/protobuf";
33
+ import { clone, create } from "@bufbuild/protobuf";
26
34
 
27
35
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
28
36
  import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
@@ -38,7 +46,11 @@ import {
38
46
  } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
39
47
  import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
40
48
 
41
- import { kindEnumName } from "../../pipeline/apiresource-meta.js";
49
+ import type { PrincipalDisplay } from "../../extensions/principal-display.js";
50
+ import {
51
+ kindByEnumName,
52
+ kindEnumName,
53
+ } from "../../pipeline/apiresource-meta.js";
42
54
  import { assignableRelations, roleInfoFromRelation } from "./roles.js";
43
55
  import type { IamPolicyStore } from "./store.js";
44
56
 
@@ -100,12 +112,14 @@ export interface PrincipalDisplayResolver {
100
112
 
101
113
  /**
102
114
  * Groups assignable-role policies by principal in first-seen order (the
103
- * Java LinkedHashMap contract), enriched where the resolver answers and
104
- * the Java fallback shape (name = id) everywhere else.
115
+ * Java LinkedHashMap contract), enriched where the resolver (people) or
116
+ * the composed principal display (every other kind) answers, and the Java
117
+ * fallback shape (name = id) everywhere else.
105
118
  */
106
119
  export async function buildPrincipalAccessList(
107
120
  policies: IamPolicyStore,
108
121
  displayResolver: PrincipalDisplayResolver,
122
+ principalDisplay: PrincipalDisplay | undefined,
109
123
  hierarchy: ReadonlyArray<HierarchyLevel>,
110
124
  ): Promise<ReadonlyArray<PrincipalAccess>> {
111
125
  interface GrantAtLevel {
@@ -128,11 +142,11 @@ export async function buildPrincipalAccessList(
128
142
  return [];
129
143
  }
130
144
 
131
- const accountKind = kindEnumName(ApiResourceKind.identity_account);
132
- const accountIds = grants
133
- .filter((g) => g.policy.spec?.principal?.kind === accountKind)
134
- .map((g) => g.policy.spec?.principal?.id ?? "");
135
- const enriched = await displayResolver.resolveIdentityAccounts(accountIds);
145
+ const enriched = await resolvePrincipalViews(
146
+ grants.map((g) => g.policy),
147
+ displayResolver,
148
+ principalDisplay,
149
+ );
136
150
 
137
151
  const grouped = new Map<
138
152
  string,
@@ -143,19 +157,24 @@ export async function buildPrincipalAccessList(
143
157
  if (principal === undefined) {
144
158
  continue;
145
159
  }
146
- const key = `${principal.kind}:${principal.id}`;
160
+ const key = `${principal.kind}:${principal.id}#${principal.relation}`;
147
161
  let entry = grouped.get(key);
148
162
  if (entry === undefined) {
163
+ const view = enriched.get(`${principal.kind}:${principal.id}`);
149
164
  entry = {
150
- // The Java enrich() fallback shape for unresolved principals of
151
- // ANY kind: kind/id with the id standing in as the display name.
152
- principal:
153
- enriched.get(principal.id) ??
154
- create(ApiResourceRefViewSchema, {
155
- kind: principal.kind,
156
- id: principal.id,
157
- name: principal.id,
158
- }),
165
+ // A resolved view is shared by every qualifier of one grantee, so
166
+ // each entry stamps its own copy. The Java enrich() fallback shape
167
+ // for unresolved principals of ANY kind: kind/id with the id
168
+ // standing in as the display name.
169
+ principal: withRelation(
170
+ view ??
171
+ create(ApiResourceRefViewSchema, {
172
+ kind: principal.kind,
173
+ id: principal.id,
174
+ name: principal.id,
175
+ }),
176
+ principal.relation,
177
+ ),
159
178
  roles: [],
160
179
  };
161
180
  grouped.set(key, entry);
@@ -171,6 +190,62 @@ export async function buildPrincipalAccessList(
171
190
  );
172
191
  }
173
192
 
193
+ /**
194
+ * The display views of every distinct principal the rows name, keyed
195
+ * `kind:id` (the grouping key, so two kinds can never share an id's view):
196
+ * people through the identity-account resolver, every other kind the
197
+ * edition's principal display knows through it, one call per kind. A kind
198
+ * string that names no kind (a legacy row) and every kind with no display
199
+ * are simply absent, so the caller's fallback shape names them.
200
+ */
201
+ async function resolvePrincipalViews(
202
+ rows: ReadonlyArray<IamPolicy>,
203
+ displayResolver: PrincipalDisplayResolver,
204
+ principalDisplay: PrincipalDisplay | undefined,
205
+ ): Promise<ReadonlyMap<string, ApiResourceRefView>> {
206
+ const accountKind = kindEnumName(ApiResourceKind.identity_account);
207
+ const idsByKind = new Map<string, Set<string>>();
208
+ for (const row of rows) {
209
+ const principal = row.spec?.principal;
210
+ if (principal === undefined) {
211
+ continue;
212
+ }
213
+ const ids = idsByKind.get(principal.kind) ?? new Set<string>();
214
+ ids.add(principal.id);
215
+ idsByKind.set(principal.kind, ids);
216
+ }
217
+
218
+ const views = new Map<string, ApiResourceRefView>();
219
+ for (const [kindName, ids] of idsByKind) {
220
+ let resolved: ReadonlyMap<string, ApiResourceRefView>;
221
+ if (kindName === accountKind) {
222
+ resolved = await displayResolver.resolveIdentityAccounts([...ids]);
223
+ } else {
224
+ const kind = kindByEnumName(kindName);
225
+ if (
226
+ principalDisplay === undefined ||
227
+ kind === ApiResourceKind.api_resource_kind_unknown
228
+ ) {
229
+ continue;
230
+ }
231
+ resolved = await principalDisplay.resolve(kind, [...ids]);
232
+ }
233
+ for (const [id, view] of resolved) {
234
+ views.set(`${kindName}:${id}`, view);
235
+ }
236
+ }
237
+ return views;
238
+ }
239
+
240
+ function withRelation(
241
+ view: ApiResourceRefView,
242
+ relation: string,
243
+ ): ApiResourceRefView {
244
+ const stamped = clone(ApiResourceRefViewSchema, view);
245
+ stamped.relation = relation;
246
+ return stamped;
247
+ }
248
+
174
249
  function buildGrant(
175
250
  policy: IamPolicy,
176
251
  isInherited: boolean,