Files
Metamorph/backend/app/services/bootstrap.py

99 lines
3.3 KiB
Python
Raw Normal View History

feat(m2): auth, JWT, invitations, bootstrap, RTOps SPA pages Crypto + tokens - app/core/security.py: Argon2id PasswordHasher (time_cost=2, memory_cost= 64 MiB, parallelism=2) + opaque-token SHA-256 helpers (raw token shown once, only the hash lives in the DB). - app/core/jwt_tokens.py: HS256, claims iss/sub/type/jti/iat/exp. Access 1h, refresh 30d. Services - services/auth.py: login, refresh with token rotation + reuse-detection chain revoke, logout (idempotent), change_password (forces logout-all). - services/invitations.py: create, preview, accept, revoke. Default 7d TTL. - services/bootstrap.py: seeds the 3 system groups (admin/redteam/blueteam), consumes the install token, attaches the first user to admin. - core/install_token.py: mints, persists in settings, marks consumed, regenerate hook for /diag/reset. API - POST /setup (consume install token, create 1st admin) + GET /setup (status). - POST /auth/{login,refresh,logout,change-password} + GET /auth/me. - POST /invitations + GET /invitations + GET /invitations/preview/<token> + POST /invitations/accept/<token> + POST /invitations/<id>/revoke. - POST /diag/reset: test-only kill switch (truncate auth tables + mint fresh install token). Allowed in dev too (with WARNING log) so the e2e suite can run against a make-up stack; production locked out. Middleware - @require_auth populates g.current_user (snapshot dataclass, session closed before request handler runs). - @require_perm(*codes): atomic perm union check; admin group bypasses. Perm catalogue lands in M3, scaffolding here. - flask-limiter: 10/min/IP on /auth/login & /auth/refresh, 5/min on /auth/change-password & /setup, 10–20/min on invitation endpoints. Disabled in APP_ENV=test. CLI - flask --app app.cli metamorph print-install-token [--force] - flask --app app.cli metamorph seed-mitre (M4 placeholder) Refresh cookie metamorph_refresh: HttpOnly + Secure (localhost is a secure context for modern browsers) + SameSite=Strict + Path=/api/v1/auth/. Email validation: app.api._validation.Email permissive RFC-shape regex so internal TLDs (.local/.corp/.test) are accepted — pydantic.EmailStr's deliverability check is too strict for red-team labs. Frontend - lib/{api,auth}.ts: access token in module memory, refresh cookie, automatic 401-retry via /auth/refresh, useAuth() hook. - components/{Layout,RequireAuth}.tsx + ui/{TextField,Alert}.tsx. - pages/{Login,Setup,Register,Profile}. Testing - tests/test_auth_flow.py: 15 integration tests (24 backend total). - e2e/tests/m2-auth.spec.ts: 8 Playwright tests (20 e2e total). - tasks/testing-m2.md. DoD: make test-api → 24 passed, make e2e → 20 passed; spec-reviewer pass applied (Secure unconditional, refresh limit 10/min/IP). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:16:48 +02:00
"""Initial bootstrap : seed `admin` / `redteam` / `blueteam` system groups + first admin.
The detailed permission seeding lives in M3 (`mitre.sync` etc.); for M2 we only
need an `admin` group that effectively grants full access. We model that as an
absent permission set + a special `is_system` flag on the group, plus the
`@require_perm` decorator that bypasses checks for any user belonging to a
system `admin` group. M3 will fill in the atomic permissions.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from sqlalchemy import select
from app.core.install_token import (
mark_install_token_consumed,
verify_install_token,
)
from app.core.security import hash_password
from app.db.session import session_scope
from app.models.auth import Group, User, UserGroup
ADMIN_GROUP_NAME = "admin"
REDTEAM_GROUP_NAME = "redteam"
BLUETEAM_GROUP_NAME = "blueteam"
@dataclass
class BootstrapResult:
user_id: uuid.UUID
admin_group_id: uuid.UUID
class BootstrapError(Exception):
pass
def ensure_system_groups() -> dict[str, uuid.UUID]:
"""Create the three system groups if missing. Idempotent."""
out: dict[str, uuid.UUID] = {}
with session_scope() as s:
for name, desc in (
(ADMIN_GROUP_NAME, "Platform administrators — full access."),
(REDTEAM_GROUP_NAME, "Red team operators."),
(BLUETEAM_GROUP_NAME, "Blue team operators."),
):
grp = s.scalar(select(Group).where(Group.name == name, Group.is_system.is_(True)))
if grp is None:
grp = Group(name=name, description=desc, is_system=True)
s.add(grp)
s.flush()
out[name] = grp.id
return out
def bootstrap_admin(
*, install_token: str, email: str, password: str, display_name: str | None = None
) -> BootstrapResult:
"""Consume the install token, create the first admin user, attach to admin group."""
if not verify_install_token(install_token):
raise BootstrapError("invalid or already-consumed install token")
if len(password) < 8:
raise ValueError("password must be at least 8 characters")
email_norm = email.strip().lower()
# Re-check users count under transaction to avoid races.
with session_scope() as s:
if s.scalar(select(User.id).limit(1)) is not None:
raise BootstrapError("setup already done — at least one user exists")
groups = ensure_system_groups()
with session_scope() as s:
user = User(
email=email_norm,
display_name=(display_name or "").strip() or None,
password_hash=hash_password(password),
)
s.add(user)
s.flush()
s.add(UserGroup(user_id=user.id, group_id=groups[ADMIN_GROUP_NAME]))
admin_id = groups[ADMIN_GROUP_NAME]
user_id = user.id
mark_install_token_consumed()
# Re-seed the permission catalogue + system-group bindings. This is called
# at boot too, but on a fresh DB after `/diag/reset` the groups were just
# recreated above and have no permissions yet — seeding here keeps the
# bootstrap path self-contained.
from app.services.permissions_seed import seed_all # noqa: PLC0415 — avoid import cycle
seed_all()
return BootstrapResult(user_id=user_id, admin_group_id=admin_id)