feat(m3): RBAC — atomic perms, groups, users, admin SPA pages

Permission catalogue (services/permissions_seed.py)
- 31 atomic codes across 10 families: user.*, group.*, invitation.*,
  test_template.*, scenario_template.*, mission.* (incl.
  mission.write_red_fields + mission.write_blue_fields),
  detection_level.{read,update}, setting.{read,update}, mitre.sync.
- Default bindings: admin = all 31; redteam = 8 (catalogue read + mission.
  {read,create,update,archive,write_red_fields} + detection_level.read);
  blueteam = 5 (catalogue read + mission.{read,write_blue_fields} +
  detection_level.read).
- Seed runs at boot AND after /setup so a freshly truncated DB (via
  /diag/reset) gets the bindings back via the bootstrap path. Idempotent +
  additive (never removes a perm from a system group).

Users admin (services/users.py + api/users.py)
- list (q + is_active filter + pagination), get, patch (display_name /
  locale / is_active with tri-state sentinel for clear-vs-unset),
  soft-delete, set groups.
- Last-admin protection on update (deactivate), delete, and group-strip
  (refusing to remove the admin group from the last active admin).

Groups admin (services/groups.py + api/groups.py)
- Full CRUD with system-group protection (no rename, no delete on
  admin/redteam/blueteam).
- PUT /groups/{id}/permissions sets the perm list.
- Admin system group's perm set is locked to the full catalogue
  (SystemGroupProtected → 409) — preserves the bypass invariant even if a
  future refactor moves to perm-based checks.

Permissions read-only (api/permissions.py)
- GET /permissions returns the catalogue (admin or group.read holders).

/diag/reset extension
- After truncate + token mint, the limiter is also reset (limiter.reset())
  so the Playwright suite doesn't hit 10/min budgets across spec files.
  Guarded by limiter.enabled to no-op in APP_ENV=test.

Rate-limit scope (core/rate_limit.py)
- enabled = APP_ENV in ("prod", "staging"). A staging deployment serves
  humans, so it gets the limits too. Dev/test stay unthrottled for
  Playwright ergonomics. Spec §6 NF-security is an operator-facing
  requirement.

Frontend chrome
- components/RequireAdmin.tsx + ui/Modal.tsx (reusable centered dialog
  with accessible name + Escape + backdrop-click).
