@valentinkolb/cloud 0.1.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 +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { MiddlewareHandler } from "hono";
|
|
3
|
+
import { createMiddleware } from "hono/factory";
|
|
4
|
+
import { ratelimit } from "@valentinkolb/sync";
|
|
5
|
+
import { auth, type AuthContext } from "./auth";
|
|
6
|
+
import * as settings from "../../services/settings";
|
|
7
|
+
import type { MessageResponse } from "../../contracts/shared";
|
|
8
|
+
|
|
9
|
+
export type RateLimitRouteOverride = {
|
|
10
|
+
method?: string;
|
|
11
|
+
path: string | RegExp;
|
|
12
|
+
limitPerSecond?: number;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RateLimitConfig = {
|
|
17
|
+
limitPerSecond?: number;
|
|
18
|
+
windowSecs?: number;
|
|
19
|
+
keyBy?: "auto" | "ip" | "user";
|
|
20
|
+
routes?: RateLimitRouteOverride[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ResolvedRateLimit = {
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
limitPerSecond: number;
|
|
26
|
+
windowSecs: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_WINDOW_SECS = 1;
|
|
30
|
+
const LIMITER_PREFIX = "cloud:rate-limit";
|
|
31
|
+
const limiterCache = new Map<string, ReturnType<typeof ratelimit>>();
|
|
32
|
+
|
|
33
|
+
const getClientIp = (c: Context): string =>
|
|
34
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
35
|
+
|
|
36
|
+
const pathMatches = (requestPath: string, matcher: string | RegExp): boolean => {
|
|
37
|
+
if (matcher instanceof RegExp) return matcher.test(requestPath);
|
|
38
|
+
return requestPath === matcher || requestPath.startsWith(`${matcher}/`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const resolveRouteOverride = (c: Context, overrides: RateLimitRouteOverride[] | undefined): RateLimitRouteOverride | undefined => {
|
|
42
|
+
if (!overrides || overrides.length === 0) return undefined;
|
|
43
|
+
return overrides.find((override) => {
|
|
44
|
+
if (override.method && override.method.toUpperCase() !== c.req.method.toUpperCase()) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return pathMatches(c.req.path, override.path);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveConfig = async (c: Context, config: RateLimitConfig): Promise<ResolvedRateLimit> => {
|
|
52
|
+
const override = resolveRouteOverride(c, config.routes);
|
|
53
|
+
if (override?.disabled) {
|
|
54
|
+
return {
|
|
55
|
+
disabled: true,
|
|
56
|
+
limitPerSecond: 0,
|
|
57
|
+
windowSecs: config.windowSecs ?? DEFAULT_WINDOW_SECS,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const configuredLimit = override?.limitPerSecond ?? config.limitPerSecond ?? null;
|
|
62
|
+
const limitPerSecond = configuredLimit ?? (await settings.get<number>("security.rate_limit_per_second"));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
disabled: false,
|
|
66
|
+
limitPerSecond: Math.max(1, Math.floor(limitPerSecond)),
|
|
67
|
+
windowSecs: Math.max(1, Math.floor(config.windowSecs ?? DEFAULT_WINDOW_SECS)),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const getLimiter = (limitPerSecond: number, windowSecs: number) => {
|
|
72
|
+
const cacheKey = `${limitPerSecond}:${windowSecs}`;
|
|
73
|
+
const cached = limiterCache.get(cacheKey);
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
|
|
76
|
+
const limiter = ratelimit({
|
|
77
|
+
id: cacheKey,
|
|
78
|
+
limit: limitPerSecond,
|
|
79
|
+
windowSecs,
|
|
80
|
+
prefix: LIMITER_PREFIX,
|
|
81
|
+
});
|
|
82
|
+
limiterCache.set(cacheKey, limiter);
|
|
83
|
+
return limiter;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
|
|
87
|
+
if (keyBy === "ip") return `ip:${getClientIp(c)}`;
|
|
88
|
+
|
|
89
|
+
const token = auth.session.getToken(c);
|
|
90
|
+
if (!token) return `ip:${getClientIp(c)}`;
|
|
91
|
+
|
|
92
|
+
const data = await auth.session.getData(token);
|
|
93
|
+
if (!data) return `ip:${getClientIp(c)}`;
|
|
94
|
+
|
|
95
|
+
return `user:${data.userId}`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Stateless per-route rate limiting middleware backed by @valentinkolb/sync.
|
|
100
|
+
* Keying defaults to user ID (when session exists), otherwise client IP.
|
|
101
|
+
*/
|
|
102
|
+
export const rateLimit = (config: RateLimitConfig = {}): MiddlewareHandler<AuthContext> =>
|
|
103
|
+
createMiddleware<AuthContext>(async (c, next) => {
|
|
104
|
+
const resolved = await resolveConfig(c, config);
|
|
105
|
+
if (resolved.disabled) {
|
|
106
|
+
await next();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const keyBy = config.keyBy ?? "auto";
|
|
111
|
+
const identifier = await resolveIdentifier(c, keyBy);
|
|
112
|
+
const limiter = getLimiter(resolved.limitPerSecond, resolved.windowSecs);
|
|
113
|
+
const result = await limiter.check(identifier);
|
|
114
|
+
const resetInSeconds = Math.max(1, Math.ceil(result.resetIn / 1000));
|
|
115
|
+
|
|
116
|
+
c.header("X-RateLimit-Limit", String(resolved.limitPerSecond));
|
|
117
|
+
c.header("X-RateLimit-Remaining", String(result.remaining));
|
|
118
|
+
c.header("X-RateLimit-Reset", String(resetInSeconds));
|
|
119
|
+
|
|
120
|
+
if (result.limited) {
|
|
121
|
+
c.header("Retry-After", String(resetInSeconds));
|
|
122
|
+
return c.json({ message: "Rate limit exceeded" } as MessageResponse, 429);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await next();
|
|
126
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { logger } from "../../services/logging";
|
|
3
|
+
import type { AuthContext } from "./auth";
|
|
4
|
+
|
|
5
|
+
const log = logger("http");
|
|
6
|
+
|
|
7
|
+
const SKIP_PREFIXES = ["/public/", "/_ssr/", "/favicon", "/branding/"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HTTP request logging middleware.
|
|
11
|
+
* Logs to DB based on response status:
|
|
12
|
+
* - 5xx → error (server errors)
|
|
13
|
+
* - 429 → warn (rate limiting)
|
|
14
|
+
* - 401/403 → info (auth flows)
|
|
15
|
+
* - Everything else (2xx, 3xx, 400, 404) → not logged (too noisy)
|
|
16
|
+
*/
|
|
17
|
+
export const requestLogger = createMiddleware<AuthContext>(async (c, next) => {
|
|
18
|
+
const path = c.req.path;
|
|
19
|
+
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) return next();
|
|
20
|
+
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
await next();
|
|
23
|
+
const status = c.res.status;
|
|
24
|
+
const duration = Date.now() - start;
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
method: c.req.method,
|
|
28
|
+
path,
|
|
29
|
+
status,
|
|
30
|
+
duration,
|
|
31
|
+
userId: c.get("user")?.id ?? null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (status >= 500) {
|
|
35
|
+
log.error(`${c.req.method} ${path} ${status}`, meta);
|
|
36
|
+
} else if (status === 429) {
|
|
37
|
+
log.warn(`${c.req.method} ${path} 429 rate-limited`, meta);
|
|
38
|
+
} else if (status === 401 || status === 403) {
|
|
39
|
+
log.info(`${c.req.method} ${path} ${status}`, meta);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { validator as honoValidator } from "hono-openapi";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
import type { ValidationTargets, Context } from "hono";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zod validator middleware with pretty error messages and OpenAPI support.
|
|
7
|
+
* Uses hono-openapi validator for automatic OpenAPI schema generation.
|
|
8
|
+
*
|
|
9
|
+
* @param target - Where to validate: "json", "query", "param", "header", "cookie", or "form"
|
|
10
|
+
* @param schema - Zod schema to validate against
|
|
11
|
+
* @returns Hono middleware that validates request data and returns 400 on failure
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* app.post("/users", v("json", CreateUserSchema), async (c) => {
|
|
16
|
+
* const data = c.req.valid("json"); // Fully typed!
|
|
17
|
+
* // ...
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export const validator = <Target extends keyof ValidationTargets, T extends ZodType>(target: Target, schema: T) =>
|
|
22
|
+
honoValidator(target, schema, (result, c: Context) => {
|
|
23
|
+
if (!result.success) {
|
|
24
|
+
// Standard Schema returns issues array on failure
|
|
25
|
+
const errorMessage = result.error
|
|
26
|
+
?.map((issue) => {
|
|
27
|
+
const path = issue.path?.map((p) => (typeof p === "object" && "key" in p ? String(p.key) : String(p))).join(".");
|
|
28
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
29
|
+
})
|
|
30
|
+
.join(", ");
|
|
31
|
+
return c.json({ message: errorMessage || "Validation failed" }, 400);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const v = validator;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
|
|
3
|
+
|
|
4
|
+
// ==========================
|
|
5
|
+
// Permission Levels
|
|
6
|
+
// ==========================
|
|
7
|
+
|
|
8
|
+
export const PERMISSION_LEVELS = ["none", "read", "write", "admin"] as const;
|
|
9
|
+
export type PermissionLevel = (typeof PERMISSION_LEVELS)[number];
|
|
10
|
+
|
|
11
|
+
/** Compare permission levels (returns true if a >= b) */
|
|
12
|
+
export const hasPermission = (userLevel: PermissionLevel, requiredLevel: PermissionLevel): boolean => {
|
|
13
|
+
const levels = PERMISSION_LEVELS;
|
|
14
|
+
return levels.indexOf(userLevel) >= levels.indexOf(requiredLevel);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ==========================
|
|
18
|
+
// Principal Types
|
|
19
|
+
// ==========================
|
|
20
|
+
|
|
21
|
+
export type PrincipalType = "user" | "group" | "authenticated" | "public";
|
|
22
|
+
|
|
23
|
+
export type Principal =
|
|
24
|
+
| { type: "user"; userId: string }
|
|
25
|
+
| { type: "group"; groupId: string }
|
|
26
|
+
| { type: "authenticated" }
|
|
27
|
+
| { type: "public" };
|
|
28
|
+
|
|
29
|
+
// ==========================
|
|
30
|
+
// Access Entry Types
|
|
31
|
+
// ==========================
|
|
32
|
+
|
|
33
|
+
export type AccessEntry = {
|
|
34
|
+
id: string;
|
|
35
|
+
principal: Principal;
|
|
36
|
+
permission: PermissionLevel;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
// Resolved display info (populated by service)
|
|
39
|
+
displayName?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type DbAccess = {
|
|
43
|
+
id: string;
|
|
44
|
+
user_id: string | null;
|
|
45
|
+
group_id: string | null;
|
|
46
|
+
authenticated_only: boolean;
|
|
47
|
+
permission: PermissionLevel;
|
|
48
|
+
created_at: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ==========================
|
|
52
|
+
// Helper Functions
|
|
53
|
+
// ==========================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Converts UUID strings into a PostgreSQL uuid[] literal for relation queries.
|
|
57
|
+
*/
|
|
58
|
+
const toPgUuidArray = (values: string[] | null | undefined): string => {
|
|
59
|
+
if (!Array.isArray(values) || values.length === 0) return "{}";
|
|
60
|
+
return `{${values.join(",")}}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds a typed access principal from one database access row.
|
|
65
|
+
*/
|
|
66
|
+
const principalFromDb = (row: DbAccess): Principal => {
|
|
67
|
+
if (row.user_id) return { type: "user", userId: row.user_id };
|
|
68
|
+
if (row.group_id) return { type: "group", groupId: row.group_id };
|
|
69
|
+
if (row.authenticated_only) return { type: "authenticated" };
|
|
70
|
+
return { type: "public" };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Maps raw access rows into the normalized AccessEntry shape used by app services.
|
|
75
|
+
*/
|
|
76
|
+
const mapToAccessEntry = (row: DbAccess): AccessEntry => ({
|
|
77
|
+
id: row.id,
|
|
78
|
+
principal: principalFromDb(row),
|
|
79
|
+
permission: row.permission,
|
|
80
|
+
createdAt: row.created_at.toISOString(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ==========================
|
|
84
|
+
// Core Access Service
|
|
85
|
+
// ==========================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a new access entry.
|
|
89
|
+
* Returns the created entry ID.
|
|
90
|
+
*/
|
|
91
|
+
export const createAccess = async (params: { principal: Principal; permission: PermissionLevel }): Promise<Result<{ id: string }>> => {
|
|
92
|
+
const { principal, permission } = params;
|
|
93
|
+
|
|
94
|
+
let userId: string | null = null;
|
|
95
|
+
let groupId: string | null = null;
|
|
96
|
+
let authenticatedOnly = false;
|
|
97
|
+
|
|
98
|
+
if (principal.type === "user") {
|
|
99
|
+
userId = principal.userId;
|
|
100
|
+
// Verify user exists
|
|
101
|
+
const [user] = await sql<{ id: string }[]>`
|
|
102
|
+
SELECT id FROM auth.users WHERE id = ${userId}::uuid
|
|
103
|
+
`;
|
|
104
|
+
if (!user) {
|
|
105
|
+
return fail(err.notFound("User"));
|
|
106
|
+
}
|
|
107
|
+
} else if (principal.type === "group") {
|
|
108
|
+
groupId = principal.groupId;
|
|
109
|
+
// Verify group exists
|
|
110
|
+
const [group] = await sql<{ id: string }[]>`
|
|
111
|
+
SELECT id FROM auth.groups WHERE id = ${groupId}::uuid
|
|
112
|
+
`;
|
|
113
|
+
if (!group) {
|
|
114
|
+
return fail(err.notFound("Group"));
|
|
115
|
+
}
|
|
116
|
+
} else if (principal.type === "authenticated") {
|
|
117
|
+
authenticatedOnly = true;
|
|
118
|
+
}
|
|
119
|
+
// public: user/group null, authenticated_only false
|
|
120
|
+
|
|
121
|
+
const [row] = await sql<{ id: string }[]>`
|
|
122
|
+
INSERT INTO auth.access (user_id, group_id, authenticated_only, permission)
|
|
123
|
+
VALUES (${userId}::uuid, ${groupId}::uuid, ${authenticatedOnly}, ${permission}::auth.permission_level)
|
|
124
|
+
RETURNING id
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
if (!row) {
|
|
128
|
+
return fail(err.internal("Failed to create access entry"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return ok({ id: row.id });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get an access entry by ID.
|
|
136
|
+
*/
|
|
137
|
+
export const getAccess = async (params: { id: string }): Promise<AccessEntry | null> => {
|
|
138
|
+
const [row] = await sql<DbAccess[]>`
|
|
139
|
+
SELECT id, user_id, group_id, authenticated_only, permission, created_at
|
|
140
|
+
FROM auth.access
|
|
141
|
+
WHERE id = ${params.id}::uuid
|
|
142
|
+
`;
|
|
143
|
+
return row ? mapToAccessEntry(row) : null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update an access entry's permission level.
|
|
148
|
+
*/
|
|
149
|
+
export const updateAccess = async (params: { id: string; permission: PermissionLevel }): Promise<Result<void>> => {
|
|
150
|
+
const result = await sql`
|
|
151
|
+
UPDATE auth.access
|
|
152
|
+
SET permission = ${params.permission}::auth.permission_level
|
|
153
|
+
WHERE id = ${params.id}::uuid
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
if (result.count === 0) {
|
|
157
|
+
return fail(err.notFound("Access entry"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return ok();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Delete an access entry.
|
|
165
|
+
*/
|
|
166
|
+
export const deleteAccess = async (params: { id: string }): Promise<Result<void>> => {
|
|
167
|
+
const result = await sql`
|
|
168
|
+
DELETE FROM auth.access
|
|
169
|
+
WHERE id = ${params.id}::uuid
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
if (result.count === 0) {
|
|
173
|
+
return fail(err.notFound("Access entry"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return ok();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ==========================
|
|
180
|
+
// Resource Access Helpers
|
|
181
|
+
// ==========================
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generic interface for resource access junction tables.
|
|
185
|
+
* Each app implements this to connect their resources to auth.access.
|
|
186
|
+
*/
|
|
187
|
+
export type ResourceAccessAdapter<TResourceId = string> = {
|
|
188
|
+
/** Get all access entries for a resource */
|
|
189
|
+
list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
|
|
190
|
+
/** Add an access entry to a resource */
|
|
191
|
+
add: (resourceId: TResourceId, accessId: string) => Promise<Result<void>>;
|
|
192
|
+
/** Remove an access entry from a resource */
|
|
193
|
+
remove: (resourceId: TResourceId, accessId: string) => Promise<Result<void>>;
|
|
194
|
+
/** Count access entries for a resource */
|
|
195
|
+
count: (resourceId: TResourceId) => Promise<number>;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the effective permission level for a user on a resource.
|
|
200
|
+
* Returns the highest permission from:
|
|
201
|
+
* - Direct user access
|
|
202
|
+
* - Group memberships
|
|
203
|
+
* - Public access
|
|
204
|
+
*/
|
|
205
|
+
export const getEffectivePermission = async (params: {
|
|
206
|
+
accessIds: string[];
|
|
207
|
+
userId: string | null;
|
|
208
|
+
userGroups: string[];
|
|
209
|
+
}): Promise<PermissionLevel> => {
|
|
210
|
+
const accessIds = params.accessIds ?? [];
|
|
211
|
+
const userId = params.userId;
|
|
212
|
+
const userGroups = params.userGroups ?? [];
|
|
213
|
+
|
|
214
|
+
if (accessIds.length === 0) return "none";
|
|
215
|
+
|
|
216
|
+
// Query all matching access entries
|
|
217
|
+
const rows = await sql<{ permission: PermissionLevel }[]>`
|
|
218
|
+
SELECT permission
|
|
219
|
+
FROM auth.access
|
|
220
|
+
WHERE id = ANY(${toPgUuidArray(accessIds)}::uuid[])
|
|
221
|
+
AND (
|
|
222
|
+
user_id = ${userId}::uuid
|
|
223
|
+
OR group_id = ANY(${toPgUuidArray(userGroups)}::uuid[])
|
|
224
|
+
OR (${userId}::uuid IS NOT NULL AND authenticated_only = true)
|
|
225
|
+
OR (user_id IS NULL AND group_id IS NULL AND authenticated_only = false)
|
|
226
|
+
)
|
|
227
|
+
ORDER BY
|
|
228
|
+
CASE permission
|
|
229
|
+
WHEN 'admin' THEN 4
|
|
230
|
+
WHEN 'write' THEN 3
|
|
231
|
+
WHEN 'read' THEN 2
|
|
232
|
+
WHEN 'none' THEN 1
|
|
233
|
+
END DESC
|
|
234
|
+
LIMIT 1
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
return rows[0]?.permission ?? "none";
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolve display names for access entries.
|
|
242
|
+
* Populates the displayName field based on principal type.
|
|
243
|
+
*/
|
|
244
|
+
export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<AccessEntry[]> => {
|
|
245
|
+
const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
|
|
246
|
+
|
|
247
|
+
const groupIds = entries
|
|
248
|
+
.filter((e) => e.principal.type === "group")
|
|
249
|
+
.map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
|
|
250
|
+
|
|
251
|
+
// Fetch user display names
|
|
252
|
+
const userNames = new Map<string, string>();
|
|
253
|
+
if (userIds.length > 0) {
|
|
254
|
+
const users = await sql<{ id: string; display_name: string; uid: string }[]>`
|
|
255
|
+
SELECT id, display_name, uid
|
|
256
|
+
FROM auth.users
|
|
257
|
+
WHERE id = ANY(${toPgUuidArray(userIds)}::uuid[])
|
|
258
|
+
`;
|
|
259
|
+
for (const u of users) {
|
|
260
|
+
userNames.set(u.id, u.display_name || u.uid);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const groupNames = new Map<string, string>();
|
|
265
|
+
if (groupIds.length > 0) {
|
|
266
|
+
const groups = await sql<{ id: string; name: string }[]>`
|
|
267
|
+
SELECT id, name
|
|
268
|
+
FROM auth.groups
|
|
269
|
+
WHERE id = ANY(${toPgUuidArray(groupIds)}::uuid[])
|
|
270
|
+
`;
|
|
271
|
+
for (const group of groups) {
|
|
272
|
+
groupNames.set(group.id, group.name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return entries.map((entry) => {
|
|
277
|
+
let displayName: string;
|
|
278
|
+
switch (entry.principal.type) {
|
|
279
|
+
case "user":
|
|
280
|
+
displayName = userNames.get(entry.principal.userId) ?? "Unknown User";
|
|
281
|
+
break;
|
|
282
|
+
case "group":
|
|
283
|
+
displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
|
|
284
|
+
break;
|
|
285
|
+
case "authenticated":
|
|
286
|
+
displayName = "All users (incl. guests)";
|
|
287
|
+
break;
|
|
288
|
+
case "public":
|
|
289
|
+
displayName = "Public";
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
return { ...entry, displayName };
|
|
293
|
+
});
|
|
294
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getFreeIpaTls } from "./tls";
|
|
2
|
+
|
|
3
|
+
export type IpaRpcResult = {
|
|
4
|
+
result: unknown;
|
|
5
|
+
count: number;
|
|
6
|
+
truncated: boolean;
|
|
7
|
+
summary: string | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type IpaRpcResponse = {
|
|
11
|
+
result: IpaRpcResult | null;
|
|
12
|
+
error: { code: number; message: string; name: string } | null;
|
|
13
|
+
id: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const baseUrl = (url: string): string => `https://${url}`;
|
|
17
|
+
|
|
18
|
+
const isNoModificationError = (error: IpaRpcResponse["error"]): boolean =>
|
|
19
|
+
error?.code === 4202 && (error.message ?? "").toLowerCase().includes("no modifications to be performed");
|
|
20
|
+
|
|
21
|
+
export const call = async (
|
|
22
|
+
config: {
|
|
23
|
+
url: string;
|
|
24
|
+
ipaSession: string;
|
|
25
|
+
method: string;
|
|
26
|
+
args?: unknown[];
|
|
27
|
+
options?: Record<string, unknown>;
|
|
28
|
+
},
|
|
29
|
+
): Promise<IpaRpcResponse> => {
|
|
30
|
+
const tls = await getFreeIpaTls();
|
|
31
|
+
const res = await fetch(`${baseUrl(config.url)}/ipa/session/json`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Referer: `${baseUrl(config.url)}/ipa`,
|
|
36
|
+
Accept: "application/json",
|
|
37
|
+
Cookie: `ipa_session=${config.ipaSession}`,
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
method: config.method,
|
|
41
|
+
params: [config.args ?? [], { ...(config.options ?? {}), version: "2.251" }],
|
|
42
|
+
id: 0,
|
|
43
|
+
}),
|
|
44
|
+
...(tls ? { tls } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!res.ok || !res.headers.get("content-type")?.includes("json")) {
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
console.error("[freeipa:client] Non-JSON response", {
|
|
50
|
+
method: config.method,
|
|
51
|
+
status: res.status,
|
|
52
|
+
body: text.slice(0, 200),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (res.status === 401 || text.includes("Invalid Authentication") || text.includes("GSSAPI Error")) {
|
|
56
|
+
return {
|
|
57
|
+
result: null,
|
|
58
|
+
error: {
|
|
59
|
+
code: 403,
|
|
60
|
+
message: "Your IPA session has expired or is invalid. Please log out and log in again to refresh your session.",
|
|
61
|
+
name: "SessionExpired",
|
|
62
|
+
},
|
|
63
|
+
id: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
result: null,
|
|
69
|
+
error: {
|
|
70
|
+
code: res.status,
|
|
71
|
+
message: "Non-JSON response from IPA",
|
|
72
|
+
name: "FetchError",
|
|
73
|
+
},
|
|
74
|
+
id: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const response = (await res.json()) as IpaRpcResponse;
|
|
79
|
+
if (response.error) {
|
|
80
|
+
if (isNoModificationError(response.error)) {
|
|
81
|
+
return {
|
|
82
|
+
result: {
|
|
83
|
+
result: null,
|
|
84
|
+
count: 0,
|
|
85
|
+
truncated: false,
|
|
86
|
+
summary: response.error.message,
|
|
87
|
+
},
|
|
88
|
+
error: null,
|
|
89
|
+
id: response.id,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.error("[freeipa:client] RPC failed", {
|
|
94
|
+
method: config.method,
|
|
95
|
+
code: response.error.code,
|
|
96
|
+
message: response.error.message,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return response;
|
|
100
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { baseUrl, call } from "./client";
|
|
2
|
+
import { getFreeIpaTls, getFreeIpaTlsFingerprint } from "./tls";
|
|
3
|
+
|
|
4
|
+
export type LoginResult = { status: "success"; session: string } | { status: "password_expired" } | { status: "failed" };
|
|
5
|
+
|
|
6
|
+
export const login = async (config: { url: string; username: string; password: string }): Promise<LoginResult> => {
|
|
7
|
+
const tls = await getFreeIpaTls();
|
|
8
|
+
const res = await fetch(`${baseUrl(config.url)}/ipa/session/login_password`, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: {
|
|
11
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
12
|
+
Referer: `${baseUrl(config.url)}/ipa`,
|
|
13
|
+
Accept: "text/plain",
|
|
14
|
+
},
|
|
15
|
+
body: new URLSearchParams({ user: config.username, password: config.password }),
|
|
16
|
+
redirect: "manual",
|
|
17
|
+
...(tls ? { tls } : {}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const rejectionReason = res.headers.get("X-IPA-Rejection-Reason");
|
|
21
|
+
if (rejectionReason === "password-expired") {
|
|
22
|
+
return { status: "password_expired" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!res.ok && res.status !== 303) return { status: "failed" };
|
|
26
|
+
|
|
27
|
+
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
28
|
+
for (const cookie of cookies) {
|
|
29
|
+
const match = cookie.match(/ipa_session=([^;]+)/);
|
|
30
|
+
if (match?.[1]) return { status: "success", session: match[1] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const single = res.headers.get("set-cookie") ?? "";
|
|
34
|
+
const match = single.match(/ipa_session=([^;]+)/);
|
|
35
|
+
return match?.[1] ? { status: "success", session: match[1] } : { status: "failed" };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let svcSession: string | null = null;
|
|
39
|
+
let svcSessionPromise: Promise<string> | null = null;
|
|
40
|
+
let svcSessionKey: string | null = null;
|
|
41
|
+
|
|
42
|
+
export const getServiceSession = async (config: {
|
|
43
|
+
url: string;
|
|
44
|
+
serviceUser: string;
|
|
45
|
+
servicePassword: string;
|
|
46
|
+
}): Promise<string> => {
|
|
47
|
+
// Include TLS fingerprint so toggling allow_insecure or rotating ca_cert
|
|
48
|
+
// forces a re-login (otherwise the cached session keeps using the old TLS
|
|
49
|
+
// trust anchor for fetches that wouldn't have succeeded under it).
|
|
50
|
+
const currentKey = `${config.url}::${config.serviceUser}::${await getFreeIpaTlsFingerprint()}`;
|
|
51
|
+
if (svcSessionKey !== currentKey) {
|
|
52
|
+
svcSession = null;
|
|
53
|
+
svcSessionKey = currentKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (svcSession) {
|
|
57
|
+
const check = await call({ url: config.url, ipaSession: svcSession, method: "ping", args: [] });
|
|
58
|
+
if (!check.error) return svcSession;
|
|
59
|
+
}
|
|
60
|
+
if (!svcSessionPromise) {
|
|
61
|
+
svcSessionPromise = (async () => {
|
|
62
|
+
const result = await login({
|
|
63
|
+
url: config.url,
|
|
64
|
+
username: config.serviceUser,
|
|
65
|
+
password: config.servicePassword,
|
|
66
|
+
});
|
|
67
|
+
if (result.status !== "success") {
|
|
68
|
+
console.error("[freeipa:session] Service account auth failed");
|
|
69
|
+
throw new Error("Failed to authenticate FreeIPA service account. Check freeipa.url/freeipa.service_user/freeipa.service_password.");
|
|
70
|
+
}
|
|
71
|
+
svcSession = result.session;
|
|
72
|
+
return result.session;
|
|
73
|
+
})().finally(() => {
|
|
74
|
+
svcSessionPromise = null;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return svcSessionPromise;
|
|
78
|
+
};
|