@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.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 +14 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +134 -0
- package/src/admin-shell/i18n.ts +35 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +32 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +68 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
- package/src/audit/__tests__/audit.integration.test.ts +0 -4
- package/src/audit/constants.ts +5 -2
- package/src/audit/feature.ts +24 -1
- package/src/audit/handlers/list.query.ts +2 -2
- package/src/audit/i18n.ts +18 -0
- package/src/audit/web/audit-log-screen.tsx +136 -0
- package/src/audit/web/client-plugin.tsx +20 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +4 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/feature.ts +20 -1
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +19 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +19 -2
- package/src/delivery/handlers/log.query.ts +14 -6
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +14 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/index.ts +6 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +26 -0
- package/src/jobs/i18n.ts +32 -0
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +169 -0
- package/src/jobs/web/job-runs-screen.tsx +148 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +16 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +2 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +265 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Hard-coded query allowlists for overview-home screens — security boundary
|
|
3
|
+
// against privilege escalation via accidental cross-workspace fetches.
|
|
4
|
+
|
|
5
|
+
export type OverviewWorkspaceKind = "tenant" | "platform";
|
|
6
|
+
|
|
7
|
+
/** Tenant workspace overview may only call these queries. */
|
|
8
|
+
export const TENANT_OVERVIEW_ALLOWED_QUERIES = [
|
|
9
|
+
"tenant:query:invitations",
|
|
10
|
+
"tenant:query:members",
|
|
11
|
+
"config:query:readiness",
|
|
12
|
+
"cap-counter:query:get-counter",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/** Platform workspace overview may only call these queries. */
|
|
16
|
+
export const PLATFORM_OVERVIEW_ALLOWED_QUERIES = ["tenant:query:list", "jobs:query:list"] as const;
|
|
17
|
+
|
|
18
|
+
/** Regression guard — TenantAdmin overview must never touch these (HTTP 403). */
|
|
19
|
+
export const TENANT_OVERVIEW_FORBIDDEN_QUERIES = [
|
|
20
|
+
"tenant:query:list",
|
|
21
|
+
"jobs:query:list",
|
|
22
|
+
"feature-toggles:query:list",
|
|
23
|
+
"feature-toggles:query:registered",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export function overviewAllowedQueries(kind: OverviewWorkspaceKind): readonly string[] {
|
|
27
|
+
return kind === "tenant" ? TENANT_OVERVIEW_ALLOWED_QUERIES : PLATFORM_OVERVIEW_ALLOWED_QUERIES;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isOverviewQueryAllowed(kind: OverviewWorkspaceKind, queryName: string): boolean {
|
|
31
|
+
return overviewAllowedQueries(kind).includes(queryName);
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import {
|
|
5
|
+
ADMIN_SHELL_FEATURE,
|
|
6
|
+
PLATFORM_OVERVIEW_SCREEN_ID,
|
|
7
|
+
TENANT_OVERVIEW_SCREEN_ID,
|
|
8
|
+
} from "../constants";
|
|
9
|
+
import { defaultTranslations } from "./i18n";
|
|
10
|
+
import { PlatformOverviewScreen } from "./platform-overview-screen";
|
|
11
|
+
import { TenantOverviewScreen } from "./tenant-overview-screen";
|
|
12
|
+
|
|
13
|
+
export type AdminShellClientOptions = {
|
|
14
|
+
readonly translations?: TranslationsByLocale;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function adminShellClient(options?: AdminShellClientOptions): ClientFeatureDefinition {
|
|
18
|
+
return {
|
|
19
|
+
name: ADMIN_SHELL_FEATURE,
|
|
20
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
21
|
+
components: {
|
|
22
|
+
[TENANT_OVERVIEW_SCREEN_ID]: TenantOverviewScreen,
|
|
23
|
+
[PLATFORM_OVERVIEW_SCREEN_ID]: PlatformOverviewScreen,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client pivot of ADMIN_SHELL_I18N — same keys as server r.translations bundle.
|
|
3
|
+
|
|
4
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { ADMIN_SHELL_I18N } from "../i18n";
|
|
6
|
+
|
|
7
|
+
const LOCALES = ["de", "en"] as const;
|
|
8
|
+
|
|
9
|
+
export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
|
|
10
|
+
LOCALES.map((locale) => [
|
|
11
|
+
locale,
|
|
12
|
+
Object.fromEntries(
|
|
13
|
+
Object.entries(ADMIN_SHELL_I18N).map(([key, value]) => [key, value[locale]]),
|
|
14
|
+
),
|
|
15
|
+
]),
|
|
16
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export type OverviewCard = {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly hint?: string;
|
|
9
|
+
/** When true and hint is set, render an attention Banner inside the card. */
|
|
10
|
+
readonly attention?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type OverviewState =
|
|
14
|
+
| { readonly kind: "loading" }
|
|
15
|
+
| { readonly kind: "error"; readonly message: string }
|
|
16
|
+
| { readonly kind: "ready"; readonly cards: readonly OverviewCard[] };
|
|
17
|
+
|
|
18
|
+
export function OverviewLayout({
|
|
19
|
+
testId,
|
|
20
|
+
title,
|
|
21
|
+
state,
|
|
22
|
+
loadingLabel,
|
|
23
|
+
columns = 3,
|
|
24
|
+
}: {
|
|
25
|
+
readonly testId: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly state: OverviewState;
|
|
28
|
+
readonly loadingLabel: string;
|
|
29
|
+
readonly columns?: 2 | 3;
|
|
30
|
+
}): ReactNode {
|
|
31
|
+
const { Banner, Card, Heading, Text } = usePrimitives();
|
|
32
|
+
const gridClass = columns === 2 ? "sm:grid-cols-2" : "sm:grid-cols-3";
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div data-testid={testId} className="flex max-w-4xl flex-col gap-6 p-6">
|
|
36
|
+
<Heading variant="page">{title}</Heading>
|
|
37
|
+
|
|
38
|
+
{state.kind === "loading" && (
|
|
39
|
+
<Text variant="small" testId={`${testId}-loading`}>
|
|
40
|
+
{loadingLabel}
|
|
41
|
+
</Text>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{state.kind === "error" && (
|
|
45
|
+
<Banner variant="error" testId={`${testId}-error`}>
|
|
46
|
+
{state.message}
|
|
47
|
+
</Banner>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{state.kind === "ready" && (
|
|
51
|
+
<div className={`grid grid-cols-1 gap-4 ${gridClass}`}>
|
|
52
|
+
{state.cards.map((card) => (
|
|
53
|
+
<Card key={card.label} testId={`${testId}-card`} slots={{ title: card.label }}>
|
|
54
|
+
<div className="flex flex-col gap-2" data-overview-card={card.label}>
|
|
55
|
+
<span className="text-3xl font-semibold tabular-nums">{card.value}</span>
|
|
56
|
+
{card.attention === true && card.hint !== undefined ? (
|
|
57
|
+
<Banner variant="error">{card.hint}</Banner>
|
|
58
|
+
) : (
|
|
59
|
+
card.hint !== undefined && <Text variant="small">{card.hint}</Text>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</Card>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { isOverviewQueryAllowed, type OverviewWorkspaceKind } from "../overview-allowlist";
|
|
4
|
+
|
|
5
|
+
export async function overviewQuery<T>(
|
|
6
|
+
kind: OverviewWorkspaceKind,
|
|
7
|
+
dispatcher: Dispatcher,
|
|
8
|
+
queryName: string,
|
|
9
|
+
payload: Record<string, unknown>,
|
|
10
|
+
): Promise<QueryResult<T>> {
|
|
11
|
+
if (!isOverviewQueryAllowed(kind, queryName)) {
|
|
12
|
+
throw new Error(`admin-shell:overview query not allowlisted: ${queryName}`);
|
|
13
|
+
}
|
|
14
|
+
return dispatcher.query<T>(queryName, payload);
|
|
15
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
|
|
3
|
+
import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
5
|
+
import { JobQueries } from "../../jobs/constants";
|
|
6
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
7
|
+
import { OverviewLayout, type OverviewState } from "./overview-layout";
|
|
8
|
+
import { overviewQuery } from "./overview-query";
|
|
9
|
+
|
|
10
|
+
export function PlatformOverviewScreen(): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
const dispatcher = useDispatcher();
|
|
13
|
+
const [state, setState] = useState<OverviewState>({ kind: "loading" });
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
void (async (): Promise<void> => {
|
|
18
|
+
const tenantsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
19
|
+
"platform",
|
|
20
|
+
dispatcher,
|
|
21
|
+
TenantQueries.list,
|
|
22
|
+
{},
|
|
23
|
+
);
|
|
24
|
+
if (cancelled) return;
|
|
25
|
+
if (!tenantsRes.isSuccess) {
|
|
26
|
+
setState({ kind: "error", message: tenantsRes.error.message });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const failedJobsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
31
|
+
"platform",
|
|
32
|
+
dispatcher,
|
|
33
|
+
JobQueries.list,
|
|
34
|
+
{ status: "failed", limit: 50 },
|
|
35
|
+
);
|
|
36
|
+
if (cancelled) return;
|
|
37
|
+
if (!failedJobsRes.isSuccess) {
|
|
38
|
+
setState({ kind: "error", message: failedJobsRes.error.message });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setState({
|
|
43
|
+
kind: "ready",
|
|
44
|
+
cards: [
|
|
45
|
+
{
|
|
46
|
+
label: t("admin-shell:overview.tenants"),
|
|
47
|
+
value: String(tenantsRes.data.rows.length),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: t("admin-shell:overview.failedJobs"),
|
|
51
|
+
value: String(failedJobsRes.data.rows.length),
|
|
52
|
+
hint:
|
|
53
|
+
failedJobsRes.data.rows.length > 0
|
|
54
|
+
? t("admin-shell:overview.failedJobsHint")
|
|
55
|
+
: undefined,
|
|
56
|
+
attention: failedJobsRes.data.rows.length > 0,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
})();
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [dispatcher, t]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<OverviewLayout
|
|
68
|
+
testId="platform-overview-screen"
|
|
69
|
+
title={t("admin-shell:overview.platformTitle")}
|
|
70
|
+
state={state}
|
|
71
|
+
loadingLabel={t("admin-shell:overview.loading")}
|
|
72
|
+
columns={2}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
|
|
3
|
+
import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
5
|
+
import { ConfigQueries } from "../../config/constants";
|
|
6
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
7
|
+
import { OverviewLayout, type OverviewState } from "./overview-layout";
|
|
8
|
+
import { overviewQuery } from "./overview-query";
|
|
9
|
+
|
|
10
|
+
export function TenantOverviewScreen(): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
const dispatcher = useDispatcher();
|
|
13
|
+
const [state, setState] = useState<OverviewState>({ kind: "loading" });
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
void (async (): Promise<void> => {
|
|
18
|
+
const invitationsRes = await overviewQuery<readonly unknown[]>(
|
|
19
|
+
"tenant",
|
|
20
|
+
dispatcher,
|
|
21
|
+
TenantQueries.invitations,
|
|
22
|
+
{},
|
|
23
|
+
);
|
|
24
|
+
if (cancelled) return;
|
|
25
|
+
if (!invitationsRes.isSuccess) {
|
|
26
|
+
setState({ kind: "error", message: invitationsRes.error.message });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const membersRes = await overviewQuery<readonly unknown[]>(
|
|
31
|
+
"tenant",
|
|
32
|
+
dispatcher,
|
|
33
|
+
TenantQueries.members,
|
|
34
|
+
{},
|
|
35
|
+
);
|
|
36
|
+
if (cancelled) return;
|
|
37
|
+
if (!membersRes.isSuccess) {
|
|
38
|
+
setState({ kind: "error", message: membersRes.error.message });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const readinessRes = await overviewQuery<{ readonly missing: readonly unknown[] }>(
|
|
43
|
+
"tenant",
|
|
44
|
+
dispatcher,
|
|
45
|
+
ConfigQueries.readiness,
|
|
46
|
+
{},
|
|
47
|
+
);
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
if (!readinessRes.isSuccess) {
|
|
50
|
+
setState({ kind: "error", message: readinessRes.error.message });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setState({
|
|
55
|
+
kind: "ready",
|
|
56
|
+
cards: [
|
|
57
|
+
{
|
|
58
|
+
label: t("admin-shell:overview.pendingInvitations"),
|
|
59
|
+
value: String(invitationsRes.data.length),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: t("admin-shell:overview.members"),
|
|
63
|
+
value: String(membersRes.data.length),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: t("admin-shell:overview.missingConfig"),
|
|
67
|
+
value: String(readinessRes.data.missing.length),
|
|
68
|
+
hint:
|
|
69
|
+
readinessRes.data.missing.length > 0
|
|
70
|
+
? t("admin-shell:overview.missingConfigHint")
|
|
71
|
+
: undefined,
|
|
72
|
+
attention: readinessRes.data.missing.length > 0,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
})();
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
};
|
|
80
|
+
}, [dispatcher, t]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<OverviewLayout
|
|
84
|
+
testId="tenant-overview-screen"
|
|
85
|
+
title={t("admin-shell:overview.tenantTitle")}
|
|
86
|
+
state={state}
|
|
87
|
+
loadingLabel={t("admin-shell:overview.loading")}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
6
|
+
import { createAuditFeature } from "../feature";
|
|
7
|
+
|
|
8
|
+
describe("audit log screen + handler access alignment", () => {
|
|
9
|
+
const features = [createConfigFeature(), createAuditFeature()];
|
|
10
|
+
|
|
11
|
+
test("boot-validates with audit-log screen registered", () => {
|
|
12
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("audit-log screen is custom, access.admin-gated", () => {
|
|
16
|
+
const audit = createAuditFeature();
|
|
17
|
+
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
18
|
+
expect(screen?.type).toBe("custom");
|
|
19
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
20
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("audit:query:list uses access.admin (screen ⊆ handler)", () => {
|
|
25
|
+
const audit = createAuditFeature();
|
|
26
|
+
expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
|
|
27
|
+
void AuditQueries;
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Security integration tests for audit-log HTTP surface.
|
|
2
|
+
// Real HTTP via setupTestStack — no mocks.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import {
|
|
7
|
+
access,
|
|
8
|
+
createEntity,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineEntityWriteHandler,
|
|
11
|
+
defineFeature,
|
|
12
|
+
type SessionUser,
|
|
13
|
+
type TenantId,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import {
|
|
16
|
+
createTestUser,
|
|
17
|
+
resetEventStore,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
unsafePushTables,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
|
+
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
27
|
+
import { createConfigFeature } from "../../config/feature";
|
|
28
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
29
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
30
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
31
|
+
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
32
|
+
import { createUserFeature } from "../../user/feature";
|
|
33
|
+
import { userEntity } from "../../user/schema/user";
|
|
34
|
+
import { seedUser } from "../../user/seeding";
|
|
35
|
+
import { AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
36
|
+
import { createAuditFeature } from "../feature";
|
|
37
|
+
|
|
38
|
+
const widgetEntity = createEntity({
|
|
39
|
+
table: "audit_sec_widgets",
|
|
40
|
+
fields: {
|
|
41
|
+
name: createTextField({ required: true }),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const widgetFeature = defineFeature("audit-sec-widgets", (r) => {
|
|
46
|
+
r.entity("widget", widgetEntity);
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
defineEntityWriteHandler("widget:create", widgetEntity, {
|
|
49
|
+
access: { roles: ["Admin", "User", "SystemAdmin", "TenantAdmin"] },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
let TENANT_ID: TenantId;
|
|
56
|
+
let tenantAdminId: string;
|
|
57
|
+
|
|
58
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
59
|
+
|
|
60
|
+
function tenantAdmin(): SessionUser {
|
|
61
|
+
return { id: tenantAdminId, tenantId: TENANT_ID, roles: ["TenantAdmin"] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function regularUser(): SessionUser {
|
|
65
|
+
return createTestUser({ id: 42, tenantId: TENANT_ID, roles: ["User"] });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
stack = await setupTestStack({
|
|
70
|
+
features: [
|
|
71
|
+
createConfigFeature(),
|
|
72
|
+
createUserFeature(),
|
|
73
|
+
createTenantFeature(),
|
|
74
|
+
widgetFeature,
|
|
75
|
+
createAuditFeature(),
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
80
|
+
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
81
|
+
await unsafePushTables(stack.db, { tenantMembershipsTable });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await stack.cleanup();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
await resetEventStore(stack);
|
|
90
|
+
await asRawClient(stack.db).unsafe(`TRUNCATE audit_sec_widgets`);
|
|
91
|
+
TENANT_ID = testTenantId(1);
|
|
92
|
+
await seedTenant(stack.db, { id: TENANT_ID, key: "audit-sec", name: "Audit Sec" });
|
|
93
|
+
({ id: tenantAdminId } = await seedUser(stack.db, {
|
|
94
|
+
email: "taudit@example.com",
|
|
95
|
+
displayName: "TAudit",
|
|
96
|
+
passwordHash: await hashPassword("pw-audit-1234"),
|
|
97
|
+
emailVerified: true,
|
|
98
|
+
}));
|
|
99
|
+
await seedTenantMembership(stack.db, {
|
|
100
|
+
userId: tenantAdminId,
|
|
101
|
+
tenantId: TENANT_ID,
|
|
102
|
+
roles: ["TenantAdmin"],
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("access matrix: audit list uses access.admin", () => {
|
|
107
|
+
test("handler and screen share access.admin", () => {
|
|
108
|
+
const adminRoles = [...access.admin];
|
|
109
|
+
expect(rolesOf(stack.registry.getQueryHandler(AuditQueries.list)?.access)).toEqual(adminRoles);
|
|
110
|
+
const audit = createAuditFeature();
|
|
111
|
+
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
112
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
113
|
+
expect(screen.access.roles).toEqual(adminRoles);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("TenantAdmin can query audit log for own tenant", () => {
|
|
119
|
+
test("TenantAdmin lists events after write in tenant", async () => {
|
|
120
|
+
await stack.http.writeOk(
|
|
121
|
+
"audit-sec-widgets:write:widget:create",
|
|
122
|
+
{ name: "audited" },
|
|
123
|
+
tenantAdmin(),
|
|
124
|
+
);
|
|
125
|
+
const res = await stack.http.queryOk<{ rows: readonly { type: string }[] }>(
|
|
126
|
+
AuditQueries.list,
|
|
127
|
+
{},
|
|
128
|
+
tenantAdmin(),
|
|
129
|
+
);
|
|
130
|
+
expect(res.rows.some((r) => r.type === "widget.created")).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("regular User denied audit log", () => {
|
|
135
|
+
test("403 on audit:query:list", async () => {
|
|
136
|
+
const res = await stack.http.query(AuditQueries.list, {}, regularUser());
|
|
137
|
+
expect(res.status).toBe(403);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("systemAdmin retains audit access", () => {
|
|
142
|
+
test("SystemAdmin can list", async () => {
|
|
143
|
+
await stack.http.writeOk("audit-sec-widgets:write:widget:create", { name: "sys" }, systemAdmin);
|
|
144
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
145
|
+
AuditQueries.list,
|
|
146
|
+
{},
|
|
147
|
+
systemAdmin,
|
|
148
|
+
);
|
|
149
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -314,15 +314,11 @@ describe("audit: list query", () => {
|
|
|
314
314
|
|
|
315
315
|
test("access denied for non-admin roles", async () => {
|
|
316
316
|
await createWidget(admin, "A");
|
|
317
|
-
// regularUser has role "User" — the handler requires Admin/SystemAdmin.
|
|
318
317
|
const res = await stack.http.query(AuditQueries.list, {}, regularUser);
|
|
319
318
|
expect(res.status).toBe(403);
|
|
320
319
|
const body = (await res.json()) as {
|
|
321
320
|
error?: { code?: string; details?: { reason?: string } };
|
|
322
321
|
};
|
|
323
|
-
// Pin the specific failure class. The framework raises AccessDeniedError
|
|
324
|
-
// with code=access_denied; asserting on `code` beats a status-only check
|
|
325
|
-
// (a 403 could also come from ownership-denied, for example).
|
|
326
322
|
expect(body.error?.code).toBe("access_denied");
|
|
327
323
|
});
|
|
328
324
|
});
|
package/src/audit/constants.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Qualified handler names + screen ids — shared by server feature and web client.
|
|
3
3
|
export const AUDIT_FEATURE = "audit" as const;
|
|
4
4
|
|
|
5
5
|
export const AuditQueries = {
|
|
6
6
|
list: "audit:query:list",
|
|
7
7
|
} as const;
|
|
8
|
+
|
|
9
|
+
/** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
|
|
10
|
+
export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
|
package/src/audit/feature.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
defineFeature,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { AUDIT_LOG_SCREEN_ID } from "./constants";
|
|
2
7
|
import { listQuery } from "./handlers/list.query";
|
|
8
|
+
import { AUDIT_I18N } from "./i18n";
|
|
3
9
|
|
|
4
10
|
// Audit feature — exposes a filtered read over the framework's event log.
|
|
5
11
|
//
|
|
@@ -23,9 +29,26 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
23
29
|
category: "compliance",
|
|
24
30
|
recommended: false,
|
|
25
31
|
});
|
|
32
|
+
r.translations({ keys: AUDIT_I18N });
|
|
33
|
+
|
|
26
34
|
const queries = {
|
|
27
35
|
list: r.queryHandler(listQuery),
|
|
28
36
|
};
|
|
37
|
+
|
|
38
|
+
r.screen({
|
|
39
|
+
id: AUDIT_LOG_SCREEN_ID,
|
|
40
|
+
type: "custom",
|
|
41
|
+
renderer: { react: { __component: "AuditLogScreen" } },
|
|
42
|
+
access: { roles: access.admin },
|
|
43
|
+
});
|
|
44
|
+
r.nav({
|
|
45
|
+
id: "audit-log",
|
|
46
|
+
label: "audit:nav.auditLog",
|
|
47
|
+
icon: "file",
|
|
48
|
+
screen: "audit:screen:audit-log",
|
|
49
|
+
order: 30,
|
|
50
|
+
});
|
|
51
|
+
|
|
29
52
|
return { queries };
|
|
30
53
|
});
|
|
31
54
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// can't surface PII that the entity definition marked as sensitive.
|
|
13
13
|
|
|
14
14
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
-
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
|
|
@@ -35,7 +35,7 @@ export const listQuery = defineQueryHandler({
|
|
|
35
35
|
message: "`from` must be less than or equal to `to`",
|
|
36
36
|
path: ["from"],
|
|
37
37
|
}),
|
|
38
|
-
access: { roles:
|
|
38
|
+
access: { roles: access.admin },
|
|
39
39
|
handler: async (query, ctx) => {
|
|
40
40
|
const p = query.payload;
|
|
41
41
|
const where: WhereObject = { tenantId: query.user.tenantId };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server + client i18n for audit (nav labels + AuditLogScreen).
|
|
3
|
+
|
|
4
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
5
|
+
|
|
6
|
+
export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"screen:audit-log.title": { de: "Audit-Log", en: "Audit log" },
|
|
8
|
+
"audit:nav.auditLog": { de: "Audit", en: "Audit" },
|
|
9
|
+
"audit.log.title": { de: "Audit-Log", en: "Audit log" },
|
|
10
|
+
"audit.log.loading": { de: "Lade Ereignisse…", en: "Loading events…" },
|
|
11
|
+
"audit.log.empty": { de: "Keine Ereignisse.", en: "No events." },
|
|
12
|
+
"audit.log.newest": { de: "Neueste", en: "Newest" },
|
|
13
|
+
"audit.log.older": { de: "Ältere laden", en: "Load older" },
|
|
14
|
+
"audit.log.col.when": { de: "Zeit", en: "When" },
|
|
15
|
+
"audit.log.col.type": { de: "Ereignis", en: "Event" },
|
|
16
|
+
"audit.log.col.aggregate": { de: "Aggregate", en: "Aggregate" },
|
|
17
|
+
"audit.log.col.actor": { de: "Akteur", en: "Actor" },
|
|
18
|
+
};
|