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
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:
parent
7f1c49db53
commit
406886c56c
@ -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%';" | \
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
169
apps/web/e2e/access-rules.spec.ts
Normal file
169
apps/web/e2e/access-rules.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
295
apps/web/src/access/AccessRulesManager.tsx
Normal file
295
apps/web/src/access/AccessRulesManager.tsx
Normal 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
38
apps/web/src/access/use-access-rules.ts
Normal file
38
apps/web/src/access/use-access-rules.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
42
packages/shared/i18n/de/access.json
Normal file
42
packages/shared/i18n/de/access.json
Normal 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."
|
||||||
|
}
|
||||||
42
packages/shared/i18n/en/access.json
Normal file
42
packages/shared/i18n/en/access.json
Normal 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."
|
||||||
|
}
|
||||||
60
packages/shared/src/permissions/conflicts.test.ts
Normal file
60
packages/shared/src/permissions/conflicts.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/shared/src/permissions/conflicts.ts
Normal file
32
packages/shared/src/permissions/conflicts.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user