@opengeni/core 3.0.1 → 3.1.2
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 +4 -4
- package/dist/access/index.d.ts +131 -7
- package/dist/access/service-initiator.d.ts +3 -0
- package/dist/application/archived-session-imports.d.ts +6 -0
- package/dist/application/composer-submit.d.ts +1 -1
- package/dist/application/connect-operation.d.ts +6 -0
- package/dist/application/external-workspace-members.d.ts +13 -1
- package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
- package/dist/application/modal-native-original-configuration.d.ts +79 -0
- package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
- package/dist/application/new-session-drafts.d.ts +29 -2
- package/dist/application/organization-privacy.d.ts +57 -0
- package/dist/application/prepared-mcp-connection.d.ts +31 -0
- package/dist/application/sandbox-recovery.d.ts +58 -0
- package/dist/application/session-commands.d.ts +3 -0
- package/dist/application/session-tenancy.d.ts +13 -4
- package/dist/billing/agent-run-admission.d.ts +21 -0
- package/dist/billing/limits.d.ts +27 -1
- package/dist/billing/model-call-admission.d.ts +34 -0
- package/dist/billing/model-usage-settlement.d.ts +112 -0
- package/dist/canonical-human-identities.js +1 -1
- package/dist/{chunk-VCZFFEPB.js → chunk-7T2SZ7BI.js} +16 -2
- package/dist/chunk-7T2SZ7BI.js.map +1 -0
- package/dist/{chunk-ZYCNLYGH.js → chunk-OMJFIIWK.js} +178 -12
- package/dist/chunk-OMJFIIWK.js.map +1 -0
- package/dist/{chunk-H7X52RI2.js → chunk-QI2M2B2K.js} +83 -8
- package/dist/chunk-QI2M2B2K.js.map +1 -0
- package/dist/{chunk-OF65T3PM.js → chunk-TTWTXQB2.js} +94 -6
- package/dist/chunk-TTWTXQB2.js.map +1 -0
- package/dist/codex-model-availability.d.ts +55 -0
- package/dist/default-session-model.d.ts +192 -0
- package/dist/dependencies.d.ts +41 -7
- 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 +8 -1
- package/dist/domain/capabilities.d.ts +112 -8
- package/dist/domain/connection-account-selection-error.d.ts +8 -0
- package/dist/domain/connector-tool-permissions.d.ts +62 -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 +3 -1
- package/dist/domain/editable-artifacts/types.d.ts +1 -1
- package/dist/domain/encryption-key.d.ts +12 -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 +21 -3
- package/dist/domain/knowledge-files.d.ts +1 -1
- 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 -76
- package/dist/domain/mcp-account-bindings.d.ts +17 -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/personal-connection-delegations.d.ts +73 -12
- package/dist/domain/pr-review.d.ts +121 -6
- package/dist/domain/realtime-voice-billing.d.ts +103 -0
- package/dist/domain/resources.d.ts +17 -1
- package/dist/domain/scheduled-task-access.d.ts +265 -0
- package/dist/domain/scheduled-task-account-audit.d.ts +27 -0
- package/dist/domain/scheduled-task-audience.d.ts +70 -0
- package/dist/domain/scheduled-tasks.d.ts +61 -18
- package/dist/domain/session-connection-accounts.d.ts +53 -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 +102 -4
- package/dist/domain/sessions.d.ts +179 -15
- package/dist/domain/site-audience.d.ts +52 -0
- package/dist/domain/skills.d.ts +29 -1
- package/dist/domain/slack-bot.d.ts +46 -1
- package/dist/domain/slack-publication-secret-safety.d.ts +1 -1
- package/dist/domain/vaults/index.d.ts +7 -0
- package/dist/domain/vaults/mapping.d.ts +25 -0
- package/dist/domain/vaults/provider-registry.d.ts +72 -0
- package/dist/domain/vaults/provider-sync.d.ts +176 -0
- package/dist/domain/vaults/provider-writes.d.ts +57 -0
- package/dist/domain/vaults/secret-resolver.d.ts +34 -0
- package/dist/domain/vaults/types.d.ts +24 -0
- package/dist/domain/vaults/vault-service.d.ts +35 -0
- package/dist/domain/voice-input-billing.d.ts +77 -0
- package/dist/domain/web-search-billing.d.ts +51 -0
- package/dist/domain/workspace-credits.d.ts +7 -0
- package/dist/editable-artifact-live/application.d.ts +2 -1
- package/dist/editable-artifact-live/server.d.ts +5 -1
- package/dist/editable-artifact-live/ticket.d.ts +2 -1
- package/dist/editable-artifact-live/types.d.ts +9 -0
- package/dist/editable-artifact-live.js +2 -2
- package/dist/editable-artifacts.js +10 -2
- package/dist/goal-admission.d.ts +627 -0
- package/dist/host-session-usage-limit.d.ts +15 -0
- package/dist/index.d.ts +43 -8
- package/dist/index.js +22682 -11743
- package/dist/index.js.map +1 -1
- package/dist/managed-session.d.ts +110 -7
- package/dist/model-call.d.ts +58 -0
- package/dist/model-catalog.d.ts +29 -3
- package/dist/model-provider-credentials.d.ts +43 -0
- package/dist/product-usage-metrics.d.ts +30 -0
- package/dist/rigs/index.d.ts +1 -13
- package/dist/sandbox/fleet.d.ts +58 -4
- package/dist/sandbox/routing.d.ts +42 -2
- package/dist/sandbox/runtime-settings.d.ts +27 -17
- package/dist/session-authorization.d.ts +10 -0
- package/dist/session-usage-limit-metrics.d.ts +60 -0
- package/dist/session-usage-limits.d.ts +89 -0
- package/dist/site-agent-clamp.d.ts +71 -0
- package/dist/site-session-origin.d.ts +39 -2
- package/dist/transcription.d.ts +120 -3
- package/dist/turn-surface.d.ts +20 -0
- package/dist/user-presence.d.ts +32 -0
- package/dist/workflow-wake-contract.d.ts +13 -1
- package/package.json +15 -22
- package/src/access/index.ts +961 -38
- package/src/access/service-initiator.ts +50 -0
- package/src/application/api-integration-servers.ts +9 -0
- package/src/application/archived-session-imports.ts +161 -0
- package/src/application/composer-submit.ts +3 -2
- package/src/application/connect-authority.ts +3 -3
- package/src/application/connect-operation.ts +37 -3
- package/src/application/external-continuation.ts +20 -3
- package/src/application/external-link-work-admission.ts +3 -0
- package/src/application/external-workspace-members.ts +51 -3
- package/src/application/modal-native-live-declaration-join.ts +196 -0
- package/src/application/modal-native-original-configuration.ts +261 -0
- package/src/application/modal-native-worker-host-transport.ts +263 -0
- package/src/application/new-session-drafts.ts +172 -55
- package/src/application/organization-privacy.ts +161 -0
- package/src/application/prepared-mcp-connection.ts +133 -0
- package/src/application/sandbox-recovery.ts +78 -0
- package/src/application/session-commands.ts +73 -1
- package/src/application/session-mcp-credential-rotation.ts +61 -18
- package/src/application/session-tenancy.ts +262 -44
- package/src/application/user-resource-grants.ts +1 -29
- package/src/billing/agent-run-admission.ts +117 -0
- package/src/billing/limits.ts +138 -14
- package/src/billing/model-call-admission.ts +118 -0
- package/src/billing/model-usage-settlement.ts +472 -0
- package/src/codex-model-availability.ts +264 -0
- package/src/default-session-model.ts +692 -0
- package/src/dependencies.ts +48 -6
- package/src/developer-setup-credential-ceiling.ts +60 -0
- package/src/domain/agent-config-resolution.ts +148 -0
- package/src/domain/automations.ts +76 -2
- package/src/domain/capabilities.ts +830 -181
- package/src/domain/connection-account-selection-error.ts +19 -0
- package/src/domain/connector-tool-permissions.ts +572 -0
- package/src/domain/direct-model-provider.ts +62 -0
- package/src/domain/editable-artifacts/agent-application.ts +44 -6
- 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 +20 -0
- package/src/domain/editable-artifacts/types.ts +1 -1
- package/src/domain/encryption-key.ts +26 -0
- package/src/domain/file-owner.ts +16 -1
- package/src/domain/github-action-policies.ts +1 -1
- package/src/domain/github-skill-source.ts +3 -1
- package/src/domain/insights-timing.ts +95 -0
- package/src/domain/insights-usage.ts +67 -0
- package/src/domain/insights.ts +167 -43
- package/src/domain/knowledge-files.ts +10 -3
- package/src/domain/knowledge-messages.ts +1 -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 +617 -5
- package/src/domain/knowledge-search.ts +240 -23
- package/src/domain/mcp-account-bindings.ts +169 -0
- package/src/domain/mcp-account-routes.ts +152 -0
- package/src/domain/native-mcp-connection-admission.ts +14 -0
- package/src/domain/organization-integration-catalog.ts +88 -0
- package/src/domain/personal-connection-delegations.ts +399 -257
- package/src/domain/pr-review.ts +24 -8
- package/src/domain/realtime-voice-billing.ts +425 -0
- package/src/domain/resources.ts +50 -4
- package/src/domain/scheduled-task-access.ts +1089 -0
- package/src/domain/scheduled-task-account-audit.ts +90 -0
- package/src/domain/scheduled-task-audience.ts +249 -0
- package/src/domain/scheduled-tasks.ts +1313 -374
- package/src/domain/session-connection-accounts.ts +239 -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 +60 -0
- package/src/domain/session-tool-policy.ts +489 -14
- package/src/domain/sessions.ts +2254 -717
- package/src/domain/site-audience.ts +130 -0
- package/src/domain/skills.ts +9 -0
- package/src/domain/slack-bot.ts +141 -4
- package/src/domain/slack-publication-secret-safety.ts +1 -1
- package/src/domain/vaults/index.ts +19 -0
- package/src/domain/vaults/mapping.ts +190 -0
- package/src/domain/vaults/provider-registry.ts +201 -0
- package/src/domain/vaults/provider-sync.ts +531 -0
- package/src/domain/vaults/provider-writes.ts +288 -0
- package/src/domain/vaults/secret-resolver.ts +464 -0
- package/src/domain/vaults/types.ts +26 -0
- package/src/domain/vaults/vault-service.ts +1316 -0
- package/src/domain/voice-input-billing.ts +330 -0
- package/src/domain/web-search-billing.ts +169 -0
- package/src/domain/workspace-credits.ts +13 -0
- package/src/editable-artifact-live/application.ts +8 -1
- package/src/editable-artifact-live/server.ts +154 -6
- package/src/editable-artifact-live/ticket.ts +86 -1
- package/src/editable-artifact-live/types.ts +10 -0
- package/src/goal-admission.ts +220 -0
- package/src/host-session-usage-limit.ts +25 -0
- package/src/index.ts +75 -11
- package/src/managed-session.ts +137 -6
- package/src/model-call.ts +79 -0
- package/src/model-catalog.ts +345 -91
- package/src/model-provider-credentials.ts +394 -0
- package/src/product-usage-metrics.ts +101 -0
- package/src/rigs/index.ts +23 -111
- package/src/sandbox/fleet.ts +253 -28
- package/src/sandbox/routing.ts +204 -43
- package/src/sandbox/runtime-settings.ts +159 -85
- package/src/session-authorization.ts +172 -23
- package/src/session-usage-limit-metrics.ts +95 -0
- package/src/session-usage-limits.ts +415 -0
- package/src/site-agent-clamp.ts +226 -0
- package/src/site-session-origin.ts +84 -5
- package/src/transcription.ts +123 -4
- package/src/turn-surface.ts +47 -0
- package/src/user-presence.ts +134 -0
- package/src/workflow-wake-contract.ts +14 -1
- package/dist/application/host-mcp-owner.d.ts +0 -6
- package/dist/application/host-mcp-resolvers.d.ts +0 -13
- package/dist/chunk-5WZYFL45.js +0 -266
- package/dist/chunk-5WZYFL45.js.map +0 -1
- package/dist/chunk-H7X52RI2.js.map +0 -1
- package/dist/chunk-OF65T3PM.js.map +0 -1
- package/dist/chunk-VCZFFEPB.js.map +0 -1
- package/dist/chunk-ZYCNLYGH.js.map +0 -1
- package/dist/domain/connection-authority.d.ts +0 -97
- package/dist/domain/connection-authority.js +0 -169
- package/dist/domain/connection-authority.js.map +0 -1
- package/dist/domain/environments.d.ts +0 -33
- package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
- package/dist/domain/host-mcp-task-admission.d.ts +0 -18
- package/dist/domain/packs.d.ts +0 -59
- package/dist/domain/product-integration-pack.d.ts +0 -44
- package/dist/domain/product-integration-skill.gen.d.ts +0 -5
- package/dist/remote-mcp-credentials.d.ts +0 -23
- package/dist/remote-mcp-credentials.js +0 -11
- package/dist/remote-mcp-credentials.js.map +0 -1
- package/src/application/host-mcp-owner.ts +0 -41
- package/src/application/host-mcp-resolvers.ts +0 -84
- package/src/domain/connection-authority.ts +0 -329
- package/src/domain/environments.ts +0 -120
- package/src/domain/host-mcp-authority-source-admission.ts +0 -25
- package/src/domain/host-mcp-task-admission.ts +0 -121
- package/src/domain/packs.ts +0 -699
- package/src/domain/product-integration-pack.ts +0 -42
- package/src/domain/product-integration-skill.gen.ts +0 -52
- package/src/remote-mcp-credentials.ts +0 -361
package/src/access/index.ts
CHANGED
|
@@ -6,17 +6,29 @@ import {
|
|
|
6
6
|
type ExternalIdentity,
|
|
7
7
|
} from "@opengeni/contracts/external-identities";
|
|
8
8
|
import {
|
|
9
|
+
accessContextHasInbox,
|
|
9
10
|
verifyDelegatedAccessToken,
|
|
11
|
+
DEVELOPER_SETUP_API_KEY_PRESET,
|
|
12
|
+
normalizePermission,
|
|
13
|
+
organizationAccessPresetPermissions,
|
|
10
14
|
type AccountGrant,
|
|
11
15
|
type AccessContext,
|
|
12
16
|
type AccessGrant,
|
|
17
|
+
type OrganizationApiKeyAccess,
|
|
18
|
+
OrganizationWorkspaceScope,
|
|
19
|
+
OPENGENI_USER_ACTIVITY_ACTIVE,
|
|
20
|
+
OPENGENI_USER_ACTIVITY_HEADER,
|
|
13
21
|
Permission,
|
|
14
22
|
type Workspace,
|
|
15
23
|
} from "@opengeni/contracts";
|
|
16
24
|
import {
|
|
17
25
|
bootstrapWorkspace,
|
|
18
26
|
ensureManagedAccessForUser,
|
|
27
|
+
getManagedUserProfilesByIds,
|
|
19
28
|
ensureExternalIdentity,
|
|
29
|
+
ensureExternalWorkspaceMemberOnFirstUse,
|
|
30
|
+
USER_ISOLATION_WORKSPACE_SOURCE_PREFIX,
|
|
31
|
+
lockExternalWorkspaceMembershipLifecycle,
|
|
20
32
|
resolveExternalIdentityLink,
|
|
21
33
|
managedPersonalWorkspacePermissions,
|
|
22
34
|
nestedPostgresSqlState,
|
|
@@ -31,12 +43,109 @@ import {
|
|
|
31
43
|
} from "@opengeni/db";
|
|
32
44
|
import type { Context } from "hono";
|
|
33
45
|
import { HTTPException } from "hono/http-exception";
|
|
46
|
+
import { z } from "zod";
|
|
34
47
|
import type { ManagedAuth } from "../managed-auth-type";
|
|
35
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
getManagedSession,
|
|
50
|
+
getNativeAppManagedSession,
|
|
51
|
+
NATIVE_APP_CREDENTIAL_PREFIX,
|
|
52
|
+
} from "../managed-session";
|
|
36
53
|
import type { ManagedAuthSessionAdapter } from "../managed-auth-session-sets";
|
|
54
|
+
import type { UserPresenceRecorder } from "../user-presence";
|
|
55
|
+
import { serviceInitiatorFromHeaders } from "./service-initiator";
|
|
37
56
|
|
|
38
57
|
const bearerPrefix = "Bearer ";
|
|
39
58
|
const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null>>();
|
|
59
|
+
const accessResolvedRequests = new WeakSet<Request>();
|
|
60
|
+
const developerSetupApiKeyContexts = new WeakSet<AccessContext>();
|
|
61
|
+
const developerSetupAuthorizations = new WeakMap<AccessGrantAuthorization, AccessGrant>();
|
|
62
|
+
const developerSetupGrants = new WeakSet<AccessGrant>();
|
|
63
|
+
// Request-local permission semantics. The durable grant carries permissionMode;
|
|
64
|
+
// callers of the existing array API cannot accidentally expand a policy admin.
|
|
65
|
+
const explicitPermissionSets = new WeakSet<readonly Permission[]>();
|
|
66
|
+
function explicitPermissions(permissions: Permission[]): Permission[] {
|
|
67
|
+
explicitPermissionSets.add(permissions);
|
|
68
|
+
return permissions;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Only canonical authentication can prove setup-key provenance. */
|
|
72
|
+
export function isDeveloperSetupApiKeyContext(context: AccessContext): boolean {
|
|
73
|
+
return (
|
|
74
|
+
developerSetupApiKeyContexts.has(context) &&
|
|
75
|
+
accountScopedApiKeyWorkspaceAuthority(context) !== null
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Provenance from canonical raw-key or verified restricted-token authentication. */
|
|
80
|
+
export function isDeveloperSetupAuthorization(
|
|
81
|
+
authorization: AccessGrantAuthorization | undefined,
|
|
82
|
+
): boolean {
|
|
83
|
+
return (
|
|
84
|
+
authorization !== undefined &&
|
|
85
|
+
developerSetupAuthorizations.get(authorization) === authorization.grant
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Exact grant objects resolved under authenticated setup-only provenance. */
|
|
90
|
+
export function isDeveloperSetupGrant(grant: AccessGrant): boolean {
|
|
91
|
+
return developerSetupGrants.has(grant);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Setup-derived credentials cannot carry literal organization or key authority. */
|
|
95
|
+
export function isDeveloperSetupDelegatedPermissionAllowed(permission: Permission): boolean {
|
|
96
|
+
return (
|
|
97
|
+
permission !== "secrets:read" &&
|
|
98
|
+
permission !== "api_keys:manage" &&
|
|
99
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Canonical authentication provenance, including the backing key of asUser. */
|
|
104
|
+
export function requireApiKeyManagementContext(context: AccessContext): void {
|
|
105
|
+
if (developerSetupApiKeyContexts.has(context)) {
|
|
106
|
+
throw new HTTPException(403, { message: "Developer setup keys cannot manage API keys" });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Membership is not an escape hatch for minting durable credentials. */
|
|
111
|
+
export function requireApiKeyDelegationContext(
|
|
112
|
+
context: AccessContext,
|
|
113
|
+
permissions: Permission[],
|
|
114
|
+
): void {
|
|
115
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
116
|
+
const external = externalActorContexts.get(context);
|
|
117
|
+
const ceiling = authority ?? external;
|
|
118
|
+
if (
|
|
119
|
+
ceiling?.permissionMode === "explicit" &&
|
|
120
|
+
permissions.some((permission) => !ceiling.permissions.includes(permission))
|
|
121
|
+
) {
|
|
122
|
+
throw new HTTPException(403, {
|
|
123
|
+
message: "cannot delegate a permission outside the organization key policy",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
ceiling?.permissionMode === "explicit" &&
|
|
128
|
+
permissions.includes("workspace:admin") &&
|
|
129
|
+
organizationAccessPresetPermissions("full").some(
|
|
130
|
+
(permission) =>
|
|
131
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
132
|
+
!ceiling.permissions.includes(permission),
|
|
133
|
+
)
|
|
134
|
+
) {
|
|
135
|
+
throw new HTTPException(403, {
|
|
136
|
+
message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
(hasPermission(permissions, "api_keys:manage") ||
|
|
141
|
+
hasPermission(permissions, "members:manage")) &&
|
|
142
|
+
developerSetupApiKeyContexts.has(context)
|
|
143
|
+
) {
|
|
144
|
+
throw new HTTPException(403, {
|
|
145
|
+
message: "Setup credentials cannot delegate API key management",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
40
149
|
|
|
41
150
|
/**
|
|
42
151
|
* Contexts that were authenticated by a verified canonical managed cookie
|
|
@@ -58,16 +167,261 @@ const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null
|
|
|
58
167
|
* the value the cookie branch produced.
|
|
59
168
|
*/
|
|
60
169
|
const canonicalManagedCookieContexts = new WeakSet<AccessContext>();
|
|
170
|
+
/** Downstream legacy credentials cannot encode a partially selected admin. */
|
|
171
|
+
export function requireExplicitPermissionDelegation(
|
|
172
|
+
grant: Pick<AccessGrant, "permissions" | "permissionMode">,
|
|
173
|
+
requested: Permission[],
|
|
174
|
+
): void {
|
|
175
|
+
if (
|
|
176
|
+
grant.permissionMode === "explicit" &&
|
|
177
|
+
requested.some((permission) => !grant.permissions.includes(permission))
|
|
178
|
+
) {
|
|
179
|
+
throw new HTTPException(403, {
|
|
180
|
+
message: "cannot delegate a permission outside the organization key policy",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
grant.permissionMode === "explicit" &&
|
|
185
|
+
requested.includes("workspace:admin") &&
|
|
186
|
+
organizationAccessPresetPermissions("full").some(
|
|
187
|
+
(permission) =>
|
|
188
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
189
|
+
!grant.permissions.includes(permission),
|
|
190
|
+
)
|
|
191
|
+
) {
|
|
192
|
+
throw new HTTPException(403, {
|
|
193
|
+
message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
61
197
|
const canonicalLocalHumanContexts = new WeakSet<AccessContext>();
|
|
198
|
+
|
|
199
|
+
export type DelegatedHumanAuthorization = Readonly<{
|
|
200
|
+
organizationId: string;
|
|
201
|
+
/** Exact native person subject, never an external identity or service. */
|
|
202
|
+
subjectId: string;
|
|
203
|
+
permissions: Permission[];
|
|
204
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
205
|
+
}>;
|
|
206
|
+
|
|
207
|
+
const delegatedHumanAuthorizationSchema = z.object({
|
|
208
|
+
organizationId: z.string().uuid(),
|
|
209
|
+
subjectId: z
|
|
210
|
+
.string()
|
|
211
|
+
.max(1024)
|
|
212
|
+
.regex(/^user:[^\s\u0000-\u001f\u007f]+$/),
|
|
213
|
+
permissions: z.array(Permission),
|
|
214
|
+
workspaceScope: OrganizationWorkspaceScope,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const delegatedHumanRequests = new WeakMap<Request, DelegatedHumanAuthorization>();
|
|
218
|
+
const delegatedHumanContexts = new WeakMap<AccessContext, DelegatedHumanAuthorization>();
|
|
219
|
+
const delegatedHumanProfileSchema = z.object({
|
|
220
|
+
id: z.string(),
|
|
221
|
+
name: z.string().nullable(),
|
|
222
|
+
email: z.string(),
|
|
223
|
+
emailVerified: z.boolean().optional(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export type VerifiedDelegatedHumanContext = Readonly<{
|
|
227
|
+
/** Native identity projection only, never a browser/auth session. Absence of
|
|
228
|
+
* emailVerified means the native lookup did not prove email verification. */
|
|
229
|
+
user: Readonly<z.infer<typeof delegatedHumanProfileSchema>>;
|
|
230
|
+
subjectId: string;
|
|
231
|
+
authorization: DelegatedHumanAuthorization;
|
|
232
|
+
context: AccessContext;
|
|
233
|
+
}>;
|
|
234
|
+
|
|
235
|
+
const delegatedHumanProfiles = new WeakMap<AccessContext, VerifiedDelegatedHumanContext["user"]>();
|
|
236
|
+
const verifiedDelegatedHumanAuthorizations = new WeakMap<
|
|
237
|
+
AccessGrantAuthorization,
|
|
238
|
+
{
|
|
239
|
+
proof: DelegatedHumanAuthorization;
|
|
240
|
+
grant: AccessGrant;
|
|
241
|
+
accountGrant: AccountGrant;
|
|
242
|
+
}
|
|
243
|
+
>();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* TRUSTED OAUTH VERIFIED DISPATCH ONLY. Before each stamp the in-process caller
|
|
247
|
+
* must validate the live OAuth grant, revocation, exact native person subject,
|
|
248
|
+
* organization, permission ceilings and workspace scope. This API does not
|
|
249
|
+
* verify OAuth and must NEVER be called with headers, metadata, token claims,
|
|
250
|
+
* a previous request's proof, or other unverified caller input.
|
|
251
|
+
*
|
|
252
|
+
* Stamp the exact raw Request that the actions dispatcher will pass to routes,
|
|
253
|
+
* before any access resolution. Cloning/wrapping a Request does not copy proof.
|
|
254
|
+
* The resolver reloads the real native person's live access on this request
|
|
255
|
+
* (and on fresh reauthorization); no session or cached grant is fabricated or
|
|
256
|
+
* transplanted. This proof never becomes managed-cookie/person-present proof.
|
|
257
|
+
*/
|
|
258
|
+
export function stampDelegatedHumanAuthorization(
|
|
259
|
+
request: Request,
|
|
260
|
+
input: DelegatedHumanAuthorization,
|
|
261
|
+
): void {
|
|
262
|
+
if (delegatedHumanRequests.has(request) || accessResolvedRequests.has(request)) {
|
|
263
|
+
throw new HTTPException(403, { message: "request access authorization already resolved" });
|
|
264
|
+
}
|
|
265
|
+
const parsed = delegatedHumanAuthorizationSchema.safeParse(input);
|
|
266
|
+
if (!parsed.success) {
|
|
267
|
+
throw new HTTPException(403, { message: "invalid delegated human authorization" });
|
|
268
|
+
}
|
|
269
|
+
const verified = parsed.data;
|
|
270
|
+
const workspaceScope: DelegatedHumanAuthorization["workspaceScope"] =
|
|
271
|
+
verified.workspaceScope.kind === "all"
|
|
272
|
+
? { kind: "all" }
|
|
273
|
+
: { kind: "selected", workspaceIds: [...verified.workspaceScope.workspaceIds] };
|
|
274
|
+
if (workspaceScope.kind === "selected") Object.freeze(workspaceScope.workspaceIds);
|
|
275
|
+
Object.freeze(workspaceScope);
|
|
276
|
+
const proof: DelegatedHumanAuthorization = {
|
|
277
|
+
organizationId: verified.organizationId,
|
|
278
|
+
subjectId: verified.subjectId,
|
|
279
|
+
permissions: [...new Set(verified.permissions)],
|
|
280
|
+
workspaceScope,
|
|
281
|
+
};
|
|
282
|
+
Object.freeze(proof.permissions);
|
|
283
|
+
delegatedHumanRequests.set(request, Object.freeze(proof));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Token prefix of personal API keys, which act as the person who made them. */
|
|
287
|
+
export const PERSONAL_API_KEY_TOKEN_PREFIX = "ogp_";
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A personal API key acts as its owner. Call once per incoming request, after
|
|
291
|
+
* any middleware that may rebuild the Request and before access resolution:
|
|
292
|
+
* an active personal key stamps this exact raw Request with its owner and
|
|
293
|
+
* access setting, so the request resolves through the same live-access path as
|
|
294
|
+
* a person's signed-in agent (their current membership, role and workspaces,
|
|
295
|
+
* capped by the key) and routes treat it as that person. Anything else is left
|
|
296
|
+
* to the ordinary resolver, which refuses an unknown or revoked key.
|
|
297
|
+
*/
|
|
298
|
+
export async function stampPersonalApiKeyRequest(
|
|
299
|
+
request: Request,
|
|
300
|
+
deps: Pick<AccessDeps, "db">,
|
|
301
|
+
): Promise<boolean> {
|
|
302
|
+
const authorization = request.headers.get("authorization");
|
|
303
|
+
if (!authorization?.startsWith(`${bearerPrefix}${PERSONAL_API_KEY_TOKEN_PREFIX}`)) return false;
|
|
304
|
+
if (
|
|
305
|
+
request.headers.has("x-opengeni-external-actor") ||
|
|
306
|
+
serviceInitiatorFromHeaders(request.headers)
|
|
307
|
+
) {
|
|
308
|
+
throw new HTTPException(403, {
|
|
309
|
+
message: "A personal API key acts only as the person who created it.",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const apiKey = await findActiveApiKeyByHash(
|
|
313
|
+
deps.db,
|
|
314
|
+
await sha256Hex(authorization.slice(bearerPrefix.length)),
|
|
315
|
+
);
|
|
316
|
+
if (!apiKey || apiKey.credentialKind !== "personal" || !apiKey.ownerSubjectId) return false;
|
|
317
|
+
stampDelegatedHumanAuthorization(request, {
|
|
318
|
+
organizationId: apiKey.accountId,
|
|
319
|
+
subjectId: apiKey.ownerSubjectId,
|
|
320
|
+
permissions: (apiKey.ownerPermissions ?? []).flatMap((permission) => {
|
|
321
|
+
const parsed = Permission.safeParse(permission);
|
|
322
|
+
return parsed.success ? [parsed.data] : [];
|
|
323
|
+
}),
|
|
324
|
+
workspaceScope: apiKey.workspaceScope ?? { kind: "all" },
|
|
325
|
+
});
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Immutable verified dispatch bounds for this exact raw Request, not a grant. */
|
|
330
|
+
export function verifiedDelegatedHumanAuthorizationForRequest(
|
|
331
|
+
request: Request,
|
|
332
|
+
): DelegatedHumanAuthorization | null {
|
|
333
|
+
return delegatedHumanRequests.get(request) ?? null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve the exact verified OAuth-dispatch person and constrained live native
|
|
338
|
+
* access for this raw Request. Unstamped requests (even browser cookies) fail
|
|
339
|
+
* closed; callers must not fall back to a service or subject-shaped identity.
|
|
340
|
+
* Uses the same request-local native resolution as requireAccessContext.
|
|
341
|
+
*
|
|
342
|
+
* No session, session id or managed-cookie proof is created. emailVerified is
|
|
343
|
+
* forwarded only when the native profile lookup supplies it; missing is not
|
|
344
|
+
* verified and must never be filled from OAuth claims, headers or metadata.
|
|
345
|
+
*/
|
|
346
|
+
export async function requireVerifiedDelegatedHumanContext(
|
|
347
|
+
c: Context,
|
|
348
|
+
deps: AccessDeps,
|
|
349
|
+
): Promise<VerifiedDelegatedHumanContext> {
|
|
350
|
+
const authorization = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
|
|
351
|
+
if (!authorization) {
|
|
352
|
+
throw new HTTPException(401, { message: "verified delegated human authorization required" });
|
|
353
|
+
}
|
|
354
|
+
const context = await requireAccessContext(c, deps);
|
|
355
|
+
const user = delegatedHumanProfiles.get(context);
|
|
356
|
+
if (delegatedHumanContexts.get(context) !== authorization || !user) {
|
|
357
|
+
throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
|
|
358
|
+
}
|
|
359
|
+
return Object.freeze({ user, subjectId: context.subjectId, authorization, context });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Exact resolved owning-person authorization; clones and service claims fail.
|
|
363
|
+
* Supplying the raw Request additionally binds proof to that exact dispatch,
|
|
364
|
+
* not another request with the same native person and authorization bounds. */
|
|
365
|
+
export function isVerifiedDelegatedHumanAuthorization(
|
|
366
|
+
authorization: AccessGrantAuthorization,
|
|
367
|
+
request?: Request,
|
|
368
|
+
): boolean {
|
|
369
|
+
const verified = verifiedDelegatedHumanAuthorizations.get(authorization);
|
|
370
|
+
return Boolean(
|
|
371
|
+
verified &&
|
|
372
|
+
(request === undefined || verified.proof === delegatedHumanRequests.get(request)) &&
|
|
373
|
+
authorization.contextIntegrity &&
|
|
374
|
+
!authorization.canonicalManagedHumanSession &&
|
|
375
|
+
!authorization.canonicalLocalHumanSession &&
|
|
376
|
+
authorization.grant === verified.grant &&
|
|
377
|
+
authorization.accountGrant === verified.accountGrant &&
|
|
378
|
+
authorization.authenticatedSubjectId === verified.proof.subjectId &&
|
|
379
|
+
verified.grant.subjectId === verified.proof.subjectId &&
|
|
380
|
+
verified.grant.accountId === verified.proof.organizationId &&
|
|
381
|
+
delegatedHumanWorkspaceAllowed(verified.proof, verified.grant.workspaceId),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function delegatedHumanWorkspaceAllowed(
|
|
386
|
+
proof: DelegatedHumanAuthorization,
|
|
387
|
+
workspaceId: string,
|
|
388
|
+
): boolean {
|
|
389
|
+
return (
|
|
390
|
+
proof.workspaceScope.kind === "all" || proof.workspaceScope.workspaceIds.includes(workspaceId)
|
|
391
|
+
);
|
|
392
|
+
}
|
|
62
393
|
const externalActorContexts = new WeakMap<
|
|
63
394
|
AccessContext,
|
|
64
395
|
{
|
|
65
396
|
identity: ExternalIdentity;
|
|
66
397
|
keyId: string;
|
|
67
398
|
permissions: Permission[];
|
|
399
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
400
|
+
permissionMode: "legacy" | "explicit";
|
|
68
401
|
linked?: NonNullable<Awaited<ReturnType<typeof resolveExternalIdentityLink>>>;
|
|
402
|
+
/**
|
|
403
|
+
* Plain external mode (no native link, no service-initiator attribution):
|
|
404
|
+
* the only lane where a missing shared-workspace membership may be created
|
|
405
|
+
* on first use. See {@link provisionExternalMemberOnFirstUse}.
|
|
406
|
+
*/
|
|
407
|
+
firstUseMembership: boolean;
|
|
69
408
|
}
|
|
70
409
|
>();
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Default permissions for a membership created on an external user's first
|
|
413
|
+
* request. Keep equal to `CONVERSATION_PERMISSIONS` in
|
|
414
|
+
* `packages/sdk/src/tenant-workspaces.ts`: conversation use only, no admin.
|
|
415
|
+
*/
|
|
416
|
+
export const EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS: readonly Permission[] = [
|
|
417
|
+
"workspace:read",
|
|
418
|
+
"sessions:create",
|
|
419
|
+
"sessions:read",
|
|
420
|
+
"sessions:control",
|
|
421
|
+
"files:upload",
|
|
422
|
+
"files:read",
|
|
423
|
+
"mcp_servers:attach",
|
|
424
|
+
];
|
|
71
425
|
function attributionForExternalContext(context: AccessContext): ExternalActorAttribution {
|
|
72
426
|
const external = externalActorContexts.get(context);
|
|
73
427
|
if (!external) throw new Error("Verified external context required");
|
|
@@ -90,6 +444,7 @@ const verifiedExternalAuthorizations = new WeakMap<
|
|
|
90
444
|
{
|
|
91
445
|
grant: AccessGrant;
|
|
92
446
|
workspaceId: string;
|
|
447
|
+
workspaceSettingsCeiling: boolean;
|
|
93
448
|
identityReference: { externalId: string; source: string };
|
|
94
449
|
attribution: ExternalActorAttribution;
|
|
95
450
|
}
|
|
@@ -122,7 +477,7 @@ export function externalActorContinuationForAuthorization(
|
|
|
122
477
|
return actor && verified ? { actor, identity: { ...verified.identityReference } } : null;
|
|
123
478
|
}
|
|
124
479
|
|
|
125
|
-
/** Dedicated owning-user proof. External admission never sets the native
|
|
480
|
+
/** Dedicated owning-user proof. External/OAuth admission never sets the native
|
|
126
481
|
* cookie stamp. The resource/session layer still checks the exact owner. */
|
|
127
482
|
export function hasVerifiedOwningUserAuthorization(
|
|
128
483
|
authorization: AccessGrantAuthorization,
|
|
@@ -134,12 +489,22 @@ export function hasVerifiedOwningUserAuthorization(
|
|
|
134
489
|
return false;
|
|
135
490
|
return (
|
|
136
491
|
authorization.canonicalManagedHumanSession ||
|
|
137
|
-
externalAttributionForAuthorization(authorization, authorization.grant) !== null
|
|
492
|
+
externalAttributionForAuthorization(authorization, authorization.grant) !== null ||
|
|
493
|
+
isVerifiedDelegatedHumanAuthorization(authorization)
|
|
138
494
|
);
|
|
139
495
|
}
|
|
140
496
|
const accountScopedApiKeyContexts = new WeakMap<
|
|
141
497
|
AccessContext,
|
|
142
|
-
Readonly<{
|
|
498
|
+
Readonly<{
|
|
499
|
+
accountId: string;
|
|
500
|
+
permissions: readonly Permission[];
|
|
501
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
502
|
+
permissionMode: "legacy" | "explicit";
|
|
503
|
+
}>
|
|
504
|
+
>();
|
|
505
|
+
const apiKeyServiceContexts = new WeakMap<
|
|
506
|
+
AccessContext,
|
|
507
|
+
Pick<AccessGrant, "serviceInitiator" | "serviceInitiatorContext">
|
|
143
508
|
>();
|
|
144
509
|
const verifiedOrganizationServiceAuthorizations = new WeakMap<
|
|
145
510
|
AccessGrantAuthorization,
|
|
@@ -163,6 +528,7 @@ const accountScopedApiKeyAccountPermissions = new Set<Permission>([
|
|
|
163
528
|
"billing:read",
|
|
164
529
|
"billing:manage",
|
|
165
530
|
"api_keys:manage",
|
|
531
|
+
"usage_allowances:manage",
|
|
166
532
|
]);
|
|
167
533
|
const accountScopedApiKeyWorkspaceExcludedPermissions = new Set<Permission>([
|
|
168
534
|
"account:read",
|
|
@@ -170,13 +536,23 @@ const accountScopedApiKeyWorkspaceExcludedPermissions = new Set<Permission>([
|
|
|
170
536
|
"workspace:create",
|
|
171
537
|
"billing:read",
|
|
172
538
|
"billing:manage",
|
|
539
|
+
"usage_allowances:manage",
|
|
173
540
|
]);
|
|
174
541
|
|
|
175
542
|
export type AccountScopedApiKeyWorkspaceAuthority = Readonly<{
|
|
176
543
|
accountId: string;
|
|
177
544
|
permissions: Permission[];
|
|
545
|
+
workspaceScope: OrganizationWorkspaceScope;
|
|
546
|
+
permissionMode: "legacy" | "explicit";
|
|
178
547
|
}>;
|
|
179
548
|
|
|
549
|
+
export function organizationWorkspaceInScope(
|
|
550
|
+
scope: OrganizationWorkspaceScope,
|
|
551
|
+
workspaceId: string,
|
|
552
|
+
): boolean {
|
|
553
|
+
return scope.kind === "all" || scope.workspaceIds.includes(workspaceId.toLowerCase());
|
|
554
|
+
}
|
|
555
|
+
|
|
180
556
|
/**
|
|
181
557
|
* Return account-scoped API-key workspace authority only for the exact
|
|
182
558
|
* AccessContext object stamped by successful API-key authentication. Subject
|
|
@@ -200,10 +576,27 @@ export function accountScopedApiKeyWorkspaceAuthority(
|
|
|
200
576
|
}
|
|
201
577
|
return {
|
|
202
578
|
accountId: authority.accountId,
|
|
203
|
-
permissions:
|
|
579
|
+
permissions:
|
|
580
|
+
authority.permissionMode === "explicit"
|
|
581
|
+
? explicitPermissions([...authority.permissions])
|
|
582
|
+
: [...authority.permissions],
|
|
583
|
+
workspaceScope: authority.workspaceScope,
|
|
584
|
+
permissionMode: authority.permissionMode,
|
|
204
585
|
};
|
|
205
586
|
}
|
|
206
587
|
|
|
588
|
+
/** Classify stored scopes without changing the legacy full/read permission sets. */
|
|
589
|
+
export function organizationApiKeyAccess(permissions: Permission[]): OrganizationApiKeyAccess {
|
|
590
|
+
if (
|
|
591
|
+
permissions.length === DEVELOPER_SETUP_API_KEY_PRESET.permissions.length &&
|
|
592
|
+
DEVELOPER_SETUP_API_KEY_PRESET.permissions.every((permission) =>
|
|
593
|
+
permissions.includes(permission),
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
return "developer_setup";
|
|
597
|
+
return permissions.includes("workspace:admin") ? "full" : "read";
|
|
598
|
+
}
|
|
599
|
+
|
|
207
600
|
/**
|
|
208
601
|
* Opaque, request-local proof that the canonical access resolver authorized the
|
|
209
602
|
* exact account administrator named by the stamp. The random id is audit and
|
|
@@ -222,6 +615,8 @@ export type AccessDeps = {
|
|
|
222
615
|
settings: Settings;
|
|
223
616
|
managedAuth?: ManagedAuth | null;
|
|
224
617
|
managedAuthSessionAdapter?: ManagedAuthSessionAdapter | null;
|
|
618
|
+
/** Analytics only: notes canonical managed-cookie activity, never authority. */
|
|
619
|
+
userPresence?: UserPresenceRecorder | null;
|
|
225
620
|
};
|
|
226
621
|
|
|
227
622
|
/** null means this is not an authenticated external lane; [] means that lane
|
|
@@ -245,10 +640,16 @@ export async function listExternalActorWorkspaces(
|
|
|
245
640
|
const personal = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
|
|
246
641
|
requireWorkspace(tx, actor.linked?.personalWorkspaceId ?? actor.identity.personalWorkspaceId),
|
|
247
642
|
);
|
|
248
|
-
if (
|
|
643
|
+
if (
|
|
644
|
+
personal.accountId === actor.identity.accountId &&
|
|
645
|
+
personal.kind === "personal" &&
|
|
646
|
+
actor.permissionMode === "legacy" &&
|
|
647
|
+
organizationWorkspaceInScope(actor.workspaceScope, personal.id)
|
|
648
|
+
)
|
|
249
649
|
authorized.push(personal);
|
|
250
650
|
for (const workspace of candidates) {
|
|
251
651
|
if (workspace.accountId !== actor.identity.accountId || workspace.kind !== "shared") continue;
|
|
652
|
+
if (!organizationWorkspaceInScope(actor.workspaceScope, workspace.id)) continue;
|
|
252
653
|
const grant = await withWorkspaceSubjectRls(deps.db, workspace.id, context.subjectId, (tx) =>
|
|
253
654
|
getWorkspaceGrant(tx, context.subjectId, workspace.id),
|
|
254
655
|
);
|
|
@@ -305,8 +706,9 @@ export type AccessGrantAuthorization = {
|
|
|
305
706
|
contextIntegrity: boolean;
|
|
306
707
|
/**
|
|
307
708
|
* Did this request authenticate as the canonical managed-cookie (Better Auth)
|
|
308
|
-
* session that OWNS this grant's subject?
|
|
309
|
-
*
|
|
709
|
+
* session that OWNS this grant's subject? Cookie/person-present ceremonies
|
|
710
|
+
* retain this strict gate; owning-user boundaries use
|
|
711
|
+
* `hasVerifiedOwningUserAuthorization` instead.
|
|
310
712
|
* False for every bearer, API-key, delegated, service, local, and configured
|
|
311
713
|
* principal, and for any future path that does not verify a cookie.
|
|
312
714
|
*/
|
|
@@ -348,6 +750,10 @@ export function accessGrantAuthorizationFromContext(
|
|
|
348
750
|
canonicalLocalHumanSession: isCanonicalLocalHumanSession(context, grant),
|
|
349
751
|
};
|
|
350
752
|
resolvedAccessGrantAuthorizations.add(authorization);
|
|
753
|
+
if (contextIntegrity && developerSetupApiKeyContexts.has(context)) {
|
|
754
|
+
developerSetupAuthorizations.set(authorization, grant);
|
|
755
|
+
developerSetupGrants.add(grant);
|
|
756
|
+
}
|
|
351
757
|
if (
|
|
352
758
|
contextIntegrity &&
|
|
353
759
|
accountScopedApiKeyWorkspaceAuthority(context)?.accountId === grant.accountId &&
|
|
@@ -360,6 +766,9 @@ export function accessGrantAuthorizationFromContext(
|
|
|
360
766
|
verifiedExternalAuthorizations.set(authorization, {
|
|
361
767
|
grant,
|
|
362
768
|
workspaceId: grant.workspaceId,
|
|
769
|
+
workspaceSettingsCeiling:
|
|
770
|
+
hasPermission(external.permissions, "workspace:admin") &&
|
|
771
|
+
(!external.linked || hasPermission(external.linked.link.permissions, "workspace:admin")),
|
|
363
772
|
identityReference: {
|
|
364
773
|
externalId: external.identity.externalId,
|
|
365
774
|
source: external.identity.source,
|
|
@@ -367,6 +776,27 @@ export function accessGrantAuthorizationFromContext(
|
|
|
367
776
|
attribution: attributionForExternalContext(context),
|
|
368
777
|
});
|
|
369
778
|
}
|
|
779
|
+
const delegatedHuman = delegatedHumanContexts.get(context);
|
|
780
|
+
if (
|
|
781
|
+
delegatedHuman &&
|
|
782
|
+
contextIntegrity &&
|
|
783
|
+
context.workspaceGrants.includes(grant) &&
|
|
784
|
+
grant.accountId === delegatedHuman.organizationId &&
|
|
785
|
+
grant.subjectId === delegatedHuman.subjectId &&
|
|
786
|
+
grant.principalKind === "human_session" &&
|
|
787
|
+
!grant.serviceInitiator &&
|
|
788
|
+
!grant.serviceInitiatorContext &&
|
|
789
|
+
delegatedHumanWorkspaceAllowed(delegatedHuman, grant.workspaceId)
|
|
790
|
+
) {
|
|
791
|
+
verifiedDelegatedHumanAuthorizations.set(authorization, {
|
|
792
|
+
proof: delegatedHuman,
|
|
793
|
+
grant,
|
|
794
|
+
accountGrant: matchingAccountGrants[0]!,
|
|
795
|
+
});
|
|
796
|
+
// Keep the exact proof-bearing value immutable: changing a cookie flag,
|
|
797
|
+
// subject or grant in place must not manufacture browser authentication.
|
|
798
|
+
Object.freeze(authorization);
|
|
799
|
+
}
|
|
370
800
|
return authorization;
|
|
371
801
|
}
|
|
372
802
|
|
|
@@ -464,13 +894,19 @@ export async function requireAccessGrantAuthorization(
|
|
|
464
894
|
permission?: Permission,
|
|
465
895
|
): Promise<AccessGrantAuthorization> {
|
|
466
896
|
const context = await requireAccessContext(c, deps);
|
|
467
|
-
|
|
897
|
+
// Request entry only: a fresh re-check of a live connection never re-creates
|
|
898
|
+
// a membership that was removed while the connection was open.
|
|
899
|
+
return await accessGrantAuthorization(context, deps, workspaceId, permission, {
|
|
900
|
+
firstUseMembership: true,
|
|
901
|
+
});
|
|
468
902
|
}
|
|
469
903
|
|
|
470
904
|
/**
|
|
471
905
|
* Settings administration is narrower than workspace administration. The
|
|
472
|
-
*
|
|
906
|
+
* verified owning user may configure their Personal workspace, but
|
|
473
907
|
* never acquires the admin wildcard (and its membership/delegation powers).
|
|
908
|
+
* Delegated owners also need workspace:admin in their verified dispatch/key
|
|
909
|
+
* ceiling: a read-only delegation cannot turn ownership into a settings write.
|
|
474
910
|
* Only use this boundary for workspace configuration, not access management.
|
|
475
911
|
*/
|
|
476
912
|
export async function requireWorkspaceSettingsGrant(
|
|
@@ -481,8 +917,15 @@ export async function requireWorkspaceSettingsGrant(
|
|
|
481
917
|
const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
|
|
482
918
|
const grant = requireResolvedAccessGrantAuthorization(authorization, workspaceId);
|
|
483
919
|
if (hasPermission(grant.permissions, "workspace:admin")) return grant;
|
|
920
|
+
const delegatedHuman = verifiedDelegatedHumanAuthorizations.get(authorization);
|
|
921
|
+
const ownerSettingsCeiling =
|
|
922
|
+
authorization.canonicalManagedHumanSession ||
|
|
923
|
+
verifiedExternalAuthorizations.get(authorization)?.workspaceSettingsCeiling === true ||
|
|
924
|
+
(delegatedHuman !== undefined && delegatedHuman.proof.permissions.includes("workspace:admin"));
|
|
484
925
|
if (
|
|
485
|
-
authorization
|
|
926
|
+
hasVerifiedOwningUserAuthorization(authorization) &&
|
|
927
|
+
ownerSettingsCeiling &&
|
|
928
|
+
hasPermission(grant.permissions, "workspace:read") &&
|
|
486
929
|
(await resolveNamedManagedPersonalWorkspaceGrant(deps.db, {
|
|
487
930
|
accountId: grant.accountId,
|
|
488
931
|
workspaceId,
|
|
@@ -496,29 +939,183 @@ export async function requireWorkspaceSettingsGrant(
|
|
|
496
939
|
});
|
|
497
940
|
}
|
|
498
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Authority for a shared workspace's own Members surface (list, candidates,
|
|
944
|
+
* add, change, remove). A holder of the requested workspace permission keeps
|
|
945
|
+
* the ordinary grant. Otherwise an active organization owner or administrator,
|
|
946
|
+
* authenticated by the canonical managed cookie, manages any shared workspace
|
|
947
|
+
* in their organization exactly as through the organization control plane,
|
|
948
|
+
* with or without an operational membership row there. Every other principal
|
|
949
|
+
* (API keys, delegated bearers, agents, services, local/configured access)
|
|
950
|
+
* and every Personal workspace keeps the original refusal. The database
|
|
951
|
+
* functions re-derive the organization role under the organization fence.
|
|
952
|
+
*/
|
|
953
|
+
export async function requireWorkspaceMemberManagementAuthority(
|
|
954
|
+
c: Context,
|
|
955
|
+
deps: AccessDeps,
|
|
956
|
+
workspaceId: string,
|
|
957
|
+
permission: "members:manage" | "workspace:read",
|
|
958
|
+
): Promise<{ grant: AccessGrant; organizationAdministrator: boolean }> {
|
|
959
|
+
try {
|
|
960
|
+
return {
|
|
961
|
+
grant: await requireAccessGrant(c, deps, workspaceId, permission),
|
|
962
|
+
organizationAdministrator: false,
|
|
963
|
+
};
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (!(error instanceof HTTPException) || error.status !== 403) throw error;
|
|
966
|
+
const context = await requireAccessContext(c, deps);
|
|
967
|
+
if (!canonicalManagedCookieContexts.has(context) || !context.subjectId.startsWith("user:")) {
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
971
|
+
if (!workspace || workspace.kind !== "shared") throw error;
|
|
972
|
+
const organizationRole = context.accountGrants.find(
|
|
973
|
+
(candidate) =>
|
|
974
|
+
candidate.accountId === workspace.accountId && candidate.subjectId === context.subjectId,
|
|
975
|
+
)?.role;
|
|
976
|
+
if (organizationRole !== "owner" && organizationRole !== "admin") throw error;
|
|
977
|
+
return {
|
|
978
|
+
grant: {
|
|
979
|
+
workspaceId,
|
|
980
|
+
accountId: workspace.accountId,
|
|
981
|
+
subjectId: context.subjectId,
|
|
982
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
983
|
+
principalKind: "human_session",
|
|
984
|
+
permissions: ["workspace:read", "members:manage"],
|
|
985
|
+
},
|
|
986
|
+
organizationAdministrator: true,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Automatic membership for an organization key acting as an external user
|
|
993
|
+
* (`asUser`): the first request to a shared workspace in the key's own
|
|
994
|
+
* organization creates the user's missing membership with
|
|
995
|
+
* {@link EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS}, then the request continues.
|
|
996
|
+
* It is exactly the authority of explicit `addExternalWorkspaceMember`: the
|
|
997
|
+
* calling key must hold `members:manage` (or legacy `workspace:admin`) plus
|
|
998
|
+
* every default permission, with the workspace in its scope, re-checked live
|
|
999
|
+
* under the organization membership fence together with the identity's own
|
|
1000
|
+
* active status. Never for linked native identities, service-initiator
|
|
1001
|
+
* requests, or any non-key principal (agent attempts, delegated/bearer user
|
|
1002
|
+
* tokens, browser sessions), which never reach the external lane; never for
|
|
1003
|
+
* Personal or SDK per-user workspaces; never when the request needs a
|
|
1004
|
+
* permission outside the defaults. Never changes an existing membership.
|
|
1005
|
+
* Returns true only when a membership now exists; any refusal leaves the
|
|
1006
|
+
* ordinary 403 in place.
|
|
1007
|
+
*/
|
|
1008
|
+
async function provisionExternalMemberOnFirstUse(
|
|
1009
|
+
deps: AccessDeps,
|
|
1010
|
+
context: AccessContext,
|
|
1011
|
+
external: NonNullable<ReturnType<typeof externalActorContexts.get>>,
|
|
1012
|
+
workspaceId: string,
|
|
1013
|
+
permission: Permission | undefined,
|
|
1014
|
+
): Promise<boolean> {
|
|
1015
|
+
if (
|
|
1016
|
+
!external.firstUseMembership ||
|
|
1017
|
+
external.linked ||
|
|
1018
|
+
// A request that would 403 on its own permission anyway creates nothing.
|
|
1019
|
+
(permission !== undefined && !EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.includes(permission)) ||
|
|
1020
|
+
context.subjectId !== external.identity.subjectId ||
|
|
1021
|
+
!hasPermission(external.permissions, "members:manage", external.permissionMode) ||
|
|
1022
|
+
EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.some(
|
|
1023
|
+
(required) => !hasPermission(external.permissions, required, external.permissionMode),
|
|
1024
|
+
)
|
|
1025
|
+
)
|
|
1026
|
+
return false;
|
|
1027
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
1028
|
+
if (
|
|
1029
|
+
!workspace ||
|
|
1030
|
+
workspace.kind !== "shared" ||
|
|
1031
|
+
workspace.accountId !== external.identity.accountId ||
|
|
1032
|
+
// SDK per-user workspaces stay single-user: the SDK adds their owner.
|
|
1033
|
+
workspace.externalSource?.startsWith(USER_ISOLATION_WORKSPACE_SOURCE_PREFIX)
|
|
1034
|
+
)
|
|
1035
|
+
return false;
|
|
1036
|
+
try {
|
|
1037
|
+
await ensureExternalWorkspaceMemberOnFirstUse(
|
|
1038
|
+
deps.db,
|
|
1039
|
+
{
|
|
1040
|
+
organizationId: external.identity.accountId,
|
|
1041
|
+
workspaceId,
|
|
1042
|
+
actorSubjectId: `api_key:${external.keyId}`,
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
subjectId: external.identity.subjectId,
|
|
1046
|
+
identity: { source: external.identity.source, externalId: external.identity.externalId },
|
|
1047
|
+
permissions: EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS,
|
|
1048
|
+
},
|
|
1049
|
+
);
|
|
1050
|
+
return true;
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
// A changed/revoked key, narrowed policy or workspace scope keeps the
|
|
1053
|
+
// ordinary denial. Anything else is an infrastructure failure.
|
|
1054
|
+
if (
|
|
1055
|
+
(error as { code?: unknown } | null)?.code === "42501" ||
|
|
1056
|
+
nestedPostgresSqlState(error) === "42501"
|
|
1057
|
+
)
|
|
1058
|
+
return false;
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
499
1063
|
async function accessGrantAuthorization(
|
|
500
1064
|
context: AccessContext,
|
|
501
1065
|
deps: AccessDeps,
|
|
502
1066
|
workspaceId: string,
|
|
503
1067
|
permission?: Permission,
|
|
1068
|
+
options: { firstUseMembership?: boolean } = {},
|
|
504
1069
|
): Promise<AccessGrantAuthorization> {
|
|
1070
|
+
// No named-subject or organization-key fallback may widen verified OAuth
|
|
1071
|
+
// bounds. These grants came from this resolution's live native access only.
|
|
1072
|
+
if (delegatedHumanContexts.has(context)) {
|
|
1073
|
+
const grant = context.workspaceGrants.find(
|
|
1074
|
+
(candidate) => candidate.workspaceId === workspaceId,
|
|
1075
|
+
);
|
|
1076
|
+
if (!grant)
|
|
1077
|
+
throw new HTTPException(403, { message: "delegated human workspace access denied" });
|
|
1078
|
+
if (permission) requirePermission(grant, permission);
|
|
1079
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
1080
|
+
}
|
|
505
1081
|
const external = externalActorContexts.get(context);
|
|
506
1082
|
if (external) {
|
|
507
|
-
|
|
1083
|
+
if (!organizationWorkspaceInScope(external.workspaceScope, workspaceId)) {
|
|
1084
|
+
throw new HTTPException(403, { message: "workspace is outside organization key scope" });
|
|
1085
|
+
}
|
|
1086
|
+
if (external.permissionMode === "explicit") {
|
|
1087
|
+
const workspace = await requireWorkspace(deps.db, workspaceId);
|
|
1088
|
+
if (workspace.kind !== "shared" || workspace.accountId !== external.identity.accountId)
|
|
1089
|
+
throw new HTTPException(403, {
|
|
1090
|
+
message: "organization policy requires a shared workspace",
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
const personal =
|
|
508
1094
|
workspaceId ===
|
|
509
|
-
(external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId)
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1095
|
+
(external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId);
|
|
1096
|
+
const membershipGrant = () =>
|
|
1097
|
+
withWorkspaceSubjectRls(deps.db, workspaceId, context.subjectId, (tx) =>
|
|
1098
|
+
getWorkspaceGrant(tx, context.subjectId, workspaceId, {
|
|
1099
|
+
principalKind: "human_session",
|
|
1100
|
+
}),
|
|
1101
|
+
);
|
|
1102
|
+
let grant: AccessGrant | null = personal
|
|
1103
|
+
? {
|
|
1104
|
+
accountId: external.identity.accountId,
|
|
1105
|
+
workspaceId,
|
|
1106
|
+
subjectId: context.subjectId,
|
|
1107
|
+
principalKind: "human_session",
|
|
1108
|
+
permissions: [...managedPersonalWorkspacePermissions],
|
|
1109
|
+
}
|
|
1110
|
+
: await membershipGrant();
|
|
1111
|
+
if (
|
|
1112
|
+
!grant &&
|
|
1113
|
+
!personal &&
|
|
1114
|
+
options.firstUseMembership === true &&
|
|
1115
|
+
(await provisionExternalMemberOnFirstUse(deps, context, external, workspaceId, permission))
|
|
1116
|
+
) {
|
|
1117
|
+
grant = await membershipGrant();
|
|
1118
|
+
}
|
|
522
1119
|
if (!grant || grant.accountId !== external.identity.accountId) {
|
|
523
1120
|
throw new HTTPException(403, { message: "external workspace access denied" });
|
|
524
1121
|
}
|
|
@@ -531,6 +1128,10 @@ async function accessGrantAuthorization(
|
|
|
531
1128
|
hasPermission(external.permissions, value) &&
|
|
532
1129
|
(!external.linked || hasPermission(external.linked.link.permissions, value)),
|
|
533
1130
|
);
|
|
1131
|
+
if (external.permissionMode === "explicit") {
|
|
1132
|
+
grant.permissionMode = "explicit";
|
|
1133
|
+
explicitPermissions(grant.permissions);
|
|
1134
|
+
}
|
|
534
1135
|
grant.metadata = {
|
|
535
1136
|
...grant.metadata,
|
|
536
1137
|
externalActor: attributionForExternalContext(context),
|
|
@@ -538,6 +1139,30 @@ async function accessGrantAuthorization(
|
|
|
538
1139
|
if (permission) requirePermission(grant, permission);
|
|
539
1140
|
return accessGrantAuthorizationFromContext(context, grant);
|
|
540
1141
|
}
|
|
1142
|
+
const organizationKey = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1143
|
+
if (organizationKey) {
|
|
1144
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
1145
|
+
if (!workspace) throw new HTTPException(404, { message: "workspace not found" });
|
|
1146
|
+
if (
|
|
1147
|
+
workspace.accountId !== organizationKey.accountId ||
|
|
1148
|
+
workspace.kind !== "shared" ||
|
|
1149
|
+
!organizationWorkspaceInScope(organizationKey.workspaceScope, workspace.id)
|
|
1150
|
+
) {
|
|
1151
|
+
throw new HTTPException(403, { message: "workspace is outside organization key scope" });
|
|
1152
|
+
}
|
|
1153
|
+
const grant: AccessGrant = {
|
|
1154
|
+
accountId: workspace.accountId,
|
|
1155
|
+
workspaceId,
|
|
1156
|
+
subjectId: context.subjectId,
|
|
1157
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
1158
|
+
principalKind: "api_key",
|
|
1159
|
+
permissions: organizationKey.permissions,
|
|
1160
|
+
permissionMode: organizationKey.permissionMode,
|
|
1161
|
+
...apiKeyServiceContexts.get(context),
|
|
1162
|
+
};
|
|
1163
|
+
requirePermission(grant, permission ?? "workspace:read");
|
|
1164
|
+
return accessGrantAuthorizationFromContext(context, grant);
|
|
1165
|
+
}
|
|
541
1166
|
const principalKind = hostedHumanSessionPrincipalKind(context);
|
|
542
1167
|
let grant =
|
|
543
1168
|
context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
|
|
@@ -547,6 +1172,28 @@ async function accessGrantAuthorization(
|
|
|
547
1172
|
workspaceId,
|
|
548
1173
|
principalKind ? { principalKind } : undefined,
|
|
549
1174
|
));
|
|
1175
|
+
if (grant && isDeveloperSetupApiKeyContext(context)) {
|
|
1176
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1177
|
+
const workspace = await requireWorkspace(deps.db, workspaceId);
|
|
1178
|
+
if (!authority || workspace.accountId !== authority.accountId || workspace.kind !== "shared") {
|
|
1179
|
+
throw new HTTPException(403, {
|
|
1180
|
+
message: "Developer setup requires a shared organization workspace",
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
// A persisted creator/membership grant cannot widen the authenticated key
|
|
1184
|
+
// ceiling, including literal secret reads and inherited session authority.
|
|
1185
|
+
const storedPermissions = grant.permissions;
|
|
1186
|
+
grant = {
|
|
1187
|
+
...grant,
|
|
1188
|
+
permissions: Permission.options.filter(
|
|
1189
|
+
(value) =>
|
|
1190
|
+
value !== "api_keys:manage" &&
|
|
1191
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(value) &&
|
|
1192
|
+
hasPermission(storedPermissions, value) &&
|
|
1193
|
+
hasPermission(authority.permissions, value),
|
|
1194
|
+
),
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
550
1197
|
if (!grant) {
|
|
551
1198
|
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
552
1199
|
if (!workspace) {
|
|
@@ -567,6 +1214,7 @@ async function accessGrantAuthorization(
|
|
|
567
1214
|
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
568
1215
|
permissions: authority.permissions,
|
|
569
1216
|
principalKind: "api_key",
|
|
1217
|
+
...apiKeyServiceContexts.get(context),
|
|
570
1218
|
};
|
|
571
1219
|
} else {
|
|
572
1220
|
throw new HTTPException(403, { message: "workspace access denied" });
|
|
@@ -579,7 +1227,7 @@ async function accessGrantAuthorization(
|
|
|
579
1227
|
}
|
|
580
1228
|
|
|
581
1229
|
/**
|
|
582
|
-
*
|
|
1230
|
+
* Did the managed cookie authenticate this exact native person?
|
|
583
1231
|
*
|
|
584
1232
|
* A managed human's personal workspace carries no `workspace_memberships` row,
|
|
585
1233
|
* so seams that fence on one must consult the
|
|
@@ -615,6 +1263,30 @@ function isCanonicalManagedHumanSession(context: AccessContext, grant: AccessGra
|
|
|
615
1263
|
);
|
|
616
1264
|
}
|
|
617
1265
|
|
|
1266
|
+
/**
|
|
1267
|
+
* The subject whose inbox this request may read, or null when it has none.
|
|
1268
|
+
* A signed-in person (`user:`, no key) requires verified managed-human
|
|
1269
|
+
* provenance, including approved native app credentials. The local
|
|
1270
|
+
* install's human qualifies only when the in-process local bootstrap produced
|
|
1271
|
+
* this context (so a delegated bearer naming `dev` cannot borrow it), with the
|
|
1272
|
+
* human-session, non-delegated, non-service, keyless shape of
|
|
1273
|
+
* {@link accessContextHasInbox}. An agent acting as a person (verified
|
|
1274
|
+
* in-process dispatch) reads that person's inbox within its organization and
|
|
1275
|
+
* workspaces: the inbox routes check each item's workspace on this context.
|
|
1276
|
+
*/
|
|
1277
|
+
export function inboxSubjectForContext(context: AccessContext): string | null {
|
|
1278
|
+
if (!accessContextHasInbox(context)) return null;
|
|
1279
|
+
if (context.subjectId.startsWith("user:")) {
|
|
1280
|
+
return canonicalManagedCookieContexts.has(context) || delegatedHumanContexts.has(context)
|
|
1281
|
+
? context.subjectId
|
|
1282
|
+
: null;
|
|
1283
|
+
}
|
|
1284
|
+
return canonicalLocalHumanContexts.has(context) &&
|
|
1285
|
+
context.workspaceGrants.every((grant) => isCanonicalLocalHumanSession(context, grant))
|
|
1286
|
+
? context.subjectId
|
|
1287
|
+
: null;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
618
1290
|
function isCanonicalLocalHumanSession(context: AccessContext, grant: AccessGrant): boolean {
|
|
619
1291
|
return (
|
|
620
1292
|
canonicalLocalHumanContexts.has(context) &&
|
|
@@ -642,6 +1314,7 @@ function hostedHumanSessionPrincipalKind(context: AccessContext): "human_session
|
|
|
642
1314
|
}
|
|
643
1315
|
|
|
644
1316
|
export function requirePermission(grant: AccessGrant, permission: Permission): void {
|
|
1317
|
+
if (grant.permissionMode === "explicit") explicitPermissions(grant.permissions);
|
|
645
1318
|
if (!hasPermission(grant.permissions, permission)) {
|
|
646
1319
|
if (permission === "variable-sets:use") {
|
|
647
1320
|
throw new HTTPException(403, {
|
|
@@ -673,23 +1346,63 @@ export function requireLiteralPermission(grant: AccessGrant, permission: Permiss
|
|
|
673
1346
|
}
|
|
674
1347
|
}
|
|
675
1348
|
|
|
676
|
-
export function hasLiteralPermission(
|
|
1349
|
+
export function hasLiteralPermission(
|
|
1350
|
+
permissions: readonly Permission[],
|
|
1351
|
+
permission: Permission,
|
|
1352
|
+
): boolean {
|
|
677
1353
|
if (!Array.isArray(permissions)) return false;
|
|
678
1354
|
return permissions.includes(permission);
|
|
679
1355
|
}
|
|
680
1356
|
|
|
681
|
-
export function hasPermission(
|
|
1357
|
+
export function hasPermission(
|
|
1358
|
+
permissions: readonly Permission[],
|
|
1359
|
+
permission: Permission,
|
|
1360
|
+
permissionMode?: AccessGrant["permissionMode"],
|
|
1361
|
+
): boolean {
|
|
682
1362
|
if (!Array.isArray(permissions)) return false;
|
|
683
1363
|
if (permission === "secrets:read") {
|
|
684
1364
|
return permissions.includes("secrets:read");
|
|
685
1365
|
}
|
|
1366
|
+
const granted = permissions.includes(permission) || grantsSecretAlias(permissions, permission);
|
|
1367
|
+
if (permissionMode === "explicit" || explicitPermissionSets.has(permissions)) return granted;
|
|
686
1368
|
// Variable-set metadata, plaintext read, write/rotation, attachment, and
|
|
687
1369
|
// runtime use are independent capabilities. Deprecated broad permissions
|
|
688
1370
|
// remain parseable but do not imply any of the exact permissions.
|
|
689
|
-
return
|
|
1371
|
+
return granted || permissions.includes("workspace:admin");
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Stored roles and API keys keep their legacy variable-set names (Q-16): a
|
|
1376
|
+
* canonical `secrets:*` requirement is met by any granted name that aliases to
|
|
1377
|
+
* it. Legacy requirements (the variable-set routes, until they are removed)
|
|
1378
|
+
* keep their exact meaning. `secrets:read` is never aliased.
|
|
1379
|
+
*/
|
|
1380
|
+
function grantsSecretAlias(permissions: readonly Permission[], permission: Permission): boolean {
|
|
1381
|
+
if (normalizePermission(permission) !== permission) return false;
|
|
1382
|
+
return permissions.some(
|
|
1383
|
+
(granted) => granted !== permission && normalizePermission(granted) === permission,
|
|
1384
|
+
);
|
|
690
1385
|
}
|
|
691
1386
|
|
|
692
1387
|
async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
|
|
1388
|
+
accessResolvedRequests.add(c.req.raw);
|
|
1389
|
+
const delegatedHuman = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
|
|
1390
|
+
if (delegatedHuman) return resolveDelegatedHumanAccessContext(deps, delegatedHuman);
|
|
1391
|
+
const service = serviceInitiatorFromHeaders(c.req.raw.headers);
|
|
1392
|
+
if (service) {
|
|
1393
|
+
if (deps.settings.productAccessMode === "local") {
|
|
1394
|
+
throw new HTTPException(422, {
|
|
1395
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
const context = await apiKeyAccessContext(c, deps, deps.settings.productAccessMode);
|
|
1399
|
+
if (!context) {
|
|
1400
|
+
throw new HTTPException(422, {
|
|
1401
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return context;
|
|
1405
|
+
}
|
|
693
1406
|
if (c.req.header("x-opengeni-external-actor") !== undefined) {
|
|
694
1407
|
if (deps.settings.productAccessMode === "local") {
|
|
695
1408
|
throw new HTTPException(401, {
|
|
@@ -743,6 +1456,24 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
743
1456
|
|
|
744
1457
|
const bearer = bearerToken(c);
|
|
745
1458
|
if (bearer) {
|
|
1459
|
+
if (bearer.startsWith(NATIVE_APP_CREDENTIAL_PREFIX)) {
|
|
1460
|
+
// A native app account: the person approved this device from a signed-in
|
|
1461
|
+
// browser, so it carries the same human authority as that browser.
|
|
1462
|
+
if (!deps.managedAuth) return null;
|
|
1463
|
+
const session = await getNativeAppManagedSession(deps.managedAuth, bearer, deps.db);
|
|
1464
|
+
if (!session?.user) return null;
|
|
1465
|
+
const context = await ensureManagedAccessForUser(deps.db, {
|
|
1466
|
+
userId: session.user.id,
|
|
1467
|
+
email: session.user.email,
|
|
1468
|
+
name: session.user.name,
|
|
1469
|
+
emailVerified: session.user.emailVerified,
|
|
1470
|
+
provisionFallbackOrganization: false,
|
|
1471
|
+
bindPendingInvitations: false,
|
|
1472
|
+
});
|
|
1473
|
+
canonicalManagedCookieContexts.add(context);
|
|
1474
|
+
recordUserPresence(c, deps, context.subjectId);
|
|
1475
|
+
return context;
|
|
1476
|
+
}
|
|
746
1477
|
const delegated = await delegatedAccessContext(c, deps, "managed", bearer);
|
|
747
1478
|
if (delegated) {
|
|
748
1479
|
return delegated;
|
|
@@ -773,6 +1504,7 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
773
1504
|
bindPendingInvitations: false,
|
|
774
1505
|
});
|
|
775
1506
|
canonicalManagedCookieContexts.add(context);
|
|
1507
|
+
recordUserPresence(c, deps, context.subjectId);
|
|
776
1508
|
return context;
|
|
777
1509
|
}
|
|
778
1510
|
}
|
|
@@ -780,6 +1512,124 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
780
1512
|
return null;
|
|
781
1513
|
}
|
|
782
1514
|
|
|
1515
|
+
async function resolveDelegatedHumanAccessContext(
|
|
1516
|
+
deps: AccessDeps,
|
|
1517
|
+
proof: DelegatedHumanAuthorization,
|
|
1518
|
+
): Promise<AccessContext> {
|
|
1519
|
+
if (deps.settings.productAccessMode === "local") {
|
|
1520
|
+
throw new HTTPException(403, {
|
|
1521
|
+
message: "delegated humans require native organization access",
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
const userId = proof.subjectId.slice("user:".length);
|
|
1525
|
+
const profiles = await getManagedUserProfilesByIds(deps.db, [userId]);
|
|
1526
|
+
const parsedProfile = delegatedHumanProfileSchema.safeParse(
|
|
1527
|
+
profiles.length === 1 && profiles[0]?.id === userId ? profiles[0] : null,
|
|
1528
|
+
);
|
|
1529
|
+
if (!parsedProfile.success)
|
|
1530
|
+
throw new HTTPException(403, { message: "delegated native person is unavailable" });
|
|
1531
|
+
const profile = parsedProfile.data;
|
|
1532
|
+
const live = await ensureManagedAccessForUser(deps.db, {
|
|
1533
|
+
userId: profile.id,
|
|
1534
|
+
email: profile.email,
|
|
1535
|
+
name: profile.name ?? "",
|
|
1536
|
+
provisionFallbackOrganization: false,
|
|
1537
|
+
bindPendingInvitations: false,
|
|
1538
|
+
});
|
|
1539
|
+
const accounts = live.accountGrants.filter((grant) => grant.accountId === proof.organizationId);
|
|
1540
|
+
if (
|
|
1541
|
+
live.mode !== "managed" ||
|
|
1542
|
+
live.subjectId !== proof.subjectId ||
|
|
1543
|
+
accounts.length !== 1 ||
|
|
1544
|
+
live.accountGrants.some((grant) => grant.subjectId !== proof.subjectId) ||
|
|
1545
|
+
live.workspaceGrants.some(
|
|
1546
|
+
(grant) =>
|
|
1547
|
+
grant.subjectId !== proof.subjectId ||
|
|
1548
|
+
grant.principalKind !== "human_session" ||
|
|
1549
|
+
grant.metadata?.delegated === true ||
|
|
1550
|
+
grant.serviceInitiator ||
|
|
1551
|
+
grant.serviceInitiatorContext,
|
|
1552
|
+
)
|
|
1553
|
+
) {
|
|
1554
|
+
throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
|
|
1555
|
+
}
|
|
1556
|
+
const verifiedPermissions = [...proof.permissions];
|
|
1557
|
+
// The person's own role expands as usual; the connection's access setting is
|
|
1558
|
+
// literal, and so is the result: workspace:admin in a Custom setting is never
|
|
1559
|
+
// a wildcard for the permissions the person left out.
|
|
1560
|
+
const intersectWorkspacePermissions = (permissions: Permission[]) =>
|
|
1561
|
+
explicitPermissions(
|
|
1562
|
+
Permission.options.filter(
|
|
1563
|
+
(permission) =>
|
|
1564
|
+
hasPermission(permissions, permission) &&
|
|
1565
|
+
hasPermission(verifiedPermissions, permission, "explicit"),
|
|
1566
|
+
),
|
|
1567
|
+
);
|
|
1568
|
+
const accountGrant: AccountGrant = {
|
|
1569
|
+
...accounts[0]!,
|
|
1570
|
+
// Organization/billing authority must be literal on BOTH sides, and so is
|
|
1571
|
+
// the result: a workspace:admin entry here never reads as account:admin or
|
|
1572
|
+
// billing to a later permission check.
|
|
1573
|
+
permissions: explicitPermissions(
|
|
1574
|
+
Permission.options.filter(
|
|
1575
|
+
(permission) =>
|
|
1576
|
+
hasLiteralPermission(accounts[0]!.permissions, permission) &&
|
|
1577
|
+
hasLiteralPermission(verifiedPermissions, permission),
|
|
1578
|
+
),
|
|
1579
|
+
),
|
|
1580
|
+
};
|
|
1581
|
+
const workspaceGrants = live.workspaceGrants
|
|
1582
|
+
.filter(
|
|
1583
|
+
(grant) =>
|
|
1584
|
+
grant.accountId === proof.organizationId &&
|
|
1585
|
+
delegatedHumanWorkspaceAllowed(proof, grant.workspaceId),
|
|
1586
|
+
)
|
|
1587
|
+
.map((grant) => ({
|
|
1588
|
+
...grant,
|
|
1589
|
+
permissions: intersectWorkspacePermissions(grant.permissions),
|
|
1590
|
+
permissionMode: "explicit" as const,
|
|
1591
|
+
}));
|
|
1592
|
+
// Proof-bearing authority cannot be changed in place and then reused as if
|
|
1593
|
+
// the resolver had authenticated a different subject, scope or ceiling.
|
|
1594
|
+
for (const grant of [accountGrant, ...workspaceGrants]) {
|
|
1595
|
+
Object.freeze(grant.permissions);
|
|
1596
|
+
Object.freeze(grant);
|
|
1597
|
+
}
|
|
1598
|
+
const context: AccessContext = {
|
|
1599
|
+
mode: "managed",
|
|
1600
|
+
subjectId: proof.subjectId,
|
|
1601
|
+
...(live.subjectLabel ? { subjectLabel: live.subjectLabel } : {}),
|
|
1602
|
+
accountGrants: [accountGrant],
|
|
1603
|
+
workspaceGrants,
|
|
1604
|
+
defaultAccountId: proof.organizationId,
|
|
1605
|
+
defaultWorkspaceId:
|
|
1606
|
+
workspaceGrants.find((grant) => grant.workspaceId === live.defaultWorkspaceId)?.workspaceId ??
|
|
1607
|
+
workspaceGrants[0]?.workspaceId ??
|
|
1608
|
+
null,
|
|
1609
|
+
};
|
|
1610
|
+
Object.freeze(context.accountGrants);
|
|
1611
|
+
Object.freeze(context.workspaceGrants);
|
|
1612
|
+
Object.freeze(context);
|
|
1613
|
+
delegatedHumanContexts.set(context, proof);
|
|
1614
|
+
delegatedHumanProfiles.set(context, Object.freeze(profile));
|
|
1615
|
+
return context;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Presence counts people, so only the verified managed browser-session branch
|
|
1620
|
+
* reports it, and only for a request the console marked as human activity (a
|
|
1621
|
+
* visible tab with recent interaction). Each request counts once: an SSE
|
|
1622
|
+
* stream's periodic reauthorization reuses its original request.
|
|
1623
|
+
*/
|
|
1624
|
+
const presenceRecordedRequests = new WeakSet<Request>();
|
|
1625
|
+
function recordUserPresence(c: Context, deps: AccessDeps, subjectId: string): void {
|
|
1626
|
+
if (!deps.userPresence) return;
|
|
1627
|
+
if (c.req.header(OPENGENI_USER_ACTIVITY_HEADER) !== OPENGENI_USER_ACTIVITY_ACTIVE) return;
|
|
1628
|
+
if (presenceRecordedRequests.has(c.req.raw)) return;
|
|
1629
|
+
presenceRecordedRequests.add(c.req.raw);
|
|
1630
|
+
deps.userPresence.touch(subjectId);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
783
1633
|
async function apiKeyAccessContext(
|
|
784
1634
|
c: Context,
|
|
785
1635
|
deps: AccessDeps,
|
|
@@ -793,7 +1643,23 @@ async function apiKeyAccessContext(
|
|
|
793
1643
|
if (!apiKey) {
|
|
794
1644
|
return null;
|
|
795
1645
|
}
|
|
1646
|
+
// Verified at request entry (stampPersonalApiKeyRequest) and resolved as
|
|
1647
|
+
// its owner; reaching here means that step did not run. Never fall back to
|
|
1648
|
+
// a key-shaped principal.
|
|
1649
|
+
if (apiKey.credentialKind === "personal") {
|
|
1650
|
+
throw new HTTPException(401, { message: "personal API key could not be verified" });
|
|
1651
|
+
}
|
|
796
1652
|
const externalHeader = c.req.header("x-opengeni-external-actor");
|
|
1653
|
+
const service = serviceInitiatorFromHeaders(c.req.raw.headers);
|
|
1654
|
+
if (
|
|
1655
|
+
service &&
|
|
1656
|
+
apiKey.credentialKind !== "organization" &&
|
|
1657
|
+
apiKey.credentialKind !== "workspace"
|
|
1658
|
+
) {
|
|
1659
|
+
throw new HTTPException(422, {
|
|
1660
|
+
message: "service initiator headers require an organization or workspace API key",
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
797
1663
|
if (externalHeader !== undefined) {
|
|
798
1664
|
if (apiKey.workspaceId !== null || apiKey.credentialKind !== "organization") {
|
|
799
1665
|
throw new HTTPException(403, { message: "external actors require an organization key" });
|
|
@@ -807,9 +1673,15 @@ async function apiKeyAccessContext(
|
|
|
807
1673
|
}
|
|
808
1674
|
let identity: ExternalIdentity;
|
|
809
1675
|
try {
|
|
810
|
-
identity = await
|
|
811
|
-
|
|
812
|
-
|
|
1676
|
+
identity = await withAccountRls(deps.db, apiKey.accountId, async (tx) => {
|
|
1677
|
+
// Provisioning locks the live membership after its identity lock.
|
|
1678
|
+
// Claim the lifecycle prefix first so a membership-fenced session
|
|
1679
|
+
// writer can recheck that identity without a lock inversion.
|
|
1680
|
+
await lockExternalWorkspaceMembershipLifecycle(tx, apiKey.accountId);
|
|
1681
|
+
return await ensureExternalIdentity(tx, {
|
|
1682
|
+
accountId: apiKey.accountId,
|
|
1683
|
+
...selection.identity,
|
|
1684
|
+
});
|
|
813
1685
|
});
|
|
814
1686
|
} catch (error) {
|
|
815
1687
|
if (nestedPostgresSqlState(error) === "42501") {
|
|
@@ -841,9 +1713,18 @@ async function apiKeyAccessContext(
|
|
|
841
1713
|
externalActorContexts.set(context, {
|
|
842
1714
|
identity,
|
|
843
1715
|
keyId: apiKey.id,
|
|
844
|
-
permissions:
|
|
1716
|
+
permissions:
|
|
1717
|
+
apiKey.permissionMode === "explicit"
|
|
1718
|
+
? explicitPermissions([...apiKey.permissions])
|
|
1719
|
+
: [...apiKey.permissions],
|
|
1720
|
+
workspaceScope: apiKey.workspaceScope ?? { kind: "all" },
|
|
1721
|
+
permissionMode: apiKey.permissionMode ?? "legacy",
|
|
845
1722
|
...(linked ? { linked } : {}),
|
|
1723
|
+
firstUseMembership: selection.mode !== "linked_native" && !linked && !service,
|
|
846
1724
|
});
|
|
1725
|
+
if (organizationApiKeyAccess(apiKey.permissions) === "developer_setup") {
|
|
1726
|
+
developerSetupApiKeyContexts.add(context);
|
|
1727
|
+
}
|
|
847
1728
|
return context;
|
|
848
1729
|
}
|
|
849
1730
|
const subjectId = `api_key:${apiKey.id}`;
|
|
@@ -854,7 +1735,7 @@ async function apiKeyAccessContext(
|
|
|
854
1735
|
: apiKey.permissions.filter((permission) =>
|
|
855
1736
|
accountScopedApiKeyAccountPermissions.has(permission),
|
|
856
1737
|
);
|
|
857
|
-
const context = {
|
|
1738
|
+
const context: AccessContext = {
|
|
858
1739
|
mode,
|
|
859
1740
|
subjectId,
|
|
860
1741
|
subjectLabel: apiKey.name,
|
|
@@ -875,17 +1756,27 @@ async function apiKeyAccessContext(
|
|
|
875
1756
|
subjectLabel: apiKey.name,
|
|
876
1757
|
permissions: apiKey.permissions,
|
|
877
1758
|
principalKind: "api_key",
|
|
1759
|
+
...service,
|
|
878
1760
|
},
|
|
879
1761
|
]
|
|
880
1762
|
: [],
|
|
881
1763
|
defaultAccountId: apiKey.accountId,
|
|
882
1764
|
defaultWorkspaceId: apiKey.workspaceId,
|
|
883
|
-
}
|
|
1765
|
+
};
|
|
1766
|
+
if (service) apiKeyServiceContexts.set(context, service);
|
|
1767
|
+
if (
|
|
1768
|
+
apiKey.credentialKind === "organization" &&
|
|
1769
|
+
organizationApiKeyAccess(apiKey.permissions) === "developer_setup"
|
|
1770
|
+
) {
|
|
1771
|
+
developerSetupApiKeyContexts.add(context);
|
|
1772
|
+
}
|
|
884
1773
|
if (apiKey.workspaceId === null && apiKey.credentialKind === "organization") {
|
|
885
1774
|
accountScopedApiKeyContexts.set(
|
|
886
1775
|
context,
|
|
887
1776
|
Object.freeze({
|
|
888
1777
|
accountId: apiKey.accountId,
|
|
1778
|
+
workspaceScope: apiKey.workspaceScope ?? { kind: "all" as const },
|
|
1779
|
+
permissionMode: apiKey.permissionMode ?? "legacy",
|
|
889
1780
|
permissions: Object.freeze(
|
|
890
1781
|
apiKey.permissions.filter(
|
|
891
1782
|
(permission) => !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission),
|
|
@@ -894,6 +1785,32 @@ async function apiKeyAccessContext(
|
|
|
894
1785
|
}),
|
|
895
1786
|
);
|
|
896
1787
|
}
|
|
1788
|
+
// Report the exact stamped authority consumed by accessGrantAuthorization,
|
|
1789
|
+
// rather than re-deriving organization-key permissions from a separate rule.
|
|
1790
|
+
// This projection is never an authorization input, and the asUser branch
|
|
1791
|
+
// above deliberately omits it instead of advertising the service's authority.
|
|
1792
|
+
const authority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
1793
|
+
const workspaceGrant =
|
|
1794
|
+
apiKey.credentialKind === "workspace" ? context.workspaceGrants[0] : undefined;
|
|
1795
|
+
const workspacePermissions = authority?.permissions ?? workspaceGrant?.permissions;
|
|
1796
|
+
if (workspacePermissions) {
|
|
1797
|
+
context.credential = {
|
|
1798
|
+
kind: authority ? "organization_api_key" : "workspace_api_key",
|
|
1799
|
+
...(authority ? { access: organizationApiKeyAccess(apiKey.permissions) } : {}),
|
|
1800
|
+
accountId: apiKey.accountId,
|
|
1801
|
+
workspaceId: apiKey.workspaceId,
|
|
1802
|
+
effectiveWorkspacePermissions: Permission.options.filter(
|
|
1803
|
+
(permission) =>
|
|
1804
|
+
!(developerSetupApiKeyContexts.has(context) && permission === "api_keys:manage") &&
|
|
1805
|
+
!accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
|
|
1806
|
+
hasPermission(workspacePermissions, permission),
|
|
1807
|
+
),
|
|
1808
|
+
...(authority ? { policy: apiKey.policy, workspaceScope: authority.workspaceScope } : {}),
|
|
1809
|
+
note: authority
|
|
1810
|
+
? `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.`
|
|
1811
|
+
: "These permissions apply only to the workspace identified by workspaceId; they grant no organization-wide workspace authority.",
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
897
1814
|
return context;
|
|
898
1815
|
}
|
|
899
1816
|
|
|
@@ -911,7 +1828,11 @@ async function delegatedAccessContext(
|
|
|
911
1828
|
if (!payload) {
|
|
912
1829
|
return null;
|
|
913
1830
|
}
|
|
914
|
-
|
|
1831
|
+
const restricted = payload.credentialRestriction === "developer_setup";
|
|
1832
|
+
const workspacePermissions = restricted
|
|
1833
|
+
? payload.permissions.filter(isDeveloperSetupDelegatedPermissionAllowed)
|
|
1834
|
+
: payload.permissions;
|
|
1835
|
+
const context: AccessContext = {
|
|
915
1836
|
mode,
|
|
916
1837
|
subjectId: payload.subjectId,
|
|
917
1838
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
@@ -920,7 +1841,7 @@ async function delegatedAccessContext(
|
|
|
920
1841
|
accountId: payload.accountId,
|
|
921
1842
|
subjectId: payload.subjectId,
|
|
922
1843
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
923
|
-
permissions: payload.permissions,
|
|
1844
|
+
permissions: restricted ? [] : payload.permissions,
|
|
924
1845
|
},
|
|
925
1846
|
],
|
|
926
1847
|
workspaceGrants: [
|
|
@@ -929,7 +1850,7 @@ async function delegatedAccessContext(
|
|
|
929
1850
|
accountId: payload.accountId,
|
|
930
1851
|
subjectId: payload.subjectId,
|
|
931
1852
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
932
|
-
permissions:
|
|
1853
|
+
permissions: workspacePermissions,
|
|
933
1854
|
principalKind: payload.principalKind,
|
|
934
1855
|
// sessionId is worker-asserted (HMAC-signed token claim), not agent
|
|
935
1856
|
// controlled; it scopes session-bound MCP tools such as goal management.
|
|
@@ -962,6 +1883,8 @@ async function delegatedAccessContext(
|
|
|
962
1883
|
defaultAccountId: payload.accountId,
|
|
963
1884
|
defaultWorkspaceId: payload.workspaceId,
|
|
964
1885
|
};
|
|
1886
|
+
if (restricted) developerSetupApiKeyContexts.add(context);
|
|
1887
|
+
return context;
|
|
965
1888
|
}
|
|
966
1889
|
|
|
967
1890
|
function configuredSubject(c: Context): string {
|