mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 18:18:08 +00:00
fix(pat): align replay scopes, keep 404/405, retire expired names, document the PAT_ENABLED switch
The ASGI message events route now accepts the same scopes as its Flask sibling (conversations:read or chat:run) through a shared constant. A token request that fails routing gets Flask's 404/405 instead of a 403. Creating a token retires an expired token that still held the name. allowed_ids uses is_pat instead of a bare literal. PAT_ENABLED is documented as the master switch it is: turning it off stops every existing token from authenticating. The docs explain that sources are matched by name (oldest wins) and point CI flows at sources upload --replace.
This commit is contained in:
1 parent
934ae1afb3
commit
7ddb5f6400
12 files changed
+105
-15
No files matched your search
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import Token
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional, Sequence, Tuple, Union
|
||||
|
||||
import anyio
|
||||
from starlette.requests import Request
|
||||
@@ -23,13 +23,13 @@ from docsgpt.core.settings import settings
|
||||
|
||||
|
||||
async def authenticate(
|
||||
request: Request, *, pat_scope: Optional[str] = None
|
||||
request: Request, *, pat_scope: Union[str, Sequence[str], None] = None
|
||||
) -> Tuple[Optional[dict], Optional[JSONResponse]]:
|
||||
"""Decode the caller's JWT the way Flask's ``authenticate_request`` does.
|
||||
|
||||
Args:
|
||||
request: The incoming Starlette request.
|
||||
pat_scope: Scope a personal access token needs for this route. Left
|
||||
pat_scope: Scope (or any-of scopes) a personal access token needs for this route. Left
|
||||
unset, the route rejects PATs outright (deny by default, matching
|
||||
the Flask rule table in ``docsgpt/api/pat/rules.py``). A token with
|
||||
a resource filter is always rejected.
|
||||
@@ -48,7 +48,8 @@ async def authenticate(
|
||||
if is_pat(decoded):
|
||||
# A PAT lookup already excludes revoked tokens and deactivated users,
|
||||
# so the session denylist below does not apply to it.
|
||||
if pat_scope is None or pat_scope not in (decoded.get("scopes") or []):
|
||||
accepted = (pat_scope,) if isinstance(pat_scope, str) else tuple(pat_scope or ())
|
||||
if not set(accepted).intersection(decoded.get("scopes") or []):
|
||||
return None, JSONResponse(
|
||||
{"success": False, "message": "Token lacks the required scope", "error": "insufficient_scope"},
|
||||
status_code=403,
|
||||
|
||||
@@ -25,6 +25,7 @@ from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from docsgpt.api.asgi_auth import authenticate, bind_log_context, json_error
|
||||
from docsgpt.api.pat.rules import MESSAGE_REPLAY_SCOPES
|
||||
from docsgpt.api.asgi_stream import sse_response
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
@@ -94,7 +95,8 @@ async def stream_message_events(request: Request) -> Response:
|
||||
"""
|
||||
# Same JWT decoder and OIDC revocation check as the Flask routes. With
|
||||
# AUTH_TYPE unset the caller resolves to ``{"sub": "local"}``.
|
||||
decoded, error = await authenticate(request, pat_scope="chat:run")
|
||||
# Same scopes as its Flask sibling GET /api/messages/<id>/tail.
|
||||
decoded, error = await authenticate(request, pat_scope=MESSAGE_REPLAY_SCOPES)
|
||||
if error is not None:
|
||||
return error
|
||||
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
||||
|
||||
@@ -139,6 +139,7 @@ class PersonalAccessTokens(Resource):
|
||||
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(
|
||||
|
||||
@@ -25,6 +25,8 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
from docsgpt.api.pat.tokens import is_pat
|
||||
|
||||
VIEW, QUERY, JSON, FORM, BODY = "view", "query", "json", "form", "body"
|
||||
|
||||
Locator = tuple[str, str]
|
||||
@@ -53,6 +55,11 @@ def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] =
|
||||
return Rule(scopes=scopes, family=family, ids=tuple(ids), **kwargs)
|
||||
|
||||
|
||||
#: Scopes that admit a token to message replay. Shared by the Flask tail route
|
||||
#: below and its ASGI sibling GET /api/messages/<id>/events (docsgpt/api/async_sse.py),
|
||||
#: which sits outside this table.
|
||||
MESSAGE_REPLAY_SCOPES = ("conversations:read", "chat:run")
|
||||
|
||||
_ALL_FAMILIES = ("agents", "sources", "prompts", "tools", "workflows")
|
||||
|
||||
# Ids of other families that agent create/update accept in their JSON-or-form body.
|
||||
@@ -220,7 +227,7 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=("agents",)),
|
||||
# A message cannot be tied to an allowlist from here, so any restricted token is kept out.
|
||||
("/api/messages/<string:message_id>/tail", "GET"): _rule(
|
||||
any_of=("conversations:read", "chat:run"), family=None, blocked_by=_ALL_FAMILIES
|
||||
any_of=MESSAGE_REPLAY_SCOPES, family=None, blocked_by=_ALL_FAMILIES
|
||||
),
|
||||
("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
@@ -360,7 +367,11 @@ def _all_allowed(ids: Iterable[str], allowed: Iterable[str]) -> bool:
|
||||
def authorize(request, decoded_token: dict) -> Optional[tuple[dict, int]]:
|
||||
"""Check a PAT request against the table. ``None`` allows; otherwise ``(body, status)``."""
|
||||
url_rule = getattr(request, "url_rule", None)
|
||||
rule = RULES.get((url_rule.rule, request.method)) if url_rule is not None else None
|
||||
if url_rule is None:
|
||||
# Routing failed (unknown path or wrong method): no view will run, so
|
||||
# let Flask answer 404/405 instead of masking it with a 403.
|
||||
return None
|
||||
rule = RULES.get((url_rule.rule, request.method))
|
||||
if rule is None:
|
||||
return (
|
||||
{
|
||||
@@ -420,8 +431,8 @@ def allowed_ids(request, family: str) -> Optional[set[str]]:
|
||||
|
||||
Used by listing routes (``listing=True``) and by ``in_route`` handlers.
|
||||
"""
|
||||
decoded = getattr(request, "decoded_token", None) or {}
|
||||
if decoded.get("auth_method") != "pat":
|
||||
decoded = getattr(request, "decoded_token", None)
|
||||
if not is_pat(decoded):
|
||||
return None
|
||||
ids = (decoded.get("resource_filter") or {}).get(family)
|
||||
if ids is None:
|
||||
|
||||
@@ -89,8 +89,10 @@ class AuthSettings(SettingsGroup):
|
||||
PAT_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow users to create personal access tokens. Tokens are only issued under AUTH_TYPE=oidc or "
|
||||
"unset (None); simple_jwt and session_jwt have no stable user identity to bind a token to."
|
||||
"Master switch for personal access tokens. When false, no token can be created AND every existing "
|
||||
"token stops authenticating immediately (pipelines using them get 401); tokens are kept and work "
|
||||
"again when re-enabled. Tokens are only available under AUTH_TYPE=oidc or unset (None); switching "
|
||||
"to simple_jwt or session_jwt disables them the same way."
|
||||
),
|
||||
)
|
||||
PAT_DEFAULT_LIFETIME_DAYS: int = Field(
|
||||
|
||||
@@ -87,6 +87,23 @@ class PersonalAccessTokensRepository:
|
||||
{"user_id": user_id},
|
||||
).scalar_one()
|
||||
|
||||
def retire_expired_name(self, user_id: str, name: str) -> int:
|
||||
"""Revoke an expired token holding ``name`` so the name can be reused.
|
||||
|
||||
An expired token can no longer authenticate but keeps ``status = 'active'``,
|
||||
and the unique index on live names would otherwise reserve its name forever.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE personal_access_tokens "
|
||||
"SET status = 'revoked', revoked_at = now(), revoke_reason = 'expired' "
|
||||
"WHERE user_id = :user_id AND name = :name AND status = 'active' "
|
||||
"AND expires_at IS NOT NULL AND expires_at <= now()"
|
||||
),
|
||||
{"user_id": user_id, "name": name},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def name_in_use(self, user_id: str, name: str) -> bool:
|
||||
return (
|
||||
self._conn.execute(
|
||||
|
||||
Reference in new issue
Block a user