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>
This commit is contained in:
98
backend/app/services/bootstrap.py
Normal file
98
backend/app/services/bootstrap.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user