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>
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""Atomic permission catalogue + seed for the 3 default system groups.
|
|
|
|
Permissions follow the `<entity>.<action>` convention. They are the ground truth
|
|
checked by `@require_perm`; admins bypass everything (cf. `auth_decorators.py`).
|
|
|
|
This module is the single place that lists every permission code shipped with
|
|
the platform. To add a new perm in a future milestone:
|
|
|
|
1. Add an entry to `PERMISSION_CATALOGUE`.
|
|
2. Decide which system group(s) should get it by default — edit
|
|
`_default_redteam_perms()` / `_default_blueteam_perms()` if relevant
|
|
(admin always gets everything, so no edit needed there).
|
|
3. The next boot picks it up; existing groups are *upgraded* (perms added),
|
|
never downgraded (we never remove perms from a system group, even if you
|
|
trim the catalogue — that would be a destructive op disguised as a seed).
|
|
|
|
The seed is idempotent and safe to call on every boot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.db.session import session_scope
|
|
from app.models.auth import Group, GroupPermission, Permission
|
|
from app.services.bootstrap import (
|
|
ADMIN_GROUP_NAME,
|
|
BLUETEAM_GROUP_NAME,
|
|
REDTEAM_GROUP_NAME,
|
|
)
|
|
|
|
log = logging.getLogger("metamorph.permissions")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PermissionDef:
|
|
code: str
|
|
description: str
|
|
|
|
|
|
# === Catalogue ================================================================
|
|
#
|
|
# Order is presentation-only; the seed is idempotent. Grouped by family to keep
|
|
# diffs reviewable and to mirror the admin UI grouping in M3.6.
|
|
#
|
|
PERMISSION_CATALOGUE: tuple[PermissionDef, ...] = (
|
|
# users
|
|
PermissionDef("user.read", "View users."),
|
|
PermissionDef("user.create", "Create users (typically via invitation)."),
|
|
PermissionDef("user.update", "Update user metadata (display name, locale, active flag)."),
|
|
PermissionDef("user.delete", "Soft-delete a user."),
|
|
# groups
|
|
PermissionDef("group.read", "View groups and their permissions."),
|
|
PermissionDef("group.create", "Create a custom group."),
|
|
PermissionDef("group.update", "Edit a custom group (name, description, permissions, members)."),
|
|
PermissionDef("group.delete", "Soft-delete a custom group."),
|
|
# invitations
|
|
PermissionDef("invitation.read", "View pending invitations."),
|
|
PermissionDef("invitation.create", "Issue a new invitation URL."),
|
|
PermissionDef("invitation.revoke", "Revoke an unconsumed invitation."),
|
|
# test templates
|
|
PermissionDef("test_template.read", "View the test-template catalogue."),
|
|
PermissionDef("test_template.create", "Create a test template."),
|
|
PermissionDef("test_template.update", "Edit a test template."),
|
|
PermissionDef("test_template.delete", "Soft-delete a test template."),
|
|
# scenario templates
|
|
PermissionDef("scenario_template.read", "View the scenario-template catalogue."),
|
|
PermissionDef("scenario_template.create", "Create a scenario template."),
|
|
PermissionDef("scenario_template.update", "Edit a scenario template (and its ordered tests)."),
|
|
PermissionDef("scenario_template.delete", "Soft-delete a scenario template."),
|
|
# missions
|
|
PermissionDef("mission.read", "View missions (server still filters by membership for non-admin)."),
|
|
PermissionDef("mission.create", "Create a mission."),
|
|
PermissionDef("mission.update", "Edit mission metadata, scenarios, members."),
|
|
PermissionDef("mission.archive", "Move a mission to status=archived."),
|
|
PermissionDef("mission.delete", "Soft-delete a mission."),
|
|
PermissionDef("mission.write_red_fields", "Write red-side fields on a mission test."),
|
|
PermissionDef("mission.write_blue_fields", "Write blue-side fields and upload evidence."),
|
|
# detection levels + platform settings + MITRE sync
|
|
PermissionDef("detection_level.read", "View the detection-level taxonomy."),
|
|
PermissionDef("detection_level.update", "Edit the detection-level taxonomy."),
|
|
PermissionDef("setting.read", "Read platform settings."),
|
|
PermissionDef("setting.update", "Update platform settings."),
|
|
PermissionDef("mitre.sync", "Trigger a MITRE ATT&CK Enterprise re-sync."),
|
|
)
|
|
|
|
|
|
def _default_redteam_perms() -> frozenset[str]:
|
|
return frozenset(
|
|
{
|
|
# catalogue read-only
|
|
"test_template.read",
|
|
"scenario_template.read",
|
|
# MITRE/detection refs
|
|
"detection_level.read",
|
|
# missions: full lifecycle on red side
|
|
"mission.read",
|
|
"mission.create",
|
|
"mission.update",
|
|
"mission.archive",
|
|
"mission.write_red_fields",
|
|
}
|
|
)
|
|
|
|
|
|
def _default_blueteam_perms() -> frozenset[str]:
|
|
return frozenset(
|
|
{
|
|
"test_template.read",
|
|
"scenario_template.read",
|
|
"detection_level.read",
|
|
"mission.read",
|
|
"mission.write_blue_fields",
|
|
}
|
|
)
|
|
|
|
|
|
def _all_perm_codes() -> frozenset[str]:
|
|
return frozenset(p.code for p in PERMISSION_CATALOGUE)
|
|
|
|
|
|
def seed_permissions() -> dict[str, int]:
|
|
"""Insert any missing permissions. Returns counts: `created`, `total`."""
|
|
created = 0
|
|
with session_scope() as s:
|
|
existing_codes = set(s.scalars(select(Permission.code)).all())
|
|
for p in PERMISSION_CATALOGUE:
|
|
if p.code in existing_codes:
|
|
continue
|
|
s.add(Permission(code=p.code, description=p.description))
|
|
created += 1
|
|
return {"created": created, "total": len(PERMISSION_CATALOGUE)}
|
|
|
|
|
|
def _assign_perms_to_group(group_name: str, codes: frozenset[str]) -> int:
|
|
"""Attach the named perms to the given system group. Returns added count.
|
|
|
|
We never *remove* perms from a system group here — the seed is additive.
|
|
Admins/operators who want to revoke must do so explicitly via the UI/API.
|
|
"""
|
|
if not codes:
|
|
return 0
|
|
added = 0
|
|
with session_scope() as s:
|
|
group = s.scalar(select(Group).where(Group.name == group_name, Group.is_system.is_(True)))
|
|
if group is None:
|
|
raise RuntimeError(f"system group {group_name!r} missing — call ensure_system_groups() first")
|
|
|
|
existing_codes = {p.code for p in group.permissions}
|
|
perms = s.scalars(select(Permission).where(Permission.code.in_(codes))).all()
|
|
for p in perms:
|
|
if p.code in existing_codes:
|
|
continue
|
|
s.add(GroupPermission(group_id=group.id, permission_id=p.id))
|
|
added += 1
|
|
return added
|
|
|
|
|
|
def seed_default_group_permissions() -> dict[str, int]:
|
|
"""Bind the catalogue to the 3 default groups. Idempotent + additive."""
|
|
counts: dict[str, int] = {}
|
|
counts[ADMIN_GROUP_NAME] = _assign_perms_to_group(ADMIN_GROUP_NAME, _all_perm_codes())
|
|
counts[REDTEAM_GROUP_NAME] = _assign_perms_to_group(REDTEAM_GROUP_NAME, _default_redteam_perms())
|
|
counts[BLUETEAM_GROUP_NAME] = _assign_perms_to_group(BLUETEAM_GROUP_NAME, _default_blueteam_perms())
|
|
return counts
|
|
|
|
|
|
def seed_all() -> dict[str, dict[str, int]]:
|
|
"""One-shot helper: catalogue + default group bindings."""
|
|
perms = seed_permissions()
|
|
bindings = seed_default_group_permissions()
|
|
log.info(
|
|
"metamorph.permissions.seeded",
|
|
extra={"perms_created": perms["created"], "perms_total": perms["total"], "bindings": bindings},
|
|
)
|
|
return {"permissions": perms, "bindings": bindings}
|