@stigmer/server 3.31.0 → 3.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/authorization/model/data/authorization-model.json +16 -46
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +11 -1
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
  6. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  7. package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
  8. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  9. package/dist/domain/apikey/controller.d.ts.map +1 -1
  10. package/dist/domain/apikey/controller.js +7 -2
  11. package/dist/domain/apikey/controller.js.map +1 -1
  12. package/dist/domain/apikey/verifier.d.ts +5 -0
  13. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  14. package/dist/domain/apikey/verifier.js +32 -3
  15. package/dist/domain/apikey/verifier.js.map +1 -1
  16. package/dist/domain/iampolicy/constants.d.ts +11 -1
  17. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  18. package/dist/domain/iampolicy/constants.js +11 -1
  19. package/dist/domain/iampolicy/constants.js.map +1 -1
  20. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  21. package/dist/domain/iampolicy/controller.js +36 -12
  22. package/dist/domain/iampolicy/controller.js.map +1 -1
  23. package/dist/domain/iampolicy/steps.d.ts +98 -0
  24. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  25. package/dist/domain/iampolicy/steps.js +119 -4
  26. package/dist/domain/iampolicy/steps.js.map +1 -1
  27. package/dist/domain/platformclient/constants.d.ts +11 -1
  28. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  29. package/dist/domain/platformclient/constants.js +11 -1
  30. package/dist/domain/platformclient/constants.js.map +1 -1
  31. package/dist/domain/platformclient/mint.d.ts.map +1 -1
  32. package/dist/domain/platformclient/mint.js +28 -1
  33. package/dist/domain/platformclient/mint.js.map +1 -1
  34. package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
  35. package/dist/domain/platformclient/resource-store.js +13 -0
  36. package/dist/domain/platformclient/resource-store.js.map +1 -1
  37. package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
  38. package/dist/domain/platformclient/store-contract.js +59 -0
  39. package/dist/domain/platformclient/store-contract.js.map +1 -1
  40. package/dist/domain/platformclient/store.d.ts +20 -0
  41. package/dist/domain/platformclient/store.d.ts.map +1 -1
  42. package/dist/domain/platformclient/store.js.map +1 -1
  43. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  44. package/dist/domain/platformclient/verifier.js +7 -2
  45. package/dist/domain/platformclient/verifier.js.map +1 -1
  46. package/dist/extensions/identity.d.ts +14 -0
  47. package/dist/extensions/identity.d.ts.map +1 -1
  48. package/dist/extensions/identity.js.map +1 -1
  49. package/dist/identity/credential-use.d.ts +74 -0
  50. package/dist/identity/credential-use.d.ts.map +1 -0
  51. package/dist/identity/credential-use.js +59 -0
  52. package/dist/identity/credential-use.js.map +1 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/pipeline/steps/defaults.d.ts +15 -1
  58. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  59. package/dist/pipeline/steps/defaults.js +31 -1
  60. package/dist/pipeline/steps/defaults.js.map +1 -1
  61. package/dist/temporal/schedule/activities.d.ts.map +1 -1
  62. package/dist/temporal/schedule/activities.js +2 -1
  63. package/dist/temporal/schedule/activities.js.map +1 -1
  64. package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
  65. package/dist/temporal/schedule/run-starter.js +2 -1
  66. package/dist/temporal/schedule/run-starter.js.map +1 -1
  67. package/dist/temporal/schedule/status-writes.d.ts +0 -6
  68. package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
  69. package/dist/temporal/schedule/status-writes.js +8 -27
  70. package/dist/temporal/schedule/status-writes.js.map +1 -1
  71. package/dist/temporal/schedule/syncer.d.ts.map +1 -1
  72. package/dist/temporal/schedule/syncer.js +2 -1
  73. package/dist/temporal/schedule/syncer.js.map +1 -1
  74. package/package.json +6 -6
  75. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  76. package/src/authorization/model/data/authorization-model.json +16 -46
  77. package/src/boot/compose.ts +11 -1
  78. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  79. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  80. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  81. package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
  82. package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
  83. package/src/domain/apikey/controller.ts +12 -2
  84. package/src/domain/apikey/verifier.ts +46 -3
  85. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  86. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  87. package/src/domain/iampolicy/constants.ts +15 -1
  88. package/src/domain/iampolicy/controller.ts +43 -9
  89. package/src/domain/iampolicy/steps.ts +164 -4
  90. package/src/domain/platformclient/__tests__/mint.test.ts +104 -12
  91. package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
  92. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  93. package/src/domain/platformclient/constants.ts +11 -1
  94. package/src/domain/platformclient/mint.ts +34 -1
  95. package/src/domain/platformclient/resource-store.ts +18 -0
  96. package/src/domain/platformclient/store-contract.ts +100 -0
  97. package/src/domain/platformclient/store.ts +23 -0
  98. package/src/domain/platformclient/verifier.ts +7 -2
  99. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  100. package/src/extensions/identity.ts +14 -0
  101. package/src/identity/__tests__/credential-use.test.ts +186 -0
  102. package/src/identity/credential-use.ts +130 -0
  103. package/src/index.ts +5 -0
  104. package/src/pipeline/__tests__/steps.test.ts +72 -1
  105. package/src/pipeline/steps/defaults.ts +33 -1
  106. package/src/temporal/schedule/activities.ts +2 -1
  107. package/src/temporal/schedule/run-starter.ts +2 -1
  108. package/src/temporal/schedule/status-writes.ts +8 -31
  109. package/src/temporal/schedule/syncer.ts +2 -1
