@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.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 +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +41 -4
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js +44 -7
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +77 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +183 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +7 -26
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/pipeline/steps/shapes.d.ts +18 -0
- package/dist/pipeline/steps/shapes.d.ts.map +1 -1
- package/dist/pipeline/steps/shapes.js +29 -0
- package/dist/pipeline/steps/shapes.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/list-read-scope.ts +87 -9
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +218 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorization-tuples.ts +7 -28
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/pipeline/steps/shapes.ts +31 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- package/src/store/port-contract.ts +102 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins display-resolver.ts (20260913.01 slice 5, Q-S5-4): how an access
|
|
3
|
+
* listing renders a principal that is an identity account — the batch
|
|
4
|
+
* read through the identity-account PORT's `findByIds`, and the display
|
|
5
|
+
* name precedence `first+last > first > last > metadata.name > email`.
|
|
6
|
+
*
|
|
7
|
+
* The `metadata.name` arm is this entry's one code divergence from the
|
|
8
|
+
* Java PrincipalEnricher: the trusted-local operator account carries its
|
|
9
|
+
* display name THERE and empty first/last names (identityaccount/
|
|
10
|
+
* operator.ts), while the cloud names every account by its email — so on
|
|
11
|
+
* the cloud the arm and the email arm answer the same string, and on a
|
|
12
|
+
* laptop the Members row reads the name the operator configured instead
|
|
13
|
+
* of an address.
|
|
14
|
+
*/
|
|
15
|
+
import { create } from "@bufbuild/protobuf";
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
19
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
20
|
+
|
|
21
|
+
import { fakeIdentityAccountStore } from "../../identityaccount/__tests__/support.js";
|
|
22
|
+
import { newAccountDisplayResolver } from "../display-resolver.js";
|
|
23
|
+
|
|
24
|
+
function account(
|
|
25
|
+
id: string,
|
|
26
|
+
fields: {
|
|
27
|
+
name?: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
firstName?: string;
|
|
30
|
+
lastName?: string;
|
|
31
|
+
pictureUrl?: string;
|
|
32
|
+
mode?: IdentityAccountProvisioningMode;
|
|
33
|
+
},
|
|
34
|
+
) {
|
|
35
|
+
return create(IdentityAccountSchema, {
|
|
36
|
+
metadata: { id, name: fields.name ?? "" },
|
|
37
|
+
spec: {
|
|
38
|
+
email: fields.email ?? "",
|
|
39
|
+
firstName: fields.firstName ?? "",
|
|
40
|
+
lastName: fields.lastName ?? "",
|
|
41
|
+
pictureUrl: fields.pictureUrl ?? "",
|
|
42
|
+
provisioningMode: fields.mode ?? IdentityAccountProvisioningMode.direct,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function resolveOne(row: ReturnType<typeof account>) {
|
|
48
|
+
const store = fakeIdentityAccountStore();
|
|
49
|
+
await store.save(row);
|
|
50
|
+
const resolver = newAccountDisplayResolver(store);
|
|
51
|
+
const views = await resolver.resolveIdentityAccounts([
|
|
52
|
+
row.metadata?.id ?? "",
|
|
53
|
+
]);
|
|
54
|
+
return views.get(row.metadata?.id ?? "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("the display name precedence", () => {
|
|
58
|
+
it("first + last beats everything", async () => {
|
|
59
|
+
const view = await resolveOne(
|
|
60
|
+
account("ida_1", {
|
|
61
|
+
firstName: "Ada",
|
|
62
|
+
lastName: "Lovelace",
|
|
63
|
+
name: "ada@example.com",
|
|
64
|
+
email: "ada@example.com",
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
expect(view?.name).toBe("Ada Lovelace");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("first alone, then last alone", async () => {
|
|
71
|
+
expect(
|
|
72
|
+
(await resolveOne(account("ida_2", { firstName: "Ada", name: "x" })))
|
|
73
|
+
?.name,
|
|
74
|
+
).toBe("Ada");
|
|
75
|
+
expect(
|
|
76
|
+
(await resolveOne(account("ida_3", { lastName: "Lovelace", name: "x" })))
|
|
77
|
+
?.name,
|
|
78
|
+
).toBe("Lovelace");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("metadata.name beats the email — the trusted-local operator's row (Q-S5-4)", async () => {
|
|
82
|
+
const view = await resolveOne(
|
|
83
|
+
account("ida_4", { name: "The Operator", email: "operator@example.com" }),
|
|
84
|
+
);
|
|
85
|
+
expect(view?.name).toBe("The Operator");
|
|
86
|
+
expect(view?.email).toBe("operator@example.com");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("an account the cloud named by its email renders exactly as the Java precedence did", async () => {
|
|
90
|
+
const view = await resolveOne(
|
|
91
|
+
account("ida_5", { name: "bob@example.com", email: "bob@example.com" }),
|
|
92
|
+
);
|
|
93
|
+
expect(view?.name).toBe("bob@example.com");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("the email is the last resort", async () => {
|
|
97
|
+
const view = await resolveOne(account("ida_6", { email: "c@example.com" }));
|
|
98
|
+
expect(view?.name).toBe("c@example.com");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the view's other fields", () => {
|
|
103
|
+
it("carries kind, id, avatar and the identity origin with the Stigmer label for direct and machine accounts", async () => {
|
|
104
|
+
const view = await resolveOne(
|
|
105
|
+
account("ida_7", {
|
|
106
|
+
name: "Ada",
|
|
107
|
+
pictureUrl: "https://img/ada",
|
|
108
|
+
mode: IdentityAccountProvisioningMode.machine,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
expect(view?.kind).toBe("identity_account");
|
|
112
|
+
expect(view?.id).toBe("ida_7");
|
|
113
|
+
expect(view?.avatar).toBe("https://img/ada");
|
|
114
|
+
expect(view?.identityOrigin?.provisioningMode).toBe(
|
|
115
|
+
IdentityAccountProvisioningMode.machine,
|
|
116
|
+
);
|
|
117
|
+
expect(view?.identityOrigin?.providerDisplayName).toBe("Stigmer");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a federated account carries its mode and no provider label", async () => {
|
|
121
|
+
const view = await resolveOne(
|
|
122
|
+
account("ida_8", {
|
|
123
|
+
name: "Fed",
|
|
124
|
+
mode: IdentityAccountProvisioningMode.federated,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
expect(view?.identityOrigin?.providerDisplayName).toBe("");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("answers only the ids that resolve — the caller falls back for the rest", async () => {
|
|
131
|
+
const store = fakeIdentityAccountStore();
|
|
132
|
+
await store.save(account("ida_9", { name: "Nine" }));
|
|
133
|
+
const views = await newAccountDisplayResolver(
|
|
134
|
+
store,
|
|
135
|
+
).resolveIdentityAccounts(["ida_9", "ida_missing"]);
|
|
136
|
+
expect([...views.keys()]).toEqual(["ida_9"]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the ONE grant and revoke path of the IamPolicy domain
|
|
3
|
+
* (T01_0_plan.md §3a "grant-path.ts"; T01_1_review.md Q-OR-1): the two
|
|
4
|
+
* cloud#425 ordering invariants, now living in open source, recorded
|
|
5
|
+
* event by event through a fake store and a recording lifecycle:
|
|
6
|
+
*
|
|
7
|
+
* - CREATE: the row persists, THEN onPolicyGranted fires. On the
|
|
8
|
+
* duplicate arm no row is written and the hook STILL fires with
|
|
9
|
+
* `duplicate: true` — the inline heal for a row whose tuple never
|
|
10
|
+
* landed. A hook throw fails the grant with the row in place (the
|
|
11
|
+
* cloud's boot backfill heals it; the caller's retry heals it too).
|
|
12
|
+
* - REVOKE: onPolicyRevoked fires, THEN the row is deleted. A hook throw
|
|
13
|
+
* leaves row and tuple (retry converges; the reverse order was the
|
|
14
|
+
* fail-open incident class). The absent arm fires the hook with the
|
|
15
|
+
* spec and no policy, so a composition can still delete a bare tuple
|
|
16
|
+
* written before the row mirror existed.
|
|
17
|
+
* - cleanupResource is bidirectional (as target AND as principal),
|
|
18
|
+
* deduplicated, revoking each row through the same revoke order.
|
|
19
|
+
* - revokeOrgAccess revokes the account's direct rows on the
|
|
20
|
+
* organization; the inert org-column arm the cloud carried is gone
|
|
21
|
+
* (Q-OR-9).
|
|
22
|
+
* - With no lifecycle composed (the OSS default before the built-in
|
|
23
|
+
* posture, and any unit that implements only the three required
|
|
24
|
+
* methods), the path writes rows and notifies nothing.
|
|
25
|
+
* - The row for a triple is found BY TRIPLE, never by derived id (slice
|
|
26
|
+
* 2): the cloud's legacy rows carry random `iamp_<ulid>` ids and must
|
|
27
|
+
* revoke and deduplicate through this path, so a legacy row makes a
|
|
28
|
+
* re-grant the duplicate arm and is what `revokeBySpec` deletes.
|
|
29
|
+
* - The path is the one writer, so it is the one gate (slice 2 ruling
|
|
30
|
+
* Q-S2-1; T01_3_execution.md S2 slice 1, ruling 2): an unknown
|
|
31
|
+
* resource or principal kind, or a triple holding a delimiter, is
|
|
32
|
+
* INVALID_ARGUMENT before any read, write or hook — on grant AND on
|
|
33
|
+
* revoke, so a composition never receives a garbage spec to delete a
|
|
34
|
+
* bare tuple from.
|
|
35
|
+
*
|
|
36
|
+
* The row the path builds is pinned too: the proto's apiVersion const, the
|
|
37
|
+
* derived id, the caller's audit stamp — the cloud's `buildNewPolicy`
|
|
38
|
+
* stamped neither org nor creator, and this path stamps the creator.
|
|
39
|
+
*/
|
|
40
|
+
import { describe, expect, it } from "vitest";
|
|
41
|
+
|
|
42
|
+
import { clone, create } from "@bufbuild/protobuf";
|
|
43
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
44
|
+
import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
45
|
+
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
46
|
+
import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
47
|
+
import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
48
|
+
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
49
|
+
|
|
50
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
51
|
+
import type { ResourceAuthorizationLifecycle } from "../../../extensions/resource-authorization.js";
|
|
52
|
+
import {
|
|
53
|
+
IAM_POLICY_API_VERSION,
|
|
54
|
+
IAM_POLICY_KIND,
|
|
55
|
+
malformedTripleMessage,
|
|
56
|
+
policyIdFor,
|
|
57
|
+
unknownPrincipalKindMessage,
|
|
58
|
+
unknownResourceKindMessage,
|
|
59
|
+
} from "../constants.js";
|
|
60
|
+
import { newIamPolicyGrantPath } from "../grant-path.js";
|
|
61
|
+
import { DuplicatePolicyError } from "../store.js";
|
|
62
|
+
import {
|
|
63
|
+
fakeIamPolicyStore,
|
|
64
|
+
orgRole,
|
|
65
|
+
recordingLifecycle,
|
|
66
|
+
triple,
|
|
67
|
+
} from "./support.js";
|
|
68
|
+
import type { RecordedEvent } from "./support.js";
|
|
69
|
+
|
|
70
|
+
const ALICE = "ida_wtr3jcf281yfk9xx61kj59fsme";
|
|
71
|
+
const BOB = "ida_0byc5k14t1e7b7kdxft7hwz1f7";
|
|
72
|
+
const AGENT = "agt_01hzzzzzzzzzzzzzzzzzzzzzzz";
|
|
73
|
+
/** A Java-era id: random, never derivable from the triple it holds. */
|
|
74
|
+
const LEGACY_ID = "iamp_01hzzzzzzzzzzzzzzzzzzzzzzz";
|
|
75
|
+
|
|
76
|
+
const alice: CallerIdentity = {
|
|
77
|
+
identityId: ALICE,
|
|
78
|
+
callerClass: "user",
|
|
79
|
+
issuer: "https://issuer.example.com",
|
|
80
|
+
rawToken: "",
|
|
81
|
+
email: "alice@example.com",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A row as the cloud's Java service wrote it: random id, no audit actor. */
|
|
85
|
+
function legacyRow(spec: IamPolicySpec): IamPolicy {
|
|
86
|
+
return create(IamPolicySchema, {
|
|
87
|
+
apiVersion: "iam.stigmer.com/v1",
|
|
88
|
+
kind: IAM_POLICY_KIND,
|
|
89
|
+
metadata: create(ApiResourceMetadataSchema, { id: LEGACY_ID }),
|
|
90
|
+
spec,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function invalidArgument(
|
|
95
|
+
run: () => Promise<unknown>,
|
|
96
|
+
): Promise<ConnectError> {
|
|
97
|
+
try {
|
|
98
|
+
await run();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof ConnectError) {
|
|
101
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
102
|
+
return error;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
throw new Error("expected the call to be refused");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
110
|
+
|
|
111
|
+
function pathOver(
|
|
112
|
+
recorded: RecordedEvent[],
|
|
113
|
+
lifecycle: ResourceAuthorizationLifecycle | undefined,
|
|
114
|
+
) {
|
|
115
|
+
const policies = fakeIamPolicyStore(recorded);
|
|
116
|
+
const path = newIamPolicyGrantPath({
|
|
117
|
+
policies,
|
|
118
|
+
lifecycle,
|
|
119
|
+
logger: silentLogger,
|
|
120
|
+
});
|
|
121
|
+
return { policies, path };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("grant: row before onPolicyGranted", () => {
|
|
125
|
+
it("persists the row, then fires the hook once with duplicate false; the row carries the contract's stamps", async () => {
|
|
126
|
+
const recorded: RecordedEvent[] = [];
|
|
127
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
128
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
129
|
+
|
|
130
|
+
const result = await path.grant(spec, alice);
|
|
131
|
+
|
|
132
|
+
const id = policyIdFor(spec);
|
|
133
|
+
expect(result.duplicate).toBe(false);
|
|
134
|
+
expect(result.policy.metadata?.id).toBe(id);
|
|
135
|
+
expect(result.policy.apiVersion).toBe(IAM_POLICY_API_VERSION);
|
|
136
|
+
expect(result.policy.kind).toBe("IamPolicy");
|
|
137
|
+
expect(result.policy.spec).toEqual(spec);
|
|
138
|
+
expect(result.policy.status?.audit?.specAudit?.createdBy?.id).toBe(ALICE);
|
|
139
|
+
expect(policies.rows.get(id)).toBeDefined();
|
|
140
|
+
expect(recorded).toEqual([
|
|
141
|
+
{ kind: "row-save", id },
|
|
142
|
+
{ kind: "granted", id, duplicate: false },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("on a held triple writes no row and STILL fires the hook with duplicate true — the inline heal", async () => {
|
|
147
|
+
const recorded: RecordedEvent[] = [];
|
|
148
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
149
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
150
|
+
const first = await path.grant(spec, alice);
|
|
151
|
+
recorded.length = 0;
|
|
152
|
+
|
|
153
|
+
const second = await path.grant(spec, alice);
|
|
154
|
+
|
|
155
|
+
expect(second.duplicate).toBe(true);
|
|
156
|
+
expect(second.policy).toEqual(first.policy);
|
|
157
|
+
expect(recorded).toEqual([
|
|
158
|
+
{ kind: "granted", id: policyIdFor(spec), duplicate: true },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("a hook throw fails the grant and leaves the row in place for the heal", async () => {
|
|
163
|
+
const recorded: RecordedEvent[] = [];
|
|
164
|
+
const { policies, path } = pathOver(
|
|
165
|
+
recorded,
|
|
166
|
+
recordingLifecycle(recorded, { granted: new Error("fga is down") }),
|
|
167
|
+
);
|
|
168
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
169
|
+
|
|
170
|
+
await expect(path.grant(spec, alice)).rejects.toThrow("fga is down");
|
|
171
|
+
|
|
172
|
+
expect(policies.rows.has(policyIdFor(spec))).toBe(true);
|
|
173
|
+
expect(recorded).toEqual([{ kind: "row-save", id: policyIdFor(spec) }]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("with no lifecycle composed, writes the row and notifies nothing", async () => {
|
|
177
|
+
const recorded: RecordedEvent[] = [];
|
|
178
|
+
const { path } = pathOver(recorded, undefined);
|
|
179
|
+
await path.grant(orgRole(ALICE, "member", "acme"), alice);
|
|
180
|
+
expect(recorded.map((event) => event.kind)).toEqual(["row-save"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("a lifecycle without the optional hooks (the three required methods only) is the same as none", async () => {
|
|
184
|
+
const recorded: RecordedEvent[] = [];
|
|
185
|
+
const threeMethods: ResourceAuthorizationLifecycle = {
|
|
186
|
+
async onResourceCreated() {},
|
|
187
|
+
async onResourceDeleted() {},
|
|
188
|
+
async onVisibilityChanged() {},
|
|
189
|
+
};
|
|
190
|
+
const { path } = pathOver(recorded, threeMethods);
|
|
191
|
+
await path.grant(orgRole(ALICE, "member", "acme"), alice);
|
|
192
|
+
await path.revokeBySpec(orgRole(ALICE, "member", "acme"));
|
|
193
|
+
expect(recorded.map((event) => event.kind)).toEqual([
|
|
194
|
+
"row-save",
|
|
195
|
+
"row-delete",
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("the row for a triple is found by triple, so legacy ids converge", () => {
|
|
201
|
+
it("a legacy row makes a re-grant the duplicate arm: one row, ever, and the legacy id stands", async () => {
|
|
202
|
+
const recorded: RecordedEvent[] = [];
|
|
203
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
204
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
205
|
+
policies.rows.set(LEGACY_ID, legacyRow(spec));
|
|
206
|
+
|
|
207
|
+
const result = await path.grant(spec, alice);
|
|
208
|
+
|
|
209
|
+
expect(result.duplicate).toBe(true);
|
|
210
|
+
expect(result.policy.metadata?.id).toBe(LEGACY_ID);
|
|
211
|
+
expect([...policies.rows.keys()]).toEqual([LEGACY_ID]);
|
|
212
|
+
expect(recorded).toEqual([
|
|
213
|
+
{ kind: "granted", id: LEGACY_ID, duplicate: true },
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("revokeBySpec finds and deletes the legacy row", async () => {
|
|
218
|
+
const recorded: RecordedEvent[] = [];
|
|
219
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
220
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
221
|
+
policies.rows.set(LEGACY_ID, legacyRow(spec));
|
|
222
|
+
|
|
223
|
+
const revoked = await path.revokeBySpec(spec);
|
|
224
|
+
|
|
225
|
+
expect(revoked?.metadata?.id).toBe(LEGACY_ID);
|
|
226
|
+
expect(policies.rows.size).toBe(0);
|
|
227
|
+
expect(recorded).toEqual([
|
|
228
|
+
{ kind: "revoked", id: LEGACY_ID, relation: "admin" },
|
|
229
|
+
{ kind: "row-delete", id: LEGACY_ID },
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("the loser of a concurrent grant answers the winner's row as a duplicate and still fires the hook", async () => {
|
|
234
|
+
const recorded: RecordedEvent[] = [];
|
|
235
|
+
const policies = fakeIamPolicyStore(recorded);
|
|
236
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
237
|
+
const bob: CallerIdentity = { ...alice, identityId: BOB };
|
|
238
|
+
// The other writer lands between this path's read and its write: the
|
|
239
|
+
// store refuses the id and the winner's row is what the re-read finds.
|
|
240
|
+
const racingSave = policies.save.bind(policies);
|
|
241
|
+
let interposed = false;
|
|
242
|
+
policies.save = async (policy) => {
|
|
243
|
+
if (!interposed) {
|
|
244
|
+
interposed = true;
|
|
245
|
+
const winner = clone(IamPolicySchema, policy);
|
|
246
|
+
winner.status = undefined;
|
|
247
|
+
policies.rows.set(policyIdFor(spec), winner);
|
|
248
|
+
throw new DuplicatePolicyError("raced");
|
|
249
|
+
}
|
|
250
|
+
await racingSave(policy);
|
|
251
|
+
};
|
|
252
|
+
const path = newIamPolicyGrantPath({
|
|
253
|
+
policies,
|
|
254
|
+
lifecycle: recordingLifecycle(recorded),
|
|
255
|
+
logger: silentLogger,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const result = await path.grant(spec, bob);
|
|
259
|
+
|
|
260
|
+
expect(result.duplicate).toBe(true);
|
|
261
|
+
expect(result.policy.status).toBeUndefined();
|
|
262
|
+
expect(policies.rows.size).toBe(1);
|
|
263
|
+
expect(recorded).toEqual([
|
|
264
|
+
{ kind: "granted", id: policyIdFor(spec), duplicate: true },
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("the one writer is the one gate: nothing is read, written or notified for a spec no edition can hold", () => {
|
|
270
|
+
const unknownResource = triple(
|
|
271
|
+
{ kind: "identity_account", id: ALICE },
|
|
272
|
+
"admin",
|
|
273
|
+
{ kind: "organisation", id: "acme" },
|
|
274
|
+
);
|
|
275
|
+
const unknownPrincipal = triple({ kind: "team", id: "tm_1" }, "viewer", {
|
|
276
|
+
kind: "agent",
|
|
277
|
+
id: AGENT,
|
|
278
|
+
});
|
|
279
|
+
const malformed = triple({ kind: "identity_account", id: ALICE }, "admin", {
|
|
280
|
+
kind: "organization",
|
|
281
|
+
id: "acme#admin",
|
|
282
|
+
});
|
|
283
|
+
const bothUnknown = triple({ kind: "team", id: "tm_1" }, "admin", {
|
|
284
|
+
kind: "organisation",
|
|
285
|
+
id: "acme",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("grant refuses each with the pinned copy, the resource kind read first", async () => {
|
|
289
|
+
const recorded: RecordedEvent[] = [];
|
|
290
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
291
|
+
const refusals: ReadonlyArray<[IamPolicySpec, string]> = [
|
|
292
|
+
[unknownResource, unknownResourceKindMessage("organisation")],
|
|
293
|
+
[unknownPrincipal, unknownPrincipalKindMessage("team")],
|
|
294
|
+
[malformed, malformedTripleMessage("resource.id")],
|
|
295
|
+
[bothUnknown, unknownResourceKindMessage("organisation")],
|
|
296
|
+
];
|
|
297
|
+
for (const [spec, message] of refusals) {
|
|
298
|
+
const error = await invalidArgument(() => path.grant(spec, alice));
|
|
299
|
+
expect(error.rawMessage, message).toBe(message);
|
|
300
|
+
}
|
|
301
|
+
expect(policies.rows.size).toBe(0);
|
|
302
|
+
expect(recorded).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("revokeBySpec refuses the same specs — a composition never deletes a bare tuple from garbage", async () => {
|
|
306
|
+
const recorded: RecordedEvent[] = [];
|
|
307
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
308
|
+
for (const spec of [unknownResource, unknownPrincipal, malformed]) {
|
|
309
|
+
await invalidArgument(() => path.revokeBySpec(spec));
|
|
310
|
+
}
|
|
311
|
+
expect(recorded).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("the literal zero-value name is the unknown kind too", async () => {
|
|
315
|
+
const recorded: RecordedEvent[] = [];
|
|
316
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
317
|
+
const error = await invalidArgument(() =>
|
|
318
|
+
path.grant(
|
|
319
|
+
triple({ kind: "identity_account", id: ALICE }, "admin", {
|
|
320
|
+
kind: "api_resource_kind_unknown",
|
|
321
|
+
id: "acme",
|
|
322
|
+
}),
|
|
323
|
+
alice,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
expect(error.rawMessage).toBe(
|
|
327
|
+
unknownResourceKindMessage("api_resource_kind_unknown"),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("revoke: onPolicyRevoked before the row delete", () => {
|
|
333
|
+
it("fires the hook with the row, then deletes it, and answers the revoked policy", async () => {
|
|
334
|
+
const recorded: RecordedEvent[] = [];
|
|
335
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
336
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
337
|
+
await path.grant(spec, alice);
|
|
338
|
+
recorded.length = 0;
|
|
339
|
+
|
|
340
|
+
const revoked = await path.revokeBySpec(spec);
|
|
341
|
+
|
|
342
|
+
const id = policyIdFor(spec);
|
|
343
|
+
expect(revoked?.metadata?.id).toBe(id);
|
|
344
|
+
expect(policies.rows.has(id)).toBe(false);
|
|
345
|
+
expect(recorded).toEqual([
|
|
346
|
+
{ kind: "revoked", id, relation: "admin" },
|
|
347
|
+
{ kind: "row-delete", id },
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("a hook throw leaves row and tuple — retry converges, nothing fails open", async () => {
|
|
352
|
+
const recorded: RecordedEvent[] = [];
|
|
353
|
+
const { policies, path } = pathOver(
|
|
354
|
+
recorded,
|
|
355
|
+
recordingLifecycle(recorded, { revoked: new Error("fga is down") }),
|
|
356
|
+
);
|
|
357
|
+
const spec = orgRole(ALICE, "admin", "acme");
|
|
358
|
+
await path.grant(spec, alice);
|
|
359
|
+
recorded.length = 0;
|
|
360
|
+
|
|
361
|
+
await expect(path.revokeBySpec(spec)).rejects.toThrow("fga is down");
|
|
362
|
+
|
|
363
|
+
expect(policies.rows.has(policyIdFor(spec))).toBe(true);
|
|
364
|
+
expect(recorded).toEqual([]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("the absent arm fires the hook with the spec and no policy, deletes nothing, answers undefined", async () => {
|
|
368
|
+
const recorded: RecordedEvent[] = [];
|
|
369
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
370
|
+
|
|
371
|
+
const revoked = await path.revokeBySpec(orgRole(BOB, "viewer", "acme"));
|
|
372
|
+
|
|
373
|
+
expect(revoked).toBeUndefined();
|
|
374
|
+
expect(recorded).toEqual([
|
|
375
|
+
{ kind: "revoked", id: undefined, relation: "viewer" },
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("cleanupResource: bidirectional, deduplicated, each row through the revoke order", () => {
|
|
381
|
+
it("revokes rows where the ref is the resource AND rows where it is the principal", async () => {
|
|
382
|
+
const recorded: RecordedEvent[] = [];
|
|
383
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
384
|
+
const asResource = orgRole(ALICE, "owner", "acme");
|
|
385
|
+
const asPrincipal = triple(
|
|
386
|
+
{ kind: "organization", id: "acme" },
|
|
387
|
+
"organization",
|
|
388
|
+
{ kind: "agent", id: AGENT },
|
|
389
|
+
);
|
|
390
|
+
const unrelated = orgRole(ALICE, "member", "globex");
|
|
391
|
+
for (const spec of [asResource, asPrincipal, unrelated]) {
|
|
392
|
+
await path.grant(spec, alice);
|
|
393
|
+
}
|
|
394
|
+
recorded.length = 0;
|
|
395
|
+
|
|
396
|
+
await path.cleanupResource(
|
|
397
|
+
create(ApiResourceRefSchema, { kind: "organization", id: "acme" }),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect([...policies.rows.keys()]).toEqual([policyIdFor(unrelated)]);
|
|
401
|
+
expect(recorded).toEqual([
|
|
402
|
+
{ kind: "revoked", id: policyIdFor(asResource), relation: "owner" },
|
|
403
|
+
{ kind: "row-delete", id: policyIdFor(asResource) },
|
|
404
|
+
{
|
|
405
|
+
kind: "revoked",
|
|
406
|
+
id: policyIdFor(asPrincipal),
|
|
407
|
+
relation: "organization",
|
|
408
|
+
},
|
|
409
|
+
{ kind: "row-delete", id: policyIdFor(asPrincipal) },
|
|
410
|
+
]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("a ref that is both principal and resource of one row revokes it once", async () => {
|
|
414
|
+
const recorded: RecordedEvent[] = [];
|
|
415
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
416
|
+
const selfOwner = triple({ kind: "identity_account", id: ALICE }, "owner", {
|
|
417
|
+
kind: "identity_account",
|
|
418
|
+
id: ALICE,
|
|
419
|
+
});
|
|
420
|
+
await path.grant(selfOwner, alice);
|
|
421
|
+
recorded.length = 0;
|
|
422
|
+
|
|
423
|
+
await path.cleanupResource(
|
|
424
|
+
create(ApiResourceRefSchema, { kind: "identity_account", id: ALICE }),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(
|
|
428
|
+
recorded.filter((event) => event.kind === "row-delete"),
|
|
429
|
+
).toHaveLength(1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("nothing to clean is a no-op, not an error", async () => {
|
|
433
|
+
const recorded: RecordedEvent[] = [];
|
|
434
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
435
|
+
await path.cleanupResource(
|
|
436
|
+
create(ApiResourceRefSchema, { kind: "agent", id: AGENT }),
|
|
437
|
+
);
|
|
438
|
+
expect(recorded).toEqual([]);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("revokeOrgAccess: the account's direct rows on the organization", () => {
|
|
443
|
+
it("revokes every relation the account holds on that organization and nothing on another", async () => {
|
|
444
|
+
const recorded: RecordedEvent[] = [];
|
|
445
|
+
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
446
|
+
await path.grant(orgRole(ALICE, "admin", "acme"), alice);
|
|
447
|
+
await path.grant(orgRole(ALICE, "viewer", "acme"), alice);
|
|
448
|
+
await path.grant(orgRole(ALICE, "member", "globex"), alice);
|
|
449
|
+
await path.grant(orgRole(BOB, "member", "acme"), alice);
|
|
450
|
+
recorded.length = 0;
|
|
451
|
+
|
|
452
|
+
await path.revokeOrgAccess(ALICE, "acme");
|
|
453
|
+
|
|
454
|
+
expect([...policies.rows.keys()].sort()).toEqual(
|
|
455
|
+
[
|
|
456
|
+
policyIdFor(orgRole(ALICE, "member", "globex")),
|
|
457
|
+
policyIdFor(orgRole(BOB, "member", "acme")),
|
|
458
|
+
].sort(),
|
|
459
|
+
);
|
|
460
|
+
expect(recorded.map((event) => event.kind)).toEqual([
|
|
461
|
+
"revoked",
|
|
462
|
+
"row-delete",
|
|
463
|
+
"revoked",
|
|
464
|
+
"row-delete",
|
|
465
|
+
]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("an account with no rows on the organization is a no-op", async () => {
|
|
469
|
+
const recorded: RecordedEvent[] = [];
|
|
470
|
+
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
471
|
+
await path.revokeOrgAccess(BOB, "acme");
|
|
472
|
+
expect(recorded).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins open source's default PolicyGrantScope (domain/iampolicy/grant-scope.ts;
|
|
3
|
+
* 20260913.01, T01_1_review.md Q-OR-3 and Q-OR-4): the organization grants
|
|
4
|
+
* the roles its `kind_meta` lists and no other kind grants anything.
|
|
5
|
+
* Per-resource grants — a viewer on one agent — are what the Enterprise and
|
|
6
|
+
* Cloud editions add through a composed scope; open source's Members page
|
|
7
|
+
* is the one grant surface this default lights.
|
|
8
|
+
*
|
|
9
|
+
* The contract every scope is held to (extensions/policy-grant-scope.ts),
|
|
10
|
+
* proven here on the default so a composed driver has a shape to match:
|
|
11
|
+
*
|
|
12
|
+
* - it NARROWS the proto: the organization's answer IS
|
|
13
|
+
* `grantableRolesFor(organization)`, never a list written in code;
|
|
14
|
+
* - it is TOTAL over the enum: every other member, the unknown kind
|
|
15
|
+
* included, answers the empty list and never throws — checkMyPermission
|
|
16
|
+
* asks the scope about a kind that came off the wire;
|
|
17
|
+
* - it is SYNCHRONOUS: a proto read, no I/O, so no Promise.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
ApiResourceKind,
|
|
23
|
+
ApiResourceKindSchema,
|
|
24
|
+
} from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
25
|
+
|
|
26
|
+
import { grantableRolesFor } from "../../../pipeline/apiresource-meta.js";
|
|
27
|
+
import { newOrganizationOnlyGrantScope } from "../grant-scope.js";
|
|
28
|
+
|
|
29
|
+
describe("newOrganizationOnlyGrantScope — open source grants on organizations only", () => {
|
|
30
|
+
const scope = newOrganizationOnlyGrantScope();
|
|
31
|
+
|
|
32
|
+
it("the organization answers exactly the proto's grantable roles", () => {
|
|
33
|
+
const roles = scope.grantableRoles(ApiResourceKind.organization);
|
|
34
|
+
expect(roles).toEqual(grantableRolesFor(ApiResourceKind.organization));
|
|
35
|
+
expect(roles.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("every other kind answers the empty list — per-resource grants are another edition's", () => {
|
|
39
|
+
for (const value of ApiResourceKindSchema.values) {
|
|
40
|
+
if (value.number === ApiResourceKind.organization) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
expect(
|
|
44
|
+
scope.grantableRoles(value.number as ApiResourceKind),
|
|
45
|
+
value.name,
|
|
46
|
+
).toEqual([]);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("the unknown kind answers the empty list and never throws (total)", () => {
|
|
51
|
+
expect(
|
|
52
|
+
scope.grantableRoles(ApiResourceKind.api_resource_kind_unknown),
|
|
53
|
+
).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("answers synchronously — a proto read, never a Promise", () => {
|
|
57
|
+
const answer: unknown = scope.grantableRoles(ApiResourceKind.organization);
|
|
58
|
+
expect(answer).not.toBeInstanceOf(Promise);
|
|
59
|
+
expect(Array.isArray(answer)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|