@k2b/cloud 0.42.0 → 0.44.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/package.json +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/capability-execution.ts +5 -2
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/code-capability-routes.ts +1 -0
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +29 -46
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +37 -21
- package/src/ai/turn-failure.ts +24 -2
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +40 -16
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { AppMeta } from "../contracts/app";
|
|
3
|
+
import type { AppAccessDeclaration, AppAccessSnapshot } from "../contracts/app-access";
|
|
4
|
+
import { publishAppAccessChanged, wakeAppAccessChanged } from "../events/app-access";
|
|
5
|
+
import { isAppSessionActor, userFromActor } from "../server/actor";
|
|
6
|
+
import type { RequestAuthority } from "../server/middleware/auth";
|
|
7
|
+
import {
|
|
8
|
+
type AccessEntry,
|
|
9
|
+
buildAccessPrincipalCondition,
|
|
10
|
+
createAccess,
|
|
11
|
+
getEffectivePermission,
|
|
12
|
+
type Principal,
|
|
13
|
+
resolveDisplayNames,
|
|
14
|
+
} from "../server/services/access";
|
|
15
|
+
import { isAccountExpired } from "./account-model";
|
|
16
|
+
import { recursiveGroupIdsSubquery } from "./accounts/group-sql";
|
|
17
|
+
import { canUseApp } from "./app-access";
|
|
18
|
+
import { audit } from "./audit";
|
|
19
|
+
import { getIdentityRuntimeConfig } from "./identity/runtime-config";
|
|
20
|
+
import type { ServiceAccount } from "./service-accounts";
|
|
21
|
+
import { loadJwtSession } from "./session/user";
|
|
22
|
+
|
|
23
|
+
type ManagedApp = Pick<AppMeta, "id" | "name" | "access">;
|
|
24
|
+
|
|
25
|
+
export type { AppAccessSnapshot } from "../contracts/app-access";
|
|
26
|
+
export type AppAccessChange =
|
|
27
|
+
| { action: "grant"; principal: Principal; permission: "read" | "admin" }
|
|
28
|
+
| { action: "update"; accessId: string; permission: "read" | "admin" }
|
|
29
|
+
| { action: "revoke"; accessId: string };
|
|
30
|
+
export class AppAccessManagementError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly code: string,
|
|
33
|
+
message: string,
|
|
34
|
+
readonly status: 400 | 403 | 404 | 409 = 403,
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const credentialIsEligible = (authority: RequestAuthority): boolean => {
|
|
41
|
+
if (authority.scopes.includes("ai:inference")) return false;
|
|
42
|
+
const user = userFromActor(authority.actor);
|
|
43
|
+
if (user && isAccountExpired(user.accountExpires)) return false;
|
|
44
|
+
if (authority.actor.kind !== "service_account") return true;
|
|
45
|
+
const { serviceAccount, credentialExpiresAt } = authority.actor;
|
|
46
|
+
return (
|
|
47
|
+
serviceAccount.status === "active" &&
|
|
48
|
+
serviceAccount.kind !== "resource_bound" &&
|
|
49
|
+
(!credentialExpiresAt || new Date(credentialExpiresAt).getTime() > Date.now())
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Repair is administration authority, never an implicit usage grant. */
|
|
54
|
+
export const canRepairAppAccess = (authority: RequestAuthority): boolean => {
|
|
55
|
+
if (!credentialIsEligible(authority) || isAppSessionActor(authority.actor) || !userFromActor(authority.actor)?.roles.includes("admin"))
|
|
56
|
+
return false;
|
|
57
|
+
const kind = authority.credentialKind === "invocation" ? authority.actor.delegation?.credentialKind : authority.credentialKind;
|
|
58
|
+
return (
|
|
59
|
+
kind === "session" ||
|
|
60
|
+
(kind === "api_key" &&
|
|
61
|
+
authority.actor.kind === "service_account" &&
|
|
62
|
+
authority.actor.serviceAccount.kind === "user_delegated" &&
|
|
63
|
+
authority.scopes.length === 0) ||
|
|
64
|
+
authority.scopes.includes("admin")
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
const canWriteList = (authority: RequestAuthority, appId: string): boolean => {
|
|
68
|
+
if (!credentialIsEligible(authority)) return false;
|
|
69
|
+
const kind = authority.credentialKind === "invocation" ? authority.actor.delegation?.credentialKind : authority.credentialKind;
|
|
70
|
+
return (
|
|
71
|
+
kind === "session" ||
|
|
72
|
+
(kind === "api_key" &&
|
|
73
|
+
authority.actor.kind === "service_account" &&
|
|
74
|
+
authority.actor.serviceAccount.kind === "user_delegated" &&
|
|
75
|
+
authority.scopes.length === 0) ||
|
|
76
|
+
authority.scopes.some((scope) => ["write", "admin", `${appId}:write`, `${appId}:admin`, `${appId}:*`].includes(scope))
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const listEntries = async (appId: string, db: SQL = sql): Promise<AccessEntry[]> => {
|
|
81
|
+
const rows = await db<
|
|
82
|
+
{
|
|
83
|
+
id: string;
|
|
84
|
+
user_id: string | null;
|
|
85
|
+
group_id: string | null;
|
|
86
|
+
service_account_id: string | null;
|
|
87
|
+
authenticated_only: boolean;
|
|
88
|
+
permission: "read" | "admin";
|
|
89
|
+
created_at: Date;
|
|
90
|
+
}[]
|
|
91
|
+
>`
|
|
92
|
+
SELECT a.* FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
93
|
+
WHERE j.app_id = ${appId} ORDER BY a.created_at, a.id
|
|
94
|
+
`;
|
|
95
|
+
return rows.map((row) => ({
|
|
96
|
+
id: row.id,
|
|
97
|
+
permission: row.permission,
|
|
98
|
+
createdAt: row.created_at.toISOString(),
|
|
99
|
+
principal: row.user_id
|
|
100
|
+
? { type: "user", userId: row.user_id }
|
|
101
|
+
: row.group_id
|
|
102
|
+
? { type: "group", groupId: row.group_id }
|
|
103
|
+
: row.service_account_id
|
|
104
|
+
? { type: "service_account", serviceAccountId: row.service_account_id }
|
|
105
|
+
: row.authenticated_only
|
|
106
|
+
? { type: "authenticated" }
|
|
107
|
+
: { type: "public" },
|
|
108
|
+
}));
|
|
109
|
+
};
|
|
110
|
+
/** Shared eligibility predicate for the last-manager guard and its presentation. */
|
|
111
|
+
const managerEligibility = (db: SQL) => db`
|
|
112
|
+
EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
|
|
113
|
+
(u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
|
|
114
|
+
OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
|
|
115
|
+
AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const entryPresentation = async (appId: string): Promise<Map<string, { groupMemberCount?: number; effectiveManager: boolean }>> => {
|
|
119
|
+
const rows = await sql<{ id: string; group_member_count: number | null; effective_manager: boolean }[]>`
|
|
120
|
+
SELECT a.id,
|
|
121
|
+
CASE WHEN a.group_id IS NOT NULL THEN (
|
|
122
|
+
SELECT count(*)::integer FROM auth.users u
|
|
123
|
+
WHERE a.group_id IN (${recursiveGroupIdsSubquery(sql`u.id`)})
|
|
124
|
+
) END AS group_member_count,
|
|
125
|
+
a.permission = 'admin' AND (${managerEligibility(sql)}) AS effective_manager
|
|
126
|
+
FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
127
|
+
WHERE j.app_id = ${appId}
|
|
128
|
+
`;
|
|
129
|
+
return new Map(
|
|
130
|
+
rows.map((row) => [
|
|
131
|
+
row.id,
|
|
132
|
+
{
|
|
133
|
+
...(row.group_member_count === null ? {} : { groupMemberCount: row.group_member_count }),
|
|
134
|
+
effectiveManager: row.effective_manager,
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const requireConfigurable = (app: ManagedApp) => {
|
|
141
|
+
if (app.access?.kind !== "grants")
|
|
142
|
+
throw new AppAccessManagementError("APP_ACCESS_FIXED", "This application uses a fixed access rule.", 409);
|
|
143
|
+
return app.access;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const requireAppAccessManager = async (
|
|
147
|
+
authority: RequestAuthority,
|
|
148
|
+
app: ManagedApp,
|
|
149
|
+
options: { repair?: boolean; canManage?: AppAccessDeclaration["canManage"]; db?: SQL } = {},
|
|
150
|
+
): Promise<void> => {
|
|
151
|
+
requireConfigurable(app);
|
|
152
|
+
if (options.repair) {
|
|
153
|
+
if (!canRepairAppAccess(authority))
|
|
154
|
+
throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required to repair app access.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!canWriteList(authority, app.id)) throw new AppAccessManagementError("FORBIDDEN", "This credential cannot manage app access.");
|
|
158
|
+
const entries = await listEntries(app.id, options.db);
|
|
159
|
+
const permission = await getEffectivePermission(
|
|
160
|
+
{
|
|
161
|
+
accessIds: entries
|
|
162
|
+
.filter((entry) => entry.principal.type !== "public" && entry.principal.type !== "authenticated")
|
|
163
|
+
.map((entry) => entry.id),
|
|
164
|
+
subject: authority.accessSubject,
|
|
165
|
+
},
|
|
166
|
+
options.db,
|
|
167
|
+
);
|
|
168
|
+
if (permission !== "admin" || (options.canManage && !(await options.canManage(authority, { db: options.db ?? sql })))) {
|
|
169
|
+
throw new AppAccessManagementError("FORBIDDEN", "A person who manages this application's access can change the list.");
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Whether the app should show its access settings to this caller. Unexpected failures remain visible to the caller. */
|
|
174
|
+
export const canManageAppAccess = async (
|
|
175
|
+
authority: RequestAuthority,
|
|
176
|
+
app: ManagedApp,
|
|
177
|
+
canManage?: AppAccessDeclaration["canManage"],
|
|
178
|
+
): Promise<boolean> => {
|
|
179
|
+
if (app.access?.kind !== "grants") return false;
|
|
180
|
+
try {
|
|
181
|
+
await requireAppAccessManager(authority, app, { canManage });
|
|
182
|
+
return true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof AppAccessManagementError && error.status === 403) return false;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const readAppAccess = async (params: {
|
|
190
|
+
app: ManagedApp;
|
|
191
|
+
authority: RequestAuthority;
|
|
192
|
+
repair?: boolean;
|
|
193
|
+
canManage?: AppAccessDeclaration["canManage"];
|
|
194
|
+
}): Promise<AppAccessSnapshot> => {
|
|
195
|
+
await requireAppAccessManager(params.authority, params.app, params);
|
|
196
|
+
const [state] = await sql<
|
|
197
|
+
{ configured_at: Date | null }[]
|
|
198
|
+
>`SELECT configured_at FROM auth.app_access_state WHERE app_id = ${params.app.id}`;
|
|
199
|
+
const entries = await listEntries(params.app.id);
|
|
200
|
+
const rule = requireConfigurable(params.app);
|
|
201
|
+
const inheritedSignedIn = !state?.configured_at && rule.until?.kind === "signedIn";
|
|
202
|
+
if (inheritedSignedIn && !entries.some((entry) => entry.principal.type === "authenticated"))
|
|
203
|
+
entries.unshift({
|
|
204
|
+
id: "inherited-authenticated",
|
|
205
|
+
principal: { type: "authenticated" },
|
|
206
|
+
permission: "read",
|
|
207
|
+
createdAt: "1970-01-01T00:00:00.000Z",
|
|
208
|
+
});
|
|
209
|
+
const matches = buildAccessPrincipalCondition({
|
|
210
|
+
subject: params.authority.accessSubject,
|
|
211
|
+
columns: {
|
|
212
|
+
userId: sql`a.user_id`,
|
|
213
|
+
groupId: sql`a.group_id`,
|
|
214
|
+
serviceAccountId: sql`a.service_account_id`,
|
|
215
|
+
authenticatedOnly: sql`a.authenticated_only`,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
const own = await sql<{ id: string }[]>`SELECT a.id FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
219
|
+
WHERE j.app_id = ${params.app.id} AND ${matches}`;
|
|
220
|
+
const groupsAdmin = params.canManage && !params.repair ? (await getIdentityRuntimeConfig()).groupsAdmin : [];
|
|
221
|
+
const presentation = await entryPresentation(params.app.id);
|
|
222
|
+
return {
|
|
223
|
+
ownEntryIds: [...own.map((entry) => entry.id), ...(inheritedSignedIn ? ["inherited-authenticated"] : [])],
|
|
224
|
+
appId: params.app.id,
|
|
225
|
+
name: params.app.name,
|
|
226
|
+
entries: (await resolveDisplayNames(entries)).map((entry) => ({ ...entry, ...presentation.get(entry.id) })),
|
|
227
|
+
configured: !!state?.configured_at,
|
|
228
|
+
hasEffectiveManager: await hasEffectiveManager(
|
|
229
|
+
params.app,
|
|
230
|
+
sql,
|
|
231
|
+
groupsAdmin,
|
|
232
|
+
params.repair ? undefined : params.canManage,
|
|
233
|
+
params.authority,
|
|
234
|
+
),
|
|
235
|
+
inheritedSignedIn,
|
|
236
|
+
canUse: await canUseApp(params.authority, params.app),
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Restrictions are identity/domain predicates evaluated with canonical, currently usable
|
|
242
|
+
* session or API-key authority. Never manufacture roles, session kinds or broader scopes.
|
|
243
|
+
* OAuth/invocation-only eligibility cannot be established from durable credential metadata;
|
|
244
|
+
* no supported candidate within one maximum identity page means fail closed.
|
|
245
|
+
*/
|
|
246
|
+
const hasRestrictedManager = async (
|
|
247
|
+
appId: string,
|
|
248
|
+
db: SQL,
|
|
249
|
+
canManage: NonNullable<AppAccessDeclaration["canManage"]>,
|
|
250
|
+
groupsAdmin: string[],
|
|
251
|
+
): Promise<boolean> => {
|
|
252
|
+
// The maximum page size of the public PaginationQuerySchema bounds callback work,
|
|
253
|
+
// including multiple sessions/keys belonging to one principal, under the app row lock.
|
|
254
|
+
const budget = 100;
|
|
255
|
+
const sessions = await db<{ sid: string; user_id: string; auth_epoch: number }[]>`
|
|
256
|
+
SELECT sf.sid, sf.user_id, sf.auth_epoch FROM auth.session_families sf
|
|
257
|
+
JOIN auth.users u ON u.id = sf.user_id
|
|
258
|
+
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
259
|
+
WHERE sf.revoked_at IS NULL AND sf.expires_at > now() AND sf.auth_epoch = u.auth_epoch
|
|
260
|
+
AND (u.account_expires IS NULL OR u.account_expires > now())
|
|
261
|
+
AND EXISTS (
|
|
262
|
+
SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
263
|
+
WHERE j.app_id = ${appId} AND a.permission = 'admin'
|
|
264
|
+
AND (a.user_id = u.id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`u.id`)})))
|
|
265
|
+
)
|
|
266
|
+
ORDER BY sf.sid LIMIT ${budget}
|
|
267
|
+
`;
|
|
268
|
+
if (sessions.length) {
|
|
269
|
+
for (const candidate of sessions) {
|
|
270
|
+
// The same loader as HTTP authentication checks category, consent, key/family
|
|
271
|
+
// revocation and auth epoch, and strips admin for mobile app sessions.
|
|
272
|
+
const loaded = await loadJwtSession(
|
|
273
|
+
{ userId: candidate.user_id, sid: candidate.sid, authEpoch: candidate.auth_epoch, groupsAdmin },
|
|
274
|
+
db,
|
|
275
|
+
);
|
|
276
|
+
if (!loaded) continue;
|
|
277
|
+
const authority: RequestAuthority = {
|
|
278
|
+
actor: { kind: "user", user: loaded.user, ...(loaded.pwaDeviceId ? { sessionKind: "app" } : {}) },
|
|
279
|
+
accessSubject: { type: "user", userId: loaded.user.id },
|
|
280
|
+
credentialKind: "session",
|
|
281
|
+
scopes: [],
|
|
282
|
+
};
|
|
283
|
+
if (canWriteList(authority, appId) && (await canManage(authority, { db }))) return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const remaining = budget - sessions.length;
|
|
287
|
+
if (!remaining) return false;
|
|
288
|
+
const credentials = await db<
|
|
289
|
+
{
|
|
290
|
+
id: string;
|
|
291
|
+
scopes: string[];
|
|
292
|
+
expires_at: Date | null;
|
|
293
|
+
service_account_id: string;
|
|
294
|
+
name: string;
|
|
295
|
+
kind: ServiceAccount["kind"];
|
|
296
|
+
status: ServiceAccount["status"];
|
|
297
|
+
delegated_user_id: string | null;
|
|
298
|
+
app_id: string | null;
|
|
299
|
+
resource_type: string | null;
|
|
300
|
+
resource_id: string | null;
|
|
301
|
+
created_by: string | null;
|
|
302
|
+
created_at: Date;
|
|
303
|
+
}[]
|
|
304
|
+
>`
|
|
305
|
+
SELECT c.id, c.scopes, c.expires_at, sa.id AS service_account_id,
|
|
306
|
+
sa.name, sa.kind, sa.status, sa.delegated_user_id, sa.app_id, sa.resource_type, sa.resource_id, sa.created_by, sa.created_at
|
|
307
|
+
FROM auth.service_account_credentials c JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
308
|
+
WHERE c.kind = 'api_token' AND c.status = 'active' AND (c.expires_at IS NULL OR c.expires_at > now())
|
|
309
|
+
AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent')
|
|
310
|
+
AND EXISTS (
|
|
311
|
+
SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
312
|
+
WHERE j.app_id = ${appId} AND a.permission = 'admin' AND a.service_account_id = sa.id
|
|
313
|
+
)
|
|
314
|
+
ORDER BY c.id LIMIT ${remaining}
|
|
315
|
+
`;
|
|
316
|
+
for (const candidate of credentials) {
|
|
317
|
+
// Only non-secret metadata is selected. Keep both scope fields and the credential
|
|
318
|
+
// identity/expiry exactly as the API-key middleware exposes them.
|
|
319
|
+
const authority: RequestAuthority = {
|
|
320
|
+
actor: {
|
|
321
|
+
kind: "service_account",
|
|
322
|
+
serviceAccount: {
|
|
323
|
+
id: candidate.service_account_id,
|
|
324
|
+
name: candidate.name,
|
|
325
|
+
kind: candidate.kind,
|
|
326
|
+
status: candidate.status,
|
|
327
|
+
delegatedUserId: candidate.delegated_user_id,
|
|
328
|
+
appId: candidate.app_id,
|
|
329
|
+
resourceType: candidate.resource_type,
|
|
330
|
+
resourceId: candidate.resource_id,
|
|
331
|
+
createdBy: candidate.created_by,
|
|
332
|
+
createdAt: candidate.created_at.toISOString(),
|
|
333
|
+
},
|
|
334
|
+
delegatedUser: null,
|
|
335
|
+
scopes: candidate.scopes,
|
|
336
|
+
credentialId: candidate.id,
|
|
337
|
+
credentialExpiresAt: candidate.expires_at?.toISOString() ?? null,
|
|
338
|
+
},
|
|
339
|
+
accessSubject: { type: "service_account", serviceAccountId: candidate.service_account_id },
|
|
340
|
+
credentialKind: "api_key",
|
|
341
|
+
scopes: candidate.scopes,
|
|
342
|
+
};
|
|
343
|
+
if (canWriteList(authority, appId) && (await canManage(authority, { db }))) return true;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** An empty group cannot keep the list manageable. User-delegated and resource-bound keys cannot be its last manager. */
|
|
349
|
+
const hasEffectiveManager = async (
|
|
350
|
+
app: ManagedApp,
|
|
351
|
+
db: SQL,
|
|
352
|
+
groupsAdmin: string[],
|
|
353
|
+
canManage?: AppAccessDeclaration["canManage"],
|
|
354
|
+
current?: RequestAuthority,
|
|
355
|
+
): Promise<boolean> => {
|
|
356
|
+
if (canManage) {
|
|
357
|
+
// The real request can also prove eligibility (including OAuth/invocations),
|
|
358
|
+
// but only if its management grant still exists after the proposed mutation.
|
|
359
|
+
let currentHasGrant = false;
|
|
360
|
+
if (current) {
|
|
361
|
+
try {
|
|
362
|
+
await requireAppAccessManager(current, app, { db });
|
|
363
|
+
currentHasGrant = true;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!(error instanceof AppAccessManagementError && error.code === "FORBIDDEN")) throw error;
|
|
366
|
+
}
|
|
367
|
+
if (currentHasGrant && (await canManage(current, { db }))) return true;
|
|
368
|
+
}
|
|
369
|
+
return hasRestrictedManager(app.id, db, canManage, groupsAdmin);
|
|
370
|
+
}
|
|
371
|
+
const [row] = await db<{ exists: boolean }[]>`
|
|
372
|
+
SELECT EXISTS (
|
|
373
|
+
SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
374
|
+
WHERE j.app_id = ${app.id} AND a.permission = 'admin' AND (${managerEligibility(db)})
|
|
375
|
+
) AS exists
|
|
376
|
+
`;
|
|
377
|
+
return row?.exists ?? false;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const samePrincipal = (left: Principal, right: Principal): boolean => {
|
|
381
|
+
switch (left.type) {
|
|
382
|
+
case "user":
|
|
383
|
+
return right.type === "user" && left.userId === right.userId;
|
|
384
|
+
case "group":
|
|
385
|
+
return right.type === "group" && left.groupId === right.groupId;
|
|
386
|
+
case "service_account":
|
|
387
|
+
return right.type === "service_account" && left.serviceAccountId === right.serviceAccountId;
|
|
388
|
+
default:
|
|
389
|
+
return left.type === right.type;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export const changeAppAccess = async (params: {
|
|
394
|
+
app: ManagedApp;
|
|
395
|
+
authority: RequestAuthority;
|
|
396
|
+
change: AppAccessChange;
|
|
397
|
+
repair?: boolean;
|
|
398
|
+
canManage?: AppAccessDeclaration["canManage"];
|
|
399
|
+
requestId?: string;
|
|
400
|
+
}): Promise<{ entry?: AccessEntry; canUse: boolean }> => {
|
|
401
|
+
const rule = requireConfigurable(params.app);
|
|
402
|
+
const { app, authority, change } = params;
|
|
403
|
+
// Runtime settings use the global pool. Resolve them before reserving a connection;
|
|
404
|
+
// all grant checks, callbacks and candidate domain reads below must share db under the app lock.
|
|
405
|
+
const groupsAdmin =
|
|
406
|
+
params.canManage && !params.repair && canWriteList(authority, app.id) ? (await getIdentityRuntimeConfig()).groupsAdmin : [];
|
|
407
|
+
const result = await sql.begin(async (db) => {
|
|
408
|
+
await db`INSERT INTO auth.app_access_state (app_id) VALUES (${app.id}) ON CONFLICT DO NOTHING`;
|
|
409
|
+
const [state] = await db<
|
|
410
|
+
{ configured_at: Date | null }[]
|
|
411
|
+
>`SELECT configured_at FROM auth.app_access_state WHERE app_id = ${app.id} FOR UPDATE`;
|
|
412
|
+
await requireAppAccessManager(authority, app, { ...params, db });
|
|
413
|
+
const user = userFromActor(authority.actor);
|
|
414
|
+
const record = (action: string, before: AccessEntry | null, after: AccessEntry | null) =>
|
|
415
|
+
audit.record(
|
|
416
|
+
{
|
|
417
|
+
action,
|
|
418
|
+
outcome: "allowed",
|
|
419
|
+
actor: user ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles } : null,
|
|
420
|
+
target: { type: "app", id: app.id, label: app.name },
|
|
421
|
+
requestId: params.requestId,
|
|
422
|
+
metadata: {
|
|
423
|
+
before,
|
|
424
|
+
after,
|
|
425
|
+
via: params.repair ? "platform-admin" : "app",
|
|
426
|
+
actorKind: authority.actor.kind,
|
|
427
|
+
serviceAccountId: authority.actor.kind === "service_account" ? authority.actor.serviceAccount.id : null,
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
db,
|
|
431
|
+
);
|
|
432
|
+
let entries = await listEntries(app.id, db);
|
|
433
|
+
// Preserve signed-in use when saving the first manager, as shown in the initial list.
|
|
434
|
+
if (!state?.configured_at && rule.until?.kind === "signedIn" && !entries.some((entry) => entry.principal.type === "authenticated")) {
|
|
435
|
+
const created = await createAccess({ principal: { type: "authenticated" }, permission: "read" }, db);
|
|
436
|
+
if (!created.ok) throw new AppAccessManagementError(created.error.code, created.error.message, 409);
|
|
437
|
+
await db`INSERT INTO auth.app_access (app_id, access_id) VALUES (${app.id}, ${created.data.id}::uuid)`;
|
|
438
|
+
entries = await listEntries(app.id, db);
|
|
439
|
+
const seed = entries.find((entry) => entry.id === created.data.id)!;
|
|
440
|
+
await record("app.access.granted", null, seed);
|
|
441
|
+
}
|
|
442
|
+
let changedId: string | undefined;
|
|
443
|
+
if (change.action === "grant") {
|
|
444
|
+
if (change.principal.type === "public" || (change.principal.type === "authenticated" && change.permission !== "read"))
|
|
445
|
+
throw new AppAccessManagementError(
|
|
446
|
+
"BAD_INPUT",
|
|
447
|
+
"Grant Use to signed-in identities; public app access and audience managers are not supported.",
|
|
448
|
+
400,
|
|
449
|
+
);
|
|
450
|
+
if (entries.some((entry) => samePrincipal(entry.principal, change.principal)))
|
|
451
|
+
throw new AppAccessManagementError("CONFLICT", "This principal already has access.", 409);
|
|
452
|
+
const created = await createAccess(change, db);
|
|
453
|
+
if (!created.ok) throw new AppAccessManagementError(created.error.code, created.error.message, 400);
|
|
454
|
+
changedId = created.data.id;
|
|
455
|
+
await db`INSERT INTO auth.app_access (app_id, access_id) VALUES (${app.id}, ${changedId}::uuid)`;
|
|
456
|
+
} else {
|
|
457
|
+
const accessId =
|
|
458
|
+
change.accessId === "inherited-authenticated"
|
|
459
|
+
? entries.find((entry) => entry.principal.type === "authenticated")?.id
|
|
460
|
+
: change.accessId;
|
|
461
|
+
const before = entries.find((entry) => entry.id === accessId);
|
|
462
|
+
if (!before) throw new AppAccessManagementError("NOT_FOUND", "Access entry not found.", 404);
|
|
463
|
+
if (change.action === "update") {
|
|
464
|
+
if (before.principal.type === "public") throw new AppAccessManagementError("BAD_INPUT", "Public app access is not supported.", 400);
|
|
465
|
+
if (before.principal.type === "authenticated" && change.permission !== "read")
|
|
466
|
+
throw new AppAccessManagementError("BAD_INPUT", "Signed-in identities may only receive Use.", 400);
|
|
467
|
+
await db`UPDATE auth.access SET permission = ${change.permission}::auth.permission_level WHERE id = ${before.id}::uuid`;
|
|
468
|
+
changedId = before.id;
|
|
469
|
+
} else await db`DELETE FROM auth.access WHERE id = ${before.id}::uuid`;
|
|
470
|
+
await record(
|
|
471
|
+
change.action === "update" ? "app.access.updated" : "app.access.revoked",
|
|
472
|
+
before,
|
|
473
|
+
change.action === "update" ? { ...before, permission: change.permission } : null,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (!(await hasEffectiveManager(app, db, groupsAdmin, params.repair ? undefined : params.canManage, authority)))
|
|
477
|
+
throw new AppAccessManagementError("LAST_MANAGER", "At least one person must manage access. Add another manager first.", 409);
|
|
478
|
+
await db`UPDATE auth.app_access_state SET configured_at = COALESCE(configured_at, now()) WHERE app_id = ${app.id}`;
|
|
479
|
+
const after = changedId ? (await listEntries(app.id, db)).find((entry) => entry.id === changedId) : undefined;
|
|
480
|
+
if (change.action === "grant") await record("app.access.granted", null, after ?? null);
|
|
481
|
+
await publishAppAccessChanged(db, app.id);
|
|
482
|
+
return after;
|
|
483
|
+
});
|
|
484
|
+
wakeAppAccessChanged();
|
|
485
|
+
return { entry: result ? (await resolveDisplayNames([result]))[0] : undefined, canUse: await canUseApp(authority, app) };
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** Only manager names, for the intentionally limited direct-link denial page. */
|
|
489
|
+
export const appAccessManagers = async (appId: string): Promise<AccessEntry[]> => {
|
|
490
|
+
const presentation = await entryPresentation(appId);
|
|
491
|
+
const entries = (await listEntries(appId)).filter((entry) => presentation.get(entry.id)?.effectiveManager);
|
|
492
|
+
return resolveDisplayNames(entries);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** Batched grant counts for the administration repair overview. */
|
|
496
|
+
export const appAccessGrantCounts = async (authority: RequestAuthority, apps: readonly ManagedApp[]): Promise<Map<string, number>> => {
|
|
497
|
+
if (!canRepairAppAccess(authority)) throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
|
|
498
|
+
const ids = apps.filter((app) => app.access?.kind === "grants").map((app) => app.id);
|
|
499
|
+
if (!ids.length) return new Map();
|
|
500
|
+
const rows = await sql<{ app_id: string; count: number; authenticated: boolean; configured: boolean }[]>`
|
|
501
|
+
SELECT COALESCE(aa.app_id, s.app_id) AS app_id, count(aa.access_id)::integer AS count,
|
|
502
|
+
COALESCE(bool_or(a.authenticated_only), false) AS authenticated,
|
|
503
|
+
s.configured_at IS NOT NULL AS configured
|
|
504
|
+
FROM auth.app_access aa
|
|
505
|
+
FULL JOIN auth.app_access_state s ON s.app_id = aa.app_id
|
|
506
|
+
LEFT JOIN auth.access a ON a.id = aa.access_id
|
|
507
|
+
WHERE COALESCE(aa.app_id, s.app_id) IN ${sql(ids)} GROUP BY aa.app_id, s.app_id, s.configured_at
|
|
508
|
+
`;
|
|
509
|
+
return new Map(
|
|
510
|
+
apps
|
|
511
|
+
.filter((app) => app.access?.kind === "grants")
|
|
512
|
+
.map((app) => {
|
|
513
|
+
const row = rows.find((row) => row.app_id === app.id);
|
|
514
|
+
// The editor includes this inherited audience before the first save.
|
|
515
|
+
const inherited = app.access?.kind === "grants" && app.access.until?.kind === "signedIn" && !row?.configured && !row?.authenticated;
|
|
516
|
+
return [app.id, (row?.count ?? 0) + (inherited ? 1 : 0)];
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
|
+
import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
|
|
3
4
|
import type { AppMeta } from "../contracts/app";
|
|
4
5
|
import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
|
|
5
6
|
import { userFromActor } from "../server/actor";
|
|
6
|
-
import type
|
|
7
|
+
import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
|
|
7
8
|
import { buildAccessPrincipalCondition } from "../server/services/access";
|
|
8
9
|
import { logger } from "./logging";
|
|
9
10
|
import { toPgTextArray } from "./postgres";
|
|
@@ -17,7 +18,7 @@ export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): Reques
|
|
|
17
18
|
const accessSubject = c.get("accessSubject");
|
|
18
19
|
const credentialKind = c.get("credentialKind");
|
|
19
20
|
if (!actor || !accessSubject || !credentialKind) return null;
|
|
20
|
-
return
|
|
21
|
+
return auth.getAuthority(c);
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
|
|
@@ -65,6 +66,7 @@ export const evaluateAppAccess = (authority: RequestAuthority | null, app: Acces
|
|
|
65
66
|
const parsed = AppAccessRuleSchema.safeParse(app.access);
|
|
66
67
|
if (!parsed.success) return false;
|
|
67
68
|
const rule = parsed.data;
|
|
69
|
+
if (rule.kind === "resources") return false; // Asynchronous adapter decision only.
|
|
68
70
|
if (rule.kind !== "grants") return fixedAllows(authority, rule);
|
|
69
71
|
if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
|
|
70
72
|
return state.permission === "read" || state.permission === "admin";
|
|
@@ -74,21 +76,34 @@ let lastFailureLog = -Infinity;
|
|
|
74
76
|
const reportFailure = () => {
|
|
75
77
|
if (Date.now() - lastFailureLog < 60_000) return;
|
|
76
78
|
lastFailureLog = Date.now();
|
|
77
|
-
logger("app-access").error("Application access could not be resolved;
|
|
79
|
+
logger("app-access").error("Application access could not be resolved; affected applications are unavailable");
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
* One authoritative SQL read for all configurable
|
|
83
|
+
* One authoritative SQL read for all configurable lists; resource rules query the owning adapters.
|
|
84
|
+
* No cross-request grant cache: revocation applies on the next request.
|
|
82
85
|
* Pass the reserved transaction as `db` when checking access inside a guarded operation.
|
|
86
|
+
* A supplied `signal` replaces the default resource budget; the caller must bound it.
|
|
83
87
|
*/
|
|
84
88
|
export const usableAppIds = async (
|
|
85
89
|
authority: RequestAuthority | null,
|
|
86
90
|
apps: readonly AccessApp[],
|
|
87
|
-
options: { db?: SQL } = {},
|
|
91
|
+
options: { db?: SQL; signal?: AbortSignal } = {},
|
|
88
92
|
): Promise<Set<string>> => {
|
|
89
93
|
const db = options.db ?? sql;
|
|
90
94
|
const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
|
|
91
95
|
if (!authority) return allowed;
|
|
96
|
+
let resourceSignal = options.signal;
|
|
97
|
+
// Sequential: one existence probe in flight per decision, no resource enumeration.
|
|
98
|
+
for (const app of apps) {
|
|
99
|
+
if (app.access?.kind !== "resources" || !credentialAllows(authority, app.id)) continue;
|
|
100
|
+
resourceSignal ??= AbortSignal.timeout(RESOURCE_ACCESS_TIMEOUT_MS);
|
|
101
|
+
try {
|
|
102
|
+
if (await resourceAppAccess(authority, app, { ...options, signal: resourceSignal })) allowed.add(app.id);
|
|
103
|
+
} catch {
|
|
104
|
+
if (!options.signal?.aborted) reportFailure();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
92
107
|
const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
|
|
93
108
|
if (configurable.length === 0) return allowed;
|
|
94
109
|
try {
|
|
@@ -119,5 +134,8 @@ export const usableAppIds = async (
|
|
|
119
134
|
return allowed;
|
|
120
135
|
};
|
|
121
136
|
|
|
122
|
-
export const canUseApp = async (
|
|
123
|
-
|
|
137
|
+
export const canUseApp = async (
|
|
138
|
+
authority: RequestAuthority | null,
|
|
139
|
+
app: AccessApp,
|
|
140
|
+
options: { db?: SQL; signal?: AbortSignal } = {},
|
|
141
|
+
): Promise<boolean> => (await usableAppIds(authority, [app], options)).has(app.id);
|
|
@@ -55,11 +55,11 @@ const { create: secret, hash, matches, code: comparison } = pairingSecret;
|
|
|
55
55
|
const iso = (value: Date | string) => new Date(value).toISOString();
|
|
56
56
|
const future = (seconds: number) => new Date(Date.now() + seconds * 1000);
|
|
57
57
|
|
|
58
|
-
export type AppApprovalConfig = { issuer: string; appOrigin: string; enabled: boolean; adminPairing: boolean };
|
|
58
|
+
export type AppApprovalConfig = { issuer: string; appOrigin: string; enabled: boolean; adminPairing: boolean; label?: string };
|
|
59
59
|
export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = true): Promise<AppApprovalConfig> => {
|
|
60
60
|
const rows = await db<
|
|
61
61
|
{ key: string; value: string }[]
|
|
62
|
-
>`SELECT key, value FROM settings.entries WHERE key IN ('app.url','user.app_approval.enabled','user.app_approval.origin','user.app_approval.admin_pairing')`;
|
|
62
|
+
>`SELECT key, value FROM settings.entries WHERE key IN ('app.url','app.name','user.app_approval.label','user.app_approval.enabled','user.app_approval.origin','user.app_approval.admin_pairing')`;
|
|
63
63
|
const values = new Map(await Promise.all(rows.map(async (row) => [row.key, await decryptValue(row.value)] as const)));
|
|
64
64
|
const enabled = values.get("user.app_approval.enabled") ?? false;
|
|
65
65
|
const adminPairing = values.get("user.app_approval.admin_pairing") ?? false;
|
|
@@ -93,6 +93,10 @@ export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = tr
|
|
|
93
93
|
} catch {
|
|
94
94
|
return reject("UNAVAILABLE", 503);
|
|
95
95
|
}
|
|
96
|
+
const label = [values.get("user.app_approval.label"), values.get("app.name"), CORE_SETTINGS["app.name"].default]
|
|
97
|
+
.find((value): value is string => typeof value === "string" && !!value.trim())!
|
|
98
|
+
.trim()
|
|
99
|
+
.slice(0, 80);
|
|
96
100
|
if (!enabled) return { issuer, appOrigin: "", enabled: false, adminPairing };
|
|
97
101
|
let appOrigin = "";
|
|
98
102
|
try {
|
|
@@ -102,7 +106,7 @@ export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = tr
|
|
|
102
106
|
// Pairing, discovery and sign-in still require a valid trusted origin.
|
|
103
107
|
if (requireAppOrigin) throw error;
|
|
104
108
|
}
|
|
105
|
-
return { issuer, appOrigin, enabled, adminPairing };
|
|
109
|
+
return { issuer, appOrigin, enabled, adminPairing, label };
|
|
106
110
|
};
|
|
107
111
|
|
|
108
112
|
type AccountRow = {
|
|
@@ -532,7 +536,14 @@ export const createAppApprovalService = (
|
|
|
532
536
|
await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, auth_epoch, secret_hash, expires_at)
|
|
533
537
|
VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${target.auth_epoch},${hash(token)},${expiresAt})`;
|
|
534
538
|
await record(tx, "pairing.start", actor.userId, id, { targetUserId: targetId, assisted: targetId !== actor.userId });
|
|
535
|
-
return {
|
|
539
|
+
return {
|
|
540
|
+
protocol: APP_APPROVAL_PROTOCOL,
|
|
541
|
+
issuer: cfg.issuer,
|
|
542
|
+
pairingId: id,
|
|
543
|
+
secret: token,
|
|
544
|
+
expiresAt: iso(expiresAt),
|
|
545
|
+
...(cfg.label ? { label: cfg.label } : {}),
|
|
546
|
+
};
|
|
536
547
|
});
|
|
537
548
|
},
|
|
538
549
|
claimPairing: async (claim: { pairingId: string; secret: string; publicKey: AppDevicePublicKey; name: string; signature: string }) => {
|