okengine 0.15.2 → 0.16.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/package.json +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
|
@@ -132,6 +132,10 @@ describe("projectAccessPanel", () => {
|
|
|
132
132
|
expect(projection.userPlane.grantableScopes.every((s) => !s.startsWith("console:"))).toBe(true);
|
|
133
133
|
|
|
134
134
|
expect(projection.hygiene.unusedKeys.some((k) => k.id === "key_stale")).toBe(true);
|
|
135
|
+
expect(projection.userPlane.keys.find((k) => k.id === "key_stale")?.creatorId).toBe("op1");
|
|
136
|
+
expect(projection.userPlane.keys.find((k) => k.id === "key_stale")?.creatorScopes).toEqual([
|
|
137
|
+
"member",
|
|
138
|
+
]);
|
|
135
139
|
expect(projection.hygiene.neverSignedInOperators.some((o) => o.id === "op_never")).toBe(true);
|
|
136
140
|
expect(projection.hygiene.expiredInvitations.some((i) => i.id === "inv1")).toBe(true);
|
|
137
141
|
});
|
|
@@ -154,6 +158,40 @@ describe("projectAccessPanel", () => {
|
|
|
154
158
|
expect(projection.operatorPlane.grantableScopes).toContain("console:store.sql:read");
|
|
155
159
|
expect(projection.operatorPlane.grantableScopes).not.toContain("console:store.sql:write");
|
|
156
160
|
});
|
|
161
|
+
|
|
162
|
+
test("issuer scopes drop role grants that are not in the catalog", () => {
|
|
163
|
+
const auth = createDefaultGateAuthStores();
|
|
164
|
+
setRoleGrants(auth.roles, "role_member", ["member", "booking:create"]);
|
|
165
|
+
const projection = projectAccessPanel({
|
|
166
|
+
manifest: {
|
|
167
|
+
oke: "1.0",
|
|
168
|
+
app: "keel-like",
|
|
169
|
+
flows: {},
|
|
170
|
+
gates: {
|
|
171
|
+
member: { kind: "policy", scopes: ["member"] },
|
|
172
|
+
"task:write": { kind: "policy", scopes: ["task:write"] },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
roles: auth.roles,
|
|
176
|
+
apiKeys: auth.apiKeys,
|
|
177
|
+
operators: createOperatorStore(),
|
|
178
|
+
invites: createOperatorInviteStore(),
|
|
179
|
+
identities: [
|
|
180
|
+
{
|
|
181
|
+
id: "user_demo",
|
|
182
|
+
email: "demo@example.com",
|
|
183
|
+
name: "Demo",
|
|
184
|
+
status: "active",
|
|
185
|
+
scopes: ["member", "task:write"],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
roleMembers: auth.roleMembers,
|
|
189
|
+
actorScopes: ["console:*"],
|
|
190
|
+
now: () => 0,
|
|
191
|
+
});
|
|
192
|
+
expect(projection.userPlane.users?.[0]?.scopes).toEqual(["member", "task:write"]);
|
|
193
|
+
expect(projection.userPlane.users?.[0]?.scopes).not.toContain("booking:create");
|
|
194
|
+
});
|
|
157
195
|
});
|
|
158
196
|
|
|
159
197
|
describe("effectivePermissions", () => {
|
|
@@ -213,7 +251,7 @@ describe("keyBlastRadius", () => {
|
|
|
213
251
|
flow: "bookings.create",
|
|
214
252
|
trigger: "http",
|
|
215
253
|
plane: "user",
|
|
216
|
-
principal: "
|
|
254
|
+
principal: "u",
|
|
217
255
|
gates: [],
|
|
218
256
|
cache: "none",
|
|
219
257
|
effects: [],
|
|
@@ -221,14 +259,14 @@ describe("keyBlastRadius", () => {
|
|
|
221
259
|
durationMs: 10,
|
|
222
260
|
startedAt: 100,
|
|
223
261
|
endedAt: 110,
|
|
224
|
-
dimensions: { ip: "203.0.113.10" },
|
|
262
|
+
dimensions: { api_key: "key_demo", ip: "203.0.113.10" },
|
|
225
263
|
},
|
|
226
264
|
{
|
|
227
265
|
id: "r2",
|
|
228
266
|
flow: "bookings.create",
|
|
229
267
|
trigger: "http",
|
|
230
268
|
plane: "user",
|
|
231
|
-
principal: "
|
|
269
|
+
principal: "u",
|
|
232
270
|
gates: [],
|
|
233
271
|
cache: "none",
|
|
234
272
|
effects: [],
|
|
@@ -236,7 +274,7 @@ describe("keyBlastRadius", () => {
|
|
|
236
274
|
durationMs: 10,
|
|
237
275
|
startedAt: 200,
|
|
238
276
|
endedAt: 210,
|
|
239
|
-
dimensions: { source_ip: "198.51.100.7" },
|
|
277
|
+
dimensions: { api_key: "key_demo", source_ip: "198.51.100.7" },
|
|
240
278
|
},
|
|
241
279
|
];
|
|
242
280
|
const ttl = 7 * 60 * 1000;
|
|
@@ -262,14 +300,25 @@ describe("accessCreateKey / revoke", () => {
|
|
|
262
300
|
plane: "user",
|
|
263
301
|
name: "k",
|
|
264
302
|
scopes: ["member"],
|
|
265
|
-
creatorId: "
|
|
266
|
-
creatorScopes: ["
|
|
303
|
+
creatorId: "user_demo",
|
|
304
|
+
creatorScopes: ["member"],
|
|
267
305
|
catalog: ["member", "booking:create", "console:store.sql:read"],
|
|
268
306
|
now: () => 1,
|
|
269
307
|
});
|
|
270
308
|
expect(created.secret.startsWith("oke_")).toBe(true);
|
|
271
309
|
expect(created.row.scopes).toEqual(["member"]);
|
|
272
310
|
|
|
311
|
+
await expect(
|
|
312
|
+
accessCreateKey(store, {
|
|
313
|
+
plane: "user",
|
|
314
|
+
name: "wide",
|
|
315
|
+
scopes: ["booking:create"],
|
|
316
|
+
creatorId: "user_demo",
|
|
317
|
+
creatorScopes: ["member"],
|
|
318
|
+
catalog: ["member", "booking:create"],
|
|
319
|
+
}),
|
|
320
|
+
).rejects.toThrow();
|
|
321
|
+
|
|
273
322
|
await expect(
|
|
274
323
|
accessCreateKey(store, {
|
|
275
324
|
plane: "operator",
|
|
@@ -311,11 +360,70 @@ describe("accessCreateKey / revoke", () => {
|
|
|
311
360
|
});
|
|
312
361
|
});
|
|
313
362
|
|
|
363
|
+
describe("resolveAccessKeyIssuer", () => {
|
|
364
|
+
test("user-plane create without creatorUserId fails; scopes cannot exceed the user", async () => {
|
|
365
|
+
const { resolveAccessKeyIssuer } = await import("./flows.ts");
|
|
366
|
+
const state = {
|
|
367
|
+
identities: [
|
|
368
|
+
{
|
|
369
|
+
id: "user_u",
|
|
370
|
+
email: "u@example.com",
|
|
371
|
+
name: "U",
|
|
372
|
+
status: "active" as const,
|
|
373
|
+
scopes: ["member"],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
roleMembers: new Map<string, string[]>(),
|
|
377
|
+
roles: createRoleStore(),
|
|
378
|
+
operators: { roles: new Map() },
|
|
379
|
+
};
|
|
380
|
+
expect(resolveAccessKeyIssuer(state as never, "op1", { plane: "user" })).toEqual({
|
|
381
|
+
error: "creatorUserId is required for user-plane keys",
|
|
382
|
+
});
|
|
383
|
+
const issued = resolveAccessKeyIssuer(state as never, "op1", {
|
|
384
|
+
plane: "user",
|
|
385
|
+
creatorUserId: "user_u",
|
|
386
|
+
});
|
|
387
|
+
expect(issued).toEqual({ creatorId: "user_u", creatorScopes: ["member"] });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("user-plane ceiling includes role grants, not only identity.scopes", async () => {
|
|
391
|
+
const { resolveAccessKeyIssuer } = await import("./flows.ts");
|
|
392
|
+
const roles = createRoleStore();
|
|
393
|
+
upsertRole(roles, {
|
|
394
|
+
id: "role_member",
|
|
395
|
+
name: "member",
|
|
396
|
+
plane: "user",
|
|
397
|
+
description: "",
|
|
398
|
+
});
|
|
399
|
+
setRoleGrants(roles, "role_member", ["member", "task:write"]);
|
|
400
|
+
const issued = resolveAccessKeyIssuer(
|
|
401
|
+
{
|
|
402
|
+
identities: [
|
|
403
|
+
{
|
|
404
|
+
id: "user_u",
|
|
405
|
+
email: "u@example.com",
|
|
406
|
+
name: "U",
|
|
407
|
+
status: "active" as const,
|
|
408
|
+
scopes: ["member"],
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
roleMembers: new Map([["role_member", ["user_u"]]]),
|
|
412
|
+
roles,
|
|
413
|
+
operators: { roles: new Map() },
|
|
414
|
+
} as never,
|
|
415
|
+
"op1",
|
|
416
|
+
{ plane: "user", creatorUserId: "user_u" },
|
|
417
|
+
);
|
|
418
|
+
expect(issued).toEqual({ creatorId: "user_u", creatorScopes: ["member", "task:write"] });
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
314
422
|
describe("helpers", () => {
|
|
315
|
-
test("expandAccessCeiling
|
|
423
|
+
test("expandAccessCeiling expands console:* to console catalog only", () => {
|
|
316
424
|
const held = expandAccessCeiling(["console:*"], ["console:store.sql:read", "booking:create"]);
|
|
317
425
|
expect(held.has("console:store.sql:read")).toBe(true);
|
|
318
|
-
expect(held.has("booking:create")).toBe(
|
|
426
|
+
expect(held.has("booking:create")).toBe(false);
|
|
319
427
|
});
|
|
320
428
|
|
|
321
429
|
test("isKeyUnused90d", () => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
revokeApiKey,
|
|
20
20
|
revokeFamily,
|
|
21
21
|
rotateApiKey,
|
|
22
|
+
updateApiKey,
|
|
22
23
|
setRoleGrants,
|
|
23
24
|
scopesForRoles,
|
|
24
25
|
type ApiKeyStore,
|
|
@@ -81,6 +82,10 @@ export interface AccessKeyRow {
|
|
|
81
82
|
readonly id: string;
|
|
82
83
|
readonly name: string;
|
|
83
84
|
readonly plane: "user" | "operator";
|
|
85
|
+
/** Stored issuer — user id or operator id. Frozen after mint. */
|
|
86
|
+
readonly creatorId: string;
|
|
87
|
+
/** Issuer ceiling stored on the key — edit scopes stay at or under this. */
|
|
88
|
+
readonly creatorScopes: readonly string[];
|
|
84
89
|
readonly scopes: readonly string[];
|
|
85
90
|
readonly createdAt: number;
|
|
86
91
|
readonly lastUsedAt: number | null;
|
|
@@ -205,7 +210,7 @@ export function projectAccessPanel(options: ProjectAccessOptions): AccessPanelPr
|
|
|
205
210
|
});
|
|
206
211
|
|
|
207
212
|
const operators = projectOperators(options);
|
|
208
|
-
const users = projectUsers(options);
|
|
213
|
+
const users = projectUsers(options, catalog);
|
|
209
214
|
const roles = projectRoles(options);
|
|
210
215
|
const keys = projectKeys(options, now);
|
|
211
216
|
const invites = projectInvites(options, now);
|
|
@@ -497,6 +502,36 @@ export async function accessRotateKey(
|
|
|
497
502
|
};
|
|
498
503
|
}
|
|
499
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Update key metadata. Scope changes re-attenuate against stored creatorScopes.
|
|
507
|
+
*
|
|
508
|
+
* @param store - API key store
|
|
509
|
+
* @param input - Patch
|
|
510
|
+
*/
|
|
511
|
+
export function accessUpdateKey(
|
|
512
|
+
store: ApiKeyStore,
|
|
513
|
+
input: {
|
|
514
|
+
readonly keyId: string;
|
|
515
|
+
readonly name?: string;
|
|
516
|
+
readonly scopes?: readonly string[];
|
|
517
|
+
readonly expiresAt?: number | null;
|
|
518
|
+
readonly rateLimit?: { max: number; per: string } | null;
|
|
519
|
+
readonly ipAllowlist?: readonly string[];
|
|
520
|
+
},
|
|
521
|
+
): AccessKeyRow | null {
|
|
522
|
+
const existing = store.keys.get(input.keyId);
|
|
523
|
+
if (!existing) return null;
|
|
524
|
+
const row = updateApiKey(store, input.keyId, {
|
|
525
|
+
ceiling: existing.creatorScopes,
|
|
526
|
+
name: input.name,
|
|
527
|
+
scopes: input.scopes,
|
|
528
|
+
expiresAt: input.expiresAt,
|
|
529
|
+
rateLimit: input.rateLimit,
|
|
530
|
+
ipAllowlist: input.ipAllowlist,
|
|
531
|
+
});
|
|
532
|
+
return row ? toKeyRow(row, () => Date.now()) : null;
|
|
533
|
+
}
|
|
534
|
+
|
|
500
535
|
/**
|
|
501
536
|
* Create an operator invitation.
|
|
502
537
|
*
|
|
@@ -528,10 +563,9 @@ export class AccessGrantError extends Error {
|
|
|
528
563
|
/**
|
|
529
564
|
* Expand held scopes for Access administration.
|
|
530
565
|
*
|
|
531
|
-
* `console:*` covers every `console:…` pair
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
* (which Gates would flag as a plane violation).
|
|
566
|
+
* `console:*` covers every `console:…` pair in the catalog. Application
|
|
567
|
+
* scopes are never added — user-plane keys are attenuated to the issuer's
|
|
568
|
+
* own grants (`scopesForRoles`), not the operator catalog.
|
|
535
569
|
*
|
|
536
570
|
* @param held - Actor scopes
|
|
537
571
|
* @param catalog - Manifest Module:Action pairs
|
|
@@ -540,17 +574,7 @@ export function expandAccessCeiling(
|
|
|
540
574
|
held: Iterable<string>,
|
|
541
575
|
catalog: readonly string[],
|
|
542
576
|
): Set<string> {
|
|
543
|
-
|
|
544
|
-
let star = false;
|
|
545
|
-
for (const s of held) {
|
|
546
|
-
if (s === "console:*") star = true;
|
|
547
|
-
}
|
|
548
|
-
if (star) {
|
|
549
|
-
for (const scope of catalog) {
|
|
550
|
-
if (isApplicationScope(scope)) out.add(scope);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
return out;
|
|
577
|
+
return expandHeldScopes(held, catalog);
|
|
554
578
|
}
|
|
555
579
|
|
|
556
580
|
function projectOperators(options: ProjectAccessOptions): AccessOperatorRow[] {
|
|
@@ -574,14 +598,35 @@ function projectOperators(options: ProjectAccessOptions): AccessOperatorRow[] {
|
|
|
574
598
|
return rows.sort((a, b) => a.email.localeCompare(b.email));
|
|
575
599
|
}
|
|
576
600
|
|
|
577
|
-
|
|
601
|
+
/**
|
|
602
|
+
* User-plane issuer ceiling — identity scopes plus role grants.
|
|
603
|
+
* Create must use this union; the list already did.
|
|
604
|
+
*
|
|
605
|
+
* @param options - User + roles
|
|
606
|
+
*/
|
|
607
|
+
export function accessUserCeiling(options: {
|
|
608
|
+
readonly userId: string;
|
|
609
|
+
readonly identityScopes?: Iterable<string>;
|
|
610
|
+
readonly roles: RoleStore;
|
|
611
|
+
readonly roleMembers: ReadonlyMap<string, readonly string[]>;
|
|
612
|
+
}): string[] {
|
|
613
|
+
const roleIds = roleIdsForMember(options.roleMembers, options.userId);
|
|
614
|
+
const fromRoles = [...scopesForRoles(options.roles, roleIds, "user")];
|
|
615
|
+
const fromIdentity = [...(options.identityScopes ?? [])];
|
|
616
|
+
return [...new Set([...fromRoles, ...fromIdentity])].sort((a, b) => a.localeCompare(b));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function projectUsers(options: ProjectAccessOptions, catalog: readonly string[]): AccessUserRow[] {
|
|
620
|
+
const allowed = new Set(catalog);
|
|
578
621
|
return options.identities
|
|
579
622
|
.map((identity) => {
|
|
580
623
|
const roleIds = roleIdsForMember(options.roleMembers, identity.id);
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
624
|
+
const scopes = accessUserCeiling({
|
|
625
|
+
userId: identity.id,
|
|
626
|
+
identityScopes: identity.scopes,
|
|
627
|
+
roles: options.roles,
|
|
628
|
+
roleMembers: options.roleMembers,
|
|
629
|
+
}).filter((scope) => allowed.has(scope));
|
|
585
630
|
return {
|
|
586
631
|
id: identity.id,
|
|
587
632
|
email: identity.email,
|
|
@@ -637,6 +682,8 @@ function toKeyRow(
|
|
|
637
682
|
readonly id: string;
|
|
638
683
|
readonly name: string;
|
|
639
684
|
readonly plane: "user" | "operator";
|
|
685
|
+
readonly creatorId: string;
|
|
686
|
+
readonly creatorScopes: readonly string[];
|
|
640
687
|
readonly scopes: readonly string[];
|
|
641
688
|
readonly createdAt: number;
|
|
642
689
|
readonly lastUsedAt: number | null;
|
|
@@ -652,6 +699,8 @@ function toKeyRow(
|
|
|
652
699
|
id: key.id,
|
|
653
700
|
name: key.name,
|
|
654
701
|
plane: key.plane,
|
|
702
|
+
creatorId: key.creatorId,
|
|
703
|
+
creatorScopes: [...key.creatorScopes].sort((a, b) => a.localeCompare(b)),
|
|
655
704
|
scopes: [...key.scopes].sort((a, b) => a.localeCompare(b)),
|
|
656
705
|
createdAt: key.createdAt,
|
|
657
706
|
lastUsedAt: key.lastUsedAt,
|
|
@@ -717,9 +766,7 @@ function roleIdsForMember(
|
|
|
717
766
|
}
|
|
718
767
|
|
|
719
768
|
function runTouchesKey(run: WideEvent, keyId: string): boolean {
|
|
720
|
-
if (run.principal === keyId) return true;
|
|
721
769
|
const dims = run.dimensions;
|
|
722
|
-
if (dims.principal === keyId) return true;
|
|
723
770
|
if (dims.api_key === keyId || dims.apiKey === keyId || dims.key === keyId) {
|
|
724
771
|
return true;
|
|
725
772
|
}
|
|
@@ -137,6 +137,7 @@ export function createConsoleApp(options: CreateConsoleAppOptions = {}): Console
|
|
|
137
137
|
accessCreateKey: routes.access.createKey,
|
|
138
138
|
accessRevokeKey: routes.access.revokeKey,
|
|
139
139
|
accessRotateKey: routes.access.rotateKey,
|
|
140
|
+
accessUpdateKey: routes.access.updateKey,
|
|
140
141
|
accessSetRoleGrants: routes.access.setRoleGrants,
|
|
141
142
|
diffList: routes.diff.list,
|
|
142
143
|
clockList: routes.clock.list,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default invoke-as ladder — ten rungs, historic demo + member ids.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { Manifest } from "../../manifest/types.ts";
|
|
7
|
+
import { createRoleStore } from "../../auth/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEV_IDENTITY_LADDER,
|
|
10
|
+
KEEL_SCOPES,
|
|
11
|
+
defaultDevIdentities,
|
|
12
|
+
defaultMemberIdentityIds,
|
|
13
|
+
demoScopesFromManifest,
|
|
14
|
+
isDefaultIdentitySeed,
|
|
15
|
+
refreshSeededIdentities,
|
|
16
|
+
scopesForDevIdentityRung,
|
|
17
|
+
seedKeelAccessRoles,
|
|
18
|
+
} from "./dev-identities.ts";
|
|
19
|
+
|
|
20
|
+
const KEEL_LIKE: Manifest = {
|
|
21
|
+
oke: "1.0",
|
|
22
|
+
app: "keel-like",
|
|
23
|
+
gates: {
|
|
24
|
+
member: { kind: "policy", scopes: ["member"] },
|
|
25
|
+
"task:write": { kind: "policy", scopes: ["task:write"] },
|
|
26
|
+
"comment:write": { kind: "policy", scopes: ["comment:write"] },
|
|
27
|
+
"files:write": { kind: "policy", scopes: ["files:write"] },
|
|
28
|
+
"project:admin": { kind: "policy", scopes: ["project:admin"] },
|
|
29
|
+
"member:admin": { kind: "policy", scopes: ["member:admin"] },
|
|
30
|
+
"webhook:admin": { kind: "policy", scopes: ["webhook:admin"] },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("DEV_IDENTITY_LADDER", () => {
|
|
35
|
+
test("is ten people from owner to guest and keeps demo + member", () => {
|
|
36
|
+
expect(DEV_IDENTITY_LADDER).toHaveLength(10);
|
|
37
|
+
expect(DEV_IDENTITY_LADDER[0]).toMatchObject({
|
|
38
|
+
id: "user_demo",
|
|
39
|
+
email: "demo@example.com",
|
|
40
|
+
role: "owner",
|
|
41
|
+
});
|
|
42
|
+
expect(DEV_IDENTITY_LADDER[7]).toMatchObject({
|
|
43
|
+
id: "user_member",
|
|
44
|
+
email: "member@example.com",
|
|
45
|
+
role: "member",
|
|
46
|
+
});
|
|
47
|
+
expect(DEV_IDENTITY_LADDER[9]?.role).toBe("guest");
|
|
48
|
+
expect(new Set(DEV_IDENTITY_LADDER.map((p) => p.role)).size).toBe(10);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("defaultDevIdentities", () => {
|
|
53
|
+
test("demo follows the Manifest; member stays member-only", () => {
|
|
54
|
+
const rows = defaultDevIdentities({
|
|
55
|
+
oke: "1.0",
|
|
56
|
+
app: "keel-like",
|
|
57
|
+
gates: {
|
|
58
|
+
member: { kind: "policy", scopes: ["member"] },
|
|
59
|
+
"task:write": { kind: "policy", scopes: ["task:write"] },
|
|
60
|
+
"booking:create": { kind: "policy", scopes: ["booking:create"] },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const demo = rows.find((row) => row.id === "user_demo");
|
|
64
|
+
const member = rows.find((row) => row.id === "user_member");
|
|
65
|
+
const guest = rows.find((row) => row.id === "user_guest");
|
|
66
|
+
expect(demo?.scopes).toEqual(["booking:create", "member", "task:write"]);
|
|
67
|
+
expect(member?.scopes).toEqual(["member"]);
|
|
68
|
+
expect(guest?.scopes).toEqual([]);
|
|
69
|
+
expect(demo?.scopes).not.toEqual(member?.scopes);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("keel profiles step down from owner to guest", () => {
|
|
73
|
+
const catalog = demoScopesFromManifest(KEEL_LIKE);
|
|
74
|
+
const owner = scopesForDevIdentityRung(0, catalog);
|
|
75
|
+
const admin = scopesForDevIdentityRung(1, catalog);
|
|
76
|
+
const member = scopesForDevIdentityRung(7, catalog);
|
|
77
|
+
const guest = scopesForDevIdentityRung(9, catalog);
|
|
78
|
+
expect(owner).toEqual(catalog);
|
|
79
|
+
expect(admin).toEqual(catalog);
|
|
80
|
+
expect(scopesForDevIdentityRung(2, catalog).length).toBeLessThan(admin.length);
|
|
81
|
+
expect(member).toEqual(["member"]);
|
|
82
|
+
expect(guest).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("refreshSeededIdentities", () => {
|
|
87
|
+
test("replaces the default ladder and ignores a custom list", () => {
|
|
88
|
+
const seeded = defaultDevIdentities();
|
|
89
|
+
refreshSeededIdentities(seeded, KEEL_LIKE);
|
|
90
|
+
expect(seeded.find((row) => row.id === "user_demo")?.scopes).toContain("task:write");
|
|
91
|
+
expect(isDefaultIdentitySeed(seeded)).toBe(true);
|
|
92
|
+
|
|
93
|
+
const custom = [{ ...seeded[0]!, id: "user_custom" }];
|
|
94
|
+
refreshSeededIdentities(custom, KEEL_LIKE);
|
|
95
|
+
expect(custom).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("keel scope seed", () => {
|
|
100
|
+
test("keel Manifest unions every declared Gate scope onto owner", () => {
|
|
101
|
+
const rows = defaultDevIdentities({ oke: "1.0", app: "keel" });
|
|
102
|
+
const demo = rows.find((row) => row.id === "user_demo");
|
|
103
|
+
expect(demo?.scopes).toEqual([...KEEL_SCOPES].sort((a, b) => a.localeCompare(b)));
|
|
104
|
+
expect(rows.find((row) => row.id === "user_admin")?.scopes).toEqual([...KEEL_SCOPES].sort());
|
|
105
|
+
expect(rows.find((row) => row.id === "user_member")?.scopes).toEqual(["member"]);
|
|
106
|
+
expect(rows.find((row) => row.id === "user_guest")?.scopes).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("seedKeelAccessRoles writes every Keel scope onto owner", () => {
|
|
110
|
+
const roles = createRoleStore();
|
|
111
|
+
const roleMembers = new Map<string, string[]>();
|
|
112
|
+
seedKeelAccessRoles(roles, roleMembers);
|
|
113
|
+
expect([...(roles.grants.get("role_owner") ?? [])].sort()).toEqual(
|
|
114
|
+
[...KEEL_SCOPES].sort((a, b) => a.localeCompare(b)),
|
|
115
|
+
);
|
|
116
|
+
expect(KEEL_SCOPES.every((scope) => roles.grants.get("role_owner")?.has(scope))).toBe(true);
|
|
117
|
+
expect(roleMembers.get("role_owner")).toEqual(["user_demo"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("defaultMemberIdentityIds", () => {
|
|
122
|
+
test("omits guest", () => {
|
|
123
|
+
expect(defaultMemberIdentityIds()).not.toContain("user_guest");
|
|
124
|
+
expect(defaultMemberIdentityIds()).toContain("user_demo");
|
|
125
|
+
expect(defaultMemberIdentityIds()).toContain("user_member");
|
|
126
|
+
});
|
|
127
|
+
});
|