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:
348
frontend/src/pages/AdminGroupsPage.tsx
Normal file
348
frontend/src/pages/AdminGroupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user