diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 00dd2b0..c88a544 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -212,6 +212,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/legal.spec.ts + - name: Reset login rate limit before system pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run system pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts + - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/prisma/migrations/20260711180000_audit_log_and_job_duration/migration.sql b/apps/api/prisma/migrations/20260711180000_audit_log_and_job_duration/migration.sql new file mode 100644 index 0000000..81755f3 --- /dev/null +++ b/apps/api/prisma/migrations/20260711180000_audit_log_and_job_duration/migration.sql @@ -0,0 +1,23 @@ +-- Persistent audit trail for the Site-Admin system panel (issue #86) plus +-- the duration of the last completed maintenance-job run. + +ALTER TABLE "jobs" ADD COLUMN "last_duration_ms" INTEGER; + +CREATE TABLE "audit_log" ( + "id" TEXT NOT NULL, + "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "actor_id" TEXT, + "target_type" TEXT, + "target_id" TEXT, + "details" JSONB, + + CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "audit_log_at_idx" ON "audit_log"("at"); +CREATE INDEX "audit_log_actor_id_at_idx" ON "audit_log"("actor_id", "at"); +CREATE INDEX "audit_log_action_at_idx" ON "audit_log"("action", "at"); + +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_fkey" + FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 91c169b..bf58bdf 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,10 +49,36 @@ model User { pages Page[] attachments Attachment[] conversionJobs ConversionJob[] + auditEntries AuditEntry[] @@map("users") } +/// Persistent audit trail (issue #86, security.md §Logging): auth events and +/// admin actions — grants, member roles, plugin installs, quota and settings +/// changes, setup steps, manual job triggers. Written by AuditService, which +/// also keeps emitting the established `audit: …` stdout log line. Content +/// activity (pages, files, exports) stays log-only by design. +model AuditEntry { + id String @id @default(uuid()) + at DateTime @default(now()) + /// Stable dot-namespaced action id, e.g. `grant.created`, `auth.login_failed`. + action String + /// Null for anonymous events (failed login for an unknown user) and after a + /// hard account deletion; pseudonymized accounts keep their id. + actorId String? @map("actor_id") + targetType String? @map("target_type") + targetId String? @map("target_id") + details Json? + + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + + @@index([at]) + @@index([actorId, at]) + @@index([action, at]) + @@map("audit_log") +} + enum PondType { PERSONAL SHARED @@ -515,6 +541,8 @@ model Job { lastRunAt DateTime? @map("last_run_at") lockedAt DateTime? @map("locked_at") lastError String? @map("last_error") + /// Wall-clock time of the last completed run (issue #86 admin panel). + lastDurationMs Int? @map("last_duration_ms") updatedAt DateTime @updatedAt @map("updated_at") @@map("jobs") diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 762f2f7..bcb0115 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -2,18 +2,26 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { QuotasModule } from '../quotas/quotas.module'; +import { SchedulerModule } from '../scheduler/scheduler.module'; import { UsersModule } from '../users/users.module'; import { AdminSettingsController } from './admin.controller'; import { PseudonymizationService } from './pseudonymization.service'; import { QuotaAdminController } from './quota-admin.controller'; +import { SystemAdminController } from './system-admin.controller'; +import { SystemAdminService } from './system-admin.service'; import { QuotaAdminService } from './quota-admin.service'; import { UserAdminController } from './user-admin.controller'; import { UserAdminService } from './user-admin.service'; @Module({ - imports: [QuotasModule, UsersModule, AuthModule], - controllers: [AdminSettingsController, QuotaAdminController, UserAdminController], - providers: [QuotaAdminService, UserAdminService, PseudonymizationService], + imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule], + controllers: [ + AdminSettingsController, + QuotaAdminController, + SystemAdminController, + UserAdminController, + ], + providers: [QuotaAdminService, SystemAdminService, UserAdminService, PseudonymizationService], }) export class AdminModule {} diff --git a/apps/api/src/admin/pseudonymization.service.ts b/apps/api/src/admin/pseudonymization.service.ts index dacdd09..6b39729 100644 --- a/apps/api/src/admin/pseudonymization.service.ts +++ b/apps/api/src/admin/pseudonymization.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared'; import { PinoLogger } from 'nestjs-pino'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -15,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service'; export class PseudonymizationService { constructor( private readonly prisma: PrismaService, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(PseudonymizationService.name); @@ -43,6 +45,10 @@ export class PseudonymizationService { data: { deletedAt: new Date(), deletedBy: userId }, }); }); - this.logger.info({ userId }, 'audit: user pseudonymized (account deleted)'); + await this.audit.record({ + action: 'user.pseudonymized', + targetType: 'user', + targetId: userId, + }); } } diff --git a/apps/api/src/admin/quota-admin.service.ts b/apps/api/src/admin/quota-admin.service.ts index f3afb23..9079b42 100644 --- a/apps/api/src/admin/quota-admin.service.ts +++ b/apps/api/src/admin/quota-admin.service.ts @@ -9,6 +9,7 @@ import { import { User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; import { UsersService } from '../users/users.service'; @@ -27,6 +28,7 @@ export class QuotaAdminService { private readonly prisma: PrismaService, private readonly quotas: QuotaService, private readonly users: UsersService, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(QuotaAdminService.name); @@ -79,7 +81,13 @@ export class QuotaAdminService { create: { subjectType, subjectId: id, quotaKey, value }, update: { value }, }); - this.logger.info({ actor: actor.id, type, id, quotaKey, value }, 'audit: quota override set'); + await this.audit.record({ + action: 'quota.override_set', + actorId: actor.id, + targetType: type, + targetId: id, + details: { quotaKey, value }, + }); return this.subject(type, id); } @@ -94,7 +102,13 @@ export class QuotaAdminService { await this.prisma.quotaOverride.deleteMany({ where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id, quotaKey }, }); - this.logger.info({ actor: actor.id, type, id, quotaKey }, 'audit: quota override cleared'); + await this.audit.record({ + action: 'quota.override_cleared', + actorId: actor.id, + targetType: type, + targetId: id, + details: { quotaKey }, + }); return this.subject(type, id); } diff --git a/apps/api/src/admin/system-admin.controller.ts b/apps/api/src/admin/system-admin.controller.ts new file mode 100644 index 0000000..c31c88d --- /dev/null +++ b/apps/api/src/admin/system-admin.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { + auditListQuerySchema, + type AuditListQuery, + type AuditListView, + type JobTriggerResult, + type StorageOverviewView, + type SystemBackupView, + type SystemJobView, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { SiteAdminGuard } from './site-admin.guard'; +import { SystemAdminService } from './system-admin.service'; + +/** Site-Admin system panel (issue #86): jobs, backup card, audit, storage. */ +@Controller('admin/system') +@UseGuards(SiteAdminGuard) +export class SystemAdminController { + constructor(private readonly system: SystemAdminService) {} + + @Get('jobs') + async jobs(): Promise { + return this.system.jobs(); + } + + @Post('jobs/:name/run') + async triggerJob( + @Param('name') name: string, + @Req() request: AuthedRequest, + ): Promise { + return this.system.triggerJob(request.user!, name); + } + + @Get('backup') + backup(): SystemBackupView { + return this.system.backup(); + } + + @Get('audit') + async audit( + @Query(new ZodValidationPipe(auditListQuerySchema)) query: AuditListQuery, + ): Promise { + return this.system.auditLog(query); + } + + @Get('storage') + async storage(): Promise { + return this.system.storage(); + } +} diff --git a/apps/api/src/admin/system-admin.e2e.db.test.ts b/apps/api/src/admin/system-admin.e2e.db.test.ts new file mode 100644 index 0000000..e6eaf9e --- /dev/null +++ b/apps/api/src/admin/system-admin.e2e.db.test.ts @@ -0,0 +1,258 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { INestApplication } from '@nestjs/common'; +import { + BACKUP_STATUS_FILE, + type AuditListView, + type BackupStatus, + type JobTriggerResult, + type StorageOverviewView, + type SystemBackupView, + type SystemJobView, +} from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { GrantsService } from '../grants/grants.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +const HOUR = 3_600_000; + +function statusFixture(finishedAt: string): BackupStatus { + return { + schemaVersion: 1, + updatedAt: finishedAt, + retentionDays: 7, + lastRun: { + backupId: '20260711-030000', + startedAt: finishedAt, + finishedAt, + durationMs: 1200, + outcome: 'succeeded', + sizes: { dumpBytes: 100, archiveBytes: 200 }, + }, + lastSuccess: { + backupId: '20260711-030000', + finishedAt, + sizes: { dumpBytes: 100, archiveBytes: 200 }, + }, + }; +} + +/** + * Site-Admin system panel end to end (issue #86): registered jobs with + * truthful last-run data, an audit-logged manual trigger, the backup card + * mirroring status.json, the audit viewer finding a grant change by actor, + * and the storage top list — all Site-Admin-only. + */ +describe.skipIf(!hasTestDb)('system admin panel (e2e, issue #86)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let backupsDir: string; + const suffix = uniqueSuffix(); + const password = 'systempanel ist wachsam 1'; + const ids: Record = {}; + const cookies: Record = {}; + const pondIds: string[] = []; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string, siteAdmin: boolean): Promise { + const users = app.get(UsersService); + const username = `sys-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Sys ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + if (siteAdmin) + await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + beforeAll(async () => { + backupsDir = mkdtempSync(join(tmpdir(), 'dorfteich-system-backups-')); + process.env.BACKUPS_DIR = backupsDir; + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + await makeUser('admin', true); + await makeUser('user', false); + }); + + afterAll(async () => { + delete process.env.BACKUPS_DIR; + const all = Object.values(ids); + await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); + await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); + await prisma.session.deleteMany({ where: { userId: { in: all } } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); + await prisma.user.deleteMany({ where: { id: { in: all } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('lists every registered maintenance job', async () => { + const res = await api().get('/api/v1/admin/system/jobs').set('Cookie', cookies.admin!); + expect(res.status).toBe(200); + const jobs = res.body as SystemJobView[]; + const names = jobs.map((job) => job.name); + for (const expected of [ + 'trash-purge', + 'version-thinning', + 'page-compaction', + 'data-export-purge', + ]) { + expect(names).toContain(expected); + } + for (const job of jobs) { + expect(job.cadenceSeconds).toBeGreaterThan(0); + } + }); + + it('manually triggers a job, records truthful run data, and audit-logs itself', async () => { + const res = await api() + .post('/api/v1/admin/system/jobs/trash-purge/run') + .set('Cookie', cookies.admin!) + .expect(201); + const result = res.body as JobTriggerResult; + expect(result.outcome).toBe('succeeded'); + expect(result.job.status).toBe('IDLE'); + expect(result.job.lastRunAt).not.toBeNull(); + expect(result.job.lastDurationMs).not.toBeNull(); + + const audit = await api() + .get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=job.triggered`) + .set('Cookie', cookies.admin!) + .expect(200); + const list = audit.body as AuditListView; + expect(list.total).toBeGreaterThan(0); + expect(list.entries[0]).toMatchObject({ + action: 'job.triggered', + targetType: 'job', + targetId: 'trash-purge', + details: { outcome: 'succeeded' }, + }); + expect(list.entries[0]?.actor?.username).toBe(`sys-admin-${suffix}`); + }); + + it('rejects triggering an unknown job', async () => { + await api() + .post('/api/v1/admin/system/jobs/no-such-job/run') + .set('Cookie', cookies.admin!) + .expect(404); + }); + + it('finds a grant change through the audit viewer, filtered by actor', async () => { + const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } }); + const target = await prisma.user.findUniqueOrThrow({ where: { id: ids.user! } }); + const pond = await prisma.pond.create({ + data: { slug: `sys-pond-${suffix}`, name: 'Sys Pond', type: 'SHARED', ownerId: admin.id }, + }); + pondIds.push(pond.id); + await app.get(GrantsService).createGrant(admin, pond.id, { + subjectType: 'user', + subjectId: target.id, + role: 'editor', + scopeType: 'pond', + scopeId: null, + effect: 'allow', + }); + + const res = await api() + .get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=grant.created`) + .set('Cookie', cookies.admin!) + .expect(200); + const list = res.body as AuditListView; + expect(list.total).toBe(1); + expect(list.entries[0]).toMatchObject({ + action: 'grant.created', + targetType: 'pond', + targetId: pond.id, + details: expect.objectContaining({ role: 'editor', subjectId: target.id }), + }); + + // Filtering by a different actor does not surface it. + const other = await api() + .get(`/api/v1/admin/system/audit?actor=sys-user-${suffix}&action=grant.created`) + .set('Cookie', cookies.admin!) + .expect(200); + expect((other.body as AuditListView).total).toBe(0); + }); + + it('mirrors status.json in the backup card, including staleness', async () => { + const stale = new Date(Date.now() - 40 * HOUR).toISOString(); + writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(stale))); + const staleRes = await api() + .get('/api/v1/admin/system/backup') + .set('Cookie', cookies.admin!) + .expect(200); + const staleView = staleRes.body as SystemBackupView; + expect(staleView).toMatchObject({ available: true, fresh: false, maxAgeHours: 26 }); + expect(staleView.status?.lastSuccess?.backupId).toBe('20260711-030000'); + + const freshAt = new Date(Date.now() - 2 * HOUR).toISOString(); + writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(freshAt))); + const freshRes = await api() + .get('/api/v1/admin/system/backup') + .set('Cookie', cookies.admin!) + .expect(200); + expect((freshRes.body as SystemBackupView).fresh).toBe(true); + }); + + it('lists the largest ponds in the storage overview', async () => { + const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } }); + const big = await prisma.pond.create({ + data: { slug: `sys-big-${suffix}`, name: 'Big Pond', type: 'SHARED', ownerId: admin.id }, + }); + const small = await prisma.pond.create({ + data: { slug: `sys-small-${suffix}`, name: 'Small Pond', type: 'SHARED', ownerId: admin.id }, + }); + pondIds.push(big.id, small.id); + await prisma.pondUsage.create({ data: { pondId: big.id, storageBytesUsed: 5_000_000n } }); + await prisma.pondUsage.create({ data: { pondId: small.id, storageBytesUsed: 1_000n } }); + + const res = await api() + .get('/api/v1/admin/system/storage') + .set('Cookie', cookies.admin!) + .expect(200); + const view = res.body as StorageOverviewView; + const bigIndex = view.ponds.findIndex((p) => p.pondId === big.id); + const smallIndex = view.ponds.findIndex((p) => p.pondId === small.id); + expect(bigIndex).toBeGreaterThanOrEqual(0); + expect(view.ponds[bigIndex]?.storageBytesUsed).toBe(5_000_000); + if (smallIndex >= 0) expect(bigIndex).toBeLessThan(smallIndex); + expect(view.totalBytes).toBeGreaterThanOrEqual(5_000_000); + }); + + it('is Site-Admin-only', async () => { + for (const path of [ + '/api/v1/admin/system/jobs', + '/api/v1/admin/system/backup', + '/api/v1/admin/system/audit', + '/api/v1/admin/system/storage', + ]) { + await api().get(path).set('Cookie', cookies.user!).expect(403); + } + await api() + .post('/api/v1/admin/system/jobs/trash-purge/run') + .set('Cookie', cookies.user!) + .expect(403); + }); +}); diff --git a/apps/api/src/admin/system-admin.service.ts b/apps/api/src/admin/system-admin.service.ts new file mode 100644 index 0000000..d7032aa --- /dev/null +++ b/apps/api/src/admin/system-admin.service.ts @@ -0,0 +1,175 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, User } from '@prisma/client'; +import { + AUDIT_PAGE_SIZE, + BACKUP_FRESH_MAX_AGE_HOURS, + BACKUP_STATUS_FILE, + type AuditListQuery, + type AuditListView, + type BackupStatus, + type JobTriggerResult, + type StorageOverviewView, + type SystemBackupView, + type SystemJobView, +} from '@dorfteich/shared'; + +import { AuditService } from '../audit/audit.service'; +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SchedulerService } from '../scheduler/scheduler.service'; + +/** + * Data behind the Site-Admin "System" panel (issue #86): maintenance jobs + * with truthful last-run data, the backup card mirroring the sidecar's + * status.json, the persistent audit trail, and the per-pond storage top + * list. Reads only — the single write path is the manual job trigger, + * which is itself audit-logged. + */ +@Injectable() +export class SystemAdminService { + constructor( + private readonly prisma: PrismaService, + private readonly scheduler: SchedulerService, + private readonly audit: AuditService, + private readonly config: AppConfig, + ) {} + + /** + * Registered jobs merged with their `jobs` rows: a job that never ran yet + * appears with null last-run data, and a leftover row whose job no longer + * registers in this build is flagged instead of hidden. + */ + async jobs(): Promise { + const definitions = this.scheduler.definitions(); + const rows = await this.prisma.job.findMany(); + const rowsByName = new Map(rows.map((row) => [row.name, row])); + const views: SystemJobView[] = definitions.map((definition) => { + const row = rowsByName.get(definition.name); + rowsByName.delete(definition.name); + return { + name: definition.name, + cadenceSeconds: definition.cadenceSeconds, + status: row?.status ?? 'IDLE', + lastRunAt: row?.lastRunAt?.toISOString() ?? null, + lastDurationMs: row?.lastDurationMs ?? null, + lastError: row?.lastError ?? null, + registered: true, + } satisfies SystemJobView; + }); + for (const row of rowsByName.values()) { + views.push({ + name: row.name, + cadenceSeconds: row.cadenceSeconds, + status: row.status, + lastRunAt: row.lastRunAt?.toISOString() ?? null, + lastDurationMs: row.lastDurationMs ?? null, + lastError: row.lastError ?? null, + registered: false, + }); + } + return views.sort((a, b) => a.name.localeCompare(b.name)); + } + + async triggerJob(actor: User, name: string): Promise { + if (!this.scheduler.definitions().some((job) => job.name === name)) { + throw new NotFoundException(); + } + const outcome = await this.scheduler.runNow(name); + await this.audit.record({ + action: 'job.triggered', + actorId: actor.id, + targetType: 'job', + targetId: name, + details: { outcome }, + }); + const job = (await this.jobs()).find((view) => view.name === name)!; + return { outcome, job }; + } + + /** The backup card mirrors status.json including the freshness verdict. */ + backup(): SystemBackupView { + const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE); + let status: BackupStatus | null = null; + if (existsSync(path)) { + try { + status = JSON.parse(readFileSync(path, 'utf8')) as BackupStatus; + } catch { + status = null; + } + } + const finishedAt = status?.lastSuccess?.finishedAt; + const ageHours = finishedAt + ? (Date.now() - new Date(finishedAt).getTime()) / 3_600_000 + : Number.POSITIVE_INFINITY; + return { + available: status !== null, + fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS, + status, + maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS, + }; + } + + async auditLog(query: AuditListQuery): Promise { + const where: Prisma.AuditEntryWhereInput = {}; + if (query.actor) { + const actor = await this.prisma.user.findUnique({ where: { username: query.actor } }); + // An unknown username matches nothing rather than everything. + where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000'; + } + if (query.action) where.action = { startsWith: query.action }; + if (query.from || query.to) { + where.at = { + ...(query.from ? { gte: query.from } : {}), + ...(query.to ? { lte: query.to } : {}), + }; + } + + const total = await this.prisma.auditEntry.count({ where }); + const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)); + const page = Math.min(query.page, pageCount); + const entries = await this.prisma.auditEntry.findMany({ + where, + orderBy: { at: 'desc' }, + skip: (page - 1) * AUDIT_PAGE_SIZE, + take: AUDIT_PAGE_SIZE, + include: { actor: { select: { id: true, username: true, displayName: true } } }, + }); + return { + entries: entries.map((entry) => ({ + id: entry.id, + at: entry.at.toISOString(), + action: entry.action, + actor: entry.actor, + targetType: entry.targetType, + targetId: entry.targetId, + details: (entry.details as Record | null) ?? null, + })), + page, + pageCount, + total, + }; + } + + async storage(): Promise { + const usages = await this.prisma.pondUsage.findMany({ + where: { pond: { deletedAt: null } }, + orderBy: { storageBytesUsed: 'desc' }, + take: 20, + include: { pond: { select: { name: true, slug: true, type: true } } }, + }); + const totals = await this.prisma.pondUsage.aggregate({ _sum: { storageBytesUsed: true } }); + return { + totalBytes: Number(totals._sum.storageBytesUsed ?? 0n), + ponds: usages.map((usage) => ({ + pondId: usage.pondId, + name: usage.pond.name, + slug: usage.pond.slug, + type: usage.pond.type === 'PERSONAL' ? 'personal' : 'shared', + storageBytesUsed: Number(usage.storageBytesUsed), + })), + }; + } +} diff --git a/apps/api/src/admin/user-admin.service.ts b/apps/api/src/admin/user-admin.service.ts index 4a0f8fd..1f88f8c 100644 --- a/apps/api/src/admin/user-admin.service.ts +++ b/apps/api/src/admin/user-admin.service.ts @@ -9,6 +9,7 @@ import { Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AuthService } from '../auth/auth.service'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { PseudonymizationService } from './pseudonymization.service'; @@ -26,6 +27,7 @@ export class UserAdminService { private readonly prisma: PrismaService, private readonly pseudonymizer: PseudonymizationService, private readonly auth: AuthService, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(UserAdminService.name); @@ -74,7 +76,13 @@ export class UserAdminService { // A disabled user is logged out everywhere; login then blocks with a // distinct message (auth.service: account_disabled). if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } }); - this.logger.info({ actor: actor.id, userId: id, disabled }, 'audit: user disabled toggled'); + await this.audit.record({ + action: 'user.disabled_set', + actorId: actor.id, + targetType: 'user', + targetId: id, + details: { disabled }, + }); return this.viewOf(updated, await this.pondCountOf(id)); } @@ -82,14 +90,24 @@ export class UserAdminService { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException(); await this.auth.resendVerification(user.email); // no-op unless PENDING - this.logger.info({ actor: actor.id, userId: id }, 'audit: verification resent'); + await this.audit.record({ + action: 'user.verification_resent', + actorId: actor.id, + targetType: 'user', + targetId: id, + }); } async deleteUser(actor: User, id: string): Promise { const user = await this.requireOther(actor, id); if (user.isSiteAdmin) await this.assertNotLastSiteAdmin(); await this.pseudonymizer.pseudonymize(id); - this.logger.info({ actor: actor.id, userId: id }, 'audit: user deleted'); + await this.audit.record({ + action: 'user.deleted', + actorId: actor.id, + targetType: 'user', + targetId: id, + }); } async setSiteAdmin(actor: User, id: string, value: boolean): Promise { @@ -99,7 +117,13 @@ export class UserAdminService { where: { id }, data: { isSiteAdmin: value }, }); - this.logger.info({ actor: actor.id, userId: id, isSiteAdmin: value }, 'audit: site-admin set'); + await this.audit.record({ + action: 'user.site_admin_set', + actorId: actor.id, + targetType: 'user', + targetId: id, + details: { isSiteAdmin: value }, + }); return this.viewOf(updated, await this.pondCountOf(id)); } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 202764d..8cc0a18 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,7 @@ import { APP_FILTER } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; import { AdminModule } from './admin/admin.module'; +import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; import { CompactionModule } from './compaction/compaction.module'; @@ -35,6 +36,7 @@ import { VersionsModule } from './versions/versions.module'; imports: [ ConfigModule, PrismaModule, + AuditModule, RateLimitModule, MailModule, SettingsModule, diff --git a/apps/api/src/audit/audit.module.ts b/apps/api/src/audit/audit.module.ts new file mode 100644 index 0000000..4d9e5c0 --- /dev/null +++ b/apps/api/src/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; + +import { AuditService } from './audit.service'; + +/** + * Global because the audit trail cuts across nearly every feature module + * (auth, grants, members, admin, plugins, setup) — like PrismaModule, one + * import list entry per consumer would only add noise. + */ +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts new file mode 100644 index 0000000..dc602ff --- /dev/null +++ b/apps/api/src/audit/audit.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { PrismaService } from '../prisma/prisma.service'; + +export interface AuditEvent { + /** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */ + action: string; + /** The acting user; null/undefined for anonymous events. */ + actorId?: string | null; + targetType?: string; + targetId?: string; + /** Small, structured context — never secrets, tokens, or page content. */ + details?: Record; +} + +/** + * The persistent audit trail (issue #86, security.md §Logging): auth events + * and admin actions land as queryable rows for the Site-Admin viewer AND as + * the established `audit: …` stdout line. Content activity (pages, files, + * exports, labels) intentionally stays log-only — the trail answers "who + * changed access/configuration", not "who edited what". + * + * Recording must never break the audited operation: failures are logged and + * swallowed (the stdout line still fired, so nothing is silently lost). + */ +@Injectable() +export class AuditService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(AuditService.name); + } + + async record(event: AuditEvent): Promise { + const { action, actorId, targetType, targetId, details } = event; + this.logger.info( + { actor: actorId ?? null, targetType, targetId, ...details }, + `audit: ${action}`, + ); + try { + await this.prisma.auditEntry.create({ + data: { + action, + actorId: actorId ?? null, + targetType, + targetId, + details: details ? (details as Prisma.InputJsonObject) : undefined, + }, + }); + } catch (error) { + this.logger.error({ action, err: error }, 'audit entry could not be persisted'); + } + } +} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 104a956..5e12786 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { MailService } from '../mail/mail.service'; import { PondsService } from '../ponds/ponds.service'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { RateLimitService } from '../rate-limit/rate-limit.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; @@ -33,6 +34,7 @@ export class AuthService { private readonly mail: MailService, private readonly ponds: PondsService, private readonly rateLimits: RateLimitService, + private readonly audit: AuditService, private readonly config: AppConfig, private readonly settings: InstanceSettingsService, private readonly logger: PinoLogger, @@ -46,7 +48,7 @@ export class AuthService { } const user = await this.users.createUser(input); await this.sendVerificationMail(user); - this.logger.info({ userId: user.id }, 'audit: user signed up'); + await this.audit.record({ action: 'auth.signup', actorId: user.id }); } async verifyEmail(token: string): Promise { @@ -56,7 +58,7 @@ export class AuthService { if (!user) throw new BadRequestException({ code: 'token_invalid' }); if (user.status === 'PENDING_VERIFICATION') { await this.users.markEmailVerified(userId); - this.logger.info({ userId }, 'audit: e-mail verified'); + await this.audit.record({ action: 'auth.email_verified', actorId: userId }); } // Every verified account owns a personal pond (issue #21). Idempotent, // so re-verification attempts and races cannot create duplicates. @@ -95,7 +97,7 @@ export class AuthService { const passwordOk = user ? await this.users.checkPassword(user.id, password) : false; if (!user || !passwordOk) { // Same generic error for unknown user and wrong password. - this.logger.info({ userId: user?.id ?? null }, 'audit: login failed'); + await this.audit.record({ action: 'auth.login_failed', actorId: user?.id ?? null }); throw new UnauthorizedException({ code: 'login_failed' }); } if (user.status === 'DISABLED') { @@ -108,7 +110,7 @@ export class AuthService { await this.rateLimits.reset('login-account', user.id); const sessionToken = await this.sessions.create(user.id, userAgent); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); - this.logger.info({ userId: user.id }, 'audit: login succeeded'); + await this.audit.record({ action: 'auth.login_succeeded', actorId: user.id }); return { sessionToken, user }; } @@ -134,7 +136,7 @@ export class AuthService { await this.users.setPassword(userId, password); // Whoever held old sessions (possibly an attacker) is logged out. await this.sessions.destroyAllForUser(userId); - this.logger.info({ userId }, 'audit: password reset'); + await this.audit.record({ action: 'auth.password_reset', actorId: userId }); } private async sendVerificationMail(user: User): Promise { diff --git a/apps/api/src/grants/grants.service.ts b/apps/api/src/grants/grants.service.ts index 7b1cfc9..1f62766 100644 --- a/apps/api/src/grants/grants.service.ts +++ b/apps/api/src/grants/grants.service.ts @@ -8,6 +8,7 @@ import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorftei import { Pond, RoleGrant, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; +import { AuditService } from '../audit/audit.service'; import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -28,6 +29,7 @@ export class GrantsService { private readonly prisma: PrismaService, private readonly permissionCache: PondPermissionCache, private readonly accessNotifier: PondAccessNotifier, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(GrantsService.name); @@ -170,11 +172,13 @@ export class GrantsService { data: { pondId, createdBy: user.id, ...columns }, }); await this.accessChanged(pondId); - this.logger.info( - { + await this.audit.record({ + action: 'grant.created', + actorId: user.id, + targetType: 'pond', + targetId: pondId, + details: { grantId: created.id, - pondId, - userId: user.id, subject: grant.subjectType, subjectId: grant.subjectId, role: grant.role, @@ -182,8 +186,7 @@ export class GrantsService { scopeId: grant.scopeId, effect: grant.effect, }, - 'audit: grant created', - ); + }); return GrantsService.viewOf(created); } @@ -205,10 +208,13 @@ export class GrantsService { await this.prisma.roleGrant.delete({ where: { id: grantId } }); await this.accessChanged(pondId); - this.logger.info( - { grantId, pondId, userId: user.id, subjectId: grant.subjectId, role: grant.role }, - 'audit: grant deleted', - ); + await this.audit.record({ + action: 'grant.deleted', + actorId: user.id, + targetType: 'pond', + targetId: pondId, + details: { grantId, subjectId: grant.subjectId, role: grant.role }, + }); } /** Revoked/added permission takes effect immediately: drop the cached pond diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index 840caad..a9ca9a4 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -20,6 +20,7 @@ import { toGrant, toGrantColumns } from '../grants/grant-mappers'; import { PermissionService } from '../permissions/permission.service'; import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService, quotaExceeded } from '../quotas/quota.service'; import { UsersService } from '../users/users.service'; @@ -45,6 +46,7 @@ export class MembersService { private readonly permissions: PermissionService, private readonly permissionCache: PondPermissionCache, private readonly accessNotifier: PondAccessNotifier, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(MembersService.name); @@ -158,10 +160,13 @@ export class MembersService { }); await this.accessChanged(pondId); - this.logger.info( - { pondId, actor: actor.id, member: target.id, role: input.role }, - 'audit: member added', - ); + await this.audit.record({ + action: 'member.added', + actorId: actor.id, + targetType: 'pond', + targetId: pondId, + details: { member: target.id, role: input.role }, + }); return this.viewOf(target, input.role, pond); } @@ -208,10 +213,13 @@ export class MembersService { if (changed) { await this.accessChanged(pondId); - this.logger.info( - { pondId, actor: actor.id, member: memberUserId, role: input.role }, - 'audit: member role changed', - ); + await this.audit.record({ + action: 'member.role_changed', + actorId: actor.id, + targetType: 'pond', + targetId: pondId, + details: { member: memberUserId, role: input.role }, + }); } return this.viewOf(target, input.role, pond); } @@ -234,7 +242,13 @@ export class MembersService { }); await this.accessChanged(pondId); - this.logger.info({ pondId, actor: actor.id, member: memberUserId }, 'audit: member removed'); + await this.audit.record({ + action: 'member.removed', + actorId: actor.id, + targetType: 'pond', + targetId: pondId, + details: { member: memberUserId }, + }); } private assertGrantValid(grant: Grant, pond: Pond): void { diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts index 8afcef4..671b684 100644 --- a/apps/api/src/plugins/plugin-admin.controller.ts +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -12,6 +12,7 @@ import { Patch, PayloadTooLargeException, Post, + Req, UploadedFile, UseGuards, UseInterceptors, @@ -20,6 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared'; import { SiteAdminGuard } from '../admin/site-admin.guard'; +import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { PluginsService } from './plugins.service'; @@ -57,10 +59,13 @@ export class PluginAdminController { /** Upload and install (or update) a plugin ZIP. */ @Post() @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } })) - async install(@UploadedFile() file: Express.Multer.File | undefined): Promise { + async install( + @UploadedFile() file: Express.Multer.File | undefined, + @Req() request: AuthedRequest, + ): Promise { if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' }); try { - return await this.plugins.install(file.buffer); + return await this.plugins.install(file.buffer, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; @@ -78,9 +83,10 @@ export class PluginAdminController { async setMode( @Param('id') id: string, @Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput, + @Req() request: AuthedRequest, ): Promise { try { - return await this.plugins.setMode(id, body.mode); + return await this.plugins.setMode(id, body.mode, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; @@ -90,9 +96,9 @@ export class PluginAdminController { /** Uninstall a plugin (refused while `required`). */ @Delete(':id') @HttpCode(204) - async uninstall(@Param('id') id: string): Promise { + async uninstall(@Param('id') id: string, @Req() request: AuthedRequest): Promise { try { - await this.plugins.uninstall(id); + await this.plugins.uninstall(id, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; diff --git a/apps/api/src/plugins/plugin-pond.controller.ts b/apps/api/src/plugins/plugin-pond.controller.ts index 1fc794f..d2de6b1 100644 --- a/apps/api/src/plugins/plugin-pond.controller.ts +++ b/apps/api/src/plugins/plugin-pond.controller.ts @@ -8,6 +8,7 @@ import { Param, Put, Body, + Req, } from '@nestjs/common'; import { pondPluginToggleInputSchema, @@ -16,6 +17,7 @@ import { type PondPluginToggleInput, } from '@dorfteich/shared'; +import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequiresPondRole } from '../permissions/permission.decorators'; @@ -54,9 +56,10 @@ export class PluginPondController { @Param('pondId') pondId: string, @Param('pluginId') pluginId: string, @Body(new ZodValidationPipe(pondPluginToggleInputSchema)) body: PondPluginToggleInput, + @Req() request: AuthedRequest, ): Promise { try { - await this.plugins.setPondActivation(pondId, pluginId, body.enabled); + await this.plugins.setPondActivation(pondId, pluginId, body.enabled, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts index 010857b..d1a77f0 100644 --- a/apps/api/src/plugins/plugins.service.ts +++ b/apps/api/src/plugins/plugins.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; +import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; import type { @@ -10,6 +10,7 @@ import type { } from '@dorfteich/shared'; import { ClockService } from '../common/clock.service'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { PluginPackageService } from './plugin-package.service'; @@ -42,6 +43,7 @@ export class PluginsService { private readonly packages: PluginPackageService, private readonly storage: PluginStorageService, private readonly clock: ClockService, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(PluginsService.name); @@ -53,7 +55,8 @@ export class PluginsService { * version is strictly higher; the admin's chosen instance `mode` is preserved * across updates. Files land atomically before the metadata pointer flips. */ - async install(zip: Buffer): Promise { + /** `actor` is absent for dropzone installs (watcher, no session). */ + async install(zip: Buffer, actor?: User): Promise { const { manifest, files } = this.packages.parse(zip); const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); @@ -95,10 +98,13 @@ export class PluginsService { await this.storage.removeVersion(manifest.id, existing.version); } - this.logger.info( - { plugin: manifest.id, version: manifest.version, update: isActiveUpdate }, - 'audit: plugin installed', - ); + await this.audit.record({ + action: 'plugin.installed', + actorId: actor?.id, + targetType: 'plugin', + targetId: manifest.id, + details: { version: manifest.version, update: isActiveUpdate }, + }); return this.toView(record); } @@ -109,7 +115,7 @@ export class PluginsService { * kept across mode changes (they are simply ignored while non-optional), so * flipping optional→required→optional restores the previous per-pond choices. */ - async setMode(id: string, mode: PluginInstanceMode): Promise { + async setMode(id: string, mode: PluginInstanceMode, actor?: User): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); @@ -118,7 +124,13 @@ export class PluginsService { where: { id }, data: { mode: VIEW_MODE_TO_DB[mode] }, }); - this.logger.info({ plugin: id, mode }, 'audit: plugin mode set'); + await this.audit.record({ + action: 'plugin.mode_set', + actorId: actor?.id, + targetType: 'plugin', + targetId: id, + details: { mode }, + }); return this.toView(updated); } @@ -127,7 +139,7 @@ export class PluginsService { * tombstoned (`removedAt` set, per-pond activations dropped) and every file is * removed from disk. */ - async uninstall(id: string): Promise { + async uninstall(id: string, actor?: User): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); @@ -147,7 +159,12 @@ export class PluginsService { }), ]); await this.storage.removePlugin(id); - this.logger.info({ plugin: id }, 'audit: plugin uninstalled'); + await this.audit.record({ + action: 'plugin.uninstalled', + actorId: actor?.id, + targetType: 'plugin', + targetId: id, + }); } /** @@ -210,7 +227,12 @@ export class PluginsService { * plugins are per-pond choices; toggling a required/disabled/absent plugin is * rejected so the UI cannot desync the model. */ - async setPondActivation(pondId: string, pluginId: string, enabled: boolean): Promise { + async setPondActivation( + pondId: string, + pluginId: string, + enabled: boolean, + actor?: User, + ): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`); @@ -226,7 +248,13 @@ export class PluginsService { create: { pondId, pluginId, enabled }, update: { enabled }, }); - this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled'); + await this.audit.record({ + action: 'plugin.pond_toggled', + actorId: actor?.id, + targetType: 'pond', + targetId: pondId, + details: { plugin: pluginId, enabled }, + }); } /** diff --git a/apps/api/src/scheduler/scheduler.service.ts b/apps/api/src/scheduler/scheduler.service.ts index 7e54394..2a9d42d 100644 --- a/apps/api/src/scheduler/scheduler.service.ts +++ b/apps/api/src/scheduler/scheduler.service.ts @@ -50,6 +50,23 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { this.jobs.set(job.name, job); } + /** Every job registered in this process (issue #86 admin panel). */ + definitions(): JobDefinition[] { + return [...this.jobs.values()]; + } + + /** + * Manual trigger from the admin panel (issue #86): runs `name` now, + * regardless of cadence — only the run-mutex still applies, so a job + * already running elsewhere reports `already_running` instead of + * doubling up. + */ + async runNow(name: string): Promise<'succeeded' | 'failed' | 'already_running'> { + const job = this.jobs.get(name); + if (!job) throw new Error(`unknown job: ${name}`); + return this.claimAndRun(job, { force: true }); + } + onModuleInit(): void { if (this.config.env.NODE_ENV === 'test') return; // tests drive jobs directly this.timer = setInterval(() => void this.tick(), TICK_MS); @@ -69,6 +86,13 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { /** Runs `job` now if due and not already running elsewhere; a no-op otherwise. */ async runIfDue(job: JobDefinition): Promise { + await this.claimAndRun(job, { force: false }); + } + + private async claimAndRun( + job: JobDefinition, + { force }: { force: boolean }, + ): Promise<'succeeded' | 'failed' | 'already_running'> { try { await this.prisma.job.upsert({ where: { name: job.name }, @@ -92,27 +116,34 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { where: { name: job.name, AND: [ - { OR: [{ lastRunAt: null }, { lastRunAt: { lte: dueBefore } }] }, + // A manual trigger skips the due check, never the run-mutex. + ...(force ? [] : [{ OR: [{ lastRunAt: null }, { lastRunAt: { lte: dueBefore } }] }]), { OR: [{ status: { not: 'RUNNING' } }, { lockedAt: { lte: staleLockBefore } }] }, ], }, data: { status: 'RUNNING', lockedAt: now, lastRunAt: now }, }); - if (claim.count === 0) return; // not due, or another run already holds it + if (claim.count === 0) return 'already_running'; // or, unforced, simply not due try { await job.run(); await this.prisma.job.update({ where: { name: job.name }, - data: { status: 'IDLE', lastError: null }, + data: { status: 'IDLE', lastError: null, lastDurationMs: this.sinceMs(now) }, }); + return 'succeeded'; } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : String(error); this.logger.error({ job: job.name, err: error }, 'maintenance job failed'); await this.prisma.job.update({ where: { name: job.name }, - data: { status: 'FAILED', lastError: message }, + data: { status: 'FAILED', lastError: message, lastDurationMs: this.sinceMs(now) }, }); + return 'failed'; } } + + private sinceMs(start: Date): number { + return Math.max(0, this.clock.now().getTime() - start.getTime()); + } } diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 153b944..041d2b2 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -79,6 +80,7 @@ export class InstanceSettingsService { constructor( private readonly prisma: PrismaService, + private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(InstanceSettingsService.name); @@ -125,7 +127,14 @@ export class InstanceSettingsService { update: { value: stored }, }); this.cache.set(key, parsed.data); - this.logger.info({ key, actorUserId }, 'audit: instance setting changed'); + // Values stay out of the trail: legal texts are long, and future keys + // could be sensitive — the key names what changed, the log has the actor. + await this.audit.record({ + action: 'settings.changed', + actorId: actorUserId, + targetType: 'setting', + targetId: key, + }); return parsed.data as InstanceSettingValue; } } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 2f2e696..8031272 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -22,6 +22,7 @@ import { SecretStoreService } from '../config/secret-store.service'; import { renderMail } from '../mail/mail-templates'; import { SmtpConfigService, SmtpSettings } from '../mail/smtp-config.service'; import { PondsService } from '../ponds/ponds.service'; +import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { UsersService } from '../users/users.service'; @@ -45,6 +46,7 @@ export class SetupService implements OnModuleInit { private readonly sessions: SessionsService, private readonly secretStore: SecretStoreService, private readonly smtpConfig: SmtpConfigService, + private readonly audit: AuditService, private readonly config: AppConfig, private readonly logger: PinoLogger, ) { @@ -87,7 +89,7 @@ export class SetupService implements OnModuleInit { await this.settings.set('auth.registrationMode', env.SETUP_REGISTRATION_MODE, admin.id); } await this.complete(admin); - this.logger.info({ userId: admin.id }, 'audit: setup pre-seeded from environment'); + await this.audit.record({ action: 'setup.preseeded', actorId: admin.id }); } async status(): Promise { @@ -117,7 +119,12 @@ export class SetupService implements OnModuleInit { data: { isSiteAdmin: true, status: 'ACTIVE', emailVerifiedAt: new Date() }, }); await this.ponds.ensurePersonalPond(admin); - this.logger.info({ userId: admin.id }, 'audit: setup created site admin'); + await this.audit.record({ + action: 'setup.admin_created', + actorId: admin.id, + targetType: 'user', + targetId: admin.id, + }); return admin; } @@ -160,7 +167,7 @@ export class SetupService implements OnModuleInit { SMTP_FROM: candidate.from, }); this.smtpConfig.refresh(); - this.logger.info({ userId: actor.id }, 'audit: setup stored SMTP configuration'); + await this.audit.record({ action: 'setup.smtp_stored', actorId: actor.id }); } /** Step 4 — registration mode (ADR 0007). */ @@ -176,7 +183,7 @@ export class SetupService implements OnModuleInit { throw new BadRequestException({ code: 'setup_admin_missing' }); } await this.settings.set('setup.completedAt', new Date().toISOString(), actor.id); - this.logger.info({ userId: actor.id }, 'audit: setup completed and locked'); + await this.audit.record({ action: 'setup.completed', actorId: actor.id }); } private async sendTestMail(candidate: SmtpSettings, actor: User): Promise { diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts new file mode 100644 index 0000000..4c36c3f --- /dev/null +++ b/apps/web/e2e/system.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Site-Admin system panel (issue #86): the maintenance-job table with a + * working, audit-logged manual trigger, the audit viewer finding entries by + * actor, the backup card, and Site-Admin-only access. + */ +test('lists maintenance jobs and triggers one manually', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await admin.newPage(); + await page.goto('/admin/system'); + + const jobsTable = page.locator('.system-jobs__table'); + await expect(jobsTable).toBeVisible(); + // All four registered jobs appear (language-neutral: row count + button). + await expect(jobsTable.locator('tbody tr')).toHaveCount(4); + + const firstRow = jobsTable.locator('tbody tr').first(); + await firstRow.getByRole('button').click(); + await expect(page.locator('.system-jobs__notice')).toBeVisible(); + // A completed run shows truthful last-run data: a date and a duration. + await expect(firstRow.locator('td').nth(2)).not.toHaveText('—'); + await expect(firstRow.locator('td').nth(3)).not.toHaveText('—'); + + // The trigger itself lands in the audit log, attributed to the admin. + const audit = page.locator('.system-audit__table'); + await page.locator('.system-audit__filters input[type="text"]').fill('fixture-admin'); + await page.locator('.system-audit__filters select').selectOption('job.triggered'); + await page.locator('.system-audit__filters button[type="submit"]').click(); + await expect(audit.locator('tbody tr').first()).toContainText('job:'); + + await admin.close(); +}); + +test('the audit viewer finds a grant change by actor', async ({ browser }) => { + // The fixture grant change happens as the shared pond's owner via the API + // (the UI flow itself is covered by #55's pack). + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ponds = await owner.request.get('/api/v1/ponds'); + expect(ponds.ok()).toBe(true); + const pond = ((await ponds.json()) as { id: string; type: string }[]).find( + (p) => p.type === 'shared', + ); + expect(pond).toBeTruthy(); + const me = await owner.request.get('/api/v1/auth/me'); + const ownerId = ((await me.json()) as { id: string }).id; + const created = await owner.request.post(`/api/v1/ponds/${pond!.id}/grants`, { + data: { + subjectType: 'user', + subjectId: ownerId, + role: 'reader', + scopeType: 'pond', + scopeId: null, + effect: 'allow', + }, + }); + expect(created.ok()).toBe(true); + const grantId = ((await created.json()) as { id: string }).id; + await owner.close(); + + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await admin.newPage(); + await page.goto('/admin/system'); + await page.locator('.system-audit__filters input[type="text"]').fill('fixture-user'); + await page.locator('.system-audit__filters select').selectOption('grant.created'); + await page.locator('.system-audit__filters button[type="submit"]').click(); + const firstRow = page.locator('.system-audit__table tbody tr').first(); + await expect(firstRow).toContainText(`pond:${pond!.id}`); + await expect(firstRow).toContainText('reader'); + await admin.close(); + + // Cleanup so the pack is repeatable without stacking grants. + const cleanup = await contextForUser(browser, BASE_URL, 'fixture-user'); + await cleanup.request.delete(`/api/v1/ponds/${pond!.id}/grants/${grantId}`); + await cleanup.close(); +}); + +test('shows the backup card state', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await admin.newPage(); + await page.goto('/admin/system'); + // CI stacks run without the sidecar → the unavailable notice; a stage with + // backups shows the freshness badge instead. Either way the card renders. + const card = page.locator('.system-backup'); + await expect(card).toBeVisible(); + await expect(card.locator('.system-backup__unavailable, .system-badge').first()).toBeVisible(); + await admin.close(); +}); + +test('regular users cannot open the system panel', async ({ browser }) => { + const user = await contextForUser(browser, BASE_URL, 'fixture-user'); + const page = await user.newPage(); + const response = await user.request.get('/api/v1/admin/system/jobs'); + expect(response.status()).toBe(403); + await page.goto('/admin/system'); + // The RequireSiteAdmin route guard keeps non-admins out of the panel. + await expect(page.locator('.system-jobs__table')).toHaveCount(0); + await user.close(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 58ee75f..f7ab259 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards'; import { AppLayout } from './layout/AppLayout'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; +import { AdminSystemPage } from './pages/AdminSystemPage'; import { FontCatalogPage } from './pages/FontCatalogPage'; import { HomePage } from './pages/HomePage'; import { LegalPage } from './pages/LegalPage'; @@ -78,6 +79,7 @@ export function App(): React.JSX.Element { }> } /> + } /> {/* Sandbox preview of one installed plugin (issue #73). */} } /> diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 2dceaa2..2afc0a5 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -16,6 +16,7 @@ import dePublic from '@dorfteich/shared/i18n/de/public.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json'; +import deSystem from '@dorfteich/shared/i18n/de/system.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; @@ -36,6 +37,7 @@ import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json'; +import enSystem from '@dorfteich/shared/i18n/en/system.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; @@ -73,6 +75,7 @@ void i18n quotas: enQuotas, search: enSearch, setup: enSetup, + system: enSystem, users: enUsers, }, de: { @@ -95,6 +98,7 @@ void i18n quotas: deQuotas, search: deSearch, setup: deSetup, + system: deSystem, users: deUsers, }, }, diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 5b2b462..418da71 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -3,6 +3,7 @@ import { docToHtml, markdownToDoc } from '@dorfteich/shared'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import { Field, FormError, FormSuccess } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; @@ -56,6 +57,9 @@ export function AdminSettingsPage(): React.JSX.Element { return ( <>

{t('settings:admin.title')}

+

+ {t('system:settingsLink')} → +

diff --git a/apps/web/src/pages/AdminSystemPage.tsx b/apps/web/src/pages/AdminSystemPage.tsx new file mode 100644 index 0000000..909860c --- /dev/null +++ b/apps/web/src/pages/AdminSystemPage.tsx @@ -0,0 +1,418 @@ +import type { + AuditEntryView, + AuditListView, + JobTriggerOutcome, + JobTriggerResult, + StorageOverviewView, + SystemBackupView, + SystemJobView, +} from '@dorfteich/shared'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { formatBytes } from '../files/file-format'; +import { apiGet, apiPost } from '../lib/api'; + +/** + * Site-Admin "System" panel (issue #86): maintenance jobs with a manual + * trigger, the backup card fed by the sidecar's status.json, the audit-log + * viewer, and the per-pond storage top list — the operator's single glance + * for instance health (operations.md). + */ +export function AdminSystemPage(): React.JSX.Element { + const { t } = useTranslation('system'); + return ( + <> +

{t('title')}

+

+ ← {t('backLink')} +

+ + + + + + ); +} + +function cadenceParts(seconds: number): { + unit: 'days' | 'hours' | 'minutes' | 'seconds'; + count: number; +} { + if (seconds % 86_400 === 0) return { unit: 'days', count: seconds / 86_400 }; + if (seconds % 3_600 === 0) return { unit: 'hours', count: seconds / 3_600 }; + if (seconds % 60 === 0) return { unit: 'minutes', count: seconds / 60 }; + return { unit: 'seconds', count: seconds }; +} + +function durationLabel(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`; +} + +function JobsSection(): React.JSX.Element { + const { t } = useTranslation('system'); + const [notice, setNotice] = useState<{ job: string; outcome: JobTriggerOutcome } | null>(null); + const [pending, setPending] = useState(null); + + const query = useQuery({ + queryKey: ['admin', 'system', 'jobs'], + queryFn: () => apiGet('/admin/system/jobs'), + }); + + const trigger = async (name: string): Promise => { + setPending(name); + setNotice(null); + try { + const result = await apiPost(`/admin/system/jobs/${name}/run`, {}); + setNotice({ job: name, outcome: result.outcome }); + } finally { + setPending(null); + await query.refetch(); + } + }; + + return ( +
+

{t('jobs.title')}

+ {notice && ( +

+ {t(`jobs.triggered.${notice.outcome}`)} +

+ )} + + + + + + + + + + + + + {(query.data ?? []).map((job) => ( + + + + + + + + + ))} + +
{t('jobs.columns.job')}{t('jobs.columns.cadence')}{t('jobs.columns.lastRun')}{t('jobs.columns.duration')}{t('jobs.columns.outcome')}{t('jobs.columns.actions')}
+ {t(`jobs.names.${job.name}`, { defaultValue: job.name })} + {!job.registered && ( + {t('jobs.unregistered')} + )} + + {(() => { + const { unit, count } = cadenceParts(job.cadenceSeconds); + return t(`jobs.cadence.${unit}`, { count }); + })()} + {job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '—'}{job.lastDurationMs !== null ? durationLabel(job.lastDurationMs) : '—'} + + + {job.registered && ( + + )} +
+
+ ); +} + +function JobOutcome({ job }: { job: SystemJobView }): React.JSX.Element { + const { t } = useTranslation('system'); + if (job.status === 'RUNNING') + return {t('jobs.outcome.running')}; + if (job.status === 'FAILED') + return ( + + {t('jobs.outcome.failed')} + + ); + if (!job.lastRunAt) return {t('jobs.outcome.never')}; + return {t('jobs.outcome.ok')}; +} + +function BackupCard(): React.JSX.Element { + const { t } = useTranslation('system'); + const query = useQuery({ + queryKey: ['admin', 'system', 'backup'], + queryFn: () => apiGet('/admin/system/backup'), + }); + const view = query.data; + if (!view) return <>; + + return ( +
+

{t('backup.title')}

+ {!view.available &&

{t('backup.unavailable')}

} + {view.available && view.status && ( + <> +

+ + {view.fresh ? t('backup.fresh') : t('backup.stale', { hours: view.maxAgeHours })} + +

+
+
{t('backup.lastSuccess')}
+
+ {view.status.lastSuccess + ? `${new Date(view.status.lastSuccess.finishedAt).toLocaleString()} (${view.status.lastSuccess.backupId}) — ${t( + 'backup.sizes', + { + dump: formatBytes(view.status.lastSuccess.sizes.dumpBytes), + archive: formatBytes(view.status.lastSuccess.sizes.archiveBytes), + }, + )}` + : t('backup.never')} +
+
{t('backup.lastRun')}
+
+ {new Date(view.status.lastRun.finishedAt).toLocaleString()} —{' '} + {t(`backup.outcome.${view.status.lastRun.outcome}`)} +
+ {view.status.lastRun.error && ( + <> +
{t('backup.error')}
+
+ {view.status.lastRun.error} +
+ + )} +
+

+ {t('backup.retention', { days: view.status.retentionDays })} +

+ + )} +
+ ); +} + +interface AuditFilter { + actor: string; + action: string; + from: string; + to: string; +} + +const EMPTY_FILTER: AuditFilter = { actor: '', action: '', from: '', to: '' }; + +function AuditViewer(): React.JSX.Element { + const { t } = useTranslation('system'); + const [draft, setDraft] = useState(EMPTY_FILTER); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [page, setPage] = useState(1); + + const query = useQuery({ + queryKey: ['admin', 'system', 'audit', filter, page], + queryFn: () => { + const params = new URLSearchParams({ page: String(page) }); + if (filter.actor) params.set('actor', filter.actor); + if (filter.action) params.set('action', filter.action); + if (filter.from) params.set('from', new Date(filter.from).toISOString()); + if (filter.to) params.set('to', new Date(filter.to).toISOString()); + return apiGet(`/admin/system/audit?${params.toString()}`); + }, + placeholderData: keepPreviousData, + }); + + const data = query.data; + + return ( +
+

{t('audit.title')}

+ { + e.preventDefault(); + setPage(1); + setFilter(draft); + }} + > + setDraft({ ...draft, actor: e.target.value })} + /> + + setDraft({ ...draft, from: e.target.value })} + /> + setDraft({ ...draft, to: e.target.value })} + /> + + + + {data && data.entries.length === 0 &&

{t('audit.empty')}

} + {data && data.entries.length > 0 && ( + + + + + + + + + + + + {data.entries.map((entry) => ( + + ))} + +
{t('audit.columns.time')}{t('audit.columns.actor')}{t('audit.columns.action')}{t('audit.columns.target')}{t('audit.columns.details')}
+ )} + {data && ( +
+ + + {t('audit.pager', { page: data.page, pageCount: data.pageCount, total: data.total })} + + +
+ )} +
+ ); +} + +const KNOWN_ACTIONS = [ + 'grant.created', + 'grant.deleted', + 'member.added', + 'member.role_changed', + 'member.removed', + 'user.disabled_set', + 'user.verification_resent', + 'user.deleted', + 'user.site_admin_set', + 'user.pseudonymized', + 'quota.override_set', + 'quota.override_cleared', + 'plugin.installed', + 'plugin.mode_set', + 'plugin.uninstalled', + 'plugin.pond_toggled', + 'settings.changed', + 'setup.preseeded', + 'setup.admin_created', + 'setup.smtp_stored', + 'setup.completed', + 'auth.signup', + 'auth.email_verified', + 'auth.login_failed', + 'auth.login_succeeded', + 'auth.password_reset', + 'job.triggered', +]; + +function AuditRow({ entry }: { entry: AuditEntryView }): React.JSX.Element { + const { t } = useTranslation('system'); + return ( + + {new Date(entry.at).toLocaleString()} + {entry.actor ? entry.actor.displayName : t('audit.systemActor')} + {t(`audit.actions.${entry.action}`, { defaultValue: entry.action })} + + {entry.targetType && ( + + {entry.targetType}:{entry.targetId} + + )} + + {entry.details && {JSON.stringify(entry.details)}} + + ); +} + +function StorageSection(): React.JSX.Element { + const { t } = useTranslation('system'); + const query = useQuery({ + queryKey: ['admin', 'system', 'storage'], + queryFn: () => apiGet('/admin/system/storage'), + }); + const view = query.data; + if (!view) return <>; + + return ( +
+

{t('storage.title')}

+

{t('storage.total', { total: formatBytes(view.totalBytes) })}

+ {view.ponds.length === 0 &&

{t('storage.empty')}

} + {view.ponds.length > 0 && ( + + + + + + + + + + {view.ponds.map((pond) => ( + + + + + + ))} + +
{t('storage.columns.pond')}{t('storage.columns.type')}{t('storage.columns.used')}
+ {pond.name} + {t(`storage.types.${pond.type}`)}{formatBytes(pond.storageBytesUsed)}
+ )} +
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 967f738..eb76ef4 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2287,3 +2287,66 @@ button { color: var(--color-text-muted); font-size: 0.875rem; } + +/* Site-Admin system panel (issue #86) */ +.system-badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.8125rem; + background: var(--color-surface-muted, #e2e8f0); + margin-left: var(--space-1); +} + +.system-badge--ok { + background: #d6f2df; + color: #1d6f42; +} + +.system-badge--error { + background: #fbe3e0; + color: #a02818; +} + +.system-badge--warn { + background: #fdf0d4; + color: #8a5a00; +} + +.system-jobs__notice { + color: var(--color-text-muted); +} + +.system-backup__facts dt { + font-weight: 600; + margin-top: var(--space-2); +} + +.system-backup__facts dd { + margin: 0; +} + +.system-backup__retention, +.system-backup__unavailable { + color: var(--color-text-muted); +} + +.system-audit__filters { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + margin-bottom: var(--space-3); +} + +.system-audit__table code, +.system-storage__table code { + font-size: 0.8125rem; + word-break: break-all; +} + +.system-audit__pager { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-3); +} diff --git a/docs/architecture/operations.md b/docs/architecture/operations.md index 27ecf26..345a4be 100644 --- a/docs/architecture/operations.md +++ b/docs/architecture/operations.md @@ -34,6 +34,10 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack. denials), admin actions (grants, plugin installs, quota changes) as an **audit trail**, collab session open/close. Never log passwords, tokens, session ids, or page content. +- **Persistent audit trail** (issue #86): the auth events and admin actions + additionally land as rows in `audit_log` via `AuditService` — queryable in + the Site-Admin System panel (filter by actor/action/time, paginated). + Content activity (pages, files, exports, labels) stays log-only by design. - Reading logs = `docker compose logs` / `docker logs` on the host; no central log stack at this scale (revisit if a second Prod host appears). diff --git a/packages/shared/i18n/de/system.json b/packages/shared/i18n/de/system.json new file mode 100644 index 0000000..d601fc5 --- /dev/null +++ b/packages/shared/i18n/de/system.json @@ -0,0 +1,127 @@ +{ + "title": "System", + "backLink": "Zurück zu den Einstellungen", + "settingsLink": "System-Panel", + "jobs": { + "title": "Wartungsjobs", + "columns": { + "job": "Job", + "cadence": "Rhythmus", + "lastRun": "Letzter Lauf", + "duration": "Dauer", + "outcome": "Ergebnis", + "actions": "Aktionen" + }, + "names": { + "trash-purge": "Papierkorb-Bereinigung", + "version-thinning": "Versions-Ausdünnung", + "page-compaction": "Seiten-Kompaktierung", + "data-export-purge": "Datenexport-Bereinigung" + }, + "outcome": { + "ok": "OK", + "running": "Läuft", + "failed": "Fehlgeschlagen", + "never": "Noch nie gelaufen" + }, + "unregistered": "In diesem Build nicht mehr registriert", + "run": "Jetzt ausführen", + "runPending": "Läuft…", + "triggered": { + "succeeded": "Lauf erfolgreich abgeschlossen.", + "failed": "Lauf fehlgeschlagen — Fehler steht in der Zeile.", + "already_running": "Läuft bereits — gleich noch einmal versuchen." + }, + "cadence": { + "days_one": "täglich", + "days_other": "alle {{count}} Tage", + "hours_one": "stündlich", + "hours_other": "alle {{count}} Stunden", + "minutes_one": "jede Minute", + "minutes_other": "alle {{count}} Minuten", + "seconds_other": "alle {{count}} Sekunden" + } + }, + "backup": { + "title": "Backups", + "fresh": "Aktuell", + "stale": "Veraltet — älter als {{hours}} h", + "unavailable": "Kein Backup-Status gefunden. Der Backup-Sidecar ist noch nicht gelaufen (oder nicht eingerichtet) — siehe deploy/monitoring.md.", + "lastSuccess": "Letztes erfolgreiches Backup", + "lastRun": "Letzter Lauf", + "outcome": { + "succeeded": "erfolgreich", + "failed": "fehlgeschlagen" + }, + "never": "noch keines", + "sizes": "Dump {{dump}}, Dateien {{archive}}", + "retention": "Aufbewahrung: {{days}} Tage", + "error": "Letzter Fehler" + }, + "audit": { + "title": "Audit-Log", + "filters": { + "actor": "Akteur (Benutzername)", + "action": "Aktion", + "actionAny": "Alle Aktionen", + "from": "Von", + "to": "Bis", + "apply": "Filtern" + }, + "columns": { + "time": "Zeit", + "actor": "Akteur", + "action": "Aktion", + "target": "Ziel", + "details": "Details" + }, + "systemActor": "System", + "empty": "Keine Einträge für diesen Filter.", + "pager": "Seite {{page}} von {{pageCount}} ({{total}} Einträge)", + "previous": "Zurück", + "next": "Weiter", + "actions": { + "grant.created": "Zugriffsregel angelegt", + "grant.deleted": "Zugriffsregel gelöscht", + "member.added": "Mitglied hinzugefügt", + "member.role_changed": "Mitgliedsrolle geändert", + "member.removed": "Mitglied entfernt", + "user.disabled_set": "Nutzer gesperrt/entsperrt", + "user.verification_resent": "Bestätigungsmail erneut gesendet", + "user.deleted": "Nutzer gelöscht", + "user.site_admin_set": "Site-Admin-Rolle geändert", + "user.pseudonymized": "Nutzer pseudonymisiert", + "quota.override_set": "Quota-Ausnahme gesetzt", + "quota.override_cleared": "Quota-Ausnahme entfernt", + "plugin.installed": "Plugin installiert", + "plugin.mode_set": "Plugin-Modus geändert", + "plugin.uninstalled": "Plugin deinstalliert", + "plugin.pond_toggled": "Teich-Plugin umgeschaltet", + "settings.changed": "Instanz-Einstellung geändert", + "setup.preseeded": "Setup vorbefüllt", + "setup.admin_created": "Setup: Admin angelegt", + "setup.smtp_stored": "Setup: SMTP gespeichert", + "setup.completed": "Setup abgeschlossen", + "auth.signup": "Registrierung", + "auth.email_verified": "E-Mail bestätigt", + "auth.login_failed": "Anmeldung fehlgeschlagen", + "auth.login_succeeded": "Anmeldung", + "auth.password_reset": "Passwort zurückgesetzt", + "job.triggered": "Job manuell gestartet" + } + }, + "storage": { + "title": "Speicher", + "total": "Gesamtbelegung: {{total}}", + "columns": { + "pond": "Teich", + "type": "Typ", + "used": "Belegt" + }, + "types": { + "personal": "persönlich", + "shared": "geteilt" + }, + "empty": "Noch keine Belegung erfasst." + } +} diff --git a/packages/shared/i18n/en/system.json b/packages/shared/i18n/en/system.json new file mode 100644 index 0000000..cea8ae8 --- /dev/null +++ b/packages/shared/i18n/en/system.json @@ -0,0 +1,127 @@ +{ + "title": "System", + "backLink": "Back to settings", + "settingsLink": "System panel", + "jobs": { + "title": "Maintenance jobs", + "columns": { + "job": "Job", + "cadence": "Cadence", + "lastRun": "Last run", + "duration": "Duration", + "outcome": "Outcome", + "actions": "Actions" + }, + "names": { + "trash-purge": "Trash purge", + "version-thinning": "Version thinning", + "page-compaction": "Page compaction", + "data-export-purge": "Data-export purge" + }, + "outcome": { + "ok": "OK", + "running": "Running", + "failed": "Failed", + "never": "Never ran" + }, + "unregistered": "No longer registered in this build", + "run": "Run now", + "runPending": "Running…", + "triggered": { + "succeeded": "Run finished successfully.", + "failed": "Run failed — see the error in the row.", + "already_running": "Already running elsewhere — try again in a moment." + }, + "cadence": { + "days_one": "every day", + "days_other": "every {{count}} days", + "hours_one": "hourly", + "hours_other": "every {{count}} hours", + "minutes_one": "every minute", + "minutes_other": "every {{count}} minutes", + "seconds_other": "every {{count}} seconds" + } + }, + "backup": { + "title": "Backups", + "fresh": "Fresh", + "stale": "Stale — older than {{hours}} h", + "unavailable": "No backup status found. The backup sidecar has not run yet (or is not deployed) — see deploy/monitoring.md.", + "lastSuccess": "Last successful backup", + "lastRun": "Last run", + "outcome": { + "succeeded": "succeeded", + "failed": "failed" + }, + "never": "none yet", + "sizes": "Dump {{dump}}, files {{archive}}", + "retention": "Retention: {{days}} days", + "error": "Last error" + }, + "audit": { + "title": "Audit log", + "filters": { + "actor": "Actor (username)", + "action": "Action", + "actionAny": "All actions", + "from": "From", + "to": "Until", + "apply": "Filter" + }, + "columns": { + "time": "Time", + "actor": "Actor", + "action": "Action", + "target": "Target", + "details": "Details" + }, + "systemActor": "System", + "empty": "No entries match the filter.", + "pager": "Page {{page}} of {{pageCount}} ({{total}} entries)", + "previous": "Previous", + "next": "Next", + "actions": { + "grant.created": "Access rule created", + "grant.deleted": "Access rule deleted", + "member.added": "Member added", + "member.role_changed": "Member role changed", + "member.removed": "Member removed", + "user.disabled_set": "User disabled/enabled", + "user.verification_resent": "Verification mail resent", + "user.deleted": "User deleted", + "user.site_admin_set": "Site-Admin role changed", + "user.pseudonymized": "User pseudonymized", + "quota.override_set": "Quota override set", + "quota.override_cleared": "Quota override cleared", + "plugin.installed": "Plugin installed", + "plugin.mode_set": "Plugin mode changed", + "plugin.uninstalled": "Plugin uninstalled", + "plugin.pond_toggled": "Pond plugin toggled", + "settings.changed": "Instance setting changed", + "setup.preseeded": "Setup pre-seeded", + "setup.admin_created": "Setup: admin created", + "setup.smtp_stored": "Setup: SMTP stored", + "setup.completed": "Setup completed", + "auth.signup": "Sign-up", + "auth.email_verified": "E-mail verified", + "auth.login_failed": "Login failed", + "auth.login_succeeded": "Login", + "auth.password_reset": "Password reset", + "job.triggered": "Job triggered manually" + } + }, + "storage": { + "title": "Storage", + "total": "Total usage: {{total}}", + "columns": { + "pond": "Pond", + "type": "Type", + "used": "Used" + }, + "types": { + "personal": "personal", + "shared": "shared" + }, + "empty": "No usage recorded yet." + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ec6fa42..fa12c93 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,7 @@ export * from './plugins'; export * from './search'; export * from './secret-store'; export * from './setup'; +export * from './system'; export * from './ponds'; export * from './quotas'; export * from './text-diff'; diff --git a/packages/shared/src/system.ts b/packages/shared/src/system.ts new file mode 100644 index 0000000..aaf7224 --- /dev/null +++ b/packages/shared/src/system.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +import type { BackupStatus } from './backup-status'; + +/** + * Site-Admin system panel (issue #86): maintenance jobs, backup status, + * audit trail, and storage overview — the operator's single glance for + * instance health (operations.md §Maintenance jobs). + */ + +export interface SystemJobView { + name: string; + cadenceSeconds: number; + status: 'IDLE' | 'RUNNING' | 'FAILED'; + lastRunAt: string | null; + lastDurationMs: number | null; + lastError: string | null; + /** False for a database row whose job no longer registers in this build. */ + registered: boolean; +} + +export type JobTriggerOutcome = 'succeeded' | 'failed' | 'already_running'; + +export interface JobTriggerResult { + outcome: JobTriggerOutcome; + job: SystemJobView; +} + +export interface SystemBackupView { + /** False when no status.json exists (sidecar never ran / not deployed). */ + available: boolean; + /** Freshness verdict mirroring the readyz `backup` check (issue #85). */ + fresh: boolean; + status: BackupStatus | null; + maxAgeHours: number; +} + +export interface AuditActorView { + id: string; + username: string; + displayName: string; +} + +export interface AuditEntryView { + id: string; + at: string; + action: string; + actor: AuditActorView | null; + targetType: string | null; + targetId: string | null; + details: Record | null; +} + +export const AUDIT_PAGE_SIZE = 50; + +export const auditListQuerySchema = z.object({ + /** Exact username of the acting user. */ + actor: z.string().trim().min(1).optional(), + /** Action id or prefix, e.g. `grant.` matches created and deleted. */ + action: z.string().trim().min(1).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + page: z.coerce.number().int().min(1).default(1), +}); + +export type AuditListQuery = z.infer; + +export interface AuditListView { + entries: AuditEntryView[]; + page: number; + pageCount: number; + total: number; +} + +export interface StoragePondView { + pondId: string; + name: string; + slug: string; + /** Lowercase like PondView's `type` (the api's wire convention). */ + type: 'personal' | 'shared'; + storageBytesUsed: number; +} + +export interface StorageOverviewView { + totalBytes: number; + /** Top ponds by storage use, largest first. */ + ponds: StoragePondView[]; +}