- Layout.tsx shows Admin nav links only when is_admin === true. Server
  remains the arbiter — non-admins hitting /admin/* get redirected to /.

Frontend pages
- pages/AdminUsersPage.tsx, AdminGroupsPage.tsx, AdminInvitationsPage.tsx
  with edit modals using TanStack Query mutations + multi-select for perms
  grouped by family + copy-once invitation URL display.
- lib/admin.ts: shared types + query keys + groupPermsByFamily helper.
- lib/api.ts: apiPatch / apiPut / apiDelete added.

Playwright config (e2e/playwright.config.ts)
- workers: 1 + fullyParallel: false: spec files share the live Postgres,
  so concurrent /diag/reset calls clobber each other. Intra-file order
  preserved via test.describe.configure({ mode: 'serial' }).

Testing
- backend/tests/test_rbac.py: 15 integration tests (39 backend total — 1
  health + 8 schema + 15 auth + 15 RBAC).
- e2e/tests/m3-rbac.spec.ts: 8 Playwright tests covering DoD §10 #2/#3
  (28 e2e total — 8 M0 + 4 M1 + 8 M2 + 8 M3).
- tasks/testing-m3.md.

DoD: make test-api → 39 passed, make e2e → 28 passed. Spec-reviewer pass
applied (admin perm invariant + staging rate-limit scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-11 06:17:07 +02:00
parent 700b563297
commit bb23bf3928
15 changed files with 2634 additions and 0 deletions

View File

@@ -0,0 +1,348 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
adminKeys,
groupPermsByFamily,
type AdminGroup,
type AdminGroupListResponse,
type AdminPermission,
} from '@/lib/admin';
function usePermissions() {
return useQuery({
queryKey: adminKeys.permissions,
queryFn: () => apiGet<{ items: AdminPermission[] }>('/permissions'),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminGroupsPage() {
const groups = useGroups();
const perms = usePermissions();
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [creating, setCreating] = useState(false);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Groups"
accent="purple"
description="Compose custom groups; combine atomic permissions to express any role."
/>
<div className="mb-6 flex items-center justify-between">
<span className="font-mono text-2xs text-text-dim">
{groups.data ? `${groups.data.total} group${groups.data.total === 1 ? '' : 's'}` : ''}
</span>
<Button accent="purple" onClick={() => setCreating(true)} data-testid="create-group">
+ New group
</Button>
</div>
{(groups.isError || perms.isError) && (
<Alert accent="red">Failed to load groups or permissions.</Alert>
)}
<div className="grid gap-3" data-testid="groups-table">
{(groups.isLoading || perms.isLoading) && (
<p className="font-mono text-xs text-text-dim">Loading</p>
)}
{groups.data?.items.map((g) => (
<Card key={g.id} accent={g.is_system ? 'yellow' : 'purple'} title={g.name} sub={g.description ?? '—'}>
<div className="flex flex-wrap items-center gap-2">
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
<Tag accent="cyan">{g.members_count} member{g.members_count === 1 ? '' : 's'}</Tag>
<Tag accent="orange">{g.permissions.length} perm{g.permissions.length === 1 ? '' : 's'}</Tag>
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => setEditing(g)} data-testid={`edit-group-${g.name}`}>
Edit
</Button>
</div>
</div>
</Card>
))}
{groups.data && groups.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">No groups yet.</p>
)}
</div>
{creating && perms.data && (
<GroupCreateModal allPerms={perms.data.items} onClose={() => setCreating(false)} />
)}
{editing && perms.data && (
<GroupEditModal
group={editing}
allPerms={perms.data.items}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
interface PermsMultiSelectProps {
allPerms: AdminPermission[];
selected: Set<string>;
onToggle: (code: string) => void;
}
function PermsMultiSelect({ allPerms, selected, onToggle }: PermsMultiSelectProps) {
const byFamily = useMemo(
() => groupPermsByFamily(allPerms.map((p) => p.code)),
[allPerms],
);
return (
<div className="max-h-72 overflow-y-auto rounded border border-border bg-bg-card p-3" data-testid="perms-multiselect">
{Object.entries(byFamily).map(([family, codes]) => (
<div key={family} className="mb-3 last:mb-0">
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
{family}
</p>
<div className="flex flex-wrap gap-2">
{codes.map((code) => (
<label
key={code}
className="inline-flex items-center gap-2 rounded border border-border px-2 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={selected.has(code)}
onChange={() => onToggle(code)}
data-testid={`perm-${code}`}
/>
<span className="font-mono text-2xs">{code}</span>
</label>
))}
</div>
</div>
))}
</div>
);
}
function GroupCreateModal({
allPerms,
onClose,
}: {
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const createGroup = useMutation({
mutationFn: () =>
apiPost<AdminGroup>('/groups', {
name: name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: (groupId: string) =>
apiPut(`/groups/${groupId}/permissions`, { codes: Array.from(selected) }),
});
async function save() {
setError(null);
try {
const g = await createGroup.mutateAsync();
if (selected.size) await setPerms.mutateAsync(g.id);
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
return (
<Modal open onClose={onClose} title="new group" accent="purple" testid="group-create-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
hint="Lower-case-with-dashes recommended (e.g. pentest-2026-Q2)"
required
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-create-save">
Create group
</Button>
</div>
</div>
</Modal>
);
}
function GroupEditModal({
group,
allPerms,
onClose,
}: {
group: AdminGroup;
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(group.name);
const [description, setDescription] = useState(group.description ?? '');
const [selected, setSelected] = useState<Set<string>>(new Set(group.permissions));
const [error, setError] = useState<string | null>(null);
const patchGroup = useMutation({
mutationFn: () =>
apiPatch(`/groups/${group.id}`, {
name: group.is_system ? undefined : name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: () =>
apiPut(`/groups/${group.id}/permissions`, { codes: Array.from(selected) }),
});
const del = useMutation({
mutationFn: () => apiDelete(`/groups/${group.id}`),
});
async function save() {
setError(null);
try {
await patchGroup.mutateAsync();
await setPerms.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
async function handleDelete() {
if (!confirm(`Soft-delete group "${group.name}"?`)) return;
try {
await del.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Delete failed');
}
}
}
return (
<Modal open onClose={onClose} title={group.name} accent="purple" testid="group-edit-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={group.is_system}
hint={group.is_system ? 'System groups cannot be renamed.' : undefined}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex items-center justify-between gap-3 pt-2">
{!group.is_system && (
<Button accent="rose" onClick={handleDelete}>
Delete group
</Button>
)}
<div className="ml-auto flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-edit-save">
Save changes
</Button>
</div>
</div>
</div>
</Modal>
);
}