@stigmer/server 3.32.0 → 3.34.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 +6 -0
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +8 -1
- package/dist/boot/compose.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/organization/controller.js +14 -4
- package/dist/domain/organization/controller.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/extensions/gate-slots.d.ts +11 -1
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js +1 -0
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/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/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +25 -0
- 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/__tests__/wire-permissions.test.ts +0 -6
- package/src/authorization/model/data/authorization-model.json +6 -0
- package/src/boot/compose.ts +8 -1
- 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/organization/controller.ts +17 -4
- package/src/domain/platformclient/__tests__/mint.test.ts +97 -11
- package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
- 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/extensions/__tests__/extension-composition.test.ts +49 -3
- package/src/extensions/__tests__/registry.test.ts +3 -0
- package/src/extensions/gate-slots.ts +11 -0
- package/src/identity/__tests__/credential-use.test.ts +186 -0
- package/src/identity/credential-use.ts +130 -0
- package/src/pipeline/steps/defaults.ts +27 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -156,6 +156,11 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
|
|
|
156
156
|
* ValidateProto so clients can omit the slug and have it derived before
|
|
157
157
|
* field constraints (slug pattern, 2–15 chars) are checked.
|
|
158
158
|
*
|
|
159
|
+
* The pre-side-effect gate slot splices before Persist, after the last pure
|
|
160
|
+
* step, the session chain's position: every step above it only reads, so a
|
|
161
|
+
* refusal there leaves no organization behind. It is where a limit on
|
|
162
|
+
* which organizations may exist is enforced.
|
|
163
|
+
*
|
|
159
164
|
* The post-persist gate slot splices after Persist, before IndexSearch —
|
|
160
165
|
* the verified Java OrganizationCreateHandler ordering (FGA tuple
|
|
161
166
|
* seeding, billing account getOrCreate: synchronous, a failure fails the
|
|
@@ -190,11 +195,19 @@ async function createOrganization(
|
|
|
190
195
|
.addStep(newCheckOrgDuplicateStep(deps.store))
|
|
191
196
|
.addStep(newBuildNewStateStep())
|
|
192
197
|
.addStep(newGuardReservedLabelsStep(deps.authorizer))
|
|
193
|
-
.addStep(newCopySlugToIdStep())
|
|
198
|
+
.addStep(newCopySlugToIdStep());
|
|
199
|
+
// The pre-side-effect gate slot (see the doc comment above). Empty in OSS.
|
|
200
|
+
for (const step of stepsForSlot<typeof OrganizationSchema>(
|
|
201
|
+
deps.gateSteps,
|
|
202
|
+
"org-create:pre-side-effect-gate",
|
|
203
|
+
)) {
|
|
204
|
+
builder.addStep(step);
|
|
205
|
+
}
|
|
206
|
+
builder
|
|
194
207
|
.addStep(newPersistStep(deps.store))
|
|
195
|
-
// The C2 tuple step runs BEFORE the slot — the verified
|
|
196
|
-
// (createAuthorizationTuples → linkManagedOrgToIdentityProvider
|
|
197
|
-
// provisionBillingAccount). No-op with no driver composed.
|
|
208
|
+
// The C2 tuple step runs BEFORE the post-persist slot — the verified
|
|
209
|
+
// Java order (createAuthorizationTuples → linkManagedOrgToIdentityProvider
|
|
210
|
+
// → provisionBillingAccount). No-op with no driver composed.
|
|
198
211
|
.addStep(
|
|
199
212
|
newCreateAuthorizationTuplesStep(
|
|
200
213
|
deps.authorizationLifecycle,
|
|
@@ -15,13 +15,17 @@
|
|
|
15
15
|
* nothing; a failed create leaves the grant, and the next mint completes
|
|
16
16
|
* the account; a concurrent winner is read back;
|
|
17
17
|
* - the default role is viewer, owner is refused, and the token carries
|
|
18
|
-
* 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).
|
|
19
23
|
*/
|
|
20
24
|
import { generateKeyPairSync } from "node:crypto";
|
|
21
25
|
|
|
22
26
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
23
|
-
import { create } from "@bufbuild/protobuf";
|
|
24
|
-
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
27
|
+
import { clone, create } from "@bufbuild/protobuf";
|
|
28
|
+
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
25
29
|
import { describe, expect, it } from "vitest";
|
|
26
30
|
|
|
27
31
|
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
@@ -30,12 +34,18 @@ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/
|
|
|
30
34
|
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
31
35
|
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
32
36
|
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
33
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
PlatformClientSchema,
|
|
39
|
+
PlatformClientStatusSchema,
|
|
40
|
+
} from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
34
41
|
import { MintUserTokenRequestSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
|
|
35
42
|
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
36
43
|
|
|
44
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
45
|
+
import type { Logger } from "../../../boot/logger.js";
|
|
37
46
|
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
38
47
|
import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
|
|
48
|
+
import { LAST_USED_RESOLUTION_MS } from "../../../identity/credential-use.js";
|
|
39
49
|
import { verifyPlatformToken } from "../../../platformtoken/envelope.js";
|
|
40
50
|
import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
|
|
41
51
|
import type { PlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
|
|
@@ -101,6 +111,10 @@ interface Harness {
|
|
|
101
111
|
}>;
|
|
102
112
|
readonly granted: Array<{ spec: IamPolicySpec; caller: CallerIdentity }>;
|
|
103
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[];
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
function harness(
|
|
@@ -109,9 +123,12 @@ function harness(
|
|
|
109
123
|
keys?: PlatformTokenKeyRing | undefined;
|
|
110
124
|
failGrant?: boolean;
|
|
111
125
|
failCreate?: "fault" | "lost-race";
|
|
126
|
+
failStamp?: boolean;
|
|
127
|
+
logger?: Logger;
|
|
112
128
|
} = {},
|
|
113
129
|
): Harness {
|
|
114
|
-
const
|
|
130
|
+
const stored = { client: options.client ?? platformClient() };
|
|
131
|
+
const clientWrites: string[] = [];
|
|
115
132
|
const events: string[] = [];
|
|
116
133
|
const created: Harness["created"] = [];
|
|
117
134
|
const granted: Harness["granted"] = [];
|
|
@@ -126,12 +143,25 @@ function harness(
|
|
|
126
143
|
};
|
|
127
144
|
const deps: PlatformClientMintDeps = {
|
|
128
145
|
clients: {
|
|
129
|
-
save: async () => {
|
|
130
|
-
|
|
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
|
+
},
|
|
131
161
|
deleteById: async () => {},
|
|
132
|
-
findById: async () => client,
|
|
162
|
+
findById: async () => stored.client,
|
|
133
163
|
findByClientId: async (clientId) =>
|
|
134
|
-
clientId === client.spec?.clientId ? client : undefined,
|
|
164
|
+
clientId === stored.client.spec?.clientId ? stored.client : undefined,
|
|
135
165
|
findByOrgAndSlug: async () => undefined,
|
|
136
166
|
findByOrg: async () => [],
|
|
137
167
|
},
|
|
@@ -156,10 +186,10 @@ function harness(
|
|
|
156
186
|
},
|
|
157
187
|
grantPath,
|
|
158
188
|
keys: "keys" in options ? options.keys : RING,
|
|
159
|
-
logger: silentLogger,
|
|
189
|
+
logger: options.logger ?? silentLogger,
|
|
160
190
|
now: () => NOW,
|
|
161
191
|
};
|
|
162
|
-
return { deps, events, created, granted, accounts };
|
|
192
|
+
return { deps, events, created, granted, accounts, stored, clientWrites };
|
|
163
193
|
}
|
|
164
194
|
|
|
165
195
|
function request(
|
|
@@ -419,3 +449,59 @@ describe("mintUserToken — the user", () => {
|
|
|
419
449
|
);
|
|
420
450
|
});
|
|
421
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'",
|
|
@@ -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>;
|
|
@@ -736,9 +736,10 @@ describe("extension composition (O5 driver substitution)", () => {
|
|
|
736
736
|
/**
|
|
737
737
|
* The O4 gate-slot + status-hook arms: an extension gate spliced into a
|
|
738
738
|
* declared slot refuses with its own ConnectError code and copy — before
|
|
739
|
-
* the side effect on
|
|
740
|
-
* it on the post-persist slot
|
|
741
|
-
* inherited Java semantics — O4
|
|
739
|
+
* the side effect on a pre-side-effect slot (nothing persisted: the
|
|
740
|
+
* session and organization chains), after it on the post-persist slot
|
|
741
|
+
* (the row survives the failed request, the inherited Java semantics — O4
|
|
742
|
+
* verification V1); the status observers
|
|
742
743
|
* see the terminal updateStatus transition exactly once (the Q4
|
|
743
744
|
* phase-change rule) and the response decorator contributes the control
|
|
744
745
|
* signal on the shared reply schema.
|
|
@@ -746,6 +747,7 @@ describe("extension composition (O5 driver substitution)", () => {
|
|
|
746
747
|
describe("extension composition (O4 gate slots + status hooks)", () => {
|
|
747
748
|
const REFUSED_SESSION = "o4-refused-session";
|
|
748
749
|
const REFUSED_ORG_SLUG = "o4refusedorg";
|
|
750
|
+
const PRE_REFUSED_ORG_SLUG = "prerefusedorg";
|
|
749
751
|
let server: ComposedServer;
|
|
750
752
|
let dir: string;
|
|
751
753
|
let portTransport: Transport;
|
|
@@ -777,11 +779,25 @@ describe("extension composition (O4 gate slots + status hooks)", () => {
|
|
|
777
779
|
},
|
|
778
780
|
};
|
|
779
781
|
|
|
782
|
+
const orgPreSideEffectGate: PipelineStep<DescMessage> = {
|
|
783
|
+
name: "FakeOrgPreSideEffectGate",
|
|
784
|
+
execute: (ctx) => {
|
|
785
|
+
const org = ctx.newState as { metadata?: { slug?: string } };
|
|
786
|
+
if (org.metadata?.slug === PRE_REFUSED_ORG_SLUG) {
|
|
787
|
+
throw new ConnectError(
|
|
788
|
+
"fake organization limit reached",
|
|
789
|
+
Code.FailedPrecondition,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
|
|
780
795
|
const gateExtension: ServerExtension = {
|
|
781
796
|
name: "fake-gates-and-hooks",
|
|
782
797
|
gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
|
|
783
798
|
["session-create:pre-side-effect-gate", [sessionGate]],
|
|
784
799
|
["org-create:post-persist", [orgPostPersistGate]],
|
|
800
|
+
["org-create:pre-side-effect-gate", [orgPreSideEffectGate]],
|
|
785
801
|
]),
|
|
786
802
|
statusTransitionHooks: {
|
|
787
803
|
observers: [
|
|
@@ -892,6 +908,36 @@ describe("extension composition (O4 gate slots + status hooks)", () => {
|
|
|
892
908
|
expect(org.metadata?.slug).toBe(REFUSED_ORG_SLUG);
|
|
893
909
|
});
|
|
894
910
|
|
|
911
|
+
it("a pre-side-effect gate refusal aborts organization create with no row written", async () => {
|
|
912
|
+
const command = createClient(OrganizationCommandController, portTransport);
|
|
913
|
+
const query = createClient(OrganizationQueryController, portTransport);
|
|
914
|
+
|
|
915
|
+
let refused: ConnectError | undefined;
|
|
916
|
+
try {
|
|
917
|
+
await command.create(
|
|
918
|
+
create(OrganizationSchema, {
|
|
919
|
+
apiVersion: "tenancy.stigmer.ai/v1",
|
|
920
|
+
kind: "Organization",
|
|
921
|
+
metadata: { name: "Pre Refused Org", slug: PRE_REFUSED_ORG_SLUG },
|
|
922
|
+
}),
|
|
923
|
+
);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
refused = ConnectError.from(error);
|
|
926
|
+
}
|
|
927
|
+
expect(refused?.code).toBe(Code.FailedPrecondition);
|
|
928
|
+
expect(refused?.rawMessage).toBe("fake organization limit reached");
|
|
929
|
+
|
|
930
|
+
// The slot sits BEFORE Persist: the refused organization was never
|
|
931
|
+
// written, which is the whole reason the slot exists.
|
|
932
|
+
let getError: ConnectError | undefined;
|
|
933
|
+
try {
|
|
934
|
+
await query.get({ value: PRE_REFUSED_ORG_SLUG });
|
|
935
|
+
} catch (error) {
|
|
936
|
+
getError = ConnectError.from(error);
|
|
937
|
+
}
|
|
938
|
+
expect(getError?.code).toBe(Code.NotFound);
|
|
939
|
+
});
|
|
940
|
+
|
|
895
941
|
it("observers see the terminal updateStatus transition once and the decorator contributes the signal", async () => {
|
|
896
942
|
const executionId = "aexec_o4_hooks";
|
|
897
943
|
await server.store.saveResource(
|
|
@@ -488,6 +488,9 @@ describe("resolveExtensions — loud-fail throws (DD-006 §2b)", () => {
|
|
|
488
488
|
// personal-organization ensure and backfill ride it).
|
|
489
489
|
"identity-account-provision:post-persist",
|
|
490
490
|
"org-create:post-persist",
|
|
491
|
+
// The ninth: the organization create chain before Persist, where a
|
|
492
|
+
// limit on which organizations may exist refuses with nothing written.
|
|
493
|
+
"org-create:pre-side-effect-gate",
|
|
491
494
|
"sandbox-acquisition:gate",
|
|
492
495
|
"session-create:pre-side-effect-gate",
|
|
493
496
|
]);
|