Add label- and page-scope access rules UI including deny (#55)
Some checks failed
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Failing after 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s

Pond Admins configure the vision's fine-grained cases through a plain-language
surface, on top of the base roles from #54.

- shared: `AccessRuleView` (a grant enriched with subject/scope display names)
  and pure conflict helpers `scopeSpecificity`/`sameGrantSubject`/
  `isRuleShadowed` (unit-tested) for the client-side shadowed-rule hint. New
  `access` i18n namespace (de+en) with sentence templates (ADR 0012).
- api: `GET /ponds/:id/grants/access-rules` (Pond-Admin) returns the pond's
  grants enriched with each user's display name and each label/page scope's
  name, resolved in one batched query per kind.
- web `access/`: `AccessRulesManager` in Pond Settings — the pond's rules
  grouped by subject and rendered as readable de/en sentences ("Anna may not
  edit pages labeled “Confidential”"), an add form (subject = member or the
  `signed-in`/`public` pseudo-subjects; scope = label from the tree or a
  specific page; role; allow/deny) that warns when a rule would be shadowed by
  a more specific existing one (shared algorithm) and requires an explicit
  confirmation before granting anything to `public`. Semantics are the shared
  resolver's — the UI only reflects permissions.md.
- tests: shared `conflicts.test.ts`; an api db case for the enriched endpoint;
  a browser `access-rules` pack that configures BOTH vision patterns through
  the UI and verifies their effect end to end — "deny label X" (an editor
  loses a labelled page) and "only label Y" (a signed-in non-member, new
  `fixture-viewer`, reads only the labelled pages) — plus the shadow hint and
  the public confirmation, with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-09 21:45:31 +02:00
parent 7f1c49db53
commit 406886c56c
18 changed files with 890 additions and 1 deletions

View File

@ -177,6 +177,17 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/members.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/members.spec.ts
# Three contexts per test (owner + editor + viewer) → reset first.
- name: Reset login rate limit before access-rules pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run access-rules pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/access-rules.spec.ts
- name: Reset login rate limit before offline pack - name: Reset login rate limit before offline pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -7,6 +7,8 @@
* fixture-user active, regular account * fixture-user active, regular account
* fixture-editor active, regular account (a second non-admin for the * fixture-editor active, regular account (a second non-admin for the
* collab permission packs: reader/editor of another's pond) * collab permission packs: reader/editor of another's pond)
* fixture-viewer active, regular account, never a member (for the
* `authenticated`/`public` access-rule cases, issue #55)
* fixture-pending registered but e-mail not verified * fixture-pending registered but e-mail not verified
* *
* All fixture accounts share the password below they exist only on * All fixture accounts share the password below they exist only on
@ -66,6 +68,14 @@ const FIXTURES: FixtureUser[] = [
status: 'ACTIVE', status: 'ACTIVE',
isSiteAdmin: false, isSiteAdmin: false,
}, },
{
// A signed-in, non-admin, non-member account: for `authenticated`/`public`
// access-rule cases (issue #55) where the viewer must be no one's member.
username: 'fixture-viewer',
displayName: 'Fixture Viewer',
status: 'ACTIVE',
isSiteAdmin: false,
},
{ {
username: 'fixture-pending', username: 'fixture-pending',
displayName: 'Fixture Pending', displayName: 'Fixture Pending',

View File

@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import { import {
AccessRuleView,
CreateGrantInput, CreateGrantInput,
GrantView, GrantView,
createGrantInputSchema, createGrantInputSchema,
@ -26,6 +27,13 @@ export class GrantsController {
return this.grants.listGrants(pondId); return this.grants.listGrants(pondId);
} }
/** Grants enriched with subject/scope names for the access-rules UI (#55). */
@Get('access-rules')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async accessRules(@Param('pondId') pondId: string): Promise<AccessRuleView[]> {
return this.grants.listAccessRules(pondId);
}
@Post() @Post()
@RequiresPondRole('pond_admin', { idParam: 'pondId' }) @RequiresPondRole('pond_admin', { idParam: 'pondId' })
async create( async create(

View File

@ -63,6 +63,8 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
afterAll(async () => { afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } }); await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } });
await prisma.page.deleteMany({ where: { pondId: { in: [shared, personal] } } });
await prisma.label.deleteMany({ where: { pondId: { in: [shared, personal] } } });
await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } }); await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, target.id] } } }); await prisma.user.deleteMany({ where: { id: { in: [owner.id, target.id] } } });
await prisma.$disconnect(); await prisma.$disconnect();
@ -101,6 +103,47 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
); );
}); });
it('lists access rules enriched with subject and scope names (#55)', async () => {
const label = await prisma.label.create({
data: { pondId: shared, name: 'Confidential', color: '#64748b' },
});
const page = await prisma.page.create({
data: {
pondId: shared,
slug: `secret-${suffix}`,
title: 'Salaries',
createdBy: owner.id,
sortKey: 'a0',
ydocState: new Uint8Array(),
},
});
await grants.createGrant(owner, shared, {
...editorGrant,
scopeType: 'label',
scopeId: label.id,
effect: 'deny',
});
await grants.createGrant(owner, shared, {
...editorGrant,
role: 'reader',
scopeType: 'page',
scopeId: page.id,
effect: 'allow',
});
const rules = await grants.listAccessRules(shared);
const labelRule = rules.find((r) => r.scopeType === 'label');
const pageRule = rules.find((r) => r.scopeType === 'page');
expect(labelRule).toMatchObject({
subjectName: 'Grant Target',
scopeName: 'Confidential',
effect: 'deny',
});
expect(pageRule).toMatchObject({ subjectName: 'Grant Target', scopeName: 'Salaries' });
// Pond-scope (the editor grant from the first test) carries no scope name.
expect(rules.find((r) => r.scopeType === 'pond')).toMatchObject({ scopeName: null });
});
// The pond_admin-scope rule also has a DB CHECK backstop (in the migration); // The pond_admin-scope rule also has a DB CHECK backstop (in the migration);
// it is not exercised here because the test database is built with `db push`, // it is not exercised here because the test database is built with `db push`,
// which syncs tables/columns but not the raw CHECK constraint. // which syncs tables/columns but not the raw CHECK constraint.

