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

View File

@@ -0,0 +1,30 @@
"""Lightweight email validator that tolerates internal/lab TLDs (.local, .corp, …).
`pydantic.EmailStr` relies on `email-validator` with `globally_deliverable=True`,
which rejects RFC 6761 special-use domains. Red-team and corporate intranet
deployments routinely use such suffixes — we accept any RFC-shape email and
defer deliverability checks to the operator.
"""
from __future__ import annotations
import re
from typing import Annotated
from pydantic import AfterValidator
# Permissive RFC-shape pattern: local-part 1..64 chars, domain has at least one
# dot, each label is 1..63 chars of letters/digits/hyphens, total ≤ 254.
_EMAIL_RE = re.compile(
r"^(?=.{1,254}$)[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$"
)
def _validate_email(value: str) -> str:
v = value.strip()
if not _EMAIL_RE.match(v):
raise ValueError("not a valid email address")
return v.lower()
Email = Annotated[str, AfterValidator(_validate_email)]

157
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,157 @@
"""Authentication endpoints.
`POST /auth/login` returns the access token in the body and sets the refresh
token in an HTTPOnly cookie scoped to `/api/v1/auth/`. The cookie is
`Secure; SameSite=Strict` and only the matching paths can read it.
"""
from __future__ import annotations
import logging
from flask import Blueprint, g, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from app.core.auth_decorators import require_auth
from app.core.config import settings
from app.core.rate_limit import limiter
from app.services import auth as auth_svc
bp = Blueprint("auth", __name__, url_prefix="/auth")
log = logging.getLogger("metamorph.api.auth")
REFRESH_COOKIE_NAME = "metamorph_refresh"
REFRESH_COOKIE_PATH = "/api/v1/auth/"
class LoginPayload(BaseModel):
email: Email
password: str = Field(min_length=1)
class ChangePasswordPayload(BaseModel):
current_password: str = Field(min_length=1)
new_password: str = Field(min_length=8)
def _set_refresh_cookie(resp, token: str, expires_at) -> None:
resp.set_cookie(
REFRESH_COOKIE_NAME,
token,
expires=expires_at,
httponly=True,
secure=True, # spec §M2; localhost is a secure context for modern browsers
samesite="Strict",
path=REFRESH_COOKIE_PATH,
)
def _clear_refresh_cookie(resp) -> None:
resp.set_cookie(
REFRESH_COOKIE_NAME,
"",
expires=0,
httponly=True,
secure=True, # spec §M2; localhost is a secure context for modern browsers
samesite="Strict",
path=REFRESH_COOKIE_PATH,
)
def _read_refresh_cookie() -> str | None:
return request.cookies.get(REFRESH_COOKIE_NAME)
def _serialize_pair(pair: auth_svc.TokenPair) -> dict:
return {
"access_token": pair.access_token,
"token_type": "Bearer",
"user_id": str(pair.user_id),
}
@bp.post("/login")
@limiter.limit("10 per minute")
def login():
try:
payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
pair = auth_svc.login(payload.email, payload.password)
except auth_svc.InvalidCredentials:
return jsonify({"error": "invalid_credentials"}), 401
resp = make_response(jsonify(_serialize_pair(pair)))
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
return resp
@bp.post("/refresh")
@limiter.limit("10 per minute")
def refresh_endpoint():
raw = _read_refresh_cookie()
if not raw:
return jsonify({"error": "no_refresh_cookie"}), 401
try:
pair = auth_svc.refresh(raw)
except auth_svc.TokenRevoked:
resp = make_response(jsonify({"error": "token_revoked"}), 401)
_clear_refresh_cookie(resp)
return resp
except auth_svc.InvalidCredentials:
resp = make_response(jsonify({"error": "invalid_refresh"}), 401)
_clear_refresh_cookie(resp)
return resp
resp = make_response(jsonify(_serialize_pair(pair)))
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
return resp
@bp.post("/logout")
def logout():
raw = _read_refresh_cookie()
if raw:
auth_svc.logout(raw)
resp = make_response(jsonify({"ok": True}))
_clear_refresh_cookie(resp)
return resp
@bp.get("/me")
@require_auth
def me():
u = g.current_user
return jsonify(
{
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"locale": u.locale,
"is_admin": u.is_admin,
"groups": sorted(u.group_names),
"permissions": sorted(u.permissions),
}
)
@bp.post("/change-password")
@require_auth
@limiter.limit("5 per minute")
def change_password():
try:
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
except auth_svc.InvalidCredentials:
return jsonify({"error": "current_password_incorrect"}), 400
except ValueError as e:
return jsonify({"error": "weak_password", "message": str(e)}), 400
resp = make_response(jsonify({"ok": True}))
_clear_refresh_cookie(resp)
return resp

