From 488d0d06f108a4e86370e6fd9ee114d9311cfdc8 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 06:12:26 +0200 Subject: [PATCH] #205: classification inherits down the tree; lowering is a guarded, audited act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pages take max(instance default, parent level); moving a subtree under a higher-classified parent raises every member below that level. No move-like path (reposition, trash-promote, purge-promote) lowers a level as a side effect — pinned by test. Raising is ordinary editorial work; lowering requires the dedicated capability canLowerClassification (pond-wide Pond Admin) in the central permission model. Both directions are audited (page.classification_raised/_lowered, catalogue v1.1) with old value, new value, actor and page. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/src/audit/audit-actions.ts | 2 + .../classification-inheritance.e2e.db.test.ts | 254 ++++++++++++++++++ apps/api/src/pages/pages.service.ts | 132 ++++++++- .../api/src/permissions/permission.service.ts | 9 + docs/architecture/audit-events.md | 10 +- docs/architecture/permissions.md | 11 + docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/i18n/de/errors.json | 3 +- packages/shared/i18n/en/errors.json | 3 +- packages/shared/src/pages.ts | 20 ++ packages/shared/src/permissions/pond.ts | 13 + 11 files changed, 444 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/pages/classification-inheritance.e2e.db.test.ts diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 6667bac..49e80da 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -31,6 +31,8 @@ export const AUDIT_EVENTS = { 'member.added': { severity: 'notice' }, 'member.removed': { severity: 'notice' }, 'member.role_changed': { severity: 'notice' }, + 'page.classification_lowered': { severity: 'warning' }, + 'page.classification_raised': { severity: 'notice' }, 'plugin.installed': { severity: 'notice' }, 'plugin.mode_set': { severity: 'notice' }, 'plugin.pond_toggled': { severity: 'info' }, diff --git a/apps/api/src/pages/classification-inheritance.e2e.db.test.ts b/apps/api/src/pages/classification-inheritance.e2e.db.test.ts new file mode 100644 index 0000000..e718792 --- /dev/null +++ b/apps/api/src/pages/classification-inheritance.e2e.db.test.ts @@ -0,0 +1,254 @@ +import { INestApplication } from '@nestjs/common'; +import { CreateGrantInput } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { PondsService } from '../ponds/ponds.service'; +import { TrashService } from '../trash/trash.service'; +import { UsersService } from '../users/users.service'; + +/** + * Classification inheritance in the page tree (issue #205, ADR 0022): + * children inherit, moves can only raise (audited), lowering needs the + * dedicated capability (pond-wide Pond Admin) and is audited — and no + * move-like path (reposition, promote-on-trash, promote-on-purge) ever + * lowers a level as a side effect. + */ +describe.skipIf(!hasTestDb)('classification inheritance (e2e, issue #205)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'vererbte einstufung 123'; + + const userIds: Record = {}; + const cookies: Record = {}; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + const users = app.get(UsersService); + const username = `${handle}-inherit-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `${handle} ${suffix}`, + password, + locale: 'en', + }); + userIds[handle] = user.id; + await users.markEmailVerified(user.id); + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + cookies[handle] = sessionCookieOf(res); + } + + async function createPage(title: string, parentId?: string): Promise<{ id: string }> { + const res = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', cookies.owner!) + .send({ title, ...(parentId ? { parentId } : {}) }) + .expect(201); + return res.body as { id: string }; + } + + async function classificationOf(pageId: string): Promise { + const res = await api() + .get(`/api/v1/pages/${pageId}`) + .set('Cookie', cookies.owner!) + .expect(200); + return (res.body as { classification: string }).classification; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + + for (const handle of ['owner', 'editor']) { + await makeUser(handle); + } + await app + .get(PondsService) + .ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } })); + await prisma.quotaOverride.create({ + data: { + subjectType: 'USER', + subjectId: userIds.owner!, + quotaKey: 'additional_ponds', + value: 10, + }, + }); + + const pond = await api() + .post('/api/v1/ponds') + .set('Cookie', cookies.owner!) + .send({ name: `Inherit Pond ${suffix}` }) + .expect(201); + pondId = (pond.body as { id: string }).id; + + const editorGrant: CreateGrantInput = { + subjectType: 'user', + subjectId: userIds.editor!, + role: 'editor', + scopeType: 'pond', + scopeId: undefined, + effect: 'allow', + }; + await api() + .post(`/api/v1/ponds/${pondId}/grants`) + .set('Cookie', cookies.owner!) + .send(editorGrant) + .expect(201); + }); + + afterAll(async () => { + const ids = Object.values(userIds); + await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); + await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('any writer may raise; a new page inherits its parent level', async () => { + const parent = await createPage(`Classified Parent ${suffix}`); + // The EDITOR raises — raising is ordinary editorial work, no admin needed. + const raised = await api() + .patch(`/api/v1/pages/${parent.id}`) + .set('Cookie', cookies.editor!) + .send({ classification: 'vs_nfd' }) + .expect(200); + expect((raised.body as { classification: string }).classification).toBe('vs_nfd'); + const raiseAudit = await prisma.auditEntry.findFirst({ + where: { action: 'page.classification_raised', targetId: parent.id }, + }); + expect(raiseAudit?.actorId).toBe(userIds.editor); + expect(raiseAudit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd' }); + + // Inherit on create: the child starts at the parent's level, not the + // instance default. + const child = await createPage(`Inherited Child ${suffix}`, parent.id); + expect(await classificationOf(child.id)).toBe('vs_nfd'); + }); + + it('moving under a higher-classified parent raises the whole moved subtree, audited', async () => { + const top = await createPage(`Secret Top ${suffix}`); + await api() + .patch(`/api/v1/pages/${top.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'vs_nfd' }) + .expect(200); + + const movedRoot = await createPage(`Open Subtree ${suffix}`); + const movedChild = await createPage(`Open Leaf ${suffix}`, movedRoot.id); + + await api() + .patch(`/api/v1/pages/${movedRoot.id}/position`) + .set('Cookie', cookies.editor!) + .send({ afterId: null, beforeId: null, parentId: top.id }) + .expect(200); + + expect(await classificationOf(movedRoot.id)).toBe('vs_nfd'); + expect(await classificationOf(movedChild.id)).toBe('vs_nfd'); + for (const pageId of [movedRoot.id, movedChild.id]) { + const audit = await prisma.auditEntry.findFirst({ + where: { action: 'page.classification_raised', targetId: pageId }, + }); + expect(audit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd', trigger: 'move' }); + expect(audit?.actorId).toBe(userIds.editor); + } + }); + + it('lowering is denied without the dedicated capability and audited with it', async () => { + const page = await createPage(`To Lower ${suffix}`); + await api() + .patch(`/api/v1/pages/${page.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'vs_nfd' }) + .expect(200); + + // The editor may write the page but holds no pond-admin role → 403. + const denied = await api() + .patch(`/api/v1/pages/${page.id}`) + .set('Cookie', cookies.editor!) + .send({ classification: 'unclassified' }) + .expect(403); + expect((denied.body as { code: string }).code).toBe('classification_lower_forbidden'); + expect(await classificationOf(page.id)).toBe('vs_nfd'); + + // The owner (Pond Admin) may lower — and the sensitive direction is audited. + await api() + .patch(`/api/v1/pages/${page.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'unclassified' }) + .expect(200); + expect(await classificationOf(page.id)).toBe('unclassified'); + const audit = await prisma.auditEntry.findFirst({ + where: { action: 'page.classification_lowered', targetId: page.id }, + }); + expect(audit?.actorId).toBe(userIds.owner); + expect(audit?.details).toMatchObject({ from: 'vs_nfd', to: 'unclassified', trigger: 'edit' }); + }); + + it('no move-like path lowers as a side effect: reposition, trash-promote, purge-promote', async () => { + // Moving a classified page under an unclassified parent keeps its level. + const openParent = await createPage(`Open Parent ${suffix}`); + const classified = await createPage(`Stays Classified ${suffix}`); + await api() + .patch(`/api/v1/pages/${classified.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'vs_nfd' }) + .expect(200); + await api() + .patch(`/api/v1/pages/${classified.id}/position`) + .set('Cookie', cookies.owner!) + .send({ afterId: null, beforeId: null, parentId: openParent.id }) + .expect(200); + expect(await classificationOf(classified.id)).toBe('vs_nfd'); + + // Trash-promote: trashing the classified middle page re-attaches its + // classified child to the open grandparent — the child keeps its level. + const middle = await createPage(`Classified Middle ${suffix}`, openParent.id); + await api() + .patch(`/api/v1/pages/${middle.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'vs_nfd' }) + .expect(200); + const grandchild = await createPage(`Classified Leaf ${suffix}`, middle.id); + expect(await classificationOf(grandchild.id)).toBe('vs_nfd'); + await api().delete(`/api/v1/pages/${middle.id}`).set('Cookie', cookies.owner!).expect(204); + const promoted = await prisma.page.findUniqueOrThrow({ where: { id: grandchild.id } }); + expect(promoted.parentId).toBe(openParent.id); + expect(promoted.classification).toBe('VS_NFD'); + + // Purge-promote: a subtree-trashed child still points at its trashed + // parent; purging the parent promotes it — again without lowering. + const purgeParent = await createPage(`Purge Parent ${suffix}`, openParent.id); + await api() + .patch(`/api/v1/pages/${purgeParent.id}`) + .set('Cookie', cookies.owner!) + .send({ classification: 'vs_nfd' }) + .expect(200); + const purgeChild = await createPage(`Purge Leaf ${suffix}`, purgeParent.id); + await api() + .delete(`/api/v1/pages/${purgeParent.id}?mode=subtree`) + .set('Cookie', cookies.owner!) + .expect(204); + const ownerUser = await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }); + await app.get(TrashService).purgeNow(ownerUser, purgeParent.id); + const promotedAfterPurge = await prisma.page.findUniqueOrThrow({ + where: { id: purgeChild.id }, + }); + expect(promotedAfterPurge.parentId).toBe(openParent.id); + expect(promotedAfterPurge.classification).toBe('VS_NFD'); + }); +}); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 5317390..6020346 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -22,6 +22,7 @@ import { TaskToggleRequest, TreeItem, UpdatePageInput, + classificationRank, collectSubtreeIds, nodeDepth, pondSettingsSchema, @@ -33,6 +34,7 @@ import { Page, Prisma, User } from '@prisma/client'; import { generateKeyBetween } from 'fractional-indexing'; import { PinoLogger } from 'nestjs-pino'; +import { AuditService } from '../audit/audit.service'; import { AppConfig } from '../config/app-config.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -58,6 +60,15 @@ function contentCacheData( * (realtime-collaboration.md). 60 s is the ceiling the story specifies. */ const COLLAB_TOKEN_TTL_SECONDS = 60; +/** DB ↔ API spelling of the classification enum (ADR 0022): Prisma stores + * SCREAMING_SNAKE, every representation outside the schema is lowercase. */ +function toDbClassification(value: PageClassification): Page['classification'] { + return value === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED'; +} +function fromDbClassification(value: Page['classification']): PageClassification { + return value.toLowerCase() as PageClassification; +} + @Injectable() export class PagesService { constructor( @@ -68,6 +79,7 @@ export class PagesService { private readonly search: SearchProvider, private readonly watches: WatchesService, private readonly settings: InstanceSettingsService, + private readonly audit: AuditService, ) { this.logger.setContext(PagesService.name); } @@ -80,7 +92,7 @@ export class PagesService { title: page.title, slug: page.slug, sortKey: page.sortKey, - classification: page.classification.toLowerCase() as PageClassification, + classification: fromDbClassification(page.classification), createdAt: page.createdAt.toISOString(), updatedAt: page.updatedAt.toISOString(), deletedAt: page.deletedAt?.toISOString() ?? null, @@ -303,9 +315,22 @@ export class PagesService { }); const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const content = deriveContent(state); - // New pages start at the instance-wide default level (ADR 0022, #204); - // inheritance of the parent's level arrives with #205 and wins over this. - const defaultClassification = await this.settings.get('classification.newPageDefault'); + // A new page starts at the instance-wide default level (ADR 0022, #204), + // raised to its parent's level when that is higher (#205): a subpage of + // classified content must never begin unmarked. + let classification: PageClassification = await this.settings.get( + 'classification.newPageDefault', + ); + if (parentId) { + const parent = await this.prisma.page.findUniqueOrThrow({ + where: { id: parentId }, + select: { classification: true }, + }); + const parentLevel = fromDbClassification(parent.classification); + if (classificationRank(parentLevel) > classificationRank(classification)) { + classification = parentLevel; + } + } const page = await this.prisma.page.create({ data: { @@ -314,7 +339,7 @@ export class PagesService { title, slug, sortKey, - classification: defaultClassification === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED', + classification: toDbClassification(classification), ydocState: state, createdBy: user.id, contentCache: { create: contentCacheData(content) }, @@ -403,7 +428,7 @@ export class PagesService { return this.stateViewOf(page); } - async update(_user: User, id: string, input: UpdatePageInput): Promise { + async update(user: User, id: string, input: UpdatePageInput): Promise { const page = await this.findLivePage(id); let slug = page.slug; @@ -419,10 +444,36 @@ export class PagesService { slug = normalized; } + // Classification change (#205, ADR 0022): raising is ordinary editorial + // work (the write guard has run); LOWERING is the sensitive direction and + // needs the dedicated capability from the central permission model. + // Both directions are audited with old value, new value, actor and page. + const currentLevel = fromDbClassification(page.classification); + const targetLevel = input.classification; + const levelChanges = targetLevel !== undefined && targetLevel !== currentLevel; + if (levelChanges && classificationRank(targetLevel) < classificationRank(currentLevel)) { + const mayLower = await this.permissions.canLowerClassification(user, page.pondId); + if (!mayLower) throw new ForbiddenException({ code: 'classification_lower_forbidden' }); + } + const updated = await this.prisma.page.update({ where: { id: page.id }, - data: { title: input.title, slug }, + data: { + title: input.title, + slug, + ...(levelChanges ? { classification: toDbClassification(targetLevel) } : {}), + }, }); + if (levelChanges) { + const raised = classificationRank(targetLevel) > classificationRank(currentLevel); + await this.audit.record({ + action: raised ? 'page.classification_raised' : 'page.classification_lowered', + actorId: user.id, + targetType: 'page', + targetId: page.id, + details: { from: currentLevel, to: targetLevel, trigger: 'edit', pondId: page.pondId }, + }); + } // Renaming to a slug pages already link to resolves those phantom links (#47). if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id); // A changed title changes the (weighted) search entry (#49). @@ -444,7 +495,7 @@ export class PagesService { * Requires write access; the sort mode does not have to be `manual` (the key * is stored regardless, just not applied in other modes). */ - async reposition(_user: User, id: string, input: RepositionPageInput): Promise { + async reposition(user: User, id: string, input: RepositionPageInput): Promise { const page = await this.findLivePage(id); const { afterId, beforeId } = input; if (afterId === id || beforeId === id) { @@ -454,8 +505,22 @@ export class PagesService { // An absent `parentId` leaves the parent untouched; a present one (page id // or null-for-root) reparents atomically with the placement (issue #106). const parentId = input.parentId === page.parentId ? undefined : input.parentId; + // Moving under a higher-classified parent raises the moved subtree to + // that level (#205, ADR 0022) — never the other way around: a move can + // raise but must not lower as a side effect. Plan before placing (the + // tree walk needs the pre-move tree), apply after. + let raisePlan: { subtreeIds: string[]; to: PageClassification } | null = null; if (parentId != null) { - this.assertValidParent(await this.livePageTree(page.pondId), parentId, id); + const tree = await this.livePageTree(page.pondId); + this.assertValidParent(tree, parentId, id); + const parent = await this.prisma.page.findUniqueOrThrow({ + where: { id: parentId }, + select: { classification: true }, + }); + const parentLevel = fromDbClassification(parent.classification); + if (classificationRank(parentLevel) > 0) { + raisePlan = { subtreeIds: [...collectSubtreeIds(tree, id)], to: parentLevel }; + } } const [afterPage, beforePage] = await Promise.all([ @@ -476,14 +541,59 @@ export class PagesService { if (beforeId && !beforePage) throw new NotFoundException(); const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null); + let placed: PageView; if (key !== null) { const updated = await this.prisma.page.update({ where: { id }, data: { sortKey: key, parentId }, }); - return this.viewOf(updated); + placed = this.viewOf(updated); + } else { + placed = await this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId); + } + if (raisePlan) { + await this.raiseSubtree(user, page.pondId, raisePlan.subtreeIds, raisePlan.to); + return this.viewOf(await this.prisma.page.findUniqueOrThrow({ where: { id } })); + } + return placed; + } + + /** + * Raise every page of a moved subtree that sits below `to` (#205). The + * whole subtree is covered, not just its root: descendants must not end + * up below the level of the tree above them. With the current two-level + * vocabulary "below `to`" is exactly `UNCLASSIFIED` rows. Every raised + * page gets its own audit record (old value, new value, actor, page). + */ + private async raiseSubtree( + user: User, + pondId: string, + subtreeIds: string[], + to: PageClassification, + ): Promise { + const below = await this.prisma.page.findMany({ + where: { id: { in: subtreeIds }, classification: { not: toDbClassification(to) } }, + select: { id: true, classification: true }, + }); + if (below.length === 0) return; + await this.prisma.page.updateMany({ + where: { id: { in: below.map((p) => p.id) } }, + data: { classification: toDbClassification(to) }, + }); + for (const raised of below) { + await this.audit.record({ + action: 'page.classification_raised', + actorId: user.id, + targetType: 'page', + targetId: raised.id, + details: { + from: fromDbClassification(raised.classification), + to, + trigger: 'move', + pondId, + }, + }); } - return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId); } /** diff --git a/apps/api/src/permissions/permission.service.ts b/apps/api/src/permissions/permission.service.ts index d169f55..34373fc 100644 --- a/apps/api/src/permissions/permission.service.ts +++ b/apps/api/src/permissions/permission.service.ts @@ -3,6 +3,7 @@ import { PermissionAction, PermissionViewer, PondRole, + canLowerClassification, canSeePond, hasPondRole, resolvePageCapability, @@ -65,6 +66,14 @@ export class PermissionService { return canSeePond(PermissionService.viewerOf(user), grants); } + /** The dedicated capability to lower a page's classification (issue #205, + * ADR 0022) — resolved through the central model (`@dorfteich/shared`), + * never checked ad hoc at a call site. */ + async canLowerClassification(user: User | null, pondId: string): Promise { + const { grants } = await this.pondContext(pondId); + return canLowerClassification(PermissionService.viewerOf(user), grants); + } + /** Does the user hold `role` pond-wide? (`reader` = may see the pond.) */ async hasPondRole(user: User | null, pondId: string, role: RequiredPondRole): Promise { if (role === 'reader') return this.canSeePond(user, pondId); diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 5a12503..bd2f5a8 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -1,6 +1,7 @@ # Audit event catalogue -**Catalogue version 1.0 (2026-07-31, issue #201).** +**Catalogue version 1.1 (2026-07-31; 1.1 adds `page.classification_*`, +issue #205).** This is the operator-facing contract for the audit trail: every event id the application can emit, with its trigger, severity, actor/target @@ -93,6 +94,13 @@ failure), `warning` = feeds detection (suspicious or destructive), | `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — | | `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` | +### Classification (`page.classification_*`, ADR 0022, issue #205) + +| Id | Trigger | Severity | Actor | Target | Fields | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------- | --------------- | ------ | ------------------------------------------------------------------------ | +| `page.classification_raised` | Page's VS-NfD level raised — by an editor, or automatically when moved under a higher-classified parent | notice | the acting user | `page` | `from`, `to` (levels, lowercase), `trigger` (`edit` \| `move`), `pondId` | +| `page.classification_lowered` | Page's VS-NfD level lowered — requires the dedicated capability (pond-wide Pond Admin) | warning | the acting user | `page` | `from`, `to`, `trigger` (`edit`), `pondId` | + ### Content integrity & lifecycle (`file.*`, `pond.*`, `audit.*`) | Id | Trigger | Severity | Actor | Target | Fields | diff --git a/docs/architecture/permissions.md b/docs/architecture/permissions.md index fc9028b..4a20f5b 100644 --- a/docs/architecture/permissions.md +++ b/docs/architecture/permissions.md @@ -87,6 +87,17 @@ Pond "Handbook", user Uma has pond-scope `editor` (allow): - **Search** results are filtered through the same resolution (ADR 0010). - **Plugin API** calls execute with the viewing user's permissions (ADR 0008) — the API enforces this server-side. +- **Classification lowering** (issue #205, ADR 0022): _raising_ a page's + VS-NfD classification is ordinary editorial work and needs only write + permission; _lowering_ it is the sensitive direction (content escapes + marking through it) and requires the dedicated capability + `canLowerClassification` — pond-wide **Pond Admin** (the owner's + standing grant; Site Admin bypasses). The capability lives in the + shared model (`permissions/pond.ts`), never as an ad-hoc check; every + raise and lower is audited (`page.classification_raised` / + `page.classification_lowered`, see `audit-events.md`). The + classification itself changes **no** read/write decision — it is a + marking, not a protection mechanism. ## Performance diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 4323ff1..f4078ae 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -50,7 +50,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Enum-Feld `classification` an `Page`, Migration, Default aus Instance-Setting · 2 AT · #204 -- [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 +- [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 - Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 12c515d..03d1b62 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -109,5 +109,6 @@ "backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.", "backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.", "scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", - "pond_not_found": "Der Teich existiert nicht." + "pond_not_found": "Der Teich existiert nicht.", + "classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich)." } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index fb78570..b422c57 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -109,5 +109,6 @@ "backup_remote_not_configured": "No Nextcloud backup target is configured.", "backup_set_not_found": "The selected backup set was not found.", "scope_required": "This API token does not have the required scope.", - "pond_not_found": "The pond does not exist." + "pond_not_found": "The pond does not exist.", + "classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required)." } diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 9ece5c5..f1d03a1 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -26,6 +26,22 @@ export function classificationMarking(classification: PageClassification): strin return classification === 'vs_nfd' ? 'VS – NUR FÜR DEN DIENSTGEBRAUCH' : null; } +/** Ordering of levels: the index in {@link PAGE_CLASSIFICATIONS} (lowest + * first) — the tree invariant (#205) and archive-level statements (#210) + * compare through this, never through string comparison. */ +export function classificationRank(classification: PageClassification): number { + return PAGE_CLASSIFICATIONS.indexOf(classification); +} + +/** The highest level among `values` (ADR 0022 #210: "the highest + * classification contained"); `unclassified` for an empty list. */ +export function highestClassification(values: PageClassification[]): PageClassification { + return values.reduce( + (max, value) => (classificationRank(value) > classificationRank(max) ? value : max), + 'unclassified' as PageClassification, + ); +} + export const pageTitleSchema = z .string() .trim() @@ -57,6 +73,10 @@ export const updatePageInputSchema = z .object({ title: pageTitleSchema, slug: pageSlugSchema, + /** VS-NfD level (#205): raising is ordinary editorial work (any + * writer); lowering needs the dedicated capability + * (`canLowerClassification`) and is audited. */ + classification: z.enum(PAGE_CLASSIFICATIONS), }) .partial(); export type UpdatePageInput = z.infer; diff --git a/packages/shared/src/permissions/pond.ts b/packages/shared/src/permissions/pond.ts index eb98399..2acc734 100644 --- a/packages/shared/src/permissions/pond.ts +++ b/packages/shared/src/permissions/pond.ts @@ -35,6 +35,19 @@ export function hasPondRole(role: PondRole, viewer: PermissionViewer, grants: Gr return !matching.some((g) => g.effect === 'deny'); } +/** + * The dedicated capability to LOWER a page's VS-NfD classification + * (issue #205, ADR 0022). Lowering is the sensitive direction — content + * escapes marking through it — so it is not part of ordinary write + * capability: it requires the pond-wide Pond Admin role (the owner's + * standing grant; Site Admin bypasses inside {@link hasPondRole}). + * Raising needs no dedicated capability (any writer). Expressed here, in + * the central model, so no caller can invent an ad-hoc check. + */ +export function canLowerClassification(viewer: PermissionViewer, grants: Grant[]): boolean { + return hasPondRole('pond_admin', viewer, grants); +} + /** * May the viewer see that the pond exists (its metadata, label tree, page * list — individual pages still resolve per page)? Any `allow` grant whose