mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token in place: name, scopes and restrictions stay, the old secret stops matching at once, and the expiry is reset. The lifetime defaults to the one the token was last issued with (clamped to today's policy) or to expires_in_days when given. An expired token can be renewed this way; a revoked one cannot. It is session only like the rest of token management, and writes a pat_regenerated audit event. regenerated_at records the rotation.
314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""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/<string:token_id>")
|
|
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/<string:token_id>/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/<string:user_id>/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/<string:token_id>")
|
|
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"},
|
|
)
|
|
if not revoked:
|
|
return _error("Token not found", 404)
|
|
return make_response(jsonify({"success": True}), 200)
|