@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.
- package/dist/authorization/model/data/authorization-model.json +16 -46
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +11 -1
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
- package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
- package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
- package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
- package/dist/domain/apikey/controller.d.ts.map +1 -1
- package/dist/domain/apikey/controller.js +7 -2
- package/dist/domain/apikey/controller.js.map +1 -1
- package/dist/domain/apikey/verifier.d.ts +5 -0
- package/dist/domain/apikey/verifier.d.ts.map +1 -1
- package/dist/domain/apikey/verifier.js +32 -3
- package/dist/domain/apikey/verifier.js.map +1 -1
- package/dist/domain/iampolicy/constants.d.ts +11 -1
- package/dist/domain/iampolicy/constants.d.ts.map +1 -1
- package/dist/domain/iampolicy/constants.js +11 -1
- package/dist/domain/iampolicy/constants.js.map +1 -1
- package/dist/domain/iampolicy/controller.d.ts.map +1 -1
- package/dist/domain/iampolicy/controller.js +36 -12
- package/dist/domain/iampolicy/controller.js.map +1 -1
- package/dist/domain/iampolicy/steps.d.ts +98 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -1
- package/dist/domain/iampolicy/steps.js +119 -4
- package/dist/domain/iampolicy/steps.js.map +1 -1
- package/dist/domain/platformclient/constants.d.ts +11 -1
- package/dist/domain/platformclient/constants.d.ts.map +1 -1
- package/dist/domain/platformclient/constants.js +11 -1
- package/dist/domain/platformclient/constants.js.map +1 -1
- package/dist/domain/platformclient/mint.d.ts.map +1 -1
- package/dist/domain/platformclient/mint.js +28 -1
- package/dist/domain/platformclient/mint.js.map +1 -1
- package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
- package/dist/domain/platformclient/resource-store.js +13 -0
- package/dist/domain/platformclient/resource-store.js.map +1 -1
- package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
- package/dist/domain/platformclient/store-contract.js +59 -0
- package/dist/domain/platformclient/store-contract.js.map +1 -1
- package/dist/domain/platformclient/store.d.ts +20 -0
- package/dist/domain/platformclient/store.d.ts.map +1 -1
- package/dist/domain/platformclient/store.js.map +1 -1
- package/dist/domain/platformclient/verifier.d.ts.map +1 -1
- package/dist/domain/platformclient/verifier.js +7 -2
- package/dist/domain/platformclient/verifier.js.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/identity/credential-use.d.ts +74 -0
- package/dist/identity/credential-use.d.ts.map +1 -0
- package/dist/identity/credential-use.js +59 -0
- package/dist/identity/credential-use.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +15 -1
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +31 -1
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/temporal/schedule/activities.d.ts.map +1 -1
- package/dist/temporal/schedule/activities.js +2 -1
- package/dist/temporal/schedule/activities.js.map +1 -1
- package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
- package/dist/temporal/schedule/run-starter.js +2 -1
- package/dist/temporal/schedule/run-starter.js.map +1 -1
- package/dist/temporal/schedule/status-writes.d.ts +0 -6
- package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
- package/dist/temporal/schedule/status-writes.js +8 -27
- package/dist/temporal/schedule/status-writes.js.map +1 -1
- package/dist/temporal/schedule/syncer.d.ts.map +1 -1
- package/dist/temporal/schedule/syncer.js +2 -1
- package/dist/temporal/schedule/syncer.js.map +1 -1
- package/package.json +6 -6
- package/src/authorization/__tests__/store-tests.test.ts +1 -0
- package/src/authorization/model/data/authorization-model.json +16 -46
- package/src/boot/compose.ts +11 -1
- package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
- package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
- package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
- package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
- package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
- package/src/domain/apikey/controller.ts +12 -2
- package/src/domain/apikey/verifier.ts +46 -3
- package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
- package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
- package/src/domain/iampolicy/constants.ts +15 -1
- package/src/domain/iampolicy/controller.ts +43 -9
- package/src/domain/iampolicy/steps.ts +164 -4
- package/src/domain/platformclient/__tests__/mint.test.ts +104 -12
- package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
- package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
- package/src/domain/platformclient/constants.ts +11 -1
- package/src/domain/platformclient/mint.ts +34 -1
- package/src/domain/platformclient/resource-store.ts +18 -0
- package/src/domain/platformclient/store-contract.ts +100 -0
- package/src/domain/platformclient/store.ts +23 -0
- package/src/domain/platformclient/verifier.ts +7 -2
- package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
- package/src/extensions/identity.ts +14 -0
- package/src/identity/__tests__/credential-use.test.ts +186 -0
- package/src/identity/credential-use.ts +130 -0
- package/src/index.ts +5 -0
- package/src/pipeline/__tests__/steps.test.ts +72 -1
- package/src/pipeline/steps/defaults.ts +33 -1
- package/src/temporal/schedule/activities.ts +2 -1
- package/src/temporal/schedule/run-starter.ts +2 -1
- package/src/temporal/schedule/status-writes.ts +8 -31
- 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):
|
|
20
|
-
* environment_refs
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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(
|
|
61
|
-
|
|
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
|
|
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) <=
|
|
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
|
|
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
|
-
|
|
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
|