@stigmer/server 3.14.0 → 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/agentexecution/constants.d.ts +16 -0
- package/dist/domain/agentexecution/constants.d.ts.map +1 -1
- package/dist/domain/agentexecution/constants.js +22 -0
- package/dist/domain/agentexecution/constants.js.map +1 -1
- package/dist/domain/agentexecution/controller.d.ts.map +1 -1
- package/dist/domain/agentexecution/controller.js +10 -3
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/run-target.d.ts +26 -0
- package/dist/domain/agentexecution/run-target.d.ts.map +1 -0
- package/dist/domain/agentexecution/run-target.js +31 -0
- package/dist/domain/agentexecution/run-target.js.map +1 -0
- 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/domain/session/constants.d.ts +15 -0
- package/dist/domain/session/constants.d.ts.map +1 -0
- package/dist/domain/session/constants.js +17 -0
- package/dist/domain/session/constants.js.map +1 -0
- package/dist/domain/session/controller.d.ts.map +1 -1
- package/dist/domain/session/controller.js +10 -0
- package/dist/domain/session/controller.js.map +1 -1
- package/dist/domain/session/run-target.d.ts +15 -0
- package/dist/domain/session/run-target.d.ts.map +1 -0
- package/dist/domain/session/run-target.js +14 -0
- package/dist/domain/session/run-target.js.map +1 -0
- package/dist/domain/workflowexecution/constants.d.ts +9 -0
- package/dist/domain/workflowexecution/constants.d.ts.map +1 -1
- package/dist/domain/workflowexecution/constants.js +13 -0
- package/dist/domain/workflowexecution/constants.js.map +1 -1
- package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
- package/dist/domain/workflowexecution/controller.js +8 -0
- package/dist/domain/workflowexecution/controller.js.map +1 -1
- package/dist/domain/workflowexecution/run-target.d.ts +18 -0
- package/dist/domain/workflowexecution/run-target.d.ts.map +1 -0
- package/dist/domain/workflowexecution/run-target.js +23 -0
- package/dist/domain/workflowexecution/run-target.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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -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/dist/pipeline/steps/authorize-run-target.d.ts +105 -0
- package/dist/pipeline/steps/authorize-run-target.d.ts.map +1 -0
- package/dist/pipeline/steps/authorize-run-target.js +61 -0
- package/dist/pipeline/steps/authorize-run-target.js.map +1 -0
- package/dist/query/search/criteria.d.ts +10 -3
- package/dist/query/search/criteria.d.ts.map +1 -1
- package/dist/query/search/criteria.js +11 -4
- package/dist/query/search/criteria.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/agentexecution/__tests__/run-gate.test.ts +245 -0
- package/src/domain/agentexecution/__tests__/run-target.test.ts +117 -0
- package/src/domain/agentexecution/constants.ts +25 -0
- package/src/domain/agentexecution/controller.ts +10 -3
- package/src/domain/agentexecution/run-target.ts +64 -0
- 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/domain/session/__tests__/run-gate.test.ts +219 -0
- package/src/domain/session/__tests__/run-target.test.ts +43 -0
- package/src/domain/session/constants.ts +17 -0
- package/src/domain/session/controller.ts +10 -0
- package/src/domain/session/run-target.ts +30 -0
- package/src/domain/workflowexecution/__tests__/run-gate.test.ts +200 -0
- package/src/domain/workflowexecution/__tests__/run-target.test.ts +80 -0
- package/src/domain/workflowexecution/constants.ts +17 -0
- package/src/domain/workflowexecution/controller.ts +10 -0
- package/src/domain/workflowexecution/run-target.ts +47 -0
- package/src/extensions/__tests__/extension-composition.test.ts +49 -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 +26 -0
- package/src/pipeline/__tests__/support.ts +34 -0
- package/src/pipeline/interceptors/auth.ts +13 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
- package/src/pipeline/steps/__tests__/authorize-run-target.test.ts +261 -0
- package/src/pipeline/steps/authorize-run-target.ts +148 -0
- package/src/query/search/__tests__/criteria.test.ts +18 -1
- package/src/query/search/criteria.ts +11 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trusted-local operator account (20260911.11, T01_1_review.md A2,
|
|
3
|
+
* A5): under the posture with no authentication, the server knows its one
|
|
4
|
+
* principal from config, so the server states the fact at boot instead of
|
|
5
|
+
* asking three clients to provision it (the console's gate is off on the
|
|
6
|
+
* laptop and the desktop has none).
|
|
7
|
+
*
|
|
8
|
+
* `ensureOperatorAccount` is create-if-absent under the derived id of
|
|
9
|
+
* `local|<operator email, or "system">`, through the domain's ONE create
|
|
10
|
+
* path AS the trusted-local identity, so the row is stamped, derived and
|
|
11
|
+
* tuple-lifecycled exactly like every other account. Create-if-absent
|
|
12
|
+
* means a later boot never touches the row: preferences set through
|
|
13
|
+
* `update` survive a reboot, and a changed display name is edited like
|
|
14
|
+
* any profile, never re-synced from config. A changed operator email is a
|
|
15
|
+
* different subject and therefore a different account — the platform
|
|
16
|
+
* already treats the email as the identity (boot/config.ts
|
|
17
|
+
* loadOperatorIdentity). Two replicas ensuring at once converge by
|
|
18
|
+
* primary key: a lost race reads the winner back.
|
|
19
|
+
*
|
|
20
|
+
* The operator arrives from the SAME seam the interceptor stamps the
|
|
21
|
+
* caller from (pipeline/interceptors/auth.ts operatorIdentitySnapshot,
|
|
22
|
+
* read by the composition root), and the account is created AS the
|
|
23
|
+
* identity that seam yields for it (trustedLocalIdentityFor — the one
|
|
24
|
+
* construction of that principal), never from config directly: one
|
|
25
|
+
* source, so the row, its audit stamp and the per-request caller cannot
|
|
26
|
+
* disagree (A5).
|
|
27
|
+
*
|
|
28
|
+
* Whether this runs at all — only when no authentication posture is on —
|
|
29
|
+
* is the composition root's decision (boot/compose.ts); the cloud and an
|
|
30
|
+
* OIDC self-host never create one.
|
|
31
|
+
*/
|
|
32
|
+
import { create } from "@bufbuild/protobuf";
|
|
33
|
+
|
|
34
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
35
|
+
import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
|
|
36
|
+
|
|
37
|
+
import { trustedLocalIdentityFor } from "../../pipeline/interceptors/auth.js";
|
|
38
|
+
import { accountIdFor, localIdpIdFor } from "./constants.js";
|
|
39
|
+
import type { CreateAccount } from "./provisioning.js";
|
|
40
|
+
import { resolveCreateRace } from "./provisioning.js";
|
|
41
|
+
import { assignDirectBackendFields } from "./steps.js";
|
|
42
|
+
import type { IdentityAccountStore } from "./store.js";
|
|
43
|
+
|
|
44
|
+
/** The operator as boot/config.ts loads it; empty email = the "system" placeholder. */
|
|
45
|
+
export interface OperatorIdentity {
|
|
46
|
+
readonly email: string;
|
|
47
|
+
readonly displayName: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface OperatorAccountDeps {
|
|
51
|
+
readonly accounts: IdentityAccountStore;
|
|
52
|
+
readonly createAccount: CreateAccount;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The unconfigured laptop's operator: what the interceptor stamps as identityId. */
|
|
56
|
+
const SYSTEM_OPERATOR = "system";
|
|
57
|
+
|
|
58
|
+
export async function ensureOperatorAccount(
|
|
59
|
+
deps: OperatorAccountDeps,
|
|
60
|
+
operator: OperatorIdentity,
|
|
61
|
+
): Promise<IdentityAccount> {
|
|
62
|
+
// The caller the interceptor would stamp for this operator; its
|
|
63
|
+
// identityId (the email, or "system") is the subject's local principal.
|
|
64
|
+
const caller = trustedLocalIdentityFor(operator);
|
|
65
|
+
const idpId = localIdpIdFor(caller.identityId);
|
|
66
|
+
const existing = await deps.accounts.findById(accountIdFor(idpId));
|
|
67
|
+
if (existing !== undefined) {
|
|
68
|
+
return existing;
|
|
69
|
+
}
|
|
70
|
+
const name =
|
|
71
|
+
operator.displayName !== ""
|
|
72
|
+
? operator.displayName
|
|
73
|
+
: operator.email !== ""
|
|
74
|
+
? operator.email
|
|
75
|
+
: SYSTEM_OPERATOR;
|
|
76
|
+
try {
|
|
77
|
+
return await deps.createAccount(
|
|
78
|
+
{
|
|
79
|
+
name,
|
|
80
|
+
spec: assignDirectBackendFields(
|
|
81
|
+
create(IdentityAccountSpecSchema, {
|
|
82
|
+
idpId,
|
|
83
|
+
email: operator.email,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
caller,
|
|
88
|
+
);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return resolveCreateRace(error, () =>
|
|
91
|
+
deps.accounts.findById(accountIdFor(idpId)),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-account self-provisioning (20260911.11, Q-IA-2; the cloud's
|
|
3
|
+
* iam/account/provisioning.ts steps 1 to 3 as OSS core): the console, the
|
|
4
|
+
* CLI and the React hook call provisionMyAccount when whoAmI answers
|
|
5
|
+
* NOT_FOUND on a first sign-in, and the caller's OWN authenticated request
|
|
6
|
+
* drives provisioning synchronously — no webhook, no external trigger.
|
|
7
|
+
*
|
|
8
|
+
* The flow:
|
|
9
|
+
* 1. Idempotent early return when the subject already has an account
|
|
10
|
+
* (a primary-key read of the derived id; no userinfo call).
|
|
11
|
+
* 2. The profile. A token-bearing caller's comes from the userinfo
|
|
12
|
+
* endpoint of the issuer that vouched for the token, fetched with the
|
|
13
|
+
* caller's own access token — the token goes only where it came from
|
|
14
|
+
* (T01_1_review.md A8). One path for every issuer: `given_name` and
|
|
15
|
+
* `family_name` as the IdP states them, where splitting a `name`
|
|
16
|
+
* claim would be guesswork. A caller with no issuer (the trusted-local
|
|
17
|
+
* operator) never dials out: its profile is what the CallerIdentity
|
|
18
|
+
* carries.
|
|
19
|
+
* 3. Create through the ONE create path the domain owns — the same chain
|
|
20
|
+
* the create RPC runs, so the tuple lifecycle fires for both — AS the
|
|
21
|
+
* caller: the account is created by the subject it is for, and its
|
|
22
|
+
* audit stamp says so. A lost first-login race resolves the winner by
|
|
23
|
+
* subject whatever the failure's shape (the cloud's idempotency arm):
|
|
24
|
+
* the primary key guarantees one row, so a failed create with a row
|
|
25
|
+
* underneath it is a win, not an error.
|
|
26
|
+
*
|
|
27
|
+
* Step 4 of the cloud's flow (the personal organization) is not core: it
|
|
28
|
+
* fires on the `identity-account-provision:post-persist` slot in the
|
|
29
|
+
* composition (A10), wired by the controller.
|
|
30
|
+
*
|
|
31
|
+
* This module holds the domain flow over two PORTS — the store and the
|
|
32
|
+
* userinfo client — and no network code; identity/oidc-userinfo.ts is the
|
|
33
|
+
* OIDC implementation the composition root wires in.
|
|
34
|
+
*/
|
|
35
|
+
import { create } from "@bufbuild/protobuf";
|
|
36
|
+
|
|
37
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
38
|
+
import type { IdentityAccountSpec } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
|
|
39
|
+
import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
|
|
40
|
+
|
|
41
|
+
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
42
|
+
import { assignDirectBackendFields } from "./steps.js";
|
|
43
|
+
import type { IdentityAccountStore } from "./store.js";
|
|
44
|
+
|
|
45
|
+
/** The profile an identity provider's userinfo endpoint states. */
|
|
46
|
+
export interface UserProfile {
|
|
47
|
+
readonly email: string;
|
|
48
|
+
readonly firstName: string;
|
|
49
|
+
readonly lastName: string;
|
|
50
|
+
readonly pictureUrl: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The userinfo port: `issuer` is the one that vouched for `accessToken`. */
|
|
54
|
+
export interface UserInfoClient {
|
|
55
|
+
/** Throws on any failure to obtain a profile (the handler maps UNAVAILABLE). */
|
|
56
|
+
fetchUserProfile(issuer: string, accessToken: string): Promise<UserProfile>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Thrown when the profile cannot be fetched; the handler answers UNAVAILABLE. */
|
|
60
|
+
export class UserInfoFetchError extends Error {
|
|
61
|
+
constructor(cause: unknown) {
|
|
62
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
63
|
+
this.name = "UserInfoFetchError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a caller of the create path supplies: the rest is the chain's. */
|
|
68
|
+
export interface CreateAccountInput {
|
|
69
|
+
/** `metadata.name`; the chain defaults an empty one. */
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly spec: IdentityAccountSpec;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The domain's one create path, as its callers see it: runs the create
|
|
76
|
+
* chain AS `caller` and answers the persisted account. Built by the
|
|
77
|
+
* controller (newCreateAccountPath); the provisioner and the operator
|
|
78
|
+
* ensure call it, nothing else builds an account.
|
|
79
|
+
*/
|
|
80
|
+
export type CreateAccount = (
|
|
81
|
+
input: CreateAccountInput,
|
|
82
|
+
caller: CallerIdentity,
|
|
83
|
+
) => Promise<IdentityAccount>;
|
|
84
|
+
|
|
85
|
+
export interface DirectAccountProvisionerDeps {
|
|
86
|
+
readonly accounts: IdentityAccountStore;
|
|
87
|
+
readonly createAccount: CreateAccount;
|
|
88
|
+
readonly userInfo: UserInfoClient;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DirectAccountProvisioner {
|
|
92
|
+
/** provisionMyAccount's domain flow; `idpId` is the caller's verified subject. */
|
|
93
|
+
provisionDirectAccount(
|
|
94
|
+
idpId: string,
|
|
95
|
+
caller: CallerIdentity,
|
|
96
|
+
): Promise<IdentityAccount>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function newDirectAccountProvisioner(
|
|
100
|
+
deps: DirectAccountProvisionerDeps,
|
|
101
|
+
): DirectAccountProvisioner {
|
|
102
|
+
return {
|
|
103
|
+
async provisionDirectAccount(idpId, caller): Promise<IdentityAccount> {
|
|
104
|
+
const existing = await deps.accounts.findDirectByIdpId(idpId);
|
|
105
|
+
if (existing !== undefined) {
|
|
106
|
+
return existing;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const profile = await profileOf(deps.userInfo, caller);
|
|
110
|
+
const input: CreateAccountInput = {
|
|
111
|
+
name: displayNameOf(caller, profile),
|
|
112
|
+
spec: assignDirectBackendFields(
|
|
113
|
+
create(IdentityAccountSpecSchema, {
|
|
114
|
+
idpId,
|
|
115
|
+
email: profile.email,
|
|
116
|
+
firstName: profile.firstName,
|
|
117
|
+
lastName: profile.lastName,
|
|
118
|
+
pictureUrl: profile.pictureUrl,
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
return await deps.createAccount(input, caller);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return resolveCreateRace(error, () =>
|
|
127
|
+
deps.accounts.findDirectByIdpId(idpId),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Userinfo for a token-bearing caller; the identity's own claims otherwise. */
|
|
135
|
+
async function profileOf(
|
|
136
|
+
userInfo: UserInfoClient,
|
|
137
|
+
caller: CallerIdentity,
|
|
138
|
+
): Promise<UserProfile> {
|
|
139
|
+
if (caller.issuer === "") {
|
|
140
|
+
return {
|
|
141
|
+
email: caller.email ?? "",
|
|
142
|
+
firstName: "",
|
|
143
|
+
lastName: "",
|
|
144
|
+
pictureUrl: "",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return await userInfo.fetchUserProfile(caller.issuer, caller.rawToken);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new UserInfoFetchError(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The cloud names a provisioned account by its email; the trusted-local
|
|
156
|
+
* operator, who has no userinfo, by the display name the identity
|
|
157
|
+
* carries. An empty result is the chain's to default (DefaultAccountName).
|
|
158
|
+
*/
|
|
159
|
+
function displayNameOf(caller: CallerIdentity, profile: UserProfile): string {
|
|
160
|
+
if (caller.issuer === "") {
|
|
161
|
+
return caller.displayName ?? profile.email;
|
|
162
|
+
}
|
|
163
|
+
return profile.email;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The concurrent-create resolution every lane uses: on a failed create,
|
|
168
|
+
* read the winner back by the lane's natural key; a winner is the answer,
|
|
169
|
+
* whatever the failure's shape (the chain's ALREADY_EXISTS, a driver's
|
|
170
|
+
* DuplicateAccountError, a transport fault after the row landed). No
|
|
171
|
+
* winner means the failure was real — it propagates untouched.
|
|
172
|
+
*/
|
|
173
|
+
export async function resolveCreateRace(
|
|
174
|
+
error: unknown,
|
|
175
|
+
findWinner: () => Promise<IdentityAccount | undefined>,
|
|
176
|
+
): Promise<IdentityAccount> {
|
|
177
|
+
const winner = await findWinner();
|
|
178
|
+
if (winner === undefined) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return winner;
|
|
182
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subject → identity resolution (20260911.11; T01_0_plan.md Q-IA-2,
|
|
3
|
+
* T01_1_review.md A1 and A6): the ONE statement of "what identityId does
|
|
4
|
+
* this subject get stamped with", called by every verifier after its own
|
|
5
|
+
* credential checks pass — the OIDC lane with the token's `sub`, the
|
|
6
|
+
* API-key lane with the key's creator stamp.
|
|
7
|
+
*
|
|
8
|
+
* The rule is the cloud's direct-login posture (iam/direct/verifier.ts):
|
|
9
|
+
* one primary-key read of the DIRECT account for the subject; a hit
|
|
10
|
+
* stamps the account id, so the Authorizer, the audit actor and the
|
|
11
|
+
* tuple lifecycle see one principal shape for every provisioned user; a
|
|
12
|
+
* miss returns the subject unchanged, so an unprovisioned caller is
|
|
13
|
+
* admitted idp-shaped and exactly two RPCs mean anything to them —
|
|
14
|
+
* whoAmI (NOT_FOUND) and provisionMyAccount. No cache between the two
|
|
15
|
+
* (Q5's liveness posture): a row that appears is seen by the very next
|
|
16
|
+
* request, a row that is deleted stops resolving on the next.
|
|
17
|
+
*
|
|
18
|
+
* It lives in the domain, not in either verifier, because it is the
|
|
19
|
+
* domain's knowledge that a subject and an account are two names for one
|
|
20
|
+
* principal; a verifier only knows which string it was handed. Stated
|
|
21
|
+
* once, both lanes cannot drift, and a stamp that is already an account
|
|
22
|
+
* id needs no special case: no account carries an `ida_` as its subject,
|
|
23
|
+
* so the read is a miss and the stamp is kept.
|
|
24
|
+
*
|
|
25
|
+
* Faults: a store failure propagates as the error it is — the chassis
|
|
26
|
+
* maps a non-ConnectError to INTERNAL (pipeline/interceptors/auth.ts),
|
|
27
|
+
* so an outage never reads as a bad credential. A hit whose row carries
|
|
28
|
+
* no id is the one deliberate divergence from the cloud's inline
|
|
29
|
+
* `metadata?.id ?? ""`: a `""` principal is the silent-junk failure the
|
|
30
|
+
* oss#405 doctrine forbids, so it is a loud fault naming the subject.
|
|
31
|
+
*/
|
|
32
|
+
import type { IdentityAccountStore } from "./store.js";
|
|
33
|
+
|
|
34
|
+
/** The one read a verifier needs from the domain: the direct account for a subject. */
|
|
35
|
+
export type AccountsBySubject = Pick<IdentityAccountStore, "findDirectByIdpId">;
|
|
36
|
+
|
|
37
|
+
export async function identityIdForSubject(
|
|
38
|
+
accounts: AccountsBySubject,
|
|
39
|
+
subject: string,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const account = await accounts.findDirectByIdpId(subject);
|
|
42
|
+
if (account === undefined) {
|
|
43
|
+
return subject;
|
|
44
|
+
}
|
|
45
|
+
const id = account.metadata?.id ?? "";
|
|
46
|
+
if (id === "") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`identity account for subject '${subject}' carries no id — refusing to stamp an empty principal`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open-source IdentityAccountStore: the port (store.ts) over the
|
|
3
|
+
* generic Store, rows of the `resources` table by kind (20260911.11,
|
|
4
|
+
* T01_1_review.md A1). The composition root installs it when no
|
|
5
|
+
* extension registers `drivers.identityAccountStore`.
|
|
6
|
+
*
|
|
7
|
+
* Every subject lookup is a PRIMARY-KEY read of the derived id
|
|
8
|
+
* (constants.ts accountIdFor): open source has no legacy random ids, so
|
|
9
|
+
* "the account with subject S" IS "the row with id accountIdFor(S)". That
|
|
10
|
+
* is what makes the OIDC lane's per-request resolve, whoAmI and the
|
|
11
|
+
* provisioning idempotency check O(1) reads with no secondary index and
|
|
12
|
+
* no scan, and it is why `save` refuses a direct account whose id is not
|
|
13
|
+
* its derived id — a stray row would be unreachable by subject forever.
|
|
14
|
+
* `findDirectByEmail` is the one lookup that scans (Store.findByField, a
|
|
15
|
+
* decode-and-scan of the kind's rows, T01_1_review.md finding 2): an
|
|
16
|
+
* administrative RPC, never a per-request path.
|
|
17
|
+
*
|
|
18
|
+
* `save` and `update` are both read-then-write because the generic
|
|
19
|
+
* Store's saveResource is an upsert with neither a create-only nor an
|
|
20
|
+
* update-only form. `save` refuses when the read finds a row (the port:
|
|
21
|
+
* a held id is DuplicateAccountError, never a silent overwrite); `update`
|
|
22
|
+
* returns when the read finds none (the port, A12: replace, never
|
|
23
|
+
* create — a driver whose UPDATE matches no row writes nothing, and this
|
|
24
|
+
* adapter must not differ). The residual window between the read and
|
|
25
|
+
* the write admits a same-content overwrite of the first writer's audit
|
|
26
|
+
* stamp, or a row recreated under a concurrent delete, by milliseconds —
|
|
27
|
+
* the posture every chain on the platform has today (CheckDuplicate then
|
|
28
|
+
* upsert); conditional store writes are a platform-wide follow-up, not
|
|
29
|
+
* this adapter's to invent. The primary key still guarantees ONE row per
|
|
30
|
+
* subject, which is the invariant that matters.
|
|
31
|
+
*
|
|
32
|
+
* Store faults follow the ratified mapping (domain/apikey/lookup.ts): a
|
|
33
|
+
* typed ResourceNotFoundError reads as `undefined`; anything else
|
|
34
|
+
* propagates — an outage must never read as "no account".
|
|
35
|
+
*
|
|
36
|
+
* The port's contract is proven by store-contract.ts, run over this
|
|
37
|
+
* adapter on both drivers in __tests__/resource-store.test.ts, which
|
|
38
|
+
* also pins the two invariants above that are this adapter's own.
|
|
39
|
+
*/
|
|
40
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
41
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
42
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
43
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
44
|
+
|
|
45
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
46
|
+
import type { Store } from "../../store/interface.js";
|
|
47
|
+
import { accountIdFor } from "./constants.js";
|
|
48
|
+
import { DuplicateAccountError } from "./store.js";
|
|
49
|
+
import type { IdentityAccountStore } from "./store.js";
|
|
50
|
+
|
|
51
|
+
const KIND = ApiResourceKind.identity_account;
|
|
52
|
+
|
|
53
|
+
export function newResourceIdentityAccountStore(
|
|
54
|
+
store: Store,
|
|
55
|
+
): IdentityAccountStore {
|
|
56
|
+
async function readById(id: string): Promise<IdentityAccount | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
return await store.getResource(KIND, id, IdentityAccountSchema);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof ResourceNotFoundError) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The row for a subject — undefined for the empty subject, which no account carries. */
|
|
68
|
+
async function readBySubject(
|
|
69
|
+
idpId: string,
|
|
70
|
+
): Promise<IdentityAccount | undefined> {
|
|
71
|
+
if (idpId === "") {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return readById(accountIdFor(idpId));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async save(account): Promise<void> {
|
|
79
|
+
const id = account.metadata?.id ?? "";
|
|
80
|
+
const idpId = account.spec?.idpId ?? "";
|
|
81
|
+
if (isDirect(account) && id !== accountIdFor(idpId)) {
|
|
82
|
+
throw new Error("direct account id must be derived from its idp_id");
|
|
83
|
+
}
|
|
84
|
+
if ((await readById(id)) !== undefined) {
|
|
85
|
+
throw new DuplicateAccountError(
|
|
86
|
+
`identity account '${id}' already exists`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
await store.saveResource(KIND, id, IdentityAccountSchema, account);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async update(account): Promise<void> {
|
|
93
|
+
const id = account.metadata?.id ?? "";
|
|
94
|
+
if ((await readById(id)) === undefined) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await store.saveResource(KIND, id, IdentityAccountSchema, account);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async deleteById(id): Promise<void> {
|
|
101
|
+
await store.deleteResource(KIND, id);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
findById: readById,
|
|
105
|
+
|
|
106
|
+
findByIdpId: readBySubject,
|
|
107
|
+
|
|
108
|
+
async findDirectByIdpId(idpId): Promise<IdentityAccount | undefined> {
|
|
109
|
+
const account = await readBySubject(idpId);
|
|
110
|
+
return account !== undefined && isDirect(account) ? account : undefined;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async findDirectByEmail(email): Promise<IdentityAccount | undefined> {
|
|
114
|
+
let account: IdentityAccount;
|
|
115
|
+
try {
|
|
116
|
+
account = await store.findByField(
|
|
117
|
+
KIND,
|
|
118
|
+
"spec.email",
|
|
119
|
+
email,
|
|
120
|
+
IdentityAccountSchema,
|
|
121
|
+
);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof ResourceNotFoundError) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
return isDirect(account) ? account : undefined;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async findByIds(ids): Promise<ReadonlyArray<IdentityAccount>> {
|
|
132
|
+
const found: IdentityAccount[] = [];
|
|
133
|
+
// One row per distinct id, at its first position (the port, A12):
|
|
134
|
+
// a Set iterates in insertion order, so the dedupe keeps the order.
|
|
135
|
+
for (const id of new Set(ids)) {
|
|
136
|
+
const account = await readById(id);
|
|
137
|
+
if (account !== undefined) {
|
|
138
|
+
found.push(account);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return found;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Non-federated: the platform's own subject. A federated account carries
|
|
148
|
+
* its identity_provider_ref; open source never writes one (its create
|
|
149
|
+
* path assigns `direct`), so this is the port's contract stated, not a
|
|
150
|
+
* branch the OSS store expects to take.
|
|
151
|
+
*/
|
|
152
|
+
function isDirect(account: IdentityAccount): boolean {
|
|
153
|
+
return (
|
|
154
|
+
account.spec?.identityProviderRef === undefined &&
|
|
155
|
+
account.spec?.provisioningMode !== IdentityAccountProvisioningMode.federated
|
|
156
|
+
);
|
|
157
|
+
}
|