@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins direct-account self-provisioning (provisioning.ts), the cloud's
|
|
3
|
+
* `provisionDirectAccount` flow (its iam/account/provisioning.ts header,
|
|
4
|
+
* steps 1 to 3) as OSS core, over a fake IdentityAccountStore and an
|
|
5
|
+
* injected UserInfoClient — no network, no store driver:
|
|
6
|
+
*
|
|
7
|
+
* 1. idempotent early return when the subject already has an account
|
|
8
|
+
* (no create, no userinfo call);
|
|
9
|
+
* 2. the profile comes from the userinfo endpoint of the ISSUER THAT
|
|
10
|
+
* VOUCHED for the token (caller.issuer; T01_1_review.md A8), fetched
|
|
11
|
+
* with the caller's own access token, for every token-bearing caller
|
|
12
|
+
* — the cloud's posture, one path, given_name/family_name as the IdP
|
|
13
|
+
* states them (a claims shortcut that split `name` into two would be
|
|
14
|
+
* guesswork; recorded in T01_3_execution.md). A caller with no issuer
|
|
15
|
+
* never dials out: its profile is what the CallerIdentity carries;
|
|
16
|
+
* 3. create through the ONE create path the domain owns (the same path
|
|
17
|
+
* the create RPC's chain runs, so the tuple lifecycle and the
|
|
18
|
+
* provisioning slot fire for both); a first-login race resolves the
|
|
19
|
+
* winner by subject whatever the failure's shape.
|
|
20
|
+
*
|
|
21
|
+
* Step 4 of the cloud's flow (the personal organization) is not core: it
|
|
22
|
+
* fires on `identity-account-provision:post-persist` in the composition.
|
|
23
|
+
*/
|
|
24
|
+
import { create } from "@bufbuild/protobuf";
|
|
25
|
+
import { describe, expect, it } from "vitest";
|
|
26
|
+
|
|
27
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
28
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
29
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
30
|
+
|
|
31
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
32
|
+
import { accountIdFor } from "../constants.js";
|
|
33
|
+
import {
|
|
34
|
+
UserInfoFetchError,
|
|
35
|
+
newDirectAccountProvisioner,
|
|
36
|
+
} from "../provisioning.js";
|
|
37
|
+
import type {
|
|
38
|
+
CreateAccountInput,
|
|
39
|
+
UserInfoClient,
|
|
40
|
+
UserProfile,
|
|
41
|
+
} from "../provisioning.js";
|
|
42
|
+
import type { IdentityAccountStore } from "../store.js";
|
|
43
|
+
import { fakeIdentityAccountStore as fakeAccounts } from "./support.js";
|
|
44
|
+
|
|
45
|
+
/** The one create path, as the test sees it: derive, save, return. */
|
|
46
|
+
function fakeCreatePath(accounts: IdentityAccountStore) {
|
|
47
|
+
const calls: CreateAccountInput[] = [];
|
|
48
|
+
return {
|
|
49
|
+
calls,
|
|
50
|
+
async createAccount(
|
|
51
|
+
input: CreateAccountInput,
|
|
52
|
+
_caller: CallerIdentity,
|
|
53
|
+
): Promise<IdentityAccount> {
|
|
54
|
+
calls.push(input);
|
|
55
|
+
const account = create(IdentityAccountSchema, {
|
|
56
|
+
apiVersion: "iam.stigmer.ai/v1",
|
|
57
|
+
kind: "IdentityAccount",
|
|
58
|
+
metadata: { id: accountIdFor(input.spec.idpId), name: input.name },
|
|
59
|
+
spec: input.spec,
|
|
60
|
+
});
|
|
61
|
+
await accounts.save(account);
|
|
62
|
+
return account;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Records every `[issuer, token]` the provisioner asks about. */
|
|
68
|
+
function fakeUserInfo(
|
|
69
|
+
profile: UserProfile | Error,
|
|
70
|
+
): UserInfoClient & { calls: Array<[string, string]>; tokens: string[] } {
|
|
71
|
+
const calls: Array<[string, string]> = [];
|
|
72
|
+
return {
|
|
73
|
+
calls,
|
|
74
|
+
get tokens() {
|
|
75
|
+
return calls.map(([, token]) => token);
|
|
76
|
+
},
|
|
77
|
+
async fetchUserProfile(issuer, accessToken) {
|
|
78
|
+
calls.push([issuer, accessToken]);
|
|
79
|
+
if (profile instanceof Error) throw profile;
|
|
80
|
+
return profile;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const PROFILE: UserProfile = {
|
|
86
|
+
email: "alice@example.com",
|
|
87
|
+
firstName: "Alice",
|
|
88
|
+
lastName: "Liddell",
|
|
89
|
+
pictureUrl: "https://img.example/alice.png",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function oidcCaller(sub: string): CallerIdentity {
|
|
93
|
+
return {
|
|
94
|
+
identityId: sub,
|
|
95
|
+
callerClass: "user",
|
|
96
|
+
issuer: "https://issuer.test",
|
|
97
|
+
rawToken: `token-for-${sub}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const operatorCaller: CallerIdentity = {
|
|
102
|
+
identityId: "operator@example.com",
|
|
103
|
+
callerClass: "user",
|
|
104
|
+
issuer: "",
|
|
105
|
+
rawToken: "",
|
|
106
|
+
email: "operator@example.com",
|
|
107
|
+
displayName: "The Operator",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe("provisionDirectAccount", () => {
|
|
111
|
+
it("returns the existing account for a held subject without creating or dialling out", async () => {
|
|
112
|
+
const accounts = fakeAccounts();
|
|
113
|
+
const path = fakeCreatePath(accounts);
|
|
114
|
+
const userInfo = fakeUserInfo(PROFILE);
|
|
115
|
+
const provisioner = newDirectAccountProvisioner({
|
|
116
|
+
accounts,
|
|
117
|
+
createAccount: path.createAccount,
|
|
118
|
+
userInfo,
|
|
119
|
+
});
|
|
120
|
+
const first = await provisioner.provisionDirectAccount(
|
|
121
|
+
"auth0|alice",
|
|
122
|
+
oidcCaller("auth0|alice"),
|
|
123
|
+
);
|
|
124
|
+
const second = await provisioner.provisionDirectAccount(
|
|
125
|
+
"auth0|alice",
|
|
126
|
+
oidcCaller("auth0|alice"),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(second).toEqual(first);
|
|
130
|
+
expect(path.calls).toHaveLength(1);
|
|
131
|
+
expect(userInfo.tokens).toEqual(["token-for-auth0|alice"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("a token-bearing caller's profile comes from the vouching issuer's userinfo with the caller's own token", async () => {
|
|
135
|
+
const accounts = fakeAccounts();
|
|
136
|
+
const path = fakeCreatePath(accounts);
|
|
137
|
+
const userInfo = fakeUserInfo(PROFILE);
|
|
138
|
+
const provisioner = newDirectAccountProvisioner({
|
|
139
|
+
accounts,
|
|
140
|
+
createAccount: path.createAccount,
|
|
141
|
+
userInfo,
|
|
142
|
+
});
|
|
143
|
+
const account = await provisioner.provisionDirectAccount(
|
|
144
|
+
"auth0|alice",
|
|
145
|
+
oidcCaller("auth0|alice"),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(userInfo.calls).toEqual([
|
|
149
|
+
["https://issuer.test", "token-for-auth0|alice"],
|
|
150
|
+
]);
|
|
151
|
+
expect(account.metadata?.id).toBe(accountIdFor("auth0|alice"));
|
|
152
|
+
expect(account.metadata?.name).toBe("alice@example.com");
|
|
153
|
+
expect(account.spec).toMatchObject({
|
|
154
|
+
idpId: "auth0|alice",
|
|
155
|
+
email: "alice@example.com",
|
|
156
|
+
firstName: "Alice",
|
|
157
|
+
lastName: "Liddell",
|
|
158
|
+
pictureUrl: "https://img.example/alice.png",
|
|
159
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
160
|
+
isMachineAccount: false,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("a userinfo failure is UserInfoFetchError (the handler's UNAVAILABLE arm) and nothing is created", async () => {
|
|
165
|
+
const accounts = fakeAccounts();
|
|
166
|
+
const path = fakeCreatePath(accounts);
|
|
167
|
+
const provisioner = newDirectAccountProvisioner({
|
|
168
|
+
accounts,
|
|
169
|
+
createAccount: path.createAccount,
|
|
170
|
+
userInfo: fakeUserInfo(new Error("userinfo answered 503")),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
provisioner.provisionDirectAccount("auth0|bob", oidcCaller("auth0|bob")),
|
|
175
|
+
).rejects.toBeInstanceOf(UserInfoFetchError);
|
|
176
|
+
expect(path.calls).toHaveLength(0);
|
|
177
|
+
expect(accounts.rows.size).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a caller with no issuer never dials out — its profile is what the identity carries", async () => {
|
|
181
|
+
const accounts = fakeAccounts();
|
|
182
|
+
const path = fakeCreatePath(accounts);
|
|
183
|
+
const userInfo = fakeUserInfo(new Error("must not be called"));
|
|
184
|
+
const provisioner = newDirectAccountProvisioner({
|
|
185
|
+
accounts,
|
|
186
|
+
createAccount: path.createAccount,
|
|
187
|
+
userInfo,
|
|
188
|
+
});
|
|
189
|
+
const account = await provisioner.provisionDirectAccount(
|
|
190
|
+
"local|operator@example.com",
|
|
191
|
+
operatorCaller,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(userInfo.tokens).toEqual([]);
|
|
195
|
+
expect(account.metadata?.name).toBe("The Operator");
|
|
196
|
+
expect(account.spec?.email).toBe("operator@example.com");
|
|
197
|
+
expect(account.spec?.idpId).toBe("local|operator@example.com");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("a machine subject (…@clients) is flagged is_machine_account by the backend", async () => {
|
|
201
|
+
const accounts = fakeAccounts();
|
|
202
|
+
const path = fakeCreatePath(accounts);
|
|
203
|
+
const provisioner = newDirectAccountProvisioner({
|
|
204
|
+
accounts,
|
|
205
|
+
createAccount: path.createAccount,
|
|
206
|
+
userInfo: fakeUserInfo({ ...PROFILE, email: "" }),
|
|
207
|
+
});
|
|
208
|
+
const account = await provisioner.provisionDirectAccount(
|
|
209
|
+
"abc123@clients",
|
|
210
|
+
oidcCaller("abc123@clients"),
|
|
211
|
+
);
|
|
212
|
+
expect(account.spec?.isMachineAccount).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("the first-login race resolves to the winner by subject whatever the failure's shape", async () => {
|
|
216
|
+
const accounts = fakeAccounts();
|
|
217
|
+
const path = fakeCreatePath(accounts);
|
|
218
|
+
// The create path loses the race to a row that appears underneath it.
|
|
219
|
+
const winner = create(IdentityAccountSchema, {
|
|
220
|
+
metadata: { id: accountIdFor("auth0|carol"), name: "carol@example.com" },
|
|
221
|
+
spec: { idpId: "auth0|carol", email: "carol@example.com" },
|
|
222
|
+
});
|
|
223
|
+
const provisioner = newDirectAccountProvisioner({
|
|
224
|
+
accounts,
|
|
225
|
+
async createAccount(input, caller) {
|
|
226
|
+
await accounts.save(winner);
|
|
227
|
+
return path.createAccount(input, caller);
|
|
228
|
+
},
|
|
229
|
+
userInfo: fakeUserInfo(PROFILE),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const resolved = await provisioner.provisionDirectAccount(
|
|
233
|
+
"auth0|carol",
|
|
234
|
+
oidcCaller("auth0|carol"),
|
|
235
|
+
);
|
|
236
|
+
expect(resolved).toEqual(winner);
|
|
237
|
+
expect(accounts.rows.size).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("a create failure with no winner behind it propagates as the infrastructure fault it is", async () => {
|
|
241
|
+
const accounts = fakeAccounts();
|
|
242
|
+
const provisioner = newDirectAccountProvisioner({
|
|
243
|
+
accounts,
|
|
244
|
+
async createAccount() {
|
|
245
|
+
throw new Error("store is on fire");
|
|
246
|
+
},
|
|
247
|
+
userInfo: fakeUserInfo(PROFILE),
|
|
248
|
+
});
|
|
249
|
+
await expect(
|
|
250
|
+
provisioner.provisionDirectAccount(
|
|
251
|
+
"auth0|dave",
|
|
252
|
+
oidcCaller("auth0|dave"),
|
|
253
|
+
),
|
|
254
|
+
).rejects.toThrow("store is on fire");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("two concurrent first logins for one subject end in one row and one answer", async () => {
|
|
258
|
+
const accounts = fakeAccounts();
|
|
259
|
+
const path = fakeCreatePath(accounts);
|
|
260
|
+
const provisioner = newDirectAccountProvisioner({
|
|
261
|
+
accounts,
|
|
262
|
+
createAccount: path.createAccount,
|
|
263
|
+
userInfo: fakeUserInfo(PROFILE),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const [a, b] = await Promise.all([
|
|
267
|
+
provisioner.provisionDirectAccount(
|
|
268
|
+
"auth0|erin",
|
|
269
|
+
oidcCaller("auth0|erin"),
|
|
270
|
+
),
|
|
271
|
+
provisioner.provisionDirectAccount(
|
|
272
|
+
"auth0|erin",
|
|
273
|
+
oidcCaller("auth0|erin"),
|
|
274
|
+
),
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
expect(a.metadata?.id).toBe(accountIdFor("auth0|erin"));
|
|
278
|
+
expect(b.metadata?.id).toBe(a.metadata?.id);
|
|
279
|
+
expect(accounts.rows.size).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the one statement of "what identityId does this subject get
|
|
3
|
+
* stamped with" (resolve.ts; 20260911.11 Q-IA-2, A1, A6), the rule both
|
|
4
|
+
* verifiers call after their credential checks pass:
|
|
5
|
+
*
|
|
6
|
+
* - a subject with a DIRECT account resolves to that account's id (the
|
|
7
|
+
* cloud's direct-login posture, iam/direct/verifier.ts);
|
|
8
|
+
* - a subject without one is returned unchanged, so the caller is
|
|
9
|
+
* admitted idp-shaped and whoAmI / provisionMyAccount can run;
|
|
10
|
+
* - one `findDirectByIdpId` read per call, no cache — a row that
|
|
11
|
+
* appears between two calls is seen by the second (the liveness
|
|
12
|
+
* posture the API-key lane already has);
|
|
13
|
+
* - a store fault propagates as the SAME error object, never a
|
|
14
|
+
* credential rejection (the chassis maps a non-ConnectError to
|
|
15
|
+
* INTERNAL);
|
|
16
|
+
* - a hit whose row carries no id is an infrastructure fault naming the
|
|
17
|
+
* subject, never a `""` principal — the one deliberate divergence
|
|
18
|
+
* from the cloud's inline `?? ""`.
|
|
19
|
+
*/
|
|
20
|
+
import { create } from "@bufbuild/protobuf";
|
|
21
|
+
import { ConnectError } from "@connectrpc/connect";
|
|
22
|
+
import { describe, expect, it, vi } from "vitest";
|
|
23
|
+
|
|
24
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
25
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
26
|
+
|
|
27
|
+
import { accountIdFor } from "../constants.js";
|
|
28
|
+
import { identityIdForSubject } from "../resolve.js";
|
|
29
|
+
import type { AccountsBySubject } from "../resolve.js";
|
|
30
|
+
import { fakeIdentityAccountStore } from "./support.js";
|
|
31
|
+
|
|
32
|
+
function seeded(sub: string, id: string = accountIdFor(sub)) {
|
|
33
|
+
const accounts = fakeIdentityAccountStore();
|
|
34
|
+
accounts.rows.set(
|
|
35
|
+
id,
|
|
36
|
+
create(IdentityAccountSchema, {
|
|
37
|
+
metadata: { id, name: sub },
|
|
38
|
+
spec: {
|
|
39
|
+
idpId: sub,
|
|
40
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
return accounts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("identityIdForSubject", () => {
|
|
48
|
+
it("a subject with an account resolves to the account's id", async () => {
|
|
49
|
+
const accounts = seeded("auth0|known");
|
|
50
|
+
expect(await identityIdForSubject(accounts, "auth0|known")).toBe(
|
|
51
|
+
accountIdFor("auth0|known"),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("a subject without an account is returned unchanged — the caller stays idp-shaped", async () => {
|
|
56
|
+
const accounts = seeded("auth0|someone-else");
|
|
57
|
+
expect(await identityIdForSubject(accounts, "auth0|unknown")).toBe(
|
|
58
|
+
"auth0|unknown",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is one read per call with no cache — a row that appears between two calls is seen by the second", async () => {
|
|
63
|
+
const accounts = fakeIdentityAccountStore();
|
|
64
|
+
const lookup = vi.spyOn(accounts, "findDirectByIdpId");
|
|
65
|
+
|
|
66
|
+
expect(await identityIdForSubject(accounts, "auth0|late")).toBe(
|
|
67
|
+
"auth0|late",
|
|
68
|
+
);
|
|
69
|
+
accounts.rows.set(
|
|
70
|
+
accountIdFor("auth0|late"),
|
|
71
|
+
create(IdentityAccountSchema, {
|
|
72
|
+
metadata: { id: accountIdFor("auth0|late"), name: "late" },
|
|
73
|
+
spec: {
|
|
74
|
+
idpId: "auth0|late",
|
|
75
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
expect(await identityIdForSubject(accounts, "auth0|late")).toBe(
|
|
80
|
+
accountIdFor("auth0|late"),
|
|
81
|
+
);
|
|
82
|
+
expect(lookup).toHaveBeenCalledTimes(2);
|
|
83
|
+
expect(lookup).toHaveBeenNthCalledWith(1, "auth0|late");
|
|
84
|
+
expect(lookup).toHaveBeenNthCalledWith(2, "auth0|late");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a store fault propagates as the same error — never a credential rejection", async () => {
|
|
88
|
+
const fault = new Error("store is on fire");
|
|
89
|
+
const broken: AccountsBySubject = {
|
|
90
|
+
findDirectByIdpId: () => Promise.reject(fault),
|
|
91
|
+
};
|
|
92
|
+
const error = await identityIdForSubject(broken, "auth0|anyone").then(
|
|
93
|
+
() => {
|
|
94
|
+
throw new Error("expected rejection");
|
|
95
|
+
},
|
|
96
|
+
(e: unknown) => e,
|
|
97
|
+
);
|
|
98
|
+
expect(error).toBe(fault);
|
|
99
|
+
expect(error).not.toBeInstanceOf(ConnectError);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("a hit whose row carries no id is an infrastructure fault naming the subject — never a '' principal", async () => {
|
|
103
|
+
const accounts = seeded("auth0|corrupt", "");
|
|
104
|
+
const error = await identityIdForSubject(accounts, "auth0|corrupt").then(
|
|
105
|
+
() => {
|
|
106
|
+
throw new Error("expected rejection");
|
|
107
|
+
},
|
|
108
|
+
(e: unknown) => e,
|
|
109
|
+
);
|
|
110
|
+
expect(error).toBeInstanceOf(Error);
|
|
111
|
+
expect(error).not.toBeInstanceOf(ConnectError);
|
|
112
|
+
expect(String(error)).toContain("auth0|corrupt");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the IdentityAccountStore port-contract kit (../store-contract.ts)
|
|
3
|
+
* over the OSS adapter (../resource-store.ts) on both drivers through the
|
|
4
|
+
* drivers' own fixtures (sqlite always; Postgres under TEST_DATABASE_URL,
|
|
5
|
+
* the store contract's gating), and pins the two behaviors that are the
|
|
6
|
+
* OSS adapter's rather than the port's (T01_1_review.md A1):
|
|
7
|
+
*
|
|
8
|
+
* - every subject lookup is a PRIMARY-KEY read of the derived id — it
|
|
9
|
+
* never calls Store.findByField (the per-request scan A1 removed).
|
|
10
|
+
* Asserted with a spy over the real store, not by inspection;
|
|
11
|
+
* findDirectByEmail is the one lookup that scans (an administrative
|
|
12
|
+
* RPC, T01_1_review.md finding 2);
|
|
13
|
+
* - save refuses a direct account whose id is not its derived id — the
|
|
14
|
+
* invariant that makes the primary-key read correct, since a stray row
|
|
15
|
+
* would be unreachable by subject forever.
|
|
16
|
+
*
|
|
17
|
+
* The kit's case list is pinned by name here so a case cannot drop out of
|
|
18
|
+
* the kit unnoticed: the cloud driver's test iterates the same export and
|
|
19
|
+
* would silently prove less.
|
|
20
|
+
*/
|
|
21
|
+
import { create } from "@bufbuild/protobuf";
|
|
22
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
23
|
+
|
|
24
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
25
|
+
import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
26
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
27
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
28
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
29
|
+
|
|
30
|
+
import type { Store } from "../../../store/interface.js";
|
|
31
|
+
import { PostgresStore } from "../../../store/postgres/store.js";
|
|
32
|
+
import {
|
|
33
|
+
createTestDatabase,
|
|
34
|
+
testDatabaseAdminUrl,
|
|
35
|
+
type TestDatabase,
|
|
36
|
+
} from "../../../store/postgres/__tests__/support.js";
|
|
37
|
+
import { tempStore } from "../../../store/sqlite/__tests__/support.js";
|
|
38
|
+
import { accountIdFor } from "../constants.js";
|
|
39
|
+
import { newResourceIdentityAccountStore } from "../resource-store.js";
|
|
40
|
+
import type { IdentityAccountStoreContractFixture } from "../store-contract.js";
|
|
41
|
+
import { identityAccountStoreContract } from "../store-contract.js";
|
|
42
|
+
import type { IdentityAccountStore } from "../store.js";
|
|
43
|
+
|
|
44
|
+
/** The kit's contract lines, pinned: a dropped or renamed case is a visible diff here. */
|
|
45
|
+
const CONTRACT_CASE_NAMES = [
|
|
46
|
+
"save then findById round-trips the account",
|
|
47
|
+
"a second save under a held id is DuplicateAccountError, and the first row stands",
|
|
48
|
+
"findDirectByIdpId answers a direct account by its subject and undefined for an unknown one",
|
|
49
|
+
"findByIdpId (any mode) answers the same direct account",
|
|
50
|
+
"findDirectByEmail is an exact, case-sensitive match on direct accounts",
|
|
51
|
+
"update replaces the row in place and keeps the id",
|
|
52
|
+
"deleteById removes the row; the subject is free to be provisioned again",
|
|
53
|
+
"findByIds answers the present ones in request order and skips the unknown",
|
|
54
|
+
"a disconnected store is an infrastructure fault, never 'not found'",
|
|
55
|
+
"two concurrent saves of one subject end in fulfilments and DuplicateAccountErrors only, and the winner is readable by subject",
|
|
56
|
+
"findDirectByIdpId and findDirectByEmail never answer a federated account, while findByIdpId (any mode) does",
|
|
57
|
+
"findByIds answers one row per distinct id, in first-occurrence order; an empty request answers an empty list",
|
|
58
|
+
"update of an unknown id is a no-op: no row appears",
|
|
59
|
+
"deleteById of an unknown id resolves",
|
|
60
|
+
"save refuses a direct account with an empty idp_id",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
/** A direct account as the domain writes it: id derived, mode direct. */
|
|
64
|
+
function makeDirectAccount(overrides: {
|
|
65
|
+
idpId: string;
|
|
66
|
+
email?: string;
|
|
67
|
+
}): IdentityAccount {
|
|
68
|
+
return create(IdentityAccountSchema, {
|
|
69
|
+
apiVersion: "iam.stigmer.ai/v1",
|
|
70
|
+
kind: "IdentityAccount",
|
|
71
|
+
metadata: {
|
|
72
|
+
id: accountIdFor(overrides.idpId),
|
|
73
|
+
name: overrides.email ?? overrides.idpId,
|
|
74
|
+
},
|
|
75
|
+
spec: {
|
|
76
|
+
idpId: overrides.idpId,
|
|
77
|
+
email: overrides.email ?? "",
|
|
78
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface OpenedStore {
|
|
84
|
+
readonly store: Store;
|
|
85
|
+
close(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface DriverFixture {
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly skip: boolean;
|
|
91
|
+
open(): Promise<OpenedStore>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sqliteFixture: DriverFixture = {
|
|
95
|
+
name: "sqlite",
|
|
96
|
+
skip: false,
|
|
97
|
+
async open() {
|
|
98
|
+
const temp = tempStore();
|
|
99
|
+
return { store: temp.store, close: () => temp.cleanup() };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let postgresDatabase: TestDatabase | undefined;
|
|
104
|
+
const postgresFixture: DriverFixture = {
|
|
105
|
+
name: "postgres",
|
|
106
|
+
skip: testDatabaseAdminUrl() === undefined,
|
|
107
|
+
async open() {
|
|
108
|
+
if (postgresDatabase === undefined) {
|
|
109
|
+
postgresDatabase = await createTestDatabase();
|
|
110
|
+
}
|
|
111
|
+
const store = await PostgresStore.open(postgresDatabase.databaseUrl);
|
|
112
|
+
await store.deleteResourcesByKind(ApiResourceKind.identity_account);
|
|
113
|
+
return { store, close: () => store.close() };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
await postgresDatabase?.drop();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe.each([sqliteFixture, postgresFixture])(
|
|
122
|
+
"IdentityAccountStore over the OSS adapter ($name)",
|
|
123
|
+
(fixture) => {
|
|
124
|
+
describe.skipIf(fixture.skip)("the port-contract kit", () => {
|
|
125
|
+
const cases = identityAccountStoreContract(
|
|
126
|
+
async (): Promise<IdentityAccountStoreContractFixture> => {
|
|
127
|
+
const opened = await fixture.open();
|
|
128
|
+
return {
|
|
129
|
+
store: newResourceIdentityAccountStore(opened.store),
|
|
130
|
+
// Both drivers' close is idempotent, so cleanup after a
|
|
131
|
+
// disconnect is a no-op on the handle (plus the temp dir).
|
|
132
|
+
disconnect: () => opened.store.close(),
|
|
133
|
+
cleanup: () => opened.close(),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it("carries every contract line, by name", () => {
|
|
139
|
+
expect(cases.map((contractCase) => contractCase.name)).toEqual(
|
|
140
|
+
CONTRACT_CASE_NAMES,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (const contractCase of cases) {
|
|
145
|
+
it(contractCase.name, contractCase.run);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe.skipIf(fixture.skip)("the OSS adapter's own invariants", () => {
|
|
150
|
+
let opened: OpenedStore;
|
|
151
|
+
let accounts: IdentityAccountStore;
|
|
152
|
+
// Every findByField call the adapter makes, by field path — the spy
|
|
153
|
+
// that proves the hot path is a primary-key read.
|
|
154
|
+
let findByFieldPaths: string[];
|
|
155
|
+
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
opened = await fixture.open();
|
|
158
|
+
findByFieldPaths = [];
|
|
159
|
+
const spied: Store = Object.create(opened.store, {
|
|
160
|
+
findByField: {
|
|
161
|
+
value: (...args: Parameters<Store["findByField"]>) => {
|
|
162
|
+
findByFieldPaths.push(args[1]);
|
|
163
|
+
return opened.store.findByField(...args);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}) as Store;
|
|
167
|
+
accounts = newResourceIdentityAccountStore(spied);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(async () => {
|
|
171
|
+
await opened.close();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("every subject lookup is a primary-key read — never a findByField scan", async () => {
|
|
175
|
+
await accounts.save(makeDirectAccount({ idpId: "auth0|carol" }));
|
|
176
|
+
expect(
|
|
177
|
+
(await accounts.findDirectByIdpId("auth0|carol"))?.metadata?.id,
|
|
178
|
+
).toBe(accountIdFor("auth0|carol"));
|
|
179
|
+
expect((await accounts.findByIdpId("auth0|carol"))?.spec?.idpId).toBe(
|
|
180
|
+
"auth0|carol",
|
|
181
|
+
);
|
|
182
|
+
expect(
|
|
183
|
+
await accounts.findDirectByIdpId("auth0|nobody"),
|
|
184
|
+
).toBeUndefined();
|
|
185
|
+
expect(findByFieldPaths).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("findDirectByEmail is the one lookup that scans (administrative, exact match)", async () => {
|
|
189
|
+
await accounts.save(
|
|
190
|
+
makeDirectAccount({ idpId: "auth0|erin", email: "erin@example.com" }),
|
|
191
|
+
);
|
|
192
|
+
expect(
|
|
193
|
+
(await accounts.findDirectByEmail("erin@example.com"))?.spec?.idpId,
|
|
194
|
+
).toBe("auth0|erin");
|
|
195
|
+
expect(
|
|
196
|
+
await accounts.findDirectByEmail("nobody@example.com"),
|
|
197
|
+
).toBeUndefined();
|
|
198
|
+
expect(findByFieldPaths).toEqual(["spec.email", "spec.email"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses to save a direct account whose id is not its derived id", async () => {
|
|
202
|
+
const stray = makeDirectAccount({ idpId: "auth0|judy" });
|
|
203
|
+
stray.metadata = create(ApiResourceMetadataSchema, {
|
|
204
|
+
id: "ida_01hzzzzzzzzzzzzzzzzzzzzzzz",
|
|
205
|
+
name: "stray",
|
|
206
|
+
});
|
|
207
|
+
await expect(accounts.save(stray)).rejects.toThrow(
|
|
208
|
+
"direct account id must be derived from its idp_id",
|
|
209
|
+
);
|
|
210
|
+
expect(
|
|
211
|
+
await opened.store.listResources(ApiResourceKind.identity_account),
|
|
212
|
+
).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixtures for the identity-account domain: an in-memory
|
|
3
|
+
* IdentityAccountStore with the primary-key semantics the OSS adapter has
|
|
4
|
+
* (a second save under a held id is DuplicateAccountError), so provisioning
|
|
5
|
+
* and verifier tests exercise the port's contract without a store driver.
|
|
6
|
+
* Driver-backed fixtures live with the drivers (the sqlite and postgres
|
|
7
|
+
* __tests__/support modules under src/store).
|
|
8
|
+
*/
|
|
9
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
10
|
+
|
|
11
|
+
import { DuplicateAccountError } from "../store.js";
|
|
12
|
+
import type { IdentityAccountStore } from "../store.js";
|
|
13
|
+
|
|
14
|
+
export interface FakeIdentityAccountStore extends IdentityAccountStore {
|
|
15
|
+
readonly rows: Map<string, IdentityAccount>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fakeIdentityAccountStore(): FakeIdentityAccountStore {
|
|
19
|
+
const rows = new Map<string, IdentityAccount>();
|
|
20
|
+
const byIdpId = (idpId: string): IdentityAccount | undefined =>
|
|
21
|
+
[...rows.values()].find((row) => row.spec?.idpId === idpId);
|
|
22
|
+
return {
|
|
23
|
+
rows,
|
|
24
|
+
async save(account) {
|
|
25
|
+
const id = account.metadata?.id ?? "";
|
|
26
|
+
if (rows.has(id)) {
|
|
27
|
+
throw new DuplicateAccountError(
|
|
28
|
+
`identity account '${id}' already exists`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
rows.set(id, account);
|
|
32
|
+
},
|
|
33
|
+
async update(account) {
|
|
34
|
+
rows.set(account.metadata?.id ?? "", account);
|
|
35
|
+
},
|
|
36
|
+
async deleteById(id) {
|
|
37
|
+
rows.delete(id);
|
|
38
|
+
},
|
|
39
|
+
async findById(id) {
|
|
40
|
+
return rows.get(id);
|
|
41
|
+
},
|
|
42
|
+
async findByIdpId(idpId) {
|
|
43
|
+
return byIdpId(idpId);
|
|
44
|
+
},
|
|
45
|
+
async findDirectByIdpId(idpId) {
|
|
46
|
+
return byIdpId(idpId);
|
|
47
|
+
},
|
|
48
|
+
async findDirectByEmail(email) {
|
|
49
|
+
return [...rows.values()].find((row) => row.spec?.email === email);
|
|
50
|
+
},
|
|
51
|
+
async findByIds(ids) {
|
|
52
|
+
return ids.flatMap((id) => {
|
|
53
|
+
const row = rows.get(id);
|
|
54
|
+
return row === undefined ? [] : [row];
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|