@k2b/cloud 0.41.0 → 0.42.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 +6 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { createMiddleware } from "hono/factory";
|
|
4
|
+
import { TrieRouter } from "hono/router/trie-router";
|
|
5
|
+
import type { WSEvents } from "hono/ws";
|
|
6
|
+
import { getApp } from "../../_internal/registry";
|
|
7
|
+
import type { AppMeta } from "../../contracts/app";
|
|
8
|
+
import { appAccessAuthority, canUseApp } from "../../services/app-access";
|
|
9
|
+
import { recordAppAccessDenied } from "../../services/app-access-metrics";
|
|
10
|
+
import { createLoginRedirectUrl } from "../../shared/redirect";
|
|
11
|
+
import { type AuthContext, auth, type RequestAuthority } from "./auth";
|
|
12
|
+
|
|
13
|
+
type AccessApp = Pick<AppMeta, "id" | "access"> & { name?: string };
|
|
14
|
+
export const APP_ACCESS_RECHECK_MS = 50_000;
|
|
15
|
+
export const APP_ACCESS_CHECK_TIMEOUT_MS = 10_000;
|
|
16
|
+
const connections = new Set<() => void>();
|
|
17
|
+
/** app.start() closes sockets and SSE before draining the process's shared services. */
|
|
18
|
+
export const stopAppAccessConnections = (): void => {
|
|
19
|
+
for (const close of [...connections]) close();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const appAccessDeniedBody = (app: AccessApp) => ({
|
|
23
|
+
code: "app_access_denied" as const,
|
|
24
|
+
app: app.id,
|
|
25
|
+
message: `You cannot use ${app.name ?? app.id}. ${app.access?.kind === "grants" ? "Ask someone who manages access to this app." : "Ask your Cloud administrator about access."}`,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export class AppAccessDeniedError extends Error {
|
|
29
|
+
readonly code = "app_access_denied";
|
|
30
|
+
constructor(readonly app: AccessApp) {
|
|
31
|
+
super(appAccessDeniedBody(app).message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Public paths bypass this gate only; the route still owns its authentication. */
|
|
36
|
+
export const appPublicRouteMatcher = (app: AccessApp): ((path: string) => boolean) => {
|
|
37
|
+
const router = new TrieRouter<boolean>();
|
|
38
|
+
for (const path of app.access?.publicRoutes ?? []) router.add("ALL", path, true);
|
|
39
|
+
return (path) => router.match("ALL", path)[0].length > 0;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Resolve afresh rather than reuse the original Context's authentication cache. */
|
|
43
|
+
export const appAccessRevalidator = (c: Context<AuthContext>, app: AccessApp): (() => Promise<boolean>) => {
|
|
44
|
+
const original = appAccessAuthority(c);
|
|
45
|
+
const sessionToken = c.get("sessionToken");
|
|
46
|
+
const sessionKind = c.get("sessionKind");
|
|
47
|
+
const samePrincipal = (current: RequestAuthority): boolean => {
|
|
48
|
+
if (!original || current.accessSubject.type !== original.accessSubject.type) return false;
|
|
49
|
+
if (
|
|
50
|
+
current.accessSubject.type === "user" &&
|
|
51
|
+
original.accessSubject.type === "user" &&
|
|
52
|
+
current.accessSubject.userId !== original.accessSubject.userId
|
|
53
|
+
)
|
|
54
|
+
return false;
|
|
55
|
+
if (
|
|
56
|
+
current.accessSubject.type === "service_account" &&
|
|
57
|
+
original.accessSubject.type === "service_account" &&
|
|
58
|
+
current.accessSubject.serviceAccountId !== original.accessSubject.serviceAccountId
|
|
59
|
+
)
|
|
60
|
+
return false;
|
|
61
|
+
if (current.credentialKind !== original.credentialKind || current.actor.kind !== original.actor.kind) return false;
|
|
62
|
+
if (current.actor.kind === "user" && original.actor.kind === "user") return current.actor.user.id === original.actor.user.id;
|
|
63
|
+
return (
|
|
64
|
+
current.actor.kind === "service_account" &&
|
|
65
|
+
original.actor.kind === "service_account" &&
|
|
66
|
+
current.actor.serviceAccount.id === original.actor.serviceAccount.id &&
|
|
67
|
+
current.actor.credentialId === original.actor.credentialId
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
return async () => {
|
|
71
|
+
if (!original || original.credentialKind === "invocation") return false;
|
|
72
|
+
let allowed = false;
|
|
73
|
+
const probe = new Hono<AuthContext>()
|
|
74
|
+
.onError((error) => {
|
|
75
|
+
throw error;
|
|
76
|
+
})
|
|
77
|
+
.use(auth.requireRole("*"))
|
|
78
|
+
.all("*", async (fresh) => {
|
|
79
|
+
const authority = appAccessAuthority(fresh);
|
|
80
|
+
if (!authority || !samePrincipal(authority)) return fresh.body(null, 401);
|
|
81
|
+
if (
|
|
82
|
+
original.credentialKind === "session" &&
|
|
83
|
+
(fresh.get("sessionToken") !== sessionToken || fresh.get("sessionKind") !== sessionKind)
|
|
84
|
+
)
|
|
85
|
+
return fresh.body(null, 401);
|
|
86
|
+
// A stream retains its initial domain context. Reconnect on changed scopes so
|
|
87
|
+
// neither a narrowed key nor an empty-scope personal key keeps stale authority.
|
|
88
|
+
if (JSON.stringify([...new Set(authority.scopes)].sort()) !== JSON.stringify([...new Set(original.scopes)].sort()))
|
|
89
|
+
return fresh.body(null, 401);
|
|
90
|
+
allowed = await canUseApp(authority, app);
|
|
91
|
+
return fresh.body(null, 204);
|
|
92
|
+
});
|
|
93
|
+
await probe.fetch(new Request(c.req.url, { headers: c.req.raw.headers }));
|
|
94
|
+
return allowed;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type WatchOptions = {
|
|
99
|
+
check: () => Promise<boolean>;
|
|
100
|
+
denied: () => void;
|
|
101
|
+
subscribe: (changed: () => void) => () => void;
|
|
102
|
+
intervalMs?: number;
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** One check in flight, coalesced invalidations, and a deadline that fails closed. */
|
|
107
|
+
export const watchAppAccess = (options: WatchOptions): (() => void) => {
|
|
108
|
+
let stopped = false;
|
|
109
|
+
let checking = false;
|
|
110
|
+
let again = false;
|
|
111
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
let unsubscribe = () => {};
|
|
113
|
+
const stop = () => {
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
stopped = true;
|
|
116
|
+
clearInterval(timer);
|
|
117
|
+
clearTimeout(deadline);
|
|
118
|
+
unsubscribe();
|
|
119
|
+
};
|
|
120
|
+
const deny = () => {
|
|
121
|
+
if (stopped) return;
|
|
122
|
+
stop();
|
|
123
|
+
options.denied();
|
|
124
|
+
};
|
|
125
|
+
const check = async () => {
|
|
126
|
+
if (stopped) return;
|
|
127
|
+
if (checking) {
|
|
128
|
+
again = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
checking = true;
|
|
132
|
+
deadline = setTimeout(deny, options.timeoutMs ?? APP_ACCESS_CHECK_TIMEOUT_MS);
|
|
133
|
+
try {
|
|
134
|
+
if (!(await options.check())) deny();
|
|
135
|
+
} catch {
|
|
136
|
+
deny();
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(deadline);
|
|
139
|
+
checking = false;
|
|
140
|
+
if (again && !stopped) {
|
|
141
|
+
again = false;
|
|
142
|
+
void check();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const timer = setInterval(() => void check(), options.intervalMs ?? APP_ACCESS_RECHECK_MS);
|
|
147
|
+
timer.unref?.();
|
|
148
|
+
unsubscribe = options.subscribe(() => void check());
|
|
149
|
+
// Close the race between the handshake's check and subscribing to changes.
|
|
150
|
+
void check();
|
|
151
|
+
return stop;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const subscribeApp = async (appId: string): Promise<(changed: () => void) => () => void> => {
|
|
155
|
+
const { subscribeAppAccessChanged } = await import("../../events/app-access");
|
|
156
|
+
return (changed) => subscribeAppAccessChanged(appId, changed);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Used by shared live updates and custom sockets; close always releases the watcher. */
|
|
160
|
+
export const guardAppAccessSocket = async (c: Context<AuthContext>, appId: string, events: WSEvents): Promise<WSEvents> => {
|
|
161
|
+
let app: AccessApp | null;
|
|
162
|
+
try {
|
|
163
|
+
app = await getApp(appId);
|
|
164
|
+
} catch {
|
|
165
|
+
app = null;
|
|
166
|
+
}
|
|
167
|
+
if (app && (!app.access || appPublicRouteMatcher(app)(c.req.path))) return events;
|
|
168
|
+
const authority = appAccessAuthority(c);
|
|
169
|
+
if (!app || !(await canUseApp(authority, app))) {
|
|
170
|
+
if (app) recordAppAccessDenied(app.id);
|
|
171
|
+
return { onOpen: (_event, ws) => ws.close(1008, "app_access_denied") };
|
|
172
|
+
}
|
|
173
|
+
const revalidate = appAccessRevalidator(c, app);
|
|
174
|
+
const subscribe = await subscribeApp(appId);
|
|
175
|
+
let stop: (() => void) | undefined;
|
|
176
|
+
let denied = false;
|
|
177
|
+
let shutdown = () => {};
|
|
178
|
+
return {
|
|
179
|
+
...events,
|
|
180
|
+
onOpen: (event, ws) => {
|
|
181
|
+
shutdown = () => {
|
|
182
|
+
stop?.();
|
|
183
|
+
connections.delete(shutdown);
|
|
184
|
+
denied = true;
|
|
185
|
+
ws.close(1012, "server_restarting");
|
|
186
|
+
};
|
|
187
|
+
connections.add(shutdown);
|
|
188
|
+
stop = watchAppAccess({
|
|
189
|
+
check: revalidate,
|
|
190
|
+
subscribe,
|
|
191
|
+
denied: () => {
|
|
192
|
+
connections.delete(shutdown);
|
|
193
|
+
denied = true;
|
|
194
|
+
ws.close(1008, "app_access_denied");
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return events.onOpen?.(event, ws);
|
|
198
|
+
},
|
|
199
|
+
onMessage: (event, ws) => {
|
|
200
|
+
if (!denied) return events.onMessage?.(event, ws);
|
|
201
|
+
},
|
|
202
|
+
onClose: (event, ws) => {
|
|
203
|
+
connections.delete(shutdown);
|
|
204
|
+
stop?.();
|
|
205
|
+
return events.onClose?.(event, ws);
|
|
206
|
+
},
|
|
207
|
+
onError: (event, ws) => {
|
|
208
|
+
connections.delete(shutdown);
|
|
209
|
+
stop?.();
|
|
210
|
+
ws.close(1011, "app_access_check_failed");
|
|
211
|
+
return events.onError?.(event, ws);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const guardSse = async (c: Context<AuthContext>, app: AccessApp, response: Response): Promise<Response> => {
|
|
217
|
+
if (!response.body) return response;
|
|
218
|
+
const subscribe = await subscribeApp(app.id);
|
|
219
|
+
const reader = response.body.getReader();
|
|
220
|
+
let ended = false;
|
|
221
|
+
let stop = () => {};
|
|
222
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
223
|
+
const abort = () => {
|
|
224
|
+
if (!ended) streamController?.close();
|
|
225
|
+
finish();
|
|
226
|
+
};
|
|
227
|
+
const finish = () => {
|
|
228
|
+
if (ended) return;
|
|
229
|
+
ended = true;
|
|
230
|
+
stop();
|
|
231
|
+
connections.delete(abort);
|
|
232
|
+
c.req.raw.signal.removeEventListener("abort", abort);
|
|
233
|
+
void reader.cancel().catch(() => {});
|
|
234
|
+
};
|
|
235
|
+
const body = new ReadableStream<Uint8Array>({
|
|
236
|
+
start(controller) {
|
|
237
|
+
streamController = controller;
|
|
238
|
+
connections.add(abort);
|
|
239
|
+
stop = watchAppAccess({
|
|
240
|
+
check: appAccessRevalidator(c, app),
|
|
241
|
+
subscribe,
|
|
242
|
+
denied: () => {
|
|
243
|
+
if (ended) return;
|
|
244
|
+
controller.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify(appAccessDeniedBody(app))}\n\n`));
|
|
245
|
+
controller.close();
|
|
246
|
+
finish();
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
c.req.raw.signal.addEventListener("abort", abort, { once: true });
|
|
250
|
+
if (c.req.raw.signal.aborted) {
|
|
251
|
+
controller.close();
|
|
252
|
+
finish();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async pull(controller) {
|
|
256
|
+
try {
|
|
257
|
+
const item = await reader.read();
|
|
258
|
+
if (ended) return;
|
|
259
|
+
if (item.done) {
|
|
260
|
+
controller.close();
|
|
261
|
+
finish();
|
|
262
|
+
} else controller.enqueue(item.value);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (!ended) {
|
|
265
|
+
controller.error(error);
|
|
266
|
+
finish();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
cancel: finish,
|
|
271
|
+
});
|
|
272
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Framework gate: install after static and internal invocation mounts, before every app-owned handler. */
|
|
276
|
+
export const requireAppAccess = (
|
|
277
|
+
app: AccessApp,
|
|
278
|
+
renderDenied: (c: Context<AuthContext>, message: string) => Promise<Response>,
|
|
279
|
+
): MiddlewareHandler<AuthContext> => {
|
|
280
|
+
const isPublic = appPublicRouteMatcher(app);
|
|
281
|
+
return createMiddleware<AuthContext>(async (c, next) => {
|
|
282
|
+
if (!app.access || isPublic(c.req.path)) return next();
|
|
283
|
+
let resolved: RequestAuthority | null = null;
|
|
284
|
+
await auth.requireRole("*")(c, async () => {
|
|
285
|
+
resolved = appAccessAuthority(c);
|
|
286
|
+
});
|
|
287
|
+
const browser =
|
|
288
|
+
c.req.method === "GET" && c.req.header("accept")?.includes("text/html") && c.req.header("upgrade")?.toLowerCase() !== "websocket";
|
|
289
|
+
if (!resolved) {
|
|
290
|
+
// Let the established mobile launch flow renew an app session.
|
|
291
|
+
if (browser && c.req.path.startsWith("/pwa/")) {
|
|
292
|
+
const url = new URL(c.req.url);
|
|
293
|
+
return c.redirect(
|
|
294
|
+
url.searchParams.has("pwa_launch")
|
|
295
|
+
? "/pwa/?pwa=unavailable"
|
|
296
|
+
: `/pwa/_auth/session/launch?to=${encodeURIComponent(url.pathname + url.search)}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return browser ? c.redirect(createLoginRedirectUrl(c.req.url)) : c.json({ message: "Authentication required" }, 401);
|
|
300
|
+
}
|
|
301
|
+
if (!(await canUseApp(resolved, app))) {
|
|
302
|
+
recordAppAccessDenied(app.id);
|
|
303
|
+
c.header("Cache-Control", "private, no-store");
|
|
304
|
+
const body = appAccessDeniedBody(app);
|
|
305
|
+
const response = browser ? await renderDenied(c, body.message) : c.json(body, 403);
|
|
306
|
+
response.headers.set("Cache-Control", "private, no-store");
|
|
307
|
+
return response;
|
|
308
|
+
}
|
|
309
|
+
await next();
|
|
310
|
+
if (c.res.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) c.res = await guardSse(c, app, c.res);
|
|
311
|
+
});
|
|
312
|
+
};
|
|
@@ -394,7 +394,7 @@ const requireOAuthScope = (...requiredScopes: string[]) =>
|
|
|
394
394
|
return c.json({ code: "FORBIDDEN", message: `OAuth scope ${requiredScopes.join(" or ")} is required` }, 403);
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
-
const getAuthority = (c: Context<
|
|
397
|
+
const getAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority => {
|
|
398
398
|
const actor = c.get("actor");
|
|
399
399
|
const accessSubject = c.get("accessSubject");
|
|
400
400
|
const credentialKind = c.get("credentialKind");
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { createMiddleware } from "hono/factory";
|
|
3
|
+
import { getApp } from "../../_internal/registry";
|
|
4
|
+
import type { AppMeta } from "../../contracts/app";
|
|
5
|
+
import { canUseApp } from "../../services/app-access";
|
|
6
|
+
import { recordAppAccessDenied } from "../../services/app-access-metrics";
|
|
3
7
|
import { type ResolvedInvocationAuthority, resolveInvocationAuthority } from "../../services/identity/invocation-actor";
|
|
4
8
|
import { type CloudInvocationClaims, isInvocationJwtCandidate, verifyInvocationToken } from "../../services/identity/invocation-token";
|
|
9
|
+
import { appAccessDeniedBody } from "./app-access";
|
|
5
10
|
import type { AuthContext } from "./auth";
|
|
6
11
|
|
|
7
12
|
export type InvocationExpectation = {
|
|
@@ -11,6 +16,7 @@ export type InvocationExpectation = {
|
|
|
11
16
|
};
|
|
12
17
|
|
|
13
18
|
type InvocationMiddlewareDependencies = {
|
|
19
|
+
getApp?: (appId: string) => Promise<Pick<AppMeta, "id" | "access"> | null>;
|
|
14
20
|
verify?: typeof verifyInvocationToken;
|
|
15
21
|
resolve?: typeof resolveInvocationAuthority;
|
|
16
22
|
};
|
|
@@ -47,6 +53,11 @@ export const requireInvocation = (
|
|
|
47
53
|
if (!claims) return reject(c);
|
|
48
54
|
const authority = await (dependencies.resolve ?? resolveInvocationAuthority)(claims);
|
|
49
55
|
if (!authority) return reject(c);
|
|
56
|
+
const app = await (dependencies.getApp ?? getApp)(expectation.targetAppId).catch(() => null);
|
|
57
|
+
if (!app || !(await canUseApp(authority, app))) {
|
|
58
|
+
if (app) recordAppAccessDenied(app.id);
|
|
59
|
+
return c.json(appAccessDeniedBody(app ?? { id: expectation.targetAppId }), 403);
|
|
60
|
+
}
|
|
50
61
|
if (claims.schema_hash !== expectation.schemaHash) {
|
|
51
62
|
return c.json({ code: "SCHEMA_MISMATCH", message: "The invocation schema no longer matches the target" }, 409);
|
|
52
63
|
}
|
|
@@ -2,7 +2,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { createMiddleware } from "hono/factory";
|
|
3
3
|
import type { MessageResponse } from "../../contracts/shared";
|
|
4
4
|
import * as settings from "../../services/settings";
|
|
5
|
-
import { clientIp } from "../client-ip";
|
|
5
|
+
import { clientIp, clientIpBucket } from "../client-ip";
|
|
6
6
|
import { ratelimit } from "../ratelimit";
|
|
7
7
|
import { type AuthContext, auth } from "./auth";
|
|
8
8
|
|
|
@@ -81,13 +81,13 @@ const getLimiter = (limitPerSecond: number, windowSecs: number) => {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
|
|
84
|
-
if (keyBy === "ip") return
|
|
84
|
+
if (keyBy === "ip") return clientIpBucket(clientIp(c));
|
|
85
85
|
|
|
86
86
|
const token = auth.session.getToken(c);
|
|
87
|
-
if (!token) return
|
|
87
|
+
if (!token) return clientIpBucket(clientIp(c));
|
|
88
88
|
|
|
89
89
|
const userId = await auth.session.getRequestSubject(c, token);
|
|
90
|
-
if (!userId) return
|
|
90
|
+
if (!userId) return clientIpBucket(clientIp(c));
|
|
91
91
|
|
|
92
92
|
return `user:${userId}`;
|
|
93
93
|
};
|
|
@@ -655,12 +655,18 @@ export const listUsersWithAccess = async (params: {
|
|
|
655
655
|
|
|
656
656
|
/**
|
|
657
657
|
* Resolve display names for access entries.
|
|
658
|
-
*
|
|
659
|
-
* serviceAccountKind
|
|
658
|
+
* Missing principals and audiences have no displayName; render with principalLabel.
|
|
659
|
+
* Includes serviceAccountKind for existing service-account principals.
|
|
660
660
|
*/
|
|
661
661
|
export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
662
662
|
entries: T[],
|
|
663
|
-
): Promise<
|
|
663
|
+
): Promise<
|
|
664
|
+
(Omit<T, "displayName" | "avatarHash" | "serviceAccountKind"> & {
|
|
665
|
+
displayName?: string;
|
|
666
|
+
avatarHash?: string | null;
|
|
667
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
668
|
+
})[]
|
|
669
|
+
> => {
|
|
664
670
|
const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
|
|
665
671
|
|
|
666
672
|
const groupIds = entries
|
|
@@ -709,30 +715,28 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
|
709
715
|
}
|
|
710
716
|
|
|
711
717
|
return entries.map((entry) => {
|
|
712
|
-
let displayName: string;
|
|
718
|
+
let displayName: string | undefined;
|
|
713
719
|
let avatarHash: string | null | undefined;
|
|
714
720
|
let serviceAccountKind: ServiceAccountKind | undefined;
|
|
715
721
|
switch (entry.principal.type) {
|
|
716
722
|
case "user": {
|
|
717
723
|
const user = userNames.get(entry.principal.userId);
|
|
718
|
-
displayName = user?.displayName
|
|
724
|
+
displayName = user?.displayName;
|
|
719
725
|
avatarHash = user?.avatarHash ?? null;
|
|
720
726
|
break;
|
|
721
727
|
}
|
|
722
728
|
case "group":
|
|
723
|
-
displayName = groupNames.get(entry.principal.groupId)
|
|
729
|
+
displayName = groupNames.get(entry.principal.groupId);
|
|
724
730
|
break;
|
|
725
731
|
case "service_account": {
|
|
726
732
|
const serviceAccount = serviceAccountsById.get(entry.principal.serviceAccountId);
|
|
727
|
-
displayName = serviceAccount?.name
|
|
733
|
+
displayName = serviceAccount?.name;
|
|
728
734
|
serviceAccountKind = serviceAccount?.kind;
|
|
729
735
|
break;
|
|
730
736
|
}
|
|
731
737
|
case "authenticated":
|
|
732
|
-
displayName = "All users (incl. guests)";
|
|
733
738
|
break;
|
|
734
739
|
case "public":
|
|
735
|
-
displayName = "Public";
|
|
736
740
|
break;
|
|
737
741
|
}
|
|
738
742
|
return { ...entry, displayName, avatarHash, serviceAccountKind };
|
|
@@ -32,9 +32,7 @@ export {
|
|
|
32
32
|
updateAccess,
|
|
33
33
|
} from "./access";
|
|
34
34
|
export { freeipa } from "./freeipa";
|
|
35
|
-
export type { GeoPlace, GeoService } from "./geo";
|
|
36
35
|
|
|
37
|
-
export { geo, geoService } from "./geo";
|
|
38
36
|
export { paginateItems } from "./pagination";
|
|
39
37
|
export { services } from "./services";
|
|
40
38
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { err, fail, isServiceError, ok, okMany, paginate, password, svg, tryCatch, unwrap } from "@k2b/stdlib";
|
|
2
2
|
import * as access from "./access";
|
|
3
3
|
import { freeipa } from "./freeipa";
|
|
4
|
-
import { geo } from "./geo";
|
|
5
4
|
|
|
6
5
|
export const services = {
|
|
7
6
|
access,
|
|
8
7
|
freeipa,
|
|
9
|
-
geo,
|
|
10
8
|
images: { generateFallback: svg.generateAvatar, parseWebpDataUrl: svg.parseWebpDataUrl },
|
|
11
9
|
password,
|
|
12
10
|
result: { ok, okMany, fail, err, unwrap, paginate, tryCatch, isServiceError },
|
|
@@ -132,8 +132,8 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
132
132
|
const readConfig = async (connection = db): Promise<LinuxIdentityConfiguration> => {
|
|
133
133
|
// Provisioning must not use a stale cache after an operator disables or changes the range.
|
|
134
134
|
const [row] = await connection<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${CONFIG_KEY}`;
|
|
135
|
-
const raw = row ? await decryptValue(row.value) : JSON.stringify(DEFAULT_LINUX_IDENTITY_CONFIGURATION);
|
|
136
135
|
try {
|
|
136
|
+
const raw = row ? await decryptValue(row.value) : JSON.stringify(DEFAULT_LINUX_IDENTITY_CONFIGURATION);
|
|
137
137
|
return LinuxIdentityConfigurationSchema.parse(typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
138
138
|
} catch {
|
|
139
139
|
throw new PosixError("invalid_configuration", 400);
|
|
@@ -304,15 +304,21 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
304
304
|
};
|
|
305
305
|
|
|
306
306
|
const service = {
|
|
307
|
-
async configuration(actor: Actor) {
|
|
307
|
+
async configuration(actor: Actor): Promise<LinuxIdentityConfiguration & { unreadable?: boolean }> {
|
|
308
308
|
requireAdmin(actor);
|
|
309
|
-
|
|
309
|
+
try {
|
|
310
|
+
return { ...(await readConfig()), unreadable: false };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// Provisioning still fails on this row. Administration alone may display the default for recovery.
|
|
313
|
+
if (!(error instanceof PosixError) || error.code !== "invalid_configuration") throw error;
|
|
314
|
+
return { ...DEFAULT_LINUX_IDENTITY_CONFIGURATION, unreadable: true };
|
|
315
|
+
}
|
|
310
316
|
},
|
|
311
317
|
async overview(actor: Actor, after: string | null = null, filters: { search?: string; scope?: "ready" | "all" } = {}) {
|
|
312
318
|
requireAdmin(actor);
|
|
313
319
|
const result = await rows(db, null, after, filters);
|
|
314
320
|
const items = result.slice(0, POSIX_PAGE_SIZE).map(candidate);
|
|
315
|
-
return { config: await
|
|
321
|
+
return { config: await service.configuration(actor), items, nextCursor: result.length > POSIX_PAGE_SIZE ? items.at(-1)!.id : null };
|
|
316
322
|
},
|
|
317
323
|
async get(actor: Actor, id: string) {
|
|
318
324
|
requireAdmin(actor);
|
|
@@ -320,7 +326,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
320
326
|
if (!row) throw new PosixError("user_not_found", 404);
|
|
321
327
|
return { config: await readConfig(), user: candidate(row) };
|
|
322
328
|
},
|
|
323
|
-
async configure(actor: Actor, input: LinuxIdentityConfiguration) {
|
|
329
|
+
async configure(actor: Actor, input: LinuxIdentityConfiguration, options?: { replaceUnreadable?: boolean }) {
|
|
324
330
|
requireAdmin(actor);
|
|
325
331
|
const parsed = LinuxIdentityConfigurationSchema.safeParse(input);
|
|
326
332
|
if (!parsed.success) throw new PosixError("invalid_configuration", 400);
|
|
@@ -328,9 +334,17 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
328
334
|
await db.begin(async (tx) => {
|
|
329
335
|
// Same order as provisioning; settings write upgrades this lock in the same transaction.
|
|
330
336
|
await lock(tx);
|
|
337
|
+
if (options?.replaceUnreadable !== true) {
|
|
338
|
+
try {
|
|
339
|
+
await readConfig(tx);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error instanceof PosixError && error.code === "invalid_configuration")
|
|
342
|
+
throw new settings.SettingUnreadableError(CONFIG_KEY);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
331
346
|
if (parsed.data.enabled) await checkRange(tx, parsed.data, ranges);
|
|
332
|
-
|
|
333
|
-
await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: true });
|
|
347
|
+
await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: options?.replaceUnreadable === true });
|
|
334
348
|
await audit.record(
|
|
335
349
|
{
|
|
336
350
|
action: "accounts.linux.configure",
|
|
@@ -2,6 +2,7 @@ import { type ClientRequest, request as httpRequest, type IncomingMessage, type
|
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
3
|
import { type NetworkAllowlist, networkAddressFamily, normalizeNetworkHostname } from "../shared/network-address";
|
|
4
4
|
import { type NetworkLookup, pinnedNetworkLookup, resolveAllowedNetworkAddresses } from "./network-security";
|
|
5
|
+
import { awaitNetworkOperation } from "./network-transport";
|
|
5
6
|
|
|
6
7
|
type RequestFactory = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
7
8
|
type AllowedNetworkFetchDeps = {
|
|
@@ -25,18 +26,19 @@ export const createAllowedNetworkFetch =
|
|
|
25
26
|
if (url.username || url.password) throw new TypeError("Network target must not include user information");
|
|
26
27
|
const signal = request.signal;
|
|
27
28
|
signal.throwIfAborted();
|
|
28
|
-
const addresses = await
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
const addresses = await awaitNetworkOperation(
|
|
30
|
+
(deps.resolve ?? resolveAllowedNetworkAddresses)(
|
|
31
|
+
url.hostname,
|
|
32
|
+
allowlist,
|
|
33
|
+
deps.lookup,
|
|
34
|
+
Number(url.port || (url.protocol === "https:" ? 443 : 80)),
|
|
35
|
+
),
|
|
36
|
+
signal,
|
|
37
|
+
);
|
|
36
38
|
signal.throwIfAborted();
|
|
37
39
|
// A body of known size goes out with Content-Length; only a caller's stream (in init or an input Request) is chunked.
|
|
38
40
|
const streamed = init?.body != null ? init.body instanceof ReadableStream : input instanceof Request && input.body !== null;
|
|
39
|
-
const payload = request.body && !streamed ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
|
41
|
+
const payload = request.body && !streamed ? new Uint8Array(await awaitNetworkOperation(request.arrayBuffer(), signal)) : undefined;
|
|
40
42
|
signal.throwIfAborted();
|
|
41
43
|
return new Promise<Response>((resolve, reject) => {
|
|
42
44
|
const headers = new Headers(request.headers);
|
|
@@ -125,7 +127,11 @@ export const createAllowedNetworkFetch =
|
|
|
125
127
|
});
|
|
126
128
|
try {
|
|
127
129
|
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
|
|
128
|
-
if (empty)
|
|
130
|
+
if (empty) {
|
|
131
|
+
incoming.destroy();
|
|
132
|
+
outgoing.destroy();
|
|
133
|
+
cleanup();
|
|
134
|
+
}
|
|
129
135
|
} catch (error) {
|
|
130
136
|
fail(error instanceof Error ? error : new Error(String(error)));
|
|
131
137
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { logger } from "./logging";
|
|
2
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
3
|
+
|
|
4
|
+
const KEY = "cloud:app-access:denied";
|
|
5
|
+
let lastFailureLog = -Infinity;
|
|
6
|
+
|
|
7
|
+
/** Count denied entry/invocation attempts, never discovery filtering or audit rows. Call only for registered app IDs. */
|
|
8
|
+
export const recordAppAccessDenied = (appId: string): void => {
|
|
9
|
+
void requestCacheRedis()
|
|
10
|
+
.then((redis) => redis.send("HINCRBY", [KEY, appId, "1"]))
|
|
11
|
+
.catch(() => {
|
|
12
|
+
if (Date.now() - lastFailureLog < 60_000) return;
|
|
13
|
+
lastFailureLog = Date.now();
|
|
14
|
+
logger("app-access").warn("Application access denial metrics are unavailable");
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The metric collector reads only the current registry's bounded set of app labels. */
|
|
19
|
+
export const readAppAccessDenials = async (appIds: readonly string[]): Promise<{ app: string; count: number }[]> => {
|
|
20
|
+
if (!appIds.length) return [];
|
|
21
|
+
const values: unknown = await (await requestCacheRedis()).send("HMGET", [KEY, ...appIds]);
|
|
22
|
+
if (!Array.isArray(values)) throw new Error("Invalid application access metrics response");
|
|
23
|
+
return appIds.map((app, index) => ({ app, count: Number(values[index] ?? 0) }));
|
|
24
|
+
};
|