#205: classification inheritance + guarded, audited lowering #264

Merged
fable-5 merged 1 commits from issue-205-classification-inheritance into main 2026-07-31 06:49:15 +02:00
11 changed files with 444 additions and 15 deletions
Showing only changes of commit 488d0d06f1 - Show all commits

View File

@ -31,6 +31,8 @@ export const AUDIT_EVENTS = {
'member.added': { severity: 'notice' }, 'member.added': { severity: 'notice' },
'member.removed': { severity: 'notice' }, 'member.removed': { severity: 'notice' },
'member.role_changed': { severity: 'notice' }, 'member.role_changed': { severity: 'notice' },
'page.classification_lowered': { severity: 'warning' },
'page.classification_raised': { severity: 'notice' },
'plugin.installed': { severity: 'notice' }, 'plugin.installed': { severity: 'notice' },
'plugin.mode_set': { severity: 'notice' }, 'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' }, 'plugin.pond_toggled': { severity: 'info' },

View File

@ -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<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
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<string> {
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');
});
});

View File

@ -22,6 +22,7 @@ import {
TaskToggleRequest, TaskToggleRequest,
TreeItem, TreeItem,
UpdatePageInput, UpdatePageInput,
classificationRank,
collectSubtreeIds, collectSubtreeIds,
nodeDepth, nodeDepth,
pondSettingsSchema, pondSettingsSchema,
@ -33,6 +34,7 @@ import { Page, Prisma, User } from '@prisma/client';
import { generateKeyBetween } from 'fractional-indexing'; import { generateKeyBetween } from 'fractional-indexing';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -58,6 +60,15 @@ function contentCacheData(
* (realtime-collaboration.md). 60 s is the ceiling the story specifies. */ * (realtime-collaboration.md). 60 s is the ceiling the story specifies. */
const COLLAB_TOKEN_TTL_SECONDS = 60; 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() @Injectable()
export class PagesService { export class PagesService {
constructor( constructor(
@ -68,6 +79,7 @@ export class PagesService {
private readonly search: SearchProvider, private readonly search: SearchProvider,
private readonly watches: WatchesService, private readonly watches: WatchesService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
) { ) {
this.logger.setContext(PagesService.name); this.logger.setContext(PagesService.name);
} }
@ -80,7 +92,7 @@ export class PagesService {
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
sortKey: page.sortKey, sortKey: page.sortKey,
classification: page.classification.toLowerCase() as PageClassification, classification: fromDbClassification(page.classification),
createdAt: page.createdAt.toISOString(), createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(), updatedAt: page.updatedAt.toISOString(),
deletedAt: page.deletedAt?.toISOString() ?? null, deletedAt: page.deletedAt?.toISOString() ?? null,
@ -303,9 +315,22 @@ export class PagesService {
}); });
const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
const content = deriveContent(state); const content = deriveContent(state);
// New pages start at the instance-wide default level (ADR 0022, #204); // A new page starts at the instance-wide default level (ADR 0022, #204),
// inheritance of the parent's level arrives with #205 and wins over this. // raised to its parent's level when that is higher (#205): a subpage of
const defaultClassification = await this.settings.get('classification.newPageDefault'); // 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({ const page = await this.prisma.page.create({
data: { data: {
@ -314,7 +339,7 @@ export class PagesService {
title, title,
slug, slug,
sortKey, sortKey,
classification: defaultClassification === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED', classification: toDbClassification(classification),
ydocState: state, ydocState: state,
createdBy: user.id, createdBy: user.id,
contentCache: { create: contentCacheData(content) }, contentCache: { create: contentCacheData(content) },
@ -403,7 +428,7 @@ export class PagesService {
return this.stateViewOf(page); return this.stateViewOf(page);
} }
async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> { async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
let slug = page.slug; let slug = page.slug;
@ -419,10 +444,36 @@ export class PagesService {
slug = normalized; 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({ const updated = await this.prisma.page.update({
where: { id: page.id }, 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). // 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); if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
// A changed title changes the (weighted) search entry (#49). // 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 * Requires write access; the sort mode does not have to be `manual` (the key
* is stored regardless, just not applied in other modes). * is stored regardless, just not applied in other modes).
*/ */
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> { async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
const { afterId, beforeId } = input; const { afterId, beforeId } = input;
if (afterId === id || beforeId === id) { if (afterId === id || beforeId === id) {
@ -454,8 +505,22 @@ export class PagesService {
// An absent `parentId` leaves the parent untouched; a present one (page id // An absent `parentId` leaves the parent untouched; a present one (page id
// or null-for-root) reparents atomically with the placement (issue #106). // or null-for-root) reparents atomically with the placement (issue #106).
const parentId = input.parentId === page.parentId ? undefined : input.parentId; 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) { 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([ const [afterPage, beforePage] = await Promise.all([
@ -476,14 +541,59 @@ export class PagesService {
if (beforeId && !beforePage) throw new NotFoundException(); if (beforeId && !beforePage) throw new NotFoundException();
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null); const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
let placed: PageView;
if (key !== null) { if (key !== null) {
const updated = await this.prisma.page.update({ const updated = await this.prisma.page.update({
where: { id }, where: { id },
data: { sortKey: key, parentId }, 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<void> {
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);
} }
/** /**

View File

@ -3,6 +3,7 @@ import {
PermissionAction, PermissionAction,
PermissionViewer, PermissionViewer,
PondRole, PondRole,
canLowerClassification,
canSeePond, canSeePond,
hasPondRole, hasPondRole,
resolvePageCapability, resolvePageCapability,
@ -65,6 +66,14 @@ export class PermissionService {
return canSeePond(PermissionService.viewerOf(user), grants); 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<boolean> {
const { grants } = await this.pondContext(pondId);
return canLowerClassification(PermissionService.viewerOf(user), grants);
}
/** Does the user hold `role` pond-wide? (`reader` = may see the pond.) */ /** Does the user hold `role` pond-wide? (`reader` = may see the pond.) */
async hasPondRole(user: User | null, pondId: string, role: RequiredPondRole): Promise<boolean> { async hasPondRole(user: User | null, pondId: string, role: RequiredPondRole): Promise<boolean> {
if (role === 'reader') return this.canSeePond(user, pondId); if (role === 'reader') return this.canSeePond(user, pondId);

View File

@ -1,6 +1,7 @@
# Audit event catalogue # 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 This is the operator-facing contract for the audit trail: every event id
the application can emit, with its trigger, severity, actor/target 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) | — | | `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` | | `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.*`) ### Content integrity & lifecycle (`file.*`, `pond.*`, `audit.*`)
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |

View File

@ -87,6 +87,17 @@ Pond "Handbook", user Uma has pond-scope `editor` (allow):
- **Search** results are filtered through the same resolution (ADR 0010). - **Search** results are filtered through the same resolution (ADR 0010).
- **Plugin API** calls execute with the viewing user's permissions - **Plugin API** calls execute with the viewing user's permissions
(ADR 0008) — the API enforces this server-side. (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 ## Performance

View File

@ -50,7 +50,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
- [x] Enum-Feld `classification` an `Page`, Migration, Default aus - [x] Enum-Feld `classification` an `Page`, Migration, Default aus
Instance-Setting · 2 AT · #204 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 · 812 AT · #206#212 - [ ] Durchreichen in alle Ausgabekanäle · 812 AT · #206#212
- Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - Web-Ansicht (Kopf/Fuß) · 1 AT · #206
- **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207

View File

@ -109,5 +109,6 @@
"backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.", "backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.",
"backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.", "backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.",
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", "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)."
} }

View File

@ -109,5 +109,6 @@
"backup_remote_not_configured": "No Nextcloud backup target is configured.", "backup_remote_not_configured": "No Nextcloud backup target is configured.",
"backup_set_not_found": "The selected backup set was not found.", "backup_set_not_found": "The selected backup set was not found.",
"scope_required": "This API token does not have the required scope.", "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)."
} }

View File

@ -26,6 +26,22 @@ export function classificationMarking(classification: PageClassification): strin
return classification === 'vs_nfd' ? 'VS NUR FÜR DEN DIENSTGEBRAUCH' : null; 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 export const pageTitleSchema = z
.string() .string()
.trim() .trim()
@ -57,6 +73,10 @@ export const updatePageInputSchema = z
.object({ .object({
title: pageTitleSchema, title: pageTitleSchema,
slug: pageSlugSchema, 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(); .partial();
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>; export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;

View File

@ -35,6 +35,19 @@ export function hasPondRole(role: PondRole, viewer: PermissionViewer, grants: Gr
return !matching.some((g) => g.effect === 'deny'); 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 * May the viewer see that the pond exists (its metadata, label tree, page
* list individual pages still resolve per page)? Any `allow` grant whose * list individual pages still resolve per page)? Any `allow` grant whose