@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.
- package/dist/boot/compose.d.ts +8 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +98 -12
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/apikey/verifier.d.ts +8 -1
- package/dist/domain/apikey/verifier.d.ts.map +1 -1
- package/dist/domain/apikey/verifier.js +14 -2
- package/dist/domain/apikey/verifier.js.map +1 -1
- package/dist/domain/identityaccount/constants.d.ts +66 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -0
- package/dist/domain/identityaccount/constants.js +153 -0
- package/dist/domain/identityaccount/constants.js.map +1 -0
- package/dist/domain/identityaccount/controller.d.ts +34 -0
- package/dist/domain/identityaccount/controller.d.ts.map +1 -0
- package/dist/domain/identityaccount/controller.js +342 -0
- package/dist/domain/identityaccount/controller.js.map +1 -0
- package/dist/domain/identityaccount/operator.d.ts +14 -0
- package/dist/domain/identityaccount/operator.d.ts.map +1 -0
- package/dist/domain/identityaccount/operator.js +67 -0
- package/dist/domain/identityaccount/operator.js.map +1 -0
- package/dist/domain/identityaccount/provisioning.d.ts +52 -0
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
- package/dist/domain/identityaccount/provisioning.js +114 -0
- package/dist/domain/identityaccount/provisioning.js.map +1 -0
- package/dist/domain/identityaccount/resolve.d.ts +36 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
- package/dist/domain/identityaccount/resolve.js +12 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -0
- package/dist/domain/identityaccount/resource-store.d.ts +4 -0
- package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
- package/dist/domain/identityaccount/resource-store.js +131 -0
- package/dist/domain/identityaccount/resource-store.js.map +1 -0
- package/dist/domain/identityaccount/steps.d.ts +79 -0
- package/dist/domain/identityaccount/steps.d.ts.map +1 -0
- package/dist/domain/identityaccount/steps.js +291 -0
- package/dist/domain/identityaccount/steps.js.map +1 -0
- package/dist/domain/identityaccount/store-contract.d.ts +22 -0
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
- package/dist/domain/identityaccount/store-contract.js +307 -0
- package/dist/domain/identityaccount/store-contract.js.map +1 -0
- package/dist/domain/identityaccount/store.d.ts +66 -0
- package/dist/domain/identityaccount/store.d.ts.map +1 -0
- package/dist/domain/identityaccount/store.js +13 -0
- package/dist/domain/identityaccount/store.js.map +1 -0
- package/dist/extensions/drivers.d.ts +26 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/gate-slots.d.ts +16 -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/extensions/identity-federation.d.ts +36 -0
- package/dist/extensions/identity-federation.d.ts.map +1 -0
- package/dist/extensions/identity-federation.js +2 -0
- package/dist/extensions/identity-federation.js.map +1 -0
- package/dist/extensions/registry.d.ts +18 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +20 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/identity/oidc-discovery.d.ts +36 -0
- package/dist/identity/oidc-discovery.d.ts.map +1 -0
- package/dist/identity/oidc-discovery.js +60 -0
- package/dist/identity/oidc-discovery.js.map +1 -0
- package/dist/identity/oidc-userinfo.d.ts +30 -0
- package/dist/identity/oidc-userinfo.d.ts.map +1 -0
- package/dist/identity/oidc-userinfo.js +28 -0
- package/dist/identity/oidc-userinfo.js.map +1 -0
- package/dist/identity/oidc-verifier.d.ts +6 -0
- package/dist/identity/oidc-verifier.d.ts.map +1 -1
- package/dist/identity/oidc-verifier.js +35 -34
- package/dist/identity/oidc-verifier.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +10 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +9 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/package.json +4 -4
- package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
- package/src/boot/compose.ts +118 -13
- package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
- package/src/domain/apikey/verifier.ts +24 -2
- package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
- package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
- package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
- package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
- package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
- package/src/domain/identityaccount/__tests__/support.ts +58 -0
- package/src/domain/identityaccount/constants.ts +178 -0
- package/src/domain/identityaccount/controller.ts +667 -0
- package/src/domain/identityaccount/operator.ts +94 -0
- package/src/domain/identityaccount/provisioning.ts +182 -0
- package/src/domain/identityaccount/resolve.ts +52 -0
- package/src/domain/identityaccount/resource-store.ts +157 -0
- package/src/domain/identityaccount/steps.ts +377 -0
- package/src/domain/identityaccount/store-contract.ts +471 -0
- package/src/domain/identityaccount/store.ts +70 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
- package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
- package/src/extensions/__tests__/registry.test.ts +4 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
- package/src/extensions/drivers.ts +26 -0
- package/src/extensions/gate-slots.ts +16 -0
- package/src/extensions/identity-federation.ts +58 -0
- package/src/extensions/registry.ts +44 -1
- package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
- package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
- package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
- package/src/identity/oidc-discovery.ts +84 -0
- package/src/identity/oidc-userinfo.ts +64 -0
- package/src/identity/oidc-verifier.ts +42 -45
- package/src/index.ts +19 -0
- package/src/pipeline/interceptors/auth.ts +13 -1
- 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
|
+
});
|