@stigmer/server 3.14.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/boot/compose.d.ts +8 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +98 -12
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/apikey/verifier.d.ts +8 -1
  6. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  7. package/dist/domain/apikey/verifier.js +14 -2
  8. package/dist/domain/apikey/verifier.js.map +1 -1
  9. package/dist/domain/identityaccount/constants.d.ts +66 -0
  10. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  11. package/dist/domain/identityaccount/constants.js +153 -0
  12. package/dist/domain/identityaccount/constants.js.map +1 -0
  13. package/dist/domain/identityaccount/controller.d.ts +34 -0
  14. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/controller.js +342 -0
  16. package/dist/domain/identityaccount/controller.js.map +1 -0
  17. package/dist/domain/identityaccount/operator.d.ts +14 -0
  18. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/operator.js +67 -0
  20. package/dist/domain/identityaccount/operator.js.map +1 -0
  21. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  22. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/provisioning.js +114 -0
  24. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  25. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  26. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/resolve.js +12 -0
  28. package/dist/domain/identityaccount/resolve.js.map +1 -0
  29. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  30. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resource-store.js +131 -0
  32. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  33. package/dist/domain/identityaccount/steps.d.ts +79 -0
  34. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/steps.js +291 -0
  36. package/dist/domain/identityaccount/steps.js.map +1 -0
  37. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  38. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/store-contract.js +307 -0
  40. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  41. package/dist/domain/identityaccount/store.d.ts +66 -0
  42. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store.js +13 -0
  44. package/dist/domain/identityaccount/store.js.map +1 -0
  45. package/dist/extensions/drivers.d.ts +26 -0
  46. package/dist/extensions/drivers.d.ts.map +1 -1
  47. package/dist/extensions/gate-slots.d.ts +16 -1
  48. package/dist/extensions/gate-slots.d.ts.map +1 -1
  49. package/dist/extensions/gate-slots.js +1 -0
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/extensions/identity-federation.d.ts +36 -0
  52. package/dist/extensions/identity-federation.d.ts.map +1 -0
  53. package/dist/extensions/identity-federation.js +2 -0
  54. package/dist/extensions/identity-federation.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +18 -1
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +20 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/identity/oidc-discovery.d.ts +36 -0
  60. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  61. package/dist/identity/oidc-discovery.js +60 -0
  62. package/dist/identity/oidc-discovery.js.map +1 -0
  63. package/dist/identity/oidc-userinfo.d.ts +30 -0
  64. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  65. package/dist/identity/oidc-userinfo.js +28 -0
  66. package/dist/identity/oidc-userinfo.js.map +1 -0
  67. package/dist/identity/oidc-verifier.d.ts +6 -0
  68. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  69. package/dist/identity/oidc-verifier.js +35 -34
  70. package/dist/identity/oidc-verifier.js.map +1 -1
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +3 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  76. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  77. package/dist/pipeline/interceptors/auth.js +9 -1
  78. package/dist/pipeline/interceptors/auth.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  81. package/src/boot/compose.ts +118 -13
  82. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  83. package/src/domain/apikey/verifier.ts +24 -2
  84. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  85. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  86. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  87. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  88. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  89. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  90. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  91. package/src/domain/identityaccount/constants.ts +178 -0
  92. package/src/domain/identityaccount/controller.ts +667 -0
  93. package/src/domain/identityaccount/operator.ts +94 -0
  94. package/src/domain/identityaccount/provisioning.ts +182 -0
  95. package/src/domain/identityaccount/resolve.ts +52 -0
  96. package/src/domain/identityaccount/resource-store.ts +157 -0
  97. package/src/domain/identityaccount/steps.ts +377 -0
  98. package/src/domain/identityaccount/store-contract.ts +471 -0
  99. package/src/domain/identityaccount/store.ts +70 -0
  100. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  101. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  102. package/src/extensions/__tests__/registry.test.ts +4 -0
  103. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  104. package/src/extensions/drivers.ts +26 -0
  105. package/src/extensions/gate-slots.ts +16 -0
  106. package/src/extensions/identity-federation.ts +58 -0
  107. package/src/extensions/registry.ts +44 -1
  108. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  109. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  110. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  111. package/src/identity/oidc-discovery.ts +84 -0
  112. package/src/identity/oidc-userinfo.ts +64 -0
  113. package/src/identity/oidc-verifier.ts +42 -45
  114. package/src/index.ts +19 -0
  115. package/src/pipeline/interceptors/auth.ts +13 -1
  116. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
