Milestone 3

This commit is contained in:
Knacky
2026-05-11 06:05:27 +02:00
commit 4c25e198fc
125 changed files with 13489 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
"""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}