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

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:
Claude Opus 4.8 2026-07-10 00:00:27 +02:00
parent fc41c91003
commit f5f1310eb2
14 changed files with 622 additions and 20 deletions

View File

@ -4,17 +4,19 @@ import { PondsModule } from '../ponds/ponds.module';
import { GrantsController } from './grants.controller';
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
* `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them.
* Resolution itself lives in PermissionsModule; the member-management UI
* arrives with #54.
* `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them, plus the
* effective-permissions inspector (issue #57). Resolution itself lives in
* PermissionsModule; the access-rules and member UIs are #54/#55.
*/
@Module({
imports: [PondsModule],
controllers: [GrantsController],
providers: [GrantsService],
controllers: [GrantsController, InspectorController],
providers: [GrantsService, InspectorService],
exports: [GrantsService],
})
export class GrantsModule {}

View 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);
}
}

View 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);
});
});

Binary file not shown.

View File

@ -167,3 +167,53 @@ test('hints at shadowed rules and confirms before going public', async ({ browse
await owner.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();
});

View File

@ -18,8 +18,15 @@ import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
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). */
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 ability = t(`ability.${rule.role}`);
const scope = t(`scope.${rule.scopeType}`, { name: rule.scopeName ?? '' });

View 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 };
}

View File

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

View File

@ -1640,3 +1640,56 @@ button {
max-width: 100%;
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);
}

View File

@ -38,5 +38,22 @@
"publicConfirm": "Ja, öffentlich machen"
},
"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)"
}
}
}

View File

@ -38,5 +38,22 @@
"publicConfirm": "Yes, make it public"
},
"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)"
}
}
}

View File

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { canAccessPage, canAccessTrashedPage, resolvePageCapability } from './resolve';
import {
canAccessPage,
canAccessTrashedPage,
explainPageCapability,
resolvePageCapability,
} from './resolve';
import type {
Grant,
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));
});
});

View File

@ -30,11 +30,6 @@ function roleCovers(grant: Grant, action: PermissionAction): boolean {
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
* those labels a grant on label L applies to L and all its descendants
@ -61,27 +56,63 @@ export function resolvePageCapability(
action: PermissionAction,
ctx: PageResolutionContext,
): 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));
// Page scope — grants on the page itself.
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.
const effective = effectiveLabelIds(ctx);
const labelGrants = matching.filter(
(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.
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.
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]! };
}
/**

View File

@ -1,6 +1,7 @@
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`,
@ -46,6 +47,40 @@ export interface AccessRuleView extends GrantView {
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). */
export function grantOfInput(input: CreateGrantInput): Grant {
return {