@stigmer/server 3.30.0 → 3.32.0

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 (111) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization/model/data/authorization-model.json +16 -46
  3. package/dist/boot/compose.d.ts.map +1 -1
  4. package/dist/boot/compose.js +8 -0
  5. package/dist/boot/compose.js.map +1 -1
  6. package/dist/boot/config.d.ts +10 -0
  7. package/dist/boot/config.d.ts.map +1 -1
  8. package/dist/boot/config.js +2 -0
  9. package/dist/boot/config.js.map +1 -1
  10. package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
  11. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  12. package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
  13. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  14. package/dist/domain/iampolicy/constants.d.ts +11 -1
  15. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  16. package/dist/domain/iampolicy/constants.js +11 -1
  17. package/dist/domain/iampolicy/constants.js.map +1 -1
  18. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  19. package/dist/domain/iampolicy/controller.js +36 -12
  20. package/dist/domain/iampolicy/controller.js.map +1 -1
  21. package/dist/domain/iampolicy/steps.d.ts +98 -0
  22. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  23. package/dist/domain/iampolicy/steps.js +119 -4
  24. package/dist/domain/iampolicy/steps.js.map +1 -1
  25. package/dist/domain/oauthapp/controller.d.ts +16 -8
  26. package/dist/domain/oauthapp/controller.d.ts.map +1 -1
  27. package/dist/domain/oauthapp/controller.js +28 -11
  28. package/dist/domain/oauthapp/controller.js.map +1 -1
  29. package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
  30. package/dist/domain/oauthapp/refresolution.js +3 -2
  31. package/dist/domain/oauthapp/refresolution.js.map +1 -1
  32. package/dist/domain/oauthapp/steps.d.ts +5 -5
  33. package/dist/domain/oauthapp/steps.js +6 -6
  34. package/dist/domain/oauthapp/steps.js.map +1 -1
  35. package/dist/domain/platformclient/constants.d.ts +11 -1
  36. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  37. package/dist/domain/platformclient/constants.js +11 -1
  38. package/dist/domain/platformclient/constants.js.map +1 -1
  39. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  40. package/dist/domain/platformclient/verifier.js +7 -2
  41. package/dist/domain/platformclient/verifier.js.map +1 -1
  42. package/dist/extensions/identity.d.ts +14 -0
  43. package/dist/extensions/identity.d.ts.map +1 -1
  44. package/dist/extensions/identity.js.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/pipeline/steps/defaults.d.ts +5 -1
  50. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  51. package/dist/pipeline/steps/defaults.js +6 -1
  52. package/dist/pipeline/steps/defaults.js.map +1 -1
  53. package/dist/sandbox/docker.d.ts.map +1 -1
  54. package/dist/sandbox/docker.js +13 -5
  55. package/dist/sandbox/docker.js.map +1 -1
  56. package/dist/sandbox/kubernetes.d.ts +1 -1
  57. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  58. package/dist/sandbox/kubernetes.js +18 -4
  59. package/dist/sandbox/kubernetes.js.map +1 -1
  60. package/dist/sandbox/local-process.d.ts.map +1 -1
  61. package/dist/sandbox/local-process.js +8 -1
  62. package/dist/sandbox/local-process.js.map +1 -1
  63. package/dist/sandbox/provisioner.d.ts +12 -0
  64. package/dist/sandbox/provisioner.d.ts.map +1 -1
  65. package/dist/sandbox/provisioner.js.map +1 -1
  66. package/dist/temporal/manager.d.ts +10 -0
  67. package/dist/temporal/manager.d.ts.map +1 -1
  68. package/dist/temporal/manager.js +10 -0
  69. package/dist/temporal/manager.js.map +1 -1
  70. package/package.json +6 -6
  71. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  72. package/src/authorization/model/data/authorization-model.json +16 -46
  73. package/src/boot/__tests__/config.test.ts +23 -1
  74. package/src/boot/compose.ts +8 -0
  75. package/src/boot/config.ts +13 -0
  76. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  77. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  78. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  79. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  80. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  81. package/src/domain/iampolicy/constants.ts +15 -1
  82. package/src/domain/iampolicy/controller.ts +43 -9
  83. package/src/domain/iampolicy/steps.ts +164 -4
  84. package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
  85. package/src/domain/oauthapp/controller.ts +51 -18
  86. package/src/domain/oauthapp/refresolution.ts +3 -2
  87. package/src/domain/oauthapp/steps.ts +6 -6
  88. package/src/domain/platformclient/__tests__/mint.test.ts +7 -1
  89. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  90. package/src/domain/platformclient/constants.ts +11 -1
  91. package/src/domain/platformclient/verifier.ts +7 -2
  92. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  93. package/src/extensions/identity.ts +14 -0
  94. package/src/index.ts +5 -0
  95. package/src/pipeline/__tests__/steps.test.ts +72 -1
  96. package/src/pipeline/steps/defaults.ts +6 -1
  97. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  98. package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
  99. package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
  100. package/src/sandbox/__tests__/provisioner.test.ts +2 -0
  101. package/src/sandbox/docker.ts +11 -5
  102. package/src/sandbox/kubernetes.ts +18 -3
  103. package/src/sandbox/local-process.ts +11 -1
  104. package/src/sandbox/provisioner.ts +12 -0
  105. package/src/temporal/__tests__/manager.test.ts +59 -1
  106. package/src/temporal/manager.ts +13 -0
  107. package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
  108. package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
  109. package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
  110. package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
  111. package/src/domain/oauthapp/refresolution/refresolution.ts +0 -94
