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,
|
||||
getBillingPlans,
|
||||
} 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 { getGroups } from "@/features/group/services/group-service.ts";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
|
|
@ -106,3 +108,18 @@ export const prefetchScimTokens = () => {
|
|||
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,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
prefetchAcadeniceAuditLogs,
|
||||
prefetchAcadeniceApiKeys,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
|
@ -85,10 +87,10 @@ const groupedData: DataGroup[] = [
|
|||
path: "/settings/notifications",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.5 — open source API keys (replaces EE-gated page)
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
path: "/settings/acadenice/api-keys",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.3 — Web Clipper token management
|
||||
|
|
@ -118,10 +120,10 @@ const groupedData: DataGroup[] = [
|
|||
env: "cloud",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.5 — open source security/OIDC status (replaces EE-gated page)
|
||||
label: "Security & SSO",
|
||||
icon: IconLock,
|
||||
path: "/settings/security",
|
||||
feature: Feature.SECURITY_SETTINGS,
|
||||
path: "/settings/acadenice/security",
|
||||
role: "admin",
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
|
|
@ -153,6 +155,8 @@ const groupedData: DataGroup[] = [
|
|||
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",
|
||||
icon: IconKey,
|
||||
path: "/settings/api-keys",
|
||||
|
|
@ -166,11 +170,11 @@ const groupedData: DataGroup[] = [
|
|||
role: "admin",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.5 — open source audit log (replaces EE-gated page)
|
||||
label: "Audit log",
|
||||
icon: IconHistory,
|
||||
path: "/settings/audit",
|
||||
feature: Feature.AUDIT_LOGS,
|
||||
role: "owner",
|
||||
path: "/settings/acadenice/audit-log",
|
||||
role: "admin",
|
||||
env: "selfhosted",
|
||||
},
|
||||
],
|
||||
|
|
@ -264,13 +268,15 @@ export default function SettingsSidebar() {
|
|||
prefetchHandler = prefetchShares;
|
||||
break;
|
||||
case "API keys":
|
||||
prefetchHandler = prefetchApiKeys;
|
||||
// Acadenice R4.5: points to open source endpoint
|
||||
prefetchHandler = prefetchAcadeniceApiKeys;
|
||||
break;
|
||||
case "API management":
|
||||
prefetchHandler = prefetchApiKeyManagement;
|
||||
break;
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
// Acadenice R4.5: points to open source endpoint
|
||||
prefetchHandler = prefetchAcadeniceAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
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',
|
||||
// Acadenice R4.3 — Web Clipper
|
||||
'clipper:use',
|
||||
// Acadenice R4.5 — Audit log read (admin-only by default)
|
||||
'audit_log:read',
|
||||
'admin:*',
|
||||
] as const;
|
||||
|
||||
|
|
@ -257,6 +259,13 @@ export const PERMISSIONS_CATALOG: ReadonlyArray<PermissionDescriptor> = [
|
|||
group: 'clipper',
|
||||
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:*',
|
||||
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';
|
||||
// Acadenice R4.3 — Web Clipper
|
||||
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';
|
||||
|
||||
@Module({
|
||||
|
|
@ -70,6 +74,9 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||
AcadeniceCommentsModule,
|
||||
AcadeniceSyncBlocksModule,
|
||||
AcadeniceClipperModule,
|
||||
AcadeniceAuditLogModule,
|
||||
AcadeniceApiKeysModule,
|
||||
AcadeniceSecurityModule,
|
||||
],
|
||||
})
|
||||
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