Add effective-permissions inspector (#57)
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Pond Admins can answer "what can X actually see/do here?" — load-bearing for trust in the grant system (permissions.md §UI obligations). - shared: `explainPageCapability` — the resolver's decision chain (deciding level + the single deciding grant), sharing one code path with the boolean `resolvePageCapability` (now a thin wrapper), so the trace can never diverge from real access. Unit-tested against the permissions.md worked examples. - api: `GET /ponds/:id/effective-permissions?subjectType=&subjectId=&pageId=` (Pond-Admin-gated, one pond only) resolves as the chosen subject (a user with their real Site-Admin flag, all signed-in users, or the public), optionally against a page, and returns the read + write outcome with the deciding rule enriched with subject/scope names. - web: `EffectivePermissionsInspector` in Pond Settings — pick a subject and optionally a page → see the resolved read/edit verdict, the level that decided it, and the deciding rule spelled out as a de/en sentence (reusing the #55 sentence renderer). Hidden from non-admins. - tests: explain-mode unit tests (worked examples + trace-matches-boolean); `inspector.e2e.db.test.ts` (deciding rule on a labelled page, pond-level base capability, public default-closed, Pond-Admin gating); a browser assertion in the access-rules pack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
fc41c91003
commit
f5f1310eb2
@ -4,17 +4,19 @@ import { PondsModule } from '../ponds/ponds.module';
|
|||||||
|
|
||||||
import { GrantsController } from './grants.controller';
|
import { GrantsController } from './grants.controller';
|
||||||
import { GrantsService } from './grants.service';
|
import { GrantsService } from './grants.service';
|
||||||
|
import { InspectorController } from '../inspector/inspector.controller';
|
||||||
|
import { InspectorService } from '../inspector/inspector.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission grants (issues #51/#52): the management endpoints under
|
* Permission grants (issues #51/#52): the management endpoints under
|
||||||
* `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them.
|
* `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them, plus the
|
||||||
* Resolution itself lives in PermissionsModule; the member-management UI
|
* effective-permissions inspector (issue #57). Resolution itself lives in
|
||||||
* arrives with #54.
|
* PermissionsModule; the access-rules and member UIs are #54/#55.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule],
|
imports: [PondsModule],
|
||||||
controllers: [GrantsController],
|
controllers: [GrantsController, InspectorController],
|
||||||
providers: [GrantsService],
|
providers: [GrantsService, InspectorService],
|
||||||
exports: [GrantsService],
|
exports: [GrantsService],
|
||||||
})
|
})
|
||||||
export class GrantsModule {}
|
export class GrantsModule {}
|
||||||
|
|||||||
28
apps/api/src/inspector/inspector.controller.ts
Normal file
28
apps/api/src/inspector/inspector.controller.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
EffectivePermissionView,
|
||||||
|
InspectSubjectQuery,
|
||||||
|
inspectSubjectSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
import { InspectorService } from './inspector.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective-permissions inspector endpoint (issue #57). Pond-Admin-gated, so
|
||||||
|
* an admin can only inspect their own pond.
|
||||||
|
*/
|
||||||
|
@Controller('ponds/:pondId/effective-permissions')
|
||||||
|
export class InspectorController {
|
||||||
|
constructor(private readonly inspector: InspectorService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async inspect(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Query(new ZodValidationPipe(inspectSubjectSchema)) query: InspectSubjectQuery,
|
||||||
|
): Promise<EffectivePermissionView> {
|
||||||
|
return this.inspector.inspect(pondId, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
apps/api/src/inspector/inspector.e2e.db.test.ts
Normal file
155
apps/api/src/inspector/inspector.e2e.db.test.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { EffectivePermissionView } 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 { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective-permissions inspector end to end (issue #57): a worked example from
|
||||||
|
* permissions.md resolved through the endpoint returns the right outcome AND
|
||||||
|
* the right deciding rule; the endpoint is Pond-Admin-gated.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('effective-permissions inspector (e2e, issue #57)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'wer darf was hier 123';
|
||||||
|
const ids: Record<string, string> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
let pondId: string;
|
||||||
|
let pageId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function makeUser(handle: string): Promise<void> {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const username = `insp-${handle}-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `Insp ${handle}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
ids[handle] = user.id;
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
cookies[handle] = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
for (const h of ['owner', 'uma', 'foreign']) await makeUser(h);
|
||||||
|
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: { slug: `insp-${suffix}`, name: 'Inspect', type: 'SHARED', ownerId: ids.owner! },
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
await prisma.roleGrant.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: ids.owner!,
|
||||||
|
role: 'POND_ADMIN',
|
||||||
|
scopeType: 'POND',
|
||||||
|
effect: 'ALLOW',
|
||||||
|
createdBy: ids.owner!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const label = await prisma.label.create({
|
||||||
|
data: { pondId, name: 'Confidential', color: '#64748b' },
|
||||||
|
});
|
||||||
|
const page = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
slug: `salaries-${suffix}`,
|
||||||
|
title: 'Salaries',
|
||||||
|
createdBy: ids.owner!,
|
||||||
|
sortKey: 'a0',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
labels: { create: { labelId: label.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pageId = page.id;
|
||||||
|
// Worked example: pond-wide editor for Uma, but a label-scope deny on
|
||||||
|
// "Confidential" — which the page carries.
|
||||||
|
for (const g of [
|
||||||
|
{
|
||||||
|
role: 'EDITOR' as const,
|
||||||
|
scopeType: 'POND' as const,
|
||||||
|
scopeId: null,
|
||||||
|
effect: 'ALLOW' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'EDITOR' as const,
|
||||||
|
scopeType: 'LABEL' as const,
|
||||||
|
scopeId: label.id,
|
||||||
|
effect: 'DENY' as const,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
await prisma.roleGrant.create({
|
||||||
|
data: { pondId, subjectType: 'USER', subjectId: ids.uma!, createdBy: ids.owner!, ...g },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const all = Object.values(ids);
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.pageLabel.deleteMany({ where: { page: { pondId } } });
|
||||||
|
await prisma.label.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const inspect = async (q: string) =>
|
||||||
|
(
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/effective-permissions?${q}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(200)
|
||||||
|
).body as EffectivePermissionView;
|
||||||
|
|
||||||
|
it('shows the label-deny as the deciding rule on the labelled page', async () => {
|
||||||
|
const view = await inspect(`subjectType=user&subjectId=${ids.uma}&pageId=${pageId}`);
|
||||||
|
expect(view.write).toMatchObject({ outcome: 'deny', decidedBy: 'label' });
|
||||||
|
expect(view.write.decidingRule).toMatchObject({
|
||||||
|
subjectName: 'Insp uma',
|
||||||
|
scopeType: 'label',
|
||||||
|
scopeName: 'Confidential',
|
||||||
|
effect: 'deny',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a page, resolves the pond-level base capability', async () => {
|
||||||
|
const view = await inspect(`subjectType=user&subjectId=${ids.uma}`);
|
||||||
|
// No page/labels → only the pond-scope allow applies.
|
||||||
|
expect(view.page).toBeNull();
|
||||||
|
expect(view.write).toMatchObject({ outcome: 'allow', decidedBy: 'pond' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an anonymous (public) subject is denied a non-public page (default-closed)', async () => {
|
||||||
|
const view = await inspect(`subjectType=public&pageId=${pageId}`);
|
||||||
|
expect(view.read).toMatchObject({ outcome: 'deny', decidedBy: 'default' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is Pond-Admin-gated: a non-admin cannot inspect', async () => {
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/effective-permissions?subjectType=user&subjectId=${ids.uma}`)
|
||||||
|
.set('Cookie', cookies.foreign!)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
apps/api/src/inspector/inspector.service.ts
Normal file
BIN
apps/api/src/inspector/inspector.service.ts
Normal file
Binary file not shown.
@ -167,3 +167,53 @@ test('hints at shadowed rules and confirms before going public', async ({ browse
|
|||||||
await owner.close();
|
await owner.close();
|
||||||
await editor.close();
|
await editor.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the effective-permissions inspector shows the outcome and deciding rule', 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: `Inspect ${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: 'Confidential',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const page = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
title: 'Salaries',
|
||||||
|
});
|
||||||
|
await json(owner.request, 'post', `/api/v1/pages/${page.id}/labels`, { labelId: label.id });
|
||||||
|
// Pond-wide editor minus a label-scope deny on Confidential.
|
||||||
|
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/grants`, {
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: editorId,
|
||||||
|
role: 'editor',
|
||||||
|
scopeType: 'label',
|
||||||
|
scopeId: label.id,
|
||||||
|
effect: 'deny',
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = await owner.newPage();
|
||||||
|
await view.goto(`/p/${pond.slug}/settings`);
|
||||||
|
await view.locator('.inspector__subject').selectOption(`user:${editorId}`);
|
||||||
|
await view.locator('.inspector__page').selectOption(page.id);
|
||||||
|
|
||||||
|
// Editing is denied, decided at the label level, and the rule is spelled out.
|
||||||
|
const write = view.locator('.inspector__outcome[data-capability="write"]');
|
||||||
|
await expect(write).toHaveClass(/inspector__outcome--deny/);
|
||||||
|
await expect(write.locator('.inspector__deciding')).toContainText('Confidential');
|
||||||
|
|
||||||
|
await owner.close();
|
||||||
|
await editor.close();
|
||||||
|
});
|
||||||
|
|||||||
@ -18,8 +18,15 @@ import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
|
|||||||
|
|
||||||
type ScopedType = 'label' | 'page';
|
type ScopedType = 'label' | 'page';
|
||||||
|
|
||||||
|
/** The fields a rule sentence needs — shared by the rule list (#55) and the
|
||||||
|
* inspector's deciding rule (#57). */
|
||||||
|
export type SentenceRule = Pick<
|
||||||
|
AccessRuleView,
|
||||||
|
'subjectType' | 'subjectName' | 'role' | 'scopeType' | 'scopeName' | 'effect'
|
||||||
|
>;
|
||||||
|
|
||||||
/** Renders one grant as a readable de/en sentence (issue #55, ADR 0012). */
|
/** Renders one grant as a readable de/en sentence (issue #55, ADR 0012). */
|
||||||
export function ruleSentence(rule: AccessRuleView, t: TFunction): string {
|
export function ruleSentence(rule: SentenceRule, t: TFunction): string {
|
||||||
const subject = t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' });
|
const subject = t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' });
|
||||||
const ability = t(`ability.${rule.role}`);
|
const ability = t(`ability.${rule.role}`);
|
||||||
const scope = t(`scope.${rule.scopeType}`, { name: rule.scopeName ?? '' });
|
const scope = t(`scope.${rule.scopeType}`, { name: rule.scopeName ?? '' });
|
||||||
|
|||||||
137
apps/web/src/access/EffectivePermissionsInspector.tsx
Normal file
137
apps/web/src/access/EffectivePermissionsInspector.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import type { DecisionView, EffectivePermissionView, PageListItemView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ApiError, apiGet } from '../lib/api';
|
||||||
|
import { usePondMembers } from '../members/use-pond-members';
|
||||||
|
import { ruleSentence } from './AccessRulesManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective-permissions inspector (issue #57): pick a subject (a member, all
|
||||||
|
* signed-in users, or the public) and optionally a page → see the resolved
|
||||||
|
* read/edit outcome with the deciding rule spelled out. Powered by the api's
|
||||||
|
* explain endpoint, which shares the resolver's code path — the answer always
|
||||||
|
* matches real access (permissions.md §UI obligations).
|
||||||
|
*/
|
||||||
|
export function EffectivePermissionsInspector({ pondId }: { pondId: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('access');
|
||||||
|
const members = usePondMembers(pondId);
|
||||||
|
const pages = useQuery({
|
||||||
|
queryKey: ['pages', pondId],
|
||||||
|
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pondId}/pages`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [subject, setSubject] = useState(''); // "" | "user:<id>" | "authenticated" | "public"
|
||||||
|
const [pageId, setPageId] = useState('');
|
||||||
|
|
||||||
|
const parsed = parseSubject(subject);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['effective', pondId, subject, pageId],
|
||||||
|
enabled: Boolean(parsed),
|
||||||
|
retry: false,
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams({ subjectType: parsed!.type });
|
||||||
|
if (parsed!.id) params.set('subjectId', parsed!.id);
|
||||||
|
if (pageId) params.set('pageId', pageId);
|
||||||
|
return apiGet<EffectivePermissionView>(
|
||||||
|
`/ponds/${pondId}/effective-permissions?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-admins get 403 — hide the tool entirely (the member list stays).
|
||||||
|
if (
|
||||||
|
members.data?.canManage === false ||
|
||||||
|
(query.error instanceof ApiError && query.error.status === 403)
|
||||||
|
) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = query.data;
|
||||||
|
return (
|
||||||
|
<section className="inspector">
|
||||||
|
<h2>{t('inspector.title')}</h2>
|
||||||
|
<p className="inspector__intro">{t('inspector.description')}</p>
|
||||||
|
<div className="inspector__controls">
|
||||||
|
<label className="inspector__field">
|
||||||
|
<span>{t('inspector.subject')}</span>
|
||||||
|
<select
|
||||||
|
className="inspector__subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(members.data?.members ?? []).map((m) => (
|
||||||
|
<option key={m.userId} value={`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="inspector__field">
|
||||||
|
<span>{t('inspector.page')}</span>
|
||||||
|
<select
|
||||||
|
className="inspector__page"
|
||||||
|
value={pageId}
|
||||||
|
onChange={(e) => setPageId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('inspector.wholePond')}</option>
|
||||||
|
{(pages.data ?? []).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view && (
|
||||||
|
<dl className="inspector__result">
|
||||||
|
<Outcome capability="read" label={t('inspector.read')} decision={view.read} />
|
||||||
|
<Outcome capability="write" label={t('inspector.write')} decision={view.write} />
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Outcome({
|
||||||
|
capability,
|
||||||
|
label,
|
||||||
|
decision,
|
||||||
|
}: {
|
||||||
|
capability: 'read' | 'write';
|
||||||
|
label: string;
|
||||||
|
decision: DecisionView;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('access');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inspector__outcome inspector__outcome--${decision.outcome}`}
|
||||||
|
data-capability={capability}
|
||||||
|
>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>
|
||||||
|
<strong className="inspector__verdict">{t(`inspector.outcome.${decision.outcome}`)}</strong>
|
||||||
|
<span className="inspector__reason">
|
||||||
|
{' '}
|
||||||
|
— {t(`inspector.decidedBy.${decision.decidedBy}`)}
|
||||||
|
</span>
|
||||||
|
{decision.decidingRule && (
|
||||||
|
<span className="inspector__deciding">: “{ruleSentence(decision.decidingRule, t)}”</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSubject(
|
||||||
|
value: string,
|
||||||
|
): { type: 'user' | 'authenticated' | 'public'; id: string | null } | null {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value.startsWith('user:')) return { type: 'user', id: value.slice(5) };
|
||||||
|
return { type: value as 'authenticated' | 'public', id: null };
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ 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 { AccessRulesManager } from '../access/AccessRulesManager';
|
||||||
|
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
<MemberManager pondId={pond.data.id} />
|
<MemberManager pondId={pond.data.id} />
|
||||||
</section>
|
</section>
|
||||||
<AccessRulesManager pondId={pond.data.id} />
|
<AccessRulesManager pondId={pond.data.id} />
|
||||||
|
<EffectivePermissionsInspector pondId={pond.data.id} />
|
||||||
<section>
|
<section>
|
||||||
<h2>{t('settings.title')}</h2>
|
<h2>{t('settings.title')}</h2>
|
||||||
{canModify ? (
|
{canModify ? (
|
||||||
|
|||||||
@ -1640,3 +1640,56 @@ button {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Effective-permissions inspector (issue #57) */
|
||||||
|
.inspector__intro {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__result {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__outcome {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__outcome dt {
|
||||||
|
min-width: 5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__outcome dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__outcome--allow {
|
||||||
|
border-left-color: var(--color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__outcome--deny {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector__reason {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|||||||
@ -38,5 +38,22 @@
|
|||||||
"publicConfirm": "Ja, öffentlich machen"
|
"publicConfirm": "Ja, öffentlich machen"
|
||||||
},
|
},
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"loadError": "Zugriffsregeln konnten nicht geladen werden."
|
"loadError": "Zugriffsregeln konnten nicht geladen werden.",
|
||||||
|
"inspector": {
|
||||||
|
"title": "Effektive Rechte",
|
||||||
|
"description": "Prüfe, was eine Person, alle Angemeldeten oder die Öffentlichkeit hier tatsächlich dürfen.",
|
||||||
|
"subject": "Wer",
|
||||||
|
"page": "Auf Seite (optional)",
|
||||||
|
"wholePond": "Der ganze Teich",
|
||||||
|
"read": "Lesen",
|
||||||
|
"write": "Bearbeiten",
|
||||||
|
"outcome": { "allow": "Erlaubt", "deny": "Verweigert" },
|
||||||
|
"decidedBy": {
|
||||||
|
"site-admin": "Site-Admin — umgeht alle Regeln",
|
||||||
|
"page": "entschieden durch eine Seiten-Regel",
|
||||||
|
"label": "entschieden durch eine Label-Regel",
|
||||||
|
"pond": "entschieden durch eine Teich-Regel",
|
||||||
|
"default": "keine Regel gewährt dies (Standard: verweigert)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,5 +38,22 @@
|
|||||||
"publicConfirm": "Yes, make it public"
|
"publicConfirm": "Yes, make it public"
|
||||||
},
|
},
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"loadError": "Access rules could not be loaded."
|
"loadError": "Access rules could not be loaded.",
|
||||||
|
"inspector": {
|
||||||
|
"title": "Effective permissions",
|
||||||
|
"description": "Check what a person, all signed-in users, or the public can actually do here.",
|
||||||
|
"subject": "Who",
|
||||||
|
"page": "On page (optional)",
|
||||||
|
"wholePond": "The whole pond",
|
||||||
|
"read": "Read",
|
||||||
|
"write": "Edit",
|
||||||
|
"outcome": { "allow": "Allowed", "deny": "Denied" },
|
||||||
|
"decidedBy": {
|
||||||
|
"site-admin": "Site Admin — bypasses all rules",
|
||||||
|
"page": "decided by a page-level rule",
|
||||||
|
"label": "decided by a label-level rule",
|
||||||
|
"pond": "decided by a pond-level rule",
|
||||||
|
"default": "no rule grants this (default: denied)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { canAccessPage, canAccessTrashedPage, resolvePageCapability } from './resolve';
|
import {
|
||||||
|
canAccessPage,
|
||||||
|
canAccessTrashedPage,
|
||||||
|
explainPageCapability,
|
||||||
|
resolvePageCapability,
|
||||||
|
} from './resolve';
|
||||||
import type {
|
import type {
|
||||||
Grant,
|
Grant,
|
||||||
GrantEffect,
|
GrantEffect,
|
||||||
@ -226,3 +231,66 @@ describe('property: a less-specific grant never overrides a more-specific decisi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('explainPageCapability — decision chain (issue #57)', () => {
|
||||||
|
it('pond-scope allow: decided at pond level by the allow grant', () => {
|
||||||
|
const g = grant('editor', 'pond', null, 'allow');
|
||||||
|
const t = explainPageCapability('write', ctx([g]));
|
||||||
|
expect(t).toMatchObject({ outcome: true, decidedBy: 'pond', decidingGrant: g });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label-scope deny beats pond allow: decided at label level by the deny', () => {
|
||||||
|
const deny = grant('editor', 'label', 'confidential', 'deny');
|
||||||
|
const t = explainPageCapability(
|
||||||
|
'write',
|
||||||
|
ctx([grant('editor', 'pond', null, 'allow'), deny], {
|
||||||
|
pageLabelIds: ['confidential'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(t).toMatchObject({ outcome: false, decidedBy: 'label', decidingGrant: deny });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page-scope allow is most specific: decided at page level', () => {
|
||||||
|
const pageAllow = grant('editor', 'page', PAGE, 'allow');
|
||||||
|
const t = explainPageCapability(
|
||||||
|
'write',
|
||||||
|
ctx([grant('editor', 'label', 'confidential', 'deny'), pageAllow], {
|
||||||
|
pageLabelIds: ['confidential'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(t).toMatchObject({ outcome: true, decidedBy: 'page', decidingGrant: pageAllow });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two labels at the same level: the deny is the decider', () => {
|
||||||
|
const deny = grant('editor', 'label', 'confidential', 'deny');
|
||||||
|
const t = explainPageCapability(
|
||||||
|
'write',
|
||||||
|
ctx([deny, grant('editor', 'label', 'hr', 'allow')], {
|
||||||
|
pageLabelIds: ['confidential', 'hr'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(t).toMatchObject({ outcome: false, decidedBy: 'label', decidingGrant: deny });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('site admin bypasses with no deciding grant', () => {
|
||||||
|
const t = explainPageCapability(
|
||||||
|
'write',
|
||||||
|
ctx([], { viewer: { userId: 'x', isSiteAdmin: true } }),
|
||||||
|
);
|
||||||
|
expect(t).toMatchObject({ outcome: true, decidedBy: 'site-admin', decidingGrant: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default-closed: no matching grant → deny at default level', () => {
|
||||||
|
const t = explainPageCapability('read', ctx([]));
|
||||||
|
expect(t).toMatchObject({ outcome: false, decidedBy: 'default', decidingGrant: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the trace outcome always matches the boolean resolver', () => {
|
||||||
|
const grants = [
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'label', 'confidential', 'deny'),
|
||||||
|
];
|
||||||
|
const c = ctx(grants, { pageLabelIds: ['confidential'] });
|
||||||
|
expect(explainPageCapability('write', c).outcome).toBe(resolvePageCapability('write', c));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -30,11 +30,6 @@ function roleCovers(grant: Grant, action: PermissionAction): boolean {
|
|||||||
return action === 'read' || grant.role !== 'reader';
|
return action === 'read' || grant.role !== 'reader';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Within one specificity level: deny wins, otherwise (some allow) allows. */
|
|
||||||
function decide(levelGrants: Grant[]): boolean {
|
|
||||||
return !levelGrants.some((g) => g.effect === 'deny');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page's "effective" label ids: its direct labels plus every ancestor of
|
* The page's "effective" label ids: its direct labels plus every ancestor of
|
||||||
* those labels — a grant on label L applies to L and all its descendants
|
* those labels — a grant on label L applies to L and all its descendants
|
||||||
@ -61,27 +56,63 @@ export function resolvePageCapability(
|
|||||||
action: PermissionAction,
|
action: PermissionAction,
|
||||||
ctx: PageResolutionContext,
|
ctx: PageResolutionContext,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (ctx.viewer.isSiteAdmin) return true;
|
return explainPageCapability(action, ctx).outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The specificity level that decided a resolution (permissions.md §Resolution). */
|
||||||
|
export type ResolutionLevel = 'site-admin' | 'page' | 'label' | 'pond' | 'default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full decision chain behind {@link resolvePageCapability}, for the
|
||||||
|
* effective-permissions inspector (issue #57). Pure and shares one code path
|
||||||
|
* with the boolean resolver above, so the trace can never diverge from the
|
||||||
|
* actual decision.
|
||||||
|
*/
|
||||||
|
export interface ResolutionTrace {
|
||||||
|
/** `true` = allowed. */
|
||||||
|
outcome: boolean;
|
||||||
|
/** Which level settled it (or `site-admin`/`default`). */
|
||||||
|
decidedBy: ResolutionLevel;
|
||||||
|
/** The subject/action-matching grants at the deciding level. */
|
||||||
|
matched: Grant[];
|
||||||
|
/** The single grant that determined the outcome — a `deny` if any, otherwise
|
||||||
|
* the first `allow`. `null` for the site-admin bypass and default-closed. */
|
||||||
|
decidingGrant: Grant | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainPageCapability(
|
||||||
|
action: PermissionAction,
|
||||||
|
ctx: PageResolutionContext,
|
||||||
|
): ResolutionTrace {
|
||||||
|
if (ctx.viewer.isSiteAdmin) {
|
||||||
|
return { outcome: true, decidedBy: 'site-admin', matched: [], decidingGrant: null };
|
||||||
|
}
|
||||||
|
|
||||||
const matching = ctx.grants.filter((g) => subjectMatches(g, ctx.viewer) && roleCovers(g, action));
|
const matching = ctx.grants.filter((g) => subjectMatches(g, ctx.viewer) && roleCovers(g, action));
|
||||||
|
|
||||||
// Page scope — grants on the page itself.
|
// Page scope — grants on the page itself.
|
||||||
const pageGrants = matching.filter((g) => g.scopeType === 'page' && g.scopeId === ctx.pageId);
|
const pageGrants = matching.filter((g) => g.scopeType === 'page' && g.scopeId === ctx.pageId);
|
||||||
if (pageGrants.length > 0) return decide(pageGrants);
|
if (pageGrants.length > 0) return traceOf('page', pageGrants);
|
||||||
|
|
||||||
// Label scope — grants on any label assigned to the page or an ancestor.
|
// Label scope — grants on any label assigned to the page or an ancestor.
|
||||||
const effective = effectiveLabelIds(ctx);
|
const effective = effectiveLabelIds(ctx);
|
||||||
const labelGrants = matching.filter(
|
const labelGrants = matching.filter(
|
||||||
(g) => g.scopeType === 'label' && g.scopeId !== null && effective.has(g.scopeId),
|
(g) => g.scopeType === 'label' && g.scopeId !== null && effective.has(g.scopeId),
|
||||||
);
|
);
|
||||||
if (labelGrants.length > 0) return decide(labelGrants);
|
if (labelGrants.length > 0) return traceOf('label', labelGrants);
|
||||||
|
|
||||||
// Pond scope — grants on the whole pond.
|
// Pond scope — grants on the whole pond.
|
||||||
const pondGrants = matching.filter((g) => g.scopeType === 'pond');
|
const pondGrants = matching.filter((g) => g.scopeType === 'pond');
|
||||||
if (pondGrants.length > 0) return decide(pondGrants);
|
if (pondGrants.length > 0) return traceOf('pond', pondGrants);
|
||||||
|
|
||||||
// No matching grant at any level → deny.
|
// No matching grant at any level → deny.
|
||||||
return false;
|
return { outcome: false, decidedBy: 'default', matched: [], decidingGrant: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Within one level: a single `deny` denies (and is the decider); else allow. */
|
||||||
|
function traceOf(level: ResolutionLevel, grants: Grant[]): ResolutionTrace {
|
||||||
|
const deny = grants.find((g) => g.effect === 'deny');
|
||||||
|
return { outcome: !deny, decidedBy: level, matched: grants, decidingGrant: deny ?? grants[0]! };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Grant } from './types';
|
import type { ResolutionLevel } from './resolve';
|
||||||
|
import type { Grant, GrantSubjectType } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire schemas and views for the grant-management API (`/ponds/:id/grants`,
|
* Wire schemas and views for the grant-management API (`/ponds/:id/grants`,
|
||||||
@ -46,6 +47,40 @@ export interface AccessRuleView extends GrantView {
|
|||||||
scopeName: string | null;
|
scopeName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Query for the effective-permissions inspector (issue #57). */
|
||||||
|
export const inspectSubjectSchema = z.object({
|
||||||
|
subjectType: z.enum(GRANT_SUBJECT_TYPES),
|
||||||
|
/** Required for `user`, absent for `authenticated`/`public`. */
|
||||||
|
subjectId: z.string().min(1).optional(),
|
||||||
|
/** Optional page to resolve against; omit for the pond-level base capability. */
|
||||||
|
pageId: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
export type InspectSubjectQuery = z.infer<typeof inspectSubjectSchema>;
|
||||||
|
|
||||||
|
/** A grant enriched with the names needed to render it as a sentence — the
|
||||||
|
* shape the inspector highlights (no id: the decider is shown, not managed). */
|
||||||
|
export interface DecidingRule extends Grant {
|
||||||
|
subjectName: string | null;
|
||||||
|
scopeName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One resolved capability (read or write) with the rule that decided it. */
|
||||||
|
export interface DecisionView {
|
||||||
|
outcome: 'allow' | 'deny';
|
||||||
|
/** Which specificity level settled it (permissions.md §Resolution). */
|
||||||
|
decidedBy: ResolutionLevel;
|
||||||
|
/** The deciding grant, enriched with names; `null` for site-admin/default. */
|
||||||
|
decidingRule: DecidingRule | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inspector's answer to "what can this subject do here?" (issue #57). */
|
||||||
|
export interface EffectivePermissionView {
|
||||||
|
subject: { type: GrantSubjectType; id: string | null; name: string | null };
|
||||||
|
page: { id: string; title: string } | null;
|
||||||
|
read: DecisionView;
|
||||||
|
write: DecisionView;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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