"""Personal access token management. ``/api/user/tokens`` lets a signed-in user list, create and revoke their own tokens; ``/api/admin/...`` lets an admin inspect and revoke anyone's. None of these routes accept a PAT (see ``docsgpt/api/pat/rules.py``), so a leaked token can neither mint a replacement nor widen itself. """ from __future__ import annotations import uuid from datetime import datetime, timezone from flask import jsonify, make_response, request from flask_restx import Namespace, Resource from sqlalchemy.exc import IntegrityError from docsgpt.api.pat.tokens import ( FILTERABLE_FAMILIES, SCOPES, auth_type_supports_pats, generate_token, is_pat, normalize_resource_filter, normalize_scopes, renewal_lifetime_days, resolve_expiry, ) from docsgpt.api.user.authz import admin_required from docsgpt.core.settings import settings from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository from docsgpt.storage.db.repositories.personal_access_tokens import ( PersonalAccessTokensRepository, ) from docsgpt.storage.db.session import db_readonly, db_session pat_ns = Namespace("tokens", description="Personal access tokens", path="/api") _MAX_NAME_LENGTH = 100 def _error(message: str, status: int): return make_response(jsonify({"success": False, "message": message}), status) def _session_user_id(): """The caller's id, or ``None`` for anonymous and PAT callers alike.""" decoded = getattr(request, "decoded_token", None) if not decoded or is_pat(decoded): return None return decoded.get("sub") def _valid_uuid(value: str) -> bool: """Canonical form only: ``uuid.UUID`` also accepts ``urn:uuid:…`` and braces, which Postgres does not.""" try: return str(uuid.UUID(str(value))) == str(value).lower() except (ValueError, AttributeError, TypeError): return False def _is_expired(expires_at) -> bool: if not expires_at: return False try: moment = expires_at if isinstance(expires_at, datetime) else datetime.fromisoformat(str(expires_at)) except ValueError: return False if moment.tzinfo is None: moment = moment.replace(tzinfo=timezone.utc) return moment <= datetime.now(timezone.utc) def serialize_token(row: dict) -> dict: # The row keeps status 'active' until someone revokes it; report what is true for a caller. status = "expired" if row["status"] == "active" and _is_expired(row.get("expires_at")) else row["status"] return { "id": str(row["id"]), "name": row["name"], "token_prefix": row["token_prefix"], "scopes": list(row.get("scopes") or []), "resource_filter": row.get("resource_filter") or {}, "status": status, "expires_at": row.get("expires_at"), "last_used_at": row.get("last_used_at"), "last_used_ip": row.get("last_used_ip"), "created_at": row.get("created_at"), "regenerated_at": row.get("regenerated_at"), "revoked_at": row.get("revoked_at"), } def _policy() -> dict: return { "enabled": auth_type_supports_pats(), "default_lifetime_days": settings.PAT_DEFAULT_LIFETIME_DAYS, "max_lifetime_days": settings.PAT_MAX_LIFETIME_DAYS, "allow_non_expiring": settings.PAT_ALLOW_NON_EXPIRING, "max_per_user": settings.PAT_MAX_PER_USER, "filterable_families": list(FILTERABLE_FAMILIES), } @pat_ns.route("/user/tokens") class PersonalAccessTokens(Resource): def get(self): """List the caller's tokens with the scope catalog and the server's token policy.""" user_id = _session_user_id() if not user_id: return _error("Authentication required", 401) with db_readonly() as conn: rows = PersonalAccessTokensRepository(conn).list_for_user(user_id) return make_response( jsonify( { "success": True, "tokens": [serialize_token(r) for r in rows], "scopes": [{"name": k, "description": v} for k, v in SCOPES.items()], "policy": _policy(), } ), 200, ) def post(self): """Create a token. The plaintext ``token`` is returned here and never again.""" user_id = _session_user_id() if not user_id: return _error("Authentication required", 401) if not auth_type_supports_pats(): return _error("Personal access tokens are not available on this server", 403) body = request.get_json(silent=True) if body is None: body = {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) name = body.get("name") if not isinstance(name, str) or not name.strip(): return _error("name is required", 400) name = name.strip() if len(name) > _MAX_NAME_LENGTH: return _error(f"name must be at most {_MAX_NAME_LENGTH} characters", 400) try: scopes = normalize_scopes(body.get("scopes")) resource_filter = normalize_resource_filter(body.get("resource_filter"), scopes) expires_at = resolve_expiry(body.get("expires_in_days")) except ValueError as exc: return _error(str(exc), 400) token, token_hash, token_prefix = generate_token() try: with db_session() as conn: repo = PersonalAccessTokensRepository(conn) # Serialise this user's creates so concurrent requests cannot both pass the cap check. repo.lock_user(user_id) if repo.count_active(user_id) >= settings.PAT_MAX_PER_USER: return _error( f"Token limit reached ({settings.PAT_MAX_PER_USER}); revoke one first", 409 ) repo.retire_expired_name(user_id, name) if repo.name_in_use(user_id, name): return _error("A token with this name already exists", 409) row = repo.create( user_id, name, token_hash=token_hash, token_prefix=token_prefix, scopes=scopes, resource_filter=resource_filter, expires_at=expires_at, ) AuthEventsRepository(conn).insert( user_id, "pat_created", ip=request.remote_addr, user_agent=request.headers.get("User-Agent"), metadata={ "token_id": str(row["id"]), "name": name, "scopes": scopes, "resource_filter": resource_filter, "expires_at": row.get("expires_at"), }, ) except IntegrityError: # Lost a race against a concurrent create with the same name. return _error("A token with this name already exists", 409) return make_response( jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}), 201, ) @pat_ns.route("/user/tokens/") class PersonalAccessToken(Resource): def delete(self, token_id): """Revoke one of the caller's tokens. Takes effect on the next request.""" user_id = _session_user_id() if not user_id: return _error("Authentication required", 401) if not _valid_uuid(token_id): return _error("Token not found", 404) with db_session() as conn: revoked = PersonalAccessTokensRepository(conn).revoke(token_id, user_id) if revoked: AuthEventsRepository(conn).insert( user_id, "pat_revoked", ip=request.remote_addr, user_agent=request.headers.get("User-Agent"), metadata={"token_id": token_id, "by": user_id}, ) if not revoked: return _error("Token not found", 404) return make_response(jsonify({"success": True}), 200) @pat_ns.route("/user/tokens//regenerate") class PersonalAccessTokenRegenerate(Resource): def post(self, token_id): """Issue a new secret for a token and reset its expiry. Name, scopes and restrictions stay; the old secret stops working at once. ``expires_in_days`` is optional and defaults to the lifetime the token was last issued with. An expired token can be renewed this way; a revoked one cannot. The plaintext ``token`` is returned here and never again. """ user_id = _session_user_id() if not user_id: return _error("Authentication required", 401) if not auth_type_supports_pats(): return _error("Personal access tokens are not available on this server", 403) if not _valid_uuid(token_id): return _error("Token not found", 404) body = request.get_json(silent=True) if body is None: body = {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) token, token_hash, token_prefix = generate_token() with db_session() as conn: repo = PersonalAccessTokensRepository(conn) current = repo.get(token_id, user_id) if not current or current["status"] != "active": return _error("Token not found", 404) requested = body.get("expires_in_days") if requested is None: requested = renewal_lifetime_days(current) try: expires_at = resolve_expiry(requested) except ValueError as exc: return _error(str(exc), 400) row = repo.regenerate( token_id, user_id, token_hash=token_hash, token_prefix=token_prefix, expires_at=expires_at ) if not row: # Revoked between the read and the write. return _error("Token not found", 404) AuthEventsRepository(conn).insert( user_id, "pat_regenerated", ip=request.remote_addr, user_agent=request.headers.get("User-Agent"), metadata={ "token_id": token_id, "name": row["name"], "expires_at": row.get("expires_at"), "previous_expires_at": current.get("expires_at"), }, ) return make_response( jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}), 200, ) @pat_ns.route("/admin/users//tokens") class AdminUserTokens(Resource): @admin_required def get(self, user_id): """List a user's tokens, revoked ones included.""" with db_readonly() as conn: rows = PersonalAccessTokensRepository(conn).list_for_user(user_id, include_revoked=True) return make_response( jsonify({"success": True, "tokens": [serialize_token(r) for r in rows]}), 200 ) @pat_ns.route("/admin/tokens/") class AdminToken(Resource): @admin_required def delete(self, token_id): """Revoke any user's token.""" if not _valid_uuid(token_id): return _error("Token not found", 404) actor = (getattr(request, "decoded_token", None) or {}).get("sub") with db_session() as conn: repo = PersonalAccessTokensRepository(conn) row = repo.get(token_id) revoked = bool(row) and repo.revoke(token_id, reason="admin_revoked") if revoked: AuthEventsRepository(conn).insert( row["user_id"], "pat_revoked", ip=request.remote_addr, user_agent=request.headers.get("User-Agent"), metadata={"token_id": token_id, "by": actor, "via": "admin_api"}, actor_id=actor, target_id=row["user_id"], ) if not revoked: return _error("Token not found", 404) return make_response(jsonify({"success": True}), 200)