@@ -16,10 +16,14 @@
16
16
  * no instance layers, and the session's own MCP servers are the whole
17
17
  * tool set.
18
18
  *
19
- * Merge priority (lowest to highest): schedule/workflow-task
20
- * environment_refs (the share/channel/schedule/agent_call layering) →
21
- * instance environment_refs (via the environment RuntimeResolutionService
22
- * — decrypted, the RPC surface redacts, oss#405) → spec.runtime_env.
19
+ * Merge priority (lowest to highest): the minting PlatformClient's
20
+ * environment_refs, for an execution a PlatformClient-minted user created
21
+ * (#1256) → schedule/workflow-task environment_refs (the
22
+ * share/channel/schedule/agent_call layering) → instance environment_refs
23
+ * (via the environment RuntimeResolutionService — decrypted, the RPC
24
+ * surface redacts, oss#405) → spec.runtime_env. Every layer is keyed on
25
+ * the persisted execution alone (its labels, its audit), never on the
26
+ * request's caller, so recovery rebuilds exactly what create built.
23
27
  *
24
28
  * Then ONE declaration rule for every shape: the run declares what its
25
29
  * agent declares and what its session's MCP servers declare, and the
@@ -96,6 +100,7 @@ import type { PersonalEnvironmentResolution } from "../environment/personal.js";
96
100
  import type { RuntimeResolutionService } from "../environment/resolution/resolution.js";
97
101
  import type { ManagedEnvironmentService } from "../mcpserver/oauth/managed-env.js";
98
102
  import { refreshTokenIfExpired } from "../mcpserver/oauth/refresh.js";
103
+ import type { PlatformClientStore } from "../platformclient/store.js";
99
104
  import { unmarshalTaskConfig } from "../workflow/converter/unmarshal.js";
100
105
 
101
106
  import { DEFAULT_INSTANCE_ID_KEY } from "./create-steps.js";
@@ -160,6 +165,12 @@ export interface ExecutionContextBuilderDeps {
160
165
  readonly environmentResolution: RuntimeResolutionService;
161
166
  readonly executionContextCreator: () => ExecutionContextCreator;
162
167
  readonly managedEnvService: ManagedEnvironmentService;
168
+ /**
169
+ * The PlatformClient port the minting client's environment layer reads
170
+ * (4.7). The port, not the Store: a composition may keep client rows in
171
+ * a table of its own (domain/platformclient/store.ts).
172
+ */
173
+ readonly platformClients: Pick<PlatformClientStore, "findById">;
163
174
  /** Test seam forwarded to the OAuth token-endpoint refresh. */
164
175
  readonly fetchImpl?: typeof fetch;
165
176
  }
@@ -323,6 +334,22 @@ export async function buildAndPersistExecutionContext(
323
334
  environments = [...workflowEnvironments, ...environments];
324
335
  }
325
336
 
337
+ // 4.7 PlatformClient-minted executions: the minting client's own
338
+ // environment_refs, BELOW every other layer — the fifth application of
339
+ // the connection-resource mechanism (#381, restored by #1256). The key
340
+ // is the execution's audit created_by, which the server stamped from
341
+ // the verified token, so it holds on recover, where no minted caller
342
+ // exists. Independent of 4.5/4.6: a minted user's own turn carries no
343
+ // schedule or workflow provenance, and a fire or a workflow task stamps
344
+ // no minted actor.
345
+ const platformClientEnvironments = await resolvePlatformClientEnvironments(
346
+ deps,
347
+ execution,
348
+ );
349
+ if (platformClientEnvironments.length > 0) {
350
+ environments = [...platformClientEnvironments, ...environments];
351
+ }
352
+
326
353
  // 5. Merge all layers.
327
354
  const merged = mergeEnvironmentLayers(
328
355
  environments,
@@ -745,6 +772,85 @@ async function resolveScheduleEnvironments(
745
772
  }
746
773
  }
747
774
 
775
+ /**
776
+ * Resolves the environment_refs of the PlatformClient whose minted user
777
+ * created this execution (its audit created_by.platform_client_id, stamped
778
+ * by the server from the verified token). No minting client — every
779
+ * execution not created by a minted user — answers empty without a read.
780
+ * A DELETED client degrades to no client environments (the schedule
781
+ * layer's posture; its tokens are already refused). A client of another
782
+ * organization than the execution's contributes nothing: no environment
783
+ * value crosses an organization, the run-time twin of the reference
784
+ * rule's cross-organization clause (pipeline/steps/references.ts). An
785
+ * expired client still contributes: expiry stops minting, not the tokens
786
+ * already minted. Environment visibility is not consulted, as for every
787
+ * layer (the reference rule's reasoning: the server resolves the
788
+ * environment on the run's behalf). An unresolvable REF fails the create.
789
+ */
790
+ async function resolvePlatformClientEnvironments(
791
+ deps: ExecutionContextBuilderDeps,
792
+ execution: AgentExecution,
793
+ ): Promise<Environment[]> {
794
+ const platformClientId =
795
+ execution.status?.audit?.specAudit?.createdBy?.platformClientId ?? "";
796
+ if (platformClientId === "") {
797
+ return [];
798
+ }
799
+ const executionId = execution.metadata?.id ?? "";
800
+
801
+ let client;
802
+ try {
803
+ client = await deps.platformClients.findById(platformClientId);
804
+ } catch (error) {
805
+ throw new Error(
806
+ `load platform client ${platformClientId} for environment resolution: ${error instanceof Error ? error.message : String(error)}`,
807
+ );
808
+ }
809
+ if (client === undefined) {
810
+ deps.logger.warn(
811
+ "Minting platform client is gone — running without its environments",
812
+ { platformClientId, executionId },
813
+ );
814
+ return [];
815
+ }
816
+
817
+ const clientOrg = client.metadata?.org ?? "";
818
+ const executionOrg = execution.metadata?.org ?? "";
819
+ if (clientOrg !== executionOrg) {
820
+ deps.logger.warn(
821
+ "Minting platform client belongs to another organization — its environments are not delivered",
822
+ { platformClientId, clientOrg, executionOrg, executionId },
823
+ );
824
+ return [];
825
+ }
826
+
827
+ const refs = client.spec?.environmentRefs ?? [];
828
+ if (refs.length === 0) {
829
+ return [];
830
+ }
831
+ // Stored refs are absolute (NormalizeReferences on the client's writes);
832
+ // an empty org is filled from the client's own, as the schedule layer
833
+ // does for its manifest refs.
834
+ const resolved = refs.map((ref) =>
835
+ ref.org === ""
836
+ ? create(ApiResourceReferenceSchema, {
837
+ kind: ref.kind,
838
+ org: clientOrg,
839
+ slug: ref.slug,
840
+ })
841
+ : ref,
842
+ );
843
+
844
+ try {
845
+ return await resolveEnvironments(deps, resolved);
846
+ } catch (error) {
847
+ chainError(
848
+ `resolve platform client ${platformClientId} environment_refs`,
849
+ error,
850
+ );
851
+ }
852
+ }
853
+
748
854
  /**
749
855
  * Resolves the environment_refs of the agent_call task that created this
750
856
  * execution (the workflow-provenance labels). Missing labels — every
@@ -12,7 +12,9 @@
12
12
  * values; only the expiry fields move;
13
13
  * - getByKeyHash resolves by the stored hash and answers the
14
14
  * byte-pinned "ApiKey not found" for an unknown or deleted hash —
15
- * deletion takes effect on the very next lookup (no cache).
15
+ * deletion takes effect on the very next lookup (no cache);
16
+ * - findAll answers newest first, so a key's last-use stamp rewriting
17
+ * its row never reorders the list (stigmer/stigmer#1255).
16
18
  */
17
19
  import { mkdtempSync, rmSync } from "node:fs";
18
20
  import { tmpdir } from "node:os";
@@ -21,7 +23,7 @@ import path from "node:path";
21
23
  import { Code, ConnectError, createClient } from "@connectrpc/connect";
22
24
  import type { Client, Transport } from "@connectrpc/connect";
23
25
  import { createGrpcTransport } from "@connectrpc/connect-node";
24
- import { timestampFromDate } from "@bufbuild/protobuf/wkt";
26
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
25
27
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
26
28
 
27
29
  import { ApiKeyCommandController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/command_pb";
@@ -250,4 +252,17 @@ describe("apikey domain (composed server)", () => {
250
252
  expect(mine).toBeDefined();
251
253
  expect(mine?.spec?.keyHash).toBe(hashApiKey(plaintext));
252
254
  });
255
+
256
+ it("findAll answers newest first", async () => {
257
+ for (let i = 0; i < 3; i += 1) {
258
+ await command.create(keyInput());
259
+ }
260
+ const created = (await query.findAll({})).entries.map((entry) => {
261
+ const at = entry.status?.audit?.specAudit?.createdAt;
262
+ expect(at, "every stored key carries its creation instant").toBeDefined();
263
+ return timestampDate(at!).getTime();
264
+ });
265
+ expect(created.length).toBeGreaterThanOrEqual(3);
266
+ expect(created).toEqual([...created].sort((a, b) => b - a));
267
+ });
253
268
  });
@@ -15,13 +15,18 @@
15
15
  * the REAL open-source adapter over the same SQLite store the keys live
16
16
  * in, so what is pinned is the production pair; only the fault arm
17
17
  * substitutes a one-method lookup that rejects.
18
+ *
19
+ * An accepted key's use lands on its own row (stigmer/stigmer#1255):
20
+ * stamped on the first verify, left alone inside the resolution, moved
21
+ * forward after it; a refused key records nothing; a stamp that cannot be
22
+ * written is logged and the request is still authenticated.
18
23
  */
19
24
  import { mkdtempSync, rmSync } from "node:fs";
20
25
  import { tmpdir } from "node:os";
21
26
  import path from "node:path";
22
27
 
23
28
  import { create } from "@bufbuild/protobuf";
24
- import { timestampFromDate } from "@bufbuild/protobuf/wkt";
29
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
25
30
  import { Code, ConnectError } from "@connectrpc/connect";
26
31
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
27
32
 
@@ -31,6 +36,9 @@ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityac
31
36
  import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
32
37
 
33
38
  import { createLogger } from "../../../boot/logger.js";
39
+ import type { Logger } from "../../../boot/logger.js";
40
+ import { LAST_USED_RESOLUTION_MS } from "../../../identity/credential-use.js";
41
+ import type { Store } from "../../../store/interface.js";
34
42
  import { SqliteStore } from "../../../store/sqlite/store.js";
35
43
  import { accountIdFor } from "../../identityaccount/constants.js";
36
44
  import type { AccountsBySubject } from "../../identityaccount/resolve.js";
@@ -56,9 +64,24 @@ beforeAll(() => {
56
64
  accounts = newResourceIdentityAccountStore(store);
57
65
  });
58
66
 
67
+ const silent = createLogger({ level: "error", pretty: false, write: () => {} });
68
+
59
69
  /** The verifier as compose builds it: the key store and the account port over ONE store. */
60
- function verifier(lookup: AccountsBySubject = accounts) {
61
- return newApiKeyIdentityVerifier({ store, accounts: lookup });
70
+ function verifier(
71
+ lookup: AccountsBySubject = accounts,
72
+ options: { store?: Store; now?: () => Date; logger?: Logger } = {},
73
+ ) {
74
+ return newApiKeyIdentityVerifier({
75
+ store: options.store ?? store,
76
+ accounts: lookup,
77
+ logger: options.logger ?? silent,
78
+ now: options.now ?? (() => new Date()),
79
+ });
80
+ }
81
+
82
+ /** The key's row as stored now. */
83
+ function storedKey(id: string) {
84
+ return store.getResource(ApiResourceKind.api_key, id, ApiKeySchema);
62
85
  }
63
86
 
64
87
  /** A direct account for `sub` through the real adapter — derived id, as provisioning writes it. */
@@ -302,3 +325,63 @@ describe("creator stamp → account resolution (20260911.11 A6)", () => {
302
325
  expect(reads).toEqual([]);
303
326
  });
304
327
  });
