@k2b/cloud 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +22 -11
- package/scripts/README.md +10 -0
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +20 -35
- package/scripts/preload.ts +16 -12
- package/scripts/sync-recovery-smoke.ts +22 -6
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/build-metadata.ts +8 -1
- package/src/_internal/capabilities.ts +131 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +80 -37
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/readiness.ts +4 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +18 -151
- package/src/access/PrincipalPicker.tsx +110 -0
- package/src/access/ResourceApiKeys.tsx +1 -1
- package/src/access/ui.ts +1 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +201 -0
- package/src/ai/browser.ts +4 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +229 -90
- package/src/ai/chat/builtin-tools.tsx +2 -4
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +83 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +25 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +31 -47
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +21 -2
- package/src/ai/chat/tool-groups.ts +58 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +14 -10
- package/src/ai/chat-tasks.ts +297 -43
- package/src/ai/client/controller.ts +165 -39
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +29 -11
- package/src/ai/client/transport.ts +6 -0
- package/src/ai/code-capability-routes.ts +105 -0
- package/src/ai/code-capability-transport.ts +39 -0
- package/src/ai/code-execution.ts +53 -0
- package/src/ai/code-mode-skill.ts +118 -0
- package/src/ai/code-runtime-tools.ts +179 -0
- package/src/ai/code-source-contracts.ts +270 -0
- package/src/ai/code-source-tools.ts +188 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +99 -3
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +49 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +189 -33
- package/src/ai/fetch-file-tool.ts +2 -2
- package/src/ai/file-content-version.ts +27 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +8 -1
- package/src/ai/files-store.ts +212 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +42 -10
- package/src/ai/http.ts +26 -8
- package/src/ai/index.ts +26 -3
- package/src/ai/inference-calls.ts +197 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/live.ts +1 -1
- package/src/ai/markdown-pdf-tool.ts +2 -2
- package/src/ai/memories.ts +30 -27
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +64 -32
- package/src/ai/memory-tool.ts +1 -5
- package/src/ai/memory-workflow-evidence.ts +9 -4
- package/src/ai/message-queue.ts +178 -0
- package/src/ai/migrate.ts +189 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +75 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +18 -43
- package/src/ai/projects.ts +33 -14
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +192 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +241 -11
- package/src/ai/run-timeout.ts +12 -0
- package/src/ai/runtime-tools.ts +7 -5
- package/src/ai/runtime.ts +74 -17
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +4 -5
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +58 -0
- package/src/ai/skill-seeds.ts +114 -20
- package/src/ai/skill-tool.ts +21 -5
- package/src/ai/skills-routes.ts +49 -4
- package/src/ai/skills.ts +358 -163
- package/src/ai/store.ts +416 -201
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +12 -1
- package/src/ai/system-prompt.ts +14 -2
- package/src/ai/task-contracts.ts +84 -0
- package/src/ai/task-execution.ts +92 -0
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +32 -0
- package/src/ai/todo-tool.ts +19 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +3 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +60 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +80 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-linux-identities.ts +10 -4
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/app-approval.ts +2 -2
- package/src/api/auth.ts +1 -1
- package/src/api/capabilities.ts +324 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +8 -2
- package/src/api/mcp.ts +45 -34
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +67 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +6 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +116 -35
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +77 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +10 -1
- package/src/cli/admin/instance.ts +2 -2
- package/src/cli/admin/linux.ts +4 -2
- package/src/cli/admin/sync.ts +1 -1
- package/src/cli/capabilities.ts +42 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/define-env.ts +97 -0
- package/src/config/env.ts +147 -51
- package/src/config/index.ts +3 -1
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +75 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +5 -2
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +5 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +18 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/index.ts +1 -0
- package/src/desktop/solid.tsx +1 -1
- package/src/index.ts +8 -6
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/ratelimit.ts +1 -1
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +3 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/notification-sender.ts +12 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/app-approval.ts +11 -10
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +8 -5
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/identity/runtime-config.ts +6 -5
- package/src/services/index.ts +15 -0
- package/src/services/ipa/posix.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +21 -4
- package/src/services/mandates/policy.ts +165 -2
- package/src/services/notifications/batches.ts +125 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +131 -72
- package/src/services/pdf/index.ts +11 -8
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +29 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/app.ts +5 -1
- package/src/services/settings/core-settings.ts +26 -35
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/index.ts +3 -3
- package/src/services/settings/store.ts +58 -84
- package/src/services/webauthn.ts +1 -1
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +3 -0
- package/src/shared/markdown/extensions/info-blocks.ts +1 -1
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +30 -16
- package/src/ssr/AppLaunchpad.island.tsx +33 -136
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +22 -23
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MinimalLayout.tsx +1 -1
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +11 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/index.ts +2 -2
- package/src/workflows/ai/runtime.ts +19 -90
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai/types.ts +6 -82
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +4 -2
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/server/services/freeipa/test-certificates.ts +0 -44
- package/src/services/notifications/live.ts +0 -50
- package/src/services/session/test-fixture.ts +0 -18
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { type SQLQuery, sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DEFAULT_LINUX_IDENTITY_CONFIGURATION, LinuxIdentityConfigurationSchema } from "../../contracts/posix";
|
|
4
|
+
import type { RequestActor, UserProfile, UserProvider } from "../../contracts/shared";
|
|
5
|
+
import { userFromActor } from "../../server/actor";
|
|
6
|
+
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
7
|
+
import { toPgTextArray } from "../postgres";
|
|
8
|
+
import { decryptValue } from "../settings/crypto";
|
|
9
|
+
import { buildMemberGroupScopeCondition } from "./group-sql";
|
|
10
|
+
import { type AccountIdentityReconciliation, readUpstreamIdentity } from "./identity-reconciliation";
|
|
11
|
+
|
|
12
|
+
export type AccountIdentityUser = {
|
|
13
|
+
id: string;
|
|
14
|
+
provider: UserProvider;
|
|
15
|
+
username: string;
|
|
16
|
+
profile: UserProfile;
|
|
17
|
+
posix: { uidNumber: number; primaryGidNumber: number } | null;
|
|
18
|
+
};
|
|
19
|
+
export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null };
|
|
20
|
+
export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
|
|
21
|
+
export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
|
|
22
|
+
type InventoryFilter = { provider: UserProvider; after?: string; id?: string; name?: string };
|
|
23
|
+
|
|
24
|
+
export class AccountIdentityError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly code: string,
|
|
27
|
+
public readonly status: 400 | 401 | 403 = 403,
|
|
28
|
+
) {
|
|
29
|
+
super(code);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The same bounded page size as Linux identity administration.
|
|
34
|
+
const PAGE_SIZE = 50;
|
|
35
|
+
const cursorSchema = z.uuid();
|
|
36
|
+
const positiveId = (value: number | null): value is number => Number.isSafeInteger(value) && value !== null && value > 0;
|
|
37
|
+
type UserRow = {
|
|
38
|
+
id: string;
|
|
39
|
+
provider: UserProvider;
|
|
40
|
+
uid: string;
|
|
41
|
+
profile: UserProfile;
|
|
42
|
+
admin: boolean;
|
|
43
|
+
expired: boolean;
|
|
44
|
+
managed_by: UserProvider | null;
|
|
45
|
+
uid_number: number | null;
|
|
46
|
+
primary_gid_number: number | null;
|
|
47
|
+
};
|
|
48
|
+
const projectUser = (row: UserRow): AccountIdentityUser => ({
|
|
49
|
+
id: row.id,
|
|
50
|
+
provider: row.provider,
|
|
51
|
+
username: row.uid,
|
|
52
|
+
profile: row.profile,
|
|
53
|
+
posix:
|
|
54
|
+
row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
|
|
55
|
+
? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
|
|
56
|
+
: null,
|
|
57
|
+
});
|
|
58
|
+
const page = <T extends { id: string }>(rows: T[]): AccountIdentityPage<T> => ({
|
|
59
|
+
items: rows.slice(0, PAGE_SIZE),
|
|
60
|
+
nextCursor: rows.length > PAGE_SIZE ? rows[PAGE_SIZE - 1]!.id : null,
|
|
61
|
+
});
|
|
62
|
+
const cursor = (after?: string): string | null => {
|
|
63
|
+
if (after !== undefined && !cursorSchema.safeParse(after).success) throw new AccountIdentityError("invalid_cursor", 400);
|
|
64
|
+
return after ?? null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Server-side identity reads. Authentication and application resource authorization remain caller-owned. */
|
|
68
|
+
export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdentity = readUpstreamIdentity) => {
|
|
69
|
+
const setting = async (key: string, fallback: unknown): Promise<unknown> => {
|
|
70
|
+
const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${key}`;
|
|
71
|
+
return row ? decryptValue(row.value) : fallback;
|
|
72
|
+
};
|
|
73
|
+
const availability = async (): Promise<AccountIdentityAvailability> => {
|
|
74
|
+
const [raw, ipa] = await Promise.all([
|
|
75
|
+
setting("linux.identity_config", DEFAULT_LINUX_IDENTITY_CONFIGURATION),
|
|
76
|
+
setting("freeipa.enable", false),
|
|
77
|
+
]);
|
|
78
|
+
try {
|
|
79
|
+
const config = LinuxIdentityConfigurationSchema.parse(typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
80
|
+
if (typeof ipa !== "boolean") throw new Error("Invalid FreeIPA state");
|
|
81
|
+
return { localLinuxEnabled: config.enabled, freeipaEnabled: ipa };
|
|
82
|
+
} catch {
|
|
83
|
+
throw new AccountIdentityError("invalid_identity_configuration");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const userRows = (filter: SQLQuery) => db<UserRow[]>`
|
|
87
|
+
SELECT u.id, u.provider, u.uid, u.profile, u.admin,
|
|
88
|
+
(u.account_expires IS NOT NULL AND u.account_expires <= now()) AS expired,
|
|
89
|
+
p.managed_by, p.uid_number, p.primary_gid_number
|
|
90
|
+
FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id = u.id
|
|
91
|
+
WHERE ${filter}
|
|
92
|
+
ORDER BY u.id LIMIT ${PAGE_SIZE + 1}
|
|
93
|
+
`;
|
|
94
|
+
const requireUser = async (actor: RequestActor) => {
|
|
95
|
+
const supplied = userFromActor(actor);
|
|
96
|
+
if (!supplied) throw new AccountIdentityError("user_required", 401);
|
|
97
|
+
const [row] = await userRows(sql`u.id = ${supplied.id}::uuid`);
|
|
98
|
+
if (!row || row.expired || row.provider !== supplied.provider || row.uid !== supplied.uid || row.profile !== supplied.profile)
|
|
99
|
+
throw new AccountIdentityError("identity_unavailable");
|
|
100
|
+
if (!(await isAccountCategoryAllowed(row, db))) throw new AccountIdentityError("identity_unavailable");
|
|
101
|
+
const state = await availability();
|
|
102
|
+
if (row.provider === "ipa" && !state.freeipaEnabled) throw new AccountIdentityError("identity_unavailable");
|
|
103
|
+
return { row, state, supplied };
|
|
104
|
+
};
|
|
105
|
+
const requireAdmin = async (actor: RequestActor) => {
|
|
106
|
+
const current = await requireUser(actor);
|
|
107
|
+
if (!current.supplied.roles.includes("admin")) throw new AccountIdentityError("admin_required");
|
|
108
|
+
if (current.row.provider === "local") {
|
|
109
|
+
if (!current.row.admin || current.row.profile !== "user") throw new AccountIdentityError("admin_required");
|
|
110
|
+
} else {
|
|
111
|
+
const configured = await setting("freeipa.groups.admin", ["admins"]);
|
|
112
|
+
if (!Array.isArray(configured) || !configured.every((value): value is string => typeof value === "string"))
|
|
113
|
+
throw new AccountIdentityError("invalid_identity_configuration");
|
|
114
|
+
const names = configured.length > 0 ? configured : ["admins"];
|
|
115
|
+
const [membership] = await db`SELECT 1 FROM auth.ipa_user_effective_groups
|
|
116
|
+
WHERE user_id = ${current.row.id}::uuid AND group_name = ANY(${toPgTextArray(names)}::text[]) LIMIT 1`;
|
|
117
|
+
if (!membership) throw new AccountIdentityError("admin_required");
|
|
118
|
+
}
|
|
119
|
+
return current;
|
|
120
|
+
};
|
|
121
|
+
const groupRows = async (filter: SQLQuery) => {
|
|
122
|
+
const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null }[]>`
|
|
123
|
+
SELECT g.id, g.provider, g.name, g.gid_number FROM auth.groups g
|
|
124
|
+
WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
|
|
125
|
+
`;
|
|
126
|
+
return rows.map(
|
|
127
|
+
(row): AccountIdentityGroup => ({
|
|
128
|
+
id: row.id,
|
|
129
|
+
provider: row.provider,
|
|
130
|
+
name: row.name,
|
|
131
|
+
gidNumber: positiveId(row.gid_number) ? row.gid_number : null,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
async function inventory(
|
|
136
|
+
actor: RequestActor,
|
|
137
|
+
options: InventoryFilter & { kind: "users" },
|
|
138
|
+
): Promise<AccountIdentityPage<AccountIdentityUser>>;
|
|
139
|
+
async function inventory(
|
|
140
|
+
actor: RequestActor,
|
|
141
|
+
options: InventoryFilter & { kind: "groups" },
|
|
142
|
+
): Promise<AccountIdentityPage<AccountIdentityGroup>>;
|
|
143
|
+
async function inventory(actor: RequestActor, options: InventoryFilter & { kind: "users" | "groups" }) {
|
|
144
|
+
await requireAdmin(actor);
|
|
145
|
+
const after = cursor(options.after);
|
|
146
|
+
const id = cursor(options.id);
|
|
147
|
+
const name = options.name ?? null;
|
|
148
|
+
if (options.provider !== "local" && options.provider !== "ipa") throw new AccountIdentityError("invalid_provider", 400);
|
|
149
|
+
if (options.kind === "users") {
|
|
150
|
+
return page(
|
|
151
|
+
(
|
|
152
|
+
await userRows(sql`u.provider = ${options.provider} AND (${after}::uuid IS NULL OR u.id > ${after}::uuid)
|
|
153
|
+
AND (${id}::uuid IS NULL OR u.id = ${id}::uuid) AND (${name}::text IS NULL OR u.uid = ${name})`)
|
|
154
|
+
).map(projectUser),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (options.kind !== "groups") throw new AccountIdentityError("invalid_inventory_kind", 400);
|
|
158
|
+
return page(
|
|
159
|
+
await groupRows(sql`g.provider = ${options.provider} AND (${after}::uuid IS NULL OR g.id > ${after}::uuid)
|
|
160
|
+
AND (${id}::uuid IS NULL OR g.id = ${id}::uuid) AND (${name}::text IS NULL OR g.name = ${name})`),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const readLocalIdentity = async (input: {
|
|
164
|
+
kind: "users" | "groups";
|
|
165
|
+
name: string;
|
|
166
|
+
identityId?: string;
|
|
167
|
+
}): Promise<AccountIdentityReconciliation> => {
|
|
168
|
+
if (input.kind === "users") {
|
|
169
|
+
const rows = await userRows(sql`(u.provider = 'local' AND u.uid = ${input.name}) OR u.id = ${input.identityId ?? null}::uuid`);
|
|
170
|
+
const expected = rows.find((row) => row.id === input.identityId);
|
|
171
|
+
if (expected && (expected.provider !== "local" || expected.uid !== input.name))
|
|
172
|
+
return { state: "unknown", reason: "identity_conflict" };
|
|
173
|
+
const matches = rows.filter((row) => row.provider === "local" && row.uid === input.name);
|
|
174
|
+
if (!matches.length) return { state: "absent" };
|
|
175
|
+
if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
|
|
176
|
+
const current = projectUser(matches[0]!);
|
|
177
|
+
return {
|
|
178
|
+
state: "present",
|
|
179
|
+
identity: {
|
|
180
|
+
id: current.id,
|
|
181
|
+
name: current.username,
|
|
182
|
+
uidNumber: current.posix?.uidNumber ?? null,
|
|
183
|
+
gidNumber: current.posix?.primaryGidNumber ?? null,
|
|
184
|
+
},
|
|
185
|
+
eligible: current.profile === "user",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
|
|
189
|
+
const expected = rows.find((row) => row.id === input.identityId);
|
|
190
|
+
if (expected && (expected.provider !== "local" || expected.name !== input.name))
|
|
191
|
+
return { state: "unknown", reason: "identity_conflict" };
|
|
192
|
+
const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
|
|
193
|
+
if (!matches.length) return { state: "absent" };
|
|
194
|
+
if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
|
|
195
|
+
const current = matches[0]!;
|
|
196
|
+
return {
|
|
197
|
+
state: "present",
|
|
198
|
+
identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
|
|
199
|
+
eligible: current.gidNumber !== null,
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
|
|
203
|
+
if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
|
|
204
|
+
throw new AccountIdentityError("invalid_identity_lookup", 400);
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
async reconcile(
|
|
208
|
+
actor: RequestActor,
|
|
209
|
+
input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
|
|
210
|
+
): Promise<AccountIdentityReconciliation> {
|
|
211
|
+
const { state } = await requireAdmin(actor);
|
|
212
|
+
validateLookup(input);
|
|
213
|
+
if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
|
|
214
|
+
if (input.identityId !== undefined) cursor(input.identityId);
|
|
215
|
+
if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
|
|
216
|
+
if (input.provider === "ipa") {
|
|
217
|
+
if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
|
|
218
|
+
return upstreamIdentity(input);
|
|
219
|
+
}
|
|
220
|
+
const result = await readLocalIdentity(input);
|
|
221
|
+
return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
|
|
222
|
+
},
|
|
223
|
+
/** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
|
|
224
|
+
async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
|
|
225
|
+
validateLookup(input);
|
|
226
|
+
cursor(input.identityId);
|
|
227
|
+
const { localLinuxEnabled } = await availability();
|
|
228
|
+
return { localLinuxEnabled, identity: await readLocalIdentity(input) };
|
|
229
|
+
},
|
|
230
|
+
async self(actor: RequestActor) {
|
|
231
|
+
const { row, state } = await requireUser(actor);
|
|
232
|
+
return { user: projectUser(row), availability: state };
|
|
233
|
+
},
|
|
234
|
+
async groups(actor: RequestActor, options: { after?: string } = {}): Promise<AccountIdentityPage<AccountIdentityGroup>> {
|
|
235
|
+
const { row, state } = await requireUser(actor);
|
|
236
|
+
const after = cursor(options.after);
|
|
237
|
+
return page(
|
|
238
|
+
await groupRows(sql`
|
|
239
|
+
(${after}::uuid IS NULL OR g.id > ${after}::uuid)
|
|
240
|
+
AND (g.provider = 'local' OR ${state.freeipaEnabled})
|
|
241
|
+
AND ${buildMemberGroupScopeCondition({ userId: row.id, groupProvider: sql`g.provider` })}
|
|
242
|
+
`),
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
inventory,
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const accountIdentities = createAccountIdentityService();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { freeipa } from "../../server/services";
|
|
3
|
+
import type { IpaRpcResponse } from "../../server/services/freeipa/client";
|
|
4
|
+
import { FREEIPA_REQUEST_TIMEOUT_MS } from "../../server/services/freeipa/transport";
|
|
5
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
6
|
+
|
|
7
|
+
export type AccountIdentityReconciliation =
|
|
8
|
+
| {
|
|
9
|
+
state: "present";
|
|
10
|
+
identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
|
|
11
|
+
eligible: boolean;
|
|
12
|
+
}
|
|
13
|
+
| { state: "absent" }
|
|
14
|
+
| { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
|
|
15
|
+
export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
|
|
16
|
+
|
|
17
|
+
const number = z
|
|
18
|
+
.union([z.number().int(), z.string().regex(/^\d+$/).transform(Number)])
|
|
19
|
+
.pipe(z.number().int().positive().max(2_147_483_647));
|
|
20
|
+
const attributeNumber = z.array(number).length(1).optional();
|
|
21
|
+
const userRecord = z.object({ uid: z.array(z.string()).length(1), uidnumber: attributeNumber, gidnumber: attributeNumber });
|
|
22
|
+
const groupRecord = z.object({ cn: z.array(z.string()).length(1), gidnumber: attributeNumber });
|
|
23
|
+
const notFound = (response: IpaRpcResponse) =>
|
|
24
|
+
response.error?.kind === "rpc" && response.error.code === 4001 && response.error.name === "NotFound";
|
|
25
|
+
|
|
26
|
+
// Exact show responses have no pagination or Cloud sync filter. Only the named
|
|
27
|
+
// upstream NotFound error proves absence; HTTP 404 and malformed success do not.
|
|
28
|
+
export function parseIdentityShow(input: IdentityLookup, response: IpaRpcResponse): AccountIdentityReconciliation {
|
|
29
|
+
if (notFound(response)) return { state: "absent" };
|
|
30
|
+
if (response.error)
|
|
31
|
+
return { state: "unknown", reason: response.error.kind === "invalid_response" ? "invalid_response" : "provider_unavailable" };
|
|
32
|
+
const parsed = input.kind === "users" ? userRecord.safeParse(response.result?.result) : groupRecord.safeParse(response.result?.result);
|
|
33
|
+
if (!parsed.success) return { state: "unknown", reason: "invalid_response" };
|
|
34
|
+
const record = parsed.data;
|
|
35
|
+
const name = "uid" in record ? record.uid[0]! : record.cn[0]!;
|
|
36
|
+
if (name !== input.name) return { state: "unknown", reason: "invalid_response" };
|
|
37
|
+
const uidNumber = "uidnumber" in record ? (record.uidnumber?.[0] ?? null) : null;
|
|
38
|
+
const gidNumber = record.gidnumber?.[0] ?? null;
|
|
39
|
+
return {
|
|
40
|
+
state: "present",
|
|
41
|
+
identity: { id: null, name, uidNumber, gidNumber },
|
|
42
|
+
eligible: gidNumber !== null && (input.kind === "groups" || uidNumber !== null),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const readUpstreamIdentity = async (
|
|
47
|
+
input: IdentityLookup,
|
|
48
|
+
deps = { config: getFreeIpaConfig, session: freeipa.session.getServiceSession, call: freeipa.client.call },
|
|
49
|
+
): Promise<AccountIdentityReconciliation> => {
|
|
50
|
+
try {
|
|
51
|
+
const timeout = AbortSignal.timeout(FREEIPA_REQUEST_TIMEOUT_MS);
|
|
52
|
+
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
const config = await deps.config();
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
if (!config.enabled) return { state: "unknown", reason: "provider_disabled" };
|
|
57
|
+
if (!config.configured) return { state: "unknown", reason: "provider_unavailable" };
|
|
58
|
+
const ipaSession = await deps.session({
|
|
59
|
+
url: config.url,
|
|
60
|
+
serviceUser: config.serviceUser,
|
|
61
|
+
servicePassword: config.servicePassword,
|
|
62
|
+
signal,
|
|
63
|
+
});
|
|
64
|
+
signal.throwIfAborted();
|
|
65
|
+
const query = async (method: string) => {
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
const response = await deps.call({
|
|
68
|
+
url: config.url,
|
|
69
|
+
ipaSession,
|
|
70
|
+
method,
|
|
71
|
+
args: [input.name],
|
|
72
|
+
options: { all: true, no_members: true },
|
|
73
|
+
signal,
|
|
74
|
+
});
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
return response;
|
|
77
|
+
};
|
|
78
|
+
const result = parseIdentityShow(input, await query(input.kind === "users" ? "user_show" : "group_show"));
|
|
79
|
+
if (input.kind !== "users" || result.state !== "absent") return result;
|
|
80
|
+
// user_show includes preserved users, but staged users live in a separate
|
|
81
|
+
// container and can retain a former account's data while awaiting activation.
|
|
82
|
+
const staged = parseIdentityShow(input, await query("stageuser_show"));
|
|
83
|
+
return staged.state === "present" ? { ...staged, eligible: false } : staged;
|
|
84
|
+
} catch {
|
|
85
|
+
return { state: "unknown", reason: "provider_unavailable" };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -3,9 +3,9 @@ import type { IpaUserData } from "../../contracts/shared";
|
|
|
3
3
|
|
|
4
4
|
type DbRow = Record<string, unknown>;
|
|
5
5
|
|
|
6
|
-
export const userIpaDataJoin = sql`LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id`;
|
|
6
|
+
export const userIpaDataJoin = () => sql`LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id`;
|
|
7
7
|
|
|
8
|
-
export const userIpaDataColumns = sql`
|
|
8
|
+
export const userIpaDataColumns = () => sql`
|
|
9
9
|
CASE WHEN EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.user_id = u.id AND p.managed_by = 'ipa')
|
|
10
10
|
THEN (SELECT p.uid_number FROM auth.user_posix p WHERE p.user_id = u.id AND p.managed_by = 'ipa')
|
|
11
11
|
ELSE ui.uid_number END AS ipa_uid_number,
|
|
@@ -77,10 +77,10 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
|
|
|
77
77
|
return row ? toBaseGroup(row) : null;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
export const create = async (params: { name: string; description?: string }): Promise<MutationResult<BaseGroup>> => {
|
|
80
|
+
export const create = async (params: { name: string; description?: string }, db: typeof sql = sql): Promise<MutationResult<BaseGroup>> => {
|
|
81
81
|
const storedCn = `local:${params.name}`;
|
|
82
82
|
try {
|
|
83
|
-
const rows = await
|
|
83
|
+
const rows = await db<DbRow[]>`
|
|
84
84
|
INSERT INTO auth.groups (id, cn, provider, name, description, synced_at)
|
|
85
85
|
VALUES (gen_random_uuid(), ${storedCn}, 'local', ${params.name}, ${params.description ?? null}, now())
|
|
86
86
|
RETURNING id, provider, name, description, gid_number
|
|
@@ -3,7 +3,12 @@ import type { TypedNotificationSendResult } from "../notifications/platform";
|
|
|
3
3
|
export type AccountNotificationDeliveryResult = Pick<TypedNotificationSendResult, "id" | "status">;
|
|
4
4
|
|
|
5
5
|
export type AccountsNotificationSender = {
|
|
6
|
-
sendLoginLink: (input: {
|
|
6
|
+
sendLoginLink: (input: {
|
|
7
|
+
email: string;
|
|
8
|
+
token: string;
|
|
9
|
+
magicLink: string;
|
|
10
|
+
locale?: string;
|
|
11
|
+
}) => Promise<AccountNotificationDeliveryResult>;
|
|
7
12
|
sendFreeIpaWelcome: (input: {
|
|
8
13
|
userId: string;
|
|
9
14
|
uid: string;
|
|
@@ -11,7 +16,12 @@ export type AccountsNotificationSender = {
|
|
|
11
16
|
accountExpires: string | null;
|
|
12
17
|
locale?: string;
|
|
13
18
|
}) => Promise<AccountNotificationDeliveryResult>;
|
|
14
|
-
sendLocalWelcome: (input: {
|
|
19
|
+
sendLocalWelcome: (input: {
|
|
20
|
+
userId: string;
|
|
21
|
+
email: string;
|
|
22
|
+
accountExpires: string | null;
|
|
23
|
+
locale?: string;
|
|
24
|
+
}) => Promise<AccountNotificationDeliveryResult>;
|
|
15
25
|
sendRequestDenied: (input: {
|
|
16
26
|
requestId: string;
|
|
17
27
|
userId: string;
|
|
@@ -2,18 +2,19 @@ import { sql } from "bun";
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_LINUX_IDENTITY_CONFIGURATION,
|
|
4
4
|
isPosixName,
|
|
5
|
-
LinuxIdentityConfigurationSchema,
|
|
6
5
|
type LinuxIdentityConfiguration,
|
|
6
|
+
LinuxIdentityConfigurationSchema,
|
|
7
7
|
type PosixIdentity,
|
|
8
8
|
PosixOverridesSchema,
|
|
9
9
|
} from "../../contracts/posix";
|
|
10
|
+
import type { MutationResult } from "../../contracts/shared";
|
|
10
11
|
import { freeipa } from "../../server/services";
|
|
12
|
+
import { type AuditActor, audit } from "../audit";
|
|
11
13
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
12
14
|
import { readCompleteIpaList } from "../ipa/sync-planning";
|
|
13
|
-
import { decryptValue } from "../settings/crypto";
|
|
14
15
|
import * as settings from "../settings";
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
16
|
+
import { decryptValue } from "../settings/crypto";
|
|
17
|
+
import { create as createLocalGroup } from "./local-groups";
|
|
17
18
|
|
|
18
19
|
const CONFIG_KEY = "linux.identity_config";
|
|
19
20
|
export const POSIX_PAGE_SIZE = 50;
|
|
@@ -274,6 +275,34 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
274
275
|
}
|
|
275
276
|
};
|
|
276
277
|
|
|
278
|
+
const assignGroup = async (tx: typeof sql, actor: Actor, id: string, ranges: IpaIdRange[]) => {
|
|
279
|
+
const config = await readConfig(tx);
|
|
280
|
+
if (!config.enabled) throw new PosixError("setup_disabled");
|
|
281
|
+
const [group] = await tx<
|
|
282
|
+
{ id: string; name: string; provider: string; gid_number: number | null }[]
|
|
283
|
+
>`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
|
|
284
|
+
if (!group) throw new PosixError("group_not_found", 404);
|
|
285
|
+
if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
|
|
286
|
+
if (group.gid_number !== null) return { gidNumber: group.gid_number };
|
|
287
|
+
if (!isPosixName(group.name)) throw new PosixError("invalid_name");
|
|
288
|
+
const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
|
|
289
|
+
if (conflict) throw new PosixError("group_conflict");
|
|
290
|
+
await checkRange(tx, config, ranges);
|
|
291
|
+
const gidNumber = await allocate(tx, "gid", id, config);
|
|
292
|
+
await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
|
|
293
|
+
await audit.record(
|
|
294
|
+
{
|
|
295
|
+
action: "accounts.linux.provision_group",
|
|
296
|
+
outcome: "allowed",
|
|
297
|
+
actor: { userId: actor.id, roles: actor.roles },
|
|
298
|
+
target: { type: "group", id },
|
|
299
|
+
metadata: { gidNumber },
|
|
300
|
+
},
|
|
301
|
+
tx,
|
|
302
|
+
);
|
|
303
|
+
return { gidNumber };
|
|
304
|
+
};
|
|
305
|
+
|
|
277
306
|
const service = {
|
|
278
307
|
async configuration(actor: Actor) {
|
|
279
308
|
requireAdmin(actor);
|
|
@@ -347,36 +376,23 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
347
376
|
return { ...candidate(row), identity: { ...candidate(row).identity!, ...parsed.data } };
|
|
348
377
|
});
|
|
349
378
|
},
|
|
379
|
+
async createGroup(actor: Actor, input: { name: string; description?: string }) {
|
|
380
|
+
requireAdmin(actor);
|
|
381
|
+
const ranges = await readRanges();
|
|
382
|
+
return db.begin(async (tx) => {
|
|
383
|
+
await lock(tx);
|
|
384
|
+
const created = await createLocalGroup(input, tx);
|
|
385
|
+
if (!created.ok) throw new PosixError("group_conflict");
|
|
386
|
+
const { gidNumber } = await assignGroup(tx, actor, created.data.id, ranges);
|
|
387
|
+
return { ...created.data, gidnumber: gidNumber };
|
|
388
|
+
});
|
|
389
|
+
},
|
|
350
390
|
async provisionGroup(actor: Actor, id: string) {
|
|
351
391
|
requireAdmin(actor);
|
|
352
392
|
const ranges = await readRanges();
|
|
353
393
|
return db.begin(async (tx) => {
|
|
354
394
|
await lock(tx);
|
|
355
|
-
|
|
356
|
-
if (!config.enabled) throw new PosixError("setup_disabled");
|
|
357
|
-
const [group] = await tx<
|
|
358
|
-
{ id: string; name: string; provider: string; gid_number: number | null }[]
|
|
359
|
-
>`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
|
|
360
|
-
if (!group) throw new PosixError("group_not_found", 404);
|
|
361
|
-
if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
|
|
362
|
-
if (group.gid_number !== null) return { gidNumber: group.gid_number };
|
|
363
|
-
if (!isPosixName(group.name)) throw new PosixError("invalid_name");
|
|
364
|
-
const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
|
|
365
|
-
if (conflict) throw new PosixError("group_conflict");
|
|
366
|
-
await checkRange(tx, config, ranges);
|
|
367
|
-
const gidNumber = await allocate(tx, "gid", id, config);
|
|
368
|
-
await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
|
|
369
|
-
await audit.record(
|
|
370
|
-
{
|
|
371
|
-
action: "accounts.linux.provision_group",
|
|
372
|
-
outcome: "allowed",
|
|
373
|
-
actor: { userId: actor.id, roles: actor.roles },
|
|
374
|
-
target: { type: "group", id },
|
|
375
|
-
metadata: { gidNumber },
|
|
376
|
-
},
|
|
377
|
-
tx,
|
|
378
|
-
);
|
|
379
|
-
return { gidNumber };
|
|
395
|
+
return assignGroup(tx, actor, id, ranges);
|
|
380
396
|
});
|
|
381
397
|
},
|
|
382
398
|
};
|
|
@@ -172,7 +172,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
|
|
|
172
172
|
const { groupsAdmin } = await getFreeIpaConfig();
|
|
173
173
|
const rows = await sql<DbRow[]>`
|
|
174
174
|
SELECT u.*,
|
|
175
|
-
${userIpaDataColumns},
|
|
175
|
+
${userIpaDataColumns()},
|
|
176
176
|
CASE
|
|
177
177
|
WHEN u.provider = 'local' THEN u.admin
|
|
178
178
|
ELSE EXISTS(
|
|
@@ -203,7 +203,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
|
|
|
203
203
|
${managedGroupIdsSubquery(userIdExpr)}
|
|
204
204
|
), '{}') AS manages_group_ids
|
|
205
205
|
FROM auth.users u
|
|
206
|
-
${userIpaDataJoin}
|
|
206
|
+
${userIpaDataJoin()}
|
|
207
207
|
WHERE ${whereClause}
|
|
208
208
|
`;
|
|
209
209
|
if (rows.length === 0) return null;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
2
|
import { sql } from "bun";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { z } from "zod";
|
|
3
5
|
import type {
|
|
4
6
|
AnnouncementCookieState,
|
|
5
7
|
AnnouncementDisplayEntry,
|
|
@@ -7,10 +9,25 @@ import type {
|
|
|
7
9
|
CreateAnnouncement,
|
|
8
10
|
UpdateAnnouncement,
|
|
9
11
|
} from "../../contracts/announcements";
|
|
12
|
+
import { AnnouncementDisplayEntrySchema } from "../../contracts/announcements";
|
|
13
|
+
import { hasRole, type User } from "../../contracts/shared";
|
|
10
14
|
import { markdown } from "../../shared/markdown";
|
|
15
|
+
import { claimCacheFill, completeCacheFill } from "../cache-fill";
|
|
11
16
|
import { logger } from "../logging";
|
|
17
|
+
import { requestCacheRedis } from "../request-cache-redis";
|
|
12
18
|
|
|
13
19
|
const log = logger("announcements");
|
|
20
|
+
const ACTIVE_CACHE_KEY = "appglobalcache:shared:announcements:v1";
|
|
21
|
+
const invalidateActiveCache = async () => (await requestCacheRedis()).del(ACTIVE_CACHE_KEY);
|
|
22
|
+
const invalidateAfterMutation = async () => {
|
|
23
|
+
try {
|
|
24
|
+
await invalidateActiveCache();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// The database mutation already succeeded; do not invite duplicate creates.
|
|
27
|
+
// Unavailable Redis reads fall back to Postgres; old cache entries expire.
|
|
28
|
+
log.warn("Announcement saved but cache invalidation failed", { error: error instanceof Error ? error.message : String(error) });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
14
31
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
32
|
|
|
16
33
|
type AnnouncementRow = {
|
|
@@ -119,6 +136,7 @@ const create = async (params: { data: CreateAnnouncement; actorId: string }): Pr
|
|
|
119
136
|
RETURNING id, version, kind, title, body, tone, published_at, expires_at,
|
|
120
137
|
created_at, updated_at, created_by, updated_by
|
|
121
138
|
`;
|
|
139
|
+
if (row) await invalidateAfterMutation();
|
|
122
140
|
return row ? ok(mapRow(row)) : fail(err.internal("Failed to create announcement."));
|
|
123
141
|
} catch (error) {
|
|
124
142
|
log.error("Failed to create announcement", { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -156,6 +174,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
|
|
|
156
174
|
RETURNING id, version, kind, title, body, tone, published_at, expires_at,
|
|
157
175
|
created_at, updated_at, created_by, updated_by
|
|
158
176
|
`;
|
|
177
|
+
if (row) await invalidateAfterMutation();
|
|
159
178
|
return row ? ok(mapRow(row)) : fail(err.notFound("Announcement"));
|
|
160
179
|
} catch (error) {
|
|
161
180
|
log.error("Failed to update announcement", { id: params.id, error: error instanceof Error ? error.message : String(error) });
|
|
@@ -166,6 +185,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
|
|
|
166
185
|
const remove = async (params: { id: string }): Promise<Result<void>> => {
|
|
167
186
|
if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
|
|
168
187
|
const result = await sql`DELETE FROM announcements.entries WHERE id = ${params.id}::uuid`;
|
|
188
|
+
if (result.count > 0) await invalidateAfterMutation();
|
|
169
189
|
return result.count > 0 ? ok() : fail(err.notFound("Announcement"));
|
|
170
190
|
};
|
|
171
191
|
|
|
@@ -182,28 +202,65 @@ const listActive = async (params: { now?: Date } = {}): Promise<AnnouncementEntr
|
|
|
182
202
|
return rows.map(mapRow);
|
|
183
203
|
};
|
|
184
204
|
|
|
185
|
-
|
|
186
|
-
entries:
|
|
205
|
+
const selectEntriesForState = <T extends { kind: AnnouncementEntry["kind"]; version: number }>(
|
|
206
|
+
entries: T[],
|
|
187
207
|
state: AnnouncementCookieState,
|
|
188
|
-
)
|
|
208
|
+
) => {
|
|
189
209
|
const dismissedBanners = new Set(state.dismissedBannerVersions);
|
|
190
210
|
const activeAnnouncements = entries.filter((entry) => entry.kind === "announcement");
|
|
191
211
|
const latestAnnouncementVersion = activeAnnouncements.reduce((max, entry) => Math.max(max, entry.version), state.seenAnnouncementVersion);
|
|
192
212
|
return {
|
|
193
213
|
banners: entries
|
|
194
214
|
.filter((entry) => entry.kind === "banner" && !dismissedBanners.has(entry.version))
|
|
195
|
-
.sort((a, b) => b.version - a.version)
|
|
196
|
-
.map(renderAnnouncement),
|
|
215
|
+
.sort((a, b) => b.version - a.version),
|
|
197
216
|
announcements: activeAnnouncements
|
|
198
217
|
.filter((entry) => entry.version > state.seenAnnouncementVersion)
|
|
199
|
-
.sort((a, b) => b.version - a.version)
|
|
200
|
-
.map(renderAnnouncement),
|
|
218
|
+
.sort((a, b) => b.version - a.version),
|
|
201
219
|
latestAnnouncementVersion,
|
|
202
220
|
};
|
|
203
221
|
};
|
|
204
222
|
|
|
223
|
+
export const selectVisibleForState = (entries: AnnouncementEntry[], state: AnnouncementCookieState) => {
|
|
224
|
+
const selected = selectEntriesForState(entries, state);
|
|
225
|
+
return { ...selected, banners: selected.banners.map(renderAnnouncement), announcements: selected.announcements.map(renderAnnouncement) };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const cachedActive = async (): Promise<AnnouncementDisplayEntry[]> => {
|
|
229
|
+
let cached: string | null = null;
|
|
230
|
+
try {
|
|
231
|
+
cached = await (await requestCacheRedis()).get(ACTIVE_CACHE_KEY);
|
|
232
|
+
} catch {
|
|
233
|
+
/* Read authoritative data below. */
|
|
234
|
+
}
|
|
235
|
+
let entries: AnnouncementDisplayEntry[] | undefined;
|
|
236
|
+
if (cached !== null) {
|
|
237
|
+
try {
|
|
238
|
+
entries = z.array(AnnouncementDisplayEntrySchema).parse(JSON.parse(cached));
|
|
239
|
+
} catch {
|
|
240
|
+
/* Miss. */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!entries) {
|
|
244
|
+
const lease = await claimCacheFill(ACTIVE_CACHE_KEY, cached);
|
|
245
|
+
// Include scheduled entries: publish/expiry boundaries are applied at read time.
|
|
246
|
+
const rows = await sql<AnnouncementRow[]>`
|
|
247
|
+
SELECT id, version, kind, title, body, tone, published_at, expires_at,
|
|
248
|
+
created_at, updated_at, created_by, updated_by
|
|
249
|
+
FROM announcements.entries
|
|
250
|
+
WHERE expires_at IS NULL OR expires_at > now()
|
|
251
|
+
ORDER BY version DESC
|
|
252
|
+
`;
|
|
253
|
+
entries = rows.map((row) => renderAnnouncement(mapRow(row)));
|
|
254
|
+
await completeCacheFill(ACTIVE_CACHE_KEY, lease, JSON.stringify(entries), 300);
|
|
255
|
+
}
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
return entries.filter((entry) => Date.parse(entry.publishedAt) <= now && (!entry.expiresAt || Date.parse(entry.expiresAt) > now));
|
|
258
|
+
};
|
|
259
|
+
|
|
205
260
|
const activeForState = async (params: { state: AnnouncementCookieState; now?: Date }) =>
|
|
206
|
-
|
|
261
|
+
params.now
|
|
262
|
+
? selectVisibleForState(await listActive({ now: params.now }), params.state)
|
|
263
|
+
: selectEntriesForState(await cachedActive(), params.state);
|
|
207
264
|
|
|
208
265
|
export const announcements = {
|
|
209
266
|
admin: {
|
|
@@ -212,6 +269,10 @@ export const announcements = {
|
|
|
212
269
|
create,
|
|
213
270
|
update,
|
|
214
271
|
remove,
|
|
272
|
+
invalidateCache: async (actor: User | undefined) => {
|
|
273
|
+
if (!actor || !hasRole(actor, "admin")) throw new HTTPException(403, { message: "Administrator access required" });
|
|
274
|
+
await invalidateActiveCache();
|
|
275
|
+
},
|
|
215
276
|
},
|
|
216
277
|
active: {
|
|
217
278
|
list: listActive,
|