93
backend/app/api/diag.py Normal file
View File

@@ -0,0 +1,93 @@
"""Operational diagnostics. No auth in v1 (M0/M1 only expose non-sensitive
counts and the current Alembic revision).
The `/diag/reset` endpoint is **test-only** — it requires `APP_ENV=test` and
is the bedrock of the e2e suite (clean DB + freshly minted install token).
"""
from __future__ import annotations
import logging
from flask import Blueprint, abort, jsonify
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.install_token import regenerate_install_token
from app.db.session import get_engine
bp = Blueprint("diag", __name__, url_prefix="/diag")
log = logging.getLogger("metamorph.diag")
@bp.get("/db")
def db_diag():
"""Return the Alembic revision and the count of public-schema tables."""
try:
with get_engine().connect() as conn:
revision = conn.execute(
text("SELECT version_num FROM alembic_version")
).scalar()
table_count = conn.execute(
text(
"SELECT count(*) FROM information_schema.tables "
"WHERE table_schema='public' AND table_type='BASE TABLE'"
)
).scalar_one()
except SQLAlchemyError as e:
log.warning("metamorph.diag.db_unreachable", extra={"error": str(e)})
return jsonify({"reachable": False, "error": "database_unreachable"}), 503
return jsonify(
{
"reachable": True,
"alembic_revision": revision,
"table_count": int(table_count),
}
)
@bp.post("/reset")
def reset_test_state():
"""TEST-ONLY: wipe users/auth tables and mint a fresh install token.
Refuses unless `APP_ENV=test`. Used by the Playwright suite to start each
auth scenario from a deterministic state.
"""
# NOTE: this endpoint is the test-suite reset hook. Allowed in `dev` too so
# the e2e suite can run against a normal `make up` stack, but in dev it is
# destructive — equivalent to `make clean` for the auth tables. Production
# (APP_ENV=prod/staging) is locked out.
if settings.APP_ENV not in ("dev", "test"):
abort(403, description="diag/reset is only available in dev/test")
if settings.APP_ENV == "dev":
log.warning("metamorph.diag.reset_in_dev_environment")
try:
with get_engine().begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, settings, groups RESTART IDENTITY CASCADE"
)
)
except SQLAlchemyError as e:
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
return jsonify({"reset": False, "error": "database_error"}), 500
token = regenerate_install_token()
# Clear the in-memory rate-limit counters so the e2e suite that follows can
# log in repeatedly without hitting `/auth/login`/`/auth/refresh` limits.
# The limiter uses `memory://` in dev (cf. `app/core/rate_limit.py`).
try:
from app.core.rate_limit import limiter # noqa: PLC0415 — avoid import cycle
if limiter.enabled:
limiter.reset()
except Exception as e: # noqa: BLE001
log.warning("metamorph.diag.rate_limit_reset_failed", extra={"error": str(e)})
log.warning("metamorph.diag.reset_completed")
return jsonify({"reset": True, "install_token": token})

