diff --git a/apps/api/src/grants/grants.module.ts b/apps/api/src/grants/grants.module.ts index 4eba288..6c57806 100644 --- a/apps/api/src/grants/grants.module.ts +++ b/apps/api/src/grants/grants.module.ts @@ -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 {} diff --git a/apps/api/src/inspector/inspector.controller.ts b/apps/api/src/inspector/inspector.controller.ts new file mode 100644 index 0000000..4499136 --- /dev/null +++ b/apps/api/src/inspector/inspector.controller.ts @@ -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 { + return this.inspector.inspect(pondId, query); + } +} diff --git a/apps/api/src/inspector/inspector.e2e.db.test.ts b/apps/api/src/inspector/inspector.e2e.db.test.ts new file mode 100644 index 0000000..68e7584 --- /dev/null +++ b/apps/api/src/inspector/inspector.e2e.db.test.ts @@ -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 = {}; + const cookies: Record = {}; + let pondId: string; + let pageId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + 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); + }); +}); diff --git a/apps/api/src/inspector/inspector.service.ts b/apps/api/src/inspector/inspector.service.ts new file mode 100644 index 0000000..4f06a04 Binary files /dev/null and b/apps/api/src/inspector/inspector.service.ts differ diff --git a/apps/web/e2e/access-rules.spec.ts b/apps/web/e2e/access-rules.spec.ts index 9fad2ad..77f15a7 100644 --- a/apps/web/e2e/access-rules.spec.ts +++ b/apps/web/e2e/access-rules.spec.ts @@ -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(); +}); diff --git a/apps/web/src/access/AccessRulesManager.tsx b/apps/web/src/access/AccessRulesManager.tsx index 3953e28..e208256 100644 --- a/apps/web/src/access/AccessRulesManager.tsx +++ b/apps/web/src/access/AccessRulesManager.tsx @@ -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 ?? '' }); diff --git a/apps/web/src/access/EffectivePermissionsInspector.tsx b/apps/web/src/access/EffectivePermissionsInspector.tsx new file mode 100644 index 0000000..bc51b45 --- /dev/null +++ b/apps/web/src/access/EffectivePermissionsInspector.tsx @@ -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(`/ponds/${pondId}/pages`), + }); + + const [subject, setSubject] = useState(''); // "" | "user:" | "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( + `/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 ( +
+

{t('inspector.title')}

+

{t('inspector.description')}

+
+ + +
+ + {view && ( +
+ + +
+ )} +
+ ); +} + +function Outcome({ + capability, + label, + decision, +}: { + capability: 'read' | 'write'; + label: string; + decision: DecisionView; +}): React.JSX.Element { + const { t } = useTranslation('access'); + return ( +
+
{label}
+
+ {t(`inspector.outcome.${decision.outcome}`)} + + {' '} + — {t(`inspector.decidedBy.${decision.decidedBy}`)} + + {decision.decidingRule && ( + : “{ruleSentence(decision.decidingRule, t)}” + )} +
+
+ ); +} + +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 }; +} diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index a0c78a0..5ee8deb 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -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 { +

{t('settings.title')}

{canModify ? ( diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 0f1ff18..9a96139 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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); +} diff --git a/packages/shared/i18n/de/access.json b/packages/shared/i18n/de/access.json index 40cdc0a..1bfb08a 100644 --- a/packages/shared/i18n/de/access.json +++ b/packages/shared/i18n/de/access.json @@ -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)" + } + } } diff --git a/packages/shared/i18n/en/access.json b/packages/shared/i18n/en/access.json index ff76b1d..56e353b 100644 --- a/packages/shared/i18n/en/access.json +++ b/packages/shared/i18n/en/access.json @@ -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)" + } + } } diff --git a/packages/shared/src/permissions/resolve.test.ts b/packages/shared/src/permissions/resolve.test.ts index 14f6c2c..a6299dc 100644 --- a/packages/shared/src/permissions/resolve.test.ts +++ b/packages/shared/src/permissions/resolve.test.ts @@ -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)); + }); +}); diff --git a/packages/shared/src/permissions/resolve.ts b/packages/shared/src/permissions/resolve.ts index 1c81501..88590e0 100644 --- a/packages/shared/src/permissions/resolve.ts +++ b/packages/shared/src/permissions/resolve.ts @@ -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]! }; } /** diff --git a/packages/shared/src/permissions/schemas.ts b/packages/shared/src/permissions/schemas.ts index d5eb3b1..2296aca 100644 --- a/packages/shared/src/permissions/schemas.ts +++ b/packages/shared/src/permissions/schemas.ts @@ -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; + +/** 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 {