"""Atomic permission catalogue + seed for the 3 default system groups. Permissions follow the `.` 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}