@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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* - no ring (a server that trusts every request) and a verify-only ring
|
|
5
5
|
* refuse FAILED_PRECONDITION before any credential is read;
|
|
6
6
|
* - an unknown client_id and a wrong secret answer one UNAUTHENTICATED
|
|
7
|
-
* copy; an expired secret refuses
|
|
7
|
+
* copy; an expired secret refuses with copy naming the owner's fix (not
|
|
8
|
+
* rotation, #1254), an unset or never-expiring one mints;
|
|
8
9
|
* an org_id other than the owning organization refuses;
|
|
9
10
|
* - an existing platform-client account is reused with no grant and no
|
|
10
11
|
* write; an account under the subject in any other mode is refused;
|
|
@@ -14,13 +15,17 @@
|
|
|
14
15
|
* nothing; a failed create leaves the grant, and the next mint completes
|
|
15
16
|
* the account; a concurrent winner is read back;
|
|
16
17
|
* - the default role is viewer, owner is refused, and the token carries
|
|
17
|
-
* the cloud's claim set for the account
|
|
18
|
+
* the cloud's claim set for the account;
|
|
19
|
+
* - a successful mint stamps the client's last use through the port's
|
|
20
|
+
* atomic modifyById (never the whole-row update), once per resolution;
|
|
21
|
+
* a refused mint records nothing; a stamp that cannot be written is
|
|
22
|
+
* logged and the token is still answered (stigmer/stigmer#1255).
|
|
18
23
|
*/
|
|
19
24
|
import { generateKeyPairSync } from "node:crypto";
|
|
20
25
|
|
|
21
26
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
22
|
-
import { create } from "@bufbuild/protobuf";
|
|
23
|
-
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
27
|
+
import { clone, create } from "@bufbuild/protobuf";
|
|
28
|
+
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
24
29
|
import { describe, expect, it } from "vitest";
|
|
25
30
|
|
|
26
31
|
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
@@ -29,12 +34,18 @@ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/
|
|
|
29
34
|
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
30
35
|
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
31
36
|
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
32
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
PlatformClientSchema,
|
|
39
|
+
PlatformClientStatusSchema,
|
|
40
|
+
} from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
33
41
|
import { MintUserTokenRequestSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
|
|
34
42
|
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
35
43
|
|
|
44
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
45
|
+
import type { Logger } from "../../../boot/logger.js";
|
|
36
46
|
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
37
47
|
import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
|
|
48
|
+
import { LAST_USED_RESOLUTION_MS } from "../../../identity/credential-use.js";
|
|
38
49
|
import { verifyPlatformToken } from "../../../platformtoken/envelope.js";
|
|
39
50
|
import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
|
|
40
51
|
import type { PlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
|
|
@@ -100,6 +111,10 @@ interface Harness {
|
|
|
100
111
|
}>;
|
|
101
112
|
readonly granted: Array<{ spec: IamPolicySpec; caller: CallerIdentity }>;
|
|
102
113
|
readonly accounts: Map<string, IdentityAccount>;
|
|
114
|
+
/** The client row as the port holds it; modifyById writes here. */
|
|
115
|
+
readonly stored: { client: PlatformClient };
|
|
116
|
+
/** Every port write, by method: the stamp must ride modifyById alone. */
|
|
117
|
+
readonly clientWrites: string[];
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
function harness(
|
|
@@ -108,9 +123,12 @@ function harness(
|
|
|
108
123
|
keys?: PlatformTokenKeyRing | undefined;
|
|
109
124
|
failGrant?: boolean;
|
|
110
125
|
failCreate?: "fault" | "lost-race";
|
|
126
|
+
failStamp?: boolean;
|
|
127
|
+
logger?: Logger;
|
|
111
128
|
} = {},
|
|
112
129
|
): Harness {
|
|
113
|
-
const
|
|
130
|
+
const stored = { client: options.client ?? platformClient() };
|
|
131
|
+
const clientWrites: string[] = [];
|
|
114
132
|
const events: string[] = [];
|
|
115
133
|
const created: Harness["created"] = [];
|
|
116
134
|
const granted: Harness["granted"] = [];
|
|
@@ -125,12 +143,25 @@ function harness(
|
|
|
125
143
|
};
|
|
126
144
|
const deps: PlatformClientMintDeps = {
|
|
127
145
|
clients: {
|
|
128
|
-
save: async () => {
|
|
129
|
-
|
|
146
|
+
save: async () => {
|
|
147
|
+
clientWrites.push("save");
|
|
148
|
+
},
|
|
149
|
+
update: async () => {
|
|
150
|
+
clientWrites.push("update");
|
|
151
|
+
},
|
|
152
|
+
modifyById: async (id, modify) => {
|
|
153
|
+
clientWrites.push("modifyById");
|
|
154
|
+
if (options.failStamp === true) throw new Error("client store down");
|
|
155
|
+
if (id !== stored.client.metadata?.id) return undefined;
|
|
156
|
+
const next = clone(PlatformClientSchema, stored.client);
|
|
157
|
+
modify(next);
|
|
158
|
+
stored.client = next;
|
|
159
|
+
return next;
|
|
160
|
+
},
|
|
130
161
|
deleteById: async () => {},
|
|
131
|
-
findById: async () => client,
|
|
162
|
+
findById: async () => stored.client,
|
|
132
163
|
findByClientId: async (clientId) =>
|
|
133
|
-
clientId === client.spec?.clientId ? client : undefined,
|
|
164
|
+
clientId === stored.client.spec?.clientId ? stored.client : undefined,
|
|
134
165
|
findByOrgAndSlug: async () => undefined,
|
|
135
166
|
findByOrg: async () => [],
|
|
136
167
|
},
|
|
@@ -155,10 +186,10 @@ function harness(
|
|
|
155
186
|
},
|
|
156
187
|
grantPath,
|
|
157
188
|
keys: "keys" in options ? options.keys : RING,
|
|
158
|
-
logger: silentLogger,
|
|
189
|
+
logger: options.logger ?? silentLogger,
|
|
159
190
|
now: () => NOW,
|
|
160
191
|
};
|
|
161
|
-
return { deps, events, created, granted, accounts };
|
|
192
|
+
return { deps, events, created, granted, accounts, stored, clientWrites };
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
function request(
|
|
@@ -232,6 +263,11 @@ describe("mintUserToken — the server and the client", () => {
|
|
|
232
263
|
);
|
|
233
264
|
expect(expired.code).toBe(Code.FailedPrecondition);
|
|
234
265
|
expect(expired.rawMessage).toBe(EXPIRED_CLIENT_SECRET_MESSAGE);
|
|
266
|
+
// The literal, so a change to the copy is deliberate: it names the
|
|
267
|
+
// owner's fix, never rotation, which leaves the expiry as it is (#1254).
|
|
268
|
+
expect(expired.rawMessage).toBe(
|
|
269
|
+
"PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.",
|
|
270
|
+
);
|
|
235
271
|
|
|
236
272
|
await mintUserToken(harness({ client: platformClient() }).deps, request());
|
|
237
273
|
await mintUserToken(
|
|
@@ -413,3 +449,59 @@ describe("mintUserToken — the user", () => {
|
|
|
413
449
|
);
|
|
414
450
|
});
|
|
415
451
|
});
|
|
452
|
+
|
|
453
|
+
describe("mintUserToken — the client's last use", () => {
|
|
454
|
+
it("a successful mint stamps the client's last use at the mint's clock, through modifyById alone", async () => {
|
|
455
|
+
const h = harness();
|
|
456
|
+
await mintUserToken(h.deps, request());
|
|
457
|
+
expect(h.clientWrites).toEqual(["modifyById"]);
|
|
458
|
+
expect(timestampDate(h.stored.client.status!.lastUsedAt!)).toEqual(NOW);
|
|
459
|
+
expect(h.stored.client.status?.audit?.statusAudit?.event).toBe("updated");
|
|
460
|
+
expect(
|
|
461
|
+
h.stored.client.spec?.clientSecretHash,
|
|
462
|
+
"the stamp never rewrites the credential",
|
|
463
|
+
).toBe(hashClientSecret(SECRET));
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("a mint inside the resolution writes nothing", async () => {
|
|
467
|
+
const recent = platformClient();
|
|
468
|
+
recent.status = create(PlatformClientStatusSchema, {
|
|
469
|
+
lastUsedAt: timestampFromDate(
|
|
470
|
+
new Date(NOW.getTime() - LAST_USED_RESOLUTION_MS / 2),
|
|
471
|
+
),
|
|
472
|
+
});
|
|
473
|
+
const h = harness({ client: recent });
|
|
474
|
+
await mintUserToken(h.deps, request());
|
|
475
|
+
expect(h.clientWrites).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("a refused mint records nothing", async () => {
|
|
479
|
+
const h = harness();
|
|
480
|
+
await refusal(mintUserToken(h.deps, request({ clientSecret: "wrong" })));
|
|
481
|
+
await refusal(mintUserToken(h.deps, request({ orgId: "globex" })));
|
|
482
|
+
expect(h.clientWrites).toEqual([]);
|
|
483
|
+
expect(h.stored.client.status?.lastUsedAt).toBeUndefined();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("a stamp that cannot be written is logged with the client's id and the token is still answered", async () => {
|
|
487
|
+
const lines: string[] = [];
|
|
488
|
+
const h = harness({
|
|
489
|
+
failStamp: true,
|
|
490
|
+
logger: createLogger({
|
|
491
|
+
level: "warn",
|
|
492
|
+
pretty: false,
|
|
493
|
+
write: (line) => lines.push(line),
|
|
494
|
+
}),
|
|
495
|
+
});
|
|
496
|
+
const response = await mintUserToken(h.deps, request());
|
|
497
|
+
expect(response.accessToken).not.toBe("");
|
|
498
|
+
expect(lines).toHaveLength(1);
|
|
499
|
+
expect(JSON.parse(lines[0]!)).toMatchObject({
|
|
500
|
+
level: "warn",
|
|
501
|
+
credential: "platform client",
|
|
502
|
+
id: "pcl_dashboard",
|
|
503
|
+
error: "client store down",
|
|
504
|
+
});
|
|
505
|
+
expect(lines[0], "the secret is never logged").not.toContain(SECRET);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
@@ -39,6 +39,10 @@ const CONTRACT_CASE_NAMES = [
|
|
|
39
39
|
"the same slug in another organization is a different client",
|
|
40
40
|
"update replaces the row in place",
|
|
41
41
|
"update of an unknown id writes nothing",
|
|
42
|
+
"modifyById persists what modify did, answers the persisted row, and the lookups follow it",
|
|
43
|
+
"modifyById of an unknown id answers undefined and writes nothing",
|
|
44
|
+
"a modify that throws writes nothing and rejects with its error",
|
|
45
|
+
"concurrent modifyById calls on one row lose no write",
|
|
42
46
|
"deleteById frees the id, the client_id and the slug; deleting an unknown id resolves",
|
|
43
47
|
"findByOrg answers every client of the organization and no other",
|
|
44
48
|
"a disconnected store is an infrastructure fault, never 'not found'",
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* or typed one, refuses a bad one with the envelope's copy, refuses a
|
|
6
6
|
* token naming no client, refuses a deleted client's token with the
|
|
7
7
|
* cloud's liveness copy, lets a store fault propagate (never a
|
|
8
|
-
* revocation), and stamps the account from `sub` as a `user
|
|
8
|
+
* revocation), and stamps the account from `sub` as a `user` with the
|
|
9
|
+
* minting client it verified, for the audit actor (#1256);
|
|
9
10
|
* - the guard reads no client for a request without an Origin, passes an
|
|
10
11
|
* open allowlist and a listed origin case-insensitively, refuses an
|
|
11
12
|
* unlisted origin and the opaque "null" with the cloud's copy, and
|
|
@@ -83,7 +84,7 @@ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
describe("the PlatformClient user-token verifier", () => {
|
|
86
|
-
it("stamps the account the token was minted for as a user, with the asserted profile", async () => {
|
|
87
|
+
it("stamps the account the token was minted for as a user, with the asserted profile and the minting client", async () => {
|
|
87
88
|
const token = userToken();
|
|
88
89
|
const identity = await newPlatformClientTokenVerifier({
|
|
89
90
|
keys: ring,
|
|
@@ -96,6 +97,7 @@ describe("the PlatformClient user-token verifier", () => {
|
|
|
96
97
|
rawToken: token,
|
|
97
98
|
email: "pat@example.com",
|
|
98
99
|
displayName: "Pat",
|
|
100
|
+
platformClientId: "pcl_dashboard",
|
|
99
101
|
});
|
|
100
102
|
});
|
|
101
103
|
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* sentences open source adds — a server that cannot mint, the guest
|
|
12
12
|
* method's edition refusal, the mismatched-account guard — follow the
|
|
13
13
|
* house style (single-quoted handles).
|
|
14
|
+
*
|
|
15
|
+
* One inherited sentence was changed on purpose: the expired-secret
|
|
16
|
+
* refusal told integrators to rotate, which never helps, because rotating
|
|
17
|
+
* leaves the expiry as it is (steps.ts, newRotateClientCredentialsStep).
|
|
18
|
+
* It now names the one fix and who can apply it (stigmer/stigmer#1254).
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
/** The kind's name as the shared error constructors render it. */
|
|
@@ -63,8 +68,13 @@ export function referenceNotFoundMessage(org: string, slug: string): string {
|
|
|
63
68
|
export const INVALID_CLIENT_CREDENTIALS_MESSAGE =
|
|
64
69
|
"Invalid client_id or client_secret";
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The mint's refusal for a client whose expires_at has passed. Rotating
|
|
73
|
+
* does not extend the expiry, and both update and rotateSecret need the
|
|
74
|
+
* client's owner (can_edit), so the sentence names the owner's fix.
|
|
75
|
+
*/
|
|
66
76
|
export const EXPIRED_CLIENT_SECRET_MESSAGE =
|
|
67
|
-
"PlatformClient secret has expired.
|
|
77
|
+
"PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.";
|
|
68
78
|
|
|
69
79
|
export function organizationMismatchMessage(owningOrg: string): string {
|
|
70
80
|
return `org_id must be empty or the PlatformClient's owning organization ('${owningOrg}'); cross-organization minting is not supported`;
|
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
*
|
|
45
45
|
* The email and name in the token and on the account are the platform's
|
|
46
46
|
* assertion; nothing on the server derives authority from either.
|
|
47
|
+
*
|
|
48
|
+
* A signed token is a successful mint, so the client's use is then
|
|
49
|
+
* recorded on its status.last_used_at (identity/credential-use.ts): at most
|
|
50
|
+
* once a resolution, judged from the row authentication already read,
|
|
51
|
+
* written through the port's atomic `modifyById` — never the whole-row
|
|
52
|
+
* `update`, which could write back the secret hash a concurrent rotation
|
|
53
|
+
* just replaced — and best-effort: a failed stamp is logged and the token
|
|
54
|
+
* is still answered. A refused mint records nothing (stigmer/stigmer#1255).
|
|
47
55
|
*/
|
|
48
56
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
49
57
|
import { create } from "@bufbuild/protobuf";
|
|
@@ -54,6 +62,7 @@ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityacc
|
|
|
54
62
|
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
55
63
|
import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
|
|
56
64
|
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
65
|
+
import { PlatformClientStatusSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
57
66
|
import type {
|
|
58
67
|
MintUserTokenRequest,
|
|
59
68
|
MintUserTokenResponse,
|
|
@@ -63,6 +72,7 @@ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
|
63
72
|
|
|
64
73
|
import type { Logger } from "../../boot/logger.js";
|
|
65
74
|
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
75
|
+
import { recordCredentialUse } from "../../identity/credential-use.js";
|
|
66
76
|
import { internalError } from "../../pipeline/errors.js";
|
|
67
77
|
import { serverActingFor } from "../../pipeline/interceptors/auth.js";
|
|
68
78
|
import { validator } from "../../pipeline/steps/validation.js";
|
|
@@ -164,6 +174,7 @@ export async function mintUserToken(
|
|
|
164
174
|
user,
|
|
165
175
|
);
|
|
166
176
|
|
|
177
|
+
const now = deps.now();
|
|
167
178
|
const signed = signPlatformToken(
|
|
168
179
|
keys,
|
|
169
180
|
{
|
|
@@ -174,8 +185,9 @@ export async function mintUserToken(
|
|
|
174
185
|
[USER_TOKEN_CLAIMS.org]: owningOrg,
|
|
175
186
|
[USER_TOKEN_CLAIMS.platformClientId]: client.metadata?.id ?? "",
|
|
176
187
|
},
|
|
177
|
-
{ now
|
|
188
|
+
{ now },
|
|
178
189
|
);
|
|
190
|
+
await recordClientUse(deps, client, now);
|
|
179
191
|
return create(MintUserTokenResponseSchema, {
|
|
180
192
|
accessToken: signed.token,
|
|
181
193
|
tokenType: BEARER_TOKEN_TYPE,
|
|
@@ -223,6 +235,27 @@ async function authenticateClient(
|
|
|
223
235
|
return client;
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
/** Stamps the minting client's last use on its own row, through the port's atomic write. */
|
|
239
|
+
function recordClientUse(
|
|
240
|
+
deps: PlatformClientMintDeps,
|
|
241
|
+
client: PlatformClient,
|
|
242
|
+
now: Date,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const id = client.metadata?.id ?? "";
|
|
245
|
+
return recordCredentialUse({
|
|
246
|
+
credential: "platform client",
|
|
247
|
+
id,
|
|
248
|
+
lastUsedAt: client.status?.lastUsedAt,
|
|
249
|
+
now,
|
|
250
|
+
logger: deps.logger,
|
|
251
|
+
write: (stamp) =>
|
|
252
|
+
deps.clients.modifyById(id, (live) => {
|
|
253
|
+
live.status ??= create(PlatformClientStatusSchema);
|
|
254
|
+
stamp(live.status);
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
226
259
|
/** The account id for the user: the existing platform-client account, or one provisioned now. */
|
|
227
260
|
async function resolveOrProvisionAccount(
|
|
228
261
|
deps: PlatformClientMintDeps,
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* (org, slug) or client_id; `update` writes nothing for an unknown id; the
|
|
21
21
|
* residual window between the read and the write is the platform-wide
|
|
22
22
|
* CheckDuplicate-then-upsert window, not this adapter's to close.
|
|
23
|
+
* `modifyById` has no such window: it IS Store.updateResource, the generic
|
|
24
|
+
* store's own atomic read-modify-write, its not-found read as `undefined`.
|
|
23
25
|
*
|
|
24
26
|
* Store faults follow the ratified mapping: a typed ResourceNotFoundError
|
|
25
27
|
* reads as `undefined`; anything else propagates — an outage must never
|
|
@@ -135,6 +137,22 @@ export function newResourcePlatformClientStore(
|
|
|
135
137
|
await store.saveResource(KIND, id, PlatformClientSchema, client);
|
|
136
138
|
},
|
|
137
139
|
|
|
140
|
+
async modifyById(id, modify): Promise<PlatformClient | undefined> {
|
|
141
|
+
try {
|
|
142
|
+
return await store.updateResource(
|
|
143
|
+
KIND,
|
|
144
|
+
id,
|
|
145
|
+
PlatformClientSchema,
|
|
146
|
+
modify,
|
|
147
|
+
);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof ResourceNotFoundError) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
138
156
|
async deleteById(id): Promise<void> {
|
|
139
157
|
await store.deleteResource(KIND, id);
|
|
140
158
|
},
|
|
@@ -242,6 +242,106 @@ const CASES: ReadonlyArray<PortContractDeclaration<PlatformClientStore>> = [
|
|
|
242
242
|
);
|
|
243
243
|
},
|
|
244
244
|
],
|
|
245
|
+
[
|
|
246
|
+
"modifyById persists what modify did, answers the persisted row, and the lookups follow it",
|
|
247
|
+
async ({ store }) => {
|
|
248
|
+
await store.save(ACME_DASHBOARD);
|
|
249
|
+
const answered = await store.modifyById(
|
|
250
|
+
"pcl_contract_acme_dashboard",
|
|
251
|
+
(client) => {
|
|
252
|
+
assert.ok(client.spec, "the saved client carries a spec");
|
|
253
|
+
client.spec.allowedOrigins = ["https://app.acme.example"];
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
const expected = platformClient({
|
|
257
|
+
id: "pcl_contract_acme_dashboard",
|
|
258
|
+
org: "acme",
|
|
259
|
+
slug: "dashboard",
|
|
260
|
+
clientId: "stgm_cid_contract_acme_dashboard",
|
|
261
|
+
allowedOrigins: ["https://app.acme.example"],
|
|
262
|
+
});
|
|
263
|
+
assertSameClient(answered, expected, "modifyById must answer the persisted row");
|
|
264
|
+
assertSameClient(
|
|
265
|
+
await store.findById("pcl_contract_acme_dashboard"),
|
|
266
|
+
expected,
|
|
267
|
+
"findById must answer the modified row",
|
|
268
|
+
);
|
|
269
|
+
assertSameClient(
|
|
270
|
+
await store.findByClientId("stgm_cid_contract_acme_dashboard"),
|
|
271
|
+
expected,
|
|
272
|
+
"findByClientId must answer the modified row",
|
|
273
|
+
);
|
|
274
|
+
assertSameClient(
|
|
275
|
+
await store.findByOrgAndSlug("acme", "dashboard"),
|
|
276
|
+
expected,
|
|
277
|
+
"findByOrgAndSlug must answer the modified row",
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
"modifyById of an unknown id answers undefined and writes nothing",
|
|
283
|
+
async ({ store }) => {
|
|
284
|
+
let called = false;
|
|
285
|
+
const answered = await store.modifyById(
|
|
286
|
+
"pcl_contract_acme_dashboard",
|
|
287
|
+
() => {
|
|
288
|
+
called = true;
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
assert.equal(answered, undefined);
|
|
292
|
+
assert.equal(called, false, "modify must not run for a row that is not held");
|
|
293
|
+
assert.equal(
|
|
294
|
+
await store.findById("pcl_contract_acme_dashboard"),
|
|
295
|
+
undefined,
|
|
296
|
+
"modifyById must modify, never create",
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
[
|
|
301
|
+
"a modify that throws writes nothing and rejects with its error",
|
|
302
|
+
async ({ store }) => {
|
|
303
|
+
await store.save(ACME_DASHBOARD);
|
|
304
|
+
const refusal = new Error("modify refused");
|
|
305
|
+
await assert.rejects(
|
|
306
|
+
store.modifyById("pcl_contract_acme_dashboard", (client) => {
|
|
307
|
+
assert.ok(client.spec, "the saved client carries a spec");
|
|
308
|
+
client.spec.allowedOrigins = ["https://half.written.example"];
|
|
309
|
+
throw refusal;
|
|
310
|
+
}),
|
|
311
|
+
(error: unknown) => error === refusal,
|
|
312
|
+
"the modify's own error must propagate",
|
|
313
|
+
);
|
|
314
|
+
assertSameClient(
|
|
315
|
+
await store.findById("pcl_contract_acme_dashboard"),
|
|
316
|
+
ACME_DASHBOARD,
|
|
317
|
+
"nothing a throwing modify did may persist",
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
[
|
|
322
|
+
"concurrent modifyById calls on one row lose no write",
|
|
323
|
+
async ({ store }) => {
|
|
324
|
+
await store.save(ACME_DASHBOARD);
|
|
325
|
+
const origins = Array.from(
|
|
326
|
+
{ length: 8 },
|
|
327
|
+
(_, i) => `https://writer-${i}.acme.example`,
|
|
328
|
+
);
|
|
329
|
+
await Promise.all(
|
|
330
|
+
origins.map((origin) =>
|
|
331
|
+
store.modifyById("pcl_contract_acme_dashboard", (client) => {
|
|
332
|
+
assert.ok(client.spec, "the saved client carries a spec");
|
|
333
|
+
client.spec.allowedOrigins.push(origin);
|
|
334
|
+
}),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
const stored = await store.findById("pcl_contract_acme_dashboard");
|
|
338
|
+
assert.deepEqual(
|
|
339
|
+
[...(stored?.spec?.allowedOrigins ?? [])].sort(),
|
|
340
|
+
[...origins].sort(),
|
|
341
|
+
"every concurrent modify must see the one before it: a read-then-write driver loses edits here",
|
|
342
|
+
);
|
|
343
|
+
},
|
|
344
|
+
],
|
|
245
345
|
[
|
|
246
346
|
"deleteById frees the id, the client_id and the slug; deleting an unknown id resolves",
|
|
247
347
|
async ({ store }) => {
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* duplicate check), by organization (listByOrg). A port carries nothing
|
|
14
14
|
* only one edition calls.
|
|
15
15
|
*
|
|
16
|
+
* One write is atomic: `modifyById`, the port's rendering of
|
|
17
|
+
* `Store.updateResource`, for the status the platform writes on its own
|
|
18
|
+
* (the mint's last-use stamp, identity/credential-use.ts). `update` stays
|
|
19
|
+
* a whole-row replace for the chains that load, edit and persist; a stamp
|
|
20
|
+
* written that way would race them and could restore a rotated secret. The
|
|
21
|
+
* driver supplies only the atomicity; what `modify` does stays the
|
|
22
|
+
* domain's, written once for every edition.
|
|
23
|
+
*
|
|
16
24
|
* Contract every implementation must satisfy — proven by the port-contract
|
|
17
25
|
* kit (store-contract.ts, exported), which the OSS adapter's test iterates
|
|
18
26
|
* on both drivers and a composition's driver test iterates too:
|
|
@@ -21,6 +29,10 @@
|
|
|
21
29
|
* overwrite;
|
|
22
30
|
* - `update` replaces by id and never creates: an unknown id writes
|
|
23
31
|
* nothing;
|
|
32
|
+
* - `modifyById` reads the row, applies `modify` and persists the result
|
|
33
|
+
* in one atomic step, so no concurrent write is lost; it answers the
|
|
34
|
+
* persisted row, an unknown id answers `undefined` and writes nothing,
|
|
35
|
+
* and a `modify` that throws writes nothing and rejects with its error;
|
|
24
36
|
* - `deleteById` of an unknown id resolves;
|
|
25
37
|
* - `findByOrg` answers every client of the organization and no other,
|
|
26
38
|
* in no promised order (the chain sorts);
|
|
@@ -49,6 +61,17 @@ export interface PlatformClientStore {
|
|
|
49
61
|
save(client: PlatformClient): Promise<void>;
|
|
50
62
|
/** Full-row replace by id; an unknown id writes nothing. */
|
|
51
63
|
update(client: PlatformClient): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Atomic read-modify-write by id: `modify` mutates the stored client in
|
|
66
|
+
* place and the result is persisted, or nothing is. `modify` MUST be
|
|
67
|
+
* synchronous (Store.updateResource's rule: a driver may hold a write
|
|
68
|
+
* transaction or a row lock while it runs). Answers the persisted client;
|
|
69
|
+
* an unknown id answers `undefined`.
|
|
70
|
+
*/
|
|
71
|
+
modifyById(
|
|
72
|
+
id: string,
|
|
73
|
+
modify: (client: PlatformClient) => void,
|
|
74
|
+
): Promise<PlatformClient | undefined>;
|
|
52
75
|
/** Removes the row; no error when it does not exist. */
|
|
53
76
|
deleteById(id: string): Promise<void>;
|
|
54
77
|
findById(id: string): Promise<PlatformClient | undefined>;
|
|
@@ -26,8 +26,12 @@
|
|
|
26
26
|
* The identity is the account the token was minted for: `sub` IS the
|
|
27
27
|
* account id (the mint signs it), so no account read is needed; class
|
|
28
28
|
* `user`, issuer "stigmer", the token carried (the memory-capture gate and
|
|
29
|
-
* the origin guard read its `platform_client_id
|
|
30
|
-
* the
|
|
29
|
+
* the origin guard read its `platform_client_id`, guards decoding their
|
|
30
|
+
* own skip logic from the raw token as extensions/caller-guards.ts
|
|
31
|
+
* prescribes), the email and name the platform asserted, for display
|
|
32
|
+
* only, and the minting client as `platformClientId` for the audit-actor
|
|
33
|
+
* seam, so every resource a minted user writes names the client they came
|
|
34
|
+
* through (stigmer/stigmer#1256).
|
|
31
35
|
*/
|
|
32
36
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
33
37
|
|
|
@@ -100,6 +104,7 @@ export function newPlatformClientTokenVerifier(
|
|
|
100
104
|
rawToken: token,
|
|
101
105
|
...(email !== undefined ? { email } : {}),
|
|
102
106
|
...(displayName !== undefined ? { displayName } : {}),
|
|
107
|
+
platformClientId,
|
|
103
108
|
};
|
|
104
109
|
},
|
|
105
110
|
};
|
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
* `owner`, the member `member` — each written AS the account it names
|
|
18
18
|
* (the audit actor), `findMyOrganizations` answers the organization
|
|
19
19
|
* to both, and the marker is set.
|
|
20
|
-
* 3. The founder
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* 3. The founder removes the member, and is refused removing themself
|
|
21
|
+
* (an organization keeps its last owner on the member lanes); the
|
|
22
|
+
* founder's row then goes from the store, the way boot 2 made its
|
|
23
|
+
* shape, and the directory boots once more: NO row returns. The
|
|
24
|
+
* marker, not "zero rows", is what makes the reconciliation one-shot
|
|
25
|
+
* — a founder whose row is gone is not handed the organization back
|
|
26
|
+
* by a reboot.
|
|
24
27
|
*
|
|
25
28
|
* The unit vouches for unsigned JWT-shaped tokens and declares the
|
|
26
29
|
* require-authentication posture with no Authorizer (composed-support.ts;
|
|
@@ -35,7 +38,7 @@ import { tmpdir } from "node:os";
|
|
|
35
38
|
import path from "node:path";
|
|
36
39
|
|
|
37
40
|
import { fromBinary } from "@bufbuild/protobuf";
|
|
38
|
-
import { createClient } from "@connectrpc/connect";
|
|
41
|
+
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
39
42
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
40
43
|
|
|
41
44
|
import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
|
|
@@ -184,16 +187,21 @@ describe("role reconciliation (composed server, OIDC with no unit Authorizer, th
|
|
|
184
187
|
]);
|
|
185
188
|
});
|
|
186
189
|
|
|
187
|
-
it("boot 3: every role
|
|
190
|
+
it("boot 3: every role removed after the marker stays removed — a reboot hands nothing back", async () => {
|
|
188
191
|
const policies = createClient(IamPolicyCommandController, asFounder());
|
|
189
192
|
await policies.revokeOrgAccess({
|
|
190
193
|
identityAccountId: memberId,
|
|
191
194
|
organizationId: ORG,
|
|
192
195
|
});
|
|
193
|
-
await policies
|
|
194
|
-
identityAccountId: founderId,
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
const lastOwner = await policies
|
|
197
|
+
.revokeOrgAccess({ identityAccountId: founderId, organizationId: ORG })
|
|
198
|
+
.then(
|
|
199
|
+
() => undefined,
|
|
200
|
+
(error: unknown) => ConnectError.from(error),
|
|
201
|
+
);
|
|
202
|
+
expect(lastOwner?.code).toBe(Code.FailedPrecondition);
|
|
203
|
+
// The founder's row, the one left, goes the way boot 2 made its shape.
|
|
204
|
+
await server.store.deleteResourcesByKind(ApiResourceKind.iam_policy);
|
|
197
205
|
expect(await rolesIn(server.store)).toEqual([]);
|
|
198
206
|
await server.shutdown();
|
|
199
207
|
|
|
@@ -74,6 +74,20 @@ export interface CallerIdentity {
|
|
|
74
74
|
*/
|
|
75
75
|
readonly email?: string;
|
|
76
76
|
readonly displayName?: string;
|
|
77
|
+
/**
|
|
78
|
+
* The PlatformClient whose minted user token this caller presented,
|
|
79
|
+
* carried for the audit-actor seam as `email` and `displayName` are:
|
|
80
|
+
* the audit actor records it (ApiResourceAuditActor.platform_client_id),
|
|
81
|
+
* and that server-owned record is what later readers key on, never this
|
|
82
|
+
* field (stigmer/stigmer#1256). Set ONLY by the PlatformClient
|
|
83
|
+
* user-token verifier (domain/platformclient/verifier.ts), from the
|
|
84
|
+
* claim it has just verified and whose client it has just found alive;
|
|
85
|
+
* absent on every other credential. It is provenance, not authorization
|
|
86
|
+
* data (the account the token names is still the principal), and not a
|
|
87
|
+
* guard vocabulary: caller guards decode their own skip logic from
|
|
88
|
+
* `rawToken` (extensions/caller-guards.ts).
|
|
89
|
+
*/
|
|
90
|
+
readonly platformClientId?: string;
|
|
77
91
|
/**
|
|
78
92
|
* The transport the request entered through (C2 Stage 3, ruling R5).
|
|
79
93
|
* Absent = the wire; the in-process interceptor stamps `in-process` on
|