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:
Corentin JOGUET 2026-05-08 12:24:00 +02:00
parent 38f7d73e85
commit 5b512e6324
37 changed files with 2382 additions and 9 deletions

View file

@ -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(),
});
};

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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",
});
},
});
}

View file

@ -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}`);
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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,
});
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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>
</>
);
}

View file

@ -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,
});
}

View file

@ -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;
}

View file

@ -0,0 +1,8 @@
export interface OidcStatusResponse {
enabled: boolean;
providerName: string | null;
issuer: string | null;
scopes: string | null;
redirectUri: string | null;
loginUrl: string | null;
}

View 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 {}

View file

@ -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);
}
}

View 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>;

View file

@ -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');
}
}

View file

@ -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);
});
});

View file

@ -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');
});
});
});

View 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 {}

View file

@ -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);
}
}

View file

@ -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>;

View file

@ -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);
}
}

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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',

View file

@ -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,
};
}
}

View 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 {}

View file

@ -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');
});
});

View file

@ -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 {

View file

@ -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
View 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).