@valentinkolb/cloud 0.4.0 → 0.5.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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/src/server/api/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { api, respond } from "./respond";
|
|
1
|
+
export { api, respond, respondMessage } from "./respond";
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { isServiceError, ok, type Result, type ServiceError } from "@valentinkolb/stdlib";
|
|
2
|
+
import type { Context, TypedResponse } from "hono";
|
|
3
|
+
import type { StatusCode } from "hono/utils/http-status";
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
type LegacyErrorStatus = ServiceError["status"] | 413;
|
|
6
|
+
type LegacyErrorResult<S extends number = number> = { ok: false; error: string; status: S };
|
|
7
|
+
type LegacyResult<T = void> = { ok: true; data: T } | LegacyErrorResult;
|
|
5
8
|
|
|
6
9
|
type AnyResult<T = unknown> = Result<T> | LegacyResult<T>;
|
|
10
|
+
type ResultOrFn<T> = T | Promise<T> | (() => T | Promise<T>);
|
|
11
|
+
type SuccessStatus = 200 | 201;
|
|
12
|
+
type ErrorStatus = ServiceError["status"] | LegacyErrorStatus;
|
|
13
|
+
type JsonTypedResponse<T, Status extends StatusCode> = TypedResponse<T, Status, "json">;
|
|
7
14
|
|
|
8
15
|
type ErrorResponseBody = {
|
|
9
16
|
message: string;
|
|
@@ -35,21 +42,54 @@ const toErrorResponse = (result: AnyResult): [ErrorResponseBody, number] => {
|
|
|
35
42
|
return [{ message: "Internal server error", code: "INTERNAL" }, 500];
|
|
36
43
|
};
|
|
37
44
|
|
|
38
|
-
export
|
|
45
|
+
export async function respond<E extends ServiceError>(
|
|
39
46
|
c: Context,
|
|
40
|
-
resultOrFn:
|
|
41
|
-
successStatus
|
|
42
|
-
)
|
|
47
|
+
resultOrFn: ResultOrFn<Result<never, E>>,
|
|
48
|
+
successStatus?: SuccessStatus,
|
|
49
|
+
): Promise<JsonTypedResponse<ErrorResponseBody, E["status"]>>;
|
|
50
|
+
export async function respond<T>(
|
|
51
|
+
c: Context,
|
|
52
|
+
resultOrFn: ResultOrFn<Result<T, never>>,
|
|
53
|
+
successStatus?: SuccessStatus,
|
|
54
|
+
): Promise<JsonTypedResponse<T, SuccessStatus>>;
|
|
55
|
+
export async function respond<T, E extends ServiceError>(
|
|
56
|
+
c: Context,
|
|
57
|
+
resultOrFn: ResultOrFn<Result<T, E>>,
|
|
58
|
+
successStatus?: SuccessStatus,
|
|
59
|
+
): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, E["status"]>>;
|
|
60
|
+
export async function respond<S extends LegacyErrorStatus>(
|
|
61
|
+
c: Context,
|
|
62
|
+
resultOrFn: ResultOrFn<LegacyErrorResult<S>>,
|
|
63
|
+
successStatus?: SuccessStatus,
|
|
64
|
+
): Promise<JsonTypedResponse<ErrorResponseBody, S>>;
|
|
65
|
+
export async function respond<T>(
|
|
66
|
+
c: Context,
|
|
67
|
+
resultOrFn: ResultOrFn<AnyResult<T>>,
|
|
68
|
+
successStatus?: SuccessStatus,
|
|
69
|
+
): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, ErrorStatus>>;
|
|
70
|
+
export async function respond<T>(
|
|
71
|
+
c: Context,
|
|
72
|
+
resultOrFn: ResultOrFn<AnyResult<T>>,
|
|
73
|
+
successStatus: SuccessStatus = 200,
|
|
74
|
+
): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, ErrorStatus>> {
|
|
43
75
|
const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
|
|
44
76
|
|
|
45
77
|
if (!result.ok) {
|
|
46
78
|
const [body, status] = toErrorResponse(result);
|
|
47
|
-
return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500)
|
|
79
|
+
return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 413 | 500) as JsonTypedResponse<ErrorResponseBody, ErrorStatus>;
|
|
48
80
|
}
|
|
49
81
|
|
|
50
|
-
return c.json(result.data, successStatus as 200 | 201)
|
|
51
|
-
}
|
|
82
|
+
return c.json(result.data, successStatus as 200 | 201) as JsonTypedResponse<T, SuccessStatus>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const respondMessage = (c: Context, resultPromise: Promise<Result<void>>, message: string) =>
|
|
86
|
+
respond(c, async () => {
|
|
87
|
+
const result = await resultPromise;
|
|
88
|
+
if (!result.ok) return result;
|
|
89
|
+
return ok({ message });
|
|
90
|
+
});
|
|
52
91
|
|
|
53
92
|
export const api = {
|
|
54
93
|
respond,
|
|
94
|
+
respondMessage,
|
|
55
95
|
} as const;
|
package/src/server/index.ts
CHANGED
|
@@ -1,62 +1,68 @@
|
|
|
1
|
-
export { api, respond } from "./api";
|
|
2
|
-
export { api as apiClient } from "./api-client";
|
|
1
|
+
export { api, respond, respondMessage } from "./api";
|
|
3
2
|
export type { CreateApiClientConfig } from "./api-client";
|
|
4
|
-
|
|
3
|
+
export { api as apiClient } from "./api-client";
|
|
4
|
+
export type { AppContext } from "./app-context";
|
|
5
|
+
export type { AuthContext, RateLimitConfig, RateLimitRouteOverride } from "./middleware";
|
|
5
6
|
export {
|
|
6
|
-
middleware,
|
|
7
7
|
auth,
|
|
8
|
-
jsonResponse,
|
|
9
8
|
imageResponse,
|
|
9
|
+
jsonResponse,
|
|
10
|
+
middleware,
|
|
10
11
|
openApiMeta,
|
|
11
|
-
|
|
12
|
+
rateLimit,
|
|
13
|
+
requestLogger,
|
|
12
14
|
requiresAdmin,
|
|
15
|
+
requiresAuth,
|
|
13
16
|
requiresIpa,
|
|
14
17
|
requiresIpaUser,
|
|
15
18
|
requiresUser,
|
|
16
|
-
rateLimit,
|
|
17
|
-
requestLogger,
|
|
18
|
-
validator,
|
|
19
19
|
v,
|
|
20
|
+
validator,
|
|
20
21
|
} from "./middleware";
|
|
21
|
-
export type {
|
|
22
|
-
|
|
22
|
+
export type {
|
|
23
|
+
AccessEntry,
|
|
24
|
+
AccessSubject,
|
|
25
|
+
AccessUser,
|
|
26
|
+
AccessUserSource,
|
|
27
|
+
GeoPlace,
|
|
28
|
+
GeoService,
|
|
29
|
+
PageParams,
|
|
30
|
+
Paginated,
|
|
31
|
+
PermissionLevel,
|
|
32
|
+
Principal,
|
|
33
|
+
PrincipalType,
|
|
34
|
+
ResourceAccessAdapter,
|
|
35
|
+
Result,
|
|
36
|
+
ServiceError,
|
|
37
|
+
ServiceErrorCode,
|
|
38
|
+
} from "./services";
|
|
39
|
+
export type { RequestActor, ServiceAccountRequestActor, UserRequestActor } from "./middleware";
|
|
23
40
|
|
|
24
41
|
export {
|
|
25
|
-
|
|
42
|
+
createAccess,
|
|
43
|
+
deleteAccess,
|
|
44
|
+
err,
|
|
45
|
+
fail,
|
|
26
46
|
freeipa,
|
|
27
|
-
images,
|
|
28
|
-
password,
|
|
29
47
|
generatePassword,
|
|
30
48
|
geo,
|
|
31
49
|
geoService,
|
|
32
|
-
PERMISSION_LEVELS,
|
|
33
|
-
hasPermission,
|
|
34
|
-
createAccess,
|
|
35
50
|
getAccess,
|
|
36
|
-
updateAccess,
|
|
37
|
-
deleteAccess,
|
|
38
51
|
getEffectivePermission,
|
|
39
|
-
|
|
52
|
+
hasPermission,
|
|
53
|
+
images,
|
|
54
|
+
isServiceError,
|
|
55
|
+
listUsersWithAccess,
|
|
40
56
|
ok,
|
|
41
57
|
okMany,
|
|
42
|
-
|
|
43
|
-
err,
|
|
44
|
-
unwrap,
|
|
58
|
+
PERMISSION_LEVELS,
|
|
45
59
|
paginate,
|
|
60
|
+
paginateItems,
|
|
61
|
+
password,
|
|
62
|
+
resolveDisplayNames,
|
|
63
|
+
services,
|
|
46
64
|
tryCatch,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export type {
|
|
50
|
-
AccessEntry,
|
|
51
|
-
PermissionLevel,
|
|
52
|
-
PrincipalType,
|
|
53
|
-
Principal,
|
|
54
|
-
ResourceAccessAdapter,
|
|
55
|
-
GeoService,
|
|
56
|
-
GeoPlace,
|
|
57
|
-
Result,
|
|
58
|
-
Paginated,
|
|
59
|
-
PageParams,
|
|
60
|
-
ServiceError,
|
|
61
|
-
ServiceErrorCode,
|
|
65
|
+
unwrap,
|
|
66
|
+
updateAccess,
|
|
62
67
|
} from "./services";
|
|
68
|
+
export { getDateConfig, getTimeZone, TIMEZONE_COOKIE, time } from "./time";
|
|
@@ -2,17 +2,45 @@ import type { Context } from "hono";
|
|
|
2
2
|
import { createMiddleware } from "hono/factory";
|
|
3
3
|
import type { MessageResponse, Role, RoleOrSpecial, User, UserProfile, UserProvider } from "../../contracts/shared";
|
|
4
4
|
import { accounts } from "../../services/accounts";
|
|
5
|
+
import { oauthTokens } from "../../services/oauth-tokens";
|
|
5
6
|
import { session } from "../../services/session";
|
|
7
|
+
import { serviceAccountCredentials } from "../../services/service-account-credentials";
|
|
8
|
+
import { createLoginRedirectUrl } from "../../shared/redirect";
|
|
9
|
+
import type { ServiceAccount } from "../../services/service-accounts";
|
|
10
|
+
import type { AccessSubject } from "../services/access";
|
|
6
11
|
|
|
7
12
|
// ==========================
|
|
8
13
|
// Types
|
|
9
14
|
// ==========================
|
|
10
15
|
|
|
16
|
+
export type UserRequestActor = {
|
|
17
|
+
kind: "user";
|
|
18
|
+
user: User;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ServiceAccountRequestActor =
|
|
22
|
+
| {
|
|
23
|
+
kind: "service_account";
|
|
24
|
+
serviceAccount: ServiceAccount;
|
|
25
|
+
delegatedUser: User;
|
|
26
|
+
scopes: string[];
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
kind: "service_account";
|
|
30
|
+
serviceAccount: ServiceAccount;
|
|
31
|
+
delegatedUser: null;
|
|
32
|
+
scopes: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type RequestActor = UserRequestActor | ServiceAccountRequestActor;
|
|
36
|
+
|
|
11
37
|
/** Hono context with authenticated user variables. */
|
|
12
38
|
export type AuthContext = {
|
|
13
39
|
Variables: {
|
|
40
|
+
actor: RequestActor;
|
|
41
|
+
accessSubject: AccessSubject;
|
|
14
42
|
user: User;
|
|
15
|
-
sessionToken
|
|
43
|
+
sessionToken?: string;
|
|
16
44
|
};
|
|
17
45
|
};
|
|
18
46
|
|
|
@@ -45,17 +73,74 @@ const handleReject = (c: Context, options: RoleOptions, reason: "unauthenticated
|
|
|
45
73
|
return c.json({ message: "Insufficient permissions" } as MessageResponse, 403);
|
|
46
74
|
};
|
|
47
75
|
|
|
48
|
-
const
|
|
76
|
+
const loadAuthenticatedActor = async (c: Context<AuthContext>): Promise<{
|
|
77
|
+
token: string | null;
|
|
78
|
+
user: User | null;
|
|
79
|
+
actor: RequestActor | null;
|
|
80
|
+
}> => {
|
|
49
81
|
const token = session.getToken(c);
|
|
50
82
|
const data = token ? await session.getData(token) : null;
|
|
51
83
|
const user = data ? await accounts.users.get({ id: data.userId }) : null;
|
|
52
84
|
|
|
53
85
|
if (user && token) {
|
|
86
|
+
c.set("actor", { kind: "user", user });
|
|
87
|
+
c.set("accessSubject", { type: "user", userId: user.id });
|
|
54
88
|
c.set("user", user);
|
|
55
89
|
c.set("sessionToken", token);
|
|
56
90
|
}
|
|
57
91
|
|
|
58
|
-
return { token, user };
|
|
92
|
+
if (user) return { token, user, actor: { kind: "user", user } };
|
|
93
|
+
|
|
94
|
+
const bearer = session.getBearerToken(c);
|
|
95
|
+
if (bearer && serviceAccountCredentials.isApiToken(bearer)) {
|
|
96
|
+
const authResult = await serviceAccountCredentials.authenticateApiToken(bearer);
|
|
97
|
+
if (!authResult) return { token: null, user: null, actor: null };
|
|
98
|
+
|
|
99
|
+
const actor: RequestActor = {
|
|
100
|
+
kind: "service_account",
|
|
101
|
+
serviceAccount: authResult.serviceAccount,
|
|
102
|
+
delegatedUser: authResult.delegatedUser,
|
|
103
|
+
scopes: authResult.credential.scopes,
|
|
104
|
+
};
|
|
105
|
+
c.set("actor", actor);
|
|
106
|
+
if (authResult.delegatedUser) {
|
|
107
|
+
c.set("accessSubject", { type: "user", userId: authResult.delegatedUser.id });
|
|
108
|
+
c.set("user", authResult.delegatedUser);
|
|
109
|
+
} else {
|
|
110
|
+
c.set("accessSubject", { type: "service_account", serviceAccountId: authResult.serviceAccount.id });
|
|
111
|
+
}
|
|
112
|
+
return { token: null, user: authResult.delegatedUser, actor };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (bearer) {
|
|
116
|
+
const authResult = await oauthTokens.verifyAccessToken(bearer);
|
|
117
|
+
if (!authResult) return { token: null, user: null, actor: null };
|
|
118
|
+
|
|
119
|
+
if (authResult.kind === "user") {
|
|
120
|
+
const actor: RequestActor = { kind: "user", user: authResult.user };
|
|
121
|
+
c.set("actor", actor);
|
|
122
|
+
c.set("accessSubject", { type: "user", userId: authResult.user.id });
|
|
123
|
+
c.set("user", authResult.user);
|
|
124
|
+
return { token: null, user: authResult.user, actor };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const actor: RequestActor = {
|
|
128
|
+
kind: "service_account",
|
|
129
|
+
serviceAccount: authResult.serviceAccount,
|
|
130
|
+
delegatedUser: authResult.delegatedUser,
|
|
131
|
+
scopes: authResult.scopes,
|
|
132
|
+
};
|
|
133
|
+
c.set("actor", actor);
|
|
134
|
+
if (authResult.delegatedUser) {
|
|
135
|
+
c.set("accessSubject", { type: "user", userId: authResult.delegatedUser.id });
|
|
136
|
+
c.set("user", authResult.delegatedUser);
|
|
137
|
+
} else {
|
|
138
|
+
c.set("accessSubject", { type: "service_account", serviceAccountId: authResult.serviceAccount.id });
|
|
139
|
+
}
|
|
140
|
+
return { token: null, user: authResult.delegatedUser, actor };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { token: null, user: null, actor: null };
|
|
59
144
|
};
|
|
60
145
|
|
|
61
146
|
/**
|
|
@@ -92,22 +177,22 @@ const requireRole = (...args: (RoleOrSpecial | RoleOptions)[]) => {
|
|
|
92
177
|
return createMiddleware<AuthContext>(async (c, next) => {
|
|
93
178
|
// "*" = no check at all, pass through (but try to load user)
|
|
94
179
|
if (roles.includes("*")) {
|
|
95
|
-
await
|
|
180
|
+
await loadAuthenticatedActor(c);
|
|
96
181
|
return next();
|
|
97
182
|
}
|
|
98
183
|
|
|
99
|
-
const { user } = await
|
|
184
|
+
const { user, actor } = await loadAuthenticatedActor(c);
|
|
100
185
|
|
|
101
186
|
// "anonymous" = must NOT be logged in
|
|
102
187
|
if (roles.includes("anonymous")) {
|
|
103
|
-
if (
|
|
188
|
+
if (actor) {
|
|
104
189
|
return handleReject(c, options, "forbidden");
|
|
105
190
|
}
|
|
106
191
|
return next();
|
|
107
192
|
}
|
|
108
193
|
|
|
109
194
|
// All other roles require authentication
|
|
110
|
-
if (!
|
|
195
|
+
if (!actor) {
|
|
111
196
|
return handleReject(c, options, "unauthenticated");
|
|
112
197
|
}
|
|
113
198
|
|
|
@@ -116,6 +201,10 @@ const requireRole = (...args: (RoleOrSpecial | RoleOptions)[]) => {
|
|
|
116
201
|
return next();
|
|
117
202
|
}
|
|
118
203
|
|
|
204
|
+
if (!user) {
|
|
205
|
+
return handleReject(c, options, "forbidden");
|
|
206
|
+
}
|
|
207
|
+
|
|
119
208
|
// Check if user has at least one required role
|
|
120
209
|
const hasRequiredRole = roles.some((role) => user.roles.includes(role as Role));
|
|
121
210
|
if (!hasRequiredRole) {
|
|
@@ -133,12 +222,12 @@ const redirect = (url: string): RoleOptions => ({
|
|
|
133
222
|
|
|
134
223
|
/** Preset: Redirect to login page with returnTo parameter */
|
|
135
224
|
const redirectToLogin: RoleOptions = {
|
|
136
|
-
onReject: (c) =>
|
|
225
|
+
onReject: (c) => createLoginRedirectUrl(c.req.url),
|
|
137
226
|
};
|
|
138
227
|
|
|
139
228
|
const requireAccount = (options: AccountOptions) =>
|
|
140
229
|
createMiddleware<AuthContext>(async (c, next) => {
|
|
141
|
-
const { user } = await
|
|
230
|
+
const { user } = await loadAuthenticatedActor(c);
|
|
142
231
|
|
|
143
232
|
if (!user) {
|
|
144
233
|
return handleReject(c, options, "unauthenticated");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { middleware } from "./middleware";
|
|
2
2
|
|
|
3
|
-
export { auth, type AuthContext } from "./auth";
|
|
3
|
+
export { auth, type AuthContext, type RequestActor, type ServiceAccountRequestActor, type UserRequestActor } from "./auth";
|
|
4
|
+
export type { AccessSubject } from "../services/access";
|
|
4
5
|
export { jsonResponse, imageResponse, openApiMeta, requiresAuth, requiresAdmin, requiresIpa, requiresIpaUser, requiresUser } from "./openapi";
|
|
5
6
|
export { rateLimit, type RateLimitConfig, type RateLimitRouteOverride } from "./rate-limit";
|
|
6
7
|
export { requestLogger } from "./request-logger";
|
|
@@ -13,9 +13,25 @@
|
|
|
13
13
|
* `.use()` path instead.
|
|
14
14
|
*/
|
|
15
15
|
import { createMiddleware } from "hono/factory";
|
|
16
|
+
import {
|
|
17
|
+
type ActiveAnnouncementsResponse,
|
|
18
|
+
type AnnouncementCookieState,
|
|
19
|
+
parseAnnouncementCookieHeader,
|
|
20
|
+
} from "../../contracts/announcements";
|
|
21
|
+
import { announcements } from "../../services/announcements";
|
|
22
|
+
import { logger } from "../../services/logging";
|
|
16
23
|
import { loadSnapshot } from "../../services/settings/snapshot";
|
|
17
24
|
|
|
18
25
|
const DEFAULT_SKIP = ["/public/", "/_ssr/", "/branding/", "/favicon"] as const;
|
|
26
|
+
const ANNOUNCEMENT_SKIP = ["/api/", "/public/", "/_ssr/", "/branding/", "/favicon"] as const;
|
|
27
|
+
const log = logger("middleware:settings");
|
|
28
|
+
|
|
29
|
+
export type LayoutAnnouncementsState = ActiveAnnouncementsResponse & {
|
|
30
|
+
cookieState: AnnouncementCookieState;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const shouldLoadAnnouncements = (path: string, cookieHeader: string | null): boolean =>
|
|
34
|
+
Boolean(cookieHeader?.match(/(?:^|;\s*)session_token=/)) && !ANNOUNCEMENT_SKIP.some((prefix) => path.startsWith(prefix));
|
|
19
35
|
|
|
20
36
|
export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
|
|
21
37
|
const skip = opts?.skipPrefixes ?? DEFAULT_SKIP;
|
|
@@ -24,6 +40,16 @@ export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
|
|
|
24
40
|
if (!skip.some((p) => path.startsWith(p))) {
|
|
25
41
|
(c as unknown as { set: (k: string, v: unknown) => void }).set("settings", await loadSnapshot());
|
|
26
42
|
}
|
|
43
|
+
const cookieHeader = c.req.header("Cookie") ?? null;
|
|
44
|
+
if (shouldLoadAnnouncements(path, cookieHeader)) {
|
|
45
|
+
const cookieState = parseAnnouncementCookieHeader(cookieHeader);
|
|
46
|
+
try {
|
|
47
|
+
const active = await announcements.active.forState({ state: cookieState });
|
|
48
|
+
(c as unknown as { set: (k: string, v: unknown) => void }).set("announcements", { ...active, cookieState });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
log.warn("Failed to preload announcements", { error: error instanceof Error ? error.message : String(error) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
27
53
|
await next();
|
|
28
54
|
});
|
|
29
55
|
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import { createAccess, deleteAccess, getEffectivePermission, listUsersWithAccess } from "./access";
|
|
4
|
+
|
|
5
|
+
type Fixture = {
|
|
6
|
+
accessIds: string[];
|
|
7
|
+
userIds: {
|
|
8
|
+
direct: string;
|
|
9
|
+
group: string;
|
|
10
|
+
nested: string;
|
|
11
|
+
outside: string;
|
|
12
|
+
};
|
|
13
|
+
groupIds: {
|
|
14
|
+
parent: string;
|
|
15
|
+
child: string;
|
|
16
|
+
};
|
|
17
|
+
serviceAccountId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const canUseDatabase = async () => {
|
|
21
|
+
try {
|
|
22
|
+
const [row] = await sql<{ users: string | null; groups: string | null; access: string | null }[]>`
|
|
23
|
+
SELECT
|
|
24
|
+
to_regclass('auth.users')::text AS users,
|
|
25
|
+
to_regclass('auth.groups')::text AS groups,
|
|
26
|
+
to_regclass('auth.access')::text AS access
|
|
27
|
+
`;
|
|
28
|
+
return Boolean(row?.users && row.groups && row.access);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const insertUser = async (suffix: string, label: string) => {
|
|
35
|
+
const [row] = await sql<{ id: string }[]>`
|
|
36
|
+
INSERT INTO auth.users (uid, provider, profile, display_name, mail)
|
|
37
|
+
VALUES (${`access-helper-${label}-${suffix}`}, 'local', 'user', ${`Access ${label}`}, ${`${label}.${suffix}@example.test`})
|
|
38
|
+
RETURNING id
|
|
39
|
+
`;
|
|
40
|
+
return row!.id;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const insertGroup = async (suffix: string, label: string) => {
|
|
44
|
+
const [row] = await sql<{ id: string }[]>`
|
|
45
|
+
INSERT INTO auth.groups (cn, provider, name, description)
|
|
46
|
+
VALUES (${`access-helper-${label}-${suffix}`}, 'local', ${`Access ${label}`}, ${`Access ${label} test group`})
|
|
47
|
+
RETURNING id
|
|
48
|
+
`;
|
|
49
|
+
return row!.id;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const createFixture = async (): Promise<Fixture> => {
|
|
53
|
+
const suffix = crypto.randomUUID();
|
|
54
|
+
const directUserId = await insertUser(suffix, "direct");
|
|
55
|
+
const groupUserId = await insertUser(suffix, "group");
|
|
56
|
+
const nestedUserId = await insertUser(suffix, "nested");
|
|
57
|
+
const outsideUserId = await insertUser(suffix, "outside");
|
|
58
|
+
const parentGroupId = await insertGroup(suffix, "parent");
|
|
59
|
+
const childGroupId = await insertGroup(suffix, "child");
|
|
60
|
+
const [serviceAccount] = await sql<{ id: string }[]>`
|
|
61
|
+
INSERT INTO auth.service_accounts (name, kind, app_id, resource_type, resource_id)
|
|
62
|
+
VALUES (${`Access service ${suffix}`}, 'resource_bound', 'access-test', 'fixture', ${suffix})
|
|
63
|
+
RETURNING id
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${groupUserId}::uuid, ${parentGroupId}::uuid)`;
|
|
67
|
+
await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${nestedUserId}::uuid, ${childGroupId}::uuid)`;
|
|
68
|
+
await sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${parentGroupId}::uuid, ${childGroupId}::uuid)`;
|
|
69
|
+
|
|
70
|
+
const [directAccess] = await sql<{ id: string }[]>`
|
|
71
|
+
INSERT INTO auth.access (user_id, permission)
|
|
72
|
+
VALUES (${directUserId}::uuid, 'read')
|
|
73
|
+
RETURNING id
|
|
74
|
+
`;
|
|
75
|
+
const [groupAccess] = await sql<{ id: string }[]>`
|
|
76
|
+
INSERT INTO auth.access (group_id, permission)
|
|
77
|
+
VALUES (${parentGroupId}::uuid, 'write')
|
|
78
|
+
RETURNING id
|
|
79
|
+
`;
|
|
80
|
+
const [publicAccess] = await sql<{ id: string }[]>`
|
|
81
|
+
INSERT INTO auth.access (permission)
|
|
82
|
+
VALUES ('admin')
|
|
83
|
+
RETURNING id
|
|
84
|
+
`;
|
|
85
|
+
const [authenticatedAccess] = await sql<{ id: string }[]>`
|
|
86
|
+
INSERT INTO auth.access (authenticated_only, permission)
|
|
87
|
+
VALUES (TRUE, 'admin')
|
|
88
|
+
RETURNING id
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
accessIds: [directAccess!.id, groupAccess!.id, publicAccess!.id, authenticatedAccess!.id],
|
|
93
|
+
userIds: {
|
|
94
|
+
direct: directUserId,
|
|
95
|
+
group: groupUserId,
|
|
96
|
+
nested: nestedUserId,
|
|
97
|
+
outside: outsideUserId,
|
|
98
|
+
},
|
|
99
|
+
groupIds: {
|
|
100
|
+
parent: parentGroupId,
|
|
101
|
+
child: childGroupId,
|
|
102
|
+
},
|
|
103
|
+
serviceAccountId: serviceAccount!.id,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const cleanupFixture = async (fixture: Fixture) => {
|
|
108
|
+
for (const accessId of fixture.accessIds) {
|
|
109
|
+
await sql`DELETE FROM auth.access WHERE id = ${accessId}::uuid`;
|
|
110
|
+
}
|
|
111
|
+
for (const groupId of Object.values(fixture.groupIds)) {
|
|
112
|
+
await sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${groupId}::uuid OR child_group_id = ${groupId}::uuid`;
|
|
113
|
+
await sql`DELETE FROM auth.user_groups_v2 WHERE group_id = ${groupId}::uuid`;
|
|
114
|
+
}
|
|
115
|
+
for (const groupId of Object.values(fixture.groupIds)) {
|
|
116
|
+
await sql`DELETE FROM auth.groups WHERE id = ${groupId}::uuid`;
|
|
117
|
+
}
|
|
118
|
+
await sql`DELETE FROM auth.service_accounts WHERE id = ${fixture.serviceAccountId}::uuid`;
|
|
119
|
+
for (const userId of Object.values(fixture.userIds)) {
|
|
120
|
+
await sql`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("listUsersWithAccess", () => {
|
|
125
|
+
test("expands direct and recursive group access without exposing mail", async () => {
|
|
126
|
+
if (!(await canUseDatabase())) {
|
|
127
|
+
console.warn("Skipping access helper DB test: auth tables are not available.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fixture = await createFixture();
|
|
132
|
+
try {
|
|
133
|
+
const users = await listUsersWithAccess({ accessIds: fixture.accessIds, limit: 20 });
|
|
134
|
+
const byId = new Map(users.map((user) => [user.id, user]));
|
|
135
|
+
|
|
136
|
+
expect(byId.has(fixture.userIds.direct)).toBe(true);
|
|
137
|
+
expect(byId.has(fixture.userIds.group)).toBe(true);
|
|
138
|
+
expect(byId.has(fixture.userIds.nested)).toBe(true);
|
|
139
|
+
expect(byId.has(fixture.userIds.outside)).toBe(false);
|
|
140
|
+
|
|
141
|
+
expect(byId.get(fixture.userIds.direct)?.source).toEqual({ type: "direct" });
|
|
142
|
+
expect(byId.get(fixture.userIds.nested)?.source).toEqual({
|
|
143
|
+
type: "group",
|
|
144
|
+
groupId: fixture.groupIds.parent,
|
|
145
|
+
groupName: "Access parent",
|
|
146
|
+
});
|
|
147
|
+
expect(byId.get(fixture.userIds.nested)?.permission).toBe("write");
|
|
148
|
+
expect("mail" in byId.get(fixture.userIds.nested)!).toBe(false);
|
|
149
|
+
|
|
150
|
+
const groupSearch = await listUsersWithAccess({ accessIds: fixture.accessIds, search: "parent", limit: 20 });
|
|
151
|
+
expect(groupSearch.map((user) => user.id)).toContain(fixture.userIds.nested);
|
|
152
|
+
|
|
153
|
+
const explicitUsers = await listUsersWithAccess({
|
|
154
|
+
accessIds: fixture.accessIds,
|
|
155
|
+
userIds: [fixture.userIds.nested, fixture.userIds.outside],
|
|
156
|
+
});
|
|
157
|
+
expect(explicitUsers.map((user) => user.id)).toEqual([fixture.userIds.nested]);
|
|
158
|
+
|
|
159
|
+
const writers = await listUsersWithAccess({ accessIds: fixture.accessIds, minimumPermission: "write", limit: 20 });
|
|
160
|
+
expect(writers.map((user) => user.id)).not.toContain(fixture.userIds.direct);
|
|
161
|
+
expect(writers.map((user) => user.id)).toContain(fixture.userIds.group);
|
|
162
|
+
|
|
163
|
+
const serviceAccountPublicPermission = await getEffectivePermission({
|
|
164
|
+
accessIds: fixture.accessIds,
|
|
165
|
+
userId: null,
|
|
166
|
+
userGroups: [],
|
|
167
|
+
serviceAccountId: fixture.serviceAccountId,
|
|
168
|
+
});
|
|
169
|
+
expect(serviceAccountPublicPermission).toBe("none");
|
|
170
|
+
|
|
171
|
+
const serviceAccountAccess = await createAccess({
|
|
172
|
+
principal: { type: "service_account", serviceAccountId: fixture.serviceAccountId },
|
|
173
|
+
permission: "write",
|
|
174
|
+
});
|
|
175
|
+
expect(serviceAccountAccess.ok).toBe(true);
|
|
176
|
+
if (!serviceAccountAccess.ok) return;
|
|
177
|
+
fixture.accessIds.push(serviceAccountAccess.data.id);
|
|
178
|
+
|
|
179
|
+
const serviceAccountPermission = await getEffectivePermission({
|
|
180
|
+
accessIds: [serviceAccountAccess.data.id],
|
|
181
|
+
userId: null,
|
|
182
|
+
userGroups: [],
|
|
183
|
+
serviceAccountId: fixture.serviceAccountId,
|
|
184
|
+
});
|
|
185
|
+
expect(serviceAccountPermission).toBe("write");
|
|
186
|
+
|
|
187
|
+
const serviceAccountUsers = await listUsersWithAccess({ accessIds: [serviceAccountAccess.data.id], limit: 20 });
|
|
188
|
+
expect(serviceAccountUsers).toEqual([]);
|
|
189
|
+
|
|
190
|
+
const deleteResult = await deleteAccess({ id: serviceAccountAccess.data.id });
|
|
191
|
+
expect(deleteResult.ok).toBe(true);
|
|
192
|
+
fixture.accessIds = fixture.accessIds.filter((id) => id !== serviceAccountAccess.data.id);
|
|
193
|
+
} finally {
|
|
194
|
+
await cleanupFixture(fixture);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|