328
+
329
+ describe("last use (stigmer/stigmer#1255)", () => {
330
+ it("an accepted key stamps its last use on its own row, once per resolution, forward after it", async () => {
331
+ const { plaintext, id } = await seedKey();
332
+ let clock = new Date("2026-09-28T12:00:00Z");
333
+ const apikey = verifier(accounts, { now: () => clock });
334
+
335
+ expect((await storedKey(id)).status?.lastUsedAt).toBeUndefined();
336
+ await apikey.verify(plaintext);
337
+ const first = (await storedKey(id)).status;
338
+ expect(timestampDate(first!.lastUsedAt!)).toEqual(clock);
339
+ expect(first?.audit?.statusAudit?.event).toBe("updated");
340
+ expect(
341
+ first?.audit?.statusAudit?.createdBy?.id,
342
+ "the status-audit slot keeps who created the key",
343
+ ).toBe("user@example.com");
344
+ expect(first?.audit?.specAudit?.updatedAt, "the spec audit is not touched").toBeUndefined();
345
+
346
+ clock = new Date(clock.getTime() + LAST_USED_RESOLUTION_MS - 1);
347
+ await apikey.verify(plaintext);
348
+ expect(
349
+ (await storedKey(id)).status,
350
+ "inside the resolution nothing is written",
351
+ ).toEqual(first);
352
+
353
+ clock = new Date(clock.getTime() + 1);
354
+ await apikey.verify(plaintext);
355
+ expect(timestampDate((await storedKey(id)).status!.lastUsedAt!)).toEqual(clock);
356
+ });
357
+
358
+ it("a refused key records nothing", async () => {
359
+ const { plaintext, id } = await seedKey({
360
+ expiresAt: new Date(Date.now() - 60_000),
361
+ });
362
+ await expect(verifier().verify(plaintext)).rejects.toBeInstanceOf(ConnectError);
363
+ expect((await storedKey(id)).status?.lastUsedAt).toBeUndefined();
364
+ });
365
+
366
+ it("a stamp that cannot be written is logged with the key's id and the request is still authenticated", async () => {
367
+ const { plaintext, id } = await seedKey({ ownerId: "ida_resilient" });
368
+ const lines: string[] = [];
369
+ const logger = createLogger({
370
+ level: "warn",
371
+ pretty: false,
372
+ write: (line) => lines.push(line),
373
+ });
374
+ const failingWrites = {
375
+ findByField: store.findByField.bind(store),
376
+ updateResource: () => Promise.reject(new Error("disk full")),
377
+ } as unknown as Store;
378
+
379
+ const identity = await verifier(accounts, { store: failingWrites, logger }).verify(plaintext);
380
+
381
+ expect(identity?.identityId).toBe("ida_resilient");
382
+ expect(lines).toHaveLength(1);
383
+ const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
384
+ expect(entry).toMatchObject({ level: "warn", credential: "api key", id, error: "disk full" });
385
+ expect(lines[0], "the token is never logged").not.toContain(plaintext);
386
+ });
387
+ });
@@ -58,6 +58,7 @@ import {
58
58
  newLoadExistingForDeleteStep,
59
59
  } from "../../pipeline/steps/delete.js";
