Milestone 3
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
30
backend/app/api/_validation.py
Normal file
30
backend/app/api/_validation.py
Normal 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
157
backend/app/api/auth.py
Normal 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
93
backend/app/api/diag.py
Normal 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
169
backend/app/api/groups.py
Normal 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
14
backend/app/api/health.py
Normal 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__})
|
||||
146
backend/app/api/invitations.py
Normal file
146
backend/app/api/invitations.py
Normal 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)
|
||||
17
backend/app/api/permissions.py
Normal file
17
backend/app/api/permissions.py
Normal 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
79
backend/app/api/setup.py
Normal 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
185
backend/app/api/users.py
Normal 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
24
backend/app/api/v1.py
Normal 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)
|
||||
Reference in New Issue
Block a user