View File

@ -4,7 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Grant, GrantView, grantValidationError } from '@dorfteich/shared'; import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorfteich/shared';
import { Pond, RoleGrant, User } from '@prisma/client'; import { Pond, RoleGrant, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
@ -66,6 +66,59 @@ export class GrantsService {
return rows.map((row) => GrantsService.viewOf(row)); return rows.map((row) => GrantsService.viewOf(row));
} }
/**
* A pond's grants enriched with the display names the access-rules UI renders
* as sentences (issue #55): each user subject's display name and each
* label/page scope's name, resolved in one batched query per kind so the
* client needs no id lookups.
*/
async listAccessRules(pondId: string): Promise<AccessRuleView[]> {
await this.requireLivePond(pondId);
const rows = await this.prisma.roleGrant.findMany({
where: { pondId },
orderBy: { createdAt: 'asc' },
});
const userIds = rows.filter((r) => r.subjectId).map((r) => r.subjectId!);
const labelIds = rows
.filter((r) => r.scopeType === 'LABEL' && r.scopeId)
.map((r) => r.scopeId!);
const pageIds = rows.filter((r) => r.scopeType === 'PAGE' && r.scopeId).map((r) => r.scopeId!);
const [users, labels, pages] = await Promise.all([
this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, displayName: true },
}),
this.prisma.label.findMany({
where: { id: { in: labelIds } },
select: { id: true, name: true },
}),
this.prisma.page.findMany({
where: { id: { in: pageIds } },
select: { id: true, title: true },
}),
]);
const userName = new Map(users.map((u) => [u.id, u.displayName]));
const labelName = new Map(labels.map((l) => [l.id, l.name]));
const pageTitle = new Map(pages.map((p) => [p.id, p.title]));
return rows.map((row) => {
const base = GrantsService.viewOf(row);
const scopeName =
row.scopeType === 'LABEL'
? (labelName.get(row.scopeId ?? '') ?? null)
: row.scopeType === 'PAGE'
? (pageTitle.get(row.scopeId ?? '') ?? null)
: null;
return {
...base,
subjectName: row.subjectId ? (userName.get(row.subjectId) ?? null) : null,
scopeName,
};
});
}
/** The grant must point at things that exist in this pond a label/page /** The grant must point at things that exist in this pond a label/page
* from elsewhere would silently never match during resolution. */ * from elsewhere would silently never match during resolution. */
private async assertScopeAndSubjectExist(pondId: string, grant: Grant): Promise<void> { private async assertScopeAndSubjectExist(pondId: string, grant: Grant): Promise<void> {

View File

@ -47,6 +47,7 @@ only on dev machines and disposable CI/Test databases.
| `fixture-admin` | active, Site Admin | admin UI/permissions cases | | `fixture-admin` | active, Site Admin | admin UI/permissions cases |
| `fixture-user` | active | regular journeys, settings, sessions | | `fixture-user` | active | regular journeys, settings, sessions |
| `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) | | `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) |
| `fixture-viewer` | active | signed-in non-member for `authenticated`/`public` access-rule cases (issue #55) |
| `fixture-pending` | e-mail not verified | unverified-login cases | | `fixture-pending` | e-mail not verified | unverified-login cases |
## Content fixtures ## Content fixtures

View File

@ -0,0 +1,169 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext, BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Fine-grained access rules (issue #55): both vision patterns are configured
* through the pond-settings UI and their effect is verified end to end
* "deny label X" (an editor loses a labelled page) and "only label Y" (a
* signed-in non-member reads only the labelled pages). Also covers the
* shadowed-rule hint and the public confirmation.
*/
async function json<T>(
ctx: APIRequestContext,
method: 'post',
path: string,
data: unknown,
): Promise<T> {
const res = await ctx[method](path, { data });
if (!res.ok()) throw new Error(`${method} ${path}${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
const meId = async (ctx: BrowserContext): Promise<string> =>
((await (await ctx.request.get('/api/v1/auth/me')).json()) as { id: string }).id;
const tokenStatus = async (ctx: BrowserContext, pageId: string): Promise<number> =>
(await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`)).status();
const tokenMode = async (ctx: BrowserContext, pageId: string): Promise<string | number> => {
const res = await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`);
return res.ok() ? ((await res.json()) as { mode: string }).mode : res.status();
};
test('both vision patterns are configurable in the UI and take effect', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer');
const editorId = await meId(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', {
name: `Access ${Date.now()}`,
});
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const labelX = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Confidential',
},
);
const labelY = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Handbook',
},
);
const px = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Secret',
});
const py = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Guide',
});
const pz = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Misc',
});
await json(owner.request, 'post', `/api/v1/pages/${px.id}/labels`, { labelId: labelX.id });
await json(owner.request, 'post', `/api/v1/pages/${py.id}/labels`, { labelId: labelY.id });
// Baseline: the editor may write every page.
expect(await tokenMode(editor, px.id)).toBe('rw');
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// Pattern 1 — "deny label X": the editor may not touch Confidential pages.
await page.locator('.rule-add__subject').selectOption(`user:${editorId}`);
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(labelX.id);
await page.locator('.rule-add__role').selectOption('editor');
await page.locator('.rule-add__effect').selectOption('deny');
await page.locator('.rule-add__submit').click();
await expect(page.locator('.rule-sentence', { hasText: 'Confidential' })).toBeVisible();
await expect.poll(() => tokenStatus(editor, px.id)).toBe(404); // labelled page now hidden
expect(await tokenMode(editor, pz.id)).toBe('rw'); // unlabelled page still editable
// Pattern 2 — "only label Y": signed-in users read only Handbook pages.
await page.locator('.rule-add__subject').selectOption('authenticated');
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(labelY.id);
await page.locator('.rule-add__role').selectOption('reader');
await page.locator('.rule-add__effect').selectOption('allow');
await page.locator('.rule-add__submit').click();
await expect(page.locator('.rule-sentence', { hasText: 'Handbook' })).toBeVisible();
// A signed-in non-member reads the labelled page, nothing else.
await expect.poll(() => tokenMode(viewer, py.id)).toBe('ro');
expect(await tokenStatus(viewer, px.id)).toBe(404);
expect(await tokenStatus(viewer, pz.id)).toBe(404);
await owner.close();
await editor.close();
await viewer.close();
});
test('hints at shadowed rules and confirms before going public', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const editorId = await meId(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', {
name: `Access2 ${Date.now()}`,
});
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const label = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Docs',
},
);
const pg = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Page',
});
// A more specific existing rule (page-scope deny) will shadow a label-scope allow.
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: editorId,
role: 'editor',
scopeType: 'page',
scopeId: pg.id,
effect: 'deny',
});
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// Shadowed: a label-scope allow for the same editor, opposite to the page deny.
await page.locator('.rule-add__subject').selectOption(`user:${editorId}`);
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(label.id);
await page.locator('.rule-add__role').selectOption('editor');
await page.locator('.rule-add__effect').selectOption('allow');
await expect(page.locator('.rule-add__shadow')).toBeVisible();
// Public + allow: a confirmation gate blocks submission until acknowledged.
await page.locator('.rule-add__subject').selectOption('public');
await expect(page.locator('.rule-add__public-warning')).toBeVisible();
await expect(page.locator('.rule-add__submit')).toBeDisabled();
await page.locator('.rule-add__public-confirm').check();
await expect(page.locator('.rule-add__submit')).toBeEnabled();
await owner.close();
await editor.close();
});

View File

@ -0,0 +1,295 @@
import type {
AccessRuleView,
CreateGrantInput,
Grant,
GrantSubjectType,
PageListItemView,
} from '@dorfteich/shared';
import { isRuleShadowed } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError, apiGet } from '../lib/api';
import { usePondLabels } from '../labels/use-pond-labels';
import { usePondMembers } from '../members/use-pond-members';
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
type ScopedType = 'label' | 'page';
/** Renders one grant as a readable de/en sentence (issue #55, ADR 0012). */
export function ruleSentence(rule: AccessRuleView, t: TFunction): string {
const subject = t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' });
const ability = t(`ability.${rule.role}`);
const scope = t(`scope.${rule.scopeType}`, { name: rule.scopeName ?? '' });
return t(`sentence.${rule.effect}`, { subject, ability, scope });
}
const subjectValue = (type: GrantSubjectType, id: string | null): string =>
type === 'user' ? `user:${id}` : type;
const parseSubject = (value: string): { type: GrantSubjectType; id: string | null } =>
value.startsWith('user:')
? { type: 'user', id: value.slice(5) }
: { type: value as GrantSubjectType, id: null };
/**
* Pond-settings access-rules management (issue #55): fine-grained label/page
* rules (allow/deny) for members and the `signed-in`/`public` subjects, on top
* of the base roles from #54. Rules render as sentences, not raw tuples; the
* add form warns when a rule would be shadowed by a more specific one (shared
* algorithm) and confirms before opening anything to the public. The api
* enforces the semantics this UI only reflects permissions.md.
*/
export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.Element | null {
const { t } = useTranslation('access');
const { t: tErrors } = useTranslation('errors');
const rulesQuery = useAccessRules(pondId);
const mutations = useAccessRuleMutations(pondId);
const members = usePondMembers(pondId);
const { flat: labels } = usePondLabels(pondId);
const pages = useQuery({
queryKey: ['pages', pondId],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pondId}/pages`),
});
const [subject, setSubject] = useState('');
const [scopeType, setScopeType] = useState<ScopedType>('label');
const [scopeId, setScopeId] = useState('');
const [role, setRole] = useState<'reader' | 'editor'>('editor');
const [effect, setEffect] = useState<'allow' | 'deny'>('allow');
const [publicConfirmed, setPublicConfirmed] = useState(false);
const [error, setError] = useState<string | null>(null);
const rules = rulesQuery.data ?? [];
const grouped = useMemo(() => groupBySubject(rules), [rules]);
// A non-admin gets 403 here — render nothing (the member list stays visible).
if (rulesQuery.error instanceof ApiError && rulesQuery.error.status === 403) return null;
const parsed = subject ? parseSubject(subject) : null;
const candidate: Grant | null =
parsed && scopeId
? {
subjectType: parsed.type,
subjectId: parsed.id,
role,
scopeType,
scopeId,
effect,
}
: null;
const shadowed = candidate ? isRuleShadowed(candidate, rules) : false;
const isPublic = parsed?.type === 'public';
const needsPublicConfirm = isPublic && effect === 'allow' && !publicConfirmed;
const canSubmit = Boolean(candidate) && !needsPublicConfirm;
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!candidate || !parsed) return;
setError(null);
const input: CreateGrantInput = {
subjectType: parsed.type,
subjectId: parsed.id ?? undefined,
role,
scopeType,
scopeId,
effect,
};
try {
await mutations.add(input);
setScopeId('');
setPublicConfirmed(false);
} catch (err) {
setError(
err instanceof ApiError
? tErrors(err.body.code, { defaultValue: err.body.message, ...(err.body.details ?? {}) })
: tErrors('internal_error'),
);
}
};
const scopeOptions =
scopeType === 'label'
? labels.map((l) => ({ id: l.id, name: l.name }))
: (pages.data ?? []).map((p) => ({ id: p.id, name: p.title }));
if (rulesQuery.isLoading) return null;
return (
<section className="access-rules">
<h2>{t('title')}</h2>
<p className="access-rules__intro">{t('description')}</p>
<form className="rule-add" onSubmit={submit}>
<h3 className="rule-add__title">{t('add.title')}</h3>
<div className="rule-add__row">
<label className="rule-add__field">
<span>{t('add.subject')}</span>
<select
className="rule-add__subject"
value={subject}
onChange={(e) => {
setSubject(e.target.value);
setPublicConfirmed(false);
}}
>
<option value="" disabled>
</option>
{(members.data?.members ?? [])
.filter((m) => !m.isOwner)
.map((m) => (
<option key={m.userId} value={subjectValue('user', m.userId)}>
{m.displayName}
</option>
))}
<option value="authenticated">{t('subject.authenticated')}</option>
<option value="public">{t('subject.public', { name: '' })}</option>
</select>
</label>
<label className="rule-add__field">
<span>{t('add.scopeType')}</span>
<select
className="rule-add__scope-type"
value={scopeType}
onChange={(e) => {
setScopeType(e.target.value as ScopedType);
setScopeId('');
}}
>
<option value="label">{t('add.scopeLabel')}</option>
<option value="page">{t('add.scopePage')}</option>
</select>
</label>
<label className="rule-add__field">
<span>{scopeType === 'label' ? t('add.scopeLabel') : t('add.scopePage')}</span>
<select
className="rule-add__scope-target"
value={scopeId}
onChange={(e) => setScopeId(e.target.value)}
>
<option value="" disabled>
{t('add.pickScope')}
</option>
{scopeOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</label>
<label className="rule-add__field">
<span>{t('add.role')}</span>
<select
className="rule-add__role"
value={role}
onChange={(e) => setRole(e.target.value as 'reader' | 'editor')}
>
<option value="reader">{t('ability.reader')}</option>
<option value="editor">{t('ability.editor')}</option>
</select>
</label>
<label className="rule-add__field">
<span>{t('add.effect')}</span>
<select
className="rule-add__effect"
value={effect}
onChange={(e) => setEffect(e.target.value as 'allow' | 'deny')}
>
<option value="allow">{t('add.allow')}</option>
<option value="deny">{t('add.deny')}</option>
</select>
</label>
</div>
{shadowed && (
<p className="rule-add__shadow form-banner form-banner--info" role="note">
{t('add.shadowedWarning')}
</p>
)}
{isPublic && effect === 'allow' && (
<label className="rule-add__public-warning form-banner form-banner--error">
<input
type="checkbox"
className="rule-add__public-confirm"
checked={publicConfirmed}
onChange={(e) => setPublicConfirmed(e.target.checked)}
/>
<span>
{t('add.publicWarning')} {t('add.publicConfirm')}
</span>
</label>
)}
{error && (
<p className="form-banner form-banner--error" role="alert">
{error}
</p>
)}
<button className="button rule-add__submit" type="submit" disabled={!canSubmit}>
{t('add.submit')}
</button>
</form>
{rules.length === 0 ? (
<p className="access-rules__empty">{t('noRules')}</p>
) : (
<ul className="rule-groups">
{grouped.map((group) => (
<li key={group.key} className="rule-group">
<h4 className="rule-group__subject">{group.subjectLabel(t)}</h4>
<ul className="rule-group__list">
{group.rules.map((rule) => (
<li key={rule.id} className="rule-item">
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button
type="button"
className="button rule-remove"
onClick={() => void mutations.remove(rule.id)}
>
{t('remove')}
</button>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</section>
);
}
interface RuleGroup {
key: string;
subjectLabel: (t: TFunction) => string;
rules: AccessRuleView[];
}
/** Groups rules by subject, ordering users first, then signed-in, then public. */
function groupBySubject(rules: AccessRuleView[]): RuleGroup[] {
const order: Record<GrantSubjectType, number> = { user: 0, authenticated: 1, public: 2 };
const map = new Map<string, RuleGroup>();
for (const rule of rules) {
const key = `${rule.subjectType}:${rule.subjectId ?? ''}`;
let group = map.get(key);
if (!group) {
group = {
key,
subjectLabel: (t) => t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' }),
rules: [],
};
map.set(key, group);
}
group.rules.push(rule);
}
return [...map.values()].sort((a, b) => {
const at = a.key.split(':')[0] as GrantSubjectType;
const bt = b.key.split(':')[0] as GrantSubjectType;
return order[at] - order[bt];
});
}

View File

@ -0,0 +1,38 @@
import type { AccessRuleView, CreateGrantInput } from '@dorfteich/shared';
import { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { apiDelete, apiGet, apiPost } from '../lib/api';
/** Query key for a pond's enriched access rules (issue #55). */
export const accessRulesKey = (pondId: string): string[] => ['access-rules', pondId];
/** The pond's grants enriched with subject/scope names (Pond-Admin only). */
export function useAccessRules(pondId: string | undefined): UseQueryResult<AccessRuleView[]> {
return useQuery({
queryKey: accessRulesKey(pondId ?? ''),
queryFn: () => apiGet<AccessRuleView[]>(`/ponds/${pondId}/grants/access-rules`),
enabled: Boolean(pondId),
retry: false, // a non-admin gets 403 — do not hammer the endpoint
});
}
/** Add/remove access-rule grants; each refreshes the rule list. */
export function useAccessRuleMutations(pondId: string): {
add: (input: CreateGrantInput) => Promise<void>;
remove: (grantId: string) => Promise<void>;
} {
const queryClient = useQueryClient();
const invalidate = (): Promise<void> =>
queryClient.invalidateQueries({ queryKey: accessRulesKey(pondId) });
return {
add: async (input) => {
await apiPost(`/ponds/${pondId}/grants`, input);
await invalidate();
},
remove: async (grantId) => {
await apiDelete(`/ponds/${pondId}/grants/${grantId}`);
await invalidate();
},
};
}

View File

@ -1,3 +1,4 @@
import deAccess from '@dorfteich/shared/i18n/de/access.json';
import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json';
@ -7,6 +8,7 @@ import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json';
@ -34,6 +36,7 @@ void i18n
en: { en: {
common: enCommon, common: enCommon,
errors: enErrors, errors: enErrors,
access: enAccess,
auth: enAuth, auth: enAuth,
settings: enSettings, settings: enSettings,
editor: enEditor, editor: enEditor,
@ -45,6 +48,7 @@ void i18n
de: { de: {
common: deCommon, common: deCommon,
errors: deErrors, errors: deErrors,
access: deAccess,
auth: deAuth, auth: deAuth,
settings: deSettings, settings: deSettings,
editor: deEditor, editor: deEditor,

View File

@ -7,6 +7,7 @@ import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { LabelManager } from '../labels/LabelManager'; import { LabelManager } from '../labels/LabelManager';
import { PhantomPagesView } from '../links/PhantomPagesView'; import { PhantomPagesView } from '../links/PhantomPagesView';
import { AccessRulesManager } from '../access/AccessRulesManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
@ -43,6 +44,7 @@ export function PondSettingsPage(): React.JSX.Element {
<h2>{tMembers('title')}</h2> <h2>{tMembers('title')}</h2>
<MemberManager pondId={pond.data.id} /> <MemberManager pondId={pond.data.id} />
</section> </section>
<AccessRulesManager pondId={pond.data.id} />
<section> <section>
<h2>{t('settings.title')}</h2> <h2>{t('settings.title')}</h2>
{canModify ? ( {canModify ? (

View File

@ -1546,3 +1546,69 @@ button {
border-radius: 2px; border-radius: 2px;
padding: 0 2px; padding: 0 2px;
} }
/* Access rules (issue #55) */
.access-rules__intro,
.access-rules__empty {
color: var(--color-text-muted);
margin-bottom: var(--space-3);
}
.rule-add {
border: 1px solid var(--color-border, rgba(127, 127, 127, 0.25));
border-radius: var(--radius-sm, 0.375rem);
padding: var(--space-3);
margin-bottom: var(--space-4);
}
.rule-add__title {
margin: 0 0 var(--space-2);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.rule-add__row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.rule-add__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 0.875rem;
}
.rule-add__public-warning {
display: flex;
align-items: center;
gap: var(--space-2);
}
.rule-groups,
.rule-group__list {
list-style: none;
margin: 0;
padding: 0;
}
.rule-group {
margin-bottom: var(--space-3);
}
.rule-group__subject {
margin: 0 0 var(--space-1);
font-size: 0.9375rem;
}
.rule-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-1) 0;
}

View File

@ -0,0 +1,42 @@
{
"title": "Zugriffsregeln",
"description": "Feingranulare Regeln zusätzlich zu den Basisrollen. Die spezifischere Regel gewinnt: eine Regel auf einer Seite schlägt eine auf einem Label, und die schlägt eine für den ganzen Teich.",
"subject": {
"user": "{{name}}",
"authenticated": "Angemeldete Personen",
"public": "Alle (öffentlich)"
},
"ability": {
"reader": "lesen",
"editor": "bearbeiten",
"pond_admin": "verwalten"
},
"scope": {
"pond": "alles in diesem Teich",
"label": "Seiten mit dem Label „{{name}}“",
"page": "die Seite „{{name}}“"
},
"sentence": {
"allow": "{{subject}} darf {{scope}} {{ability}}.",
"deny": "{{subject}} darf {{scope}} nicht {{ability}}."
},
"noRules": "Noch keine Regeln — es gelten nur die Basisrollen.",
"add": {
"title": "Regel hinzufügen",
"subject": "Wer",
"scopeType": "Wo",
"scopeLabel": "Label",
"scopePage": "Seite",
"role": "Recht",
"effect": "Wirkung",
"allow": "Erlauben",
"deny": "Verbieten",
"submit": "Regel hinzufügen",
"pickScope": "Wähle ein Label oder eine Seite.",
"shadowedWarning": "Eine spezifischere Regel für dieses Subjekt entscheidet hier bereits das Gegenteil — diese Regel bleibt womöglich wirkungslos.",
"publicWarning": "Damit werden Inhalte für alle im Internet sichtbar. Bist du sicher?",
"publicConfirm": "Ja, öffentlich machen"
},
"remove": "Entfernen",
"loadError": "Zugriffsregeln konnten nicht geladen werden."
}

View File

@ -0,0 +1,42 @@
{
"title": "Access rules",
"description": "Fine-grained rules on top of the base roles. More specific rules win: a rule on a page beats one on a label, which beats one on the whole pond.",
"subject": {
"user": "{{name}}",
"authenticated": "Signed-in users",
"public": "Everyone (public)"
},
"ability": {
"reader": "read",
"editor": "edit",
"pond_admin": "administer"
},
"scope": {
"pond": "everything in this pond",
"label": "pages labeled “{{name}}”",
"page": "the page “{{name}}”"
},
"sentence": {
"allow": "{{subject}} may {{ability}} {{scope}}.",
"deny": "{{subject}} may not {{ability}} {{scope}}."
},
"noRules": "No rules yet — only the base roles apply.",
"add": {
"title": "Add a rule",
"subject": "Who",
"scopeType": "Where",
"scopeLabel": "Label",
"scopePage": "Page",
"role": "Ability",
"effect": "Effect",
"allow": "Allow",
"deny": "Deny",
"submit": "Add rule",
"pickScope": "Choose a label or page.",
"shadowedWarning": "A more specific rule for this subject already decides the opposite here — this rule may have no effect.",
"publicWarning": "This makes content visible to everyone on the internet. Are you sure?",
"publicConfirm": "Yes, make it public"
},
"remove": "Remove",
"loadError": "Access rules could not be loaded."
}

View File

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { isRuleShadowed, sameGrantSubject, scopeSpecificity } from './conflicts';
import type { Grant } from './types';
const grant = (over: Partial<Grant>): Grant => ({
subjectType: 'user',
subjectId: 'u1',
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
...over,
});
describe('scopeSpecificity', () => {
it('orders page > label > pond', () => {
expect(scopeSpecificity('page')).toBeGreaterThan(scopeSpecificity('label'));
expect(scopeSpecificity('label')).toBeGreaterThan(scopeSpecificity('pond'));
});
});
describe('sameGrantSubject', () => {
it('matches the same user and the same pseudo-subject', () => {
expect(sameGrantSubject(grant({}), grant({ role: 'reader' }))).toBe(true);
expect(
sameGrantSubject(
grant({ subjectType: 'public', subjectId: null }),
grant({ subjectType: 'public', subjectId: null }),
),
).toBe(true);
});
it('distinguishes different users and subject types', () => {
expect(sameGrantSubject(grant({}), grant({ subjectId: 'u2' }))).toBe(false);
expect(
sameGrantSubject(grant({}), grant({ subjectType: 'authenticated', subjectId: null })),
).toBe(false);
});
});
describe('isRuleShadowed', () => {
it('flags a pond rule shadowed by an opposite, more specific label rule for the same subject', () => {
const existing = [grant({ scopeType: 'label', scopeId: 'l1', effect: 'deny' })];
expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(true);
});
it('does not flag when the more specific rule agrees in effect', () => {
const existing = [grant({ scopeType: 'label', scopeId: 'l1', effect: 'allow' })];
expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(false);
});
it('does not flag a more specific candidate against a less specific existing rule', () => {
const existing = [grant({ scopeType: 'pond', effect: 'deny' })];
expect(
isRuleShadowed(grant({ scopeType: 'page', scopeId: 'p1', effect: 'allow' }), existing),
).toBe(false);
});
it('ignores rules for other subjects', () => {
const existing = [grant({ subjectId: 'u2', scopeType: 'page', scopeId: 'p1', effect: 'deny' })];
expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(false);
});
});

View File

@ -0,0 +1,32 @@
import type { Grant } from './types';
/**
* Scope specificity, most specific highest the ordering the resolver uses
* (permissions.md §Resolution: page > label > pond). Shared so the UI's
* conflict hint (issue #55) reasons about grants the same way the server does.
*/
export function scopeSpecificity(scopeType: Grant['scopeType']): number {
return scopeType === 'page' ? 2 : scopeType === 'label' ? 1 : 0;
}
/** Whether two grants target the same subject (same user, or same pseudo-subject). */
export function sameGrantSubject(a: Grant, b: Grant): boolean {
if (a.subjectType !== b.subjectType) return false;
return a.subjectType !== 'user' || a.subjectId === b.subjectId;
}
/**
* A client-side hint (issue #55 acceptance criterion): a candidate rule is
* "shadowed" when the same subject already holds a rule at a strictly more
* specific scope with the opposite effect on the objects both touch, the more
* specific existing rule wins, so the candidate changes nothing there. Pure and
* testable; the server remains the source of truth (permissions.md).
*/
export function isRuleShadowed(candidate: Grant, existing: Grant[]): boolean {
return existing.some(
(g) =>
sameGrantSubject(g, candidate) &&
scopeSpecificity(g.scopeType) > scopeSpecificity(candidate.scopeType) &&
g.effect !== candidate.effect,
);
}

View File

@ -3,3 +3,4 @@ export * from './resolve';
export * from './pond'; export * from './pond';
export * from './schemas'; export * from './schemas';
export * from './validate'; export * from './validate';
export * from './conflicts';

View File

@ -34,6 +34,18 @@ export interface GrantView extends Grant {
createdAt: string; createdAt: string;
} }
/**
* A grant enriched with the display names the access-rules UI renders as
* sentences (issue #55): the subject's display name and the scoped label/page
* name. Names are resolved server-side so the client needs no id lookups.
*/
export interface AccessRuleView extends GrantView {
/** The user subject's display name; `null` for `authenticated`/`public`. */
subjectName: string | null;
/** Label name or page title for label/page scope; `null` at pond scope. */
scopeName: string | null;
}
/** The parsed input as the resolver's grant shape (nullish → null). */ /** The parsed input as the resolver's grant shape (nullish → null). */
export function grantOfInput(input: CreateGrantInput): Grant { export function grantOfInput(input: CreateGrantInput): Grant {
return { return {