60
60
  import { newCheckDuplicateStep } from "../../pipeline/steps/duplicate.js";
61
+ import { compareCreatedAtDesc } from "../../pipeline/steps/helpers.js";
61
62
  import {
62
63
  EXISTING_RESOURCE_KEY,
63
64
  newLoadExistingStep,
@@ -289,7 +290,10 @@ async function getByKeyHash(
289
290
  * 9) the list narrows to the caller's can_view keys — the Java
290
291
  * ApiKeyFindAllHandler baseline (no guest arm, no org intersection).
291
292
  * Stored hashes ride the response exactly as the cloud's do — the
292
- * plaintext exists nowhere.
293
+ * plaintext exists nowhere. Newest first, as PlatformClient's listByOrg
294
+ * answers: the store's scan has no order, and a key's own last-use stamp
295
+ * rewrites its row, so an unsorted list would reorder as keys are used
296
+ * (stigmer/stigmer#1255).
293
297
  */
294
298
  async function findAll(
295
299
  deps: ApiKeyControllerDeps,
@@ -319,12 +323,18 @@ async function findAll(
319
323
  } catch (error) {
320
324
  throw internalError(error, "failed to list api keys");
321
325
  }
322
- const entries = await restrictListByReadScope(
326
+ const visible = await restrictListByReadScope(
323
327
  deps.listReadScope,
324
328
  identity,
325
329
  ApiResourceKind.api_key,
326
330
  rows.map((row) => fromBinary(ApiKeySchema, row)),
327
331
  "",
328
332
  );
333
+ const entries = [...visible].sort((a, b) =>
334
+ compareCreatedAtDesc(
335
+ a.status?.audit?.specAudit?.createdAt,
336
+ b.status?.audit?.specAudit?.createdAt,
337
+ ),
338
+ );
329
339
  return create(ApiKeysSchema, { entries });
330
340
  }
@@ -33,14 +33,28 @@
33
33
  * identityId changes on a hit: email/displayName stay the stamp's, the
34
34
  * actor the key recorded at minting. The credential checks run first, so
35
35
  * a revoked or expired key never reaches the account store.
36
+ *
37
+ * An accepted key's use is then recorded on its status.last_used_at
38
+ * (identity/credential-use.ts): at most once a resolution, judged from the
39
+ * row this read already holds, written atomically through
40
+ * Store.updateResource so it can never undo a concurrent update of the
41
+ * key, and best-effort — a failed stamp is logged and the request
42
+ * proceeds. A refused key records nothing (stigmer/stigmer#1255).
36
43
  */
37
44
  import { Code, ConnectError } from "@connectrpc/connect";
45
+ import { create } from "@bufbuild/protobuf";
38
46
  import { timestampDate } from "@bufbuild/protobuf/wkt";
39
47
 
48
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
49
+ import { ApiKeySchema, ApiKeyStatusSchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
50
+ import type { ApiKey } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
51
+
52
+ import type { Logger } from "../../boot/logger.js";
40
53
  import type {
41
54
  CallerIdentity,
42
55
  IdentityVerifier,
43
56
  } from "../../extensions/identity.js";
57
+ import { recordCredentialUse } from "../../identity/credential-use.js";
44
58
  import type { Store } from "../../store/interface.js";
45
59
  import { identityIdForSubject } from "../identityaccount/resolve.js";
46
60
  import type { AccountsBySubject } from "../identityaccount/resolve.js";
@@ -58,12 +72,16 @@ export interface ApiKeyVerifierDeps {
58
72
  readonly store: Store;
59
73
  /** The identity-account domain's subject lookup the creator stamp resolves through (A6). */
60
74
  readonly accounts: AccountsBySubject;
75
+ /** Where a failed last-use stamp is reported. */
76
+ readonly logger: Logger;
77
+ /** The clock both the expiry check and the last-use stamp read. */
78
+ readonly now: () => Date;
61
79
  }
62
80
 
63
81
  export function newApiKeyIdentityVerifier(
64
82
  deps: ApiKeyVerifierDeps,
65
83
  ): IdentityVerifier {
66
- const { store, accounts } = deps;
84
+ const { store, accounts, logger } = deps;
67
85
  return {
68
86
  name: "apikey",
69
87
  async verify(token: string): Promise<CallerIdentity | null> {
@@ -74,8 +92,9 @@ export function newApiKeyIdentityVerifier(
74
92
  if (key === undefined) {
75
93
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
76
94
  }
95
+ const now = deps.now();
77
96
  const expiresAt = key.spec?.expiresAt;
78
- if (expiresAt !== undefined && timestampDate(expiresAt) <= new Date()) {
97
+ if (expiresAt !== undefined && timestampDate(expiresAt) <= now) {
79
98
  throw new ConnectError(TOKEN_EXPIRED_MESSAGE, Code.Unauthenticated);
80
99
  }
81
100
  const owner = key.status?.audit?.specAudit?.createdBy;
@@ -85,8 +104,10 @@ export function newApiKeyIdentityVerifier(
85
104
  // through the pipeline; guards hand-seeded or corrupted rows).
86
105
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
87
106
  }
107
+ const identityId = await identityIdForSubject(accounts, owner.id);
108
+ await recordKeyUse(store, logger, key, now);
88
109
  return {
89
- identityId: await identityIdForSubject(accounts, owner.id),
110
+ identityId,
90
111
  callerClass: "user",
91
112
  issuer: "",
92
113
  rawToken: token,
@@ -96,3 +117,25 @@ export function newApiKeyIdentityVerifier(
96
117
  },
97
118
  };
98
119
  }
120
+
121
+ /** Stamps the accepted key's last use on its own row, atomically. */
122
+ function recordKeyUse(
123
+ store: Store,
124
+ logger: Logger,
125
+ key: ApiKey,
126
+ now: Date,
127
+ ): Promise<void> {
128
+ const id = key.metadata?.id ?? "";
129
+ return recordCredentialUse({
130
+ credential: "api key",
131
+ id,
132
+ lastUsedAt: key.status?.lastUsedAt,
133
+ now,
134
+ logger,
135
+ write: (stamp) =>
136
+ store.updateResource(ApiResourceKind.api_key, id, ApiKeySchema, (live) => {
137
+ live.status ??= create(ApiKeyStatusSchema);
138
+ stamp(live.status);
139
+ }),
140
+ });
141
+ }
@@ -25,7 +25,11 @@
25
25
  * the row's kind and id as the target); delete authorizes before it
26
26
  * loads; create validates the role before it writes, then runs the
27
27
  * `iam-policy-create:pre-side-effect-gate` slot, whose refusal leaves
28
- * no row; bootstrapPolicy never runs that slot.
28
+ * no row; bootstrapPolicy never runs that slot;
29
+ * - owner is assigned by owners: an admin is refused granting owner,
30
+ * revoking it and removing an owner, after position 1 and before any
31
+ * write; an owner is allowed each; the last owner is kept; the
32
+ * bootstrap twins run neither owner step.
29
33
  */
30
34
  import { create } from "@bufbuild/protobuf";
31
35
  import type { DescMessage } from "@bufbuild/protobuf";
@@ -76,6 +80,8 @@ import {
76
80
  import {
77
81
  AUTHENTICATION_REQUIRED_MESSAGE,
78
82
  AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
83
+ LAST_OWNER_MESSAGE,
84
+ OWNER_ASSIGNMENT_DENIED_MESSAGE,
79
85
  policyIdFor,
80
86
  policyNotFoundMessage,
81
87
  principalNotGrantableMessage,
@@ -129,7 +135,8 @@ function stampCaller(identity: CallerIdentity): Interceptor {
129
135
 
130
136
  interface RecordingAuthorizer extends Authorizer {
131
137
  readonly checks: AuthzCheck[];
132
- decision: AuthzDecision | (() => never);
138
+ /** One answer for every check, or one per check (a throw is an outage). */
139
+ decision: AuthzDecision | ((check: AuthzCheck) => AuthzDecision);
133
140
  }
134
141
 
135
142
  function recordingAuthorizer(): RecordingAuthorizer {
@@ -140,7 +147,7 @@ function recordingAuthorizer(): RecordingAuthorizer {
140
147
  authorize(_caller, check) {
141
148
  checks.push(check);
142
149
  if (typeof authorizer.decision === "function") {
143
- return authorizer.decision();
150
+ return Promise.resolve(authorizer.decision(check));
144
151
  }
145
152
  return Promise.resolve(authorizer.decision);
146
153
  },
@@ -933,6 +940,95 @@ describe("the write lanes' order", () => {
933
940
  });
934
941
  });
935
942
 
943
+ describe("owner is assigned by owners, on the three caller lanes", () => {
944
+ /** alice is an admin of acme: she may grant access there, and may not assign owner. */
945
+ const adminOfAcme = (check: AuthzCheck): AuthzDecision =>
946
+ check.permission === IamPermission.can_assign_roles
947
+ ? { kind: "deny", reason: "" }
948
+ : { kind: "allow" };
949
+ const assignRolesOnAcme: AuthzCheck = {
950
+ permission: IamPermission.can_assign_roles,
951
+ resourceKind: ApiResourceKind.organization,
952
+ resourceId: "acme",
953
+ };
954
+
955
+ /** acme's roles, written straight through the grant path under the harness's store. */
956
+ async function acmeWith(
957
+ h: Harness,
958
+ roles: ReadonlyArray<readonly [string, string]>,
959
+ ): Promise<void> {
960
+ const path = newIamPolicyGrantPath({ policies: h.policies, lifecycle: undefined, logger: silent });
961
+ for (const [account, role] of roles) {
962
+ await path.grant(orgRole(account, role, "acme"), internal);
963
+ }
964
+ }
965
+
966
+ it("an admin is refused granting owner, after the role check and before the gate and the write", async () => {
967
+ const seen: string[] = [];
968
+ const h = await harness({
969
+ caller: alice,
970
+ createGate: { name: "RecordForTest", execute: () => void seen.push("gate") },
971
+ });
972
+ h.authorizer.decision = adminOfAcme;
973
+ const error = await refusal(() => h.command.create(orgRole("ida_bob", "owner", "acme")));
974
+ expect(error.code).toBe(Code.PermissionDenied);
975
+ expect(error.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
976
+ expect(h.authorizer.checks.at(-1)).toEqual(assignRolesOnAcme);
977
+ expect(seen).toEqual([]);
978
+ expect(h.policies.rows.size).toBe(0);
979
+
980
+ // Every role up to admin is still hers to grant, and asks nothing more.
981
+ await h.command.create(orgRole("ida_bob", "admin", "acme"));
982
+ expect(h.authorizer.checks.at(-1)?.permission).toBe(IamPermission.can_grant_access);
983
+ });
984
+
985
+ it("an admin is refused revoking owner and removing an owner, and neither row moves", async () => {
986
+ const asAlice = await harness({ caller: alice });
987
+ await acmeWith(asAlice, [["ida_root", "owner"], ["ida_rhea", "owner"]]);
988
+ asAlice.authorizer.decision = adminOfAcme;
989
+
990
+ const revoke = await refusal(() => asAlice.command.delete(orgRole("ida_rhea", "owner", "acme")));
991
+ expect(revoke.code).toBe(Code.PermissionDenied);
992
+ expect(revoke.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
993
+ const remove = await refusal(() =>
994
+ asAlice.command.revokeOrgAccess({ identityAccountId: "ida_rhea", organizationId: "acme" }),
995
+ );
996
+ expect(remove.code).toBe(Code.PermissionDenied);
997
+ expect(remove.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
998
+ expect(asAlice.policies.rows.size).toBe(2);
999
+ });
1000
+
1001
+ it("an owner grants, revokes and removes owner, but never the last one", async () => {
1002
+ const h = await harness({ caller: alice });
1003
+ await acmeWith(h, [["ida_alice", "owner"]]);
1004
+
1005
+ await h.command.create(orgRole("ida_bob", "owner", "acme"));
1006
+ await h.command.delete(orgRole("ida_bob", "owner", "acme"));
1007
+ await h.command.create(orgRole("ida_bob", "owner", "acme"));
1008
+ await h.command.revokeOrgAccess({ identityAccountId: "ida_bob", organizationId: "acme" });
1009
+ expect([...h.policies.rows.values()].map((row) => row.spec?.principal?.id)).toEqual(["ida_alice"]);
1010
+
1011
+ const revoke = await refusal(() => h.command.delete(orgRole("ida_alice", "owner", "acme")));
1012
+ expect(revoke.code).toBe(Code.FailedPrecondition);
1013
+ expect(revoke.rawMessage).toBe(LAST_OWNER_MESSAGE);
1014
+ const remove = await refusal(() =>
1015
+ h.command.revokeOrgAccess({ identityAccountId: "ida_alice", organizationId: "acme" }),
1016
+ );
1017
+ expect(remove.code).toBe(Code.FailedPrecondition);
1018
+ expect(remove.rawMessage).toBe(LAST_OWNER_MESSAGE);
1019
+ expect(h.policies.rows.size).toBe(1);
1020
+ });
1021
+
1022
+ it("the bootstrap twins run neither owner step: the platform removes the last owner", async () => {
1023
+ const h = await harness({ caller: machine });
1024
+ await acmeWith(h, [["ida_root", "owner"]]);
1025
+ h.authorizer.decision = adminOfAcme;
1026
+ await h.command.bootstrapRevokeOrgAccess({ identityAccountId: "ida_root", organizationId: "acme" });
1027
+ expect(h.policies.rows.size).toBe(0);
1028
+ expect(h.authorizer.checks.map((check) => check.permission)).not.toContain(IamPermission.can_assign_roles);
1029
+ });
1030
+ });
1031
+
936
1032
  describe("a wire kind string is refused BEFORE position 1 on every annotated lane (Q-S6-1)", () => {
937
1033
  // A kind string that names no kind names no authorization target, so
938
1034
  // there is nothing to ask the Authorizer — the same reason