@opengeni/db 4.2.2 → 4.3.0-canary.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/canonical-human-identities.js +3 -3
- package/dist/{chunk-6GAPAYZR.js → chunk-7JPLGBSA.js} +562 -86
- package/dist/chunk-7JPLGBSA.js.map +1 -0
- package/dist/{chunk-SNLIKELL.js → chunk-IHADTE7C.js} +2 -2
- package/dist/{chunk-7WX3R3YL.js → chunk-ILKJKDII.js} +7 -2
- package/dist/chunk-ILKJKDII.js.map +1 -0
- package/dist/{chunk-MGVOYVPS.js → chunk-NCSPNCQK.js} +3 -3
- package/dist/{chunk-B2KR3TEM.js → chunk-SGJDIF2D.js} +2 -2
- package/dist/{chunk-S4Y42VSX.js → chunk-Y5XFVJJM.js} +207 -3
- package/dist/chunk-Y5XFVJJM.js.map +1 -0
- package/dist/{chunk-FPTI4NP4.js → chunk-YGBOVXC7.js} +2 -2
- package/dist/{chunk-6Y7ZDVJ5.js → chunk-YGRT6VZO.js} +2 -2
- package/dist/{chunk-6IQ4X35Y.js → chunk-YVYBAWBU.js} +46 -1
- package/dist/chunk-YVYBAWBU.js.map +1 -0
- package/dist/connect-attempts.d.ts +60 -0
- package/dist/connection-setup-authority.d.ts +10 -0
- package/dist/connection-token-resolver.d.ts +11 -2
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/external-identities.d.ts +16 -0
- package/dist/external-identity-links.d.ts +74 -0
- package/dist/external-link-work.d.ts +51 -0
- package/dist/host-mcp-binding-match.d.ts +5 -0
- package/dist/host-mcp-bindings.d.ts +73 -0
- package/dist/host-mcp-task-authority.d.ts +47 -0
- package/dist/host-selection-identity.d.ts +6 -0
- package/dist/index.d.ts +51 -35
- package/dist/index.js +15171 -13775
- package/dist/index.js.map +1 -1
- package/dist/live-session-attempt.d.ts +275 -0
- package/dist/managed-auth-session-sets.js +3 -3
- package/dist/memory-domain.d.ts +3 -3
- package/dist/provision-roles.js +1 -1
- package/dist/retained-provider-commands.js +2 -2
- package/dist/runtime-posture.d.ts +6 -6
- package/dist/scheduled-task-revision-authority.d.ts +11 -0
- package/dist/schema.d.ts +1279 -9
- package/dist/schema.js +11 -1
- package/dist/session-background-commands.js +4 -4
- package/dist/session-command-output.js +4 -4
- package/dist/session-event-slices.js +2 -2
- package/dist/session-queue-commands.d.ts +7 -0
- package/dist/session-tenancy.d.ts +2 -0
- package/dist/session-tenancy.js +3 -3
- package/dist/video-generation.js +3 -3
- package/dist/workspace-authority.d.ts +4 -4
- package/dist/workspace-tool-defaults.js +4 -4
- package/drizzle/0437_organization_scoped_external_workspaces.sql +71 -0
- package/drizzle/0438_durable_connect_attempts.sql +95 -0
- package/drizzle/0439_external_identity_provisioning.sql +128 -0
- package/drizzle/0440_external_workspace_member_removal.sql +59 -0
- package/drizzle/0441_external_identity_membership_lifecycle.sql +163 -0
- package/drizzle/0442_external_owning_user_authority.sql +81 -0
- package/drizzle/0443_host_mcp_binding_registry.sql +93 -0
- package/drizzle/0444_host_mcp_delegations.sql +129 -0
- package/drizzle/0445_host_mcp_turn_authorities.sql +145 -0
- package/drizzle/0446_host_mcp_causal_continuation.sql +126 -0
- package/drizzle/0447_host_mcp_task_authorities.sql +264 -0
- package/drizzle/0448_host_mcp_child_authority.sql +115 -0
- package/drizzle/0449_external_identity_link_lifecycle.sql +107 -0
- package/drizzle/0450_external_identity_link_work.sql +179 -0
- package/drizzle/0451_external_link_preview_and_permission_ceiling.sql +82 -0
- package/drizzle/0452_external_link_scheduled_origin.sql +34 -0
- package/drizzle/0453_host_mcp_native_owner.sql +35 -0
- package/drizzle/0454_connect_origin_authority.sql +42 -0
- package/drizzle/0455_external_link_inventory_labels.sql +44 -0
- package/drizzle/0456_social_connection_versions.sql +14 -0
- package/drizzle/0457_canonical_session_scope_subject.sql +46 -0
- package/drizzle/0458_skill_review_wire_compatibility.sql +36 -0
- package/package.json +6 -6
- package/src/connect-attempts.ts +317 -0
- package/src/connection-authority.ts +9 -0
- package/src/connection-setup-authority.ts +35 -0
- package/src/connection-token-resolver.ts +177 -48
- package/src/external-identities.ts +70 -0
- package/src/external-identity-links.ts +364 -0
- package/src/external-link-work.ts +228 -0
- package/src/host-mcp-binding-match.ts +33 -0
- package/src/host-mcp-bindings.ts +741 -0
- package/src/host-mcp-task-authority.ts +383 -0
- package/src/host-selection-identity.ts +29 -0
- package/src/index.ts +398 -147
- package/src/live-session-attempt.ts +85 -0
- package/src/managed-human-provisioning.ts +6 -1
- package/src/memory-domain.ts +6 -11
- package/src/provision-roles.ts +3 -0
- package/src/runtime-posture.ts +47 -0
- package/src/scheduled-task-revision-authority.ts +30 -0
- package/src/schema.ts +228 -3
- package/src/session-control.ts +14 -0
- package/src/session-queue-commands.ts +11 -0
- package/src/session-tenancy.ts +7 -0
- package/src/workspace-authority.ts +5 -5
- package/dist/chunk-6GAPAYZR.js.map +0 -1
- package/dist/chunk-6IQ4X35Y.js.map +0 -1
- package/dist/chunk-7WX3R3YL.js.map +0 -1
- package/dist/chunk-S4Y42VSX.js.map +0 -1
- /package/dist/{chunk-SNLIKELL.js.map → chunk-IHADTE7C.js.map} +0 -0
- /package/dist/{chunk-MGVOYVPS.js.map → chunk-NCSPNCQK.js.map} +0 -0
- /package/dist/{chunk-B2KR3TEM.js.map → chunk-SGJDIF2D.js.map} +0 -0
- /package/dist/{chunk-FPTI4NP4.js.map → chunk-YGBOVXC7.js.map} +0 -0
- /package/dist/{chunk-6Y7ZDVJ5.js.map → chunk-YGRT6VZO.js.map} +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { Permission } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
ExternalIdentity,
|
|
5
|
+
ExternalIdentityReference,
|
|
6
|
+
} from "@opengeni/contracts/external-identities";
|
|
7
|
+
import { rawRows, withAccountRls, type Database } from "./database";
|
|
8
|
+
|
|
9
|
+
/** Pass an open transaction to retain the key lock through the protected write. */
|
|
10
|
+
export async function lockActiveExternalOrganizationKey(
|
|
11
|
+
tx: Database,
|
|
12
|
+
accountId: string,
|
|
13
|
+
keyId: string,
|
|
14
|
+
): Promise<Permission[] | null> {
|
|
15
|
+
return withAccountRls(tx, accountId, async (scoped) => {
|
|
16
|
+
const [row] = await rawRows<{ permissions: unknown }>(
|
|
17
|
+
scoped,
|
|
18
|
+
sql`select permissions from api_keys
|
|
19
|
+
where account_id = ${accountId}::uuid and id = ${keyId}::uuid
|
|
20
|
+
and workspace_id is null and credential_kind = 'organization'
|
|
21
|
+
and revoked_at is null and (expires_at is null or expires_at > clock_timestamp()) for share`,
|
|
22
|
+
);
|
|
23
|
+
return row ? Permission.array().parse(row.permissions) : null;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Transaction-local fence shared with organization member lifecycle writers.
|
|
28
|
+
* Caller must pass an open transaction, not a pool-level database handle. */
|
|
29
|
+
export async function lockExternalWorkspaceMembershipLifecycle(
|
|
30
|
+
tx: Database,
|
|
31
|
+
accountId: string,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
await tx.execute(
|
|
34
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`organization-membership:${accountId}`}, 0))`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Internal lifecycle seam. Caller must authenticate an organization key and
|
|
39
|
+
* establish its exact account before invoking; this does not admit HTTP users.
|
|
40
|
+
* It never creates shared-workspace membership or reactivates removed users. */
|
|
41
|
+
export async function ensureExternalIdentity(
|
|
42
|
+
db: Database,
|
|
43
|
+
input: {
|
|
44
|
+
accountId: string;
|
|
45
|
+
externalId: string;
|
|
46
|
+
source?: string;
|
|
47
|
+
},
|
|
48
|
+
): Promise<ExternalIdentity> {
|
|
49
|
+
const accountId = input.accountId;
|
|
50
|
+
const reference = ExternalIdentityReference.parse({
|
|
51
|
+
externalId: input.externalId,
|
|
52
|
+
source: input.source,
|
|
53
|
+
});
|
|
54
|
+
return withAccountRls(db, accountId, async (tx) => {
|
|
55
|
+
const [row] = await rawRows<{ result: unknown }>(
|
|
56
|
+
tx,
|
|
57
|
+
sql`select ensure_external_identity(
|
|
58
|
+
${accountId}::uuid, ${reference.source}, ${reference.externalId}) as result`,
|
|
59
|
+
);
|
|
60
|
+
const result = ExternalIdentity.parse(row?.result);
|
|
61
|
+
if (
|
|
62
|
+
result.accountId !== accountId ||
|
|
63
|
+
result.source !== reference.source ||
|
|
64
|
+
result.externalId !== reference.externalId
|
|
65
|
+
) {
|
|
66
|
+
throw new Error("External identity scope mismatch");
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
BeginExternalIdentityLinkRequest,
|
|
5
|
+
BeginExternalIdentityLinkResponse,
|
|
6
|
+
ConfirmExternalIdentityLinkRequest,
|
|
7
|
+
ExternalIdentityLink,
|
|
8
|
+
ExternalIdentityLinkPage,
|
|
9
|
+
ExternalIdentityReference,
|
|
10
|
+
type ExternalIdentity,
|
|
11
|
+
} from "@opengeni/contracts/external-identities";
|
|
12
|
+
import { rawRows, withAccountRls, setSubjectRlsContext, type Database } from "./database";
|
|
13
|
+
import { lockExternalWorkspaceMembershipLifecycle } from "./external-identities";
|
|
14
|
+
|
|
15
|
+
type LinkRow = {
|
|
16
|
+
id: string;
|
|
17
|
+
account_id: string;
|
|
18
|
+
external_identity_id: string;
|
|
19
|
+
external_subject_id: string;
|
|
20
|
+
external_authorization_revision: number | string;
|
|
21
|
+
native_subject_id: string | null;
|
|
22
|
+
native_membership_id: string | null;
|
|
23
|
+
native_authorization_revision: number | string | null;
|
|
24
|
+
status: "pending" | "active" | "revoked";
|
|
25
|
+
revision: number | string;
|
|
26
|
+
permissions: string[];
|
|
27
|
+
challenge_digest: string;
|
|
28
|
+
confirm_before: Date | string;
|
|
29
|
+
expires_at: Date | string | null;
|
|
30
|
+
};
|
|
31
|
+
export class ExternalIdentityLinkConflictError extends Error {
|
|
32
|
+
readonly name = "ExternalIdentityLinkConflictError";
|
|
33
|
+
constructor() {
|
|
34
|
+
super("Identity link changed or is unavailable");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
38
|
+
const iso = (value: Date | string) => new Date(value).toISOString();
|
|
39
|
+
function project(row: LinkRow): ExternalIdentityLink {
|
|
40
|
+
const expiry = row.status === "pending" ? row.confirm_before : row.expires_at;
|
|
41
|
+
return ExternalIdentityLink.parse({
|
|
42
|
+
id: row.id,
|
|
43
|
+
accountId: row.account_id,
|
|
44
|
+
externalIdentityId: row.external_identity_id,
|
|
45
|
+
nativeSubjectId: row.native_subject_id,
|
|
46
|
+
status:
|
|
47
|
+
row.status !== "revoked" && expiry !== null && new Date(expiry).getTime() <= Date.now()
|
|
48
|
+
? "expired"
|
|
49
|
+
: row.status,
|
|
50
|
+
revision: Number(row.revision),
|
|
51
|
+
permissions: row.permissions,
|
|
52
|
+
expiresAt: row.expires_at === null ? null : iso(row.expires_at),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function activeMembership(tx: Database, accountId: string, subjectId: string) {
|
|
56
|
+
const [prior] = await rawRows<{ subject: string }>(
|
|
57
|
+
tx,
|
|
58
|
+
sql`select coalesce(current_setting('opengeni.subject_id', true), '') as subject`,
|
|
59
|
+
);
|
|
60
|
+
// These subjects come from verified admission or the immutable stored link,
|
|
61
|
+
// not caller-selected impersonation. Restore the GUC after each named probe.
|
|
62
|
+
let succeeded = false;
|
|
63
|
+
let row:
|
|
64
|
+
| { membership: { id: string; authorizationRevision: number; personalWorkspaceId: string } }
|
|
65
|
+
| undefined;
|
|
66
|
+
try {
|
|
67
|
+
await setSubjectRlsContext(tx, subjectId);
|
|
68
|
+
[row] = await rawRows<{
|
|
69
|
+
membership: { id: string; authorizationRevision: number; personalWorkspaceId: string };
|
|
70
|
+
}>(
|
|
71
|
+
tx,
|
|
72
|
+
sql`select value as membership from jsonb_array_elements(list_self_organization_memberships(${subjectId}))
|
|
73
|
+
where value ->> 'organizationId' = ${accountId} and value ->> 'status' = 'active'`,
|
|
74
|
+
);
|
|
75
|
+
succeeded = true;
|
|
76
|
+
} finally {
|
|
77
|
+
const restore = tx.execute(
|
|
78
|
+
sql`select set_config('opengeni.subject_id', ${prior?.subject ?? ""}, true)`,
|
|
79
|
+
);
|
|
80
|
+
if (succeeded) await restore;
|
|
81
|
+
else await restore.catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
if (!row) throw new ExternalIdentityLinkConflictError();
|
|
84
|
+
return row.membership;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Internal persistence seam. The API must prove a host external actor and
|
|
88
|
+
* freeze its key permission ceiling before calling. No login is provisioned. */
|
|
89
|
+
export async function beginExternalIdentityLink(
|
|
90
|
+
db: Database,
|
|
91
|
+
identity: ExternalIdentity,
|
|
92
|
+
raw: unknown,
|
|
93
|
+
) {
|
|
94
|
+
const input = BeginExternalIdentityLinkRequest.parse(raw);
|
|
95
|
+
return withAccountRls(db, identity.accountId, async (tx) => {
|
|
96
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, identity.accountId);
|
|
97
|
+
const membership = await activeMembership(tx, identity.accountId, identity.subjectId);
|
|
98
|
+
if (
|
|
99
|
+
membership.id !== identity.organizationMembershipId ||
|
|
100
|
+
Number(membership.authorizationRevision) !== identity.authorizationRevision ||
|
|
101
|
+
identity.status !== "active"
|
|
102
|
+
)
|
|
103
|
+
throw new ExternalIdentityLinkConflictError();
|
|
104
|
+
const [quota] = await rawRows<{ count: number | string }>(
|
|
105
|
+
tx,
|
|
106
|
+
sql`select count(*) as count
|
|
107
|
+
from external_identity_links where account_id = ${identity.accountId}::uuid
|
|
108
|
+
and external_identity_id = ${identity.id}::uuid and status = 'pending'
|
|
109
|
+
and confirm_before > clock_timestamp()`,
|
|
110
|
+
);
|
|
111
|
+
if (Number(quota?.count ?? 0) >= 32) throw new ExternalIdentityLinkConflictError();
|
|
112
|
+
const challenge = randomBytes(32).toString("base64url");
|
|
113
|
+
const permissions = [...new Set(input.permissions)].sort();
|
|
114
|
+
const [row] = await rawRows<LinkRow>(
|
|
115
|
+
tx,
|
|
116
|
+
sql`insert into external_identity_links
|
|
117
|
+
(id, account_id, external_identity_id, external_subject_id, external_authorization_revision,
|
|
118
|
+
permissions, challenge_digest, confirm_before, expires_at)
|
|
119
|
+
values (${randomUUID()}::uuid, ${identity.accountId}::uuid, ${identity.id}::uuid,
|
|
120
|
+
${identity.subjectId}, ${identity.authorizationRevision}, ${JSON.stringify(permissions)}::jsonb,
|
|
121
|
+
${digest(challenge)}, clock_timestamp() + interval '10 minutes', ${input.expiresAt}::timestamptz)
|
|
122
|
+
returning *`,
|
|
123
|
+
);
|
|
124
|
+
if (!row) throw new Error("Identity link insert returned no row");
|
|
125
|
+
return BeginExternalIdentityLinkResponse.parse({
|
|
126
|
+
link: project(row),
|
|
127
|
+
challenge,
|
|
128
|
+
confirmBefore: iso(row.confirm_before),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Caller proves either exact host identity or native login, never a caller's
|
|
134
|
+
* claimed subject. The challenge is intentionally absent from every read. */
|
|
135
|
+
export async function getExternalIdentityLink(
|
|
136
|
+
db: Database,
|
|
137
|
+
input: {
|
|
138
|
+
accountId: string;
|
|
139
|
+
linkId: string;
|
|
140
|
+
subjectId: string;
|
|
141
|
+
},
|
|
142
|
+
): Promise<ExternalIdentityLink | null> {
|
|
143
|
+
return withAccountRls(db, input.accountId, async (tx) => {
|
|
144
|
+
const [row] = await rawRows<LinkRow>(
|
|
145
|
+
tx,
|
|
146
|
+
sql`select * from external_identity_links
|
|
147
|
+
where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
|
|
148
|
+
and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId})`,
|
|
149
|
+
);
|
|
150
|
+
return row ? project(row) : null;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Bounded inventory of the verified participant's links. Neither account-wide
|
|
155
|
+
* access nor a supplied cursor reveals another participant's pending requests. */
|
|
156
|
+
export async function listExternalIdentityLinks(
|
|
157
|
+
db: Database,
|
|
158
|
+
input: { accountId: string; subjectId: string; cursor?: string },
|
|
159
|
+
): Promise<ExternalIdentityLinkPage> {
|
|
160
|
+
return withAccountRls(db, input.accountId, async (tx) => {
|
|
161
|
+
const rows = await rawRows<LinkRow>(
|
|
162
|
+
tx,
|
|
163
|
+
sql`
|
|
164
|
+
select * from external_identity_links
|
|
165
|
+
where account_id = ${input.accountId}::uuid
|
|
166
|
+
and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId})
|
|
167
|
+
${input.cursor ? sql`and id > ${input.cursor}::uuid` : sql``}
|
|
168
|
+
order by id limit 51`,
|
|
169
|
+
);
|
|
170
|
+
const links = rows.slice(0, 50).map(project);
|
|
171
|
+
await setSubjectRlsContext(tx, input.subjectId);
|
|
172
|
+
const [labels] = links.length
|
|
173
|
+
? await rawRows<{ references: Record<string, unknown> }>(
|
|
174
|
+
tx,
|
|
175
|
+
sql`select get_external_identity_link_inventory_references(${input.accountId}::uuid, ARRAY[${sql.join(
|
|
176
|
+
links.map((link) => sql`${link.id}::uuid`),
|
|
177
|
+
sql`, `,
|
|
178
|
+
)}]) as references`,
|
|
179
|
+
)
|
|
180
|
+
: [];
|
|
181
|
+
return ExternalIdentityLinkPage.parse({
|
|
182
|
+
links: links.map((link) => ({
|
|
183
|
+
...link,
|
|
184
|
+
...(labels?.references[link.id]
|
|
185
|
+
? { externalIdentity: ExternalIdentityReference.parse(labels.references[link.id]) }
|
|
186
|
+
: {}),
|
|
187
|
+
})),
|
|
188
|
+
nextCursor: rows.length > 50 ? links.at(-1)!.id : null,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Consent-screen projection, only after canonical native login admission.
|
|
194
|
+
* The short-lived challenge proves which host request the human is approving. */
|
|
195
|
+
export async function previewExternalIdentityLink(
|
|
196
|
+
db: Database,
|
|
197
|
+
input: {
|
|
198
|
+
accountId: string;
|
|
199
|
+
linkId: string;
|
|
200
|
+
challenge: string;
|
|
201
|
+
},
|
|
202
|
+
): Promise<{ link: ExternalIdentityLink; externalIdentity: ExternalIdentityReference } | null> {
|
|
203
|
+
return withAccountRls(db, input.accountId, async (tx) => {
|
|
204
|
+
const [row] = await rawRows<LinkRow>(
|
|
205
|
+
tx,
|
|
206
|
+
sql`select * from external_identity_links
|
|
207
|
+
where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
|
|
208
|
+
and challenge_digest = ${digest(input.challenge)} and status = 'pending'
|
|
209
|
+
and confirm_before > clock_timestamp()
|
|
210
|
+
and (expires_at is null or expires_at > clock_timestamp())`,
|
|
211
|
+
);
|
|
212
|
+
if (!row) return null;
|
|
213
|
+
const [identity] = await rawRows<{ reference: unknown }>(
|
|
214
|
+
tx,
|
|
215
|
+
sql`select get_external_identity_link_reference(${input.accountId}::uuid, ${input.linkId}::uuid, ${digest(input.challenge)}) as reference`,
|
|
216
|
+
);
|
|
217
|
+
return identity?.reference
|
|
218
|
+
? {
|
|
219
|
+
link: project(row),
|
|
220
|
+
externalIdentity: ExternalIdentityReference.parse(identity.reference),
|
|
221
|
+
}
|
|
222
|
+
: null;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Must only receive the canonical native-session subject after explicit UI
|
|
227
|
+
* consent. Possession of the challenge alone is not native authentication. */
|
|
228
|
+
export async function confirmExternalIdentityLink(
|
|
229
|
+
db: Database,
|
|
230
|
+
input: {
|
|
231
|
+
accountId: string;
|
|
232
|
+
linkId: string;
|
|
233
|
+
nativeSubjectId: string;
|
|
234
|
+
request: ConfirmExternalIdentityLinkRequest;
|
|
235
|
+
},
|
|
236
|
+
): Promise<ExternalIdentityLink> {
|
|
237
|
+
const request = ConfirmExternalIdentityLinkRequest.parse(input.request);
|
|
238
|
+
if (!/^user:[^\r\n]+$/.test(input.nativeSubjectId)) throw new ExternalIdentityLinkConflictError();
|
|
239
|
+
return withAccountRls(db, input.accountId, async (tx) => {
|
|
240
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, input.accountId);
|
|
241
|
+
const [row] = await rawRows<LinkRow>(
|
|
242
|
+
tx,
|
|
243
|
+
sql`select * from external_identity_links
|
|
244
|
+
where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
|
|
245
|
+
and challenge_digest = ${digest(request.challenge)} for update`,
|
|
246
|
+
);
|
|
247
|
+
if (!row) throw new ExternalIdentityLinkConflictError();
|
|
248
|
+
const external = await activeMembership(tx, input.accountId, row.external_subject_id);
|
|
249
|
+
const native = await activeMembership(tx, input.accountId, input.nativeSubjectId);
|
|
250
|
+
if (Number(external.authorizationRevision) !== Number(row.external_authorization_revision))
|
|
251
|
+
throw new ExternalIdentityLinkConflictError();
|
|
252
|
+
const permissions = [...new Set(request.permissions)].sort();
|
|
253
|
+
if (
|
|
254
|
+
row.status === "active" &&
|
|
255
|
+
Number(row.revision) === request.expectedRevision + 1 &&
|
|
256
|
+
row.native_subject_id === input.nativeSubjectId &&
|
|
257
|
+
row.native_membership_id === native.id &&
|
|
258
|
+
Number(row.native_authorization_revision) === Number(native.authorizationRevision) &&
|
|
259
|
+
JSON.stringify(row.permissions) === JSON.stringify(permissions) &&
|
|
260
|
+
project(row).status === "active"
|
|
261
|
+
)
|
|
262
|
+
return project(row);
|
|
263
|
+
if (
|
|
264
|
+
row.status !== "pending" ||
|
|
265
|
+
Number(row.revision) !== request.expectedRevision ||
|
|
266
|
+
project(row).status !== "pending" ||
|
|
267
|
+
permissions.some((permission) => !row.permissions.includes(permission))
|
|
268
|
+
)
|
|
269
|
+
throw new ExternalIdentityLinkConflictError();
|
|
270
|
+
const [confirmed] = await rawRows<LinkRow>(
|
|
271
|
+
tx,
|
|
272
|
+
sql`update external_identity_links set
|
|
273
|
+
status = 'active', revision = revision + 1, native_subject_id = ${input.nativeSubjectId},
|
|
274
|
+
native_membership_id = ${native.id}::uuid, native_authorization_revision = ${Number(native.authorizationRevision)},
|
|
275
|
+
permissions = ${JSON.stringify(permissions)}::jsonb, confirmed_at = clock_timestamp()
|
|
276
|
+
where id = ${row.id}::uuid returning *`,
|
|
277
|
+
);
|
|
278
|
+
if (!confirmed) throw new ExternalIdentityLinkConflictError();
|
|
279
|
+
return project(confirmed);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function revokeExternalIdentityLink(
|
|
284
|
+
db: Database,
|
|
285
|
+
input: {
|
|
286
|
+
accountId: string;
|
|
287
|
+
linkId: string;
|
|
288
|
+
subjectId: string;
|
|
289
|
+
expectedRevision: number;
|
|
290
|
+
},
|
|
291
|
+
): Promise<ExternalIdentityLink> {
|
|
292
|
+
return withAccountRls(db, input.accountId, async (tx) => {
|
|
293
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, input.accountId);
|
|
294
|
+
const [row] = await rawRows<LinkRow>(
|
|
295
|
+
tx,
|
|
296
|
+
sql`select * from external_identity_links
|
|
297
|
+
where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
|
|
298
|
+
and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId}) for update`,
|
|
299
|
+
);
|
|
300
|
+
if (!row) throw new ExternalIdentityLinkConflictError();
|
|
301
|
+
if (row.status === "revoked" && Number(row.revision) === input.expectedRevision + 1)
|
|
302
|
+
return project(row);
|
|
303
|
+
if (row.status === "revoked" || Number(row.revision) !== input.expectedRevision)
|
|
304
|
+
throw new ExternalIdentityLinkConflictError();
|
|
305
|
+
const [revoked] = await rawRows<LinkRow>(
|
|
306
|
+
tx,
|
|
307
|
+
sql`update external_identity_links
|
|
308
|
+
set status = 'revoked', revision = revision + 1, revoked_at = clock_timestamp()
|
|
309
|
+
where id = ${row.id}::uuid returning *`,
|
|
310
|
+
);
|
|
311
|
+
if (!revoked) throw new ExternalIdentityLinkConflictError();
|
|
312
|
+
return project(revoked);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Live linked admission primitive. It retains membership/link locks when
|
|
317
|
+
* called in an open transaction. Never uses old creator audit as authority. */
|
|
318
|
+
export async function resolveExternalIdentityLink(
|
|
319
|
+
db: Database,
|
|
320
|
+
input: {
|
|
321
|
+
identity: ExternalIdentity;
|
|
322
|
+
linkId: string;
|
|
323
|
+
expectedRevision: number;
|
|
324
|
+
},
|
|
325
|
+
): Promise<{ link: ExternalIdentityLink; personalWorkspaceId: string } | null> {
|
|
326
|
+
return withAccountRls(db, input.identity.accountId, async (tx) => {
|
|
327
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, input.identity.accountId);
|
|
328
|
+
const [row] = await rawRows<LinkRow>(
|
|
329
|
+
tx,
|
|
330
|
+
sql`select * from external_identity_links
|
|
331
|
+
where id = ${input.linkId}::uuid and account_id = ${input.identity.accountId}::uuid
|
|
332
|
+
and external_identity_id = ${input.identity.id}::uuid for share`,
|
|
333
|
+
);
|
|
334
|
+
if (
|
|
335
|
+
!row ||
|
|
336
|
+
row.status !== "active" ||
|
|
337
|
+
!row.native_subject_id ||
|
|
338
|
+
Number(row.revision) !== input.expectedRevision ||
|
|
339
|
+
Number(row.external_authorization_revision) !== input.identity.authorizationRevision ||
|
|
340
|
+
input.identity.status !== "active" ||
|
|
341
|
+
project(row).status !== "active"
|
|
342
|
+
)
|
|
343
|
+
return null;
|
|
344
|
+
try {
|
|
345
|
+
const external = await activeMembership(
|
|
346
|
+
tx,
|
|
347
|
+
input.identity.accountId,
|
|
348
|
+
input.identity.subjectId,
|
|
349
|
+
);
|
|
350
|
+
const native = await activeMembership(tx, input.identity.accountId, row.native_subject_id);
|
|
351
|
+
if (
|
|
352
|
+
external.id !== input.identity.organizationMembershipId ||
|
|
353
|
+
Number(external.authorizationRevision) !== input.identity.authorizationRevision ||
|
|
354
|
+
native.id !== row.native_membership_id ||
|
|
355
|
+
Number(native.authorizationRevision) !== Number(row.native_authorization_revision)
|
|
356
|
+
)
|
|
357
|
+
return null;
|
|
358
|
+
return { link: project(row), personalWorkspaceId: native.personalWorkspaceId };
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error instanceof ExternalIdentityLinkConflictError) return null;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { stableJson, type ScheduledTask } from "@opengeni/contracts";
|
|
3
|
+
import { ExternalLinkWorkSnapshot } from "@opengeni/contracts/external-identities";
|
|
4
|
+
import { rawRows, withWorkspaceRls, withWorkspaceSubjectRls, type Database } from "./database";
|
|
5
|
+
import { ensureExternalIdentity } from "./external-identities";
|
|
6
|
+
import { resolveExternalIdentityLink } from "./external-identity-links";
|
|
7
|
+
import { nestedPostgresSqlState } from "./persistence-errors";
|
|
8
|
+
|
|
9
|
+
type Scope = { accountId: string; workspaceId: string };
|
|
10
|
+
type Source =
|
|
11
|
+
| { kind: "direct" }
|
|
12
|
+
| { kind: "causal" | "child"; turnId: string }
|
|
13
|
+
| { kind: "scheduled"; taskId: string; taskRevision: number };
|
|
14
|
+
|
|
15
|
+
/** Does not consult or resurrect the key used at creation. Its immutable
|
|
16
|
+
* permission ceiling is already captured; membership/link revocation is live. */
|
|
17
|
+
export async function externalLinkWorkSnapshotIsLive(db: Database, raw: unknown): Promise<boolean> {
|
|
18
|
+
const snapshot = ExternalLinkWorkSnapshot.parse(raw);
|
|
19
|
+
try {
|
|
20
|
+
const identity = await ensureExternalIdentity(db, {
|
|
21
|
+
accountId: snapshot.actor.accountId,
|
|
22
|
+
...snapshot.identity,
|
|
23
|
+
});
|
|
24
|
+
if (
|
|
25
|
+
identity.id !== snapshot.actor.externalIdentityId ||
|
|
26
|
+
identity.subjectId !== snapshot.actor.externalSubjectId ||
|
|
27
|
+
identity.authorizationRevision !== snapshot.actor.externalAuthorizationRevision
|
|
28
|
+
)
|
|
29
|
+
return false;
|
|
30
|
+
const resolved = await resolveExternalIdentityLink(db, {
|
|
31
|
+
identity,
|
|
32
|
+
linkId: snapshot.actor.linkId!,
|
|
33
|
+
expectedRevision: snapshot.actor.linkRevision!,
|
|
34
|
+
});
|
|
35
|
+
return (
|
|
36
|
+
resolved !== null &&
|
|
37
|
+
resolved.link.nativeSubjectId === snapshot.actor.effectiveSubjectId &&
|
|
38
|
+
snapshot.permissions.every(
|
|
39
|
+
(permission) =>
|
|
40
|
+
resolved.link.permissions.includes(permission) ||
|
|
41
|
+
(permission !== "secrets:read" && resolved.link.permissions.includes("workspace:admin")),
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (nestedPostgresSqlState(error) === "42501") return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getExternalLinkTurnSnapshot(
|
|
51
|
+
db: Database,
|
|
52
|
+
scope: Scope,
|
|
53
|
+
turnId: string,
|
|
54
|
+
): Promise<ExternalLinkWorkSnapshot | null> {
|
|
55
|
+
return withWorkspaceRls(db, scope.workspaceId, async (tx) => {
|
|
56
|
+
const [row] = await rawRows<{ canonical_snapshot: unknown }>(
|
|
57
|
+
tx,
|
|
58
|
+
sql`select canonical_snapshot
|
|
59
|
+
from external_link_turn_authorities where account_id = ${scope.accountId}::uuid
|
|
60
|
+
and workspace_id = ${scope.workspaceId}::uuid and turn_id = ${turnId}::uuid`,
|
|
61
|
+
);
|
|
62
|
+
return row ? ExternalLinkWorkSnapshot.parse(row.canonical_snapshot) : null;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** null is an ordinary native/external turn; a revoked linked turn is false,
|
|
67
|
+
* never null. Callers must not fall back to unlinked authority after denial. */
|
|
68
|
+
export async function getExternalLinkTurnAuthorization(db: Database, scope: Scope, turnId: string) {
|
|
69
|
+
const snapshot = await getExternalLinkTurnSnapshot(db, scope, turnId);
|
|
70
|
+
if (!snapshot) return null;
|
|
71
|
+
return {
|
|
72
|
+
authorized: await externalLinkWorkSnapshotIsLive(db, snapshot),
|
|
73
|
+
permissions: snapshot.permissions,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function captureExternalLinkTurnAuthority(
|
|
78
|
+
db: Database,
|
|
79
|
+
input: Scope & {
|
|
80
|
+
sessionId: string;
|
|
81
|
+
turnId: string;
|
|
82
|
+
snapshot: ExternalLinkWorkSnapshot;
|
|
83
|
+
source?: Source;
|
|
84
|
+
},
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const snapshot = ExternalLinkWorkSnapshot.parse(input.snapshot);
|
|
87
|
+
const source = input.source ?? { kind: "direct" };
|
|
88
|
+
if (snapshot.actor.accountId !== input.accountId)
|
|
89
|
+
throw new Error("Linked turn organization mismatch");
|
|
90
|
+
if (source.kind === "direct" && !(await externalLinkWorkSnapshotIsLive(db, snapshot)))
|
|
91
|
+
throw new Error("Linked turn authority revoked");
|
|
92
|
+
await withWorkspaceSubjectRls(
|
|
93
|
+
db,
|
|
94
|
+
input.workspaceId,
|
|
95
|
+
snapshot.actor.effectiveSubjectId,
|
|
96
|
+
async (tx) => {
|
|
97
|
+
const existing = await getExternalLinkTurnSnapshot(tx, input, input.turnId);
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (stableJson(existing) !== stableJson(snapshot))
|
|
100
|
+
throw new Error("Linked turn replay changed authority");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await tx.execute(sql`insert into external_link_turn_authorities
|
|
104
|
+
(turn_id, account_id, workspace_id, session_id, link_id, link_revision, canonical_snapshot,
|
|
105
|
+
source_kind, source_turn_id, source_task_id, source_task_revision)
|
|
106
|
+
values (${input.turnId}::uuid, ${input.accountId}::uuid, ${input.workspaceId}::uuid, ${input.sessionId}::uuid,
|
|
107
|
+
${snapshot.actor.linkId}::uuid, ${snapshot.actor.linkRevision}, ${JSON.stringify(snapshot)}::jsonb, ${source.kind},
|
|
108
|
+
${source.kind === "causal" || source.kind === "child" ? source.turnId : null}::uuid,
|
|
109
|
+
${source.kind === "scheduled" ? source.taskId : null}::uuid,
|
|
110
|
+
${source.kind === "scheduled" ? source.taskRevision : null})`);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function inheritExternalLinkTurnAuthority(
|
|
116
|
+
db: Database,
|
|
117
|
+
input: Scope & {
|
|
118
|
+
sessionId: string;
|
|
119
|
+
turnId: string;
|
|
120
|
+
sourceTurnId: string;
|
|
121
|
+
kind: "causal" | "child";
|
|
122
|
+
},
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const snapshot = await getExternalLinkTurnSnapshot(db, input, input.sourceTurnId);
|
|
125
|
+
if (!snapshot) return;
|
|
126
|
+
// Copy even after revocation. The worker denies the copied lane; dropping it
|
|
127
|
+
// would let an old linked continuation execute as an unrestricted native user.
|
|
128
|
+
await captureExternalLinkTurnAuthority(db, {
|
|
129
|
+
...input,
|
|
130
|
+
snapshot,
|
|
131
|
+
source: { kind: input.kind, turnId: input.sourceTurnId },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getExternalLinkTaskSnapshot(
|
|
136
|
+
db: Database,
|
|
137
|
+
input: Scope & {
|
|
138
|
+
taskId: string;
|
|
139
|
+
taskRevision: number;
|
|
140
|
+
},
|
|
141
|
+
): Promise<ExternalLinkWorkSnapshot | null> {
|
|
142
|
+
return withWorkspaceRls(db, input.workspaceId, async (tx) => {
|
|
143
|
+
const [row] = await rawRows<{ canonical_snapshot: unknown }>(
|
|
144
|
+
tx,
|
|
145
|
+
sql`select canonical_snapshot
|
|
146
|
+
from external_link_task_authorities where account_id = ${input.accountId}::uuid
|
|
147
|
+
and workspace_id = ${input.workspaceId}::uuid and task_id = ${input.taskId}::uuid and task_revision = ${input.taskRevision}`,
|
|
148
|
+
);
|
|
149
|
+
return row ? ExternalLinkWorkSnapshot.parse(row.canonical_snapshot) : null;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function captureExternalLinkTaskAuthority(
|
|
154
|
+
db: Database,
|
|
155
|
+
task: ScheduledTask,
|
|
156
|
+
raw: ExternalLinkWorkSnapshot,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const snapshot = ExternalLinkWorkSnapshot.parse(raw);
|
|
159
|
+
if (task.accountId !== snapshot.actor.accountId)
|
|
160
|
+
throw new Error("Linked task organization mismatch");
|
|
161
|
+
await withWorkspaceSubjectRls(
|
|
162
|
+
db,
|
|
163
|
+
task.workspaceId,
|
|
164
|
+
snapshot.actor.effectiveSubjectId,
|
|
165
|
+
async (tx) => {
|
|
166
|
+
const existing = await getExternalLinkTaskSnapshot(tx, {
|
|
167
|
+
...task,
|
|
168
|
+
taskId: task.id,
|
|
169
|
+
taskRevision: task.authorityRevision,
|
|
170
|
+
});
|
|
171
|
+
if (existing) {
|
|
172
|
+
if (stableJson(existing) !== stableJson(snapshot))
|
|
173
|
+
throw new Error("Linked task replay changed authority");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await tx.execute(sql`insert into external_link_task_authorities
|
|
177
|
+
(task_id, task_revision, account_id, workspace_id, link_id, link_revision, canonical_snapshot)
|
|
178
|
+
values (${task.id}::uuid, ${task.authorityRevision}, ${task.accountId}::uuid, ${task.workspaceId}::uuid,
|
|
179
|
+
${snapshot.actor.linkId}::uuid, ${snapshot.actor.linkRevision}, ${JSON.stringify(snapshot)}::jsonb)`);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function cloneExternalLinkTaskAuthority(
|
|
185
|
+
db: Database,
|
|
186
|
+
task: ScheduledTask,
|
|
187
|
+
sourceRevision: number,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
if (task.authorityRevision === sourceRevision) return;
|
|
190
|
+
const snapshot = await getExternalLinkTaskSnapshot(db, {
|
|
191
|
+
...task,
|
|
192
|
+
taskId: task.id,
|
|
193
|
+
taskRevision: sourceRevision,
|
|
194
|
+
});
|
|
195
|
+
if (snapshot) await captureExternalLinkTaskAuthority(db, task, snapshot);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function captureScheduledExternalLinkTurnAuthority(
|
|
199
|
+
db: Database,
|
|
200
|
+
input: Scope & {
|
|
201
|
+
sessionId: string;
|
|
202
|
+
turnId: string;
|
|
203
|
+
runId: string;
|
|
204
|
+
},
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const run = await withWorkspaceRls(db, input.workspaceId, async (tx) => {
|
|
207
|
+
const [row] = await rawRows<{ task_id: string; task_authority_revision: number | string }>(
|
|
208
|
+
tx,
|
|
209
|
+
sql`select task_id, task_authority_revision from scheduled_task_runs
|
|
210
|
+
where account_id = ${input.accountId}::uuid and workspace_id = ${input.workspaceId}::uuid
|
|
211
|
+
and session_id = ${input.sessionId}::uuid and id = ${input.runId}::uuid`,
|
|
212
|
+
);
|
|
213
|
+
return row;
|
|
214
|
+
});
|
|
215
|
+
if (!run) throw new Error("Linked schedule run unavailable");
|
|
216
|
+
const taskRevision = Number(run.task_authority_revision);
|
|
217
|
+
const snapshot = await getExternalLinkTaskSnapshot(db, {
|
|
218
|
+
...input,
|
|
219
|
+
taskId: run.task_id,
|
|
220
|
+
taskRevision,
|
|
221
|
+
});
|
|
222
|
+
if (snapshot)
|
|
223
|
+
await captureExternalLinkTurnAuthority(db, {
|
|
224
|
+
...input,
|
|
225
|
+
snapshot,
|
|
226
|
+
source: { kind: "scheduled", taskId: run.task_id, taskRevision },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { stableJson, type McpCredentialsRequest } from "@opengeni/contracts";
|
|
2
|
+
import { HostMcpBinding, HostMcpBindingDefinition } from "@opengeni/contracts/host-mcp-bindings";
|
|
3
|
+
|
|
4
|
+
/** Metadata comparison only. A caller must independently establish accepted
|
|
5
|
+
* execution delegation and live owner/workspace authority. A matching registry
|
|
6
|
+
* row never authorizes a turn, a scheduler, or a nested session by itself. */
|
|
7
|
+
export function hostMcpBindingMatchesRequest(
|
|
8
|
+
rawBinding: unknown,
|
|
9
|
+
request: McpCredentialsRequest,
|
|
10
|
+
): boolean {
|
|
11
|
+
const parsed = HostMcpBinding.safeParse(rawBinding);
|
|
12
|
+
if (!parsed.success) return false;
|
|
13
|
+
const binding = parsed.data;
|
|
14
|
+
const { hostBinding, ...connectionRef } = request.connectionRef;
|
|
15
|
+
if (
|
|
16
|
+
!hostBinding ||
|
|
17
|
+
binding.status !== "active" ||
|
|
18
|
+
binding.revokedAt !== null ||
|
|
19
|
+
binding.id !== hostBinding.bindingId ||
|
|
20
|
+
binding.generation !== hostBinding.generation ||
|
|
21
|
+
binding.accountId !== request.accountId ||
|
|
22
|
+
binding.workspaceId !== request.workspaceId ||
|
|
23
|
+
// The registry currently describes an MCP endpoint, not a REST API grant.
|
|
24
|
+
request.credentialTarget !== "mcp"
|
|
25
|
+
)
|
|
26
|
+
return false;
|
|
27
|
+
const candidate = HostMcpBindingDefinition.safeParse({
|
|
28
|
+
serverId: request.serverId,
|
|
29
|
+
destinationUrl: request.destinationUrl,
|
|
30
|
+
connectionRef,
|
|
31
|
+
});
|
|
32
|
+
return candidate.success && stableJson(candidate.data) === stableJson(binding.definition);
|
|
33
|
+
}
|