@opengeni/core 0.28.1 → 1.0.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/README.md +1 -1
- package/dist/access/external-actor-authority.d.ts +47 -0
- package/dist/access/index.d.ts +182 -3
- package/dist/access/service-initiator.d.ts +3 -0
- package/dist/application/api-integration-servers.d.ts +20 -0
- package/dist/application/archived-session-imports.d.ts +6 -0
- package/dist/application/composer-submit.d.ts +24 -0
- package/dist/application/connect-authority.d.ts +13 -0
- package/dist/application/connect-operation.d.ts +31 -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 +34 -0
- package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
- package/dist/application/new-session-drafts.d.ts +39 -2
- package/dist/application/sandbox-recovery.d.ts +58 -0
- package/dist/application/session-commands.d.ts +26 -13
- package/dist/application/session-mcp-credential-rotation.d.ts +29 -0
- package/dist/application/session-tenancy.d.ts +19 -0
- package/dist/application/user-resource-grants.d.ts +20 -0
- package/dist/billing/limits.d.ts +11 -0
- package/dist/canonical-human-identities.d.ts +7 -2
- package/dist/canonical-human-identities.js +17 -4
- package/dist/canonical-human-identities.js.map +1 -1
- package/dist/{chunk-6WKPWE5S.js → chunk-2MKMRGSY.js} +108 -24
- package/dist/chunk-2MKMRGSY.js.map +1 -0
- package/dist/{chunk-NYPMQ7NO.js → chunk-GVO3N37G.js} +205 -37
- package/dist/chunk-GVO3N37G.js.map +1 -0
- package/dist/{chunk-JJU6I5XD.js → chunk-LCQXNR62.js} +36 -10
- package/dist/chunk-LCQXNR62.js.map +1 -0
- package/dist/chunk-MM6N7CTO.js +387 -0
- package/dist/chunk-MM6N7CTO.js.map +1 -0
- package/dist/chunk-QO5GVFFO.js +235 -0
- package/dist/chunk-QO5GVFFO.js.map +1 -0
- package/dist/codex-model-availability.d.ts +14 -0
- package/dist/default-session-model.d.ts +139 -0
- package/dist/dependencies.d.ts +87 -4
- package/dist/developer-setup-credential-ceiling.d.ts +10 -0
- package/dist/domain/agent-config-resolution.d.ts +49 -0
- package/dist/domain/automations.d.ts +58 -0
- package/dist/domain/capabilities.d.ts +22 -4
- package/dist/domain/company-profile-agent-admin.d.ts +25 -0
- package/dist/domain/connection-account-selection-error.d.ts +8 -0
- package/dist/domain/connector-tool-permissions.d.ts +36 -0
- package/dist/domain/direct-model-provider.d.ts +3 -0
- package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
- package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
- package/dist/domain/editable-artifacts/ports.d.ts +10 -0
- package/dist/domain/editable-artifacts/service.d.ts +4 -3
- package/dist/domain/environments.d.ts +5 -9
- package/dist/domain/external-creation-attribution.d.ts +6 -0
- package/dist/domain/file-owner.d.ts +8 -0
- package/dist/domain/github-action-policies.d.ts +36 -0
- package/dist/domain/github-repository-bindings.d.ts +117 -0
- package/dist/domain/github-skill-source.d.ts +5 -0
- package/dist/domain/governed-learning-slack-publication.d.ts +45 -0
- package/dist/domain/insights-timing.d.ts +16 -0
- package/dist/domain/insights-usage.d.ts +27 -0
- package/dist/domain/insights.d.ts +28 -1
- package/dist/domain/knowledge-files.d.ts +20 -0
- package/dist/domain/knowledge-messages.d.ts +39 -0
- package/dist/domain/knowledge-messages.test.d.ts +1 -0
- package/dist/domain/knowledge-preparation.d.ts +10 -0
- package/dist/domain/knowledge-preparation.test.d.ts +1 -0
- package/dist/domain/knowledge-search.d.ts +15 -0
- package/dist/domain/knowledge-search.test.d.ts +1 -0
- package/dist/domain/knowledge.d.ts +9 -0
- package/dist/domain/mcp-account-bindings.d.ts +19 -0
- package/dist/domain/mcp-account-routes.d.ts +21 -0
- package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
- package/dist/domain/organization-integration-catalog.d.ts +15 -0
- package/dist/domain/organization-membership-lifecycle.d.ts +1 -0
- package/dist/domain/personal-connection-delegations.d.ts +87 -4
- package/dist/domain/pr-review.d.ts +249 -0
- package/dist/domain/resources.d.ts +24 -3
- package/dist/domain/scheduled-task-access.d.ts +247 -0
- package/dist/domain/scheduled-tasks.d.ts +103 -6
- package/dist/domain/session-connection-accounts.d.ts +16 -0
- package/dist/domain/session-file-access.d.ts +4 -0
- package/dist/domain/session-native-connection-replacement.d.ts +14 -0
- package/dist/domain/session-start-timing.d.ts +7 -0
- package/dist/domain/session-tool-policy.d.ts +93 -4
- package/dist/domain/sessions.d.ts +275 -28
- package/dist/domain/skill-imports.d.ts +1 -0
- package/dist/domain/skill-search.d.ts +30 -0
- package/dist/domain/skills.d.ts +117 -0
- package/dist/domain/slack-bot.d.ts +46 -1
- package/dist/domain/workspace-members.d.ts +8 -1
- package/dist/editable-artifact-live/application.d.ts +12 -3
- package/dist/editable-artifact-live/ports.d.ts +0 -1
- package/dist/editable-artifact-live/server.d.ts +5 -2
- package/dist/editable-artifact-live/ticket.d.ts +2 -2
- package/dist/editable-artifact-live/types.d.ts +14 -2
- package/dist/editable-artifact-live.js +2 -2
- package/dist/editable-artifacts.js +10 -4
- package/dist/index.d.ts +49 -3
- package/dist/index.js +18812 -6833
- package/dist/index.js.map +1 -1
- package/dist/managed-auth-session-sets.d.ts +131 -0
- package/dist/managed-auth-session-sets.js +51 -0
- package/dist/managed-auth-session-sets.js.map +1 -0
- package/dist/managed-session.d.ts +98 -11
- package/dist/model-catalog.d.ts +117 -0
- package/dist/product-usage-metrics.d.ts +30 -0
- package/dist/rigs/index.d.ts +22 -5
- package/dist/rigs/provider-images.d.ts +1 -1
- package/dist/sandbox/fleet.d.ts +59 -10
- package/dist/sandbox/routing.d.ts +22 -2
- package/dist/sandbox/runtime-settings.d.ts +56 -0
- package/dist/session-authorization.d.ts +38 -1
- package/dist/site-session-origin.d.ts +9 -0
- package/dist/transcription.d.ts +12 -1
- package/dist/turn-surface.d.ts +18 -0
- package/dist/user-presence.d.ts +32 -0
- package/package.json +18 -11
- package/src/access/external-actor-authority.ts +94 -0
- package/src/access/index.ts +1386 -44
- package/src/access/service-initiator.ts +50 -0
- package/src/application/api-integration-servers.ts +215 -0
- package/src/application/archived-session-imports.ts +161 -0
- package/src/application/composer-submit.ts +77 -0
- package/src/application/connect-authority.ts +77 -0
- package/src/application/connect-operation.ts +80 -0
- package/src/application/external-continuation.ts +129 -0
- package/src/application/external-identity-lifecycle.ts +48 -0
- package/src/application/external-link-work-admission.ts +90 -0
- package/src/application/external-workspace-members.ts +232 -0
- package/src/application/modal-native-worker-host-transport.ts +263 -0
- package/src/application/new-session-drafts.ts +222 -29
- package/src/application/sandbox-recovery.ts +78 -0
- package/src/application/session-commands.ts +430 -73
- package/src/application/session-mcp-credential-rotation.ts +217 -0
- package/src/application/session-tenancy.ts +445 -0
- package/src/application/user-resource-grants.ts +154 -0
- package/src/billing/limits.ts +103 -24
- package/src/canonical-human-identities.ts +22 -3
- package/src/codex-model-availability.ts +136 -0
- package/src/default-session-model.ts +656 -0
- package/src/dependencies.ts +97 -5
- package/src/developer-setup-credential-ceiling.ts +60 -0
- package/src/domain/agent-config-resolution.ts +148 -0
- package/src/domain/automations.ts +270 -0
- package/src/domain/capabilities.ts +244 -151
- package/src/domain/company-profile-agent-admin.ts +56 -0
- package/src/domain/connection-account-selection-error.ts +19 -0
- package/src/domain/connector-tool-permissions.ts +371 -0
- package/src/domain/direct-model-provider.ts +62 -0
- package/src/domain/editable-artifacts/agent-application.ts +61 -15
- package/src/domain/editable-artifacts/durable-export.ts +100 -5
- package/src/domain/editable-artifacts/in-memory.ts +6 -0
- package/src/domain/editable-artifacts/ports.ts +11 -0
- package/src/domain/editable-artifacts/service.ts +32 -1
- package/src/domain/editable-artifacts/snapshot-compaction.ts +9 -5
- package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +10 -8
- package/src/domain/editable-artifacts/snapshot-verifier.ts +10 -3
- package/src/domain/environments.ts +25 -45
- package/src/domain/external-creation-attribution.ts +22 -0
- package/src/domain/file-owner.ts +67 -0
- package/src/domain/github-action-policies.ts +169 -0
- package/src/domain/github-repository-bindings.ts +295 -0
- package/src/domain/github-skill-source.ts +233 -0
- package/src/domain/governed-learning-slack-publication.ts +157 -0
- package/src/domain/insights-timing.ts +95 -0
- package/src/domain/insights-usage.ts +67 -0
- package/src/domain/insights.ts +270 -169
- package/src/domain/knowledge-files.ts +46 -0
- package/src/domain/knowledge-messages.test.ts +91 -0
- package/src/domain/knowledge-messages.ts +106 -0
- package/src/domain/knowledge-preparation.test.ts +276 -0
- package/src/domain/knowledge-preparation.ts +147 -0
- package/src/domain/knowledge-search.test.ts +561 -0
- package/src/domain/knowledge-search.ts +256 -0
- package/src/domain/knowledge.ts +133 -0
- package/src/domain/mcp-account-bindings.ts +171 -0
- package/src/domain/mcp-account-routes.ts +121 -0
- package/src/domain/native-mcp-connection-admission.ts +14 -0
- package/src/domain/organization-integration-catalog.ts +88 -0
- package/src/domain/organization-membership-lifecycle.ts +10 -0
- package/src/domain/personal-connection-delegations.ts +758 -115
- package/src/domain/pr-review.ts +687 -0
- package/src/domain/resources.ts +182 -25
- package/src/domain/scheduled-task-access.ts +1052 -0
- package/src/domain/scheduled-tasks.ts +1952 -215
- package/src/domain/session-connection-accounts.ts +67 -0
- package/src/domain/session-file-access.ts +47 -0
- package/src/domain/session-native-connection-replacement.ts +64 -0
- package/src/domain/session-start-timing.ts +55 -0
- package/src/domain/session-tool-policy.ts +443 -19
- package/src/domain/sessions.ts +3417 -619
- package/src/domain/skill-imports.ts +137 -17
- package/src/domain/skill-search.ts +221 -0
- package/src/domain/skills.ts +104 -0
- package/src/domain/slack-bot.ts +128 -3
- package/src/domain/workspace-members.ts +34 -2
- package/src/editable-artifact-live/application.ts +29 -4
- package/src/editable-artifact-live/ports.ts +0 -1
- package/src/editable-artifact-live/server.ts +167 -13
- package/src/editable-artifact-live/ticket.ts +87 -6
- package/src/editable-artifact-live/types.ts +16 -2
- package/src/editable-artifact-live/wire.ts +0 -10
- package/src/index.ts +69 -4
- package/src/managed-auth-session-sets.ts +371 -0
- package/src/managed-session.ts +520 -2
- package/src/model-catalog.ts +873 -0
- package/src/product-usage-metrics.ts +101 -0
- package/src/rigs/index.ts +179 -63
- package/src/rigs/provider-images.ts +2 -3
- package/src/sandbox/fleet.ts +471 -59
- package/src/sandbox/routing.ts +160 -33
- package/src/sandbox/runtime-settings.ts +281 -0
- package/src/session-authorization.ts +262 -78
- package/src/site-session-origin.ts +22 -0
- package/src/transcription.ts +13 -1
- package/src/turn-surface.ts +45 -0
- package/src/user-presence.ts +134 -0
- package/dist/chunk-6WKPWE5S.js.map +0 -1
- package/dist/chunk-IBOEYG6N.js +0 -34
- package/dist/chunk-IBOEYG6N.js.map +0 -1
- package/dist/chunk-JJU6I5XD.js.map +0 -1
- package/dist/chunk-NYPMQ7NO.js.map +0 -1
- package/dist/domain/durable-learning-slack-publication.d.ts +0 -72
- package/dist/domain/packs.d.ts +0 -55
- package/src/domain/durable-learning-slack-publication.ts +0 -202
- package/src/domain/packs.ts +0 -556
package/src/access/index.ts
CHANGED
|
@@ -1,33 +1,614 @@
|
|
|
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,
|
|
10
|
+
DEVELOPER_SETUP_API_KEY_PRESET,
|
|
11
|
+
organizationAccessPresetPermissions,
|
|
4
12
|
type AccountGrant,
|
|
5
13
|
type AccessContext,
|
|
6
14
|
type AccessGrant,
|
|
7
|
-
type
|
|
15
|
+
type OrganizationApiKeyAccess,
|
|
16
|
+
OrganizationWorkspaceScope,
|
|
17
|
+
OPENGENI_USER_ACTIVITY_ACTIVE,
|
|
18
|
+
OPENGENI_USER_ACTIVITY_HEADER,
|
|
19
|
+
Permission,
|
|
20
|
+
type Workspace,
|
|
8
21
|
} from "@opengeni/contracts";
|
|
9
22
|
import {
|
|
10
23
|
bootstrapWorkspace,
|
|
11
24
|
ensureManagedAccessForUser,
|
|
25
|
+
getManagedUserProfilesByIds,
|
|
26
|
+
ensureExternalIdentity,
|
|
27
|
+
ensureExternalWorkspaceMemberOnFirstUse,
|
|
28
|
+
USER_ISOLATION_WORKSPACE_SOURCE_PREFIX,
|
|
29
|
+
lockExternalWorkspaceMembershipLifecycle,
|
|
30
|
+
resolveExternalIdentityLink,
|
|
31
|
+
managedPersonalWorkspacePermissions,
|
|
32
|
+
nestedPostgresSqlState,
|
|
33
|
+
withWorkspaceSubjectRls,
|
|
34
|
+
withAccountRls,
|
|
35
|
+
listWorkspacesForSubject,
|
|
12
36
|
findActiveApiKeyByHash,
|
|
13
37
|
getWorkspaceGrant,
|
|
14
38
|
requireWorkspace,
|
|
39
|
+
resolveNamedManagedPersonalWorkspaceGrant,
|
|
15
40
|
type Database,
|
|
16
41
|
} from "@opengeni/db";
|
|
17
42
|
import type { Context } from "hono";
|
|
18
43
|
import { HTTPException } from "hono/http-exception";
|
|
44
|
+
import { z } from "zod";
|
|
19
45
|
import type { ManagedAuth } from "../managed-auth-type";
|
|
20
46
|
import { getManagedSession } from "../managed-session";
|
|
47
|
+
import type { ManagedAuthSessionAdapter } from "../managed-auth-session-sets";
|
|
48
|
+
import type { UserPresenceRecorder } from "../user-presence";
|
|
49
|
+
import { serviceInitiatorFromHeaders } from "./service-initiator";
|
|
21
50
|
|
|
22
51
|
const bearerPrefix = "Bearer ";
|
|
23
52
|
const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null>>();
|
|
53
|
+
const accessResolvedRequests = new WeakSet<Request>();
|
|
54
|
+
const developerSetupApiKeyContexts = new WeakSet<AccessContext>();
|
|
55
|
+
const developerSetupAuthorizations = new WeakMap<AccessGrantAuthorization, AccessGrant>();
|
|
56
|
+
const developerSetupGrants = new WeakSet<AccessGrant>();
|
|
57
|
+
// Request-local permission semantics. The durable grant carries permissionMode;
|
|
58
|
+
// callers of the existing array API cannot accidentally expand a policy admin.
|
|
59
|
+
const explicitPermissionSets = new WeakSet<readonly Permission[]>();
|
|
60
|
+
function explicitPermissions(permissions: Permission[]): Permission[] {
|
|
61
|
+
explicitPermissionSets.add(permissions);
|
|
62
|
+
return permissions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Only canonical authentication can prove setup-key provenance. */
|
|
66
|
+
export function isDeveloperSetupApiKeyContext(context: AccessContext): boolean {
|
|
67
|
+
return (
|
|
68
|
+
developerSetupApiKeyContexts.has(context) &&
|
|
69
|
+
accountScopedApiKeyWorkspaceAuthority(context) !== null
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Provenance from canonical raw-key or verified restricted-token authentication. */
|
|
74
|
+
export function isDeveloperSetupAuthorization(
|
|
75
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
76
|
+
): boolean {
|
|
77
|
+
return (
|
|
78
|
+
authorization !== undefined &&
|
|
79
|
+
developerSetupAuthorizations.get(authorization) === authorization.grant
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Exact grant objects resolved under authenticated setup-only provenance. */
|
|
84
|
+
export function isDeveloperSetupGrant(grant: AccessGrant): boolean {
|
|
85
|
+
return developerSetupGrants.has(grant);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Setup-derived credentials cannot carry literal organization or key authority. */
|
|
89
|
+
export function isDeveloperSetupDelegatedPermissionAllowed(permission: Permission): boolean {
|
|
90
|
+
return (
|
|
91
|
+
permission !== "secrets:read" &&
|
|
92
|
+
permission !== "api_keys:manage" &&
|
|
93
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Canonical authentication provenance, including the backing key of asUser. */
|
|
98
|
+
export function requireApiKeyManagementContext(context: AccessContext): void {
|
|
99
|
+
if (developerSetupApiKeyContexts.has(context)) {
|
|
100
|
+
throw new HTTPException(403, { message: "Developer setup keys cannot manage API keys" });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Membership is not an escape hatch for minting durable credentials. */
|
|
105
|
+
export function requireApiKeyDelegationContext(
|
|
106
|
+
context: AccessContext,
|
|
107
|
+
permissions: Permission[],
|
|
108
|
+
): void {
|
|
109
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
110
|
+
const external = externalActorContexts.get(context);
|
|
111
|
+
const ceiling = authority ?? external;
|
|
112
|
+
if (
|
|
113
|
+
ceiling?.permissionMode === "explicit" &&
|
|
114
|
+
permissions.some((permission) => !ceiling.permissions.includes(permission))
|
|
115
|
+
) {
|
|
116
|
+
throw new HTTPException(403, {
|
|
117
|
+
message: "cannot delegate a permission outside the organization key policy",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
ceiling?.permissionMode === "explicit" &&
|
|
122
|
+
permissions.includes("workspace:admin") &&
|
|
123
|
+
organizationAccessPresetPermissions("full").some(
|
|
124
|
+
(permission) =>
|
|
125
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
126
|
+
!ceiling.permissions.includes(permission),
|
|
127
|
+
)
|
|
128
|
+
) {
|
|
129
|
+
throw new HTTPException(403, {
|
|
130
|
+
message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
(hasPermission(permissions, "api_keys:manage") ||
|
|
135
|
+
hasPermission(permissions, "members:manage")) &&
|
|
136
|
+
developerSetupApiKeyContexts.has(context)
|
|
137
|
+
) {
|
|
138
|
+
throw new HTTPException(403, {
|
|
139
|
+
message: "Setup credentials cannot delegate API key management",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Contexts that were authenticated by a verified canonical managed cookie
|
|
146
|
+
* (Better Auth session), stamped at the single branch of
|
|
147
|
+
* {@link resolveAccessContext} that performs that verification.
|
|
148
|
+
*
|
|
149
|
+
* This is provenance — HOW the request authenticated — not a shape check on a
|
|
150
|
+
* value. It cannot be forged by a token claim, and it FAILS CLOSED by default:
|
|
151
|
+
* membership is only ever added at that one branch, so every other path
|
|
152
|
+
* (delegated bearer, API key, local bootstrap, configured key, and any path
|
|
153
|
+
* added later) is absent from the set without anyone maintaining a list. A
|
|
154
|
+
* request that never resolves an access context has no context to test at all.
|
|
155
|
+
*
|
|
156
|
+
* Keyed on the resolved `AccessContext` object identity, deliberately NOT on
|
|
157
|
+
* the `Request`: a reused, wrapped, or retried request object can therefore
|
|
158
|
+
* never carry a stale positive into a differently-authenticated resolution, and
|
|
159
|
+
* the non-cached `requireFreshAccessGrant` path is stamped correctly too. Each
|
|
160
|
+
* resolution produces a fresh context object, so the mark travels with exactly
|
|
161
|
+
* the value the cookie branch produced.
|
|
162
|
+
*/
|
|
163
|
+
const canonicalManagedCookieContexts = new WeakSet<AccessContext>();
|
|
164
|
+
/** Downstream legacy credentials cannot encode a partially selected admin. */
|
|
165
|
+
export function requireExplicitPermissionDelegation(
|
|
166
|
+
grant: Pick<AccessGrant, "permissions" | "permissionMode">,
|
|
167
|
+
requested: Permission[],
|
|
168
|
+
): void {
|
|
169
|
+
if (
|
|
170
|
+
grant.permissionMode === "explicit" &&
|
|
171
|
+
requested.some((permission) => !grant.permissions.includes(permission))
|
|
172
|
+
) {
|
|
173
|
+
throw new HTTPException(403, {
|
|
174
|
+
message: "cannot delegate a permission outside the organization key policy",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
grant.permissionMode === "explicit" &&
|
|
179
|
+
requested.includes("workspace:admin") &&
|
|
180
|
+
organizationAccessPresetPermissions("full").some(
|
|
181
|
+
(permission) =>
|
|
182
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
183
|
+
!grant.permissions.includes(permission),
|
|
184
|
+
)
|
|
185
|
+
) {
|
|
186
|
+
throw new HTTPException(403, {
|
|
187
|
+
message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const canonicalLocalHumanContexts = new WeakSet<AccessContext>();
|
|
192
|
+
|
|
193
|
+
export type DelegatedHumanAuthorization = Readonly<{
|
|
194
|
+
organizationId: string;
|
|
195
|
+
/** Exact native person subject, never an external identity or service. */
|
|
196
|
+
subjectId: string;
|
|
197
|
+
permissions: Permission[];
|
|
198
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
199
|
+
}>;
|
|
200
|
+
|
|
201
|
+
const delegatedHumanAuthorizationSchema = z.object({
|
|
202
|
+
organizationId: z.string().uuid(),
|
|
203
|
+
subjectId: z
|
|
204
|
+
.string()
|
|
205
|
+
.max(1024)
|
|
206
|
+
.regex(/^user:[^\s\u0000-\u001f\u007f]+$/),
|
|
207
|
+
permissions: z.array(Permission),
|
|
208
|
+
workspaceScope: OrganizationWorkspaceScope,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const delegatedHumanRequests = new WeakMap<Request, DelegatedHumanAuthorization>();
|
|
212
|
+
const delegatedHumanContexts = new WeakMap<AccessContext, DelegatedHumanAuthorization>();
|
|
213
|
+
const delegatedHumanProfileSchema = z.object({
|
|
214
|
+
id: z.string(),
|
|
215
|
+
name: z.string().nullable(),
|
|
216
|
+
email: z.string(),
|
|
217
|
+
emailVerified: z.boolean().optional(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
export type VerifiedDelegatedHumanContext = Readonly<{
|
|
221
|
+
/** Native identity projection only, never a browser/auth session. Absence of
|
|
222
|
+
* emailVerified means the native lookup did not prove email verification. */
|
|
223
|
+
user: Readonly<z.infer<typeof delegatedHumanProfileSchema>>;
|
|
224
|
+
subjectId: string;
|
|
225
|
+
authorization: DelegatedHumanAuthorization;
|
|
226
|
+
context: AccessContext;
|
|
227
|
+
}>;
|
|
228
|
+
|
|
229
|
+
const delegatedHumanProfiles = new WeakMap<AccessContext, VerifiedDelegatedHumanContext["user"]>();
|
|
230
|
+
const verifiedDelegatedHumanAuthorizations = new WeakMap<
|
|
231
|
+
AccessGrantAuthorization,
|
|
232
|
+
{
|
|
233
|
+
proof: DelegatedHumanAuthorization;
|
|
234
|
+
grant: AccessGrant;
|
|
235
|
+
accountGrant: AccountGrant;
|
|
236
|
+
}
|
|
237
|
+
>();
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* TRUSTED OAUTH VERIFIED DISPATCH ONLY. Before each stamp the in-process caller
|
|
241
|
+
* must validate the live OAuth grant, revocation, exact native person subject,
|
|
242
|
+
* organization, permission ceilings and workspace scope. This API does not
|
|
243
|
+
* verify OAuth and must NEVER be called with headers, metadata, token claims,
|
|
244
|
+
* a previous request's proof, or other unverified caller input.
|
|
245
|
+
*
|
|
246
|
+
* Stamp the exact raw Request that the actions dispatcher will pass to routes,
|
|
247
|
+
* before any access resolution. Cloning/wrapping a Request does not copy proof.
|
|
248
|
+
* The resolver reloads the real native person's live access on this request
|
|
249
|
+
* (and on fresh reauthorization); no session or cached grant is fabricated or
|
|
250
|
+
* transplanted. This proof never becomes managed-cookie/person-present proof.
|
|
251
|
+
*/
|
|
252
|
+
export function stampDelegatedHumanAuthorization(
|
|
253
|
+
request: Request,
|
|
254
|
+
input: DelegatedHumanAuthorization,
|
|
255
|
+
): void {
|
|
256
|
+
if (delegatedHumanRequests.has(request) || accessResolvedRequests.has(request)) {
|
|
257
|
+
throw new HTTPException(403, { message: "request access authorization already resolved" });
|
|
258
|
+
}
|
|
259
|
+
const parsed = delegatedHumanAuthorizationSchema.safeParse(input);
|
|
260
|
+
if (!parsed.success) {
|
|
261
|
+
throw new HTTPException(403, { message: "invalid delegated human authorization" });
|
|
262
|
+
}
|
|
263
|
+
const verified = parsed.data;
|
|
264
|
+
const workspaceScope: DelegatedHumanAuthorization["workspaceScope"] =
|
|
265
|
+
verified.workspaceScope.kind === "all"
|
|
266
|
+
? { kind: "all" }
|
|
267
|
+
: { kind: "selected", workspaceIds: [...verified.workspaceScope.workspaceIds] };
|
|
268
|
+
if (workspaceScope.kind === "selected") Object.freeze(workspaceScope.workspaceIds);
|
|
269
|
+
Object.freeze(workspaceScope);
|
|
270
|
+
const proof: DelegatedHumanAuthorization = {
|
|
271
|
+
organizationId: verified.organizationId,
|
|
272
|
+
subjectId: verified.subjectId,
|
|
273
|
+
permissions: [...new Set(verified.permissions)],
|
|
274
|
+
workspaceScope,
|
|
275
|
+
};
|
|
276
|
+
Object.freeze(proof.permissions);
|
|
277
|
+
delegatedHumanRequests.set(request, Object.freeze(proof));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Immutable verified dispatch bounds for this exact raw Request, not a grant. */
|
|
281
|
+
export function verifiedDelegatedHumanAuthorizationForRequest(
|
|
282
|
+
request: Request,
|
|
283
|
+
): DelegatedHumanAuthorization | null {
|
|
284
|
+
return delegatedHumanRequests.get(request) ?? null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve the exact verified OAuth-dispatch person and constrained live native
|
|
289
|
+
* access for this raw Request. Unstamped requests (even browser cookies) fail
|
|
290
|
+
* closed; callers must not fall back to a service or subject-shaped identity.
|
|
291
|
+
* Uses the same request-local native resolution as requireAccessContext.
|
|
292
|
+
*
|
|
293
|
+
* No session, session id or managed-cookie proof is created. emailVerified is
|
|
294
|
+
* forwarded only when the native profile lookup supplies it; missing is not
|
|
295
|
+
* verified and must never be filled from OAuth claims, headers or metadata.
|
|
296
|
+
*/
|
|
297
|
+
export async function requireVerifiedDelegatedHumanContext(
|
|
298
|
+
c: Context,
|
|
299
|
+
deps: AccessDeps,
|
|
300
|
+
): Promise<VerifiedDelegatedHumanContext> {
|
|
301
|
+
const authorization = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
|
|
302
|
+
if (!authorization) {
|
|
303
|
+
throw new HTTPException(401, { message: "verified delegated human authorization required" });
|
|
304
|
+
}
|
|
305
|
+
const context = await requireAccessContext(c, deps);
|
|
306
|
+
const user = delegatedHumanProfiles.get(context);
|
|
307
|
+
if (delegatedHumanContexts.get(context) !== authorization || !user) {
|
|
308
|
+
throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
|
|
309
|
+
}
|
|
310
|
+
return Object.freeze({ user, subjectId: context.subjectId, authorization, context });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Exact resolved owning-person authorization; clones and service claims fail.
|
|
314
|
+
* Supplying the raw Request additionally binds proof to that exact dispatch,
|
|
315
|
+
* not another request with the same native person and authorization bounds. */
|
|
316
|
+
export function isVerifiedDelegatedHumanAuthorization(
|
|
317
|
+
authorization: AccessGrantAuthorization,
|
|
318
|
+
request?: Request,
|
|
319
|
+
): boolean {
|
|
320
|
+
const verified = verifiedDelegatedHumanAuthorizations.get(authorization);
|
|
321
|
+
return Boolean(
|
|
322
|
+
verified &&
|
|
323
|
+
(request === undefined || verified.proof === delegatedHumanRequests.get(request)) &&
|
|
324
|
+
authorization.contextIntegrity &&
|
|
325
|
+
!authorization.canonicalManagedHumanSession &&
|
|
326
|
+
!authorization.canonicalLocalHumanSession &&
|
|
327
|
+
authorization.grant === verified.grant &&
|
|
328
|
+
authorization.accountGrant === verified.accountGrant &&
|
|
329
|
+
authorization.authenticatedSubjectId === verified.proof.subjectId &&
|
|
330
|
+
verified.grant.subjectId === verified.proof.subjectId &&
|
|
331
|
+
verified.grant.accountId === verified.proof.organizationId &&
|
|
332
|
+
delegatedHumanWorkspaceAllowed(verified.proof, verified.grant.workspaceId),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function delegatedHumanWorkspaceAllowed(
|
|
337
|
+
proof: DelegatedHumanAuthorization,
|
|
338
|
+
workspaceId: string,
|
|
339
|
+
): boolean {
|
|
340
|
+
return (
|
|
341
|
+
proof.workspaceScope.kind === "all" || proof.workspaceScope.workspaceIds.includes(workspaceId)
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const externalActorContexts = new WeakMap<
|
|
345
|
+
AccessContext,
|
|
346
|
+
{
|
|
347
|
+
identity: ExternalIdentity;
|
|
348
|
+
keyId: string;
|
|
349
|
+
permissions: Permission[];
|
|
350
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
351
|
+
permissionMode: "legacy" | "explicit";
|
|
352
|
+
linked?: NonNullable<Awaited<ReturnType<typeof resolveExternalIdentityLink>>>;
|
|
353
|
+
/**
|
|
354
|
+
* Plain external mode (no native link, no service-initiator attribution):
|
|
355
|
+
* the only lane where a missing shared-workspace membership may be created
|
|
356
|
+
* on first use. See {@link provisionExternalMemberOnFirstUse}.
|
|
357
|
+
*/
|
|
358
|
+
firstUseMembership: boolean;
|
|
359
|
+
}
|
|
360
|
+
>();
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Default permissions for a membership created on an external user's first
|
|
364
|
+
* request. Keep equal to `CONVERSATION_PERMISSIONS` in
|
|
365
|
+
* `packages/sdk/src/tenant-workspaces.ts`: conversation use only, no admin.
|
|
366
|
+
*/
|
|
367
|
+
export const EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS: readonly Permission[] = [
|
|
368
|
+
"workspace:read",
|
|
369
|
+
"sessions:create",
|
|
370
|
+
"sessions:read",
|
|
371
|
+
"sessions:control",
|
|
372
|
+
"files:upload",
|
|
373
|
+
"files:read",
|
|
374
|
+
"mcp_servers:attach",
|
|
375
|
+
];
|
|
376
|
+
function attributionForExternalContext(context: AccessContext): ExternalActorAttribution {
|
|
377
|
+
const external = externalActorContexts.get(context);
|
|
378
|
+
if (!external) throw new Error("Verified external context required");
|
|
379
|
+
return ExternalActorAttribution.parse({
|
|
380
|
+
accountId: external.identity.accountId,
|
|
381
|
+
authenticatingApiKeyId: external.keyId,
|
|
382
|
+
externalIdentityId: external.identity.id,
|
|
383
|
+
externalSubjectId: external.identity.subjectId,
|
|
384
|
+
externalAuthorizationRevision: external.identity.authorizationRevision,
|
|
385
|
+
effectiveSubjectId: context.subjectId,
|
|
386
|
+
actingMode: external.linked ? "linked_native" : "external",
|
|
387
|
+
...(external.linked
|
|
388
|
+
? { linkId: external.linked.link.id, linkRevision: external.linked.link.revision }
|
|
389
|
+
: {}),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const resolvedAccessGrantAuthorizations = new WeakSet<object>();
|
|
393
|
+
const verifiedExternalAuthorizations = new WeakMap<
|
|
394
|
+
AccessGrantAuthorization,
|
|
395
|
+
{
|
|
396
|
+
grant: AccessGrant;
|
|
397
|
+
workspaceId: string;
|
|
398
|
+
workspaceSettingsCeiling: boolean;
|
|
399
|
+
identityReference: { externalId: string; source: string };
|
|
400
|
+
attribution: ExternalActorAttribution;
|
|
401
|
+
}
|
|
402
|
+
>();
|
|
403
|
+
|
|
404
|
+
/** Creation audit only, never an authentication or delegation token. Metadata
|
|
405
|
+
* claiming to be external is insufficient; the exact resolver proof is needed. */
|
|
406
|
+
export function externalAttributionForAuthorization(
|
|
407
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
408
|
+
grant: AccessGrant,
|
|
409
|
+
): ExternalActorAttribution | null {
|
|
410
|
+
if (!authorization || authorization.grant !== grant) return null;
|
|
411
|
+
const verified = verifiedExternalAuthorizations.get(authorization);
|
|
412
|
+
if (
|
|
413
|
+
!verified ||
|
|
414
|
+
verified.grant !== grant ||
|
|
415
|
+
verified.workspaceId !== grant.workspaceId ||
|
|
416
|
+
verified.attribution.accountId !== grant.accountId ||
|
|
417
|
+
verified.attribution.effectiveSubjectId !== grant.subjectId
|
|
418
|
+
)
|
|
419
|
+
return null;
|
|
420
|
+
return structuredClone(verified.attribution);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function externalActorContinuationForAuthorization(
|
|
424
|
+
authorization: AccessGrantAuthorization,
|
|
425
|
+
): ExternalActorContinuation | null {
|
|
426
|
+
const actor = externalAttributionForAuthorization(authorization, authorization.grant);
|
|
427
|
+
const verified = verifiedExternalAuthorizations.get(authorization);
|
|
428
|
+
return actor && verified ? { actor, identity: { ...verified.identityReference } } : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Dedicated owning-user proof. External/OAuth admission never sets the native
|
|
432
|
+
* cookie stamp. The resource/session layer still checks the exact owner. */
|
|
433
|
+
export function hasVerifiedOwningUserAuthorization(
|
|
434
|
+
authorization: AccessGrantAuthorization,
|
|
435
|
+
): boolean {
|
|
436
|
+
if (
|
|
437
|
+
!authorization.contextIntegrity ||
|
|
438
|
+
authorization.authenticatedSubjectId !== authorization.grant.subjectId
|
|
439
|
+
)
|
|
440
|
+
return false;
|
|
441
|
+
return (
|
|
442
|
+
authorization.canonicalManagedHumanSession ||
|
|
443
|
+
externalAttributionForAuthorization(authorization, authorization.grant) !== null ||
|
|
444
|
+
isVerifiedDelegatedHumanAuthorization(authorization)
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const accountScopedApiKeyContexts = new WeakMap<
|
|
448
|
+
AccessContext,
|
|
449
|
+
Readonly<{
|
|
450
|
+
accountId: string;
|
|
451
|
+
permissions: readonly Permission[];
|
|
452
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
453
|
+
permissionMode: "legacy" | "explicit";
|
|
454
|
+
}>
|
|
455
|
+
>();
|
|
456
|
+
const apiKeyServiceContexts = new WeakMap<
|
|
457
|
+
AccessContext,
|
|
458
|
+
Pick<AccessGrant, "serviceInitiator" | "serviceInitiatorContext">
|
|
459
|
+
>();
|
|
460
|
+
const verifiedOrganizationServiceAuthorizations = new WeakMap<
|
|
461
|
+
AccessGrantAuthorization,
|
|
462
|
+
AccessGrant
|
|
463
|
+
>();
|
|
464
|
+
|
|
465
|
+
/** Request-local organization service-key proof, not a principal-kind claim. */
|
|
466
|
+
export function isVerifiedOrganizationServiceAuthorization(
|
|
467
|
+
authorization: AccessGrantAuthorization,
|
|
468
|
+
): boolean {
|
|
469
|
+
return (
|
|
470
|
+
verifiedOrganizationServiceAuthorizations.has(authorization) &&
|
|
471
|
+
verifiedOrganizationServiceAuthorizations.get(authorization) === authorization.grant
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const accountScopedApiKeyAccountPermissions = new Set<Permission>([
|
|
476
|
+
"account:read",
|
|
477
|
+
"account:admin",
|
|
478
|
+
"workspace:create",
|
|
479
|
+
"billing:read",
|
|
480
|
+
"billing:manage",
|
|
481
|
+
"api_keys:manage",
|
|
482
|
+
"usage_allowances:manage",
|
|
483
|
+
]);
|
|
484
|
+
const accountScopedApiKeyWorkspaceExcludedPermissions = new Set<Permission>([
|
|
485
|
+
"account:read",
|
|
486
|
+
"account:admin",
|
|
487
|
+
"workspace:create",
|
|
488
|
+
"billing:read",
|
|
489
|
+
"billing:manage",
|
|
490
|
+
"usage_allowances:manage",
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
export type AccountScopedApiKeyWorkspaceAuthority = Readonly<{
|
|
494
|
+
accountId: string;
|
|
495
|
+
permissions: Permission[];
|
|
496
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
497
|
+
permissionMode: "legacy" | "explicit";
|
|
498
|
+
}>;
|
|
499
|
+
|
|
500
|
+
export function organizationWorkspaceInScope(
|
|
501
|
+
scope: OrganizationWorkspaceScope,
|
|
502
|
+
workspaceId: string,
|
|
503
|
+
): boolean {
|
|
504
|
+
return scope.kind === "all" || scope.workspaceIds.includes(workspaceId.toLowerCase());
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Return account-scoped API-key workspace authority only for the exact
|
|
509
|
+
* AccessContext object stamped by successful API-key authentication. Subject
|
|
510
|
+
* shape alone is deliberately insufficient.
|
|
511
|
+
*/
|
|
512
|
+
export function accountScopedApiKeyWorkspaceAuthority(
|
|
513
|
+
context: AccessContext,
|
|
514
|
+
): AccountScopedApiKeyWorkspaceAuthority | null {
|
|
515
|
+
const authority = accountScopedApiKeyContexts.get(context);
|
|
516
|
+
if (!authority) return null;
|
|
517
|
+
const matchingAccountGrants = context.accountGrants.filter(
|
|
518
|
+
(grant) => grant.accountId === authority.accountId && grant.subjectId === context.subjectId,
|
|
519
|
+
);
|
|
520
|
+
if (
|
|
521
|
+
!context.subjectId.startsWith("api_key:") ||
|
|
522
|
+
matchingAccountGrants.length !== 1 ||
|
|
523
|
+
context.defaultAccountId !== authority.accountId ||
|
|
524
|
+
context.defaultWorkspaceId !== null
|
|
525
|
+
) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
accountId: authority.accountId,
|
|
530
|
+
permissions:
|
|
531
|
+
authority.permissionMode === "explicit"
|
|
532
|
+
? explicitPermissions([...authority.permissions])
|
|
533
|
+
: [...authority.permissions],
|
|
534
|
+
workspaceScope: authority.workspaceScope,
|
|
535
|
+
permissionMode: authority.permissionMode,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Classify stored scopes without changing the legacy full/read permission sets. */
|
|
540
|
+
export function organizationApiKeyAccess(permissions: Permission[]): OrganizationApiKeyAccess {
|
|
541
|
+
if (
|
|
542
|
+
permissions.length === DEVELOPER_SETUP_API_KEY_PRESET.permissions.length &&
|
|
543
|
+
DEVELOPER_SETUP_API_KEY_PRESET.permissions.every((permission) =>
|
|
544
|
+
permissions.includes(permission),
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
return "developer_setup";
|
|
548
|
+
return permissions.includes("workspace:admin") ? "full" : "read";
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Opaque, request-local proof that the canonical access resolver authorized the
|
|
553
|
+
* exact account administrator named by the stamp. The random id is audit and
|
|
554
|
+
* replay provenance only; object identity is what prevents a caller from
|
|
555
|
+
* manufacturing this proof inside the API process.
|
|
556
|
+
*/
|
|
557
|
+
export type AccountAdminAuthorizationStamp = Readonly<{
|
|
558
|
+
authorizationId: string;
|
|
559
|
+
accountId: string;
|
|
560
|
+
actorSubjectId: string;
|
|
561
|
+
permission: "account:admin";
|
|
562
|
+
}>;
|
|
24
563
|
|
|
25
564
|
export type AccessDeps = {
|
|
26
565
|
db: Database;
|
|
27
566
|
settings: Settings;
|
|
28
567
|
managedAuth?: ManagedAuth | null;
|
|
568
|
+
managedAuthSessionAdapter?: ManagedAuthSessionAdapter | null;
|
|
569
|
+
/** Analytics only: notes canonical managed-cookie activity, never authority. */
|
|
570
|
+
userPresence?: UserPresenceRecorder | null;
|
|
29
571
|
};
|
|
30
572
|
|
|
573
|
+
/** null means this is not an authenticated external lane; [] means that lane
|
|
574
|
+
* has no readable inventory. Callers must not fall back from [] to service or
|
|
575
|
+
* bare-subject discovery. */
|
|
576
|
+
export async function listExternalActorWorkspaces(
|
|
577
|
+
context: AccessContext,
|
|
578
|
+
deps: AccessDeps,
|
|
579
|
+
): Promise<Workspace[] | null> {
|
|
580
|
+
const actor = externalActorContexts.get(context);
|
|
581
|
+
if (!actor) return null;
|
|
582
|
+
if (
|
|
583
|
+
!hasPermission(actor.permissions, "workspace:read") ||
|
|
584
|
+
(actor.linked && !hasPermission(actor.linked.link.permissions, "workspace:read"))
|
|
585
|
+
)
|
|
586
|
+
return [];
|
|
587
|
+
const candidates = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
|
|
588
|
+
listWorkspacesForSubject(tx, context.subjectId),
|
|
589
|
+
);
|
|
590
|
+
const authorized: Workspace[] = [];
|
|
591
|
+
const personal = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
|
|
592
|
+
requireWorkspace(tx, actor.linked?.personalWorkspaceId ?? actor.identity.personalWorkspaceId),
|
|
593
|
+
);
|
|
594
|
+
if (
|
|
595
|
+
personal.accountId === actor.identity.accountId &&
|
|
596
|
+
personal.kind === "personal" &&
|
|
597
|
+
actor.permissionMode === "legacy" &&
|
|
598
|
+
organizationWorkspaceInScope(actor.workspaceScope, personal.id)
|
|
599
|
+
)
|
|
600
|
+
authorized.push(personal);
|
|
601
|
+
for (const workspace of candidates) {
|
|
602
|
+
if (workspace.accountId !== actor.identity.accountId || workspace.kind !== "shared") continue;
|
|
603
|
+
if (!organizationWorkspaceInScope(actor.workspaceScope, workspace.id)) continue;
|
|
604
|
+
const grant = await withWorkspaceSubjectRls(deps.db, workspace.id, context.subjectId, (tx) =>
|
|
605
|
+
getWorkspaceGrant(tx, context.subjectId, workspace.id),
|
|
606
|
+
);
|
|
607
|
+
if (grant && hasPermission(grant.permissions, "workspace:read")) authorized.push(workspace);
|
|
608
|
+
}
|
|
609
|
+
return authorized;
|
|
610
|
+
}
|
|
611
|
+
|
|
31
612
|
export async function requireAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext> {
|
|
32
613
|
let pending = accessContextByRequest.get(c.req.raw);
|
|
33
614
|
if (!pending) {
|
|
@@ -50,11 +631,41 @@ export async function requireAccessGrant(
|
|
|
50
631
|
return (await requireAccessGrantAuthorization(c, deps, workspaceId, permission)).grant;
|
|
51
632
|
}
|
|
52
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Re-resolve the authenticated principal and its current grants without using
|
|
636
|
+
* the request-local access cache. Long-lived responses use this to ensure a
|
|
637
|
+
* membership suspension or revocation takes effect while the connection is
|
|
638
|
+
* still open.
|
|
639
|
+
*/
|
|
640
|
+
export async function requireFreshAccessGrant(
|
|
641
|
+
c: Context,
|
|
642
|
+
deps: AccessDeps,
|
|
643
|
+
workspaceId: string,
|
|
644
|
+
permission?: Permission,
|
|
645
|
+
): Promise<AccessGrant> {
|
|
646
|
+
const context = await resolveAccessContext(c, deps);
|
|
647
|
+
if (!context) {
|
|
648
|
+
throw new HTTPException(401, { message: "authentication required" });
|
|
649
|
+
}
|
|
650
|
+
return (await accessGrantAuthorization(context, deps, workspaceId, permission)).grant;
|
|
651
|
+
}
|
|
652
|
+
|
|
53
653
|
export type AccessGrantAuthorization = {
|
|
54
654
|
grant: AccessGrant;
|
|
55
655
|
accountGrant: AccountGrant | null;
|
|
56
656
|
authenticatedSubjectId: string;
|
|
57
657
|
contextIntegrity: boolean;
|
|
658
|
+
/**
|
|
659
|
+
* Did this request authenticate as the canonical managed-cookie (Better Auth)
|
|
660
|
+
* session that OWNS this grant's subject? Cookie/person-present ceremonies
|
|
661
|
+
* retain this strict gate; owning-user boundaries use
|
|
662
|
+
* `hasVerifiedOwningUserAuthorization` instead.
|
|
663
|
+
* False for every bearer, API-key, delegated, service, local, and configured
|
|
664
|
+
* principal, and for any future path that does not verify a cookie.
|
|
665
|
+
*/
|
|
666
|
+
canonicalManagedHumanSession: boolean;
|
|
667
|
+
/** Exact in-process single-user local bootstrap, never a delegated bearer. */
|
|
668
|
+
canonicalLocalHumanSession: boolean;
|
|
58
669
|
};
|
|
59
670
|
|
|
60
671
|
export function accessGrantAuthorizationFromContext(
|
|
@@ -81,12 +692,150 @@ export function accessGrantAuthorizationFromContext(
|
|
|
81
692
|
) &&
|
|
82
693
|
matchingAccountGrants.length === 1 &&
|
|
83
694
|
matchingAccountGrants[0]?.subjectId === context.subjectId;
|
|
84
|
-
|
|
695
|
+
const authorization: AccessGrantAuthorization = {
|
|
85
696
|
grant,
|
|
86
697
|
accountGrant: contextIntegrity ? matchingAccountGrants[0]! : null,
|
|
87
698
|
authenticatedSubjectId: context.subjectId,
|
|
88
699
|
contextIntegrity,
|
|
700
|
+
canonicalManagedHumanSession: isCanonicalManagedHumanSession(context, grant),
|
|
701
|
+
canonicalLocalHumanSession: isCanonicalLocalHumanSession(context, grant),
|
|
89
702
|
};
|
|
703
|
+
resolvedAccessGrantAuthorizations.add(authorization);
|
|
704
|
+
if (contextIntegrity && developerSetupApiKeyContexts.has(context)) {
|
|
705
|
+
developerSetupAuthorizations.set(authorization, grant);
|
|
706
|
+
developerSetupGrants.add(grant);
|
|
707
|
+
}
|
|
708
|
+
if (
|
|
709
|
+
contextIntegrity &&
|
|
710
|
+
accountScopedApiKeyWorkspaceAuthority(context)?.accountId === grant.accountId &&
|
|
711
|
+
grant.principalKind === "api_key"
|
|
712
|
+
) {
|
|
713
|
+
verifiedOrganizationServiceAuthorizations.set(authorization, grant);
|
|
714
|
+
}
|
|
715
|
+
const external = externalActorContexts.get(context);
|
|
716
|
+
if (external && contextIntegrity && grant.accountId === external.identity.accountId) {
|
|
717
|
+
verifiedExternalAuthorizations.set(authorization, {
|
|
718
|
+
grant,
|
|
719
|
+
workspaceId: grant.workspaceId,
|
|
720
|
+
workspaceSettingsCeiling:
|
|
721
|
+
hasPermission(external.permissions, "workspace:admin") &&
|
|
722
|
+
(!external.linked || hasPermission(external.linked.link.permissions, "workspace:admin")),
|
|
723
|
+
identityReference: {
|
|
724
|
+
externalId: external.identity.externalId,
|
|
725
|
+
source: external.identity.source,
|
|
726
|
+
},
|
|
727
|
+
attribution: attributionForExternalContext(context),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const delegatedHuman = delegatedHumanContexts.get(context);
|
|
731
|
+
if (
|
|
732
|
+
delegatedHuman &&
|
|
733
|
+
contextIntegrity &&
|
|
734
|
+
context.workspaceGrants.includes(grant) &&
|
|
735
|
+
grant.accountId === delegatedHuman.organizationId &&
|
|
736
|
+
grant.subjectId === delegatedHuman.subjectId &&
|
|
737
|
+
grant.principalKind === "human_session" &&
|
|
738
|
+
!grant.serviceInitiator &&
|
|
739
|
+
!grant.serviceInitiatorContext &&
|
|
740
|
+
delegatedHumanWorkspaceAllowed(delegatedHuman, grant.workspaceId)
|
|
741
|
+
) {
|
|
742
|
+
verifiedDelegatedHumanAuthorizations.set(authorization, {
|
|
743
|
+
proof: delegatedHuman,
|
|
744
|
+
grant,
|
|
745
|
+
accountGrant: matchingAccountGrants[0]!,
|
|
746
|
+
});
|
|
747
|
+
// Keep the exact proof-bearing value immutable: changing a cookie flag,
|
|
748
|
+
// subject or grant in place must not manufacture browser authentication.
|
|
749
|
+
Object.freeze(authorization);
|
|
750
|
+
}
|
|
751
|
+
return authorization;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Mint the capability passed to an account-admin database lifecycle.
|
|
756
|
+
*
|
|
757
|
+
* This deliberately accepts every canonical access mode (managed,
|
|
758
|
+
* local/configured, and signed delegation). Organization-membership rows are
|
|
759
|
+
* one possible source of account authority, not the definition of it. A plain
|
|
760
|
+
* object with matching fields is rejected because only values returned by the
|
|
761
|
+
* access resolver are present in `resolvedAccessGrantAuthorizations`.
|
|
762
|
+
*/
|
|
763
|
+
export function requireAccountAdminAuthorizationStamp(
|
|
764
|
+
authorization: AccessGrantAuthorization,
|
|
765
|
+
): AccountAdminAuthorizationStamp {
|
|
766
|
+
const { grant, accountGrant } = authorization;
|
|
767
|
+
if (
|
|
768
|
+
!resolvedAccessGrantAuthorizations.has(authorization) ||
|
|
769
|
+
!authorization.contextIntegrity ||
|
|
770
|
+
authorization.authenticatedSubjectId !== grant.subjectId ||
|
|
771
|
+
accountGrant?.accountId !== grant.accountId ||
|
|
772
|
+
accountGrant.subjectId !== grant.subjectId ||
|
|
773
|
+
!accountGrant.permissions.includes("account:admin")
|
|
774
|
+
) {
|
|
775
|
+
throw new HTTPException(403, { message: "missing permission: account:admin" });
|
|
776
|
+
}
|
|
777
|
+
return Object.freeze({
|
|
778
|
+
authorizationId: crypto.randomUUID(),
|
|
779
|
+
accountId: grant.accountId,
|
|
780
|
+
actorSubjectId: grant.subjectId,
|
|
781
|
+
permission: "account:admin" as const,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Verify that an access authorization was minted by the canonical request
|
|
787
|
+
* resolver for this exact subject and workspace.
|
|
788
|
+
*
|
|
789
|
+
* This is the protocol-neutral boundary for request-local services that need
|
|
790
|
+
* the authenticated grant rather than a caller-supplied grant-shaped object.
|
|
791
|
+
* Object identity is intentional: matching fields alone are not proof that the
|
|
792
|
+
* request authenticated the named subject.
|
|
793
|
+
*/
|
|
794
|
+
export function requireResolvedAccessGrantAuthorization(
|
|
795
|
+
authorization: AccessGrantAuthorization,
|
|
796
|
+
workspaceId: string,
|
|
797
|
+
): AccessGrant {
|
|
798
|
+
const { grant } = authorization;
|
|
799
|
+
if (
|
|
800
|
+
!resolvedAccessGrantAuthorizations.has(authorization) ||
|
|
801
|
+
!authorization.contextIntegrity ||
|
|
802
|
+
authorization.authenticatedSubjectId !== grant.subjectId ||
|
|
803
|
+
grant.workspaceId !== workspaceId
|
|
804
|
+
) {
|
|
805
|
+
throw new HTTPException(403, { message: "workspace access authorization is invalid" });
|
|
806
|
+
}
|
|
807
|
+
return grant;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Resolve the exact built-in single-user local administrator for an account.
|
|
812
|
+
*
|
|
813
|
+
* This is intentionally narrower than checking `context.mode === "local"` or
|
|
814
|
+
* the `dev` subject name. Only the in-process local bootstrap branch can place
|
|
815
|
+
* the resolved context in `canonicalLocalHumanContexts`, so delegated bearer
|
|
816
|
+
* tokens and caller-constructed contexts cannot borrow this authority.
|
|
817
|
+
*/
|
|
818
|
+
export async function requireCanonicalLocalAccountAdministrator(
|
|
819
|
+
c: Context,
|
|
820
|
+
deps: AccessDeps,
|
|
821
|
+
accountId: string,
|
|
822
|
+
): Promise<{ subjectId: string; authorization: AccessGrantAuthorization }> {
|
|
823
|
+
if (c.req.header("authorization")) {
|
|
824
|
+
throw new HTTPException(401, { message: "organization administrator session required" });
|
|
825
|
+
}
|
|
826
|
+
const context = await requireAccessContext(c, deps);
|
|
827
|
+
const grant = context.workspaceGrants.find((candidate) => candidate.accountId === accountId);
|
|
828
|
+
if (!grant) {
|
|
829
|
+
throw new HTTPException(403, {
|
|
830
|
+
message: "local organization administration is not authorized",
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
const authorization = accessGrantAuthorizationFromContext(context, grant);
|
|
834
|
+
if (!authorization.canonicalLocalHumanSession) {
|
|
835
|
+
throw new HTTPException(401, { message: "organization administrator session required" });
|
|
836
|
+
}
|
|
837
|
+
requireAccountAdminAuthorizationStamp(authorization);
|
|
838
|
+
return { subjectId: context.subjectId, authorization };
|
|
90
839
|
}
|
|
91
840
|
|
|
92
841
|
export async function requireAccessGrantAuthorization(
|
|
@@ -96,8 +845,228 @@ export async function requireAccessGrantAuthorization(
|
|
|
96
845
|
permission?: Permission,
|
|
97
846
|
): Promise<AccessGrantAuthorization> {
|
|
98
847
|
const context = await requireAccessContext(c, deps);
|
|
848
|
+
// Request entry only: a fresh re-check of a live connection never re-creates
|
|
849
|
+
// a membership that was removed while the connection was open.
|
|
850
|
+
return await accessGrantAuthorization(context, deps, workspaceId, permission, {
|
|
851
|
+
firstUseMembership: true,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Settings administration is narrower than workspace administration. The
|
|
857
|
+
* verified owning user may configure their Personal workspace, but
|
|
858
|
+
* never acquires the admin wildcard (and its membership/delegation powers).
|
|
859
|
+
* Delegated owners also need workspace:admin in their verified dispatch/key
|
|
860
|
+
* ceiling: a read-only delegation cannot turn ownership into a settings write.
|
|
861
|
+
* Only use this boundary for workspace configuration, not access management.
|
|
862
|
+
*/
|
|
863
|
+
export async function requireWorkspaceSettingsGrant(
|
|
864
|
+
c: Context,
|
|
865
|
+
deps: AccessDeps,
|
|
866
|
+
workspaceId: string,
|
|
867
|
+
): Promise<AccessGrant> {
|
|
868
|
+
const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
|
|
869
|
+
const grant = requireResolvedAccessGrantAuthorization(authorization, workspaceId);
|
|
870
|
+
if (hasPermission(grant.permissions, "workspace:admin")) return grant;
|
|
871
|
+
const delegatedHuman = verifiedDelegatedHumanAuthorizations.get(authorization);
|
|
872
|
+
const ownerSettingsCeiling =
|
|
873
|
+
authorization.canonicalManagedHumanSession ||
|
|
874
|
+
verifiedExternalAuthorizations.get(authorization)?.workspaceSettingsCeiling === true ||
|
|
875
|
+
(delegatedHuman !== undefined && delegatedHuman.proof.permissions.includes("workspace:admin"));
|
|
876
|
+
if (
|
|
877
|
+
hasVerifiedOwningUserAuthorization(authorization) &&
|
|
878
|
+
ownerSettingsCeiling &&
|
|
879
|
+
hasPermission(grant.permissions, "workspace:read") &&
|
|
880
|
+
(await resolveNamedManagedPersonalWorkspaceGrant(deps.db, {
|
|
881
|
+
accountId: grant.accountId,
|
|
882
|
+
workspaceId,
|
|
883
|
+
subjectId: authorization.authenticatedSubjectId,
|
|
884
|
+
}))
|
|
885
|
+
) {
|
|
886
|
+
return grant;
|
|
887
|
+
}
|
|
888
|
+
throw new HTTPException(403, {
|
|
889
|
+
message: "workspace settings require a workspace administrator or Personal workspace owner",
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Automatic membership for an organization key acting as an external user
|
|
895
|
+
* (`asUser`): the first request to a shared workspace in the key's own
|
|
896
|
+
* organization creates the user's missing membership with
|
|
897
|
+
* {@link EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS}, then the request continues.
|
|
898
|
+
* It is exactly the authority of explicit `addExternalWorkspaceMember`: the
|
|
899
|
+
* calling key must hold `members:manage` (or legacy `workspace:admin`) plus
|
|
900
|
+
* every default permission, with the workspace in its scope, re-checked live
|
|
901
|
+
* under the organization membership fence together with the identity's own
|
|
902
|
+
* active status. Never for linked native identities, service-initiator
|
|
903
|
+
* requests, or any non-key principal (agent attempts, delegated/bearer user
|
|
904
|
+
* tokens, browser sessions), which never reach the external lane; never for
|
|
905
|
+
* Personal or SDK per-user workspaces; never when the request needs a
|
|
906
|
+
* permission outside the defaults. Never changes an existing membership.
|
|
907
|
+
* Returns true only when a membership now exists; any refusal leaves the
|
|
908
|
+
* ordinary 403 in place.
|
|
909
|
+
*/
|
|
910
|
+
async function provisionExternalMemberOnFirstUse(
|
|
911
|
+
deps: AccessDeps,
|
|
912
|
+
context: AccessContext,
|
|
913
|
+
external: NonNullable<ReturnType<typeof externalActorContexts.get>>,
|
|
914
|
+
workspaceId: string,
|
|
915
|
+
permission: Permission | undefined,
|
|
916
|
+
): Promise<boolean> {
|
|
917
|
+
if (
|
|
918
|
+
!external.firstUseMembership ||
|
|
919
|
+
external.linked ||
|
|
920
|
+
// A request that would 403 on its own permission anyway creates nothing.
|
|
921
|
+
(permission !== undefined && !EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.includes(permission)) ||
|
|
922
|
+
context.subjectId !== external.identity.subjectId ||
|
|
923
|
+
!hasPermission(external.permissions, "members:manage", external.permissionMode) ||
|
|
924
|
+
EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.some(
|
|
925
|
+
(required) => !hasPermission(external.permissions, required, external.permissionMode),
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
return false;
|
|
929
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
930
|
+
if (
|
|
931
|
+
!workspace ||
|
|
932
|
+
workspace.kind !== "shared" ||
|
|
933
|
+
workspace.accountId !== external.identity.accountId ||
|
|
934
|
+
// SDK per-user workspaces stay single-user: the SDK adds their owner.
|
|
935
|
+
workspace.externalSource?.startsWith(USER_ISOLATION_WORKSPACE_SOURCE_PREFIX)
|
|
936
|
+
)
|
|
937
|
+
return false;
|
|
938
|
+
try {
|
|
939
|
+
await ensureExternalWorkspaceMemberOnFirstUse(
|
|
940
|
+
deps.db,
|
|
941
|
+
{
|
|
942
|
+
organizationId: external.identity.accountId,
|
|
943
|
+
workspaceId,
|
|
944
|
+
actorSubjectId: `api_key:${external.keyId}`,
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
subjectId: external.identity.subjectId,
|
|
948
|
+
identity: { source: external.identity.source, externalId: external.identity.externalId },
|
|
949
|
+
permissions: EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS,
|
|
950
|
+
},
|
|
951
|
+
);
|
|
952
|
+
return true;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
// A changed/revoked key, narrowed policy or workspace scope keeps the
|
|
955
|
+
// ordinary denial. Anything else is an infrastructure failure.
|
|
956
|
+
if (
|
|
957
|
+
(error as { code?: unknown } | null)?.code === "42501" ||
|
|
958
|
+
nestedPostgresSqlState(error) === "42501"
|
|
959
|
+
)
|
|
960
|
+
return false;
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function accessGrantAuthorization(
|
|
966
|
+
context: AccessContext,
|
|
967
|
+
deps: AccessDeps,
|
|
968
|
+
workspaceId: string,
|
|
969
|
+
permission?: Permission,
|
|
970
|
+
options: { firstUseMembership?: boolean } = {},
|
|
971
|
+
): Promise<AccessGrantAuthorization> {
|
|
972
|
+
// No named-subject or organization-key fallback may widen verified OAuth
|
|
973
|
+
// bounds. These grants came from this resolution's live native access only.
|
|
974
|
+
if (delegatedHumanContexts.has(context)) {
|
|
975
|
+
const grant = context.workspaceGrants.find(
|
|
976
|
+
(candidate) => candidate.workspaceId === workspaceId,
|
|
977
|
+
);
|
|
978
|
+
if (!grant)
|
|
979
|
+
throw new HTTPException(403, { message: "delegated human workspace access denied" });
|
|
980
|
+
if (permission) requirePermission(grant, permission);
|
|
981
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
982
|
+
}
|
|
983
|
+
const external = externalActorContexts.get(context);
|
|
984
|
+
if (external) {
|
|
985
|
+
if (!organizationWorkspaceInScope(external.workspaceScope, workspaceId)) {
|
|
986
|
+
throw new HTTPException(403, { message: "workspace is outside organization key scope" });
|
|
987
|
+
}
|
|
988
|
+
if (external.permissionMode === "explicit") {
|
|
989
|
+
const workspace = await requireWorkspace(deps.db, workspaceId);
|
|
990
|
+
if (workspace.kind !== "shared" || workspace.accountId !== external.identity.accountId)
|
|
991
|
+
throw new HTTPException(403, {
|
|
992
|
+
message: "organization policy requires a shared workspace",
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
const personal =
|
|
996
|
+
workspaceId ===
|
|
997
|
+
(external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId);
|
|
998
|
+
const membershipGrant = () =>
|
|
999
|
+
withWorkspaceSubjectRls(deps.db, workspaceId, context.subjectId, (tx) =>
|
|
1000
|
+
getWorkspaceGrant(tx, context.subjectId, workspaceId, {
|
|
1001
|
+
principalKind: "human_session",
|
|
1002
|
+
}),
|
|
1003
|
+
);
|
|
1004
|
+
let grant: AccessGrant | null = personal
|
|
1005
|
+
? {
|
|
1006
|
+
accountId: external.identity.accountId,
|
|
1007
|
+
workspaceId,
|
|
1008
|
+
subjectId: context.subjectId,
|
|
1009
|
+
principalKind: "human_session",
|
|
1010
|
+
permissions: [...managedPersonalWorkspacePermissions],
|
|
1011
|
+
}
|
|
1012
|
+
: await membershipGrant();
|
|
1013
|
+
if (
|
|
1014
|
+
!grant &&
|
|
1015
|
+
!personal &&
|
|
1016
|
+
options.firstUseMembership === true &&
|
|
1017
|
+
(await provisionExternalMemberOnFirstUse(deps, context, external, workspaceId, permission))
|
|
1018
|
+
) {
|
|
1019
|
+
grant = await membershipGrant();
|
|
1020
|
+
}
|
|
1021
|
+
if (!grant || grant.accountId !== external.identity.accountId) {
|
|
1022
|
+
throw new HTTPException(403, { message: "external workspace access denied" });
|
|
1023
|
+
}
|
|
1024
|
+
// Intersect effective permissions using the existing wildcard/literal
|
|
1025
|
+
// semantics on each side independently. Never return workspace:admin
|
|
1026
|
+
// unless both sides hold it, and never infer literal secrets:read.
|
|
1027
|
+
grant.permissions = Permission.options.filter(
|
|
1028
|
+
(value) =>
|
|
1029
|
+
hasPermission(grant.permissions, value) &&
|
|
1030
|
+
hasPermission(external.permissions, value) &&
|
|
1031
|
+
(!external.linked || hasPermission(external.linked.link.permissions, value)),
|
|
1032
|
+
);
|
|
1033
|
+
if (external.permissionMode === "explicit") {
|
|
1034
|
+
grant.permissionMode = "explicit";
|
|
1035
|
+
explicitPermissions(grant.permissions);
|
|
1036
|
+
}
|
|
1037
|
+
grant.metadata = {
|
|
1038
|
+
...grant.metadata,
|
|
1039
|
+
externalActor: attributionForExternalContext(context),
|
|
1040
|
+
};
|
|
1041
|
+
if (permission) requirePermission(grant, permission);
|
|
1042
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
1043
|
+
}
|
|
1044
|
+
const organizationKey = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1045
|
+
if (organizationKey) {
|
|
1046
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
1047
|
+
if (!workspace) throw new HTTPException(404, { message: "workspace not found" });
|
|
1048
|
+
if (
|
|
1049
|
+
workspace.accountId !== organizationKey.accountId ||
|
|
1050
|
+
workspace.kind !== "shared" ||
|
|
1051
|
+
!organizationWorkspaceInScope(organizationKey.workspaceScope, workspace.id)
|
|
1052
|
+
) {
|
|
1053
|
+
throw new HTTPException(403, { message: "workspace is outside organization key scope" });
|
|
1054
|
+
}
|
|
1055
|
+
const grant: AccessGrant = {
|
|
1056
|
+
accountId: workspace.accountId,
|
|
1057
|
+
workspaceId,
|
|
1058
|
+
subjectId: context.subjectId,
|
|
1059
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
1060
|
+
principalKind: "api_key",
|
|
1061
|
+
permissions: organizationKey.permissions,
|
|
1062
|
+
permissionMode: organizationKey.permissionMode,
|
|
1063
|
+
...apiKeyServiceContexts.get(context),
|
|
1064
|
+
};
|
|
1065
|
+
requirePermission(grant, permission ?? "workspace:read");
|
|
1066
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
1067
|
+
}
|
|
99
1068
|
const principalKind = hostedHumanSessionPrincipalKind(context);
|
|
100
|
-
|
|
1069
|
+
let grant =
|
|
101
1070
|
context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
|
|
102
1071
|
(await getWorkspaceGrant(
|
|
103
1072
|
deps.db,
|
|
@@ -105,12 +1074,53 @@ export async function requireAccessGrantAuthorization(
|
|
|
105
1074
|
workspaceId,
|
|
106
1075
|
principalKind ? { principalKind } : undefined,
|
|
107
1076
|
));
|
|
1077
|
+
if (grant && isDeveloperSetupApiKeyContext(context)) {
|
|
1078
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1079
|
+
const workspace = await requireWorkspace(deps.db, workspaceId);
|
|
1080
|
+
if (!authority || workspace.accountId !== authority.accountId || workspace.kind !== "shared") {
|
|
1081
|
+
throw new HTTPException(403, {
|
|
1082
|
+
message: "Developer setup requires a shared organization workspace",
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
// A persisted creator/membership grant cannot widen the authenticated key
|
|
1086
|
+
// ceiling, including literal secret reads and inherited session authority.
|
|
1087
|
+
const storedPermissions = grant.permissions;
|
|
1088
|
+
grant = {
|
|
1089
|
+
...grant,
|
|
1090
|
+
permissions: Permission.options.filter(
|
|
1091
|
+
(value) =>
|
|
1092
|
+
value !== "api_keys:manage" &&
|
|
1093
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(value) &&
|
|
1094
|
+
hasPermission(storedPermissions, value) &&
|
|
1095
|
+
hasPermission(authority.permissions, value),
|
|
1096
|
+
),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
108
1099
|
if (!grant) {
|
|
109
1100
|
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
110
1101
|
if (!workspace) {
|
|
111
1102
|
throw new HTTPException(404, { message: "workspace not found" });
|
|
112
1103
|
}
|
|
113
|
-
|
|
1104
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1105
|
+
const requiredWorkspacePermission = permission ?? "workspace:read";
|
|
1106
|
+
if (
|
|
1107
|
+
authority &&
|
|
1108
|
+
workspace.accountId === authority.accountId &&
|
|
1109
|
+
workspace.kind === "shared" &&
|
|
1110
|
+
hasPermission(authority.permissions, requiredWorkspacePermission)
|
|
1111
|
+
) {
|
|
1112
|
+
grant = {
|
|
1113
|
+
workspaceId: workspace.id,
|
|
1114
|
+
accountId: workspace.accountId,
|
|
1115
|
+
subjectId: context.subjectId,
|
|
1116
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
1117
|
+
permissions: authority.permissions,
|
|
1118
|
+
principalKind: "api_key",
|
|
1119
|
+
...apiKeyServiceContexts.get(context),
|
|
1120
|
+
};
|
|
1121
|
+
} else {
|
|
1122
|
+
throw new HTTPException(403, { message: "workspace access denied" });
|
|
1123
|
+
}
|
|
114
1124
|
}
|
|
115
1125
|
if (permission) {
|
|
116
1126
|
requirePermission(grant, permission);
|
|
@@ -118,6 +1128,55 @@ export async function requireAccessGrantAuthorization(
|
|
|
118
1128
|
return accessGrantAuthorizationFromContext(context, grant);
|
|
119
1129
|
}
|
|
120
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* Did the managed cookie authenticate this exact native person?
|
|
1133
|
+
*
|
|
1134
|
+
* A managed human's personal workspace carries no `workspace_memberships` row,
|
|
1135
|
+
* so seams that fence on one must consult the
|
|
1136
|
+
* `organization_memberships.personal_workspace_id` pointer instead. That is a
|
|
1137
|
+
* runtime property, not a schema one: migration 0219's `42501` is a
|
|
1138
|
+
* precondition inside the provisioning function rather than a constraint, and
|
|
1139
|
+
* the parity report records `personal_workspace_has_no_membership_row` as
|
|
1140
|
+
* `basis: "runtime"` - its own term for something nothing in the schema
|
|
1141
|
+
* prevents. It holds because every membership writer requires `members:manage`
|
|
1142
|
+
* on the target, which a personal-workspace grant deliberately omits. `AGENTS.md`
|
|
1143
|
+
* scopes that exception tightly: it "derives an owner-only personal-workspace
|
|
1144
|
+
* grant only for the canonical managed-cookie (Better Auth) session", and
|
|
1145
|
+
* "Bearer/delegated principals, API keys, and account or organization
|
|
1146
|
+
* administrators receive no personal-workspace access through that exception."
|
|
1147
|
+
*
|
|
1148
|
+
* This asks about PROVENANCE, not shape. Inspecting a grant's `principalKind`,
|
|
1149
|
+
* `metadata.delegated`, or `serviceInitiator` is not sufficient: a delegated
|
|
1150
|
+
* bearer chooses every one of those claims inside its own host-signed token and
|
|
1151
|
+
* can name any subject, including the owner's. So the answer comes from
|
|
1152
|
+
* {@link canonicalManagedCookieContexts}, stamped only where a Better Auth
|
|
1153
|
+
* cookie was actually verified.
|
|
1154
|
+
*
|
|
1155
|
+
* The subject equality is the second half: the exception is OWNER-only, so the
|
|
1156
|
+
* grant must belong to the authenticated human itself, not to some other
|
|
1157
|
+
* subject reached from an authenticated session. `user:` excludes machine
|
|
1158
|
+
* principals, which can never own an organization membership.
|
|
1159
|
+
*/
|
|
1160
|
+
function isCanonicalManagedHumanSession(context: AccessContext, grant: AccessGrant): boolean {
|
|
1161
|
+
return (
|
|
1162
|
+
canonicalManagedCookieContexts.has(context) &&
|
|
1163
|
+
grant.subjectId === context.subjectId &&
|
|
1164
|
+
grant.subjectId.startsWith("user:")
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function isCanonicalLocalHumanSession(context: AccessContext, grant: AccessGrant): boolean {
|
|
1169
|
+
return (
|
|
1170
|
+
canonicalLocalHumanContexts.has(context) &&
|
|
1171
|
+
context.mode === "local" &&
|
|
1172
|
+
context.subjectId === "dev" &&
|
|
1173
|
+
grant.subjectId === context.subjectId &&
|
|
1174
|
+
grant.principalKind === "human_session" &&
|
|
1175
|
+
grant.metadata?.delegated !== true &&
|
|
1176
|
+
!grant.serviceInitiator
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
121
1180
|
function hostedHumanSessionPrincipalKind(context: AccessContext): "human_session" | undefined {
|
|
122
1181
|
if (context.mode !== "managed" || context.workspaceGrants.length === 0) {
|
|
123
1182
|
return undefined;
|
|
@@ -133,6 +1192,7 @@ function hostedHumanSessionPrincipalKind(context: AccessContext): "human_session
|
|
|
133
1192
|
}
|
|
134
1193
|
|
|
135
1194
|
export function requirePermission(grant: AccessGrant, permission: Permission): void {
|
|
1195
|
+
if (grant.permissionMode === "explicit") explicitPermissions(grant.permissions);
|
|
136
1196
|
if (!hasPermission(grant.permissions, permission)) {
|
|
137
1197
|
if (permission === "variable-sets:use") {
|
|
138
1198
|
throw new HTTPException(403, {
|
|
@@ -164,52 +1224,64 @@ export function requireLiteralPermission(grant: AccessGrant, permission: Permiss
|
|
|
164
1224
|
}
|
|
165
1225
|
}
|
|
166
1226
|
|
|
167
|
-
export function hasLiteralPermission(
|
|
1227
|
+
export function hasLiteralPermission(
|
|
1228
|
+
permissions: readonly Permission[],
|
|
1229
|
+
permission: Permission,
|
|
1230
|
+
): boolean {
|
|
1231
|
+
if (!Array.isArray(permissions)) return false;
|
|
168
1232
|
return permissions.includes(permission);
|
|
169
1233
|
}
|
|
170
1234
|
|
|
171
|
-
export function hasPermission(
|
|
1235
|
+
export function hasPermission(
|
|
1236
|
+
permissions: readonly Permission[],
|
|
1237
|
+
permission: Permission,
|
|
1238
|
+
permissionMode?: AccessGrant["permissionMode"],
|
|
1239
|
+
): boolean {
|
|
1240
|
+
if (!Array.isArray(permissions)) return false;
|
|
1241
|
+
if (permissionMode === "explicit" || explicitPermissionSets.has(permissions))
|
|
1242
|
+
return permissions.includes(permission);
|
|
172
1243
|
if (permission === "secrets:read") {
|
|
173
1244
|
return permissions.includes("secrets:read");
|
|
174
1245
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
"variable-sets:use",
|
|
180
|
-
"variable-sets:manage",
|
|
181
|
-
"environments:use" as Permission,
|
|
182
|
-
"environments:manage" as Permission,
|
|
183
|
-
],
|
|
184
|
-
"variable-sets:read": [
|
|
185
|
-
"variable-sets:use",
|
|
186
|
-
"variable-sets:manage",
|
|
187
|
-
"environments:use" as Permission,
|
|
188
|
-
"environments:manage" as Permission,
|
|
189
|
-
],
|
|
190
|
-
"variable-sets:write": ["variable-sets:manage", "environments:manage" as Permission],
|
|
191
|
-
"secrets:list": [
|
|
192
|
-
"variable-sets:use",
|
|
193
|
-
"variable-sets:manage",
|
|
194
|
-
"environments:use" as Permission,
|
|
195
|
-
"environments:manage" as Permission,
|
|
196
|
-
],
|
|
197
|
-
"secrets:write": ["variable-sets:manage", "environments:manage" as Permission],
|
|
198
|
-
};
|
|
199
|
-
return (
|
|
200
|
-
permissions.includes(permission) ||
|
|
201
|
-
(aliases[permission]?.some((alias) => permissions.includes(alias)) ?? false) ||
|
|
202
|
-
permissions.includes("workspace:admin")
|
|
203
|
-
);
|
|
1246
|
+
// Variable-set metadata, plaintext read, write/rotation, attachment, and
|
|
1247
|
+
// runtime use are independent capabilities. Deprecated broad permissions
|
|
1248
|
+
// remain parseable but do not imply any of the exact permissions.
|
|
1249
|
+
return permissions.includes(permission) || permissions.includes("workspace:admin");
|
|
204
1250
|
}
|
|
205
1251
|
|
|
206
1252
|
async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
|
|
1253
|
+
accessResolvedRequests.add(c.req.raw);
|
|
1254
|
+
const delegatedHuman = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
|
|
1255
|
+
if (delegatedHuman) return resolveDelegatedHumanAccessContext(deps, delegatedHuman);
|
|
1256
|
+
const service = serviceInitiatorFromHeaders(c.req.raw.headers);
|
|
1257
|
+
if (service) {
|
|
1258
|
+
if (deps.settings.productAccessMode === "local") {
|
|
1259
|
+
throw new HTTPException(422, {
|
|
1260
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const context = await apiKeyAccessContext(c, deps, deps.settings.productAccessMode);
|
|
1264
|
+
if (!context) {
|
|
1265
|
+
throw new HTTPException(422, {
|
|
1266
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return context;
|
|
1270
|
+
}
|
|
1271
|
+
if (c.req.header("x-opengeni-external-actor") !== undefined) {
|
|
1272
|
+
if (deps.settings.productAccessMode === "local") {
|
|
1273
|
+
throw new HTTPException(401, {
|
|
1274
|
+
message: "external actors require organization key authentication",
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
return apiKeyAccessContext(c, deps, deps.settings.productAccessMode);
|
|
1278
|
+
}
|
|
207
1279
|
if (deps.settings.productAccessMode === "local") {
|
|
208
1280
|
const delegated = await delegatedAccessContext(c, deps, "local");
|
|
209
1281
|
if (delegated) {
|
|
210
1282
|
return delegated;
|
|
211
1283
|
}
|
|
212
|
-
|
|
1284
|
+
const context = await bootstrapWorkspace(deps.db, {
|
|
213
1285
|
accountExternalSource: "opengeni:local",
|
|
214
1286
|
accountExternalId: "default",
|
|
215
1287
|
accountName: "Local",
|
|
@@ -219,6 +1291,8 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
219
1291
|
subjectId: "dev",
|
|
220
1292
|
subjectLabel: "Local dev",
|
|
221
1293
|
});
|
|
1294
|
+
canonicalLocalHumanContexts.add(context);
|
|
1295
|
+
return context;
|
|
222
1296
|
}
|
|
223
1297
|
|
|
224
1298
|
if (deps.settings.productAccessMode === "configured") {
|
|
@@ -258,19 +1332,151 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
258
1332
|
}
|
|
259
1333
|
|
|
260
1334
|
if (deps.managedAuth) {
|
|
261
|
-
const session = await getManagedSession(c, deps.managedAuth, {
|
|
1335
|
+
const session = await getManagedSession(c, deps.managedAuth, {
|
|
1336
|
+
db: deps.db,
|
|
1337
|
+
sessionSetMode: deps.settings.managedAuthSessionSetMode,
|
|
1338
|
+
sessionAdapter: deps.managedAuthSessionAdapter,
|
|
1339
|
+
});
|
|
262
1340
|
if (session?.user) {
|
|
263
|
-
|
|
1341
|
+
// THE canonical managed-cookie (Better Auth) branch, and the only place
|
|
1342
|
+
// that may stamp a context as such. Every `return` above this point leaves
|
|
1343
|
+
// its context unstamped, so the owner-only personal-workspace exception
|
|
1344
|
+
// fails closed for them without depending on an exclusion list.
|
|
1345
|
+
const context = await ensureManagedAccessForUser(deps.db, {
|
|
264
1346
|
userId: session.user.id,
|
|
265
1347
|
email: session.user.email,
|
|
266
1348
|
name: session.user.name,
|
|
1349
|
+
emailVerified: session.user.emailVerified,
|
|
1350
|
+
provisionFallbackOrganization: false,
|
|
1351
|
+
bindPendingInvitations: false,
|
|
267
1352
|
});
|
|
1353
|
+
canonicalManagedCookieContexts.add(context);
|
|
1354
|
+
recordUserPresence(c, deps, context.subjectId);
|
|
1355
|
+
return context;
|
|
268
1356
|
}
|
|
269
1357
|
}
|
|
270
1358
|
|
|
271
1359
|
return null;
|
|
272
1360
|
}
|
|
273
1361
|
|
|
1362
|
+
async function resolveDelegatedHumanAccessContext(
|
|
1363
|
+
deps: AccessDeps,
|
|
1364
|
+
proof: DelegatedHumanAuthorization,
|
|
1365
|
+
): Promise<AccessContext> {
|
|
1366
|
+
if (deps.settings.productAccessMode === "local") {
|
|
1367
|
+
throw new HTTPException(403, {
|
|
1368
|
+
message: "delegated humans require native organization access",
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
const userId = proof.subjectId.slice("user:".length);
|
|
1372
|
+
const profiles = await getManagedUserProfilesByIds(deps.db, [userId]);
|
|
1373
|
+
const parsedProfile = delegatedHumanProfileSchema.safeParse(
|
|
1374
|
+
profiles.length === 1 && profiles[0]?.id === userId ? profiles[0] : null,
|
|
1375
|
+
);
|
|
1376
|
+
if (!parsedProfile.success)
|
|
1377
|
+
throw new HTTPException(403, { message: "delegated native person is unavailable" });
|
|
1378
|
+
const profile = parsedProfile.data;
|
|
1379
|
+
const live = await ensureManagedAccessForUser(deps.db, {
|
|
1380
|
+
userId: profile.id,
|
|
1381
|
+
email: profile.email,
|
|
1382
|
+
name: profile.name ?? "",
|
|
1383
|
+
provisionFallbackOrganization: false,
|
|
1384
|
+
bindPendingInvitations: false,
|
|
1385
|
+
});
|
|
1386
|
+
const accounts = live.accountGrants.filter((grant) => grant.accountId === proof.organizationId);
|
|
1387
|
+
if (
|
|
1388
|
+
live.mode !== "managed" ||
|
|
1389
|
+
live.subjectId !== proof.subjectId ||
|
|
1390
|
+
accounts.length !== 1 ||
|
|
1391
|
+
live.accountGrants.some((grant) => grant.subjectId !== proof.subjectId) ||
|
|
1392
|
+
live.workspaceGrants.some(
|
|
1393
|
+
(grant) =>
|
|
1394
|
+
grant.subjectId !== proof.subjectId ||
|
|
1395
|
+
grant.principalKind !== "human_session" ||
|
|
1396
|
+
grant.metadata?.delegated === true ||
|
|
1397
|
+
grant.serviceInitiator ||
|
|
1398
|
+
grant.serviceInitiatorContext,
|
|
1399
|
+
)
|
|
1400
|
+
) {
|
|
1401
|
+
throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
|
|
1402
|
+
}
|
|
1403
|
+
const verifiedPermissions = [...proof.permissions];
|
|
1404
|
+
// The person's own role expands as usual; the connection's access setting is
|
|
1405
|
+
// literal, and so is the result: workspace:admin in a Custom setting is never
|
|
1406
|
+
// a wildcard for the permissions the person left out.
|
|
1407
|
+
const intersectWorkspacePermissions = (permissions: Permission[]) =>
|
|
1408
|
+
explicitPermissions(
|
|
1409
|
+
Permission.options.filter(
|
|
1410
|
+
(permission) =>
|
|
1411
|
+
hasPermission(permissions, permission) &&
|
|
1412
|
+
hasPermission(verifiedPermissions, permission, "explicit"),
|
|
1413
|
+
),
|
|
1414
|
+
);
|
|
1415
|
+
const accountGrant: AccountGrant = {
|
|
1416
|
+
...accounts[0]!,
|
|
1417
|
+
// Organization/billing authority must be literal on BOTH sides, and so is
|
|
1418
|
+
// the result: a workspace:admin entry here never reads as account:admin or
|
|
1419
|
+
// billing to a later permission check.
|
|
1420
|
+
permissions: explicitPermissions(
|
|
1421
|
+
Permission.options.filter(
|
|
1422
|
+
(permission) =>
|
|
1423
|
+
hasLiteralPermission(accounts[0]!.permissions, permission) &&
|
|
1424
|
+
hasLiteralPermission(verifiedPermissions, permission),
|
|
1425
|
+
),
|
|
1426
|
+
),
|
|
1427
|
+
};
|
|
1428
|
+
const workspaceGrants = live.workspaceGrants
|
|
1429
|
+
.filter(
|
|
1430
|
+
(grant) =>
|
|
1431
|
+
grant.accountId === proof.organizationId &&
|
|
1432
|
+
delegatedHumanWorkspaceAllowed(proof, grant.workspaceId),
|
|
1433
|
+
)
|
|
1434
|
+
.map((grant) => ({
|
|
1435
|
+
...grant,
|
|
1436
|
+
permissions: intersectWorkspacePermissions(grant.permissions),
|
|
1437
|
+
permissionMode: "explicit" as const,
|
|
1438
|
+
}));
|
|
1439
|
+
// Proof-bearing authority cannot be changed in place and then reused as if
|
|
1440
|
+
// the resolver had authenticated a different subject, scope or ceiling.
|
|
1441
|
+
for (const grant of [accountGrant, ...workspaceGrants]) {
|
|
1442
|
+
Object.freeze(grant.permissions);
|
|
1443
|
+
Object.freeze(grant);
|
|
1444
|
+
}
|
|
1445
|
+
const context: AccessContext = {
|
|
1446
|
+
mode: "managed",
|
|
1447
|
+
subjectId: proof.subjectId,
|
|
1448
|
+
...(live.subjectLabel ? { subjectLabel: live.subjectLabel } : {}),
|
|
1449
|
+
accountGrants: [accountGrant],
|
|
1450
|
+
workspaceGrants,
|
|
1451
|
+
defaultAccountId: proof.organizationId,
|
|
1452
|
+
defaultWorkspaceId:
|
|
1453
|
+
workspaceGrants.find((grant) => grant.workspaceId === live.defaultWorkspaceId)?.workspaceId ??
|
|
1454
|
+
workspaceGrants[0]?.workspaceId ??
|
|
1455
|
+
null,
|
|
1456
|
+
};
|
|
1457
|
+
Object.freeze(context.accountGrants);
|
|
1458
|
+
Object.freeze(context.workspaceGrants);
|
|
1459
|
+
Object.freeze(context);
|
|
1460
|
+
delegatedHumanContexts.set(context, proof);
|
|
1461
|
+
delegatedHumanProfiles.set(context, Object.freeze(profile));
|
|
1462
|
+
return context;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Presence counts people, so only the verified managed browser-session branch
|
|
1467
|
+
* reports it, and only for a request the console marked as human activity (a
|
|
1468
|
+
* visible tab with recent interaction). Each request counts once: an SSE
|
|
1469
|
+
* stream's periodic reauthorization reuses its original request.
|
|
1470
|
+
*/
|
|
1471
|
+
const presenceRecordedRequests = new WeakSet<Request>();
|
|
1472
|
+
function recordUserPresence(c: Context, deps: AccessDeps, subjectId: string): void {
|
|
1473
|
+
if (!deps.userPresence) return;
|
|
1474
|
+
if (c.req.header(OPENGENI_USER_ACTIVITY_HEADER) !== OPENGENI_USER_ACTIVITY_ACTIVE) return;
|
|
1475
|
+
if (presenceRecordedRequests.has(c.req.raw)) return;
|
|
1476
|
+
presenceRecordedRequests.add(c.req.raw);
|
|
1477
|
+
deps.userPresence.touch(subjectId);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
274
1480
|
async function apiKeyAccessContext(
|
|
275
1481
|
c: Context,
|
|
276
1482
|
deps: AccessDeps,
|
|
@@ -284,13 +1490,93 @@ async function apiKeyAccessContext(
|
|
|
284
1490
|
if (!apiKey) {
|
|
285
1491
|
return null;
|
|
286
1492
|
}
|
|
1493
|
+
const externalHeader = c.req.header("x-opengeni-external-actor");
|
|
1494
|
+
const service = serviceInitiatorFromHeaders(c.req.raw.headers);
|
|
1495
|
+
if (
|
|
1496
|
+
service &&
|
|
1497
|
+
apiKey.credentialKind !== "organization" &&
|
|
1498
|
+
apiKey.credentialKind !== "workspace"
|
|
1499
|
+
) {
|
|
1500
|
+
throw new HTTPException(422, {
|
|
1501
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
if (externalHeader !== undefined) {
|
|
1505
|
+
if (apiKey.workspaceId !== null || apiKey.credentialKind !== "organization") {
|
|
1506
|
+
throw new HTTPException(403, { message: "external actors require an organization key" });
|
|
1507
|
+
}
|
|
1508
|
+
let selection: ExternalActorSelection;
|
|
1509
|
+
try {
|
|
1510
|
+
if (externalHeader.length > 16384) throw new Error("oversize");
|
|
1511
|
+
selection = ExternalActorSelection.parse(JSON.parse(decodeURIComponent(externalHeader)));
|
|
1512
|
+
} catch {
|
|
1513
|
+
throw new HTTPException(400, { message: "invalid external actor selection" });
|
|
1514
|
+
}
|
|
1515
|
+
let identity: ExternalIdentity;
|
|
1516
|
+
try {
|
|
1517
|
+
identity = await withAccountRls(deps.db, apiKey.accountId, async (tx) => {
|
|
1518
|
+
// Provisioning locks the live membership after its identity lock.
|
|
1519
|
+
// Claim the lifecycle prefix first so a membership-fenced session
|
|
1520
|
+
// writer can recheck that identity without a lock inversion.
|
|
1521
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, apiKey.accountId);
|
|
1522
|
+
return await ensureExternalIdentity(tx, {
|
|
1523
|
+
accountId: apiKey.accountId,
|
|
1524
|
+
...selection.identity,
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
if (nestedPostgresSqlState(error) === "42501") {
|
|
1529
|
+
throw new HTTPException(403, { message: "external identity is unavailable" });
|
|
1530
|
+
}
|
|
1531
|
+
throw new HTTPException(503, { message: "external identity authority is unavailable" });
|
|
1532
|
+
}
|
|
1533
|
+
const linked =
|
|
1534
|
+
selection.mode === "linked_native"
|
|
1535
|
+
? await resolveExternalIdentityLink(deps.db, {
|
|
1536
|
+
identity,
|
|
1537
|
+
linkId: selection.linkId,
|
|
1538
|
+
expectedRevision: selection.expectedLinkRevision,
|
|
1539
|
+
})
|
|
1540
|
+
: null;
|
|
1541
|
+
if (selection.mode === "linked_native" && !linked)
|
|
1542
|
+
throw new HTTPException(403, { message: "Native identity link is unavailable or changed" });
|
|
1543
|
+
const effectiveSubjectId = linked?.link.nativeSubjectId ?? identity.subjectId;
|
|
1544
|
+
const context: AccessContext = {
|
|
1545
|
+
mode,
|
|
1546
|
+
subjectId: effectiveSubjectId,
|
|
1547
|
+
accountGrants: [
|
|
1548
|
+
{ accountId: identity.accountId, subjectId: effectiveSubjectId, permissions: [] },
|
|
1549
|
+
],
|
|
1550
|
+
workspaceGrants: [],
|
|
1551
|
+
defaultAccountId: identity.accountId,
|
|
1552
|
+
defaultWorkspaceId: null,
|
|
1553
|
+
};
|
|
1554
|
+
externalActorContexts.set(context, {
|
|
1555
|
+
identity,
|
|
1556
|
+
keyId: apiKey.id,
|
|
1557
|
+
permissions:
|
|
1558
|
+
apiKey.permissionMode === "explicit"
|
|
1559
|
+
? explicitPermissions([...apiKey.permissions])
|
|
1560
|
+
: [...apiKey.permissions],
|
|
1561
|
+
workspaceScope: apiKey.workspaceScope ?? { kind: "all" },
|
|
1562
|
+
permissionMode: apiKey.permissionMode ?? "legacy",
|
|
1563
|
+
...(linked ? { linked } : {}),
|
|
1564
|
+
firstUseMembership: selection.mode !== "linked_native" && !linked && !service,
|
|
1565
|
+
});
|
|
1566
|
+
if (organizationApiKeyAccess(apiKey.permissions) === "developer_setup") {
|
|
1567
|
+
developerSetupApiKeyContexts.add(context);
|
|
1568
|
+
}
|
|
1569
|
+
return context;
|
|
1570
|
+
}
|
|
287
1571
|
const subjectId = `api_key:${apiKey.id}`;
|
|
288
1572
|
const accountPermissions = apiKey.workspaceId
|
|
289
1573
|
? apiKey.permissions.filter(
|
|
290
1574
|
(permission) => permission === "billing:read" || permission === "billing:manage",
|
|
291
1575
|
)
|
|
292
|
-
: apiKey.permissions
|
|
293
|
-
|
|
1576
|
+
: apiKey.permissions.filter((permission) =>
|
|
1577
|
+
accountScopedApiKeyAccountPermissions.has(permission),
|
|
1578
|
+
);
|
|
1579
|
+
const context: AccessContext = {
|
|
294
1580
|
mode,
|
|
295
1581
|
subjectId,
|
|
296
1582
|
subjectLabel: apiKey.name,
|
|
@@ -311,12 +1597,62 @@ async function apiKeyAccessContext(
|
|
|
311
1597
|
subjectLabel: apiKey.name,
|
|
312
1598
|
permissions: apiKey.permissions,
|
|
313
1599
|
principalKind: "api_key",
|
|
1600
|
+
...service,
|
|
314
1601
|
},
|
|
315
1602
|
]
|
|
316
1603
|
: [],
|
|
317
1604
|
defaultAccountId: apiKey.accountId,
|
|
318
1605
|
defaultWorkspaceId: apiKey.workspaceId,
|
|
319
|
-
}
|
|
1606
|
+
};
|
|
1607
|
+
if (service) apiKeyServiceContexts.set(context, service);
|
|
1608
|
+
if (
|
|
1609
|
+
apiKey.credentialKind === "organization" &&
|
|
1610
|
+
organizationApiKeyAccess(apiKey.permissions) === "developer_setup"
|
|
1611
|
+
) {
|
|
1612
|
+
developerSetupApiKeyContexts.add(context);
|
|
1613
|
+
}
|
|
1614
|
+
if (apiKey.workspaceId === null && apiKey.credentialKind === "organization") {
|
|
1615
|
+
accountScopedApiKeyContexts.set(
|
|
1616
|
+
context,
|
|
1617
|
+
Object.freeze({
|
|
1618
|
+
accountId: apiKey.accountId,
|
|
1619
|
+
workspaceScope: apiKey.workspaceScope ?? { kind: "all" as const },
|
|
1620
|
+
permissionMode: apiKey.permissionMode ?? "legacy",
|
|
1621
|
+
permissions: Object.freeze(
|
|
1622
|
+
apiKey.permissions.filter(
|
|
1623
|
+
(permission) => !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission),
|
|
1624
|
+
),
|
|
1625
|
+
),
|
|
1626
|
+
}),
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
// Report the exact stamped authority consumed by accessGrantAuthorization,
|
|
1630
|
+
// rather than re-deriving organization-key permissions from a separate rule.
|
|
1631
|
+
// This projection is never an authorization input, and the asUser branch
|
|
1632
|
+
// above deliberately omits it instead of advertising the service's authority.
|
|
1633
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1634
|
+
const workspaceGrant =
|
|
1635
|
+
apiKey.credentialKind === "workspace" ? context.workspaceGrants[0] : undefined;
|
|
1636
|
+
const workspacePermissions = authority?.permissions ?? workspaceGrant?.permissions;
|
|
1637
|
+
if (workspacePermissions) {
|
|
1638
|
+
context.credential = {
|
|
1639
|
+
kind: authority ? "organization_api_key" : "workspace_api_key",
|
|
1640
|
+
...(authority ? { access: organizationApiKeyAccess(apiKey.permissions) } : {}),
|
|
1641
|
+
accountId: apiKey.accountId,
|
|
1642
|
+
workspaceId: apiKey.workspaceId,
|
|
1643
|
+
effectiveWorkspacePermissions: Permission.options.filter(
|
|
1644
|
+
(permission) =>
|
|
1645
|
+
!(developerSetupApiKeyContexts.has(context) && permission === "api_keys:manage") &&
|
|
1646
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
1647
|
+
hasPermission(workspacePermissions, permission),
|
|
1648
|
+
),
|
|
1649
|
+
...(authority ? { policy: apiKey.policy, workspaceScope: authority.workspaceScope } : {}),
|
|
1650
|
+
note: authority
|
|
1651
|
+
? `These permissions apply to ${authority.workspaceScope.kind === "all" ? "every" : "selected"} shared workspace${authority.workspaceScope.kind === "all" ? "" : "s"} in this organization, not Personal workspaces. workspaceGrants need not enumerate them; accountGrants report organization-level permissions. asUser requests also require user authority.`
|
|
1652
|
+
: "These permissions apply only to the workspace identified by workspaceId; they grant no organization-wide workspace authority.",
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
return context;
|
|
320
1656
|
}
|
|
321
1657
|
|
|
322
1658
|
async function delegatedAccessContext(
|
|
@@ -333,7 +1669,11 @@ async function delegatedAccessContext(
|
|
|
333
1669
|
if (!payload) {
|
|
334
1670
|
return null;
|
|
335
1671
|
}
|
|
336
|
-
|
|
1672
|
+
const restricted = payload.credentialRestriction === "developer_setup";
|
|
1673
|
+
const workspacePermissions = restricted
|
|
1674
|
+
? payload.permissions.filter(isDeveloperSetupDelegatedPermissionAllowed)
|
|
1675
|
+
: payload.permissions;
|
|
1676
|
+
const context: AccessContext = {
|
|
337
1677
|
mode,
|
|
338
1678
|
subjectId: payload.subjectId,
|
|
339
1679
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
@@ -342,7 +1682,7 @@ async function delegatedAccessContext(
|
|
|
342
1682
|
accountId: payload.accountId,
|
|
343
1683
|
subjectId: payload.subjectId,
|
|
344
1684
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
345
|
-
permissions: payload.permissions,
|
|
1685
|
+
permissions: restricted ? [] : payload.permissions,
|
|
346
1686
|
},
|
|
347
1687
|
],
|
|
348
1688
|
workspaceGrants: [
|
|
@@ -351,7 +1691,7 @@ async function delegatedAccessContext(
|
|
|
351
1691
|
accountId: payload.accountId,
|
|
352
1692
|
subjectId: payload.subjectId,
|
|
353
1693
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
354
|
-
permissions:
|
|
1694
|
+
permissions: workspacePermissions,
|
|
355
1695
|
principalKind: payload.principalKind,
|
|
356
1696
|
// sessionId is worker-asserted (HMAC-signed token claim), not agent
|
|
357
1697
|
// controlled; it scopes session-bound MCP tools such as goal management.
|
|
@@ -384,6 +1724,8 @@ async function delegatedAccessContext(
|
|
|
384
1724
|
defaultAccountId: payload.accountId,
|
|
385
1725
|
defaultWorkspaceId: payload.workspaceId,
|
|
386
1726
|
};
|
|
1727
|
+
if (restricted) developerSetupApiKeyContexts.add(context);
|
|
1728
|
+
return context;
|
|
387
1729
|
}
|
|
388
1730
|
|
|
389
1731
|
function configuredSubject(c: Context): string {
|