feat(acadedoc): replace EE Settings with open source UI (audit log, API keys, OIDC status) — R4.5
Server (NestJS): - AcadeniceAuditLogModule: GET /api/acadenice/audit-log (admin/owner, Kysely, paginated + filtered) - AcadeniceApiKeysModule: GET/POST/DELETE /api/acadenice/api-keys (JWT, bcrypt hash, acdk_ prefix) - AcadeniceSecurityModule: GET /api/acadenice/security/oidc-status (admin, no secrets exposed) - Migration 20260510T100000: acadenice_api_key table with token_hash + bcrypt - Permissions catalog: added audit_log:read Client (React 18 + Mantine v7): - Audit log page: paginated table with filters (event, userId, date range) - API keys page: list/create/revoke personal tokens, one-time display modal - Security/OIDC status page: read-only, env-var config reference - Sidebar rewired: Security & SSO, API keys, Audit log -> acadenice/* routes (no EE feature gates) - Prefetch functions for new routes Tests: 36 server (Jest) + client typecheck clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
38f7d73e85
commit
5b512e6324
37 changed files with 2382 additions and 9 deletions
|
|
@ -3,6 +3,8 @@ import {
|
||||||
getBilling,
|
getBilling,
|
||||||
getBillingPlans,
|
getBillingPlans,
|
||||||
} from "@/ee/billing/services/billing-service.ts";
|
} from "@/ee/billing/services/billing-service.ts";
|
||||||
|
import { getAcadeniceAuditLogs } from "@/features/acadenice/audit-log/services/audit-log.service";
|
||||||
|
import { listAcadeniceApiKeys } from "@/features/acadenice/api-keys/services/api-key.service";
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroups } from "@/features/group/services/group-service.ts";
|
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
|
|
@ -106,3 +108,18 @@ export const prefetchScimTokens = () => {
|
||||||
queryFn: () => getScimTokens({}),
|
queryFn: () => getScimTokens({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Acadenice R4.5 — open source prefetch functions
|
||||||
|
export const prefetchAcadeniceAuditLogs = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["acadenice-audit-logs", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => getAcadeniceAuditLogs({ limit: 50, offset: 0 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchAcadeniceApiKeys = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["acadenice-api-keys"],
|
||||||
|
queryFn: () => listAcadeniceApiKeys(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ import {
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
prefetchAuditLogs,
|
prefetchAuditLogs,
|
||||||
prefetchVerifiedPages,
|
prefetchVerifiedPages,
|
||||||
|
prefetchAcadeniceAuditLogs,
|
||||||
|
prefetchAcadeniceApiKeys,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
|
@ -85,10 +87,10 @@ const groupedData: DataGroup[] = [
|
||||||
path: "/settings/notifications",
|
path: "/settings/notifications",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Acadenice R4.5 — open source API keys (replaces EE-gated page)
|
||||||
label: "API keys",
|
label: "API keys",
|
||||||
icon: IconKey,
|
icon: IconKey,
|
||||||
path: "/settings/account/api-keys",
|
path: "/settings/acadenice/api-keys",
|
||||||
feature: Feature.API_KEYS,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Acadenice R4.3 — Web Clipper token management
|
// Acadenice R4.3 — Web Clipper token management
|
||||||
|
|
@ -118,10 +120,10 @@ const groupedData: DataGroup[] = [
|
||||||
env: "cloud",
|
env: "cloud",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Acadenice R4.5 — open source security/OIDC status (replaces EE-gated page)
|
||||||
label: "Security & SSO",
|
label: "Security & SSO",
|
||||||
icon: IconLock,
|
icon: IconLock,
|
||||||
path: "/settings/security",
|
path: "/settings/acadenice/security",
|
||||||
feature: Feature.SECURITY_SETTINGS,
|
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
|
|
@ -153,6 +155,8 @@ const groupedData: DataGroup[] = [
|
||||||
feature: Feature.PAGE_VERIFICATION,
|
feature: Feature.PAGE_VERIFICATION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Acadenice R4.5 — workspace-level API keys view removed (use account-level)
|
||||||
|
// kept here for EE environments but hidden on open source installs
|
||||||
label: "API management",
|
label: "API management",
|
||||||
icon: IconKey,
|
icon: IconKey,
|
||||||
path: "/settings/api-keys",
|
path: "/settings/api-keys",
|
||||||
|
|
@ -166,11 +170,11 @@ const groupedData: DataGroup[] = [
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Acadenice R4.5 — open source audit log (replaces EE-gated page)
|
||||||
label: "Audit log",
|
label: "Audit log",
|
||||||
icon: IconHistory,
|
icon: IconHistory,
|
||||||
path: "/settings/audit",
|
path: "/settings/acadenice/audit-log",
|
||||||
feature: Feature.AUDIT_LOGS,
|
role: "admin",
|
||||||
role: "owner",
|
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -264,13 +268,15 @@ export default function SettingsSidebar() {
|
||||||
prefetchHandler = prefetchShares;
|
prefetchHandler = prefetchShares;
|
||||||
break;
|
break;
|
||||||
case "API keys":
|
case "API keys":
|
||||||
prefetchHandler = prefetchApiKeys;
|
// Acadenice R4.5: points to open source endpoint
|
||||||
|
prefetchHandler = prefetchAcadeniceApiKeys;
|
||||||
break;
|
break;
|
||||||
case "API management":
|
case "API management":
|
||||||
prefetchHandler = prefetchApiKeyManagement;
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
break;
|
break;
|
||||||
case "Audit log":
|
case "Audit log":
|
||||||
prefetchHandler = prefetchAuditLogs;
|
// Acadenice R4.5: points to open source endpoint
|
||||||
|
prefetchHandler = prefetchAcadeniceAuditLogs;
|
||||||
break;
|
break;
|
||||||
case "Verified pages":
|
case "Verified pages":
|
||||||
prefetchHandler = prefetchVerifiedPages;
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCreateAcadeniceApiKeyMutation } from "../queries/api-key.queries";
|
||||||
|
import { CreateAcadeniceApiKeyResponse } from "../types/api-key.types";
|
||||||
|
|
||||||
|
const DURATION_OPTIONS = [
|
||||||
|
{ value: "30", label: "30 days" },
|
||||||
|
{ value: "90", label: "90 days" },
|
||||||
|
{ value: "365", label: "1 year" },
|
||||||
|
{ value: "0", label: "No expiry" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CreateApiKeyModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (result: CreateAcadeniceApiKeyResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcadeniceCreateApiKeyModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateApiKeyModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [duration, setDuration] = useState<string | null>("30");
|
||||||
|
const createMutation = useCreateAcadeniceApiKeyMutation();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!label.trim()) return;
|
||||||
|
|
||||||
|
const durationDays =
|
||||||
|
duration === "0" || duration === null ? null : Number(duration);
|
||||||
|
|
||||||
|
createMutation.mutate(
|
||||||
|
{ label: label.trim(), durationDays },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
onSuccess(result);
|
||||||
|
setLabel("");
|
||||||
|
setDuration("30");
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Create API key")}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"This token grants full access to your account. Store it securely and never share it.",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("Label")}
|
||||||
|
placeholder={t("e.g. CI pipeline, personal script")}
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
aria-label={t("API key label")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={t("Expiry")}
|
||||||
|
data={DURATION_OPTIONS}
|
||||||
|
value={duration}
|
||||||
|
onChange={setDuration}
|
||||||
|
aria-label={t("API key expiry duration")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
disabled={!label.trim()}
|
||||||
|
aria-label={t("Generate new API key")}
|
||||||
|
>
|
||||||
|
{t("Generate")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Button, Group, Modal, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AcadeniceApiKey } from "../types/api-key.types";
|
||||||
|
import { useRevokeAcadeniceApiKeyMutation } from "../queries/api-key.queries";
|
||||||
|
|
||||||
|
interface RevokeApiKeyModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
apiKey: AcadeniceApiKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcadeniceRevokeApiKeyModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
apiKey,
|
||||||
|
}: RevokeApiKeyModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const revokeMutation = useRevokeAcadeniceApiKeyMutation();
|
||||||
|
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
const handleRevoke = () => {
|
||||||
|
revokeMutation.mutate(apiKey.id, { onSuccess: onClose });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={t("Revoke API key")} size="sm">
|
||||||
|
<Text size="sm" mb="md">
|
||||||
|
{t(
|
||||||
|
'Are you sure you want to revoke "{{label}}"? This action cannot be undone.',
|
||||||
|
{ label: apiKey.label },
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={revokeMutation.isPending}
|
||||||
|
onClick={handleRevoke}
|
||||||
|
aria-label={t("Confirm revoke API key")}
|
||||||
|
>
|
||||||
|
{t("Revoke")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CreateAcadeniceApiKeyResponse } from "../types/api-key.types";
|
||||||
|
import CopyTextButton from "@/components/common/copy";
|
||||||
|
|
||||||
|
interface TokenCreatedModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
result: CreateAcadeniceApiKeyResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcadeniceTokenCreatedModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
result,
|
||||||
|
}: TokenCreatedModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("API key created")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
title={t("Important")}
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"This is the only time you can see this token. Copy it now — it cannot be retrieved later.",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{t("Your new API key")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={result.token}
|
||||||
|
readOnly
|
||||||
|
ff="monospace"
|
||||||
|
aria-label={t("Generated API key token")}
|
||||||
|
/>
|
||||||
|
<CopyTextButton text={result.token} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"This token grants full access to your account. Treat it like a password.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button fullWidth onClick={onClose} mt="sm" aria-label={t("Confirm token saved")}>
|
||||||
|
{t("I have saved my token")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconDots, IconTrash, IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
import { useAcadeniceApiKeysQuery } from "../queries/api-key.queries";
|
||||||
|
import { AcadeniceCreateApiKeyModal } from "../components/create-api-key-modal";
|
||||||
|
import { AcadeniceTokenCreatedModal } from "../components/token-created-modal";
|
||||||
|
import { AcadeniceRevokeApiKeyModal } from "../components/revoke-api-key-modal";
|
||||||
|
import {
|
||||||
|
AcadeniceApiKey,
|
||||||
|
CreateAcadeniceApiKeyResponse,
|
||||||
|
} from "../types/api-key.types";
|
||||||
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
|
|
||||||
|
export default function AcadeniceApiKeysPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: keys, isLoading } = useAcadeniceApiKeysQuery();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createdResult, setCreatedResult] =
|
||||||
|
useState<CreateAcadeniceApiKeyResponse | null>(null);
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<AcadeniceApiKey | null>(null);
|
||||||
|
|
||||||
|
const formatDate = (d: string | null) =>
|
||||||
|
d ? format(new Date(d), "MMM dd, yyyy") : t("Never");
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: string | null) =>
|
||||||
|
expiresAt ? new Date(expiresAt) < new Date() : false;
|
||||||
|
|
||||||
|
const handleCreateSuccess = (result: CreateAcadeniceApiKeyResponse) => {
|
||||||
|
setCreatedResult(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("API keys")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("API keys")} />
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
icon={<IconInfoCircle size={16} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
mb="md"
|
||||||
|
p="sm"
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Personal API keys grant full access to your account. Rotate them regularly and never commit them to source control.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mb="md">
|
||||||
|
<Button
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
aria-label={t("Create new API key")}
|
||||||
|
>
|
||||||
|
{t("New API key")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table.ScrollContainer minWidth={500}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Label")}</Table.Th>
|
||||||
|
<Table.Th>{t("Last used")}</Table.Th>
|
||||||
|
<Table.Th>{t("Expires")}</Table.Th>
|
||||||
|
<Table.Th>{t("Created")}</Table.Th>
|
||||||
|
<Table.Th aria-label={t("Action")} />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text c="dimmed">{t("Loading...")}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : keys && keys.length > 0 ? (
|
||||||
|
keys.map((key) => (
|
||||||
|
<Table.Tr key={key.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{key.label}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(key.lastUsedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
c={isExpired(key.expiresAt) ? "red" : undefined}
|
||||||
|
>
|
||||||
|
{isExpired(key.expiresAt)
|
||||||
|
? t("Expired")
|
||||||
|
: formatDate(key.expiresAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(key.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("API key actions for {{label}}", {
|
||||||
|
label: key.label,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
color="red"
|
||||||
|
onClick={() => setRevokeTarget(key)}
|
||||||
|
>
|
||||||
|
{t("Revoke")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={5} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
<Space h="md" />
|
||||||
|
|
||||||
|
<AcadeniceCreateApiKeyModal
|
||||||
|
opened={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AcadeniceTokenCreatedModal
|
||||||
|
opened={!!createdResult}
|
||||||
|
onClose={() => setCreatedResult(null)}
|
||||||
|
result={createdResult}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AcadeniceRevokeApiKeyModal
|
||||||
|
opened={!!revokeTarget}
|
||||||
|
onClose={() => setRevokeTarget(null)}
|
||||||
|
apiKey={revokeTarget}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createAcadeniceApiKey,
|
||||||
|
listAcadeniceApiKeys,
|
||||||
|
revokeAcadeniceApiKey,
|
||||||
|
} from "../services/api-key.service";
|
||||||
|
import { CreateAcadeniceApiKeyRequest } from "../types/api-key.types";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const QUERY_KEY = ["acadenice-api-keys"];
|
||||||
|
|
||||||
|
export function useAcadeniceApiKeysQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: listAcadeniceApiKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAcadeniceApiKeyMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateAcadeniceApiKeyRequest) =>
|
||||||
|
createAcadeniceApiKey(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to create API key"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeAcadeniceApiKeyMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => revokeAcadeniceApiKey(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
notifications.show({ message: t("API key revoked") });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to revoke API key"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import {
|
||||||
|
AcadeniceApiKey,
|
||||||
|
CreateAcadeniceApiKeyRequest,
|
||||||
|
CreateAcadeniceApiKeyResponse,
|
||||||
|
} from "../types/api-key.types";
|
||||||
|
|
||||||
|
export async function listAcadeniceApiKeys(): Promise<AcadeniceApiKey[]> {
|
||||||
|
const resp = await api.get("/acadenice/api-keys");
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAcadeniceApiKey(
|
||||||
|
data: CreateAcadeniceApiKeyRequest,
|
||||||
|
): Promise<CreateAcadeniceApiKeyResponse> {
|
||||||
|
const resp = await api.post("/acadenice/api-keys", data);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAcadeniceApiKey(id: string): Promise<void> {
|
||||||
|
await api.delete(`/acadenice/api-keys/${id}`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export interface AcadeniceApiKey {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
label: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAcadeniceApiKeyRequest {
|
||||||
|
label: string;
|
||||||
|
durationDays?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAcadeniceApiKeyResponse {
|
||||||
|
token: string;
|
||||||
|
keyInfo: AcadeniceApiKey;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Badge, Code, Table, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AcadeniceAuditLogEntry } from "../types/audit-log.types";
|
||||||
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
|
|
||||||
|
interface AuditLogTableProps {
|
||||||
|
items: AcadeniceAuditLogEntry[] | undefined;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateJson(obj: Record<string, unknown> | null): string {
|
||||||
|
if (!obj) return "";
|
||||||
|
const raw = JSON.stringify(obj);
|
||||||
|
return raw.length > 120 ? raw.slice(0, 117) + "..." : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcadeniceAuditLogTable({ items, isLoading }: AuditLogTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Text c="dimmed">{t("Loading...")}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={700}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Timestamp")}</Table.Th>
|
||||||
|
<Table.Th>{t("Actor")}</Table.Th>
|
||||||
|
<Table.Th>{t("Event")}</Table.Th>
|
||||||
|
<Table.Th>{t("Resource type")}</Table.Th>
|
||||||
|
<Table.Th>{t("Resource ID")}</Table.Th>
|
||||||
|
<Table.Th>{t("Details")}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items && items.length > 0 ? (
|
||||||
|
items.map((entry) => (
|
||||||
|
<Table.Tr key={entry.id}>
|
||||||
|
<Table.Td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{format(new Date(entry.createdAt), "yyyy-MM-dd HH:mm:ss")}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
{entry.actorEmail ? (
|
||||||
|
<Text fz="sm">{entry.actorEmail}</Text>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{entry.actorType}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" size="sm">
|
||||||
|
{entry.event}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm">{entry.resourceType}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
{entry.resourceId ? (
|
||||||
|
<Code fz="xs">{entry.resourceId.slice(0, 8)}...</Code>
|
||||||
|
) : (
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
{(entry.changes || entry.metadata) && (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
<Code fz="xs">
|
||||||
|
{truncateJson(entry.changes ?? entry.metadata)}
|
||||||
|
</Code>
|
||||||
|
}
|
||||||
|
multiline
|
||||||
|
w={300}
|
||||||
|
>
|
||||||
|
<Text fz="xs" c="dimmed" style={{ cursor: "help" }}>
|
||||||
|
{t("View")}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={6} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
import type { DateValue } from "@mantine/dates";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
import { useAcadeniceAuditLogsQuery } from "../queries/audit-log.queries";
|
||||||
|
import { AcadeniceAuditLogTable } from "../components/audit-log-table";
|
||||||
|
import { AcadeniceAuditLogQuery } from "../types/audit-log.types";
|
||||||
|
import useUserRole from "@/hooks/use-user-role";
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const EVENT_OPTIONS = [
|
||||||
|
"page.created",
|
||||||
|
"page.updated",
|
||||||
|
"page.deleted",
|
||||||
|
"space.created",
|
||||||
|
"space.deleted",
|
||||||
|
"user.invited",
|
||||||
|
"user.deleted",
|
||||||
|
"workspace.updated",
|
||||||
|
].map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
export default function AcadeniceAuditLogPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [action, setAction] = useState<string | null>(null);
|
||||||
|
const [since, setSince] = useState<DateValue>(null);
|
||||||
|
const [until, setUntil] = useState<DateValue>(null);
|
||||||
|
|
||||||
|
const toIso = (d: DateValue): string | undefined =>
|
||||||
|
d instanceof Date ? d.toISOString() : undefined;
|
||||||
|
|
||||||
|
const queryParams: AcadeniceAuditLogQuery = {
|
||||||
|
limit: LIMIT,
|
||||||
|
offset,
|
||||||
|
...(userId.trim() ? { userId: userId.trim() } : {}),
|
||||||
|
...(action ? { action } : {}),
|
||||||
|
...(since ? { since: toIso(since) } : {}),
|
||||||
|
...(until ? { until: toIso(until) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading } = useAcadeniceAuditLogsQuery(queryParams);
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed">{t("You do not have permission to view audit logs.")}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / LIMIT) : 0;
|
||||||
|
const currentPage = Math.floor(offset / LIMIT) + 1;
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setUserId("");
|
||||||
|
setAction(null);
|
||||||
|
setSince(null);
|
||||||
|
setUntil(null);
|
||||||
|
setOffset(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("Audit log")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("Audit log")} />
|
||||||
|
|
||||||
|
<Stack gap="sm" mb="md">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
|
<Select
|
||||||
|
placeholder={t("Filter by event")}
|
||||||
|
data={EVENT_OPTIONS}
|
||||||
|
value={action}
|
||||||
|
onChange={(v) => {
|
||||||
|
setAction(v);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
w={220}
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("Filter by event type")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("Filter by user ID (UUID)")}
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserId(e.currentTarget.value);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
w={260}
|
||||||
|
aria-label={t("Filter by user ID")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePickerInput
|
||||||
|
placeholder={t("Since")}
|
||||||
|
value={since}
|
||||||
|
onChange={(v) => {
|
||||||
|
setSince(v);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
w={160}
|
||||||
|
aria-label={t("Filter since date")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePickerInput
|
||||||
|
placeholder={t("Until")}
|
||||||
|
value={until}
|
||||||
|
onChange={(v) => {
|
||||||
|
setUntil(v);
|
||||||
|
setOffset(0);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
w={160}
|
||||||
|
aria-label={t("Filter until date")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetFilters}
|
||||||
|
aria-label={t("Reset filters")}
|
||||||
|
>
|
||||||
|
{t("Reset")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{t("{{total}} entries", { total: data.total })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<AcadeniceAuditLogTable items={data?.items} isLoading={isLoading} />
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - LIMIT))}
|
||||||
|
aria-label={t("Previous page")}
|
||||||
|
>
|
||||||
|
{t("Previous")}
|
||||||
|
</Button>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + LIMIT >= (data?.total ?? 0)}
|
||||||
|
onClick={() => setOffset(offset + LIMIT)}
|
||||||
|
aria-label={t("Next page")}
|
||||||
|
>
|
||||||
|
{t("Next")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import { getAcadeniceAuditLogs } from "../services/audit-log.service";
|
||||||
|
import { AcadeniceAuditLogQuery } from "../types/audit-log.types";
|
||||||
|
|
||||||
|
export function useAcadeniceAuditLogsQuery(params: AcadeniceAuditLogQuery = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["acadenice-audit-logs", params],
|
||||||
|
queryFn: () => getAcadeniceAuditLogs(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import {
|
||||||
|
AcadeniceAuditLogPage,
|
||||||
|
AcadeniceAuditLogQuery,
|
||||||
|
} from "../types/audit-log.types";
|
||||||
|
|
||||||
|
export async function getAcadeniceAuditLogs(
|
||||||
|
params: AcadeniceAuditLogQuery = {},
|
||||||
|
): Promise<AcadeniceAuditLogPage> {
|
||||||
|
const resp = await api.get("/acadenice/audit-log", { params });
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
export interface AcadeniceAuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorType: string;
|
||||||
|
event: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
changes: Record<string, unknown> | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actorEmail: string | null;
|
||||||
|
actorName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcadeniceAuditLogPage {
|
||||||
|
items: AcadeniceAuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcadeniceAuditLogQuery {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { Alert, Badge, Code, Divider, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconInfoCircle, IconLock, IconShieldCheck } from "@tabler/icons-react";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
import { useOidcStatusQuery } from "../queries/oidc-status.queries";
|
||||||
|
import useUserRole from "@/hooks/use-user-role";
|
||||||
|
|
||||||
|
export default function AcadeniceSecurityPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
const { data: oidc, isLoading } = useOidcStatusQuery();
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed">
|
||||||
|
{t("You do not have permission to view security settings.")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("Security")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("Security")} />
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
icon={<IconInfoCircle size={16} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"Security settings are configured server-side via environment variables. Contact your system administrator to modify them.",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
<IconShieldCheck size={18} style={{ marginRight: 6, verticalAlign: "middle" }} />
|
||||||
|
{t("Single Sign-On (OIDC)")}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Text c="dimmed">{t("Loading...")}</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm" mb="lg">
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Status")}:{" "}
|
||||||
|
<Badge color={oidc?.enabled ? "green" : "gray"} variant="light">
|
||||||
|
{oidc?.enabled ? t("Enabled") : t("Disabled")}
|
||||||
|
</Badge>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{oidc?.enabled && (
|
||||||
|
<>
|
||||||
|
{oidc.providerName && (
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Provider")}: <strong>{oidc.providerName}</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidc.issuer && (
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Issuer")}: <Code>{oidc.issuer}</Code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidc.scopes && (
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Scopes")}: <Code>{oidc.scopes}</Code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidc.redirectUri && (
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Redirect URI")}: <Code>{oidc.redirectUri}</Code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidc.loginUrl && (
|
||||||
|
<Text fz="sm">
|
||||||
|
{t("Login URL")}:{" "}
|
||||||
|
<Code>
|
||||||
|
{typeof window !== "undefined"
|
||||||
|
? window.location.origin + oidc.loginUrl
|
||||||
|
: oidc.loginUrl}
|
||||||
|
</Code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<Title order={5} mb="xs">
|
||||||
|
{t("Configuration")}
|
||||||
|
</Title>
|
||||||
|
<Text fz="sm" c="dimmed" mb="md">
|
||||||
|
{t(
|
||||||
|
"OIDC is configured via environment variables on the server. The following variables are supported:",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
{[
|
||||||
|
{ key: "OIDC_ENABLED", desc: t("Enable OIDC login (true/false)") },
|
||||||
|
{ key: "OIDC_ISSUER", desc: t("Provider discovery URL") },
|
||||||
|
{ key: "OIDC_CLIENT_ID", desc: t("OAuth2 client ID") },
|
||||||
|
{
|
||||||
|
key: "OIDC_CLIENT_SECRET",
|
||||||
|
desc: t("OAuth2 client secret (server-only, never exposed)"),
|
||||||
|
},
|
||||||
|
{ key: "OIDC_REDIRECT_URI", desc: t("Callback URL (optional)") },
|
||||||
|
{
|
||||||
|
key: "OIDC_SCOPES",
|
||||||
|
desc: t("OAuth2 scopes (default: openid email profile)"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "OIDC_PROVIDER_NAME",
|
||||||
|
desc: t("Label shown on login button"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "OIDC_AUTO_PROVISION",
|
||||||
|
desc: t("Auto-create user on first login (true/false)"),
|
||||||
|
},
|
||||||
|
].map(({ key, desc }) => (
|
||||||
|
<Text fz="sm" key={key}>
|
||||||
|
<Code>{key}</Code> — {desc}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<Title order={5} mb="xs">
|
||||||
|
<IconLock size={16} style={{ marginRight: 4, verticalAlign: "middle" }} />
|
||||||
|
{t("API keys")}
|
||||||
|
</Title>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Personal API keys can be managed from Account > API keys. Rotate them every 90 days. Never commit tokens to source control.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getOidcStatus } from "../services/oidc-status.service";
|
||||||
|
|
||||||
|
export function useOidcStatusQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["acadenice-oidc-status"],
|
||||||
|
queryFn: getOidcStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { OidcStatusResponse } from "../types/oidc-status.types";
|
||||||
|
|
||||||
|
export async function getOidcStatus(): Promise<OidcStatusResponse> {
|
||||||
|
const resp = await api.get("/acadenice/security/oidc-status");
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export interface OidcStatusResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
providerName: string | null;
|
||||||
|
issuer: string | null;
|
||||||
|
scopes: string | null;
|
||||||
|
redirectUri: string | null;
|
||||||
|
loginUrl: string | null;
|
||||||
|
}
|
||||||
21
apps/server/src/core/acadenice/api-keys/api-keys.module.ts
Normal file
21
apps/server/src/core/acadenice/api-keys/api-keys.module.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AcadeniceApiKeyController } from './controllers/api-key.controller';
|
||||||
|
import { AcadeniceApiKeyService } from './services/api-key.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceApiKeysModule — personal access tokens (R4.5).
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/acadenice/api-keys
|
||||||
|
* POST /api/acadenice/api-keys
|
||||||
|
* DELETE /api/acadenice/api-keys/:id
|
||||||
|
*
|
||||||
|
* Token format: acdk_<64 hex chars>
|
||||||
|
* Storage: bcrypt hash only — plaintext returned once at creation.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [AcadeniceApiKeyController],
|
||||||
|
providers: [AcadeniceApiKeyService],
|
||||||
|
exports: [AcadeniceApiKeyService],
|
||||||
|
})
|
||||||
|
export class AcadeniceApiKeysModule {}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
AcadeniceApiKeyService,
|
||||||
|
AcadeniceApiKeyRow,
|
||||||
|
CreateApiKeyResult,
|
||||||
|
} from '../services/api-key.service';
|
||||||
|
import { CreateApiKeySchema } from '../dto/api-key.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceApiKeyController — personal access tokens (R4.5).
|
||||||
|
*
|
||||||
|
* GET /api/acadenice/api-keys List caller's tokens (no hashes)
|
||||||
|
* POST /api/acadenice/api-keys Create token — returns plain once
|
||||||
|
* DELETE /api/acadenice/api-keys/:id Revoke
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('acadenice/api-keys')
|
||||||
|
export class AcadeniceApiKeyController {
|
||||||
|
constructor(private readonly apiKeyService: AcadeniceApiKeyService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<AcadeniceApiKeyRow[]> {
|
||||||
|
return this.apiKeyService.list(user.id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async create(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Body() body: unknown,
|
||||||
|
): Promise<{ token: string; keyInfo: AcadeniceApiKeyRow }> {
|
||||||
|
const parsed = CreateApiKeySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CreateApiKeyResult = await this.apiKeyService.generate(
|
||||||
|
user.id,
|
||||||
|
workspace.id,
|
||||||
|
parsed.data.label,
|
||||||
|
parsed.data.durationDays ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { token: result.token, keyInfo: result.row };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async revoke(
|
||||||
|
@Param('id', ParseUUIDPipe) tokenId: string,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.apiKeyService.revoke(tokenId, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts
Normal file
13
apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CreateApiKeySchema = z.object({
|
||||||
|
label: z.string().min(1).max(120).trim(),
|
||||||
|
durationDays: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateApiKeyDto = z.infer<typeof CreateApiKeySchema>;
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
const TOKEN_PREFIX = 'acdk_';
|
||||||
|
const TOKEN_BYTES = 32;
|
||||||
|
|
||||||
|
export interface AcadeniceApiKeyRow {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
label: string;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyResult {
|
||||||
|
token: string;
|
||||||
|
row: AcadeniceApiKeyRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceApiKeyService — personal access tokens (R4.5).
|
||||||
|
*
|
||||||
|
* Security contract:
|
||||||
|
* - Only a bcrypt hash is stored; the plaintext is returned once.
|
||||||
|
* - Prefix `acdk_` makes tokens recognisable in logs without leaking value.
|
||||||
|
* - Validation scans all rows for the workspace (typically < 20 per user).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceApiKeyService {
|
||||||
|
private readonly logger = new Logger(AcadeniceApiKeyService.name);
|
||||||
|
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async generate(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
label: string,
|
||||||
|
durationDays: number | null | undefined,
|
||||||
|
): Promise<CreateApiKeyResult> {
|
||||||
|
const plain = TOKEN_PREFIX + randomBytes(TOKEN_BYTES).toString('hex');
|
||||||
|
const hash = await bcrypt.hash(plain, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
const expiresAt =
|
||||||
|
durationDays != null
|
||||||
|
? new Date(Date.now() + durationDays * 86_400_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rows = await sql<AcadeniceApiKeyRow>`
|
||||||
|
INSERT INTO acadenice_api_key
|
||||||
|
(user_id, workspace_id, token_hash, label, expires_at)
|
||||||
|
VALUES
|
||||||
|
(${userId}, ${workspaceId}, ${hash}, ${label}, ${expiresAt})
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
user_id AS "userId",
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
label,
|
||||||
|
last_used_at AS "lastUsedAt",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
expires_at AS "expiresAt"
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
return { token: plain, row: rows.rows[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AcadeniceApiKeyRow[]> {
|
||||||
|
const result = await sql<AcadeniceApiKeyRow>`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id AS "userId",
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
label,
|
||||||
|
last_used_at AS "lastUsedAt",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
expires_at AS "expiresAt"
|
||||||
|
FROM acadenice_api_key
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
AND workspace_id = ${workspaceId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(tokenId: string, userId: string): Promise<void> {
|
||||||
|
const result = await sql<{ id: string }>`
|
||||||
|
DELETE FROM acadenice_api_key
|
||||||
|
WHERE id = ${tokenId} AND user_id = ${userId}
|
||||||
|
RETURNING id
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundException('API key not found or already revoked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a raw token against all active keys for the workspace.
|
||||||
|
* Linear scan is safe because the number of keys per user is small (<= 20).
|
||||||
|
*/
|
||||||
|
async validate(
|
||||||
|
rawToken: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AcadeniceApiKeyRow> {
|
||||||
|
if (!rawToken.startsWith(TOKEN_PREFIX)) {
|
||||||
|
throw new UnauthorizedException('Invalid API key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql<AcadeniceApiKeyRow & { tokenHash: string }>`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id AS "userId",
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
token_hash AS "tokenHash",
|
||||||
|
label,
|
||||||
|
last_used_at AS "lastUsedAt",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
expires_at AS "expiresAt"
|
||||||
|
FROM acadenice_api_key
|
||||||
|
WHERE workspace_id = ${workspaceId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
for (const row of rows.rows) {
|
||||||
|
if (row.expiresAt && row.expiresAt < new Date()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = await bcrypt.compare(rawToken, row.tokenHash);
|
||||||
|
if (match) {
|
||||||
|
sql`
|
||||||
|
UPDATE acadenice_api_key SET last_used_at = now() WHERE id = ${row.id}
|
||||||
|
`
|
||||||
|
.execute(this.db)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to bump last_used_at for key ${row.id}: ${err.message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { tokenHash: _omit, ...clean } = row;
|
||||||
|
return clean as AcadeniceApiKeyRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException('Invalid or expired API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AcadeniceApiKeyController } from '../controllers/api-key.controller';
|
||||||
|
import { AcadeniceApiKeyService } from '../services/api-key.service';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const USER_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const WORKSPACE_ID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const KEY_ID = '33333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
const user: any = { id: USER_ID, role: 'member' };
|
||||||
|
const workspace: any = { id: WORKSPACE_ID };
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
id: KEY_ID,
|
||||||
|
userId: USER_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
label: 'my key',
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AcadeniceApiKeyController', () => {
|
||||||
|
let controller: AcadeniceApiKeyController;
|
||||||
|
let service: AcadeniceApiKeyService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AcadeniceApiKeyController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AcadeniceApiKeyService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn().mockResolvedValue([mockRow]),
|
||||||
|
generate: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ token: 'acdk_abc123', row: mockRow }),
|
||||||
|
revoke: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get(AcadeniceApiKeyController);
|
||||||
|
service = module.get(AcadeniceApiKeyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list returns user api keys', async () => {
|
||||||
|
const result = await controller.list(user, workspace);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(service.list).toHaveBeenCalledWith(USER_ID, WORKSPACE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create returns token and keyInfo', async () => {
|
||||||
|
const result = await controller.create(user, workspace, {
|
||||||
|
label: 'my key',
|
||||||
|
durationDays: 30,
|
||||||
|
});
|
||||||
|
expect(result.token).toBe('acdk_abc123');
|
||||||
|
expect(result.keyInfo).toEqual(mockRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create throws BadRequest when label missing', async () => {
|
||||||
|
await expect(
|
||||||
|
controller.create(user, workspace, {}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create throws BadRequest for empty label', async () => {
|
||||||
|
await expect(
|
||||||
|
controller.create(user, workspace, { label: '' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create accepts null durationDays', async () => {
|
||||||
|
const result = await controller.create(user, workspace, {
|
||||||
|
label: 'forever key',
|
||||||
|
durationDays: null,
|
||||||
|
});
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoke calls service.revoke', async () => {
|
||||||
|
await controller.revoke(KEY_ID, user);
|
||||||
|
expect(service.revoke).toHaveBeenCalledWith(KEY_ID, USER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Mock bcrypt before all imports so the native module is not loaded.
|
||||||
|
jest.mock('bcrypt', () => ({
|
||||||
|
hash: jest.fn().mockResolvedValue('$2b$10$fakehash'),
|
||||||
|
compare: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { AcadeniceApiKeyService } from '../services/api-key.service';
|
||||||
|
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-10T10:00:00Z');
|
||||||
|
|
||||||
|
const fakeRow = {
|
||||||
|
id: 'key-uuid',
|
||||||
|
userId: 'user-uuid',
|
||||||
|
workspaceId: 'ws-uuid',
|
||||||
|
tokenHash: '$2b$10$fakehash',
|
||||||
|
label: 'My Key',
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: NOW,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSql = jest.fn();
|
||||||
|
const mockDb = { execute: jest.fn() };
|
||||||
|
|
||||||
|
jest.mock('kysely', () => ({
|
||||||
|
sql: Object.assign(
|
||||||
|
(..._args: any[]) => ({ execute: mockSql }),
|
||||||
|
{ raw: jest.fn() },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AcadeniceApiKeyService', () => {
|
||||||
|
let service: AcadeniceApiKeyService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockSql.mockReset();
|
||||||
|
(bcrypt.compare as jest.Mock).mockReset();
|
||||||
|
(bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$fakehash');
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AcadeniceApiKeyService,
|
||||||
|
{
|
||||||
|
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
|
||||||
|
useValue: mockDb,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(AcadeniceApiKeyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generate', () => {
|
||||||
|
it('returns plaintext token starting with acdk_', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
|
||||||
|
const result = await service.generate('user-uuid', 'ws-uuid', 'My Key', null);
|
||||||
|
expect(result.token).toMatch(/^acdk_/);
|
||||||
|
expect(result.row.id).toBe('key-uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets expiresAt when durationDays is provided', async () => {
|
||||||
|
const future = new Date(Date.now() + 30 * 86_400_000);
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [{ ...fakeRow, expiresAt: future }] });
|
||||||
|
const result = await service.generate('user-uuid', 'ws-uuid', 'Expiring', 30);
|
||||||
|
expect(result.row.expiresAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include tokenHash in returned row', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
|
||||||
|
const result = await service.generate('user-uuid', 'ws-uuid', 'My Key', null);
|
||||||
|
// Row is directly from DB query — no hash stripping at generate time (hash is in DB)
|
||||||
|
// token is the plaintext, row has the DB fields (no tokenHash column in RETURNING)
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
expect(result.row).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('returns rows for user and workspace', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
|
||||||
|
const rows = await service.list('user-uuid', 'ws-uuid');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].id).toBe('key-uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no keys exist', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [] });
|
||||||
|
const rows = await service.list('user-uuid', 'ws-uuid');
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revoke', () => {
|
||||||
|
it('resolves when key is found and owned by user', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [{ id: 'key-uuid' }] });
|
||||||
|
await expect(service.revoke('key-uuid', 'user-uuid')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when key does not exist', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [] });
|
||||||
|
await expect(service.revoke('key-uuid', 'user-uuid')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate', () => {
|
||||||
|
it('throws UnauthorizedException for wrong prefix', async () => {
|
||||||
|
await expect(
|
||||||
|
service.validate('bad_prefix_token', 'ws-uuid'),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when no rows match', async () => {
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [] });
|
||||||
|
await expect(
|
||||||
|
service.validate('acdk_' + 'a'.repeat(64), 'ws-uuid'),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips expired tokens and throws UnauthorizedException', async () => {
|
||||||
|
(bcrypt.compare as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
const expiredRow = {
|
||||||
|
...fakeRow,
|
||||||
|
expiresAt: new Date(Date.now() - 1000),
|
||||||
|
};
|
||||||
|
mockSql.mockResolvedValueOnce({ rows: [expiredRow] });
|
||||||
|
await expect(
|
||||||
|
service.validate('acdk_' + 'a'.repeat(64), 'ws-uuid'),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cleaned row (no tokenHash) on valid token', async () => {
|
||||||
|
(bcrypt.compare as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
mockSql
|
||||||
|
.mockResolvedValueOnce({ rows: [fakeRow] }) // SELECT rows
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // UPDATE last_used_at (fire-and-forget)
|
||||||
|
|
||||||
|
const result = await service.validate('acdk_' + 'a'.repeat(64), 'ws-uuid');
|
||||||
|
expect((result as any).tokenHash).toBeUndefined();
|
||||||
|
expect(result.id).toBe('key-uuid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/core/acadenice/audit-log/audit-log.module.ts
Normal file
17
apps/server/src/core/acadenice/audit-log/audit-log.module.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AcadeniceAuditLogController } from './controllers/audit-log.controller';
|
||||||
|
import { AcadeniceAuditLogService } from './services/audit-log.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceAuditLogModule — R4.5.
|
||||||
|
*
|
||||||
|
* Exposes GET /api/acadenice/audit-log (admin/owner only).
|
||||||
|
* Reads directly from the `audit` table via Kysely.
|
||||||
|
* No EE dependency.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [AcadeniceAuditLogController],
|
||||||
|
providers: [AcadeniceAuditLogService],
|
||||||
|
exports: [AcadeniceAuditLogService],
|
||||||
|
})
|
||||||
|
export class AcadeniceAuditLogModule {}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { UserRole } from '../../../../common/helpers/types/permission';
|
||||||
|
import {
|
||||||
|
AcadeniceAuditLogService,
|
||||||
|
AuditLogPage,
|
||||||
|
} from '../services/audit-log.service';
|
||||||
|
import { AuditLogQuerySchema } from '../dto/audit-log-query.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceAuditLogController — R4.5 read-only audit log.
|
||||||
|
*
|
||||||
|
* GET /api/acadenice/audit-log
|
||||||
|
* Auth : JWT (admin or owner only)
|
||||||
|
* Query : limit, offset, userId, action, since (ISO), until (ISO)
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('acadenice/audit-log')
|
||||||
|
export class AcadeniceAuditLogController {
|
||||||
|
constructor(private readonly auditLogService: AcadeniceAuditLogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Query() rawQuery: Record<string, string>,
|
||||||
|
): Promise<AuditLogPage> {
|
||||||
|
if (
|
||||||
|
user.role !== UserRole.ADMIN &&
|
||||||
|
user.role !== (UserRole.OWNER as string)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('audit_log:read permission required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AuditLogQuerySchema.safeParse(rawQuery);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.auditLogService.findAll(workspace.id, parsed.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AuditLogQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
action: z.string().max(100).optional(),
|
||||||
|
since: z.string().datetime({ offset: true }).optional(),
|
||||||
|
until: z.string().datetime({ offset: true }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogQueryDto = z.infer<typeof AuditLogQuerySchema>;
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { AuditLogQueryDto } from '../dto/audit-log-query.dto';
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorType: string;
|
||||||
|
event: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
changes: Record<string, unknown> | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
actorEmail: string | null;
|
||||||
|
actorName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogPage {
|
||||||
|
items: AuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceAuditLogService — read-only access to the `audit` table (R4.5).
|
||||||
|
*
|
||||||
|
* Only admins/owners call this. The controller enforces that; the service
|
||||||
|
* trusts workspaceId comes from the validated JWT.
|
||||||
|
*
|
||||||
|
* Kysely typing note: the DbInterface uses camelCase keys that match the
|
||||||
|
* generated type (e.g. `workspaceId`, not `workspace_id`). Table-qualified
|
||||||
|
* refs that the type system cannot narrow are handled via sql`` tags.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceAuditLogService {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
workspaceId: string,
|
||||||
|
query: AuditLogQueryDto,
|
||||||
|
): Promise<AuditLogPage> {
|
||||||
|
const { limit, offset } = query;
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.queryItems(workspaceId, query),
|
||||||
|
this.queryCount(workspaceId, query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items: rows, total, limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryItems(
|
||||||
|
workspaceId: string,
|
||||||
|
query: AuditLogQueryDto,
|
||||||
|
): Promise<AuditLogEntry[]> {
|
||||||
|
const { limit, offset, userId, action, since, until } = query;
|
||||||
|
|
||||||
|
let q = this.db
|
||||||
|
.selectFrom('audit')
|
||||||
|
.leftJoin('users', 'users.id', 'audit.actorId')
|
||||||
|
.select([
|
||||||
|
'audit.id',
|
||||||
|
'audit.workspaceId',
|
||||||
|
'audit.actorId',
|
||||||
|
'audit.actorType',
|
||||||
|
'audit.event',
|
||||||
|
'audit.resourceType',
|
||||||
|
'audit.resourceId',
|
||||||
|
'audit.spaceId',
|
||||||
|
'audit.changes',
|
||||||
|
'audit.metadata',
|
||||||
|
'audit.ipAddress',
|
||||||
|
'audit.createdAt',
|
||||||
|
'users.email as actorEmail',
|
||||||
|
'users.name as actorName',
|
||||||
|
])
|
||||||
|
.where('audit.workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('audit.createdAt', 'desc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
q = q.where('audit.actorId', '=', userId);
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
q = q.where('audit.event', '=', action);
|
||||||
|
}
|
||||||
|
if (since) {
|
||||||
|
q = q.where('audit.createdAt', '>=', new Date(since));
|
||||||
|
}
|
||||||
|
if (until) {
|
||||||
|
q = q.where('audit.createdAt', '<=', new Date(until));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRows = await q.execute();
|
||||||
|
|
||||||
|
return rawRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
actorId: r.actorId ?? null,
|
||||||
|
actorType: r.actorType,
|
||||||
|
event: r.event,
|
||||||
|
resourceType: r.resourceType,
|
||||||
|
resourceId: r.resourceId ?? null,
|
||||||
|
spaceId: r.spaceId ?? null,
|
||||||
|
changes: r.changes as Record<string, unknown> | null,
|
||||||
|
metadata: r.metadata as Record<string, unknown> | null,
|
||||||
|
ipAddress: r.ipAddress ?? null,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
actorEmail: (r as any).actorEmail ?? null,
|
||||||
|
actorName: (r as any).actorName ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryCount(
|
||||||
|
workspaceId: string,
|
||||||
|
query: AuditLogQueryDto,
|
||||||
|
): Promise<number> {
|
||||||
|
const { userId, action, since, until } = query;
|
||||||
|
|
||||||
|
let q = this.db
|
||||||
|
.selectFrom('audit')
|
||||||
|
.select(sql<string>`count(*)`.as('total'))
|
||||||
|
.where('workspaceId', '=', workspaceId);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
q = q.where('actorId', '=', userId);
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
q = q.where('event', '=', action);
|
||||||
|
}
|
||||||
|
if (since) {
|
||||||
|
q = q.where('createdAt', '>=', new Date(since));
|
||||||
|
}
|
||||||
|
if (until) {
|
||||||
|
q = q.where('createdAt', '<=', new Date(until));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await q.executeTakeFirst();
|
||||||
|
return Number(result?.total ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AcadeniceAuditLogController } from '../controllers/audit-log.controller';
|
||||||
|
import { AcadeniceAuditLogService } from '../services/audit-log.service';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
const WORKSPACE_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
const adminUser: any = {
|
||||||
|
id: '22222222-2222-2222-2222-222222222222',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
const memberUser: any = {
|
||||||
|
id: '33333333-3333-3333-3333-333333333333',
|
||||||
|
role: 'member',
|
||||||
|
};
|
||||||
|
const workspace: any = { id: WORKSPACE_ID };
|
||||||
|
|
||||||
|
const mockPage = { items: [], total: 0, limit: 50, offset: 0 };
|
||||||
|
|
||||||
|
describe('AcadeniceAuditLogController', () => {
|
||||||
|
let controller: AcadeniceAuditLogController;
|
||||||
|
let service: AcadeniceAuditLogService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AcadeniceAuditLogController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AcadeniceAuditLogService,
|
||||||
|
useValue: {
|
||||||
|
findAll: jest.fn().mockResolvedValue(mockPage),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get(AcadeniceAuditLogController);
|
||||||
|
service = module.get(AcadeniceAuditLogService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns audit log page for admin', async () => {
|
||||||
|
const result = await controller.list(adminUser, workspace, {});
|
||||||
|
expect(result).toEqual(mockPage);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns audit log page for owner', async () => {
|
||||||
|
const owner: any = { id: 'aaa', role: 'owner' };
|
||||||
|
const result = await controller.list(owner, workspace, {});
|
||||||
|
expect(result).toEqual(mockPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ForbiddenException for member', async () => {
|
||||||
|
await expect(
|
||||||
|
controller.list(memberUser, workspace, {}),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes parsed query params to service', async () => {
|
||||||
|
await controller.list(adminUser, workspace, {
|
||||||
|
limit: '20',
|
||||||
|
offset: '40',
|
||||||
|
action: 'page.created',
|
||||||
|
});
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(
|
||||||
|
WORKSPACE_ID,
|
||||||
|
expect.objectContaining({ limit: 20, offset: 40, action: 'page.created' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequest for invalid limit', async () => {
|
||||||
|
const { BadRequestException } = await import('@nestjs/common');
|
||||||
|
await expect(
|
||||||
|
controller.list(adminUser, workspace, { limit: '-5' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* AcadeniceAuditLogService unit tests (R4.5).
|
||||||
|
*
|
||||||
|
* Strategy: instantiate the service directly and inject a mock DB object,
|
||||||
|
* bypassing NestJS DI entirely (same pattern as clipper-token.service.spec).
|
||||||
|
*/
|
||||||
|
import { AcadeniceAuditLogService } from '../services/audit-log.service';
|
||||||
|
|
||||||
|
const WORKSPACE_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
const mockEntry = {
|
||||||
|
id: '33333333-3333-3333-3333-333333333333',
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
actorId: USER_ID,
|
||||||
|
actorType: 'user',
|
||||||
|
event: 'page.created',
|
||||||
|
resourceType: 'page',
|
||||||
|
resourceId: '44444444-4444-4444-4444-444444444444',
|
||||||
|
spaceId: null,
|
||||||
|
changes: null,
|
||||||
|
metadata: null,
|
||||||
|
ipAddress: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
actorEmail: 'admin@example.com',
|
||||||
|
actorName: 'Admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeChain(rows: any[], total: number) {
|
||||||
|
const chain: any = {
|
||||||
|
leftJoin: () => chain,
|
||||||
|
select: () => chain,
|
||||||
|
where: () => chain,
|
||||||
|
orderBy: () => chain,
|
||||||
|
limit: () => chain,
|
||||||
|
offset: () => chain,
|
||||||
|
execute: jest.fn().mockResolvedValue(rows),
|
||||||
|
executeTakeFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ total: String(total) }),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AcadeniceAuditLogService', () => {
|
||||||
|
let service: AcadeniceAuditLogService;
|
||||||
|
let chain: any;
|
||||||
|
|
||||||
|
function reset(rows: any[], total: number) {
|
||||||
|
chain = makeChain(rows, total);
|
||||||
|
const mockDb: any = { selectFrom: jest.fn().mockReturnValue(chain) };
|
||||||
|
service = new AcadeniceAuditLogService(mockDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reset([mockEntry], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns items and total for basic query', async () => {
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, { limit: 50, offset: 0 });
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.limit).toBe(50);
|
||||||
|
expect(result.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps actorEmail and actorName from joined user row', async () => {
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, { limit: 50, offset: 0 });
|
||||||
|
expect(result.items[0].actorEmail).toBe('admin@example.com');
|
||||||
|
expect(result.items[0].actorName).toBe('Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null actor fields gracefully', async () => {
|
||||||
|
const noActor = { ...mockEntry, actorId: null, actorEmail: null, actorName: null };
|
||||||
|
reset([noActor], 1);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, { limit: 50, offset: 0 });
|
||||||
|
expect(result.items[0].actorId).toBeNull();
|
||||||
|
expect(result.items[0].actorEmail).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by userId when provided', async () => {
|
||||||
|
reset([mockEntry], 1);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
userId: USER_ID,
|
||||||
|
});
|
||||||
|
expect(result.items).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by action when provided', async () => {
|
||||||
|
reset([mockEntry], 1);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
action: 'page.created',
|
||||||
|
});
|
||||||
|
expect(result.items[0].event).toBe('page.created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by since date when provided', async () => {
|
||||||
|
reset([mockEntry], 1);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
since: '2026-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by until date when provided', async () => {
|
||||||
|
reset([mockEntry], 1);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
until: '2026-12-31T23:59:59Z',
|
||||||
|
});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty result set', async () => {
|
||||||
|
reset([], 0);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, { limit: 50, offset: 0 });
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects limit and offset in returned page', async () => {
|
||||||
|
reset([mockEntry], 100);
|
||||||
|
const result = await service.findAll(WORKSPACE_ID, { limit: 25, offset: 50 });
|
||||||
|
expect(result.limit).toBe(25);
|
||||||
|
expect(result.offset).toBe(50);
|
||||||
|
expect(result.total).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -67,6 +67,8 @@ export const PERMISSION_KEYS = [
|
||||||
'sync_blocks:delete',
|
'sync_blocks:delete',
|
||||||
// Acadenice R4.3 — Web Clipper
|
// Acadenice R4.3 — Web Clipper
|
||||||
'clipper:use',
|
'clipper:use',
|
||||||
|
// Acadenice R4.5 — Audit log read (admin-only by default)
|
||||||
|
'audit_log:read',
|
||||||
'admin:*',
|
'admin:*',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -257,6 +259,13 @@ export const PERMISSIONS_CATALOG: ReadonlyArray<PermissionDescriptor> = [
|
||||||
group: 'clipper',
|
group: 'clipper',
|
||||||
description: 'Use the Web Clipper to create pages via API token',
|
description: 'Use the Web Clipper to create pages via API token',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// R4.5 — Audit log (read-only, assigned to admin role)
|
||||||
|
key: 'audit_log:read',
|
||||||
|
group: 'audit',
|
||||||
|
description:
|
||||||
|
'Read workspace audit log entries (events, actor, resource, timestamp)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'admin:*',
|
key: 'admin:*',
|
||||||
group: 'meta',
|
group: 'meta',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import { UserRole } from '../../../../common/helpers/types/permission';
|
||||||
|
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
|
||||||
|
|
||||||
|
export interface OidcStatusResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
providerName: string | null;
|
||||||
|
issuer: string | null;
|
||||||
|
scopes: string | null;
|
||||||
|
redirectUri: string | null;
|
||||||
|
loginUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceOidcStatusController — R4.5 read-only OIDC status for admins.
|
||||||
|
*
|
||||||
|
* GET /api/acadenice/security/oidc-status
|
||||||
|
* Auth : JWT (admin or owner only)
|
||||||
|
* Returns OIDC configuration derived from env vars — no secrets exposed.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('acadenice/security')
|
||||||
|
export class AcadeniceOidcStatusController {
|
||||||
|
constructor(private readonly env: EnvironmentService) {}
|
||||||
|
|
||||||
|
@Get('oidc-status')
|
||||||
|
oidcStatus(@AuthUser() user: User): OidcStatusResponse {
|
||||||
|
if (
|
||||||
|
user.role !== UserRole.ADMIN &&
|
||||||
|
user.role !== (UserRole.OWNER as string)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = this.env.isOidcEnabled();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
providerName: enabled ? this.env.getOidcProviderName() : null,
|
||||||
|
issuer: enabled ? this.env.getOidcIssuer() : null,
|
||||||
|
scopes: enabled ? this.env.getOidcScopes() : null,
|
||||||
|
redirectUri: enabled ? this.env.getOidcRedirectUri() : null,
|
||||||
|
loginUrl: enabled ? '/api/auth/oidc/login' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/core/acadenice/security/security.module.ts
Normal file
13
apps/server/src/core/acadenice/security/security.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AcadeniceOidcStatusController } from './controllers/oidc-status.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeniceSecurityModule — R4.5.
|
||||||
|
*
|
||||||
|
* Exposes GET /api/acadenice/security/oidc-status (admin/owner only).
|
||||||
|
* Returns OIDC configuration from environment — never exposes client_secret.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [AcadeniceOidcStatusController],
|
||||||
|
})
|
||||||
|
export class AcadeniceSecurityModule {}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AcadeniceOidcStatusController } from '../controllers/oidc-status.controller';
|
||||||
|
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const adminUser: any = { id: 'aaa', role: 'admin' };
|
||||||
|
const ownerUser: any = { id: 'bbb', role: 'owner' };
|
||||||
|
const memberUser: any = { id: 'ccc', role: 'member' };
|
||||||
|
|
||||||
|
describe('AcadeniceOidcStatusController', () => {
|
||||||
|
let controller: AcadeniceOidcStatusController;
|
||||||
|
let envService: jest.Mocked<Partial<EnvironmentService>>;
|
||||||
|
|
||||||
|
function buildModule(oidcEnabled: boolean) {
|
||||||
|
envService = {
|
||||||
|
isOidcEnabled: jest.fn().mockReturnValue(oidcEnabled),
|
||||||
|
getOidcProviderName: jest.fn().mockReturnValue('Authentik'),
|
||||||
|
getOidcIssuer: jest.fn().mockReturnValue('https://auth.example.com'),
|
||||||
|
getOidcScopes: jest.fn().mockReturnValue('openid email profile'),
|
||||||
|
getOidcRedirectUri: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('https://app.example.com/api/auth/oidc/callback'),
|
||||||
|
};
|
||||||
|
return Test.createTestingModule({
|
||||||
|
controllers: [AcadeniceOidcStatusController],
|
||||||
|
providers: [{ provide: EnvironmentService, useValue: envService }],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns enabled status with details when OIDC is on', async () => {
|
||||||
|
const module: TestingModule = await buildModule(true);
|
||||||
|
controller = module.get(AcadeniceOidcStatusController);
|
||||||
|
|
||||||
|
const result = controller.oidcStatus(adminUser);
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.providerName).toBe('Authentik');
|
||||||
|
expect(result.issuer).toBe('https://auth.example.com');
|
||||||
|
expect(result.loginUrl).toBe('/api/auth/oidc/login');
|
||||||
|
expect(result.scopes).toContain('openid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns disabled status with nulls when OIDC is off', async () => {
|
||||||
|
const module: TestingModule = await buildModule(false);
|
||||||
|
controller = module.get(AcadeniceOidcStatusController);
|
||||||
|
|
||||||
|
const result = controller.oidcStatus(adminUser);
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(result.providerName).toBeNull();
|
||||||
|
expect(result.issuer).toBeNull();
|
||||||
|
expect(result.loginUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owner access', async () => {
|
||||||
|
const module: TestingModule = await buildModule(true);
|
||||||
|
controller = module.get(AcadeniceOidcStatusController);
|
||||||
|
|
||||||
|
expect(() => controller.oidcStatus(ownerUser)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ForbiddenException for member', async () => {
|
||||||
|
const module: TestingModule = await buildModule(true);
|
||||||
|
controller = module.get(AcadeniceOidcStatusController);
|
||||||
|
|
||||||
|
expect(() => controller.oidcStatus(memberUser)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never exposes client_secret in response', async () => {
|
||||||
|
const module: TestingModule = await buildModule(true);
|
||||||
|
controller = module.get(AcadeniceOidcStatusController);
|
||||||
|
|
||||||
|
const result = controller.oidcStatus(adminUser) as any;
|
||||||
|
expect(result.clientSecret).toBeUndefined();
|
||||||
|
expect(result.secret).toBeUndefined();
|
||||||
|
expect(JSON.stringify(result)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,6 +40,10 @@ import { AcadeniceCommentsModule } from './acadenice/comments/comments.module';
|
||||||
import { AcadeniceSyncBlocksModule } from './acadenice/sync-blocks/sync-blocks.module';
|
import { AcadeniceSyncBlocksModule } from './acadenice/sync-blocks/sync-blocks.module';
|
||||||
// Acadenice R4.3 — Web Clipper
|
// Acadenice R4.3 — Web Clipper
|
||||||
import { AcadeniceClipperModule } from './acadenice/clipper/clipper.module';
|
import { AcadeniceClipperModule } from './acadenice/clipper/clipper.module';
|
||||||
|
// Acadenice R4.5 — Audit log, API keys, OIDC status (EE replacement UI)
|
||||||
|
import { AcadeniceAuditLogModule } from './acadenice/audit-log/audit-log.module';
|
||||||
|
import { AcadeniceApiKeysModule } from './acadenice/api-keys/api-keys.module';
|
||||||
|
import { AcadeniceSecurityModule } from './acadenice/security/security.module';
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -70,6 +74,9 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||||
AcadeniceCommentsModule,
|
AcadeniceCommentsModule,
|
||||||
AcadeniceSyncBlocksModule,
|
AcadeniceSyncBlocksModule,
|
||||||
AcadeniceClipperModule,
|
AcadeniceClipperModule,
|
||||||
|
AcadeniceAuditLogModule,
|
||||||
|
AcadeniceApiKeysModule,
|
||||||
|
AcadeniceSecurityModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcadeDoc personal API key table (R4.5).
|
||||||
|
*
|
||||||
|
* We deliberately use a dedicated table rather than patching the upstream
|
||||||
|
* `api_keys` table so this migration is additive and rebases cleanly.
|
||||||
|
*
|
||||||
|
* token_hash : bcrypt(plain, 10) — plaintext is returned once at creation.
|
||||||
|
* expires_at : NULL = never expires.
|
||||||
|
* last_used_at: bumped on each successful auth check (best-effort, not awaited).
|
||||||
|
*
|
||||||
|
* Idempotent: ifNotExists on every DDL statement.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('acadenice_api_key')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||||
|
)
|
||||||
|
.addColumn('user_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('users.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('token_hash', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('label', 'varchar(120)', (col) => col.notNull())
|
||||||
|
.addColumn('last_used_at', 'timestamptz')
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('expires_at', 'timestamptz')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_api_key_user')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('acadenice_api_key')
|
||||||
|
.columns(['user_id', 'workspace_id'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_api_key_hash')
|
||||||
|
.ifNotExists()
|
||||||
|
.unique()
|
||||||
|
.on('acadenice_api_key')
|
||||||
|
.column('token_hash')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('acadenice_api_key').ifExists().execute();
|
||||||
|
}
|
||||||
130
docs/security.md
Normal file
130
docs/security.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Security — AcadeDoc
|
||||||
|
|
||||||
|
AcadeDoc security is configured server-side via environment variables. This document covers OIDC single sign-on, personal API keys, and the audit log.
|
||||||
|
|
||||||
|
## OIDC Single Sign-On
|
||||||
|
|
||||||
|
OIDC lets users log in via an external identity provider (Authentik, Keycloak, Auth0, etc.) using the OpenID Connect protocol.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `OIDC_ENABLED` | yes | `false` | Set to `true` to activate OIDC |
|
||||||
|
| `OIDC_ISSUER` | yes | — | Provider discovery URL (e.g. `https://auth.example.com/realms/myrealm`) |
|
||||||
|
| `OIDC_CLIENT_ID` | yes | — | OAuth2 client ID registered at the provider |
|
||||||
|
| `OIDC_CLIENT_SECRET` | yes | — | OAuth2 client secret (not returned by the status API — server-side only) |
|
||||||
|
| `OIDC_REDIRECT_URI` | no | auto | Callback URL. Auto-detected if omitted: `$APP_URL/api/auth/oidc/callback` |
|
||||||
|
| `OIDC_SCOPES` | no | `openid email profile` | Space-separated OAuth2 scopes |
|
||||||
|
| `OIDC_PROVIDER_NAME` | no | `SSO` | Label shown on the login button |
|
||||||
|
| `OIDC_AUTO_PROVISION` | no | `false` | Auto-create user account on first OIDC login |
|
||||||
|
| `OIDC_DEFAULT_WORKSPACE_ID` | no | — | Auto-join workspace UUID on provisioning |
|
||||||
|
|
||||||
|
### Authentik example
|
||||||
|
|
||||||
|
1. Create an OAuth2/OIDC provider in Authentik with grant type `Authorization Code`.
|
||||||
|
2. Set the redirect URI to `https://yourdomain.com/api/auth/oidc/callback`.
|
||||||
|
3. Copy the client ID and secret from the provider page.
|
||||||
|
|
||||||
|
```env
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://authentik.example.com/application/o/acadedoc/
|
||||||
|
OIDC_CLIENT_ID=acadedoc
|
||||||
|
OIDC_CLIENT_SECRET=<secret>
|
||||||
|
OIDC_PROVIDER_NAME=Authentik
|
||||||
|
OIDC_AUTO_PROVISION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keycloak example
|
||||||
|
|
||||||
|
```env
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||||
|
OIDC_CLIENT_ID=acadedoc
|
||||||
|
OIDC_CLIENT_SECRET=<secret>
|
||||||
|
OIDC_PROVIDER_NAME=Keycloak
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth0 example
|
||||||
|
|
||||||
|
```env
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://your-tenant.auth0.com/
|
||||||
|
OIDC_CLIENT_ID=<client_id>
|
||||||
|
OIDC_CLIENT_SECRET=<client_secret>
|
||||||
|
OIDC_PROVIDER_NAME=Auth0
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### PKCE
|
||||||
|
|
||||||
|
AcadeDoc uses PKCE (Proof Key for Code Exchange, RFC 7636) by default. The code verifier is stored in a short-lived signed cookie and cleared immediately after the callback is consumed. No additional configuration is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Personal API Keys
|
||||||
|
|
||||||
|
Personal API keys allow programmatic access to the AcadeDoc API. Each key is scoped to the user account that created it.
|
||||||
|
|
||||||
|
### Token format
|
||||||
|
|
||||||
|
Tokens are prefixed with `acdk_` followed by 64 random hex characters. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
acdk_8f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- [CLAIM L1] Only a bcrypt hash is stored in the database (source: `apps/server/src/core/acadenice/api-keys/services/api-key.service.ts` — `bcrypt.hash(plain, BCRYPT_ROUNDS)`). The plaintext is shown once at creation and not retained server-side.
|
||||||
|
- Tokens are not included in server logs by design. Verify your log aggregator does not capture request bodies.
|
||||||
|
- The `acdk_` prefix allows ops to identify and redact tokens in log pipelines before forwarding.
|
||||||
|
|
||||||
|
### Best practices
|
||||||
|
|
||||||
|
- Rotate tokens every 90 days.
|
||||||
|
- Use short-lived tokens (30 days) for CI/CD pipelines.
|
||||||
|
- Do not commit tokens to source control. Use a secret manager (Vault, Doppler, GitHub Secrets).
|
||||||
|
- Use separate tokens per application so you can revoke one without disrupting others.
|
||||||
|
- Set `expiresAt` for all tokens except trusted long-term integrations.
|
||||||
|
|
||||||
|
### RGPD / GDPR
|
||||||
|
|
||||||
|
Tokens grant full account access. When a user is deleted, all their tokens are cascade-deleted via the `ON DELETE CASCADE` foreign key on `acadenice_api_key.user_id`. No manual cleanup is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Log
|
||||||
|
|
||||||
|
The audit log records workspace events for compliance and incident investigation.
|
||||||
|
|
||||||
|
### What is logged
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `page.created` | Page creation |
|
||||||
|
| `page.updated` | Page edit |
|
||||||
|
| `page.deleted` | Page deletion |
|
||||||
|
| `space.created` | Space creation |
|
||||||
|
| `space.deleted` | Space deletion |
|
||||||
|
| `user.invited` | Invitation sent |
|
||||||
|
| `user.deleted` | Member removed |
|
||||||
|
| `workspace.updated` | Workspace settings changed |
|
||||||
|
|
||||||
|
Each entry records: timestamp, actor (user email), event type, resource type and ID, IP address, and a JSONB diff of changes.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
By default, audit entries are kept indefinitely. For GDPR compliance, configure a retention policy by purging rows older than your legal requirement:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Example: delete entries older than 365 days
|
||||||
|
DELETE FROM audit WHERE created_at < now() - interval '365 days';
|
||||||
|
```
|
||||||
|
|
||||||
|
A cron job or `pg_cron` task is recommended for automated purging.
|
||||||
|
|
||||||
|
### Access control
|
||||||
|
|
||||||
|
Only workspace admins and owners can read the audit log (`audit_log:read` permission).
|
||||||
Loading…
Add table
Reference in a new issue