@opengeni/core 2.8.3 → 2.9.1-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/access/external-actor-authority.d.ts +47 -0
- package/dist/access/index.d.ts +15 -1
- package/dist/application/connect-authority.d.ts +13 -0
- package/dist/application/connect-operation.d.ts +28 -0
- package/dist/application/external-continuation.d.ts +15 -0
- package/dist/application/external-identity-lifecycle.d.ts +20 -0
- package/dist/application/external-link-work-admission.d.ts +12 -0
- package/dist/application/external-workspace-members.d.ts +6 -0
- package/dist/application/host-mcp-owner.d.ts +6 -0
- package/dist/application/new-session-drafts.d.ts +2 -1
- package/dist/application/session-tenancy.d.ts +1 -0
- package/dist/dependencies.d.ts +2 -0
- package/dist/domain/capabilities.d.ts +19 -2
- package/dist/domain/external-creation-attribution.d.ts +6 -0
- package/dist/domain/host-mcp-task-admission.d.ts +18 -0
- package/dist/domain/product-integration-pack.d.ts +3 -9
- package/dist/domain/product-integration-skill.gen.d.ts +5 -0
- package/dist/domain/scheduled-tasks.d.ts +3 -0
- package/dist/domain/sessions.d.ts +15 -4
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1231 -825
- package/dist/index.js.map +1 -1
- package/dist/remote-mcp-credentials.d.ts +8 -0
- package/dist/remote-mcp-credentials.js +219 -0
- package/dist/remote-mcp-credentials.js.map +1 -0
- package/dist/session-authorization.d.ts +5 -6
- package/package.json +17 -13
- package/src/access/external-actor-authority.ts +94 -0
- package/src/access/index.ts +255 -1
- package/src/application/connect-authority.ts +77 -0
- package/src/application/connect-operation.ts +51 -0
- package/src/application/external-continuation.ts +112 -0
- package/src/application/external-identity-lifecycle.ts +48 -0
- package/src/application/external-link-work-admission.ts +87 -0
- package/src/application/external-workspace-members.ts +95 -0
- package/src/application/host-mcp-owner.ts +41 -0
- package/src/application/new-session-drafts.ts +9 -2
- package/src/application/session-tenancy.ts +24 -3
- package/src/application/user-resource-grants.ts +2 -2
- package/src/dependencies.ts +2 -0
- package/src/domain/capabilities.ts +20 -4
- package/src/domain/external-creation-attribution.ts +22 -0
- package/src/domain/host-mcp-task-admission.ts +111 -0
- package/src/domain/product-integration-pack.ts +11 -464
- package/src/domain/product-integration-skill.gen.ts +52 -0
- package/src/domain/scheduled-tasks.ts +66 -5
- package/src/domain/sessions.ts +253 -23
- package/src/index.ts +7 -0
- package/src/remote-mcp-credentials.ts +293 -0
- package/src/session-authorization.ts +22 -17
package/src/access/index.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
ExternalActorSelection,
|
|
4
|
+
ExternalActorAttribution,
|
|
5
|
+
type ExternalActorContinuation,
|
|
6
|
+
type ExternalIdentity,
|
|
7
|
+
} from "@opengeni/contracts/external-identities";
|
|
2
8
|
import {
|
|
3
9
|
verifyDelegatedAccessToken,
|
|
4
10
|
type AccountGrant,
|
|
5
11
|
type AccessContext,
|
|
6
12
|
type AccessGrant,
|
|
7
|
-
|
|
13
|
+
Permission,
|
|
14
|
+
type Workspace,
|
|
8
15
|
} from "@opengeni/contracts";
|
|
9
16
|
import {
|
|
10
17
|
bootstrapWorkspace,
|
|
11
18
|
ensureManagedAccessForUser,
|
|
19
|
+
ensureExternalIdentity,
|
|
20
|
+
resolveExternalIdentityLink,
|
|
21
|
+
managedPersonalWorkspacePermissions,
|
|
22
|
+
nestedPostgresSqlState,
|
|
23
|
+
withWorkspaceSubjectRls,
|
|
24
|
+
withAccountRls,
|
|
25
|
+
listWorkspacesForSubject,
|
|
12
26
|
findActiveApiKeyByHash,
|
|
13
27
|
getWorkspaceGrant,
|
|
14
28
|
requireWorkspace,
|
|
@@ -45,11 +59,102 @@ const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null
|
|
|
45
59
|
*/
|
|
46
60
|
const canonicalManagedCookieContexts = new WeakSet<AccessContext>();
|
|
47
61
|
const canonicalLocalHumanContexts = new WeakSet<AccessContext>();
|
|
62
|
+
const externalActorContexts = new WeakMap<
|
|
63
|
+
AccessContext,
|
|
64
|
+
{
|
|
65
|
+
identity: ExternalIdentity;
|
|
66
|
+
keyId: string;
|
|
67
|
+
permissions: Permission[];
|
|
68
|
+
linked?: NonNullable<Awaited<ReturnType<typeof resolveExternalIdentityLink>>>;
|
|
69
|
+
}
|
|
70
|
+
>();
|
|
71
|
+
function attributionForExternalContext(context: AccessContext): ExternalActorAttribution {
|
|
72
|
+
const external = externalActorContexts.get(context);
|
|
73
|
+
if (!external) throw new Error("Verified external context required");
|
|
74
|
+
return ExternalActorAttribution.parse({
|
|
75
|
+
accountId: external.identity.accountId,
|
|
76
|
+
authenticatingApiKeyId: external.keyId,
|
|
77
|
+
externalIdentityId: external.identity.id,
|
|
78
|
+
externalSubjectId: external.identity.subjectId,
|
|
79
|
+
externalAuthorizationRevision: external.identity.authorizationRevision,
|
|
80
|
+
effectiveSubjectId: context.subjectId,
|
|
81
|
+
actingMode: external.linked ? "linked_native" : "external",
|
|
82
|
+
...(external.linked
|
|
83
|
+
? { linkId: external.linked.link.id, linkRevision: external.linked.link.revision }
|
|
84
|
+
: {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
48
87
|
const resolvedAccessGrantAuthorizations = new WeakSet<object>();
|
|
88
|
+
const verifiedExternalAuthorizations = new WeakMap<
|
|
89
|
+
AccessGrantAuthorization,
|
|
90
|
+
{
|
|
91
|
+
grant: AccessGrant;
|
|
92
|
+
workspaceId: string;
|
|
93
|
+
identityReference: { externalId: string; source: string };
|
|
94
|
+
attribution: ExternalActorAttribution;
|
|
95
|
+
}
|
|
96
|
+
>();
|
|
97
|
+
|
|
98
|
+
/** Creation audit only, never an authentication or delegation token. Metadata
|
|
99
|
+
* claiming to be external is insufficient; the exact resolver proof is needed. */
|
|
100
|
+
export function externalAttributionForAuthorization(
|
|
101
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
102
|
+
grant: AccessGrant,
|
|
103
|
+
): ExternalActorAttribution | null {
|
|
104
|
+
if (!authorization || authorization.grant !== grant) return null;
|
|
105
|
+
const verified = verifiedExternalAuthorizations.get(authorization);
|
|
106
|
+
if (
|
|
107
|
+
!verified ||
|
|
108
|
+
verified.grant !== grant ||
|
|
109
|
+
verified.workspaceId !== grant.workspaceId ||
|
|
110
|
+
verified.attribution.accountId !== grant.accountId ||
|
|
111
|
+
verified.attribution.effectiveSubjectId !== grant.subjectId
|
|
112
|
+
)
|
|
113
|
+
return null;
|
|
114
|
+
return structuredClone(verified.attribution);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function externalActorContinuationForAuthorization(
|
|
118
|
+
authorization: AccessGrantAuthorization,
|
|
119
|
+
): ExternalActorContinuation | null {
|
|
120
|
+
const actor = externalAttributionForAuthorization(authorization, authorization.grant);
|
|
121
|
+
const verified = verifiedExternalAuthorizations.get(authorization);
|
|
122
|
+
return actor && verified ? { actor, identity: { ...verified.identityReference } } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Dedicated owning-user proof. External admission never sets the native
|
|
126
|
+
* cookie stamp. The resource/session layer still checks the exact owner. */
|
|
127
|
+
export function hasVerifiedOwningUserAuthorization(
|
|
128
|
+
authorization: AccessGrantAuthorization,
|
|
129
|
+
): boolean {
|
|
130
|
+
if (
|
|
131
|
+
!authorization.contextIntegrity ||
|
|
132
|
+
authorization.authenticatedSubjectId !== authorization.grant.subjectId
|
|
133
|
+
)
|
|
134
|
+
return false;
|
|
135
|
+
return (
|
|
136
|
+
authorization.canonicalManagedHumanSession ||
|
|
137
|
+
externalAttributionForAuthorization(authorization, authorization.grant) !== null
|
|
138
|
+
);
|
|
139
|
+
}
|
|
49
140
|
const accountScopedApiKeyContexts = new WeakMap<
|
|
50
141
|
AccessContext,
|
|
51
142
|
Readonly<{ accountId: string; permissions: readonly Permission[] }>
|
|
52
143
|
>();
|
|
144
|
+
const verifiedOrganizationServiceAuthorizations = new WeakMap<
|
|
145
|
+
AccessGrantAuthorization,
|
|
146
|
+
AccessGrant
|
|
147
|
+
>();
|
|
148
|
+
|
|
149
|
+
/** Request-local organization service-key proof, not a principal-kind claim. */
|
|
150
|
+
export function isVerifiedOrganizationServiceAuthorization(
|
|
151
|
+
authorization: AccessGrantAuthorization,
|
|
152
|
+
): boolean {
|
|
153
|
+
return (
|
|
154
|
+
verifiedOrganizationServiceAuthorizations.has(authorization) &&
|
|
155
|
+
verifiedOrganizationServiceAuthorizations.get(authorization) === authorization.grant
|
|
156
|
+
);
|
|
157
|
+
}
|
|
53
158
|
|
|
54
159
|
const accountScopedApiKeyAccountPermissions = new Set<Permission>([
|
|
55
160
|
"account:read",
|
|
@@ -119,6 +224,39 @@ export type AccessDeps = {
|
|
|
119
224
|
managedAuthSessionAdapter?: ManagedAuthSessionAdapter | null;
|
|
120
225
|
};
|
|
121
226
|
|
|
227
|
+
/** null means this is not an authenticated external lane; [] means that lane
|
|
228
|
+
* has no readable inventory. Callers must not fall back from [] to service or
|
|
229
|
+
* bare-subject discovery. */
|
|
230
|
+
export async function listExternalActorWorkspaces(
|
|
231
|
+
context: AccessContext,
|
|
232
|
+
deps: AccessDeps,
|
|
233
|
+
): Promise<Workspace[] | null> {
|
|
234
|
+
const actor = externalActorContexts.get(context);
|
|
235
|
+
if (!actor) return null;
|
|
236
|
+
if (
|
|
237
|
+
!hasPermission(actor.permissions, "workspace:read") ||
|
|
238
|
+
(actor.linked && !hasPermission(actor.linked.link.permissions, "workspace:read"))
|
|
239
|
+
)
|
|
240
|
+
return [];
|
|
241
|
+
const candidates = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
|
|
242
|
+
listWorkspacesForSubject(tx, context.subjectId),
|
|
243
|
+
);
|
|
244
|
+
const authorized: Workspace[] = [];
|
|
245
|
+
const personal = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
|
|
246
|
+
requireWorkspace(tx, actor.linked?.personalWorkspaceId ?? actor.identity.personalWorkspaceId),
|
|
247
|
+
);
|
|
248
|
+
if (personal.accountId === actor.identity.accountId && personal.kind === "personal")
|
|
249
|
+
authorized.push(personal);
|
|
250
|
+
for (const workspace of candidates) {
|
|
251
|
+
if (workspace.accountId !== actor.identity.accountId || workspace.kind !== "shared") continue;
|
|
252
|
+
const grant = await withWorkspaceSubjectRls(deps.db, workspace.id, context.subjectId, (tx) =>
|
|
253
|
+
getWorkspaceGrant(tx, context.subjectId, workspace.id),
|
|
254
|
+
);
|
|
255
|
+
if (grant && hasPermission(grant.permissions, "workspace:read")) authorized.push(workspace);
|
|
256
|
+
}
|
|
257
|
+
return authorized;
|
|
258
|
+
}
|
|
259
|
+
|
|
122
260
|
export async function requireAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext> {
|
|
123
261
|
let pending = accessContextByRequest.get(c.req.raw);
|
|
124
262
|
if (!pending) {
|
|
@@ -210,6 +348,25 @@ export function accessGrantAuthorizationFromContext(
|
|
|
210
348
|
canonicalLocalHumanSession: isCanonicalLocalHumanSession(context, grant),
|
|
211
349
|
};
|
|
212
350
|
resolvedAccessGrantAuthorizations.add(authorization);
|
|
351
|
+
if (
|
|
352
|
+
contextIntegrity &&
|
|
353
|
+
accountScopedApiKeyWorkspaceAuthority(context)?.accountId === grant.accountId &&
|
|
354
|
+
grant.principalKind === "api_key"
|
|
355
|
+
) {
|
|
356
|
+
verifiedOrganizationServiceAuthorizations.set(authorization, grant);
|
|
357
|
+
}
|
|
358
|
+
const external = externalActorContexts.get(context);
|
|
359
|
+
if (external && contextIntegrity && grant.accountId === external.identity.accountId) {
|
|
360
|
+
verifiedExternalAuthorizations.set(authorization, {
|
|
361
|
+
grant,
|
|
362
|
+
workspaceId: grant.workspaceId,
|
|
363
|
+
identityReference: {
|
|
364
|
+
externalId: external.identity.externalId,
|
|
365
|
+
source: external.identity.source,
|
|
366
|
+
},
|
|
367
|
+
attribution: attributionForExternalContext(context),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
213
370
|
return authorization;
|
|
214
371
|
}
|
|
215
372
|
|
|
@@ -345,6 +502,42 @@ async function accessGrantAuthorization(
|
|
|
345
502
|
workspaceId: string,
|
|
346
503
|
permission?: Permission,
|
|
347
504
|
): Promise<AccessGrantAuthorization> {
|
|
505
|
+
const external = externalActorContexts.get(context);
|
|
506
|
+
if (external) {
|
|
507
|
+
const grant: AccessGrant | null =
|
|
508
|
+
workspaceId ===
|
|
509
|
+
(external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId)
|
|
510
|
+
? {
|
|
511
|
+
accountId: external.identity.accountId,
|
|
512
|
+
workspaceId,
|
|
513
|
+
subjectId: context.subjectId,
|
|
514
|
+
principalKind: "human_session",
|
|
515
|
+
permissions: [...managedPersonalWorkspacePermissions],
|
|
516
|
+
}
|
|
517
|
+
: await withWorkspaceSubjectRls(deps.db, workspaceId, context.subjectId, (tx) =>
|
|
518
|
+
getWorkspaceGrant(tx, context.subjectId, workspaceId, {
|
|
519
|
+
principalKind: "human_session",
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
if (!grant || grant.accountId !== external.identity.accountId) {
|
|
523
|
+
throw new HTTPException(403, { message: "external workspace access denied" });
|
|
524
|
+
}
|
|
525
|
+
// Intersect effective permissions using the existing wildcard/literal
|
|
526
|
+
// semantics on each side independently. Never return workspace:admin
|
|
527
|
+
// unless both sides hold it, and never infer literal secrets:read.
|
|
528
|
+
grant.permissions = Permission.options.filter(
|
|
529
|
+
(value) =>
|
|
530
|
+
hasPermission(grant.permissions, value) &&
|
|
531
|
+
hasPermission(external.permissions, value) &&
|
|
532
|
+
(!external.linked || hasPermission(external.linked.link.permissions, value)),
|
|
533
|
+
);
|
|
534
|
+
grant.metadata = {
|
|
535
|
+
...grant.metadata,
|
|
536
|
+
externalActor: attributionForExternalContext(context),
|
|
537
|
+
};
|
|
538
|
+
if (permission) requirePermission(grant, permission);
|
|
539
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
540
|
+
}
|
|
348
541
|
const principalKind = hostedHumanSessionPrincipalKind(context);
|
|
349
542
|
let grant =
|
|
350
543
|
context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
|
|
@@ -497,6 +690,14 @@ export function hasPermission(permissions: Permission[], permission: Permission)
|
|
|
497
690
|
}
|
|
498
691
|
|
|
499
692
|
async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
|
|
693
|
+
if (c.req.header("x-opengeni-external-actor") !== undefined) {
|
|
694
|
+
if (deps.settings.productAccessMode === "local") {
|
|
695
|
+
throw new HTTPException(401, {
|
|
696
|
+
message: "external actors require organization key authentication",
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return apiKeyAccessContext(c, deps, deps.settings.productAccessMode);
|
|
700
|
+
}
|
|
500
701
|
if (deps.settings.productAccessMode === "local") {
|
|
501
702
|
const delegated = await delegatedAccessContext(c, deps, "local");
|
|
502
703
|
if (delegated) {
|
|
@@ -592,6 +793,59 @@ async function apiKeyAccessContext(
|
|
|
592
793
|
if (!apiKey) {
|
|
593
794
|
return null;
|
|
594
795
|
}
|
|
796
|
+
const externalHeader = c.req.header("x-opengeni-external-actor");
|
|
797
|
+
if (externalHeader !== undefined) {
|
|
798
|
+
if (apiKey.workspaceId !== null || apiKey.credentialKind !== "organization") {
|
|
799
|
+
throw new HTTPException(403, { message: "external actors require an organization key" });
|
|
800
|
+
}
|
|
801
|
+
let selection: ExternalActorSelection;
|
|
802
|
+
try {
|
|
803
|
+
if (externalHeader.length > 16384) throw new Error("oversize");
|
|
804
|
+
selection = ExternalActorSelection.parse(JSON.parse(decodeURIComponent(externalHeader)));
|
|
805
|
+
} catch {
|
|
806
|
+
throw new HTTPException(400, { message: "invalid external actor selection" });
|
|
807
|
+
}
|
|
808
|
+
let identity: ExternalIdentity;
|
|
809
|
+
try {
|
|
810
|
+
identity = await ensureExternalIdentity(deps.db, {
|
|
811
|
+
accountId: apiKey.accountId,
|
|
812
|
+
...selection.identity,
|
|
813
|
+
});
|
|
814
|
+
} catch (error) {
|
|
815
|
+
if (nestedPostgresSqlState(error) === "42501") {
|
|
816
|
+
throw new HTTPException(403, { message: "external identity is unavailable" });
|
|
817
|
+
}
|
|
818
|
+
throw new HTTPException(503, { message: "external identity authority is unavailable" });
|
|
819
|
+
}
|
|
820
|
+
const linked =
|
|
821
|
+
selection.mode === "linked_native"
|
|
822
|
+
? await resolveExternalIdentityLink(deps.db, {
|
|
823
|
+
identity,
|
|
824
|
+
linkId: selection.linkId,
|
|
825
|
+
expectedRevision: selection.expectedLinkRevision,
|
|
826
|
+
})
|
|
827
|
+
: null;
|
|
828
|
+
if (selection.mode === "linked_native" && !linked)
|
|
829
|
+
throw new HTTPException(403, { message: "Native identity link is unavailable or changed" });
|
|
830
|
+
const effectiveSubjectId = linked?.link.nativeSubjectId ?? identity.subjectId;
|
|
831
|
+
const context: AccessContext = {
|
|
832
|
+
mode,
|
|
833
|
+
subjectId: effectiveSubjectId,
|
|
834
|
+
accountGrants: [
|
|
835
|
+
{ accountId: identity.accountId, subjectId: effectiveSubjectId, permissions: [] },
|
|
836
|
+
],
|
|
837
|
+
workspaceGrants: [],
|
|
838
|
+
defaultAccountId: identity.accountId,
|
|
839
|
+
defaultWorkspaceId: null,
|
|
840
|
+
};
|
|
841
|
+
externalActorContexts.set(context, {
|
|
842
|
+
identity,
|
|
843
|
+
keyId: apiKey.id,
|
|
844
|
+
permissions: [...apiKey.permissions],
|
|
845
|
+
...(linked ? { linked } : {}),
|
|
846
|
+
});
|
|
847
|
+
return context;
|
|
848
|
+
}
|
|
595
849
|
const subjectId = `api_key:${apiKey.id}`;
|
|
596
850
|
const accountPermissions = apiKey.workspaceId
|
|
597
851
|
? apiKey.permissions.filter(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Permission } from "@opengeni/contracts";
|
|
2
|
+
import type { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
3
|
+
import { hasPermission } from "../access";
|
|
4
|
+
import { requireExternalContinuationAuthority } from "./external-continuation";
|
|
5
|
+
import {
|
|
6
|
+
getWorkspaceGrant,
|
|
7
|
+
resolveNamedManagedPersonalWorkspaceGrant,
|
|
8
|
+
lockExternalWorkspaceMembershipLifecycle,
|
|
9
|
+
withWorkspaceSubjectRls,
|
|
10
|
+
type Database,
|
|
11
|
+
lockConnectionSetupKey,
|
|
12
|
+
nestedPostgresSqlState,
|
|
13
|
+
} from "@opengeni/db";
|
|
14
|
+
import { HTTPException } from "hono/http-exception";
|
|
15
|
+
|
|
16
|
+
/** Accept only a verified request owner or authenticated server-minted OAuth
|
|
17
|
+
* state. The callback does not need a browser cookie: it rechecks the stored
|
|
18
|
+
* principal against current authority before claiming and committing effects. */
|
|
19
|
+
export async function requireConnectOwnerAuthority(
|
|
20
|
+
db: Database,
|
|
21
|
+
state: {
|
|
22
|
+
accountId: string;
|
|
23
|
+
workspaceId: string;
|
|
24
|
+
subjectId: string;
|
|
25
|
+
personalOwnerVerified?: boolean;
|
|
26
|
+
externalContinuation?: ExternalActorContinuation;
|
|
27
|
+
},
|
|
28
|
+
permission: Permission = "connections:write",
|
|
29
|
+
origin: ExternalActorContinuation | null = null,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
async function requireExternal(continuation: ExternalActorContinuation) {
|
|
32
|
+
try {
|
|
33
|
+
await requireExternalContinuationAuthority(db, continuation, state, permission);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const denied =
|
|
36
|
+
nestedPostgresSqlState(error) === "42501" ||
|
|
37
|
+
(error instanceof Error && error.message === "External continuation authority unavailable");
|
|
38
|
+
throw new HTTPException(denied ? 403 : 503, {
|
|
39
|
+
message: denied
|
|
40
|
+
? "Connection origin authority changed"
|
|
41
|
+
: "Connection origin authority unavailable",
|
|
42
|
+
cause: error,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Current request authority cannot replace the original restriction. Native
|
|
47
|
+
// owners may resume their work, but an expired host/link origin still denies.
|
|
48
|
+
if (origin) await requireExternal(origin);
|
|
49
|
+
if (state.externalContinuation) {
|
|
50
|
+
await requireExternal(state.externalContinuation);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (state.subjectId.startsWith("external_user:"))
|
|
54
|
+
throw new HTTPException(403, { message: "External Connect continuation required" });
|
|
55
|
+
if (state.subjectId.startsWith("api_key:")) {
|
|
56
|
+
const permissions = await lockConnectionSetupKey(db, state);
|
|
57
|
+
if (!permissions || !hasPermission(permissions, permission))
|
|
58
|
+
throw new HTTPException(403, { message: "Connection API key authority changed" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await withWorkspaceSubjectRls(db, state.workspaceId, state.subjectId, async (tx) => {
|
|
62
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, state.accountId);
|
|
63
|
+
const membership = await getWorkspaceGrant(tx, state.subjectId, state.workspaceId);
|
|
64
|
+
const grant =
|
|
65
|
+
membership?.accountId === state.accountId
|
|
66
|
+
? membership
|
|
67
|
+
: state.personalOwnerVerified
|
|
68
|
+
? await resolveNamedManagedPersonalWorkspaceGrant(tx, state)
|
|
69
|
+
: null;
|
|
70
|
+
if (
|
|
71
|
+
!grant ||
|
|
72
|
+
grant.accountId !== state.accountId ||
|
|
73
|
+
!hasPermission(grant.permissions, permission)
|
|
74
|
+
)
|
|
75
|
+
throw new HTTPException(403, { message: "Connection owner authority changed" });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ConnectAttempt } from "@opengeni/contracts/connect";
|
|
2
|
+
import {
|
|
3
|
+
claimConnectOperation,
|
|
4
|
+
finishConnectOperation,
|
|
5
|
+
type ConnectActorScope,
|
|
6
|
+
type ConnectOperationAuthorization,
|
|
7
|
+
type Database,
|
|
8
|
+
} from "@opengeni/db";
|
|
9
|
+
|
|
10
|
+
/** Provider result is deliberately not serializable: secrets can remain in its
|
|
11
|
+
* closure until encrypted credential persistence. Do not retain/log this value. */
|
|
12
|
+
export type PreparedConnectOperation = {
|
|
13
|
+
commit: (tx: Database, current: ConnectAttempt) => Promise<ConnectAttempt>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Internal provider-adapter boundary, not an HTTP authentication adapter.
|
|
17
|
+
*
|
|
18
|
+
* authorize is mandatory, including for a receipt replay. It must validate the
|
|
19
|
+
* exact saved key/actor/link authority and current permission under database
|
|
20
|
+
* locks. execute owns provider-specific proof, destination and cancellation.
|
|
21
|
+
* No remote effects may occur inside authorize or commit.
|
|
22
|
+
*
|
|
23
|
+
* A failed execute/authorization/commit keeps the durable claim occupied. It
|
|
24
|
+
* does not release or retry work whose provider outcome may be unknown.
|
|
25
|
+
*/
|
|
26
|
+
export async function executeConnectOperation(input: {
|
|
27
|
+
db: Database;
|
|
28
|
+
scope: ConnectActorScope;
|
|
29
|
+
attemptId: string;
|
|
30
|
+
expectedRevision: number;
|
|
31
|
+
operationId: string;
|
|
32
|
+
/** Caller computes a stable keyed digest if input contains low-entropy secrets. */
|
|
33
|
+
inputDigest: string;
|
|
34
|
+
authorize: ConnectOperationAuthorization;
|
|
35
|
+
execute: (attempt: ConnectAttempt) => Promise<PreparedConnectOperation>;
|
|
36
|
+
}): Promise<ConnectAttempt> {
|
|
37
|
+
// Snapshot caller-controlled identity before the first asynchronous boundary.
|
|
38
|
+
const { db, authorize, execute } = input;
|
|
39
|
+
const scope = { ...input.scope };
|
|
40
|
+
const operation = {
|
|
41
|
+
attemptId: input.attemptId,
|
|
42
|
+
expectedRevision: input.expectedRevision,
|
|
43
|
+
operationId: input.operationId,
|
|
44
|
+
inputDigest: input.inputDigest,
|
|
45
|
+
authorize,
|
|
46
|
+
};
|
|
47
|
+
const claim = await claimConnectOperation(db, scope, operation);
|
|
48
|
+
if (claim.status === "replayed") return claim.attempt;
|
|
49
|
+
const prepared = await execute(structuredClone(claim.attempt));
|
|
50
|
+
return finishConnectOperation(db, scope, { ...operation, commit: prepared.commit });
|
|
51
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
2
|
+
import type { Permission } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
ensureExternalIdentity,
|
|
5
|
+
resolveExternalIdentityLink,
|
|
6
|
+
getWorkspaceGrant,
|
|
7
|
+
lockActiveExternalOrganizationKey,
|
|
8
|
+
lockExternalWorkspaceMembershipLifecycle,
|
|
9
|
+
managedPersonalWorkspacePermissions,
|
|
10
|
+
nestedPostgresSqlState,
|
|
11
|
+
withWorkspaceSubjectRls,
|
|
12
|
+
type Database,
|
|
13
|
+
} from "@opengeni/db";
|
|
14
|
+
import {
|
|
15
|
+
hasPermission,
|
|
16
|
+
externalActorContinuationForAuthorization,
|
|
17
|
+
type AccessGrantAuthorization,
|
|
18
|
+
} from "../access";
|
|
19
|
+
import { HTTPException } from "hono/http-exception";
|
|
20
|
+
|
|
21
|
+
/** Capture verified request authority before asynchronous preflight. Invoke
|
|
22
|
+
* inside the canonical mutation transaction after its lifecycle locks, so a
|
|
23
|
+
* revoked key/generation rolls the write back without changing lock order. */
|
|
24
|
+
export function externalContinuationCommitAuthorizer(
|
|
25
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
26
|
+
): ((tx: Database) => Promise<void>) | undefined {
|
|
27
|
+
const continuation = authorization
|
|
28
|
+
? externalActorContinuationForAuthorization(authorization)
|
|
29
|
+
: null;
|
|
30
|
+
if (!continuation || !authorization) return undefined;
|
|
31
|
+
const scope = {
|
|
32
|
+
accountId: authorization.grant.accountId,
|
|
33
|
+
workspaceId: authorization.grant.workspaceId,
|
|
34
|
+
subjectId: authorization.grant.subjectId,
|
|
35
|
+
};
|
|
36
|
+
const permissions = [...authorization.grant.permissions];
|
|
37
|
+
return async (tx) => {
|
|
38
|
+
try {
|
|
39
|
+
await requireExternalContinuationAuthority(tx, continuation, scope, permissions);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const denied =
|
|
42
|
+
nestedPostgresSqlState(error) === "42501" ||
|
|
43
|
+
(error instanceof Error && error.message === "External continuation authority unavailable");
|
|
44
|
+
throw new HTTPException(denied ? 403 : 503, {
|
|
45
|
+
message: denied ? "external authority changed" : "external authority is unavailable",
|
|
46
|
+
cause: error,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Caller verifies/decrypts its server-minted continuation before invoking.
|
|
53
|
+
* An open transaction retains lifecycle/key/identity locks through persistence.
|
|
54
|
+
* Historical creator audit alone must never enter this path as a credential. */
|
|
55
|
+
export async function requireExternalContinuationAuthority(
|
|
56
|
+
tx: Database,
|
|
57
|
+
raw: unknown,
|
|
58
|
+
scope: { accountId: string; workspaceId: string; subjectId: string },
|
|
59
|
+
permission: Permission | readonly Permission[],
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const { actor, identity: reference } = ExternalActorContinuation.parse(raw);
|
|
62
|
+
const required = typeof permission === "string" ? [permission] : [...permission];
|
|
63
|
+
const deny = () => {
|
|
64
|
+
throw new Error("External continuation authority unavailable");
|
|
65
|
+
};
|
|
66
|
+
if (actor.accountId !== scope.accountId || actor.effectiveSubjectId !== scope.subjectId) deny();
|
|
67
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, scope.accountId);
|
|
68
|
+
const permissions = await lockActiveExternalOrganizationKey(
|
|
69
|
+
tx,
|
|
70
|
+
scope.accountId,
|
|
71
|
+
actor.authenticatingApiKeyId,
|
|
72
|
+
);
|
|
73
|
+
if (!permissions || required.some((value) => !hasPermission(permissions, value))) deny();
|
|
74
|
+
const identity = await ensureExternalIdentity(tx, { accountId: scope.accountId, ...reference });
|
|
75
|
+
if (
|
|
76
|
+
identity.id !== actor.externalIdentityId ||
|
|
77
|
+
identity.subjectId !== actor.externalSubjectId ||
|
|
78
|
+
identity.authorizationRevision !== actor.externalAuthorizationRevision
|
|
79
|
+
)
|
|
80
|
+
deny();
|
|
81
|
+
const linked =
|
|
82
|
+
actor.actingMode === "linked_native"
|
|
83
|
+
? await resolveExternalIdentityLink(tx, {
|
|
84
|
+
identity,
|
|
85
|
+
linkId: actor.linkId!,
|
|
86
|
+
expectedRevision: actor.linkRevision!,
|
|
87
|
+
})
|
|
88
|
+
: null;
|
|
89
|
+
if (
|
|
90
|
+
actor.actingMode === "linked_native" &&
|
|
91
|
+
(!linked ||
|
|
92
|
+
linked.link.nativeSubjectId !== scope.subjectId ||
|
|
93
|
+
required.some((value) => !hasPermission(linked.link.permissions, value)))
|
|
94
|
+
)
|
|
95
|
+
deny();
|
|
96
|
+
if (actor.actingMode === "external" && scope.subjectId !== identity.subjectId) deny();
|
|
97
|
+
const grant =
|
|
98
|
+
scope.workspaceId === (linked?.personalWorkspaceId ?? identity.personalWorkspaceId)
|
|
99
|
+
? {
|
|
100
|
+
accountId: identity.accountId,
|
|
101
|
+
permissions: managedPersonalWorkspacePermissions,
|
|
102
|
+
}
|
|
103
|
+
: await withWorkspaceSubjectRls(tx, scope.workspaceId, scope.subjectId, (db) =>
|
|
104
|
+
getWorkspaceGrant(db, scope.subjectId, scope.workspaceId),
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
!grant ||
|
|
108
|
+
grant.accountId !== scope.accountId ||
|
|
109
|
+
required.some((value) => !hasPermission(grant.permissions, value))
|
|
110
|
+
)
|
|
111
|
+
deny();
|
|
112
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import { UpdateExternalIdentityMembershipRequest } from "@opengeni/contracts/external-identities";
|
|
4
|
+
import { updateOrganizationMember } from "@opengeni/db";
|
|
5
|
+
import {
|
|
6
|
+
accountScopedApiKeyWorkspaceAuthority,
|
|
7
|
+
requireAccessContext,
|
|
8
|
+
type AccessDeps,
|
|
9
|
+
} from "../access";
|
|
10
|
+
|
|
11
|
+
/** Host service administration is separate from asUser and native-cookie
|
|
12
|
+
* administration. The database rechecks the live key and exact external target
|
|
13
|
+
* under the canonical organization lifecycle fences, including receipt replay. */
|
|
14
|
+
export async function updateExternalIdentityMembershipForRequest(
|
|
15
|
+
context: Context,
|
|
16
|
+
deps: AccessDeps,
|
|
17
|
+
organizationId: string,
|
|
18
|
+
membershipId: string,
|
|
19
|
+
input: unknown,
|
|
20
|
+
) {
|
|
21
|
+
const access = await requireAccessContext(context, deps);
|
|
22
|
+
const service = accountScopedApiKeyWorkspaceAuthority(access);
|
|
23
|
+
// The workspace projection deliberately excludes account:admin. Its opaque
|
|
24
|
+
// proof establishes service provenance; the exact account grant supplies
|
|
25
|
+
// this account-level permission, independently rechecked by the database.
|
|
26
|
+
const account = access.accountGrants.find(
|
|
27
|
+
(grant) => grant.accountId === organizationId && grant.subjectId === access.subjectId,
|
|
28
|
+
);
|
|
29
|
+
if (
|
|
30
|
+
!service ||
|
|
31
|
+
service.accountId !== organizationId ||
|
|
32
|
+
!account?.permissions.includes("account:admin")
|
|
33
|
+
) {
|
|
34
|
+
throw new HTTPException(403, {
|
|
35
|
+
message: "external identity administration requires an organization service key",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const parsed = UpdateExternalIdentityMembershipRequest.safeParse(input);
|
|
39
|
+
if (!parsed.success)
|
|
40
|
+
throw new HTTPException(422, { message: "invalid external identity transition" });
|
|
41
|
+
return updateOrganizationMember(deps.db, {
|
|
42
|
+
organizationId,
|
|
43
|
+
membershipId,
|
|
44
|
+
actorSubjectId: access.subjectId,
|
|
45
|
+
operationId: parsed.data.operationId,
|
|
46
|
+
transition: parsed.data,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ScheduledTask } from "@opengeni/contracts";
|
|
2
|
+
import { ExternalLinkWorkSnapshot } from "@opengeni/contracts/external-identities";
|
|
3
|
+
import {
|
|
4
|
+
captureExternalLinkTurnAuthority,
|
|
5
|
+
captureExternalLinkTaskAuthority,
|
|
6
|
+
getExternalLinkTurnSnapshot,
|
|
7
|
+
getSessionTurnForAttempt,
|
|
8
|
+
type Database,
|
|
9
|
+
} from "@opengeni/db";
|
|
10
|
+
import {
|
|
11
|
+
externalActorContinuationForAuthorization,
|
|
12
|
+
hasVerifiedOwningUserAuthorization,
|
|
13
|
+
type AccessGrantAuthorization,
|
|
14
|
+
} from "../access";
|
|
15
|
+
import { externalContinuationCommitAuthorizer } from "./external-continuation";
|
|
16
|
+
|
|
17
|
+
function snapshotFor(authorization: AccessGrantAuthorization | undefined) {
|
|
18
|
+
const continuation = authorization
|
|
19
|
+
? externalActorContinuationForAuthorization(authorization)
|
|
20
|
+
: null;
|
|
21
|
+
return continuation?.actor.actingMode === "linked_native"
|
|
22
|
+
? ExternalLinkWorkSnapshot.parse({
|
|
23
|
+
...continuation,
|
|
24
|
+
permissions: [...authorization!.grant.permissions],
|
|
25
|
+
})
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Closure is created only from the access resolver's object-identity proof.
|
|
30
|
+
* JSON metadata and an ordinary named native subject can never enter it. */
|
|
31
|
+
export function prepareExternalLinkTurnAdmission(
|
|
32
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
33
|
+
) {
|
|
34
|
+
const snapshot = snapshotFor(authorization);
|
|
35
|
+
if (!snapshot || !authorization) return undefined;
|
|
36
|
+
const workspaceId = authorization.grant.workspaceId;
|
|
37
|
+
const accountId = authorization.grant.accountId;
|
|
38
|
+
const commit = externalContinuationCommitAuthorizer(authorization)!;
|
|
39
|
+
return async (tx: Database, sessionId: string, turnId: string) => {
|
|
40
|
+
await commit(tx);
|
|
41
|
+
await captureExternalLinkTurnAuthority(tx, {
|
|
42
|
+
accountId,
|
|
43
|
+
workspaceId,
|
|
44
|
+
sessionId,
|
|
45
|
+
turnId,
|
|
46
|
+
snapshot,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function prepareExternalLinkTaskAdmission(
|
|
52
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
53
|
+
actor?: {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
turnId: string;
|
|
56
|
+
attemptId: string;
|
|
57
|
+
executionGeneration: number;
|
|
58
|
+
} | null,
|
|
59
|
+
): ((tx: Database, task: ScheduledTask) => Promise<void>) | undefined {
|
|
60
|
+
const snapshot = snapshotFor(authorization);
|
|
61
|
+
const commit = externalContinuationCommitAuthorizer(authorization);
|
|
62
|
+
if (snapshot)
|
|
63
|
+
return async (tx, task) => {
|
|
64
|
+
await commit?.(tx);
|
|
65
|
+
await captureExternalLinkTaskAuthority(tx, task, snapshot);
|
|
66
|
+
};
|
|
67
|
+
if (!actor) {
|
|
68
|
+
// A fresh, explicitly authenticated owning-user revision uses that user's
|
|
69
|
+
// own lane. It does not inherit a previous editor's link by accident.
|
|
70
|
+
// Service lifecycle edits and internal promotion still clone old provenance.
|
|
71
|
+
return authorization && hasVerifiedOwningUserAuthorization(authorization)
|
|
72
|
+
? async () => {}
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
return async (tx, task) => {
|
|
76
|
+
const turn = await getSessionTurnForAttempt(
|
|
77
|
+
tx,
|
|
78
|
+
task.workspaceId,
|
|
79
|
+
actor.sessionId,
|
|
80
|
+
actor.attemptId,
|
|
81
|
+
);
|
|
82
|
+
if (!turn || turn.id !== actor.turnId || turn.executionGeneration !== actor.executionGeneration)
|
|
83
|
+
throw new Error("Linked task creator attempt is no longer active");
|
|
84
|
+
const inherited = await getExternalLinkTurnSnapshot(tx, task, actor.turnId);
|
|
85
|
+
if (inherited) await captureExternalLinkTaskAuthority(tx, task, inherited);
|
|
86
|
+
};
|
|
87
|
+
}
|