@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.
- package/README.md +1 -1
- package/dist/authorization/model/data/authorization-model.json +16 -46
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +8 -0
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +10 -0
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +2 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
- package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
- package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
- package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
- package/dist/domain/iampolicy/constants.d.ts +11 -1
- package/dist/domain/iampolicy/constants.d.ts.map +1 -1
- package/dist/domain/iampolicy/constants.js +11 -1
- package/dist/domain/iampolicy/constants.js.map +1 -1
- package/dist/domain/iampolicy/controller.d.ts.map +1 -1
- package/dist/domain/iampolicy/controller.js +36 -12
- package/dist/domain/iampolicy/controller.js.map +1 -1
- package/dist/domain/iampolicy/steps.d.ts +98 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -1
- package/dist/domain/iampolicy/steps.js +119 -4
- package/dist/domain/iampolicy/steps.js.map +1 -1
- package/dist/domain/oauthapp/controller.d.ts +16 -8
- package/dist/domain/oauthapp/controller.d.ts.map +1 -1
- package/dist/domain/oauthapp/controller.js +28 -11
- package/dist/domain/oauthapp/controller.js.map +1 -1
- package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
- package/dist/domain/oauthapp/refresolution.js +3 -2
- package/dist/domain/oauthapp/refresolution.js.map +1 -1
- package/dist/domain/oauthapp/steps.d.ts +5 -5
- package/dist/domain/oauthapp/steps.js +6 -6
- package/dist/domain/oauthapp/steps.js.map +1 -1
- package/dist/domain/platformclient/constants.d.ts +11 -1
- package/dist/domain/platformclient/constants.d.ts.map +1 -1
- package/dist/domain/platformclient/constants.js +11 -1
- package/dist/domain/platformclient/constants.js.map +1 -1
- package/dist/domain/platformclient/verifier.d.ts.map +1 -1
- package/dist/domain/platformclient/verifier.js +7 -2
- package/dist/domain/platformclient/verifier.js.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +5 -1
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +6 -1
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +13 -5
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/kubernetes.d.ts +1 -1
- package/dist/sandbox/kubernetes.d.ts.map +1 -1
- package/dist/sandbox/kubernetes.js +18 -4
- package/dist/sandbox/kubernetes.js.map +1 -1
- package/dist/sandbox/local-process.d.ts.map +1 -1
- package/dist/sandbox/local-process.js +8 -1
- package/dist/sandbox/local-process.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +12 -0
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/temporal/manager.d.ts +10 -0
- package/dist/temporal/manager.d.ts.map +1 -1
- package/dist/temporal/manager.js +10 -0
- package/dist/temporal/manager.js.map +1 -1
- package/package.json +6 -6
- package/src/authorization/__tests__/store-tests.test.ts +1 -0
- package/src/authorization/model/data/authorization-model.json +16 -46
- package/src/boot/__tests__/config.test.ts +23 -1
- package/src/boot/compose.ts +8 -0
- package/src/boot/config.ts +13 -0
- package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
- package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
- package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
- package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
- package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
- package/src/domain/iampolicy/constants.ts +15 -1
- package/src/domain/iampolicy/controller.ts +43 -9
- package/src/domain/iampolicy/steps.ts +164 -4
- package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
- package/src/domain/oauthapp/controller.ts +51 -18
- package/src/domain/oauthapp/refresolution.ts +3 -2
- package/src/domain/oauthapp/steps.ts +6 -6
- package/src/domain/platformclient/__tests__/mint.test.ts +7 -1
- package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
- package/src/domain/platformclient/constants.ts +11 -1
- package/src/domain/platformclient/verifier.ts +7 -2
- package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
- package/src/extensions/identity.ts +14 -0
- package/src/index.ts +5 -0
- package/src/pipeline/__tests__/steps.test.ts +72 -1
- package/src/pipeline/steps/defaults.ts +6 -1
- package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
- package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
- package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
- package/src/sandbox/__tests__/provisioner.test.ts +2 -0
- package/src/sandbox/docker.ts +11 -5
- package/src/sandbox/kubernetes.ts +18 -3
- package/src/sandbox/local-process.ts +11 -1
- package/src/sandbox/provisioner.ts +12 -0
- package/src/temporal/__tests__/manager.test.ts +59 -1
- package/src/temporal/manager.ts +13 -0
- package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
- package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
- package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
- package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
- 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)
|
|
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 →
|
|
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
|
|
319
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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>`,
|
|
9
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
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=" })),
|