@@ -16,6 +16,14 @@
16
16
  * Grant and Revoke are proven for the one thing a step adds over the
17
17
  * grant path: the result they leave under POLICY_RESULT_KEY (the revoke
18
18
  * of an absent triple leaves the default instance, Java's contract).
19
+ *
20
+ * The two owner steps: a change that grants, revokes or removes an
21
+ * organization's owner role asks `can_assign_roles` there and refuses with
22
+ * the owner copy when it is denied; any other role, any other kind and the
23
+ * `internal` caller ask nothing; `revokeOrgAccess` asks exactly when the
24
+ * account holds owner; and the last owner is never revoked or removed,
25
+ * while one of two may be. Both read the organization's owners once per
26
+ * request.
19
27
  */
20
28
  import { Code, ConnectError } from "@connectrpc/connect";
21
29
  import { describe, expect, it } from "vitest";
@@ -26,11 +34,20 @@ import { IamPolicySpecSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1
26
34
  import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
27
35
  import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
28
36
 
37
+ import { create } from "@bufbuild/protobuf";
38
+
39
+ import { RevokeOrgAccessInputSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
40
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
41
+
29
42
  import { createLogger } from "../../../boot/logger.js";
43
+ import type { Authorizer, AuthzCheck } from "../../../extensions/authorizer.js";
44
+ import type { CallerIdentity } from "../../../extensions/identity.js";
30
45
  import type { PolicyGrantScope } from "../../../extensions/policy-grant-scope.js";
31
46
  import { trustedLocalIdentityFor } from "../../../pipeline/interceptors/auth.js";
32
47
  import { RequestContext } from "../../../pipeline/request-context.js";
33
48
  import {
49
+ LAST_OWNER_MESSAGE,
50
+ OWNER_ASSIGNMENT_DENIED_MESSAGE,
34
51
  PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE,
35
52
  noGrantableRolesMessage,
36
53
  personQualifierMessage,
@@ -46,9 +63,13 @@ import { newIamPolicyGrantPath } from "../grant-path.js";
46
63
  import { newOrganizationOnlyGrantScope } from "../grant-scope.js";
47
64
  import {
48
65
  POLICY_RESULT_KEY,
66
+ newAuthorizeOwnerAssignmentStep,
49
67
  newGrantStep,
68
+ newKeepOneOwnerStep,
50
69
  newRevokeStep,
51
70
  newValidateGrantableRoleStep,
71
+ orgAccessOwnerRoleChange,
72
+ specOwnerRoleChange,
52
73
  } from "../steps.js";
53
74
  import { fakeIamPolicyStore, orgRole, triple } from "./support.js";
54
75
 
@@ -398,3 +419,161 @@ describe("Grant and Revoke leave their result under POLICY_RESULT_KEY", () => {
398
419
  ).toBe("");
399
420
  });
400
421
  });
422
+
423
+ describe("owner is assigned by owners", () => {
424
+ const admin: CallerIdentity = { ...caller, identityId: "ida_ana", callerClass: "user" };
425
+ const internal: CallerIdentity = { ...caller, callerClass: "internal" };
426
+
427
+ /** An Authorizer that records every question and answers `can_assign_roles` as told. */
428
+ function authorizer(ownsAcme: boolean): Authorizer & { readonly asked: AuthzCheck[] } {
429
+ const asked: AuthzCheck[] = [];
430
+ return {
431
+ asked,
432
+ authorize: (_identity, check) => {
433
+ asked.push(check);
434
+ return Promise.resolve(
435
+ ownsAcme ? { kind: "allow" } : { kind: "deny", reason: "" },
436
+ );
437
+ },
438
+ };
439
+ }
440
+
441
+ const ASSIGN_ROLES_ON_ACME: AuthzCheck = {
442
+ permission: IamPermission.can_assign_roles,
443
+ resourceKind: ApiResourceKind.organization,
444
+ resourceId: "acme",
445
+ };
446
+
447
+ function specContext(
448
+ spec: IamPolicySpec,
449
+ identity: CallerIdentity = admin,
450
+ ): RequestContext<typeof IamPolicySpecSchema> {
451
+ return new RequestContext(IamPolicySpecSchema, spec, identity, ApiResourceKind.iam_policy);
452
+ }
453
+
454
+ function removalContext(
455
+ accountId: string,
456
+ identity: CallerIdentity = admin,
457
+ ): RequestContext<typeof RevokeOrgAccessInputSchema> {
458
+ return new RequestContext(
459
+ RevokeOrgAccessInputSchema,
460
+ create(RevokeOrgAccessInputSchema, { identityAccountId: accountId, organizationId: "acme" }),
461
+ identity,
462
+ ApiResourceKind.iam_policy,
463
+ );
464
+ }
465
+
466
+ /** A store holding acme's roles, the owners' read counted. */
467
+ async function storeWith(
468
+ roles: ReadonlyArray<readonly [string, string]>,
469
+ ): Promise<ReturnType<typeof fakeIamPolicyStore> & { readonly ownerReads: () => number }> {
470
+ const store = fakeIamPolicyStore();
471
+ const path = newIamPolicyGrantPath({ policies: store, lifecycle: undefined, logger: silent });
472
+ for (const [account, role] of roles) {
473
+ await path.grant(orgRole(account, role, "acme"), caller);
474
+ }
475
+ let reads = 0;
476
+ const read = store.findByResourceWithRelations.bind(store);
477
+ return Object.assign(store, {
478
+ findByResourceWithRelations: (...args: Parameters<typeof read>) => {
479
+ reads += 1;
480
+ return read(...args);
481
+ },
482
+ ownerReads: () => reads,
483
+ });
484
+ }
485
+
486
+ describe("AuthorizeOwnerAssignment", () => {
487
+ it("refuses an admin granting or revoking owner on an organization, with the owner copy", async () => {
488
+ const denied = authorizer(false);
489
+ const step = newAuthorizeOwnerAssignmentStep(denied, specOwnerRoleChange);
490
+ const error = await refusal(() => step.execute(specContext(orgRole("ida_bob", "owner", "acme"))));
491
+ expect(error.code).toBe(Code.PermissionDenied);
492
+ expect(error.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
493
+ expect(denied.asked).toEqual([ASSIGN_ROLES_ON_ACME]);
494
+ });
495
+
496
+ it("lets an owner grant owner", async () => {
497
+ const allowed = authorizer(true);
498
+ await newAuthorizeOwnerAssignmentStep(allowed, specOwnerRoleChange).execute(
499
+ specContext(orgRole("ida_bob", "owner", "acme")),
500
+ );
501
+ expect(allowed.asked).toEqual([ASSIGN_ROLES_ON_ACME]);
502
+ });
503
+
504
+ it("asks nothing for a role below owner, for owner on another kind, or for the internal caller", async () => {
505
+ const denied = authorizer(false);
506
+ const step = newAuthorizeOwnerAssignmentStep(denied, specOwnerRoleChange);
507
+ await step.execute(specContext(orgRole("ida_bob", "admin", "acme")));
508
+ await step.execute(
509
+ specContext(triple({ kind: "identity_account", id: "ida_bob" }, "owner", { kind: "agent", id: "agt_1" })),
510
+ );
511
+ await step.execute(specContext(orgRole("ida_bob", "owner", "acme"), internal));
512
+ expect(denied.asked).toEqual([]);
513
+ });
514
+
515
+ it("asks when revokeOrgAccess removes an owner, and not when it removes anyone else", async () => {
516
+ const store = await storeWith([["ida_root", "owner"], ["ida_dave", "member"]]);
517
+ const denied = authorizer(false);
518
+ const step = newAuthorizeOwnerAssignmentStep(denied, orgAccessOwnerRoleChange(store));
519
+
520
+ await step.execute(removalContext("ida_dave"));
521
+ expect(denied.asked).toEqual([]);
522
+
523
+ const error = await refusal(() => step.execute(removalContext("ida_root")));
524
+ expect(error.code).toBe(Code.PermissionDenied);
525
+ expect(error.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
526
+ expect(denied.asked).toEqual([ASSIGN_ROLES_ON_ACME]);
527
+ });
528
+ });
529
+
530
+ describe("KeepOneOwner", () => {
531
+ it("refuses revoking the last owner's role, and lets one of two go", async () => {
532
+ const sole = await storeWith([["ida_root", "owner"]]);
533
+ const error = await refusal(() =>
534
+ newKeepOneOwnerStep(sole, specOwnerRoleChange).execute(
535
+ specContext(orgRole("ida_root", "owner", "acme")),
536
+ ),
537
+ );
538
+ expect(error.code).toBe(Code.FailedPrecondition);
539
+ expect(error.rawMessage).toBe(LAST_OWNER_MESSAGE);
540
+
541
+ const two = await storeWith([["ida_root", "owner"], ["ida_rhea", "owner"]]);
542
+ await newKeepOneOwnerStep(two, specOwnerRoleChange).execute(
543
+ specContext(orgRole("ida_root", "owner", "acme")),
544
+ );
545
+ });
546
+
547
+ it("lets a revoke through when the account does not hold owner, whoever the last owner is", async () => {
548
+ const store = await storeWith([["ida_root", "owner"]]);
549
+ await newKeepOneOwnerStep(store, specOwnerRoleChange).execute(
550
+ specContext(orgRole("ida_bob", "owner", "acme")),
551
+ );
552
+ });
553
+
554
+ it("refuses removing the last owner from the organization, and lets any other member go", async () => {
555
+ const store = await storeWith([["ida_root", "owner"], ["ida_dave", "member"]]);
556
+ const step = newKeepOneOwnerStep(store, orgAccessOwnerRoleChange(store));
557
+ await step.execute(removalContext("ida_dave"));
558
+ const error = await refusal(() => step.execute(removalContext("ida_root")));
559
+ expect(error.code).toBe(Code.FailedPrecondition);
560
+ expect(error.rawMessage).toBe(LAST_OWNER_MESSAGE);
561
+ });
562
+
563
+ it("leaves the internal caller alone", async () => {
564
+ const store = await storeWith([["ida_root", "owner"]]);
565
+ await newKeepOneOwnerStep(store, orgAccessOwnerRoleChange(store)).execute(
566
+ removalContext("ida_root", internal),
567
+ );
568
+ });
569
+ });
570
+
571
+ it("reads the organization's owners once for both steps of one request", async () => {
572
+ const store = await storeWith([["ida_root", "owner"], ["ida_rhea", "owner"]]);
573
+ const change = orgAccessOwnerRoleChange(store);
574
+ const ctx = removalContext("ida_root");
575
+ await newAuthorizeOwnerAssignmentStep(authorizer(true), change).execute(ctx);
576
+ await newKeepOneOwnerStep(store, change).execute(ctx);
577
+ expect(store.ownerReads()).toBe(1);
578
+ });
579
+ });
@@ -38,7 +38,8 @@
38
38
  * unknown permission, the unknown principal kind (the resource sentence's
39
39
  * shape), the malformed triple, and the four principal refusals a team
40
40
  * grantee brought (a person's qualifier, a team's qualifier, a kind no
41
- * team may be granted, a role a team may not hold).
41
+ * team may be granted, a role a team may not hold), and the two owner
42
+ * refusals (a non-owner touching the owner role, the last owner's removal).
42
43
  */
43
44
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
44
45
  import type {
@@ -297,3 +298,16 @@ export function teamRoleNotGrantableMessage(
297
298
  export function malformedTripleMessage(field: string): string {
298
299
  return `policy ${field} must not contain ':', '#' or '@'`;
299
300
  }
301
+
302
+ /**
303
+ * Granting, revoking or removing the owner role on an organization without
304
+ * `can_assign_roles` there (PERMISSION_DENIED). An admin grants every role
305
+ * up to admin; only an owner makes, or unmakes, an owner. An edition that
306
+ * serves invitations answers the same sentence for an owner invitation.
307
+ */
308
+ export const OWNER_ASSIGNMENT_DENIED_MESSAGE =
309
+ "only an owner of the organization can grant, revoke or remove the owner role";
310
+
311
+ /** Revoking or removing the last owner of an organization (FAILED_PRECONDITION). */
312
+ export const LAST_OWNER_MESSAGE =
313
+ "an organization must keep at least one owner; make another member an owner first";
@@ -13,7 +13,9 @@
13
13
  * in-process RPCs (Q-OR-1), never through an exported constructor.
14
14
  *
15
15
  * Shape (Q-S5-1). The six command RPCs are pipeline chains, each
16
- * `Authorize → ValidateProto → <one domain step>` (steps.ts): the write is
16
+ * `Authorize → ValidateProto → <one domain step>` (steps.ts), with the two
17
+ * owner steps before the write on `create`, `delete` and `revokeOrgAccess`
18
+ * (owner is assigned by owners, steps.ts's last section): the write is
17
19
  * the grant path's, the annotation drives position 1, and the executor's
18
20
  * fault mapping covers a store fault from inside the path. The eight
19
21
  * query RPCs are direct handlers over `authorizeDirect` (the cloud's
@@ -181,11 +183,15 @@ import {
181
183
  } from "./roles.js";
182
184
  import {
183
185
  POLICY_RESULT_KEY,
186
+ newAuthorizeOwnerAssignmentStep,
184
187
  newCleanupResourceStep,
185
188
  newGrantStep,
189
+ newKeepOneOwnerStep,
186
190
  newRevokeOrgAccessStep,
187
191
  newRevokeStep,
188
192
  newValidateGrantableRoleStep,
193
+ orgAccessOwnerRoleChange,
194
+ specOwnerRoleChange,
189
195
  } from "./steps.js";
190
196
  import type { IamPolicyStore } from "./store.js";
191
197
  import {
@@ -243,7 +249,7 @@ export function registerIamPolicyServices(
243
249
  return cleanupResourcePolicies(deps, ref, ctx);
244
250
  },
245
251
  revokeOrgAccess: (input, ctx) =>
246
- revokeOrgAccess(deps, command.revokeOrgAccess, input, ctx),
252
+ revokeOrgAccess(deps, command.revokeOrgAccess, input, ctx, true),
247
253
  bootstrapRevokeOrgAccess: (input, ctx) => {
248
254
  guardSystemRpc(command.bootstrapRevokeOrgAccess, callerIdentityOf(ctx));
249
255
  return revokeOrgAccess(
@@ -251,6 +257,7 @@ export function registerIamPolicyServices(
251
257
  command.bootstrapRevokeOrgAccess,
252
258
  input,
253
259
  ctx,
260
+ false,
254
261
  );
255
262
  },
256
263
  });
@@ -296,7 +303,8 @@ function guardSystemRpc(method: DescMethod, caller: CallerIdentity): void {
296
303
 
297
304
  /**
298
305
  * `create`: the wire refusal, then Authorize → ValidateProto →
299
- * ValidateGrantableRole → [iam-policy-create:pre-side-effect-gate] → Grant.
306
+ * ValidateGrantableRole → AuthorizeOwnerAssignment →
307
+ * [iam-policy-create:pre-side-effect-gate] → Grant.
300
308
  */
301
309
  function createPolicy(
302
310
  deps: IamPolicyControllerDeps,
@@ -315,8 +323,9 @@ function createPolicy(
315
323
 
316
324
  /**
317
325
  * The grant chain both `create` and `bootstrapPolicy` run; only the user
318
- * lane validates the role and runs the create gate slot — bootstrap writes
319
- * structural relations no scope would admit and no edition gates.
326
+ * lane validates the role, asks for an owner's authority when it grants
327
+ * owner, and runs the create gate slot — bootstrap writes structural
328
+ * relations no scope would admit and no edition gates.
320
329
  */
321
330
  async function grantThroughChain(
322
331
  deps: IamPolicyControllerDeps,
@@ -339,6 +348,9 @@ async function grantThroughChain(
339
348
  .addStep(newValidateProtoStep());
340
349
  if (validateRole) {
341
350
  pipeline.addStep(newValidateGrantableRoleStep(deps.grantScope));
351
+ pipeline.addStep(
352
+ newAuthorizeOwnerAssignmentStep(deps.authorizer, specOwnerRoleChange),
353
+ );
342
354
  for (const step of stepsForSlot<typeof IamPolicySpecSchema>(
343
355
  deps.gateSteps,
344
356
  "iam-policy-create:pre-side-effect-gate",
@@ -350,7 +362,11 @@ async function grantThroughChain(
350
362
  return policyResultOf(reqCtx, method);
351
363
  }
352
364
 
353
- /** `delete`: the wire refusal, then Authorize → ValidateProto → Revoke; the revoked row, or the default instance. */
365
+ /**
366
+ * `delete`: the wire refusal, then Authorize → ValidateProto →
367
+ * AuthorizeOwnerAssignment → KeepOneOwner → Revoke; the revoked row, or the
368
+ * default instance.
369
+ */
354
370
  async function deletePolicy(
355
371
  deps: IamPolicyControllerDeps,
356
372
  spec: IamPolicySpec,
@@ -367,18 +383,29 @@ async function deletePolicy(
367
383
  await newPipeline<typeof IamPolicySpecSchema>("iampolicy-delete", deps.logger)
368
384
  .addStep(newAuthorizeStep(method, deps.authorizer))
369
385
  .addStep(newValidateProtoStep())
386
+ .addStep(
387
+ newAuthorizeOwnerAssignmentStep(deps.authorizer, specOwnerRoleChange),
388
+ )
389
+ .addStep(newKeepOneOwnerStep(deps.policies, specOwnerRoleChange))
370
390
  .addStep(newRevokeStep(deps.grantPath))
371
391
  .build()
372
392
  .execute(reqCtx);
373
393
  return policyResultOf(reqCtx, method);
374
394
  }
375
395
 
376
- /** `revokeOrgAccess` and its system twin share one chain; only the annotation differs. */
396
+ /**
397
+ * `revokeOrgAccess` and its system twin share one chain: Authorize →
398
+ * ValidateProto → RevokeOrgAccess, and on the caller lane
399
+ * AuthorizeOwnerAssignment → KeepOneOwner before the revoke. The twin's
400
+ * platform-pipeline callers hold no organization role, and removing an
401
+ * account is theirs to decide.
402
+ */
377
403
  async function revokeOrgAccess(
378
404
  deps: IamPolicyControllerDeps,
379
405
  method: DescMethod,
380
406
  input: RevokeOrgAccessInput,
381
407
  ctx: HandlerContext,
408
+ guardOwners: boolean,
382
409
  ): Promise<Empty> {
383
410
  const reqCtx = new RequestContext(
384
411
  RevokeOrgAccessInputSchema,
@@ -386,12 +413,19 @@ async function revokeOrgAccess(
386
413
  callerIdentityOf(ctx),
387
414
  kindOf(ctx),
388
415
  );
389
- await newPipeline<typeof RevokeOrgAccessInputSchema>(
416
+ const pipeline = newPipeline<typeof RevokeOrgAccessInputSchema>(
390
417
  `iampolicy-${method.localName}`,
391
418
  deps.logger,
392
419
  )
393
420
  .addStep(newAuthorizeStep(method, deps.authorizer))
394
- .addStep(newValidateProtoStep())
421
+ .addStep(newValidateProtoStep());
422
+ if (guardOwners) {
423
+ const ownerRoleChange = orgAccessOwnerRoleChange(deps.policies);
424
+ pipeline
425
+ .addStep(newAuthorizeOwnerAssignmentStep(deps.authorizer, ownerRoleChange))
426
+ .addStep(newKeepOneOwnerStep(deps.policies, ownerRoleChange));
427
+ }
428
+ await pipeline
395
429
  .addStep(newRevokeOrgAccessStep(deps.grantPath))
396
430
  .build()
397
431
  .execute(reqCtx);
@@ -5,8 +5,9 @@
5
5
  * calls the ONE grant and revoke path (grant-path.ts, which owns the
6
6
  * cloud#425 orderings, the by-triple read and the wire refusals) and leaves
7
7
  * its answer under POLICY_RESULT_KEY for the handler. The chains are
8
- * deliberately short: `Authorize → ValidateProto → <one of these>`, so the
9
- * pipeline executor's fault mapping (a step's ConnectError keeps its code;
8
+ * deliberately short: `Authorize → ValidateProto → <one of these>`, with
9
+ * only the owner steps (the last section) before the write on the three
10
+ * caller lanes, so the pipeline executor's fault mapping (a step's ConnectError keeps its code;
10
11
  * a raw store fault becomes the sanitized INTERNAL) covers the path's two
11
12
  * error shapes with no second idiom in the handlers.
12
13
  *
@@ -51,8 +52,34 @@
51
52
  * exists and belongs to the resource's organization needs a read, which
52
53
  * this synchronous step does not make: that is the
53
54
  * `iam-policy-create:pre-side-effect-gate` slot's, spliced after it.
55
+ *
56
+ * Owner is assigned by owners. Every caller of the member lanes holds
57
+ * `can_grant_access` (admin) already, so an admin grants every role up to
58
+ * admin; a change that touches an organization's `owner` role also needs
59
+ * `can_assign_roles` there, which only its owners hold. Two steps carry it,
60
+ * on the caller lanes only (`create`, `delete`, `revokeOrgAccess`; never
61
+ * the bootstrap twins, whose platform-pipeline callers hold no
62
+ * organization role):
63
+ * - AuthorizeOwnerAssignment asks `can_assign_roles` when the spec grants
64
+ * or revokes `owner` on an organization, or when the account
65
+ * `revokeOrgAccess` removes holds it. It is Authorize's own evaluation
66
+ * (`authorizeResolvedResource`), so the `internal` caller skips it as it
67
+ * skips position 1;
68
+ * - KeepOneOwner refuses a revoke or a removal that would leave the
69
+ * organization with no owner. It is a product rule rather than
70
+ * authorization, so it is its own step. It skips `internal` too. The
71
+ * port has no transaction, so two owners removing each other at the
72
+ * same instant can both pass; each still needed `can_assign_roles`.
73
+ * Both read the organization's owner rows once per request
74
+ * (`organizationOwners`). They do not govern the grants that follow
75
+ * configuration rather than a caller (the organization creator's row, the
76
+ * membership rules, the trusted-local operator), which call the grant path
77
+ * directly, nor an edition's in-process doors that grant a role on a
78
+ * person's behalf (an invitation's redemption, an identity provider's
79
+ * auto-grant), which run as `internal` and hold the same rule at their own
80
+ * sites.
54
81
  */
55
- import { create } from "@bufbuild/protobuf";
82
+ import { create, type DescMessage } from "@bufbuild/protobuf";
56
83
  import { Code, ConnectError } from "@connectrpc/connect";
57
84
 
58
85
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
@@ -63,8 +90,9 @@ import type {
63
90
  ApiResourceRefSchema,
64
91
  IamPolicySpecSchema,
65
92
  } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
66
- import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
93
+ import { IamPermission, IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
67
94
 
95
+ import type { Authorizer } from "../../extensions/authorizer.js";
68
96
  import type { PolicyGrantScope } from "../../extensions/policy-grant-scope.js";
69
97
  import {
70
98
  grantableRolesFor,
@@ -73,7 +101,10 @@ import {
73
101
  } from "../../pipeline/apiresource-meta.js";
74
102
  import type { PipelineStep } from "../../pipeline/pipeline.js";
75
103
  import type { RequestContext } from "../../pipeline/request-context.js";
104
+ import { authorizeResolvedResource } from "../../pipeline/steps/authorize.js";
76
105
  import {
106
+ LAST_OWNER_MESSAGE,
107
+ OWNER_ASSIGNMENT_DENIED_MESSAGE,
77
108
  PER_RESOURCE_GRANTS_UNIMPLEMENTED_MESSAGE,
78
109
  TEAM_MEMBERS_RELATION,
79
110
  noGrantableRolesMessage,
@@ -85,6 +116,7 @@ import {
85
116
  teamRoleNotGrantableMessage,
86
117
  } from "./constants.js";
87
118
  import type { IamPolicyGrantPath } from "./grant-path.js";
119
+ import type { IamPolicyStore } from "./store.js";
88
120
  import {
89
121
  requireKnownPrincipalKind,
90
122
  requireKnownResourceKind,
@@ -258,3 +290,131 @@ export function newCleanupResourceStep(
258
290
  },
259
291
  };
260
292
  }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Owner is assigned by owners (the module header's last section)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /** The wire names the two owner steps compare against. */
299
+ const ORGANIZATION_KIND_NAME = kindEnumName(ApiResourceKind.organization);
300
+ const IDENTITY_ACCOUNT_KIND_NAME = kindEnumName(ApiResourceKind.identity_account);
301
+ const OWNER_RELATION = IamRole[IamRole.owner];
302
+
303
+ /** The context key the organization's owner rows are kept under for the rest of the request. */
304
+ const ORGANIZATION_OWNERS_KEY = "organizationOwners";
305
+
306
+ /** An organization owner role a request would grant or remove, and whose it is. */
307
+ export interface OwnerRoleChange {
308
+ readonly organizationId: string;
309
+ readonly accountId: string;
310
+ }
311
+
312
+ /**
313
+ * The ids of the people holding `owner` on the organization, read once per
314
+ * request and shared by both owner steps: the organization's `owner` rows
315
+ * whose principal is a person, the only principal the model admits there.
316
+ */
317
+ async function organizationOwners<Desc extends DescMessage>(
318
+ ctx: RequestContext<Desc>,
319
+ policies: IamPolicyStore,
320
+ organizationId: string,
321
+ ): Promise<ReadonlySet<string>> {
322
+ const held = ctx.get(ORGANIZATION_OWNERS_KEY);
323
+ if (held instanceof Set) {
324
+ return held as ReadonlySet<string>;
325
+ }
326
+ const rows = await policies.findByResourceWithRelations(
327
+ ORGANIZATION_KIND_NAME,
328
+ organizationId,
329
+ [OWNER_RELATION],
330
+ );
331
+ const owners = new Set(
332
+ rows
333
+ .map((row) => row.spec?.principal)
334
+ .filter((principal) => principal?.kind === IDENTITY_ACCOUNT_KIND_NAME)
335
+ .map((principal) => principal?.id ?? ""),
336
+ );
337
+ ctx.set(ORGANIZATION_OWNERS_KEY, owners);
338
+ return owners;
339
+ }
340
+
341
+ /** `create` and `delete`: the spec names the change; nothing is read. */
342
+ export function specOwnerRoleChange(
343
+ ctx: RequestContext<typeof IamPolicySpecSchema>,
344
+ ): Promise<OwnerRoleChange | undefined> {
345
+ const { resource, principal, relation } = ctx.input;
346
+ if (resource?.kind !== ORGANIZATION_KIND_NAME || relation !== OWNER_RELATION) {
347
+ return Promise.resolve(undefined);
348
+ }
349
+ return Promise.resolve({
350
+ organizationId: resource.id,
351
+ accountId: principal?.id ?? "",
352
+ });
353
+ }
354
+
355
+ /** `revokeOrgAccess`: the removal touches owner exactly when the account holds it. */
356
+ export function orgAccessOwnerRoleChange(
357
+ policies: IamPolicyStore,
358
+ ): (
359
+ ctx: RequestContext<typeof RevokeOrgAccessInputSchema>,
360
+ ) => Promise<OwnerRoleChange | undefined> {
361
+ return async (ctx) => {
362
+ const { identityAccountId, organizationId } = ctx.input;
363
+ const owners = await organizationOwners(ctx, policies, organizationId);
364
+ return owners.has(identityAccountId)
365
+ ? { organizationId, accountId: identityAccountId }
366
+ : undefined;
367
+ };
368
+ }
369
+
370
+ /** A change touching an organization's owner role needs `can_assign_roles` there; see the module header. */
371
+ export function newAuthorizeOwnerAssignmentStep<Desc extends DescMessage>(
372
+ authorizer: Authorizer,
373
+ ownerRoleChange: (ctx: RequestContext<Desc>) => Promise<OwnerRoleChange | undefined>,
374
+ ): PipelineStep<Desc> {
375
+ return {
376
+ name: "AuthorizeOwnerAssignment",
377
+ async execute(ctx: RequestContext<Desc>): Promise<void> {
378
+ if (ctx.callerIdentity.callerClass === "internal") {
379
+ return;
380
+ }
381
+ const change = await ownerRoleChange(ctx);
382
+ if (change === undefined) {
383
+ return;
384
+ }
385
+ await authorizeResolvedResource(
386
+ authorizer,
387
+ ctx.callerIdentity,
388
+ {
389
+ permission: IamPermission.can_assign_roles,
390
+ resourceKind: ApiResourceKind.organization,
391
+ resourceId: change.organizationId,
392
+ },
393
+ OWNER_ASSIGNMENT_DENIED_MESSAGE,
394
+ );
395
+ },
396
+ };
397
+ }
398
+
399
+ /** A revoke or removal may not leave the organization with no owner; see the module header. */
400
+ export function newKeepOneOwnerStep<Desc extends DescMessage>(
401
+ policies: IamPolicyStore,
402
+ ownerRoleChange: (ctx: RequestContext<Desc>) => Promise<OwnerRoleChange | undefined>,
403
+ ): PipelineStep<Desc> {
404
+ return {
405
+ name: "KeepOneOwner",
406
+ async execute(ctx: RequestContext<Desc>): Promise<void> {
407
+ if (ctx.callerIdentity.callerClass === "internal") {
408
+ return;
409
+ }
410
+ const change = await ownerRoleChange(ctx);
411
+ if (change === undefined) {
412
+ return;
413
+ }
414
+ const owners = await organizationOwners(ctx, policies, change.organizationId);
415
+ if (owners.has(change.accountId) && owners.size === 1) {
416
+ throw new ConnectError(LAST_OWNER_MESSAGE, Code.FailedPrecondition);
417
+ }
418
+ },
419
+ };
420
+ }
@@ -11,15 +11,17 @@
11
11
  * ciphertext (no double encryption);
12
12
  * - the keyless WARN-degrade path stores plaintext yet still redacts on
13
13
  * read, and the enc:v<N>: shape rejection stays UNCONDITIONAL (oss#395);
14
- * - the delete RPC returns the STORED secret unredacted — Go behavior
15
- * ported byte-faithfully (delete is absent from RedactOAuthApp's
16
- * response list) and disclosed in the PR;
14
+ * - the delete RPC answers the removed app REDACTED, never the stored
15
+ * secret: not the ciphertext on a keyed server, not the plaintext on
16
+ * a keyless one (stigmer/stigmer#1257; the Go port returned it);
17
17
  * - the referential delete guard's resolution semantics (stigmer#584),
18
18
  * proven by seeding McpServer rows directly through the store — the
19
19
  * McpServer RPC surface belongs to #9 and is not required here.
20
20
  *
21
21
  * Keys are injected via env (vi.stubEnv) so the ladder short-circuits
22
- * before its file steps — the real ~/.stigmer is never touched.
22
+ * before its file steps — the real ~/.stigmer is never touched. Who may
23
+ * read or list an app is the enforcing lane's conformance suite's
24
+ * (role-enforcement.conformance.test.ts).
23
25
  */
24
26
  import { mkdtempSync, rmSync } from "node:fs";
25
27
  import { tmpdir } from "node:os";
@@ -50,7 +52,7 @@ import {
50
52
  REDACTED_MARKER,
51
53
  deleteBlockedByMcpServerMessage,
52
54
  } from "../constants.js";
53
- import { resolveOAuthAppRef } from "../refresolution/refresolution.js";
55
+ import { resolveOAuthAppRef } from "../refresolution.js";
54
56
 
55
57
  const silentLogger = createLogger({ level: "error", pretty: false, write: () => {} });
56
58
 
@@ -285,14 +287,12 @@ describe("oauthapp domain (encryption enabled)", () => {
285
287
  expect(listed.entries[1]?.metadata?.id).toBe(first.metadata?.id);
286
288
  });
287
289
 
288
- it("delete returns the STORED resource unredacted — the ported Go behavior (disclosed)", async () => {
290
+ it("delete answers the removed app redacted — never the stored ciphertext", async () => {
289
291
  const created = await ts.command.create(appInput());
290
292
  const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
291
293
 
292
- // Go's delete path never calls RedactOAuthApp: the response carries the
293
- // stored ciphertext (plaintext on a keyless server). Pinned so the
294
- // faithful port cannot silently "fix" the divergence candidate.
295
- expect(deleted.spec?.clientSecret.startsWith(ENCRYPTED_PREFIX)).toBe(true);
294
+ expect(deleted.metadata?.id).toBe(created.metadata?.id);
295
+ expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
296
296
 
297
297
  const err = await grpcError(() => ts.query.get({ value: created.metadata!.id }));
298
298
  expect(err.code).toBe(Code.NotFound);
@@ -405,6 +405,13 @@ describe("oauthapp domain (encryption disabled)", () => {
405
405
  expect(stored.spec?.clientSecret).toBe("test-client-secret");
406
406
  });
407
407
 
408
+ it("delete answers the removed app redacted — never the stored plaintext", async () => {
409
+ const created = await ts.command.create(appInput());
410
+ const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
411
+
412
+ expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
413
+ });
414
+
408
415
  it("still rejects ciphertext-shaped secrets — the oss#395 boundary is not gated on key state", async () => {
409
416
  const err = await grpcError(() =>
410
417
  ts.command.create(appInput({ clientSecret: "enc:v1:c211Z2dsZWQ=" })),