#205: classification inheritance + guarded, audited lowering #264
@ -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' },
|
||||||
|
|||||||
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,
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 · 8–12 AT · #206–#212
|
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 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
|
||||||
|
|||||||
@ -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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user