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