diff --git a/backend/src/mimic/api/__init__.py b/backend/src/mimic/api/__init__.py index 0359546..33f6db4 100644 --- a/backend/src/mimic/api/__init__.py +++ b/backend/src/mimic/api/__init__.py @@ -4,16 +4,20 @@ from __future__ import annotations from flask import Flask +from mimic.api.audit import bp as audit_bp from mimic.api.auth import bp as auth_bp from mimic.api.engagements import bp as engagements_bp from mimic.api.hosts import bp as hosts_bp from mimic.api.scenarios import bp as scenarios_bp from mimic.api.ttps import bp as ttps_bp +from mimic.api.users import bp as users_bp def register_blueprints(app: Flask) -> None: app.register_blueprint(auth_bp, url_prefix="/api/v1/auth") + app.register_blueprint(users_bp, url_prefix="/api/v1/users") app.register_blueprint(engagements_bp, url_prefix="/api/v1/engagements") app.register_blueprint(hosts_bp, url_prefix="/api/v1") app.register_blueprint(ttps_bp, url_prefix="/api/v1/library/ttps") app.register_blueprint(scenarios_bp, url_prefix="/api/v1") + app.register_blueprint(audit_bp, url_prefix="/api/v1/audit") diff --git a/backend/src/mimic/api/users.py b/backend/src/mimic/api/users.py new file mode 100644 index 0000000..f56e8a2 --- /dev/null +++ b/backend/src/mimic/api/users.py @@ -0,0 +1,187 @@ +"""User management endpoints (rt_lead only — D-015). + +All four routes require `USER_MANAGE`. The `DELETE` endpoint is a soft-disable +(sets `disabled_at`); we never hard-delete users so the audit trail and +authored resources keep their FK targets. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from flask import Blueprint, abort, jsonify +from flask.typing import ResponseReturnValue +from sqlalchemy import func, select +from sqlalchemy.orm import selectinload + +from mimic.api._helpers import ( + api_error, + audit_write, + jsonify_model, + parse_body, + parse_page_query, + parse_uuid, +) +from mimic.auth.password import hash_password +from mimic.db.models import Group, User, UserGroup +from mimic.db.types import UserType +from mimic.extensions import db +from mimic.rbac import Permission, require_perm +from mimic.rbac.matrix import GroupName +from mimic.schemas import Page, UserCreate, UserRead, UserUpdate + +bp = Blueprint("users", __name__) + + +_TYPE_TO_GROUP: dict[UserType, GroupName] = { + UserType.RT_OPERATOR: GroupName.RT_OPERATOR, + UserType.RT_LEAD: GroupName.RT_LEAD, + UserType.SOC_ANALYST: GroupName.SOC_ANALYST, +} + + +def _resolve_group(user_type: UserType) -> Group: + group_name = _TYPE_TO_GROUP[user_type] + group = db.session.execute( + select(Group).where(Group.name == group_name.value) + ).scalar_one_or_none() + if group is None: + abort(500, description=f"group {group_name.value!r} missing; run migrations") + return group + + +@bp.get("") +@require_perm(Permission.USER_MANAGE) +def list_users() -> ResponseReturnValue: + page_query = parse_page_query() + type_filter = _parse_type_filter() + + base = select(User) + if type_filter is not None: + base = base.where(User.type == type_filter) + + total = db.session.execute(select(func.count()).select_from(base.subquery())).scalar_one() + rows = ( + db.session.execute( + base.order_by(User.created_at.desc()).offset(page_query.offset).limit(page_query.limit) + ) + .scalars() + .all() + ) + page = Page[UserRead]( + items=[UserRead.model_validate(row) for row in rows], + total=total, + page=page_query.page, + page_size=page_query.page_size, + ) + return jsonify(page.model_dump(mode="json")) + + +def _parse_type_filter() -> UserType | None: + from flask import request # noqa: PLC0415 — narrow to keep module import lean + + raw = request.args.get("type") + if raw is None: + return None + try: + return UserType(raw) + except ValueError: + abort(422, description=[{"loc": ["type"], "msg": "invalid user type", "type": "enum"}]) + + +@bp.post("") +@require_perm(Permission.USER_MANAGE) +def create_user() -> ResponseReturnValue: + payload = parse_body(UserCreate) + + existing = db.session.execute( + select(User).where(User.email == payload.email) + ).scalar_one_or_none() + if existing is not None: + return api_error("email_taken", "user with this email already exists", 409) + + user = User( + email=payload.email, + display_name=payload.display_name, + type=payload.type, + local_password_hash=hash_password(payload.password), + ) + db.session.add(user) + db.session.flush() + + group = _resolve_group(payload.type) + db.session.add(UserGroup(user_id=user.id, group_id=group.id, engagement_id=None)) + db.session.commit() + + audit_write( + action="user.create", + resource_type="user", + resource_id=user.id, + metadata={"email": user.email, "type": user.type.value}, + ) + return jsonify_model(UserRead.model_validate(user), status=201) + + +@bp.patch("/") +@require_perm(Permission.USER_MANAGE) +def update_user(uid: str) -> ResponseReturnValue: + user = _user_or_404(uid) + payload = parse_body(UserUpdate) + changes = payload.model_dump(exclude_unset=True) + + type_changed = "type" in changes and changes["type"] != user.type + + if "display_name" in changes: + user.display_name = changes["display_name"] + if "password" in changes and changes["password"] is not None: + user.local_password_hash = hash_password(changes["password"]) + if type_changed: + user.type = changes["type"] + # Realign group membership: drop the previous global membership and + # attach a fresh one matching the new type. Per-engagement memberships + # (engagement_id IS NOT NULL) stay untouched. + for link in list(user.group_links): + if link.engagement_id is None: + db.session.delete(link) + new_group = _resolve_group(changes["type"]) + db.session.add(UserGroup(user_id=user.id, group_id=new_group.id, engagement_id=None)) + + db.session.commit() + audit_write( + action="user.update", + resource_type="user", + resource_id=user.id, + metadata={ + "fields": sorted(k for k in changes if k != "password"), + "password_rotated": "password" in changes and changes["password"] is not None, + }, + ) + return jsonify_model(UserRead.model_validate(user)) + + +@bp.delete("/") +@require_perm(Permission.USER_MANAGE) +def disable_user(uid: str) -> ResponseReturnValue: + user = _user_or_404(uid) + if user.disabled_at is not None: + return "", 204 # idempotent — already disabled + user.disabled_at = datetime.now(tz=UTC) + db.session.commit() + audit_write( + action="user.disable", + resource_type="user", + resource_id=user.id, + metadata={"email": user.email}, + ) + return "", 204 + + +def _user_or_404(uid: str) -> User: + user = db.session.execute( + select(User) + .where(User.id == parse_uuid(uid, field="user id")) + .options(selectinload(User.group_links)) + ).scalar_one_or_none() + if user is None: + abort(404) + return user