@@ -0,0 +1,803 @@
1
+ /**
2
+ * Pins the identity-account registry points END TO END (20260911.11
3
+ * Q-IA-9, S2 slice 3): a composed server with a fake unit, probed over the
4
+ * wire — transport → registered handler → the compose.ts wiring → the
5
+ * point. The registry's own merge rules are identity-account-points.test.ts;
6
+ * this file proves compose CONSUMES what the registry resolved, which is
7
+ * the arm a revert of the wiring would pass silently without.
8
+ *
9
+ * Three composed servers, one per describe, because a unit that registers
10
+ * into a slot the build does not declare makes resolveExtensions throw at
11
+ * boot (the §2b loud-fail contract): the slot gets its own server, so the
12
+ * driver and federation arms stay red for THEIR reasons while the slot is
13
+ * still undeclared.
14
+ *
15
+ * (A) trusted-local posture + the store driver + the federation
16
+ * capability + a selective Authorizer: the boot-time operator ensure,
17
+ * whoAmI and an in-process create all go through the REGISTERED
18
+ * driver and the generic store never holds an account; the four
19
+ * federated RPCs reach the unit's arm with the resolved ref, and the
20
+ * controller's shared preconditions (ref-with-slug, org match,
21
+ * authorize BEFORE the IdP-exists probe, IdP not found) refuse with
22
+ * the recorded codes and copy.
23
+ * (B) a unit-declared require-authentication posture + the unit's own
24
+ * verifier + the store driver — the cloud composition's exact shape:
25
+ * provisionMyAccount lands the row in the driver, and an API key the
26
+ * subject minted resolves through the OSS API-key lane to the
27
+ * driver's account. This is the lane the cloud actually runs over its
28
+ * driver; an OSS adapter over the cloud's generic store would miss
29
+ * every lookup and no other test would notice.
30
+ * (C) the same posture and verifier + the provision slot registered by
31
+ * two units: first provisioning persists the row THEN fires the gates
32
+ * with the caller re-stamped as the account; the idempotent second
33
+ * call fires them again (the backfill Q-IA-9 chose a slot for); an
34
+ * in-process create never fires them; a throwing gate fails the
35
+ * request and the row survives; unit order holds.
36
+ *
37
+ * The registered driver is the REAL OSS adapter over a SECOND SqliteStore
38
+ * behind a call recorder, so the proof of consumption is WHERE the rows
39
+ * land, and the pair under test is one that runs in production. The fake
40
+ * verifier of (B) and (C) vouches for an unsigned JWT-shaped token with no
41
+ * issuer: idpIdOf reads the `sub` from the raw token and the provisioner
42
+ * takes the identity's own claims when there is no issuer, so a real first
43
+ * provisioning runs with no network. The OSS OIDC verifier over a driver is
44
+ * deliberately NOT composed here: no real composition has that shape (a
45
+ * driver implies a composition with its own verifiers), and the verifier's
46
+ * resolution is pinned by identity/__tests__/oidc-verifier.test.ts.
47
+ */
48
+ import { mkdtempSync, rmSync } from "node:fs";
49
+ import { tmpdir } from "node:os";
50
+ import path from "node:path";
51
+
52
+ import { create, fromBinary } from "@bufbuild/protobuf";
53
+ import type { DescMessage } from "@bufbuild/protobuf";
54
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
55
+ import type { Client, Interceptor, Transport } from "@connectrpc/connect";
56
+ import { createGrpcTransport } from "@connectrpc/connect-node";
57
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
58
+
59
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
60
+ import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
61
+ import { ApiKeyCommandController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/command_pb";
62
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
63
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
64
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
65
+ import { IdentityAccountQueryController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/query_pb";
66
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
67
+
68
+ import { loadConfig } from "../../boot/config.js";
69
+ import { composeServer } from "../../boot/compose.js";
70
+ import type { ComposedServer } from "../../boot/compose.js";
71
+ import { createLogger } from "../../boot/logger.js";
72
+ import { accountIdFor } from "../../domain/identityaccount/constants.js";
73
+ import { newResourceIdentityAccountStore } from "../../domain/identityaccount/resource-store.js";
74
+ import type { IdentityAccountStore } from "../../domain/identityaccount/store.js";
75
+ import {
76
+ resetOperatorIdentityForTests,
77
+ setOperatorIdentity,
78
+ } from "../../pipeline/steps/defaults.js";
79
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
80
+ import type { Store } from "../../store/interface.js";
81
+ import { SqliteStore } from "../../store/sqlite/store.js";
82
+ import type { Authorizer } from "../authorizer.js";
83
+ import type { GateSlotName } from "../gate-slots.js";
84
+ import type { CallerIdentity, IdentityVerifier } from "../identity.js";
85
+ import type { IdentityFederation } from "../identity-federation.js";
86
+ import type { ServerExtension } from "../registry.js";
87
+
88
+ const silentLogger = createLogger({
89
+ level: "error",
90
+ pretty: false,
91
+ write: () => {},
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Shared fakes
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** The port's eight methods, delegating to the real adapter and recording each call by name. */
99
+ function recordingStore(
100
+ inner: IdentityAccountStore,
101
+ calls: string[],
102
+ ): IdentityAccountStore {
103
+ return {
104
+ save: (account) => {
105
+ calls.push("save");
106
+ return inner.save(account);
107
+ },
108
+ update: (account) => {
109
+ calls.push("update");
110
+ return inner.update(account);
111
+ },
112
+ deleteById: (id) => {
113
+ calls.push("deleteById");
114
+ return inner.deleteById(id);
115
+ },
116
+ findById: (id) => {
117
+ calls.push("findById");
118
+ return inner.findById(id);
119
+ },
120
+ findByIdpId: (idpId) => {
121
+ calls.push("findByIdpId");
122
+ return inner.findByIdpId(idpId);
123
+ },
124
+ findDirectByIdpId: (idpId) => {
125
+ calls.push("findDirectByIdpId");
126
+ return inner.findDirectByIdpId(idpId);
127
+ },
128
+ findDirectByEmail: (email) => {
129
+ calls.push("findDirectByEmail");
130
+ return inner.findDirectByEmail(email);
131
+ },
132
+ findByIds: (ids) => {
133
+ calls.push("findByIds");
134
+ return inner.findByIds(ids);
135
+ },
136
+ };
137
+ }
138
+
139
+ /** The driver a unit registers: the real adapter over its OWN store, recorded. */
140
+ interface RegisteredDriver {
141
+ readonly store: SqliteStore;
142
+ readonly accounts: IdentityAccountStore;
143
+ readonly calls: string[];
144
+ }
145
+
146
+ function openDriver(dir: string): RegisteredDriver {
147
+ const store = SqliteStore.open(path.join(dir, "driver.db"), silentLogger);
148
+ const calls: string[] = [];
149
+ return {
150
+ store,
151
+ accounts: recordingStore(newResourceIdentityAccountStore(store), calls),
152
+ calls,
153
+ };
154
+ }
155
+
156
+ /** The account ids a store holds — the one observation that says WHERE a row landed. */
157
+ async function accountIdsIn(store: Store): Promise<ReadonlyArray<string>> {
158
+ const rows = await store.listResources(ApiResourceKind.identity_account);
159
+ return rows.map(
160
+ (row) => fromBinary(IdentityAccountSchema, row).metadata?.id ?? "",
161
+ );
162
+ }
163
+
164
+ /**
165
+ * An unsigned JWT-shaped token (`header.payload.unsigned`): the fake
166
+ * verifier below vouches for it, and idpIdOf reads its `sub` exactly as it
167
+ * reads a real token's — the signature was the verifier's business.
168
+ */
169
+ function fakeJwt(sub: string, email: string): string {
170
+ const segment = (value: unknown): string =>
171
+ Buffer.from(JSON.stringify(value)).toString("base64url");
172
+ return `${segment({ alg: "none" })}.${segment({ sub, email })}.unsigned`;
173
+ }
174
+
175
+ /**
176
+ * The composition's own verifier (the cloud's shape): admits the fake
177
+ * token as a `user` whose identityId is the raw subject and whose issuer is
178
+ * empty — it does NOT resolve to an account, exactly like a verifier that
179
+ * runs before any row exists. Passes on everything else, so the OSS API-key
180
+ * lane composed ahead of it keeps its claim.
181
+ */
182
+ const fakeVerifier: IdentityVerifier = {
183
+ name: "fake-composition-verifier",
184
+ verify: (token) => {
185
+ const segments = token.split(".");
186
+ if (segments.length !== 3 || segments[2] !== "unsigned") {
187
+ return Promise.resolve(null);
188
+ }
189
+ const claims = JSON.parse(
190
+ Buffer.from(segments[1] ?? "", "base64url").toString("utf8"),
191
+ ) as { sub?: unknown; email?: unknown };
192
+ if (typeof claims.sub !== "string") {
193
+ return Promise.resolve(null);
194
+ }
195
+ const identity: CallerIdentity = {
196
+ identityId: claims.sub,
197
+ callerClass: "user",
198
+ issuer: "",
199
+ rawToken: token,
200
+ ...(typeof claims.email === "string" ? { email: claims.email } : {}),
201
+ };
202
+ return Promise.resolve(identity);
203
+ },
204
+ };
205
+
206
+ function bearer(token: string): Interceptor {
207
+ return (next) => (request) => {
208
+ request.header.set("authorization", `Bearer ${token}`);
209
+ return next(request);
210
+ };
211
+ }
212
+
213
+ function transportFor(port: number, token?: string): Transport {
214
+ return createGrpcTransport({
215
+ baseUrl: `http://127.0.0.1:${port}`,
216
+ interceptors: token !== undefined ? [bearer(token)] : [],
217
+ });
218
+ }
219
+
220
+ async function connectErrorOf(
221
+ promise: Promise<unknown>,
222
+ ): Promise<ConnectError> {
223
+ try {
224
+ await promise;
225
+ } catch (error) {
226
+ return ConnectError.from(error);
227
+ }
228
+ throw new Error("expected the call to fail");
229
+ }
230
+
231
+ let seq = 0;
232
+ function accountInput(idpId: string) {
233
+ seq += 1;
234
+ return {
235
+ apiVersion: "iam.stigmer.ai/v1",
236
+ kind: "IdentityAccount",
237
+ metadata: { name: `person${seq}@example.com` },
238
+ spec: {
239
+ idpId,
240
+ email: `person${seq}@example.com`,
241
+ firstName: "Composed",
242
+ lastName: `Person${seq}`,
243
+ },
244
+ };
245
+ }
246
+
247
+ function baseConfig(dir: string): Record<string, string> {
248
+ return {
249
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
250
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
251
+ DB_PATH: path.join(dir, "stigmer.db"),
252
+ STORAGE_PATH: path.join(dir, "storage"),
253
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
254
+ };
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // (A) trusted-local: the store driver, the federation capability
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe("identity-account points (composed server, trusted-local: driver + federation)", () => {
262
+ const OPERATOR_EMAIL = "operator@example.com";
263
+ const LOCKED_ORG = "locked-org";
264
+ let dir: string;
265
+ let server: ComposedServer;
266
+ let driver: RegisteredDriver;
267
+ let command: Client<typeof IdentityAccountCommandController>;
268
+ let query: Client<typeof IdentityAccountQueryController>;
269
+ let platform: Client<typeof IdentityAccountCommandController>;
270
+
271
+ interface ArmCall {
272
+ readonly method: string;
273
+ readonly refOrg: string;
274
+ readonly refSlug: string;
275
+ readonly callerIdentityId: string;
276
+ }
277
+ const armCalls: ArmCall[] = [];
278
+ let providerExistsCalls = 0;
279
+ let providerExists = true;
280
+
281
+ const federatedAccount = (method: string): IdentityAccount =>
282
+ create(IdentityAccountSchema, {
283
+ apiVersion: "iam.stigmer.ai/v1",
284
+ kind: "IdentityAccount",
285
+ metadata: { id: `ida_federated_${method}`, name: method },
286
+ spec: { idpId: "okta|1", email: "person@example.com" },
287
+ });
288
+
289
+ const arm =
290
+ (method: string) =>
291
+ (
292
+ _input: unknown,
293
+ ref: ApiResourceReference,
294
+ caller: CallerIdentity,
295
+ ): Promise<IdentityAccount> => {
296
+ armCalls.push({
297
+ method,
298
+ refOrg: ref.org,
299
+ refSlug: ref.slug,
300
+ callerIdentityId: caller.identityId,
301
+ });
302
+ return Promise.resolve(federatedAccount(method));
303
+ };
304
+
305
+ const fakeFederation: IdentityFederation = {
306
+ createFederatedAccount: arm("createFederatedAccount"),
307
+ updateFederatedAccount: arm("updateFederatedAccount"),
308
+ deprovisionFederatedAccount: arm("deprovisionFederatedAccount"),
309
+ getByExternalSub: arm("getByExternalSub"),
310
+ providerExists: () => {
311
+ providerExistsCalls += 1;
312
+ return Promise.resolve(providerExists);
313
+ },
314
+ };
315
+
316
+ /**
317
+ * Allows everything except the four federated RPCs' one annotation
318
+ * (organization / can_create_identity_account / field_path "org") on
319
+ * ONE org, so no other chain in the composed server is disturbed.
320
+ */
321
+ const selectiveAuthorizer: Authorizer = {
322
+ authorize: (_caller, check) =>
323
+ Promise.resolve(
324
+ check.resourceKind === ApiResourceKind.organization &&
325
+ check.permission === IamPermission.can_create_identity_account &&
326
+ check.resourceId === LOCKED_ORG
327
+ ? { kind: "deny", reason: "the fake authorizer locks this org" }
328
+ : { kind: "allow" },
329
+ ),
330
+ };
331
+
332
+ beforeAll(async () => {
333
+ dir = mkdtempSync(path.join(tmpdir(), "identity-account-composed-a-"));
334
+ driver = openDriver(dir);
335
+ const unit: ServerExtension = {
336
+ name: "fake-iam",
337
+ authorizer: selectiveAuthorizer,
338
+ drivers: {
339
+ identityAccountStore: driver.accounts,
340
+ identityFederation: fakeFederation,
341
+ },
342
+ };
343
+ setOperatorIdentity(OPERATOR_EMAIL, "The Operator");
344
+ server = await composeServer({
345
+ config: loadConfig({
346
+ ...baseConfig(dir),
347
+ STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
348
+ STIGMER_OPERATOR_NAME: "The Operator",
349
+ }),
350
+ logger: silentLogger,
351
+ extensions: [unit],
352
+ portOverride: 0,
353
+ host: "127.0.0.1",
354
+ });
355
+ const port = await server.start();
356
+ const transport = transportFor(port);
357
+ command = createClient(IdentityAccountCommandController, transport);
358
+ query = createClient(IdentityAccountQueryController, transport);
359
+ platform = createClient(
360
+ IdentityAccountCommandController,
361
+ server.inProcessTransport,
362
+ );
363
+ });
364
+
365
+ afterAll(async () => {
366
+ await server.shutdown();
367
+ await driver.store.close();
368
+ resetOperatorIdentityForTests();
369
+ rmSync(dir, { recursive: true, force: true });
370
+ });
371
+
372
+ describe("the store driver is the domain's persistence", () => {
373
+ it("the boot-time operator ensure wrote through the registered driver, and the generic store holds no account", async () => {
374
+ expect(driver.calls).toContain("save");
375
+ expect(await accountIdsIn(driver.store)).toEqual([
376
+ accountIdFor(`local|${OPERATOR_EMAIL}`),
377
+ ]);
378
+ expect(await accountIdsIn(server.store)).toEqual([]);
379
+ });
380
+
381
+ it("whoAmI reads through the driver", async () => {
382
+ driver.calls.length = 0;
383
+ const me = await query.whoAmI({});
384
+ expect(me.metadata?.id).toBe(accountIdFor(`local|${OPERATOR_EMAIL}`));
385
+ expect(driver.calls).toContain("findById");
386
+ });
387
+
388
+ it("an in-process create writes through the driver — the row lands in the driver's store, not the generic one", async () => {
389
+ const created = await platform.create(accountInput("auth0|via-driver"));
390
+ expect(created.metadata?.id).toBe(accountIdFor("auth0|via-driver"));
391
+ expect(await accountIdsIn(driver.store)).toContain(
392
+ accountIdFor("auth0|via-driver"),
393
+ );
394
+ expect(await accountIdsIn(server.store)).toEqual([]);
395
+ });
396
+ });
397
+
398
+ describe("the federation capability, present", () => {
399
+ // Inputs that pass the boundary validator (refinement 6), so every
400
+ // refusal below is the HANDLER's. `slug` is required by the
401
+ // reference's own rules; `org` on the reference may be empty.
402
+ const ref = { org: "acme", slug: "okta" };
403
+
404
+ it.each([
405
+ [
406
+ "createFederatedAccount",
407
+ (org: string, providerRef: { org: string; slug: string }) =>
408
+ command.createFederatedAccount({
409
+ org,
410
+ identityProviderRef: providerRef,
411
+ externalSub: "okta|1",
412
+ email: "person@example.com",
413
+ }),
414
+ ],
415
+ [
416
+ "updateFederatedAccount",
417
+ (org: string, providerRef: { org: string; slug: string }) =>
418
+ command.updateFederatedAccount({
419
+ org,
420
+ identityProviderRef: providerRef,
421
+ externalSub: "okta|1",
422
+ email: "person@example.com",
423
+ }),
424
+ ],
425
+ [
426
+ "deprovisionFederatedAccount",
427
+ (org: string, providerRef: { org: string; slug: string }) =>
428
+ command.deprovisionFederatedAccount({
429
+ org,
430
+ identityProviderRef: providerRef,
431
+ externalSub: "okta|1",
432
+ }),
433
+ ],
434
+ [
435
+ "getByExternalSub",
436
+ (org: string, providerRef: { org: string; slug: string }) =>
437
+ query.getByExternalSub({
438
+ org,
439
+ identityProviderRef: providerRef,
440
+ externalSub: "okta|1",
441
+ }),
442
+ ],
443
+ ] as const)(
444
+ "%s dispatches to the unit's arm with the resolved ref and the caller",
445
+ async (name, call) => {
446
+ armCalls.length = 0;
447
+ const answer = await call("acme", ref);
448
+ expect(answer.metadata?.id).toBe(`ida_federated_${name}`);
449
+ // The caller reaches the arm AS STAMPED at position 1: under the
450
+ // trusted-local posture that is the operator's email, not the
451
+ // operator's account id (Q-IA-3 kept the interceptor unchanged so
452
+ // the `created_by.id` on every existing self-host resource still
453
+ // names its owner).
454
+ expect(armCalls).toEqual([
455
+ {
456
+ method: name,
457
+ refOrg: "acme",
458
+ refSlug: "okta",
459
+ callerIdentityId: OPERATOR_EMAIL,
460
+ },
461
+ ]);
462
+ },
463
+ );
464
+
465
+ it("a reference that names no org is resolved to the input's org before the arm sees it", async () => {
466
+ armCalls.length = 0;
467
+ await command.createFederatedAccount({
468
+ org: "acme",
469
+ identityProviderRef: { slug: "okta" },
470
+ externalSub: "okta|2",
471
+ email: "person@example.com",
472
+ });
473
+ expect(armCalls[0]?.refOrg).toBe("acme");
474
+ });
475
+
476
+ it("a missing reference is INVALID_ARGUMENT from the handler — the arm is never reached", async () => {
477
+ armCalls.length = 0;
478
+ const error = await connectErrorOf(
479
+ command.createFederatedAccount({
480
+ org: "acme",
481
+ externalSub: "okta|3",
482
+ email: "person@example.com",
483
+ }),
484
+ );
485
+ expect(error.code).toBe(Code.InvalidArgument);
486
+ expect(error.rawMessage).toBe(
487
+ "identity_provider_ref with a valid slug is required",
488
+ );
489
+ expect(armCalls).toEqual([]);
490
+ });
491
+
492
+ it("a reference whose org disagrees with the input's is INVALID_ARGUMENT naming both", async () => {
493
+ armCalls.length = 0;
494
+ const error = await connectErrorOf(
495
+ command.createFederatedAccount({
496
+ org: "acme",
497
+ identityProviderRef: { org: "rival", slug: "okta" },
498
+ externalSub: "okta|4",
499
+ email: "person@example.com",
500
+ }),
501
+ );
502
+ expect(error.code).toBe(Code.InvalidArgument);
503
+ expect(error.rawMessage).toBe(
504
+ "identity_provider_ref.org 'rival' does not match input org 'acme'",
505
+ );
506
+ expect(armCalls).toEqual([]);
507
+ });
508
+
509
+ it("an identity provider the unit does not know is NOT_FOUND naming org/slug — the arm is never reached", async () => {
510
+ armCalls.length = 0;
511
+ providerExists = false;
512
+ try {
513
+ const error = await connectErrorOf(
514
+ query.getByExternalSub({
515
+ org: "acme",
516
+ identityProviderRef: { org: "acme", slug: "unknown-idp" },
517
+ externalSub: "okta|5",
518
+ }),
519
+ );
520
+ expect(error.code).toBe(Code.NotFound);
521
+ expect(error.rawMessage).toBe(
522
+ "IdentityProvider 'acme/unknown-idp' not found",
523
+ );
524
+ expect(armCalls).toEqual([]);
525
+ } finally {
526
+ providerExists = true;
527
+ }
528
+ });
529
+
530
+ it("the authorizer's refusal lands BEFORE the IdP-exists probe — a refused caller learns nothing about the org's providers", async () => {
531
+ armCalls.length = 0;
532
+ providerExistsCalls = 0;
533
+ const error = await connectErrorOf(
534
+ command.createFederatedAccount({
535
+ org: LOCKED_ORG,
536
+ identityProviderRef: { org: LOCKED_ORG, slug: "okta" },
537
+ externalSub: "okta|6",
538
+ email: "person@example.com",
539
+ }),
540
+ );
541
+ expect(error.code).toBe(Code.PermissionDenied);
542
+ expect(error.rawMessage).toBe(
543
+ "unauthorized to create identity accounts in this organization",
544
+ );
545
+ expect(providerExistsCalls).toBe(0);
546
+ expect(armCalls).toEqual([]);
547
+ });
548
+ });
549
+ });
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // (B) the cloud's shape: a declared posture, the unit's verifier, the driver
553
+ // ---------------------------------------------------------------------------
554
+
555
+ describe("identity-account points (composed server, declared posture: the verifiers follow the driver)", () => {
556
+ const SUB = "fake|provisioned-subject";
557
+ const EMAIL = "provisioned@example.com";
558
+ let dir: string;
559
+ let server: ComposedServer;
560
+ let port: number;
561
+ let driver: RegisteredDriver;
562
+
563
+ beforeAll(async () => {
564
+ dir = mkdtempSync(path.join(tmpdir(), "identity-account-composed-b-"));
565
+ driver = openDriver(dir);
566
+ const unit: ServerExtension = {
567
+ name: "fake-iam",
568
+ requireAuthentication: true,
569
+ identityVerifiers: [fakeVerifier],
570
+ drivers: { identityAccountStore: driver.accounts },
571
+ };
572
+ server = await composeServer({
573
+ config: loadConfig(baseConfig(dir)),
574
+ logger: silentLogger,
575
+ extensions: [unit],
576
+ portOverride: 0,
577
+ host: "127.0.0.1",
578
+ });
579
+ port = await server.start();
580
+ });
581
+
582
+ afterAll(async () => {
583
+ await server.shutdown();
584
+ await driver.store.close();
585
+ rmSync(dir, { recursive: true, force: true });
586
+ });
587
+
588
+ it("no operator account exists under the posture — the driver's store starts empty", async () => {
589
+ expect(await accountIdsIn(driver.store)).toEqual([]);
590
+ });
591
+
592
+ it("provisionMyAccount lands the subject's row in the driver, not the generic store", async () => {
593
+ const command = createClient(
594
+ IdentityAccountCommandController,
595
+ transportFor(port, fakeJwt(SUB, EMAIL)),
596
+ );
597
+ const account = await command.provisionMyAccount({});
598
+ expect(account.metadata?.id).toBe(accountIdFor(SUB));
599
+ expect(account.spec?.email).toBe(EMAIL);
600
+ expect(await accountIdsIn(driver.store)).toEqual([accountIdFor(SUB)]);
601
+ expect(await accountIdsIn(server.store)).toEqual([]);
602
+ });
603
+
604
+ it("an API key the subject minted resolves through the OSS API-key lane to the DRIVER's account", async () => {
605
+ const minted = await createClient(
606
+ ApiKeyCommandController,
607
+ transportFor(port, fakeJwt(SUB, EMAIL)),
608
+ ).create({
609
+ apiVersion: "iam.stigmer.ai/v1",
610
+ kind: "ApiKey",
611
+ metadata: { name: "driver lane key", org: "local" },
612
+ spec: {},
613
+ });
614
+ const plaintext = minted.spec?.keyHash ?? "";
615
+ expect(plaintext.startsWith("stk_")).toBe(true);
616
+
617
+ driver.calls.length = 0;
618
+ const me = await createClient(
619
+ IdentityAccountQueryController,
620
+ transportFor(port, plaintext),
621
+ ).whoAmI({});
622
+ expect(me.metadata?.id).toBe(accountIdFor(SUB));
623
+ // The verifier's resolution read the driver: the key's creator stamp
624
+ // (the raw sub) was looked up by subject there, not in the OSS adapter
625
+ // over the generic store.
626
+ expect(driver.calls).toContain("findDirectByIdpId");
627
+ });
628
+ });
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // (C) the provision slot
632
+ // ---------------------------------------------------------------------------
633
+
634
+ describe("identity-account points (composed server, declared posture: the provision slot)", () => {
635
+ let dir: string;
636
+ let server: ComposedServer;
637
+ let port: number;
638
+
639
+ interface Firing {
640
+ readonly gate: string;
641
+ readonly accountId: string;
642
+ readonly callerIdentityId: string;
643
+ readonly auditEvent: string;
644
+ }
645
+ const firings: Firing[] = [];
646
+ let failNextPersonalOrganization = false;
647
+ const GATE_FAILURE_MESSAGE = "fake personal-organization ensure failed";
648
+
649
+ function recordingGate(name: string): PipelineStep<DescMessage> {
650
+ return {
651
+ name,
652
+ execute: (ctx) => {
653
+ const account = ctx.newState as IdentityAccount;
654
+ firings.push({
655
+ gate: name,
656
+ accountId: account.metadata?.id ?? "",
657
+ callerIdentityId: ctx.callerIdentity.identityId,
658
+ auditEvent: account.status?.audit?.specAudit?.event ?? "",
659
+ });
660
+ if (
661
+ name === "FakePersonalOrganization" &&
662
+ failNextPersonalOrganization
663
+ ) {
664
+ failNextPersonalOrganization = false;
665
+ throw new ConnectError(GATE_FAILURE_MESSAGE, Code.FailedPrecondition);
666
+ }
667
+ },
668
+ };
669
+ }
670
+
671
+ beforeAll(async () => {
672
+ dir = mkdtempSync(path.join(tmpdir(), "identity-account-composed-c-"));
673
+ const iamUnit: ServerExtension = {
674
+ name: "fake-iam",
675
+ requireAuthentication: true,
676
+ identityVerifiers: [fakeVerifier],
677
+ gateSteps: new Map<
678
+ GateSlotName,
679
+ ReadonlyArray<PipelineStep<DescMessage>>
680
+ >([
681
+ [
682
+ "identity-account-provision:post-persist",
683
+ [recordingGate("FakePersonalOrganization")],
684
+ ],
685
+ ]),
686
+ };
687
+ const auditUnit: ServerExtension = {
688
+ name: "fake-audit",
689
+ gateSteps: new Map<
690
+ GateSlotName,
691
+ ReadonlyArray<PipelineStep<DescMessage>>
692
+ >([
693
+ [
694
+ "identity-account-provision:post-persist",
695
+ [recordingGate("FakeProvisionAudit")],
696
+ ],
697
+ ]),
698
+ };
699
+ server = await composeServer({
700
+ config: loadConfig(baseConfig(dir)),
701
+ logger: silentLogger,
702
+ extensions: [iamUnit, auditUnit],
703
+ portOverride: 0,
704
+ host: "127.0.0.1",
705
+ });
706
+ port = await server.start();
707
+ });
708
+
709
+ afterAll(async () => {
710
+ await server.shutdown();
711
+ rmSync(dir, { recursive: true, force: true });
712
+ });
713
+
714
+ function commandAs(sub: string) {
715
+ return createClient(
716
+ IdentityAccountCommandController,
717
+ transportFor(port, fakeJwt(sub, `${sub.split("|")[1]}@example.com`)),
718
+ );
719
+ }
720
+
721
+ function queryAs(sub: string) {
722
+ return createClient(
723
+ IdentityAccountQueryController,
724
+ transportFor(port, fakeJwt(sub, `${sub.split("|")[1]}@example.com`)),
725
+ );
726
+ }
727
+
728
+ it("first provisioning persists the row, THEN fires the gates in unit order with the caller re-stamped as the account", async () => {
729
+ firings.length = 0;
730
+ const sub = "fake|first";
731
+ const account = await commandAs(sub).provisionMyAccount({});
732
+ const id = accountIdFor(sub);
733
+ expect(account.metadata?.id).toBe(id);
734
+ expect(firings).toEqual([
735
+ {
736
+ gate: "FakePersonalOrganization",
737
+ accountId: id,
738
+ callerIdentityId: id,
739
+ auditEvent: "created",
740
+ },
741
+ {
742
+ gate: "FakeProvisionAudit",
743
+ accountId: id,
744
+ callerIdentityId: id,
745
+ auditEvent: "created",
746
+ },
747
+ ]);
748
+ // The caller the gates saw is the ACCOUNT, not the idp-shaped subject
749
+ // the verifier stamped at position 1.
750
+ expect(firings[0]?.callerIdentityId).not.toBe(sub);
751
+ });
752
+
753
+ it("the idempotent second call fires the gates again — the backfill the slot exists for", async () => {
754
+ firings.length = 0;
755
+ const sub = "fake|first";
756
+ const again = await commandAs(sub).provisionMyAccount({});
757
+ expect(again.metadata?.id).toBe(accountIdFor(sub));
758
+ expect(firings.map((firing) => firing.gate)).toEqual([
759
+ "FakePersonalOrganization",
760
+ "FakeProvisionAudit",
761
+ ]);
762
+ });
763
+
764
+ it("an in-process create never fires the slot — it is provisionMyAccount's alone", async () => {
765
+ firings.length = 0;
766
+ const platform = createClient(
767
+ IdentityAccountCommandController,
768
+ server.inProcessTransport,
769
+ );
770
+ const created = await platform.create(
771
+ accountInput("fake|created-not-provisioned"),
772
+ );
773
+ expect(created.metadata?.id).toBe(
774
+ accountIdFor("fake|created-not-provisioned"),
775
+ );
776
+ expect(firings).toEqual([]);
777
+ });
778
+
779
+ it("a gate that throws fails the request with its own code and copy; the row survives and the next call heals", async () => {
780
+ firings.length = 0;
781
+ const sub = "fake|gate-fails";
782
+ failNextPersonalOrganization = true;
783
+ const error = await connectErrorOf(commandAs(sub).provisionMyAccount({}));
784
+ expect(error.code).toBe(Code.FailedPrecondition);
785
+ expect(error.rawMessage).toBe(GATE_FAILURE_MESSAGE);
786
+ // The first gate fired and threw; the second never ran.
787
+ expect(firings.map((firing) => firing.gate)).toEqual([
788
+ "FakePersonalOrganization",
789
+ ]);
790
+
791
+ // Non-transactional: the account persisted before the slot ran.
792
+ const me = await queryAs(sub).whoAmI({});
793
+ expect(me.metadata?.id).toBe(accountIdFor(sub));
794
+
795
+ firings.length = 0;
796
+ const healed = await commandAs(sub).provisionMyAccount({});
797
+ expect(healed.metadata?.id).toBe(accountIdFor(sub));
798
+ expect(firings.map((firing) => firing.gate)).toEqual([
799
+ "FakePersonalOrganization",
800
+ "FakeProvisionAudit",
801
+ ]);
802
+ });
803
+ });