169
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,169 @@
"""Admin endpoints for groups + their permission bindings."""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("groups", __name__, url_prefix="/groups")
log = logging.getLogger("metamorph.api.groups")
def _serialize(g: groups_svc.GroupView) -> dict:
return {
"id": str(g.id),
"name": g.name,
"description": g.description,
"is_system": g.is_system,
"members_count": g.members_count,
"permissions": g.permissions,
"created_at": g.created_at.isoformat(),
"updated_at": g.updated_at.isoformat(),
}
class CreateGroupPayload(BaseModel):
name: str = Field(min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class UpdateGroupPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class SetPermissionsPayload(BaseModel):
codes: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("group.read")
def list_groups():
rows = groups_svc.list_groups()
return jsonify({"items": [_serialize(g) for g in rows], "total": len(rows)})
@bp.get("/<group_id>")
@require_auth
@require_perm("group.read")
def get_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
g = groups_svc.get_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(g))
@bp.post("")
@require_auth
@require_perm("group.create")
def create_group():
try:
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.create_group(name=payload.name, description=payload.description)
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.created", extra={"group_id": str(g.id), "group_name": g.name})
return jsonify(_serialize(g)), 201
@bp.patch("/<group_id>")
@require_auth
@require_perm("group.update")
def update_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateGroupPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
description_unset = "description" not in raw
try:
g = groups_svc.update_group(
gid,
name=payload.name,
description=... if description_unset else payload.description,
)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.updated", extra={"group_id": str(gid), "fields": sorted(raw.keys())})
return jsonify(_serialize(g))
@bp.delete("/<group_id>")
@require_auth
@require_perm("group.delete")
def soft_delete(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
groups_svc.soft_delete_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
log.info("metamorph.group.soft_deleted", extra={"group_id": str(gid)})
return jsonify({"ok": True})
@bp.put("/<group_id>/permissions")
@require_auth
@require_perm("group.update")
def set_permissions(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.set_group_permissions(gid, payload.codes)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.group.permissions_set",
extra={"group_id": str(gid), "count": len(payload.codes)},
)
return jsonify(_serialize(g))

14
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,14 @@
"""Health endpoint — no DB dependency, used by orchestrators and the SPA."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app import __version__
bp = Blueprint("health", __name__)
@bp.get("/health")
def health():
return jsonify({"status": "ok", "version": __version__})

View File

@@ -0,0 +1,146 @@
"""Invitation endpoints — admin issues, invitee previews + accepts."""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, g, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from app.core.auth_decorators import require_auth, require_perm
from app.core.rate_limit import limiter
from app.services import invitations as inv_svc
bp = Blueprint("invitations", __name__, url_prefix="/invitations")
log = logging.getLogger("metamorph.api.invitations")
class CreateInvitationPayload(BaseModel):
email_hint: Email | None = None
group_ids: list[uuid.UUID] = Field(default_factory=list)
ttl_days: int | None = Field(default=None, ge=1, le=30)
class AcceptInvitationPayload(BaseModel):
email: Email
password: str = Field(min_length=8)
display_name: str | None = None
@bp.post("")
@require_auth
@require_perm("invitation.create")
def create():
try:
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
from datetime import timedelta
ttl = (
timedelta(days=payload.ttl_days)
if payload.ttl_days is not None
else inv_svc.INVITATION_TTL
)
result = inv_svc.create_invitation(
created_by_user_id=g.current_user.id,
email_hint=payload.email_hint,
group_ids=payload.group_ids,
ttl=ttl,
)
log.info(
"metamorph.invitation.created",
extra={
"invitation_id": str(result.invitation_id),
"by_user_id": str(g.current_user.id),
"expires_at": result.expires_at.isoformat(),
},
)
return make_response(
jsonify(
{
"id": str(result.invitation_id),
"token": result.raw_token, # shown ONCE
"expires_at": result.expires_at.isoformat(),
}
),
201,
)
@bp.get("")
@require_auth
@require_perm("invitation.read")
def list_active():
rows = inv_svc.list_active()
return jsonify(
[
{
"id": str(r.id),
"email_hint": r.email_hint,
"expires_at": r.expires_at.isoformat(),
"groups": [g.name for g in r.pre_assigned_groups],
}
for r in rows
]
)
@bp.post("/<invitation_id>/revoke")
@require_auth
@require_perm("invitation.revoke")
def revoke(invitation_id: str):
try:
iid = uuid.UUID(invitation_id)
except ValueError:
return jsonify({"error": "invalid_id"}), 400
ok = inv_svc.revoke(iid)
if not ok:
return jsonify({"error": "not_revocable"}), 404
return jsonify({"ok": True})
@bp.get("/preview/<token>")
@limiter.limit("20 per minute")
def preview(token: str):
p = inv_svc.preview(token)
return jsonify(
{
"is_valid": p.is_valid,
"reason": p.reason,
"email_hint": p.email_hint,
"expires_at": p.expires_at.isoformat(),
"groups": p.groups,
}
)
@bp.post("/accept/<token>")
@limiter.limit("10 per minute")
def accept(token: str):
try:
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
user_id = inv_svc.accept(
token,
email=payload.email,
password=payload.password,
display_name=payload.display_name,
)
except inv_svc.InvitationExpired:
return jsonify({"error": "invitation_expired"}), 410
except inv_svc.InvitationConsumed:
return jsonify({"error": "invitation_consumed"}), 410
except inv_svc.InvitationRevoked:
return jsonify({"error": "invitation_revoked"}), 410
except inv_svc.InvitationError as e:
return jsonify({"error": "invitation_invalid", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return make_response(jsonify({"ok": True, "user_id": str(user_id)}), 201)

View File

@@ -0,0 +1,17 @@
"""Read-only catalogue of platform permission codes."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("permissions", __name__, url_prefix="/permissions")
@bp.get("")
@require_auth
@require_perm("group.read")
def list_permissions():
return jsonify({"items": groups_svc.list_permissions()})

79
backend/app/api/setup.py Normal file
View File

@@ -0,0 +1,79 @@
"""Bootstrap endpoint — consumes the install token to create the first admin."""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from sqlalchemy import select
from app.core.rate_limit import limiter
from app.db.session import session_scope
from app.models.auth import User
from app.services.bootstrap import (
BootstrapError,
bootstrap_admin,
ensure_system_groups,
)
bp = Blueprint("setup", __name__, url_prefix="/setup")
log = logging.getLogger("metamorph.api.setup")
class SetupPayload(BaseModel):
install_token: str = Field(min_length=20)
email: Email
password: str = Field(min_length=8)
display_name: str | None = None
@bp.get("")
def setup_status():
"""Tell the SPA whether the bootstrap has already been done.
Used by the front to redirect to /setup vs /login on first paint.
"""
with session_scope() as s:
any_user = s.scalar(select(User.id).limit(1)) is not None
return jsonify({"completed": any_user})
@bp.post("")
@limiter.limit("5 per minute")
def setup():
try:
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
result = bootstrap_admin(
install_token=payload.install_token,
email=payload.email,
password=payload.password,
display_name=payload.display_name,
)
except BootstrapError as e:
return jsonify({"error": "bootstrap_failed", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.warning(
"metamorph.bootstrap.completed",
extra={"user_id": str(result.user_id), "admin_group_id": str(result.admin_group_id)},
)
# Make sure the redteam/blueteam groups exist too (idempotent).
ensure_system_groups()
return make_response(
jsonify(
{
"ok": True,
"user_id": str(result.user_id),
}
),
201,
)

185
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,185 @@
"""Admin endpoints for user management.
Note: self-service updates (own display name, locale, password) belong to
`/auth/*`; this blueprint is admin-only.
"""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import users as users_svc
bp = Blueprint("users", __name__, url_prefix="/users")
log = logging.getLogger("metamorph.api.users")
def _serialize(u: users_svc.UserView) -> dict:
return {
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"locale": u.locale,
"is_active": u.is_active,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
"created_at": u.created_at.isoformat(),
"updated_at": u.updated_at.isoformat(),
"groups": [{"id": str(gid), "name": name} for gid, name in u.groups],
}
class UpdateUserPayload(BaseModel):
# display_name: omitted = no change, null = clear, str = set.
# Tri-state encoded with a `default-unset` sentinel via model_extra.
display_name: str | None = None
locale: str | None = Field(default=None, pattern=r"^[a-z]{2}$")
is_active: bool | None = None
model_config = {"extra": "forbid"}
class SetGroupsPayload(BaseModel):
group_ids: list[uuid.UUID]
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("user.read")
def list_users():
q = request.args.get("q") or None
is_active_raw = request.args.get("is_active")
is_active: bool | None
if is_active_raw is None:
is_active = None
elif is_active_raw.lower() in ("true", "1", "yes"):
is_active = True
elif is_active_raw.lower() in ("false", "0", "no"):
is_active = False
else:
return jsonify({"error": "invalid_is_active"}), 400
try:
limit = int(request.args.get("limit", "50"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return jsonify({"error": "invalid_pagination"}), 400
limit = max(1, min(limit, 200))
offset = max(0, offset)
rows, total = users_svc.list_users(q=q, is_active=is_active, limit=limit, offset=offset)
return jsonify(
{
"items": [_serialize(u) for u in rows],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<user_id>")
@require_auth
@require_perm("user.read")
def get_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
u = users_svc.get_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(u))
@bp.patch("/<user_id>")
@require_auth
@require_perm("user.update")
def update_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateUserPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
# Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
display_name_unset = "display_name" not in raw
try:
u = users_svc.update_user(
uid,
display_name=... if display_name_unset else payload.display_name,
locale=payload.locale,
is_active=payload.is_active,
)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info(
"metamorph.user.updated",
extra={
"user_id": str(uid),
"fields": sorted(raw.keys()),
},
)
return jsonify(_serialize(u))
@bp.delete("/<user_id>")
@require_auth
@require_perm("user.delete")
def soft_delete(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
users_svc.soft_delete_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info("metamorph.user.soft_deleted", extra={"user_id": str(uid)})
return jsonify({"ok": True})
@bp.put("/<user_id>/groups")
@require_auth
@require_perm("user.update")
def set_groups(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
u = users_svc.set_user_groups(uid, payload.group_ids)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.user.groups_set",
extra={"user_id": str(uid), "groups": [str(g) for g in payload.group_ids]},
)
return jsonify(_serialize(u))

24
backend/app/api/v1.py Normal file
View File

@@ -0,0 +1,24 @@
"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here."""
from __future__ import annotations
from flask import Blueprint
from app.api.auth import bp as auth_bp
from app.api.diag import bp as diag_bp
from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp
from app.api.permissions import bp as permissions_bp
from app.api.setup import bp as setup_bp
from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
bp.register_blueprint(health_bp)
bp.register_blueprint(diag_bp)
bp.register_blueprint(setup_bp)
bp.register_blueprint(auth_bp)
bp.register_blueprint(invitations_bp)
bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp)