feat(backend): add local auth + group-based RBAC matching F11 (B0.6)

- Permission enum + GroupName enum + GROUP_PERMISSIONS mapping mirror
  the F11 matrix in code (verifiable against the spec table in tests).
- @require_perm decorator: 401 on anonymous, 403 on missing permission,
  passes through otherwise. Pure-function user_has() for unit-testing.
- AuthUser (Flask-Login wrapper) resolves the permission set from a
  User's groups; load_user is the Flask-Login user_loader.
- bcrypt password hashing helpers (12 rounds by default, configurable).
- SOC opaque token (D-006): secrets.token_urlsafe(32), bcrypt-hashed at
  rest, plain value returned once at creation and never re-displayable.
- Group-based RBAC from day one (D-003) — Keycloak OIDC in v2 maps onto
  the same group model.
This commit is contained in:
knacky
2026-05-21 20:33:31 +02:00
parent 104d73143a
commit 7f4ad85a68
7 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
"""F11 permission matrix as code.
This mirrors the spec table 1:1 — keeping it as a single source in code lets
us hash-check it against the spec in CI (spec-analyst task S0.2). Three default
groups are seeded by Alembic and align with the three user types.
"""
from __future__ import annotations
import enum
class Permission(enum.StrEnum):
"""Application permissions. Codes are kebab-case + dotted scope."""
# Engagement
ENGAGEMENT_CREATE = "engagement.create"
ENGAGEMENT_READ = "engagement.read"
ENGAGEMENT_READ_OWN = "engagement.read_own" # scoped to soc_session
ENGAGEMENT_UPDATE = "engagement.update"
ENGAGEMENT_DELETE = "engagement.delete"
ENGAGEMENT_MEMBER_MANAGE = "engagement.member.manage"
ENGAGEMENT_SOC_TOKEN_ISSUE = "engagement.soc_token.issue" # noqa: S105
# Hosts
HOST_CRUD = "host.crud"
# TTPs
TTP_READ = "ttp.read"
TTP_DRAFT = "ttp.draft"
TTP_PROMOTE = "ttp.promote"
# Imports
IMPORT_JOURNAL = "import.journal"
# Scenarios
SCENARIO_CRUD = "scenario.crud"
# Runs
RUN_START = "run.start"
RUN_CONTROL = "run.control"
# Detection / evidence
EVIDENCE_ADD = "evidence.add"
DETECTION_ADD = "detection.add"
# Cleanup
CLEANUP_TRIGGER = "cleanup.trigger"
# Reports
REPORT_GENERATE = "report.generate"
REPORT_READ = "report.read"
# Audit
AUDIT_READ = "audit.read"
ALL_PERMISSIONS: tuple[Permission, ...] = tuple(Permission)
class GroupName(enum.StrEnum):
RT_OPERATOR = "rt_operator"
RT_LEAD = "rt_lead"
SOC_ANALYST = "soc_analyst"
# Source-of-truth mapping derived from spec §F11. Verified by tests against
# the spec table.
GROUP_PERMISSIONS: dict[GroupName, frozenset[Permission]] = {
GroupName.RT_OPERATOR: frozenset(
{
Permission.ENGAGEMENT_CREATE,
Permission.ENGAGEMENT_READ,
Permission.HOST_CRUD,
Permission.TTP_READ,
Permission.TTP_DRAFT,
Permission.IMPORT_JOURNAL,
Permission.SCENARIO_CRUD,
Permission.EVIDENCE_ADD,
Permission.CLEANUP_TRIGGER,
Permission.REPORT_READ,
}
),
GroupName.RT_LEAD: frozenset(ALL_PERMISSIONS),
GroupName.SOC_ANALYST: frozenset(
{
Permission.ENGAGEMENT_READ_OWN,
Permission.DETECTION_ADD,
Permission.REPORT_READ,
}
),
}
DEFAULT_GROUPS: tuple[GroupName, ...] = tuple(GroupName)