From 5b512e63240eeeea25a9a8c361f8b85f75240f5f Mon Sep 17 00:00:00 2001 From: Corentin Date: Fri, 8 May 2026 12:24:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(acadedoc):=20replace=20EE=20Settings=20wit?= =?UTF-8?q?h=20open=20source=20UI=20(audit=20log,=20API=20keys,=20OIDC=20s?= =?UTF-8?q?tatus)=20=E2=80=94=20R4.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/settings/settings-queries.tsx | 17 ++ .../components/settings/settings-sidebar.tsx | 24 ++- .../components/create-api-key-modal.tsx | 110 +++++++++++ .../components/revoke-api-key-modal.tsx | 50 +++++ .../components/token-created-modal.tsx | 76 +++++++ .../api-keys/pages/api-keys-page.tsx | 181 +++++++++++++++++ .../api-keys/queries/api-key.queries.ts | 60 ++++++ .../api-keys/services/api-key.service.ts | 22 +++ .../acadenice/api-keys/types/api-key.types.ts | 19 ++ .../audit-log/components/audit-log-table.tsx | 104 ++++++++++ .../audit-log/pages/audit-log-page.tsx | 186 ++++++++++++++++++ .../audit-log/queries/audit-log.queries.ts | 11 ++ .../audit-log/services/audit-log.service.ts | 12 ++ .../audit-log/types/audit-log.types.ts | 32 +++ .../oidc-status/pages/security-page.tsx | 154 +++++++++++++++ .../queries/oidc-status.queries.ts | 9 + .../services/oidc-status.service.ts | 7 + .../oidc-status/types/oidc-status.types.ts | 8 + .../acadenice/api-keys/api-keys.module.ts | 21 ++ .../controllers/api-key.controller.ts | 75 +++++++ .../acadenice/api-keys/dto/api-key.dto.ts | 13 ++ .../api-keys/services/api-key.service.ts | 162 +++++++++++++++ .../api-keys/spec/api-key.controller.spec.ts | 91 +++++++++ .../api-keys/spec/api-key.service.spec.ts | 148 ++++++++++++++ .../acadenice/audit-log/audit-log.module.ts | 17 ++ .../controllers/audit-log.controller.ts | 52 +++++ .../audit-log/dto/audit-log-query.dto.ts | 12 ++ .../audit-log/services/audit-log.service.ts | 149 ++++++++++++++ .../spec/audit-log.controller.spec.ts | 84 ++++++++ .../audit-log/spec/audit-log.service.spec.ts | 135 +++++++++++++ .../acadenice/rbac/permissions-catalog.ts | 9 + .../controllers/oidc-status.controller.ts | 54 +++++ .../acadenice/security/security.module.ts | 13 ++ .../spec/oidc-status.controller.spec.ts | 82 ++++++++ apps/server/src/core/core.module.ts | 7 + ...0260510T100000-create-acadenice-api-key.ts | 55 ++++++ docs/security.md | 130 ++++++++++++ 37 files changed, 2382 insertions(+), 9 deletions(-) create mode 100644 apps/client/src/features/acadenice/api-keys/components/create-api-key-modal.tsx create mode 100644 apps/client/src/features/acadenice/api-keys/components/revoke-api-key-modal.tsx create mode 100644 apps/client/src/features/acadenice/api-keys/components/token-created-modal.tsx create mode 100644 apps/client/src/features/acadenice/api-keys/pages/api-keys-page.tsx create mode 100644 apps/client/src/features/acadenice/api-keys/queries/api-key.queries.ts create mode 100644 apps/client/src/features/acadenice/api-keys/services/api-key.service.ts create mode 100644 apps/client/src/features/acadenice/api-keys/types/api-key.types.ts create mode 100644 apps/client/src/features/acadenice/audit-log/components/audit-log-table.tsx create mode 100644 apps/client/src/features/acadenice/audit-log/pages/audit-log-page.tsx create mode 100644 apps/client/src/features/acadenice/audit-log/queries/audit-log.queries.ts create mode 100644 apps/client/src/features/acadenice/audit-log/services/audit-log.service.ts create mode 100644 apps/client/src/features/acadenice/audit-log/types/audit-log.types.ts create mode 100644 apps/client/src/features/acadenice/oidc-status/pages/security-page.tsx create mode 100644 apps/client/src/features/acadenice/oidc-status/queries/oidc-status.queries.ts create mode 100644 apps/client/src/features/acadenice/oidc-status/services/oidc-status.service.ts create mode 100644 apps/client/src/features/acadenice/oidc-status/types/oidc-status.types.ts create mode 100644 apps/server/src/core/acadenice/api-keys/api-keys.module.ts create mode 100644 apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts create mode 100644 apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts create mode 100644 apps/server/src/core/acadenice/api-keys/services/api-key.service.ts create mode 100644 apps/server/src/core/acadenice/api-keys/spec/api-key.controller.spec.ts create mode 100644 apps/server/src/core/acadenice/api-keys/spec/api-key.service.spec.ts create mode 100644 apps/server/src/core/acadenice/audit-log/audit-log.module.ts create mode 100644 apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts create mode 100644 apps/server/src/core/acadenice/audit-log/dto/audit-log-query.dto.ts create mode 100644 apps/server/src/core/acadenice/audit-log/services/audit-log.service.ts create mode 100644 apps/server/src/core/acadenice/audit-log/spec/audit-log.controller.spec.ts create mode 100644 apps/server/src/core/acadenice/audit-log/spec/audit-log.service.spec.ts create mode 100644 apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts create mode 100644 apps/server/src/core/acadenice/security/security.module.ts create mode 100644 apps/server/src/core/acadenice/security/spec/oidc-status.controller.spec.ts create mode 100644 apps/server/src/database/migrations/20260510T100000-create-acadenice-api-key.ts create mode 100644 docs/security.md diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 892857f5..5c02d0d0 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -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(), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1bb079d4..747edfc1 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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; diff --git a/apps/client/src/features/acadenice/api-keys/components/create-api-key-modal.tsx b/apps/client/src/features/acadenice/api-keys/components/create-api-key-modal.tsx new file mode 100644 index 00000000..821affed --- /dev/null +++ b/apps/client/src/features/acadenice/api-keys/components/create-api-key-modal.tsx @@ -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("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 ( + + + } + color="yellow" + variant="light" + > + {t( + "This token grants full access to your account. Store it securely and never share it.", + )} + + + setLabel(e.currentTarget.value)} + required + aria-label={t("API key label")} + /> + + { + setAction(v); + setOffset(0); + }} + clearable + searchable + w={220} + size="sm" + aria-label={t("Filter by event type")} + /> + + { + setUserId(e.currentTarget.value); + setOffset(0); + }} + size="sm" + w={260} + aria-label={t("Filter by user ID")} + /> + + { + setSince(v); + setOffset(0); + }} + clearable + size="sm" + w={160} + aria-label={t("Filter since date")} + /> + + { + setUntil(v); + setOffset(0); + }} + clearable + size="sm" + w={160} + aria-label={t("Filter until date")} + /> + + + + + {data && ( + + {t("{{total}} entries", { total: data.total })} + + )} + + + + + {totalPages > 1 && ( + + + + {currentPage} / {totalPages} + + + + )} + + ); +} diff --git a/apps/client/src/features/acadenice/audit-log/queries/audit-log.queries.ts b/apps/client/src/features/acadenice/audit-log/queries/audit-log.queries.ts new file mode 100644 index 00000000..d076b14e --- /dev/null +++ b/apps/client/src/features/acadenice/audit-log/queries/audit-log.queries.ts @@ -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, + }); +} diff --git a/apps/client/src/features/acadenice/audit-log/services/audit-log.service.ts b/apps/client/src/features/acadenice/audit-log/services/audit-log.service.ts new file mode 100644 index 00000000..3c8480a6 --- /dev/null +++ b/apps/client/src/features/acadenice/audit-log/services/audit-log.service.ts @@ -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 { + const resp = await api.get("/acadenice/audit-log", { params }); + return resp.data; +} diff --git a/apps/client/src/features/acadenice/audit-log/types/audit-log.types.ts b/apps/client/src/features/acadenice/audit-log/types/audit-log.types.ts new file mode 100644 index 00000000..04f89b21 --- /dev/null +++ b/apps/client/src/features/acadenice/audit-log/types/audit-log.types.ts @@ -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 | null; + metadata: Record | 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; +} diff --git a/apps/client/src/features/acadenice/oidc-status/pages/security-page.tsx b/apps/client/src/features/acadenice/oidc-status/pages/security-page.tsx new file mode 100644 index 00000000..e889137d --- /dev/null +++ b/apps/client/src/features/acadenice/oidc-status/pages/security-page.tsx @@ -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 ( + + {t("You do not have permission to view security settings.")} + + ); + } + + return ( + <> + + + {t("Security")} - {getAppName()} + + + + + + } + color="blue" + variant="light" + mb="lg" + > + {t( + "Security settings are configured server-side via environment variables. Contact your system administrator to modify them.", + )} + + + + <IconShieldCheck size={18} style={{ marginRight: 6, verticalAlign: "middle" }} /> + {t("Single Sign-On (OIDC)")} + + + {isLoading ? ( + {t("Loading...")} + ) : ( + + + {t("Status")}:{" "} + + {oidc?.enabled ? t("Enabled") : t("Disabled")} + + + + {oidc?.enabled && ( + <> + {oidc.providerName && ( + + {t("Provider")}: {oidc.providerName} + + )} + + {oidc.issuer && ( + + {t("Issuer")}: {oidc.issuer} + + )} + + {oidc.scopes && ( + + {t("Scopes")}: {oidc.scopes} + + )} + + {oidc.redirectUri && ( + + {t("Redirect URI")}: {oidc.redirectUri} + + )} + + {oidc.loginUrl && ( + + {t("Login URL")}:{" "} + + {typeof window !== "undefined" + ? window.location.origin + oidc.loginUrl + : oidc.loginUrl} + + + )} + + )} + + )} + + + + + {t("Configuration")} + + + {t( + "OIDC is configured via environment variables on the server. The following variables are supported:", + )} + + + + {[ + { 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 }) => ( + + {key} — {desc} + + ))} + + + + + + <IconLock size={16} style={{ marginRight: 4, verticalAlign: "middle" }} /> + {t("API keys")} + + + {t( + "Personal API keys can be managed from Account > API keys. Rotate them every 90 days. Never commit tokens to source control.", + )} + + + ); +} diff --git a/apps/client/src/features/acadenice/oidc-status/queries/oidc-status.queries.ts b/apps/client/src/features/acadenice/oidc-status/queries/oidc-status.queries.ts new file mode 100644 index 00000000..cbdc05c8 --- /dev/null +++ b/apps/client/src/features/acadenice/oidc-status/queries/oidc-status.queries.ts @@ -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, + }); +} diff --git a/apps/client/src/features/acadenice/oidc-status/services/oidc-status.service.ts b/apps/client/src/features/acadenice/oidc-status/services/oidc-status.service.ts new file mode 100644 index 00000000..c3141d75 --- /dev/null +++ b/apps/client/src/features/acadenice/oidc-status/services/oidc-status.service.ts @@ -0,0 +1,7 @@ +import api from "@/lib/api-client"; +import { OidcStatusResponse } from "../types/oidc-status.types"; + +export async function getOidcStatus(): Promise { + const resp = await api.get("/acadenice/security/oidc-status"); + return resp.data; +} diff --git a/apps/client/src/features/acadenice/oidc-status/types/oidc-status.types.ts b/apps/client/src/features/acadenice/oidc-status/types/oidc-status.types.ts new file mode 100644 index 00000000..037bf243 --- /dev/null +++ b/apps/client/src/features/acadenice/oidc-status/types/oidc-status.types.ts @@ -0,0 +1,8 @@ +export interface OidcStatusResponse { + enabled: boolean; + providerName: string | null; + issuer: string | null; + scopes: string | null; + redirectUri: string | null; + loginUrl: string | null; +} diff --git a/apps/server/src/core/acadenice/api-keys/api-keys.module.ts b/apps/server/src/core/acadenice/api-keys/api-keys.module.ts new file mode 100644 index 00000000..fbb27079 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/api-keys.module.ts @@ -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 {} diff --git a/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts b/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts new file mode 100644 index 00000000..7be177d7 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts @@ -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 { + 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 { + await this.apiKeyService.revoke(tokenId, user.id); + } +} diff --git a/apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts b/apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts new file mode 100644 index 00000000..158cf2f8 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/dto/api-key.dto.ts @@ -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; diff --git a/apps/server/src/core/acadenice/api-keys/services/api-key.service.ts b/apps/server/src/core/acadenice/api-keys/services/api-key.service.ts new file mode 100644 index 00000000..8059c495 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/services/api-key.service.ts @@ -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 { + 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` + 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 { + const result = await sql` + 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 { + 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 { + if (!rawToken.startsWith(TOKEN_PREFIX)) { + throw new UnauthorizedException('Invalid API key format'); + } + + const rows = await sql` + 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'); + } +} diff --git a/apps/server/src/core/acadenice/api-keys/spec/api-key.controller.spec.ts b/apps/server/src/core/acadenice/api-keys/spec/api-key.controller.spec.ts new file mode 100644 index 00000000..87a77fa8 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/spec/api-key.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/acadenice/api-keys/spec/api-key.service.spec.ts b/apps/server/src/core/acadenice/api-keys/spec/api-key.service.spec.ts new file mode 100644 index 00000000..df939e12 --- /dev/null +++ b/apps/server/src/core/acadenice/api-keys/spec/api-key.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/audit-log/audit-log.module.ts b/apps/server/src/core/acadenice/audit-log/audit-log.module.ts new file mode 100644 index 00000000..8998c3e0 --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/audit-log.module.ts @@ -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 {} diff --git a/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts b/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts new file mode 100644 index 00000000..3c1509aa --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts @@ -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, + ): Promise { + 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); + } +} diff --git a/apps/server/src/core/acadenice/audit-log/dto/audit-log-query.dto.ts b/apps/server/src/core/acadenice/audit-log/dto/audit-log-query.dto.ts new file mode 100644 index 00000000..d5c8636d --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/dto/audit-log-query.dto.ts @@ -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; diff --git a/apps/server/src/core/acadenice/audit-log/services/audit-log.service.ts b/apps/server/src/core/acadenice/audit-log/services/audit-log.service.ts new file mode 100644 index 00000000..39e5f1d6 --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/services/audit-log.service.ts @@ -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 | null; + metadata: Record | 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 { + 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 { + 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 | null, + metadata: r.metadata as Record | 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 { + const { userId, action, since, until } = query; + + let q = this.db + .selectFrom('audit') + .select(sql`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); + } +} diff --git a/apps/server/src/core/acadenice/audit-log/spec/audit-log.controller.spec.ts b/apps/server/src/core/acadenice/audit-log/spec/audit-log.controller.spec.ts new file mode 100644 index 00000000..8a07a973 --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/spec/audit-log.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/acadenice/audit-log/spec/audit-log.service.spec.ts b/apps/server/src/core/acadenice/audit-log/spec/audit-log.service.spec.ts new file mode 100644 index 00000000..78e04527 --- /dev/null +++ b/apps/server/src/core/acadenice/audit-log/spec/audit-log.service.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts index 2e64e4cd..120da6ba 100644 --- a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts +++ b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts @@ -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 = [ 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', diff --git a/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts b/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts new file mode 100644 index 00000000..cdeb6248 --- /dev/null +++ b/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts @@ -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, + }; + } +} diff --git a/apps/server/src/core/acadenice/security/security.module.ts b/apps/server/src/core/acadenice/security/security.module.ts new file mode 100644 index 00000000..58e980f8 --- /dev/null +++ b/apps/server/src/core/acadenice/security/security.module.ts @@ -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 {} diff --git a/apps/server/src/core/acadenice/security/spec/oidc-status.controller.spec.ts b/apps/server/src/core/acadenice/security/spec/oidc-status.controller.spec.ts new file mode 100644 index 00000000..5339b5b6 --- /dev/null +++ b/apps/server/src/core/acadenice/security/spec/oidc-status.controller.spec.ts @@ -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>; + + 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'); + }); +}); diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 92a79ccd..20a0f411 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -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 { diff --git a/apps/server/src/database/migrations/20260510T100000-create-acadenice-api-key.ts b/apps/server/src/database/migrations/20260510T100000-create-acadenice-api-key.ts new file mode 100644 index 00000000..1770b452 --- /dev/null +++ b/apps/server/src/database/migrations/20260510T100000-create-acadenice-api-key.ts @@ -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): Promise { + 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): Promise { + await db.schema.dropTable('acadenice_api_key').ifExists().execute(); +} diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..5612c2a7 --- /dev/null +++ b/docs/security.md @@ -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= +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= +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= +OIDC_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).