#205: classification inheritance + guarded, audited lowering #264
@ -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' },
|
||||
|
||||
254
apps/api/src/pages/classification-inheritance.e2e.db.test.ts
Normal file
254
apps/api/src/pages/classification-inheritance.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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<PageView> {
|
||||
async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
||||
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<PageView> {
|
||||
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<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.) */
|
||||
async hasPondRole(user: User | null, pondId: string, role: RequiredPondRole): Promise<boolean> {
|
||||
if (role === 'reader') return this.canSeePond(user, pondId);
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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<typeof updatePageInputSchema>;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user