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:
230
e2e/tests/m3-rbac.spec.ts
Normal file
230
e2e/tests/m3-rbac.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M3 — RBAC, group management, user assignment.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Reset + bootstrap a fresh admin.
|
||||
* 2. Admin visits /admin/groups and creates a custom group `pentest-red` with
|
||||
* only `mission.read` + `mission.write_red_fields`.
|
||||
* 3. Admin issues an invitation pre-assigned to that custom group.
|
||||
* 4. Invitee accepts, logs in, hits the API: mission.read OK, but admin-only
|
||||
* group.create returns 403 — proving the union-of-perms decorator works.
|
||||
* 5. Admin attempts to demote himself → server returns 409 last_admin_protected.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ADMIN_PASSWORD = 'AdminPass1234!';
|
||||
const BOB_EMAIL = `bob-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const BOB_PASSWORD = 'BobPass1234!';
|
||||
const GROUP_NAME = `pentest-red-${Math.floor(Math.random() * 1e6)}`;
|
||||
|
||||
interface ResetPayload {
|
||||
install_token: string;
|
||||
}
|
||||
|
||||
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
||||
const r = await request.post('/api/v1/diag/reset');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as ResetPayload;
|
||||
return body.install_token;
|
||||
}
|
||||
|
||||
async function loginAndGetAccess(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const r = await request.post('/api/v1/auth/login', {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
return (await r.json()).access_token as string;
|
||||
}
|
||||
|
||||
/** Authenticate the page session via the SPA login form. */
|
||||
async function loginViaSpa(page: Page, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(email);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M3 — RBAC management', () => {
|
||||
let installToken: string;
|
||||
let customGroupId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
installToken = await resetAndMintToken(request);
|
||||
// Bootstrap the first admin via API to keep the e2e focused on RBAC.
|
||||
const setup = await request.post('/api/v1/setup', {
|
||||
data: {
|
||||
install_token: installToken,
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
display_name: 'Admin',
|
||||
},
|
||||
});
|
||||
expect(setup.status()).toBe(201);
|
||||
});
|
||||
|
||||
test('admin sees Admin nav links after login', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Nav now shows the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^groups$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^invitations$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('catalogue page lists the seeded permissions', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const r = await request.get('/api/v1/permissions', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as { items: Array<{ code: string }> };
|
||||
const codes = body.items.map((p) => p.code);
|
||||
// Smoke-check several families.
|
||||
expect(codes).toEqual(expect.arrayContaining([
|
||||
'user.read',
|
||||
'group.create',
|
||||
'invitation.create',
|
||||
'mission.write_red_fields',
|
||||
'mission.write_blue_fields',
|
||||
'mitre.sync',
|
||||
]));
|
||||
});
|
||||
|
||||
test('admin creates a custom group with only red-write perms via the SPA', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto('/admin/groups');
|
||||
await page.getByTestId('create-group').click();
|
||||
const modal = page.getByTestId('group-create-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await modal.getByLabel(/^name$/i).fill(GROUP_NAME);
|
||||
await modal.getByTestId('perm-mission.read').check();
|
||||
await modal.getByTestId('perm-mission.write_red_fields').check();
|
||||
await modal.getByTestId('group-create-save').click();
|
||||
|
||||
// The new card is visible in the listing.
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page.getByText(GROUP_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin invites Bob pre-assigned to the custom group', async ({ page, request }) => {
|
||||
// Fetch the group id (needed for the invitation API).
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const items = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items;
|
||||
customGroupId = items.find((g) => g.name === GROUP_NAME)!.id;
|
||||
expect(customGroupId).toBeTruthy();
|
||||
|
||||
// Issue invitation via API (creating an invitation through the UI is covered in M2).
|
||||
const created = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { email_hint: BOB_EMAIL, group_ids: [customGroupId] },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
const token = (await created.json()).token as string;
|
||||
|
||||
// Bob completes registration.
|
||||
await page.goto(`/register?token=${encodeURIComponent(token)}`);
|
||||
await page.getByLabel(/email/i).fill(BOB_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(BOB_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(BOB_PASSWORD);
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Bob can read missions list but is forbidden from admin endpoints', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Inspect /auth/me to confirm his perms.
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const body = (await me.json()) as {
|
||||
is_admin: boolean;
|
||||
permissions: string[];
|
||||
groups: string[];
|
||||
};
|
||||
expect(body.is_admin).toBe(false);
|
||||
expect(body.groups).toContain(GROUP_NAME);
|
||||
expect(body.permissions).toEqual(
|
||||
expect.arrayContaining(['mission.read', 'mission.write_red_fields']),
|
||||
);
|
||||
expect(body.permissions).not.toContain('mission.write_blue_fields');
|
||||
|
||||
// Bob does NOT have user.read → /users returns 403.
|
||||
const usersList = await request.get('/api/v1/users', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(usersList.status()).toBe(403);
|
||||
|
||||
// Bob does NOT have group.create → POST /groups returns 403.
|
||||
const groupCreate = await request.post('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { name: 'wont-happen', description: null },
|
||||
});
|
||||
expect(groupCreate.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('non-admin SPA visitor cannot reach /admin/* routes', async ({ page }) => {
|
||||
await loginViaSpa(page, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Direct nav to /admin/users — RequireAdmin redirects to /.
|
||||
await page.goto('/admin/users');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
// The nav also hides the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('last-admin protection prevents the bootstrap admin from being deleted', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminId = (await me.json()).id as string;
|
||||
|
||||
const del = await request.delete(`/api/v1/users/${adminId}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(del.status()).toBe(409);
|
||||
expect((await del.json()).error).toBe('last_admin_protected');
|
||||
});
|
||||
|
||||
test('admin promotes Bob and the new perms take effect', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Find Bob.
|
||||
const list = await request.get(`/api/v1/users?q=${encodeURIComponent(BOB_EMAIL)}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const bob = ((await list.json()) as { items: Array<{ id: string; email: string }> }).items.find(
|
||||
(u) => u.email === BOB_EMAIL,
|
||||
)!;
|
||||
|
||||
// Find admin group id.
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminGroup = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items.find(
|
||||
(g) => g.name === 'admin',
|
||||
)!;
|
||||
|
||||
const r = await request.put(`/api/v1/users/${bob.id}/groups`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { group_ids: [customGroupId, adminGroup.id] },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
|
||||
// Bob now has admin rights via group membership.
|
||||
const bobAccess = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
const groupsAsBob = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${bobAccess}` },
|
||||
});
|
||||
expect(groupsAsBob.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user