From 22ecc0aee3918e560e84dfdd7a2479f002dd2431 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 01/13] feat(pat): add personal access token storage and settings A personal_access_tokens table (migration 0032) holds scoped user-level API credentials. Only the SHA-256 of the secret is stored, like device session tokens. Lookups exclude revoked and expired tokens and the tokens of deactivated users. PAT_* settings cover the feature switch, default and maximum lifetime, the operator opt-in for non-expiring tokens and the per-user cap. --- .env-template | 8 + docs/content/Deploying/Settings-Reference.mdx | 30 ++++ .../versions/0032_personal_access_tokens.py | 72 ++++++++ docsgpt/core/settings/auth.py | 24 +++ docsgpt/storage/db/models.py | 41 +++++ .../db/repositories/personal_access_tokens.py | 156 +++++++++++++++++ .../test_personal_access_tokens.py | 163 ++++++++++++++++++ 7 files changed, 494 insertions(+) create mode 100644 docsgpt/alembic/versions/0032_personal_access_tokens.py create mode 100644 docsgpt/storage/db/repositories/personal_access_tokens.py create mode 100644 tests/storage/db/repositories/test_personal_access_tokens.py diff --git a/.env-template b/.env-template index eb218c71..5e868f5e 100644 --- a/.env-template +++ b/.env-template @@ -101,3 +101,11 @@ MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId} # pair with OIDC_USER_ID_CLAIM=email so SCIM userName matches the OIDC user id) # SCIM_ENABLED=false # SCIM_TOKEN= + +# Personal access tokens (scoped API tokens for CLI and CI/CD; Settings → Access Tokens). +# Available with AUTH_TYPE=oidc or unset. +# PAT_ENABLED=true +# PAT_DEFAULT_LIFETIME_DAYS=90 +# PAT_MAX_LIFETIME_DAYS=365 +# PAT_ALLOW_NON_EXPIRING=false +# PAT_MAX_PER_USER=25 diff --git a/docs/content/Deploying/Settings-Reference.mdx b/docs/content/Deploying/Settings-Reference.mdx index 32181adb..9266a90e 100644 --- a/docs/content/Deploying/Settings-Reference.mdx +++ b/docs/content/Deploying/Settings-Reference.mdx @@ -132,6 +132,36 @@ Type `str`, default unset. Bearer token for IdP SCIM clients (required when SCIM is enabled). +### `PAT_ENABLED` + +Type `bool`, default `true`. + +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. + +### `PAT_DEFAULT_LIFETIME_DAYS` + +Type `int`, default `90`, must be `> 0`. + +Lifetime of a personal access token created without an explicit expiry. + +### `PAT_MAX_LIFETIME_DAYS` + +Type `int`, default `365`, must be `> 0`. + +Longest lifetime a user may request for a personal access token. + +### `PAT_ALLOW_NON_EXPIRING` + +Type `bool`, default `false`. + +Let users create personal access tokens that never expire. Off by default. + +### `PAT_MAX_PER_USER` + +Type `int`, default `25`, must be `> 0`. + +Maximum number of live personal access tokens per user. + ## LLM providers diff --git a/docsgpt/alembic/versions/0032_personal_access_tokens.py b/docsgpt/alembic/versions/0032_personal_access_tokens.py new file mode 100644 index 00000000..7d5090bf --- /dev/null +++ b/docsgpt/alembic/versions/0032_personal_access_tokens.py @@ -0,0 +1,72 @@ +"""0032 personal access tokens — scoped, user-level API credentials. + +A personal access token (PAT) authenticates its owner against the management +API for CLI and CI/CD use. Only the SHA-256 of the secret is stored, mirroring +``devices.token_hash``: the plaintext is shown once at creation and a database +leak cannot reconstruct it. ``token_prefix`` keeps the first characters so a +user can tell their tokens apart in the UI. + +``scopes`` is the server-side grant list (never read from the credential +itself). ``resource_filter`` optionally narrows a resource family to specific +ids, e.g. ``{"agents": [""]}``; an absent family is unrestricted within +the token's scopes. ``expires_at`` is NULL only when the operator allows +non-expiring tokens. + +``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and +``user_roles`` so a token row never blocks user deletion. + +Revision ID: 0032_personal_access_tokens +Revises: 0031_token_usage_cache_tokens +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0032_personal_access_tokens" +down_revision: Union[str, None] = "0031_token_usage_cache_tokens" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS personal_access_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + resource_filter JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'revoked')), + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + last_used_ip TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + revoke_reason TEXT + ); + """ + ) + # Looked up on every PAT-authenticated request. + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_hash_uidx " + "ON personal_access_tokens(token_hash);" + ) + # Names are unique among a user's live tokens; a revoked name can be reused. + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_user_name_uidx " + "ON personal_access_tokens(user_id, name) WHERE status = 'active';" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS personal_access_tokens_user_idx " + "ON personal_access_tokens(user_id, created_at DESC);" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS personal_access_tokens;") diff --git a/docsgpt/core/settings/auth.py b/docsgpt/core/settings/auth.py index 85c57b84..7471dc5a 100644 --- a/docsgpt/core/settings/auth.py +++ b/docsgpt/core/settings/auth.py @@ -85,6 +85,28 @@ class AuthSettings(SettingsGroup): default=None, description="Bearer token for IdP SCIM clients (required when SCIM is enabled)." ) + # Personal access tokens: scoped user-level API credentials for CLI and CI/CD use. + 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." + ), + ) + PAT_DEFAULT_LIFETIME_DAYS: int = Field( + default=90, gt=0, description="Lifetime of a personal access token created without an explicit expiry." + ) + PAT_MAX_LIFETIME_DAYS: int = Field( + default=365, gt=0, description="Longest lifetime a user may request for a personal access token." + ) + PAT_ALLOW_NON_EXPIRING: bool = Field( + default=False, + description="Let users create personal access tokens that never expire. Off by default.", + ) + PAT_MAX_PER_USER: int = Field( + default=25, gt=0, description="Maximum number of live personal access tokens per user." + ) + @field_validator("AUTH_TYPE", mode="before") @classmethod def _normalize_auth_type(cls, v): @@ -99,4 +121,6 @@ class AuthSettings(SettingsGroup): raise ValueError(f"AUTH_TYPE=oidc requires settings: {', '.join(missing)}") if self.SCIM_ENABLED and not self.SCIM_TOKEN: raise ValueError("SCIM_ENABLED requires settings: SCIM_TOKEN") + if self.PAT_DEFAULT_LIFETIME_DAYS > self.PAT_MAX_LIFETIME_DAYS: + raise ValueError("PAT_DEFAULT_LIFETIME_DAYS must not exceed PAT_MAX_LIFETIME_DAYS") return self diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index 143c3b07..65527c76 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -1079,3 +1079,44 @@ device_auto_approve_patterns_table = Table( Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), UniqueConstraint("device_id", "user_id", "pattern", name="device_auto_approve_uidx"), ) + +# --- Personal access tokens (migration 0032) -------------------------------- +# Scoped user-level API credentials. Only the SHA-256 of the secret is stored. + +personal_access_tokens_table = Table( + "personal_access_tokens", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), + Column("user_id", Text, nullable=False), + Column("name", Text, nullable=False), + Column("token_hash", Text, nullable=False), + Column("token_prefix", Text, nullable=False), + Column("scopes", ARRAY(Text), nullable=False, server_default="{}"), + Column("resource_filter", JSONB, nullable=False, server_default=text("'{}'::jsonb")), + Column("status", Text, nullable=False, server_default="active"), + Column("expires_at", DateTime(timezone=True)), + Column("last_used_at", DateTime(timezone=True)), + Column("last_used_ip", Text), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("revoked_at", DateTime(timezone=True)), + Column("revoke_reason", Text), + CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"), +) + +Index( + "personal_access_tokens_hash_uidx", + personal_access_tokens_table.c.token_hash, + unique=True, +) +Index( + "personal_access_tokens_user_name_uidx", + personal_access_tokens_table.c.user_id, + personal_access_tokens_table.c.name, + unique=True, + postgresql_where=personal_access_tokens_table.c.status == "active", +) +Index( + "personal_access_tokens_user_idx", + personal_access_tokens_table.c.user_id, + personal_access_tokens_table.c.created_at.desc(), +) diff --git a/docsgpt/storage/db/repositories/personal_access_tokens.py b/docsgpt/storage/db/repositories/personal_access_tokens.py new file mode 100644 index 00000000..56fb5caa --- /dev/null +++ b/docsgpt/storage/db/repositories/personal_access_tokens.py @@ -0,0 +1,156 @@ +"""Repository for the ``personal_access_tokens`` table.""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Optional + +from sqlalchemy import Connection, text + +from docsgpt.storage.db.base_repository import row_to_dict + + +# token_hash never leaves the repository except through find_active_by_hash. +_PUBLIC_COLUMNS = ( + "id, user_id, name, token_prefix, scopes, resource_filter, status, " + "expires_at, last_used_at, last_used_ip, created_at, revoked_at, revoke_reason" +) + + +class PersonalAccessTokensRepository: + """CRUD for personal access tokens. Callers hash the secret; only the hash is stored.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def create( + self, + user_id: str, + name: str, + *, + token_hash: str, + token_prefix: str, + scopes: list[str], + resource_filter: Optional[dict] = None, + expires_at: Optional[datetime] = None, + ) -> dict: + row = self._conn.execute( + text( + f""" + INSERT INTO personal_access_tokens ( + user_id, name, token_hash, token_prefix, scopes, + resource_filter, expires_at + ) VALUES ( + :user_id, :name, :token_hash, :token_prefix, :scopes, + CAST(:resource_filter AS jsonb), :expires_at + ) RETURNING {_PUBLIC_COLUMNS} + """ + ), + { + "user_id": user_id, + "name": name, + "token_hash": token_hash, + "token_prefix": token_prefix, + "scopes": list(scopes), + "resource_filter": json.dumps(resource_filter or {}), + "expires_at": expires_at, + }, + ).fetchone() + return row_to_dict(row) + + def get(self, token_id: str, user_id: Optional[str] = None) -> Optional[dict]: + sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE id = CAST(:id AS uuid)" + params: dict = {"id": token_id} + if user_id is not None: + sql += " AND user_id = :user_id" + params["user_id"] = user_id + row = self._conn.execute(text(sql), params).fetchone() + return row_to_dict(row) if row is not None else None + + def list_for_user(self, user_id: str, *, include_revoked: bool = False) -> list[dict]: + sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE user_id = :user_id" + if not include_revoked: + sql += " AND status = 'active'" + sql += " ORDER BY created_at DESC" + result = self._conn.execute(text(sql), {"user_id": user_id}) + return [row_to_dict(r) for r in result.fetchall()] + + def count_active(self, user_id: str) -> int: + """Live tokens only: revoked and expired rows don't count against the per-user cap.""" + return self._conn.execute( + text( + "SELECT count(*) FROM personal_access_tokens " + "WHERE user_id = :user_id AND status = 'active' " + "AND (expires_at IS NULL OR expires_at > now())" + ), + {"user_id": user_id}, + ).scalar_one() + + def name_in_use(self, user_id: str, name: str) -> bool: + return ( + self._conn.execute( + text( + "SELECT 1 FROM personal_access_tokens " + "WHERE user_id = :user_id AND name = :name AND status = 'active' LIMIT 1" + ), + {"user_id": user_id, "name": name}, + ).fetchone() + is not None + ) + + def find_active_by_hash(self, token_hash: str) -> Optional[dict]: + """Resolve the credential on each request. + + Revoked and expired tokens never match, and neither do the tokens of a + deactivated user (admin or SCIM), so deactivation needs no token sweep + and reactivation restores them. + """ + row = self._conn.execute( + text( + f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens pat " + "WHERE token_hash = :token_hash AND status = 'active' " + "AND (expires_at IS NULL OR expires_at > now()) " + "AND NOT EXISTS (SELECT 1 FROM users u " + "WHERE u.user_id = pat.user_id AND u.active = false) " + "LIMIT 1" + ), + {"token_hash": token_hash}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def touch_last_used(self, token_id: str, ip: Optional[str], *, min_interval_seconds: int = 60) -> None: + """Record use, at most once per ``min_interval_seconds`` so hot tokens don't write per request.""" + self._conn.execute( + text( + "UPDATE personal_access_tokens " + "SET last_used_at = now(), last_used_ip = :ip " + "WHERE id = CAST(:id AS uuid) AND (last_used_at IS NULL " + "OR last_used_at <= now() - make_interval(secs => :min_interval))" + ), + {"id": token_id, "ip": ip, "min_interval": min_interval_seconds}, + ) + + def revoke(self, token_id: str, user_id: Optional[str] = None, *, reason: str = "user_revoked") -> bool: + """Revoke one token. ``user_id=None`` is the admin path (any owner).""" + sql = ( + "UPDATE personal_access_tokens " + "SET status = 'revoked', revoked_at = now(), revoke_reason = :reason " + "WHERE id = CAST(:id AS uuid) AND status = 'active'" + ) + params: dict = {"id": token_id, "reason": reason} + if user_id is not None: + sql += " AND user_id = :user_id" + params["user_id"] = user_id + return self._conn.execute(text(sql), params).rowcount > 0 + + def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> int: + result = self._conn.execute( + text( + "UPDATE personal_access_tokens " + "SET status = 'revoked', revoked_at = now(), revoke_reason = :reason " + "WHERE user_id = :user_id AND status = 'active'" + ), + {"user_id": user_id, "reason": reason}, + ) + return result.rowcount diff --git a/tests/storage/db/repositories/test_personal_access_tokens.py b/tests/storage/db/repositories/test_personal_access_tokens.py new file mode 100644 index 00000000..0102e625 --- /dev/null +++ b/tests/storage/db/repositories/test_personal_access_tokens.py @@ -0,0 +1,163 @@ +"""Tests for PersonalAccessTokensRepository against a real Postgres.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import text + +from docsgpt.storage.db.repositories.personal_access_tokens import ( + PersonalAccessTokensRepository, +) + + +def _create(repo, user_id="u1", name="ci", token_hash="h1", **kwargs): + kwargs.setdefault("scopes", ["agents:read"]) + return repo.create( + user_id, name, token_hash=token_hash, token_prefix="dgpt_pat_abc123", **kwargs + ) + + +class TestCreateAndRead: + def test_create_returns_public_columns_only(self, pg_conn): + row = _create( + PersonalAccessTokensRepository(pg_conn), + resource_filter={"agents": ["00000000-0000-0000-0000-000000000001"]}, + ) + assert "token_hash" not in row + assert row["status"] == "active" + assert row["scopes"] == ["agents:read"] + assert row["resource_filter"] == {"agents": ["00000000-0000-0000-0000-000000000001"]} + assert row["expires_at"] is None + + def test_get_is_owner_scoped(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + assert repo.get(str(row["id"]), "u1")["name"] == "ci" + assert repo.get(str(row["id"]), "someone-else") is None + assert repo.get(str(row["id"]))["user_id"] == "u1" + + def test_list_hides_revoked_by_default(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + kept = _create(repo, name="kept", token_hash="h1") + gone = _create(repo, name="gone", token_hash="h2") + repo.revoke(str(gone["id"]), "u1") + assert [r["id"] for r in repo.list_for_user("u1")] == [kept["id"]] + assert len(repo.list_for_user("u1", include_revoked=True)) == 2 + assert repo.list_for_user("u2") == [] + + +class TestUniqueness: + def test_duplicate_active_name_rejected(self, pg_conn): + from sqlalchemy.exc import IntegrityError + + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, token_hash="h1") + with pytest.raises(IntegrityError), pg_conn.begin_nested(): + _create(repo, token_hash="h2") + + def test_revoked_name_can_be_reused(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + first = _create(repo, token_hash="h1") + repo.revoke(str(first["id"]), "u1") + assert not repo.name_in_use("u1", "ci") + assert _create(repo, token_hash="h2")["name"] == "ci" + + def test_same_name_for_other_user_is_fine(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, user_id="u1", token_hash="h1") + _create(repo, user_id="u2", token_hash="h2") + assert repo.name_in_use("u1", "ci") and repo.name_in_use("u2", "ci") + + +class TestFindActiveByHash: + def test_finds_live_token(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo) + found = repo.find_active_by_hash("h1") + assert found["user_id"] == "u1" + assert "token_hash" not in found + + def test_unknown_hash(self, pg_conn): + assert PersonalAccessTokensRepository(pg_conn).find_active_by_hash("nope") is None + + def test_revoked_token_never_matches(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + repo.revoke(str(row["id"]), "u1") + assert repo.find_active_by_hash("h1") is None + + def test_expired_token_never_matches(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)) + assert repo.find_active_by_hash("h1") is None + + def test_future_expiry_matches(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, expires_at=datetime.now(timezone.utc) + timedelta(days=1)) + assert repo.find_active_by_hash("h1") is not None + + def test_deactivated_user_token_never_matches(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo) + pg_conn.execute( + text("INSERT INTO users (user_id, active) VALUES ('u1', false)") + ) + assert repo.find_active_by_hash("h1") is None + pg_conn.execute(text("UPDATE users SET active = true WHERE user_id = 'u1'")) + assert repo.find_active_by_hash("h1") is not None + + +class TestRevoke: + def test_revoke_is_owner_scoped(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + assert repo.revoke(str(row["id"]), "someone-else") is False + assert repo.revoke(str(row["id"]), "u1") is True + assert repo.revoke(str(row["id"]), "u1") is False + stored = repo.get(str(row["id"])) + assert stored["status"] == "revoked" + assert stored["revoke_reason"] == "user_revoked" + assert stored["revoked_at"] is not None + + def test_admin_revoke_needs_no_owner(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + assert repo.revoke(str(row["id"]), reason="admin_revoked") is True + assert repo.get(str(row["id"]))["revoke_reason"] == "admin_revoked" + + def test_revoke_all_for_user(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, name="a", token_hash="h1") + _create(repo, name="b", token_hash="h2") + _create(repo, user_id="u2", token_hash="h3") + assert repo.revoke_all_for_user("u1") == 2 + assert repo.list_for_user("u1") == [] + assert len(repo.list_for_user("u2")) == 1 + + +class TestCountAndUsage: + def test_count_active_ignores_revoked_and_expired(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, name="live", token_hash="h1") + _create( + repo, + name="expired", + token_hash="h2", + expires_at=datetime.now(timezone.utc) - timedelta(days=1), + ) + revoked = _create(repo, name="revoked", token_hash="h3") + repo.revoke(str(revoked["id"]), "u1") + assert repo.count_active("u1") == 1 + + def test_touch_last_used_is_throttled(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + repo.touch_last_used(str(row["id"]), "10.0.0.1") + first = repo.get(str(row["id"])) + assert first["last_used_ip"] == "10.0.0.1" + repo.touch_last_used(str(row["id"]), "10.0.0.2") + assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.1" + repo.touch_last_used(str(row["id"]), "10.0.0.3", min_interval_seconds=0) + assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.3" From 174130607a099c7bc9f704d8db16f76df9666e4b Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 02/13] feat(pat): authenticate personal access tokens handle_auth resolves a dgpt_pat_ bearer against the database instead of decoding it as a JWT, for both the Flask and the ASGI routes. Scopes and the resource filter always come from the token row, and the claims that mark a PAT are stripped from decoded JWTs so a session token cannot pose as one. ASGI routes reject tokens unless they name the scope that admits them, and /api/user/me reports what the calling token may do. --- docsgpt/api/asgi_auth.py | 20 ++- docsgpt/api/async_sse.py | 2 +- docsgpt/api/pat/__init__.py | 0 docsgpt/api/pat/tokens.py | 234 +++++++++++++++++++++++++++++ docsgpt/api/user/me/routes.py | 11 ++ docsgpt/auth.py | 23 +++ tests/api/test_asgi_auth.py | 42 ++++++ tests/api/test_pat_tokens.py | 275 ++++++++++++++++++++++++++++++++++ 8 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 docsgpt/api/pat/__init__.py create mode 100644 docsgpt/api/pat/tokens.py create mode 100644 tests/api/test_pat_tokens.py diff --git a/docsgpt/api/asgi_auth.py b/docsgpt/api/asgi_auth.py index 93f08a23..aa2ac5bd 100644 --- a/docsgpt/api/asgi_auth.py +++ b/docsgpt/api/asgi_auth.py @@ -16,27 +16,43 @@ from starlette.requests import Request from starlette.responses import JSONResponse from docsgpt.api.oidc.denylist import is_denied as oidc_session_denied +from docsgpt.api.pat.tokens import is_pat from docsgpt.auth import handle_auth from docsgpt.core import log_context from docsgpt.core.settings import settings -async def authenticate(request: Request) -> Tuple[Optional[dict], Optional[JSONResponse]]: +async def authenticate( + request: Request, *, pat_scope: Optional[str] = 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 + unset, the route rejects PATs outright (deny by default, matching + the Flask rule table in ``docsgpt/api/pat/rules.py``). Returns: tuple: ``(claims, None)`` for an authenticated caller, ``(None, None)`` when no token was sent (the route decides whether that is allowed), or ``(None, response)`` carrying the 401 to return. """ - decoded = handle_auth(request) + # A personal access token resolves against Postgres; keep that sync read off the event loop. + decoded = await anyio.to_thread.run_sync(handle_auth, request) if not decoded: return None, None if "error" in decoded: return None, JSONResponse(decoded, status_code=401) + 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 []): + return None, JSONResponse( + {"success": False, "message": "Token lacks the required scope", "error": "insufficient_scope"}, + status_code=403, + ) + return decoded, None # The denylist is a sync Redis read; keep it off the event loop. if settings.AUTH_TYPE == "oidc" and await anyio.to_thread.run_sync(oidc_session_denied, decoded): return None, JSONResponse( diff --git a/docsgpt/api/async_sse.py b/docsgpt/api/async_sse.py index a5064e2c..1bb3af1d 100644 --- a/docsgpt/api/async_sse.py +++ b/docsgpt/api/async_sse.py @@ -94,7 +94,7 @@ 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) + decoded, error = await authenticate(request, pat_scope="chat:run") if error is not None: return error user_id = decoded.get("sub") if isinstance(decoded, dict) else None diff --git a/docsgpt/api/pat/__init__.py b/docsgpt/api/pat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docsgpt/api/pat/tokens.py b/docsgpt/api/pat/tokens.py new file mode 100644 index 00000000..fa4cb9d0 --- /dev/null +++ b/docsgpt/api/pat/tokens.py @@ -0,0 +1,234 @@ +"""Personal access tokens: format, scope catalog and the per-request verifier. + +A PAT is ``dgpt_pat_`` + 32 random bytes (urlsafe). Only its SHA-256 is stored +(``personal_access_tokens.token_hash``), the same shape as device session +tokens. Scopes and the resource filter are always read from the database row; +nothing about a token's authority is encoded in the credential itself. +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from docsgpt.core.settings import settings +from docsgpt.storage.db.repositories.personal_access_tokens import ( + PersonalAccessTokensRepository, +) +from docsgpt.storage.db.session import db_readonly, db_session + +logger = logging.getLogger(__name__) + +TOKEN_PREFIX = "dgpt_pat_" +# Characters of the secret kept in ``token_prefix`` so users can tell tokens apart. +_DISPLAY_CHARS = 6 + +AUTH_METHOD_PAT = "pat" + +#: Every grantable scope with the description shown in the UI and docs. +SCOPES: dict[str, str] = { + "agents:read": "View agents, folders, guardrail events and export agent definitions", + "agents:write": "Create, update, delete, share and import (apply) agents and folders", + "agents:keys": "Regenerate agent API keys and read incoming webhook URLs", + "sources:read": "View sources, their files, chunks and ingestion task status", + "sources:write": "Upload, ingest, sync, edit and delete sources and chunks", + "prompts:read": "View prompts", + "prompts:write": "Create, update and delete prompts", + "tools:read": "View configured tools", + "tools:write": "Create, update and delete tools and MCP servers", + "models:read": "View available and custom models", + "models:write": "Create, update, test and delete custom models", + "workflows:read": "View workflows", + "workflows:write": "Create, update and delete workflows", + "schedules:read": "View agent schedules and their runs", + "schedules:write": "Create, update, run and delete agent schedules", + "conversations:read": "View conversations and messages", + "conversations:write": "Rename, delete and give feedback on conversations", + "analytics:read": "View usage analytics and logs", + "teams:read": "View teams, members and resource shares", + "chat:run": "Ask agents and search sources (answer, stream, search); used for benchmarking", +} + +#: Resource families whose tokens can be narrowed to specific ids. +FILTERABLE_FAMILIES = ("agents", "sources", "prompts", "tools", "workflows") +_MAX_FILTER_IDS = 200 + + +def auth_type_supports_pats() -> bool: + """PATs bind to a stable user id, which simple_jwt/session_jwt don't have.""" + return bool(settings.PAT_ENABLED) and settings.AUTH_TYPE in (None, "oidc") + + +def generate_token() -> tuple[str, str, str]: + """Mint a token. Returns ``(plaintext, sha256_hex, display_prefix)``.""" + secret = secrets.token_urlsafe(32) + token = TOKEN_PREFIX + secret + return token, hash_token(token), TOKEN_PREFIX + secret[:_DISPLAY_CHARS] + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def looks_like_pat(value: Optional[str]) -> bool: + return bool(value) and value.startswith(TOKEN_PREFIX) + + +def redact(value: Optional[str]) -> str: + """Log-safe form of a credential: the display prefix only.""" + if not value: + return "" + if looks_like_pat(value): + return value[: len(TOKEN_PREFIX) + _DISPLAY_CHARS] + "…" + return value[:4] + "…" + + +def expand_scopes(scopes) -> set[str]: + """Granted scopes plus what they imply (``x:write`` includes ``x:read``).""" + granted = set(scopes or []) + for scope in list(granted): + family, _, action = scope.partition(":") + if action == "write" and f"{family}:read" in SCOPES: + granted.add(f"{family}:read") + return granted + + +def normalize_scopes(raw: Any) -> list[str]: + """Validate a requested scope list. Raises ``ValueError`` with a user-facing message.""" + if not isinstance(raw, list) or not raw: + raise ValueError("scopes must be a non-empty list") + unknown = sorted({s for s in raw if not isinstance(s, str) or s not in SCOPES}, key=str) + if unknown: + raise ValueError(f"Unknown scopes: {', '.join(map(str, unknown))}") + return sorted(set(raw)) + + +def normalize_resource_filter(raw: Any, scopes: list[str]) -> dict[str, list[str]]: + """Validate ``{"": ["", ...]}``. Raises ``ValueError`` with a user-facing message. + + A family may only be restricted when the token holds a scope in it; + otherwise the restriction would be dead weight that reads as protection. + """ + if raw in (None, {}): + return {} + if not isinstance(raw, dict): + raise ValueError("resource_filter must be an object") + families = {s.partition(":")[0] for s in scopes} + # chat:run acts on agents and sources, so both may be restricted alongside it. + if "chat" in families: + families.update({"agents", "sources"}) + out: dict[str, list[str]] = {} + for family, ids in raw.items(): + if family not in FILTERABLE_FAMILIES: + raise ValueError( + f"resource_filter supports only: {', '.join(FILTERABLE_FAMILIES)}" + ) + if family not in families: + raise ValueError(f"resource_filter.{family} needs a {family} scope on the token") + if not isinstance(ids, list) or not ids: + raise ValueError(f"resource_filter.{family} must be a non-empty list of ids") + if len(ids) > _MAX_FILTER_IDS: + raise ValueError(f"resource_filter.{family} allows at most {_MAX_FILTER_IDS} ids") + normalized = [] + for value in ids: + try: + normalized.append(str(uuid.UUID(str(value)))) + except (ValueError, AttributeError, TypeError): + raise ValueError(f"resource_filter.{family} contains an invalid id: {value!r}") + out[family] = sorted(set(normalized)) + return out + + +def resolve_expiry(expires_in_days: Any) -> Optional[datetime]: + """Map the requested lifetime to ``expires_at``. Raises ``ValueError`` with a user-facing message. + + ``None`` means "use the default"; ``0`` asks for a non-expiring token, + which only an operator setting can allow. + """ + if expires_in_days is None: + days = settings.PAT_DEFAULT_LIFETIME_DAYS + elif isinstance(expires_in_days, bool) or not isinstance(expires_in_days, int): + raise ValueError("expires_in_days must be an integer") + elif expires_in_days == 0: + if not settings.PAT_ALLOW_NON_EXPIRING: + raise ValueError("Non-expiring tokens are disabled on this server") + return None + elif expires_in_days < 0: + raise ValueError("expires_in_days must be positive") + else: + days = expires_in_days + if days > settings.PAT_MAX_LIFETIME_DAYS: + raise ValueError(f"expires_in_days must not exceed {settings.PAT_MAX_LIFETIME_DAYS}") + return datetime.now(timezone.utc) + timedelta(days=days) + + +def _client_ip(request) -> Optional[str]: + # Flask exposes remote_addr; Starlette exposes client.host. + ip = getattr(request, "remote_addr", None) + if ip: + return ip + client = getattr(request, "client", None) + return getattr(client, "host", None) + + +_TOUCH_INTERVAL_SECONDS = 60 + + +def _usage_is_stale(last_used_at: Any) -> bool: + """True when ``last_used_at`` is old enough to be worth a write transaction.""" + if not last_used_at: + return True + try: + seen = last_used_at if isinstance(last_used_at, datetime) else datetime.fromisoformat(str(last_used_at)) + except ValueError: + return True + if seen.tzinfo is None: + seen = seen.replace(tzinfo=timezone.utc) + return (datetime.now(timezone.utc) - seen).total_seconds() >= _TOUCH_INTERVAL_SECONDS + + +_INVALID = {"message": "Authentication error: invalid token", "error": "invalid_token"} + + +def authenticate_pat(token: str, request) -> dict: + """Resolve a PAT into the claims dict the rest of the app reads. + + Fails closed: an unknown, revoked or expired token, a disabled feature or + a database error all yield the same ``invalid_token`` error. + """ + if not auth_type_supports_pats(): + return dict(_INVALID) + try: + with db_readonly() as conn: + row = PersonalAccessTokensRepository(conn).find_active_by_hash(hash_token(token)) + except Exception: + logger.error("PAT lookup failed for %s", redact(token), exc_info=True) + return dict(_INVALID) + if not row: + logger.warning("Rejected personal access token %s", redact(token)) + return dict(_INVALID) + if _usage_is_stale(row.get("last_used_at")): + try: + with db_session() as conn: + PersonalAccessTokensRepository(conn).touch_last_used( + str(row["id"]), _client_ip(request), min_interval_seconds=_TOUCH_INTERVAL_SECONDS + ) + except Exception: + # Usage telemetry must never fail a request. + logger.debug("PAT last-used update failed", exc_info=True) + return { + "sub": row["user_id"], + "auth_method": AUTH_METHOD_PAT, + "pat_id": str(row["id"]), + "pat_name": row["name"], + "scopes": sorted(expand_scopes(row.get("scopes"))), + "resource_filter": row.get("resource_filter") or {}, + } + + +def is_pat(decoded_token: Optional[dict]) -> bool: + return bool(decoded_token) and decoded_token.get("auth_method") == AUTH_METHOD_PAT diff --git a/docsgpt/api/user/me/routes.py b/docsgpt/api/user/me/routes.py index 34e108ad..67e31dfb 100644 --- a/docsgpt/api/user/me/routes.py +++ b/docsgpt/api/user/me/routes.py @@ -12,6 +12,8 @@ from __future__ import annotations from flask import jsonify, make_response, request from flask_restx import Namespace, Resource +from docsgpt.api.pat.tokens import is_pat + me_ns = Namespace("me", description="Current user identity and roles", path="/api") @@ -31,4 +33,13 @@ class MeResource(Resource): value = decoded_token.get(field) if value: body[field] = value + if is_pat(decoded_token): + # Lets a CLI or pipeline confirm what its token is allowed to do. + body["auth_method"] = "pat" + body["token"] = { + "id": decoded_token.get("pat_id"), + "name": decoded_token.get("pat_name"), + "scopes": decoded_token.get("scopes") or [], + "resource_filter": decoded_token.get("resource_filter") or {}, + } return make_response(jsonify(body), 200) diff --git a/docsgpt/auth.py b/docsgpt/auth.py index d56140d7..a38623a5 100644 --- a/docsgpt/auth.py +++ b/docsgpt/auth.py @@ -4,7 +4,28 @@ from jose.exceptions import ExpiredSignatureError from docsgpt.core.settings import settings +# Claims only the PAT verifier may set. Dropped from decoded JWTs so a session +# token can never present itself as a (differently scoped) personal access token. +_PAT_ONLY_CLAIMS = ("auth_method", "pat_id", "pat_name", "scopes", "resource_filter") + + +def _bearer_value(request): + header = request.headers.get("Authorization") + if not header or not isinstance(header, str): + return None + scheme, _, value = header.partition(" ") + return value.strip() if scheme.lower() == "bearer" and value else header.strip() + + def handle_auth(request, data={}): + # Personal access tokens are opaque (not JWTs) and resolve against the + # database in every auth mode that supports them, including AUTH_TYPE unset. + from docsgpt.api.pat.tokens import authenticate_pat, looks_like_pat + + bearer = _bearer_value(request) + if looks_like_pat(bearer): + return authenticate_pat(bearer, request) + if settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"]: jwt_token = request.headers.get("Authorization") if not jwt_token: @@ -26,6 +47,8 @@ def handle_auth(request, data={}): # requirement is scoped to oidc. options={"verify_exp": is_oidc, "require_exp": is_oidc}, ) + for claim in _PAT_ONLY_CLAIMS: + decoded_token.pop(claim, None) return decoded_token except ExpiredSignatureError: return { diff --git a/tests/api/test_asgi_auth.py b/tests/api/test_asgi_auth.py index cf72eb43..341ded0d 100644 --- a/tests/api/test_asgi_auth.py +++ b/tests/api/test_asgi_auth.py @@ -114,3 +114,45 @@ def test_json_error_shape(): response = asgi_auth.json_error("Forbidden", 403) assert response.status_code == 403 assert json.loads(response.body) == {"success": False, "message": "Forbidden"} + + +_PAT_CLAIMS = { + "sub": "alice", + "auth_method": "pat", + "pat_id": "t1", + "scopes": ["chat:run"], + "resource_filter": {}, +} + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestPersonalAccessTokens: + async def test_routes_reject_tokens_unless_they_name_a_scope(self): + with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)): + decoded, error = await asgi_auth.authenticate(_request()) + assert decoded is None + assert error.status_code == 403 + assert json.loads(error.body)["error"] == "insufficient_scope" + + async def test_token_with_the_route_scope_passes(self): + with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)): + decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run") + assert error is None + assert decoded["sub"] == "alice" + + async def test_token_without_the_route_scope_is_refused(self): + claims = dict(_PAT_CLAIMS, scopes=["agents:read"]) + with patch.object(asgi_auth, "handle_auth", return_value=claims): + decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run") + assert decoded is None + assert error.status_code == 403 + + async def test_session_denylist_is_not_consulted_for_tokens(self, monkeypatch): + monkeypatch.setattr(asgi_auth.settings, "AUTH_TYPE", "oidc") + with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)), patch.object( + asgi_auth, "oidc_session_denied", return_value=True + ) as denied: + decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run") + denied.assert_not_called() + assert error is None and decoded is not None diff --git a/tests/api/test_pat_tokens.py b/tests/api/test_pat_tokens.py new file mode 100644 index 00000000..1e38d0ce --- /dev/null +++ b/tests/api/test_pat_tokens.py @@ -0,0 +1,275 @@ +"""Unit tests for docsgpt/api/pat/tokens.py and the PAT branch of handle_auth.""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +import pytest + +from docsgpt.api.pat import tokens + + +def _request(authorization=None, ip="10.1.1.1"): + request = Mock() + request.headers = {"Authorization": authorization} if authorization else {} + request.remote_addr = ip + return request + + +@contextmanager +def _db(row): + repo = Mock() + repo.find_active_by_hash.return_value = row + + @contextmanager + def _conn(): + yield Mock() + + with patch.object(tokens, "db_readonly", _conn), patch.object( + tokens, "db_session", _conn + ), patch.object(tokens, "PersonalAccessTokensRepository", return_value=repo): + yield repo + + +_ROW = { + "id": "11111111-1111-1111-1111-111111111111", + "user_id": "alice", + "name": "ci", + "scopes": ["agents:write", "chat:run"], + "resource_filter": {"agents": ["22222222-2222-2222-2222-222222222222"]}, + "last_used_at": None, +} + + +@pytest.mark.unit +class TestTokenFormat: + def test_generate_returns_prefixed_secret_hash_and_display_prefix(self): + token, token_hash, display = tokens.generate_token() + assert token.startswith("dgpt_pat_") + assert len(token) > 40 + assert token_hash == tokens.hash_token(token) + assert len(token_hash) == 64 + assert token.startswith(display) and len(display) == len("dgpt_pat_") + 6 + + def test_tokens_are_unique(self): + assert tokens.generate_token()[0] != tokens.generate_token()[0] + + def test_redact_never_returns_the_secret(self): + token, _, display = tokens.generate_token() + assert tokens.redact(token) == display + "…" + assert tokens.redact("some-agent-key") == "some…" + assert tokens.redact(None) == "" + + @pytest.mark.parametrize( + "value,expected", + [("dgpt_pat_abc", True), ("eyJhbGciOi", False), ("", False), (None, False)], + ) + def test_looks_like_pat(self, value, expected): + assert tokens.looks_like_pat(value) is expected + + +@pytest.mark.unit +class TestScopes: + def test_write_implies_read(self): + assert tokens.expand_scopes(["agents:write"]) == {"agents:write", "agents:read"} + + def test_standalone_scopes_imply_nothing(self): + assert tokens.expand_scopes(["agents:keys", "chat:run"]) == {"agents:keys", "chat:run"} + + def test_normalize_sorts_and_dedupes(self): + assert tokens.normalize_scopes(["sources:read", "agents:read", "sources:read"]) == [ + "agents:read", + "sources:read", + ] + + @pytest.mark.parametrize("raw", [None, [], "agents:read", ["admin:all"], [1], ["agents:read", "x"]]) + def test_normalize_rejects_bad_input(self, raw): + with pytest.raises(ValueError): + tokens.normalize_scopes(raw) + + def test_no_admin_scope_exists(self): + assert not any(s.startswith("admin") for s in tokens.SCOPES) + + +@pytest.mark.unit +class TestResourceFilter: + UUID_A = "22222222-2222-2222-2222-222222222222" + + def test_empty_is_unrestricted(self): + assert tokens.normalize_resource_filter(None, ["agents:read"]) == {} + assert tokens.normalize_resource_filter({}, ["agents:read"]) == {} + + def test_canonicalizes_and_dedupes_ids(self): + out = tokens.normalize_resource_filter( + {"agents": [self.UUID_A.upper(), self.UUID_A]}, ["agents:read"] + ) + assert out == {"agents": [self.UUID_A]} + + def test_chat_scope_allows_agent_and_source_restrictions(self): + out = tokens.normalize_resource_filter( + {"agents": [self.UUID_A], "sources": [self.UUID_A]}, ["chat:run"] + ) + assert set(out) == {"agents", "sources"} + + @pytest.mark.parametrize( + "raw,scopes", + [ + ("agents", ["agents:read"]), + ({"conversations": [UUID_A]}, ["conversations:read"]), + ({"sources": [UUID_A]}, ["agents:read"]), + ({"agents": []}, ["agents:read"]), + ({"agents": "all"}, ["agents:read"]), + ({"agents": ["not-a-uuid"]}, ["agents:read"]), + ({"agents": [UUID_A] * 201}, ["agents:read"]), + ], + ) + def test_rejects_bad_filters(self, raw, scopes): + with pytest.raises(ValueError): + tokens.normalize_resource_filter(raw, scopes) + + +@pytest.mark.unit +class TestExpiryPolicy: + @pytest.fixture(autouse=True) + def _policy(self, monkeypatch): + monkeypatch.setattr(tokens.settings, "PAT_DEFAULT_LIFETIME_DAYS", 90) + monkeypatch.setattr(tokens.settings, "PAT_MAX_LIFETIME_DAYS", 365) + monkeypatch.setattr(tokens.settings, "PAT_ALLOW_NON_EXPIRING", False) + + def _days(self, expires_at): + return round((expires_at - datetime.now(timezone.utc)) / timedelta(days=1)) + + def test_default_lifetime(self): + assert self._days(tokens.resolve_expiry(None)) == 90 + + def test_explicit_lifetime(self): + assert self._days(tokens.resolve_expiry(7)) == 7 + + def test_max_lifetime_enforced(self): + assert self._days(tokens.resolve_expiry(365)) == 365 + with pytest.raises(ValueError): + tokens.resolve_expiry(366) + + def test_non_expiring_refused_unless_operator_allows(self, monkeypatch): + with pytest.raises(ValueError, match="disabled"): + tokens.resolve_expiry(0) + monkeypatch.setattr(tokens.settings, "PAT_ALLOW_NON_EXPIRING", True) + assert tokens.resolve_expiry(0) is None + + @pytest.mark.parametrize("raw", [-1, "30", 1.5, True]) + def test_rejects_bad_values(self, raw): + with pytest.raises(ValueError): + tokens.resolve_expiry(raw) + + +@pytest.mark.unit +class TestAuthenticatePat: + @pytest.fixture(autouse=True) + def _enabled(self, monkeypatch): + monkeypatch.setattr(tokens.settings, "PAT_ENABLED", True) + monkeypatch.setattr(tokens.settings, "AUTH_TYPE", "oidc") + + def test_valid_token_yields_claims_from_the_row(self): + with _db(dict(_ROW)) as repo: + claims = tokens.authenticate_pat("dgpt_pat_secret", _request()) + repo.find_active_by_hash.assert_called_once_with(tokens.hash_token("dgpt_pat_secret")) + assert claims == { + "sub": "alice", + "auth_method": "pat", + "pat_id": _ROW["id"], + "pat_name": "ci", + "scopes": ["agents:read", "agents:write", "chat:run"], + "resource_filter": _ROW["resource_filter"], + } + repo.touch_last_used.assert_called_once() + assert repo.touch_last_used.call_args.args[:2] == (_ROW["id"], "10.1.1.1") + + def test_unknown_token_is_invalid(self): + with _db(None): + assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token" + + def test_lookup_failure_fails_closed(self): + with _db(dict(_ROW)) as repo: + repo.find_active_by_hash.side_effect = RuntimeError("db down") + assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token" + + def test_usage_write_failure_never_fails_the_request(self): + with _db(dict(_ROW)) as repo: + repo.touch_last_used.side_effect = RuntimeError("db down") + assert tokens.authenticate_pat("dgpt_pat_x", _request())["sub"] == "alice" + + def test_recent_usage_skips_the_write(self): + row = dict(_ROW, last_used_at=datetime.now(timezone.utc).isoformat()) + with _db(row) as repo: + tokens.authenticate_pat("dgpt_pat_x", _request()) + repo.touch_last_used.assert_not_called() + + @pytest.mark.parametrize("auth_type", ["simple_jwt", "session_jwt"]) + def test_rejected_where_there_is_no_stable_user_identity(self, monkeypatch, auth_type): + monkeypatch.setattr(tokens.settings, "AUTH_TYPE", auth_type) + with _db(dict(_ROW)) as repo: + assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token" + repo.find_active_by_hash.assert_not_called() + + def test_rejected_when_disabled(self, monkeypatch): + monkeypatch.setattr(tokens.settings, "PAT_ENABLED", False) + with _db(dict(_ROW)): + assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token" + + def test_works_with_auth_disabled(self, monkeypatch): + monkeypatch.setattr(tokens.settings, "AUTH_TYPE", None) + with _db(dict(_ROW)): + assert tokens.authenticate_pat("dgpt_pat_x", _request())["sub"] == "alice" + + def test_starlette_request_ip(self): + request = Mock(spec=["headers", "client"]) + request.client.host = "10.9.9.9" + with _db(dict(_ROW)) as repo: + tokens.authenticate_pat("dgpt_pat_x", request) + assert repo.touch_last_used.call_args.args[1] == "10.9.9.9" + + +@pytest.mark.unit +class TestHandleAuthPatBranch: + def test_pat_bearer_goes_to_the_pat_verifier_in_any_auth_mode(self): + from docsgpt import auth + + for auth_type in (None, "oidc", "simple_jwt"): + with patch.object(auth.settings, "AUTH_TYPE", auth_type), patch( + "docsgpt.api.pat.tokens.authenticate_pat", return_value={"sub": "alice"} + ) as verifier: + request = _request("Bearer dgpt_pat_secret") + assert auth.handle_auth(request) == {"sub": "alice"} + verifier.assert_called_once_with("dgpt_pat_secret", request) + + def test_bearer_scheme_is_case_insensitive(self): + from docsgpt import auth + + with patch("docsgpt.api.pat.tokens.authenticate_pat", return_value={"sub": "a"}) as verifier: + auth.handle_auth(_request("bearer dgpt_pat_secret")) + verifier.assert_called_once() + + def test_jwt_cannot_smuggle_pat_claims(self): + from jose import jwt + + from docsgpt import auth + + forged = jwt.encode( + { + "sub": "mallory", + "auth_method": "pat", + "scopes": ["agents:write"], + "resource_filter": {}, + "pat_id": "x", + "pat_name": "x", + }, + "secret", + algorithm="HS256", + ) + with patch.object(auth.settings, "AUTH_TYPE", "simple_jwt"), patch.object( + auth.settings, "JWT_SECRET_KEY", "secret" + ): + decoded = auth.handle_auth(_request(f"Bearer {forged}")) + assert decoded == {"sub": "mallory"} From 3a74aa23f0c7566058c8fb404ccfae665e70c26c Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 03/13] feat(pat): token management API and admin revocation Users list, create and revoke their own tokens under /api/user/tokens; the plaintext is returned once at creation. Admins can list a user's tokens and revoke any token, and the admin revoke-sessions action now revokes the user's tokens too. Creation and revocation are written to auth_events. --- docsgpt/api/admin/routes.py | 14 ++- docsgpt/api/pat/routes.py | 229 ++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 docsgpt/api/pat/routes.py diff --git a/docsgpt/api/admin/routes.py b/docsgpt/api/admin/routes.py index 334ad936..0bad4772 100644 --- a/docsgpt/api/admin/routes.py +++ b/docsgpt/api/admin/routes.py @@ -23,6 +23,9 @@ from docsgpt.api.user.authz import ROLE_ADMIN, admin_required from docsgpt.storage.db.repositories.admin_stats import AdminStatsRepository from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository from docsgpt.storage.db.repositories.device_audit_log import DeviceAuditLogRepository +from docsgpt.storage.db.repositories.personal_access_tokens import ( + PersonalAccessTokensRepository, +) from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository from docsgpt.storage.db.repositories.user_roles import UserRolesRepository from docsgpt.storage.db.repositories.users import UsersRepository @@ -246,12 +249,21 @@ class AdminUserSessionsResource(Resource): """Force-logout: revoke the user's live OIDC sessions (best-effort).""" ok = denylist.deny_user(user_id) with db_session() as conn: + # A forced logout that left API credentials alive would not be one. + tokens_revoked = PersonalAccessTokensRepository(conn).revoke_all_for_user( + user_id, reason="admin_sessions_revoked" + ) AuthEventsRepository(conn).insert( user_id, "admin_sessions_revoked", ip=request.remote_addr, user_agent=request.headers.get("User-Agent"), - metadata={"by": _actor(), "via": "admin_api", "persisted": ok}, + metadata={ + "by": _actor(), + "via": "admin_api", + "persisted": ok, + "personal_access_tokens_revoked": tokens_revoked, + }, ) return make_response(jsonify({"success": True, "revoked": ok}), 200) diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py new file mode 100644 index 00000000..84360a1b --- /dev/null +++ b/docsgpt/api/pat/routes.py @@ -0,0 +1,229 @@ +"""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 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, + 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: + try: + uuid.UUID(str(value)) + except (ValueError, AttributeError, TypeError): + return False + return True + + +def serialize_token(row: dict) -> dict: + 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": row["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"), + "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) or {} + 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) + 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 + ) + 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("/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"}, + ) + if not revoked: + return _error("Token not found", 404) + return make_response(jsonify({"success": True}), 200) From 82ec6cba0e0acff04b7ae4a9c90cf99c9d1bec01 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 04/13] feat(pat): enforce scopes and resource restrictions, deny by default A central rule table maps each route and method to the scope a token needs; a route that is not listed cannot be called with a token, and a test fails when a registered route is left unclassified. Token management, admin, team management, sign-in, device pairing and OAuth handshakes are never token reachable, and a token never carries the admin role. A token restricted to specific agents, sources, prompts, tools or workflows is held to its allowlist: ids are checked wherever a route carries them, listings are filtered, creation is refused, and routes whose rows cannot be tied to the allowlist are closed. Agent import checks the resolved target. --- docsgpt/api/pat/rules.py | 447 +++++++++++++++++++++++++ docsgpt/api/user/agents/portability.py | 31 ++ docsgpt/api/user/agents/routes.py | 3 +- docsgpt/api/user/prompts/routes.py | 4 +- docsgpt/api/user/sources/routes.py | 3 +- docsgpt/api/user/tools/routes.py | 4 + docsgpt/app.py | 19 +- tests/api/test_agent_portability.py | 69 ++++ tests/api/test_pat_routes.py | 244 ++++++++++++++ tests/api/test_pat_rules.py | 294 ++++++++++++++++ 10 files changed, 1114 insertions(+), 4 deletions(-) create mode 100644 docsgpt/api/pat/rules.py create mode 100644 tests/api/test_pat_routes.py create mode 100644 tests/api/test_pat_rules.py diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py new file mode 100644 index 00000000..f340d698 --- /dev/null +++ b/docsgpt/api/pat/rules.py @@ -0,0 +1,447 @@ +"""What a personal access token may call: the scope and resource rule table. + +Authorization for PATs is central and deny by default. ``RULES`` maps a Flask +route (its rule string and method) to the scope it needs; a PAT request to a +route that is not listed is refused, so a new endpoint is unreachable by token +until someone classifies it here. ``tests/api/test_pat_rules.py`` fails when a +registered route is in neither ``RULES`` nor ``DENIED``. + +A token may also carry a resource filter (``{"agents": [ids]}``). For a +restricted family the rule must be able to prove the request stays inside the +allowlist: it names where the id travels (``ids``), or declares that the route +filters its own listing (``listing``), or delegates to the route +(``in_route``). Anything else, creation included, is refused. ``refs`` cover +ids of *other* families a route accepts (an agent update naming a source), and +``blocked_by`` closes routes whose rows hang off a family the rule cannot see +(a schedule belongs to an agent). + +Session (JWT) callers never pass through here. +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Optional + +VIEW, QUERY, JSON, FORM, BODY = "view", "query", "json", "form", "body" + +Locator = tuple[str, str] + + +@dataclass(frozen=True) +class Rule: + """Requirement for one route+method. ``scopes`` is any-of; empty means any valid token.""" + + scopes: tuple[str, ...] = () + family: Optional[str] = None + ids: tuple[Locator, ...] = () + refs: tuple[tuple[str, Locator], ...] = () + listing: bool = False + open: bool = False + in_route: bool = False + blocked_by: tuple[str, ...] = () + check: Optional[Callable[[Any, dict], Optional[str]]] = None + + +def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] = (), **kwargs) -> Rule: + scopes = any_of or ((scope,) if scope else ()) + family = kwargs.pop("family", None) + if family is None and scope: + family = scope.partition(":")[0] + return Rule(scopes=scopes, family=family, ids=tuple(ids), **kwargs) + + +# Ids of other families that agent create/update accept in their JSON-or-form body. +_AGENT_BODY_REFS = ( + ("sources", (BODY, "source")), + ("sources", (BODY, "sources")), + ("prompts", (BODY, "prompt_id")), + ("tools", (BODY, "tools")), + ("workflows", (BODY, "workflow")), +) + + +def _chat_check(request, resource_filter: dict) -> Optional[str]: + """Keep a restricted token's chat traffic inside its allowlists. + + An agent brings its own sources, prompt and tools, which this table cannot + see, so a token restricted on any family must name an allowed agent (or, + when only sources are restricted, chat against allowed sources directly). + An agent ``api_key`` in the body would swap in an arbitrary agent. + """ + body = _json_body(request) + if body.get("api_key"): + return "A restricted token cannot chat with an agent API key; pass agent_id" + if body.get("workflow"): + # An inline workflow graph (builder preview) can reference any resource. + return "A restricted token cannot run an inline workflow" + agent_ids = _as_ids(body.get("agent_id")) + if "agents" in resource_filter: + if not agent_ids: + return "This token is restricted to specific agents; pass agent_id" + return None # the agent id itself is verified through ``refs`` + if agent_ids: + return "This token is restricted to specific resources and cannot run arbitrary agents" + return None + + +_CHAT = dict( + family=None, + refs=( + ("agents", (JSON, "agent_id")), + ("sources", (JSON, "active_docs")), + ("prompts", (JSON, "prompt_id")), + ("workflows", (JSON, "workflow_id")), + ), + check=_chat_check, +) + +RULES: dict[tuple[str, str], Rule] = { + # Identity and public metadata: any valid token. + ("/api/user/me", "GET"): _rule(open=True), + ("/api/health", "GET"): _rule(open=True), + ("/api/config", "GET"): _rule(open=True), + # Agents + ("/api/get_agent", "GET"): _rule("agents:read", (QUERY, "id")), + ("/api/get_agents", "GET"): _rule("agents:read", listing=True), + ("/api/pinned_agents", "GET"): _rule("agents:read"), + ("/api/shared_agents", "GET"): _rule("agents:read"), + ("/api/template_agents", "GET"): _rule("agents:read", open=True), + ("/api/export_agent", "GET"): _rule("agents:read", (QUERY, "id")), + ("/api/guardrails/catalog", "GET"): _rule("agents:read", open=True), + ("/api/guardrails/events", "GET"): _rule("agents:read", (QUERY, "agent_id")), + ("/api/guardrails/summary", "GET"): _rule("agents:read", (QUERY, "agent_id")), + ("/api/agents/folders/", "GET"): _rule("agents:read", open=True), + ("/api/agents/folders/", "GET"): _rule("agents:read"), + ("/api/create_agent", "POST"): _rule("agents:write", refs=_AGENT_BODY_REFS), + ("/api/update_agent/", "PUT"): _rule( + "agents:write", (VIEW, "agent_id"), refs=_AGENT_BODY_REFS + ), + ("/api/delete_agent", "DELETE"): _rule("agents:write", (QUERY, "id")), + ("/api/adopt_agent", "POST"): _rule("agents:write"), + ("/api/pin_agent", "POST"): _rule("agents:write", (QUERY, "id")), + ("/api/remove_shared_agent", "DELETE"): _rule("agents:write", (QUERY, "id")), + ("/api/share_agent", "PUT"): _rule("agents:write", (JSON, "id")), + ("/api/import_agent/plan", "POST"): _rule("agents:write", in_route=True), + ("/api/import_agent", "POST"): _rule("agents:write", in_route=True), + ("/api/agents/folders/", "POST"): _rule("agents:write"), + ("/api/agents/folders/", "PUT"): _rule("agents:write"), + ("/api/agents/folders/", "DELETE"): _rule("agents:write"), + ("/api/agents/folders/move_agent", "POST"): _rule("agents:write", (JSON, "agent_id")), + ("/api/agents/folders/bulk_move", "POST"): _rule("agents:write", (JSON, "agent_ids")), + ("/api/regenerate_agent_key/", "POST"): _rule("agents:keys", (VIEW, "agent_id")), + ("/api/agent_webhook", "GET"): _rule("agents:keys", (QUERY, "id")), + # Schedules hang off an agent. + ("/api/agents//schedules", "GET"): _rule( + "schedules:read", refs=(("agents", (VIEW, "agent_id")),) + ), + ("/api/agents//schedules", "POST"): _rule( + "schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=("tools",) + ), + ("/api/schedules/", "GET"): _rule("schedules:read", blocked_by=("agents",)), + ("/api/schedules//runs", "GET"): _rule("schedules:read", blocked_by=("agents",)), + ("/api/schedules//runs/", "GET"): _rule( + "schedules:read", blocked_by=("agents",) + ), + ("/api/schedules/", "PUT"): _rule("schedules:write", blocked_by=("agents", "tools")), + ("/api/schedules/", "PATCH"): _rule("schedules:write", blocked_by=("agents", "tools")), + ("/api/schedules/", "DELETE"): _rule("schedules:write", blocked_by=("agents",)), + ("/api/schedules//run", "POST"): _rule("schedules:write", blocked_by=("agents",)), + # Sources + ("/api/sources", "GET"): _rule("sources:read", listing=True), + # Counted and paged in SQL, so it cannot be narrowed here; restricted tokens use /api/sources. + ("/api/sources/paginated", "GET"): _rule("sources:read"), + ("/api/directory_structure", "GET"): _rule("sources:read", (QUERY, "id")), + ("/api/get_chunks", "GET"): _rule("sources:read", (QUERY, "id")), + ("/api/sources//wiki/pages", "GET"): _rule("sources:read", (VIEW, "source_id")), + ("/api/sources//wiki/page", "GET"): _rule("sources:read", (VIEW, "source_id")), + ("/api/sources//graph", "GET"): _rule("sources:read", (VIEW, "source_id")), + ("/api/sources//graph/node/", "GET"): _rule( + "sources:read", (VIEW, "source_id") + ), + # Ingestion and attachment extraction both report through this poll. + ("/api/task_status", "GET"): _rule(any_of=("sources:read", "sources:write", "chat:run"), open=True), + ("/api/upload", "POST"): _rule("sources:write"), + ("/api/remote", "POST"): _rule("sources:write"), + ("/api/sources/wiki", "POST"): _rule("sources:write"), + ("/api/delete_old", "GET"): _rule("sources:write", (QUERY, "source_id")), + ("/api/manage_sync", "POST"): _rule("sources:write", (JSON, "source_id")), + ("/api/sync_source", "POST"): _rule("sources:write", (JSON, "source_id")), + ("/api/sources/reingest", "POST"): _rule("sources:write", (JSON, "source_id")), + ("/api/manage_source_files", "POST"): _rule("sources:write", (FORM, "source_id")), + ("/api/sources//config", "PATCH"): _rule("sources:write", (VIEW, "source_id")), + ("/api/sources//wiki/page", "PUT"): _rule("sources:write", (VIEW, "source_id")), + ("/api/sources//wiki/convert", "POST"): _rule("sources:write", (VIEW, "source_id")), + ("/api/sources//graphrag/enable", "POST"): _rule( + "sources:write", (VIEW, "source_id") + ), + ("/api/add_chunk", "POST"): _rule("sources:write", (JSON, "id")), + ("/api/update_chunk", "PUT"): _rule("sources:write", (JSON, "id")), + ("/api/delete_chunk", "DELETE"): _rule("sources:write", (QUERY, "id")), + # Prompts + ("/api/get_prompts", "GET"): _rule("prompts:read", listing=True), + ("/api/get_single_prompt", "GET"): _rule("prompts:read", (QUERY, "id")), + ("/api/create_prompt", "POST"): _rule("prompts:write"), + ("/api/update_prompt", "POST"): _rule("prompts:write", (JSON, "id")), + ("/api/delete_prompt", "POST"): _rule("prompts:write", (JSON, "id")), + # Tools + ("/api/available_tools", "GET"): _rule("tools:read", open=True), + ("/api/get_tools", "GET"): _rule("tools:read", listing=True), + ("/api/create_tool", "POST"): _rule("tools:write"), + ("/api/parse_spec", "POST"): _rule("tools:write", open=True), + ("/api/update_tool", "POST"): _rule("tools:write", (JSON, "id")), + ("/api/update_tool_config", "POST"): _rule("tools:write", (JSON, "id")), + ("/api/update_tool_actions", "POST"): _rule("tools:write", (JSON, "id")), + ("/api/update_tool_status", "POST"): _rule("tools:write", (JSON, "id")), + ("/api/delete_tool", "POST"): _rule("tools:write", (JSON, "id")), + ("/api/mcp_server/test", "POST"): _rule("tools:write", open=True), + ("/api/mcp_server/save", "POST"): _rule("tools:write", (JSON, "id")), + # Models + ("/api/models", "GET"): _rule(any_of=("models:read", "chat:run"), open=True), + ("/api/user/models", "GET"): _rule("models:read"), + ("/api/user/models/", "GET"): _rule("models:read"), + ("/api/user/models", "POST"): _rule("models:write"), + ("/api/user/models/", "PATCH"): _rule("models:write"), + ("/api/user/models/", "DELETE"): _rule("models:write"), + ("/api/user/models/test", "POST"): _rule("models:write"), + ("/api/user/models//test", "POST"): _rule("models:write"), + # Workflows + ("/api/workflows", "POST"): _rule("workflows:write"), + ("/api/workflows/", "GET"): _rule("workflows:read", (VIEW, "workflow_id")), + ("/api/workflows/", "PUT"): _rule("workflows:write", (VIEW, "workflow_id")), + ("/api/workflows/", "DELETE"): _rule("workflows:write", (VIEW, "workflow_id")), + # Conversations and analytics span every agent, so an agent-restricted token is kept out. + ("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), + ("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), + ("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=("agents",)), + ("/api/messages//tail", "GET"): _rule( + any_of=("conversations:read", "chat:run"), family=None + ), + ("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=("agents",)), + ("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=("agents",)), + ("/api/update_conversation_name", "POST"): _rule("conversations:write", blocked_by=("agents",)), + ("/api/feedback", "POST"): _rule("conversations:write", blocked_by=("agents",)), + ("/api/get_message_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/get_token_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/get_feedback_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/get_tool_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/get_schedule_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/get_user_logs", "POST"): _rule("analytics:read", blocked_by=("agents",)), + # Teams (read only) + ("/api/teams", "GET"): _rule("teams:read"), + ("/api/teams/", "GET"): _rule("teams:read"), + ("/api/teams//members", "GET"): _rule("teams:read"), + ("/api/teams//grants", "GET"): _rule("teams:read"), + ("/api/resource_shares", "GET"): _rule("teams:read"), + # Chat + ("/api/answer", "POST"): _rule("chat:run", **_CHAT), + ("/stream", "POST"): _rule("chat:run", **_CHAT), + ("/api/search", "POST"): _rule("chat:run", **_CHAT), + ("/api/store_attachment", "POST"): _rule("chat:run", family=None), + ("/api/sources//search", "POST"): _rule( + "chat:run", family=None, refs=(("sources", (VIEW, "source_id")),) + ), +} + +#: Routes a PAT may never call, by exact rule string ("*" = every method) or prefix. +#: Token management, admin, login flows and interactive OAuth handshakes need a +#: signed-in session; the rest have no scope yet. Listing them keeps the +#: classification test honest: a new route must land here or in ``RULES``. +DENIED: dict[str, tuple[str, ...]] = { + "/": ("*",), + "/api/user/tokens": ("*",), + "/api/user/tokens/": ("*",), + "/api/generate_token": ("*",), + "/api/combine": ("*",), + "/api/download": ("*",), + "/api/upload_index": ("*",), + "/api/share": ("*",), + "/api/shared_agent": ("*",), + "/api/shared_conversation/": ("*",), + "/api/webhooks/agents/": ("*",), + "/api/images//": ("*",), + "/api/mcp_server/callback": ("*",), + "/api/mcp_server/auth_status": ("*",), + "/api/artifact/": ("*",), + "/api/artifacts": ("*",), + "/api/artifacts/": ("*",), + "/api/artifacts//restore": ("*",), + "/api/artifacts//versions/": ("*",), + "/api/stt": ("*",), + "/api/stt/live/start": ("*",), + "/api/stt/live/chunk": ("*",), + "/api/stt/live/finish": ("*",), + "/api/tts": ("*",), + "/api/teams": ("POST",), + "/api/teams/": ("PUT", "DELETE"), + "/api/teams//members": ("POST",), + "/api/teams//members/": ("*",), + "/api/teams//grants": ("POST", "DELETE"), + "/api/teams//transfer_owner": ("*",), + "/swagger.json": ("*",), +} +DENIED_PREFIXES = ( + "/api/admin/", + "/api/auth/oidc/", + "/api/connectors/", + "/api/devices", + "/scim/", + "/static/", + "/swaggerui/", + "/v1/", +) + + +def is_denied(rule: str, method: str) -> bool: + if rule.startswith(DENIED_PREFIXES): + return True + methods = DENIED.get(rule) + return bool(methods) and ("*" in methods or method in methods) + + +def _json_body(request) -> dict: + body = request.get_json(silent=True) + return body if isinstance(body, dict) else {} + + +def _as_ids(value: Any) -> list[str]: + """Flatten whatever a route accepts as ids: a string, a JSON-encoded or plain list, or an ``{id}`` dict.""" + if value is None or value == "": + return [] + if isinstance(value, dict): + return _as_ids(value.get("id") or value.get("_id") or value.get("workflow_id")) + if isinstance(value, (list, tuple)): + out: list[str] = [] + for item in value: + out.extend(_as_ids(item)) + return out + text = str(value).strip() + if text[:1] in "[{": + try: + return _as_ids(json.loads(text)) + except ValueError: + return [text] + return [text] + + +def _read(request, locator: Locator) -> list[str]: + where, key = locator + if where == VIEW: + return _as_ids((request.view_args or {}).get(key)) + if where == QUERY: + return _as_ids(request.args.get(key)) + if where == JSON: + return _as_ids(_json_body(request).get(key)) + if where == FORM: + return _as_ids(request.form.get(key)) + # BODY: routes that accept JSON or a multipart form interchangeably. + if request.is_json: + return _as_ids(_json_body(request).get(key)) + return _as_ids(request.form.get(key)) + + +def _canonical(value: str) -> str: + try: + return str(uuid.UUID(value)) + except (ValueError, AttributeError, TypeError): + return value + + +def _all_allowed(ids: Iterable[str], allowed: Iterable[str]) -> bool: + allowlist = {_canonical(a) for a in allowed} + return all(_canonical(i) in allowlist for i in ids) + + +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 rule is None: + return ( + { + "success": False, + "error": "not_available_to_tokens", + "message": "This endpoint cannot be called with a personal access token", + }, + 403, + ) + granted = set(decoded_token.get("scopes") or []) + if rule.scopes and not granted.intersection(rule.scopes): + return ( + { + "success": False, + "error": "insufficient_scope", + "message": f"Token lacks the required scope: {' or '.join(rule.scopes)}", + "required_scope": rule.scopes[0], + }, + 403, + ) + resource_filter = decoded_token.get("resource_filter") or {} + if not resource_filter: + return None + reason = _check_resources(request, rule, resource_filter) + if reason is None: + return None + return ({"success": False, "error": "resource_not_allowed", "message": reason}, 403) + + +def _check_resources(request, rule: Rule, resource_filter: dict) -> Optional[str]: + for family in rule.blocked_by: + if family in resource_filter: + return f"This endpoint is not available to a token restricted to specific {family}" + if rule.check is not None: + reason = rule.check(request, resource_filter) + if reason: + return reason + for family, locator in rule.refs: + if family not in resource_filter: + continue + ids = _read(request, locator) + if ids and not _all_allowed(ids, resource_filter[family]): + return f"Token is not allowed to use one of the referenced {family}" + family = rule.family + if family is None or family not in resource_filter or rule.open or rule.listing or rule.in_route: + return None + ids = [i for locator in rule.ids for i in _read(request, locator)] + if not ids: + return f"This token is restricted to specific {family} and cannot use this endpoint" + if not _all_allowed(ids, resource_filter[family]): + return f"Token is not allowed to access this resource ({family})" + return None + + +def allowed_ids(request, family: str) -> Optional[set[str]]: + """The caller's allowlist for ``family``, or ``None`` when unrestricted (or not a PAT). + + Used by listing routes (``listing=True``) and by ``in_route`` handlers. + """ + decoded = getattr(request, "decoded_token", None) or {} + if decoded.get("auth_method") != "pat": + return None + ids = (decoded.get("resource_filter") or {}).get(family) + if ids is None: + return None + return {_canonical(str(i)) for i in ids} + + +def filter_listing(request, family: str, items: list, key: str = "id") -> list: + """Drop rows outside the caller's allowlist. Rows without a UUID id (built-in presets) are kept.""" + allowed = allowed_ids(request, family) + if allowed is None: + return items + kept = [] + for item in items: + value = str(item.get(key, "")) + if not _is_uuid(value) or _canonical(value) in allowed: + kept.append(item) + return kept + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + except (ValueError, AttributeError, TypeError): + return False + return True diff --git a/docsgpt/api/user/agents/portability.py b/docsgpt/api/user/agents/portability.py index 621d0c2d..a9121532 100644 --- a/docsgpt/api/user/agents/portability.py +++ b/docsgpt/api/user/agents/portability.py @@ -36,6 +36,7 @@ from docsgpt.agents.default_tools import ( synthesized_tool_name_for_id, ) from docsgpt.api import api +from docsgpt.api.pat.rules import allowed_ids from docsgpt.core.model_utils import validate_model_id from docsgpt.core.url_validation import SSRFError, validate_url from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url @@ -1717,6 +1718,32 @@ def _read_import_payload(req): return raw.decode("utf-8", "replace"), {} +def _restricted_token_denial(conn, user: str, doc: dict) -> Optional[str]: + """Why a resource-restricted personal access token may not import ``doc``, if it may not. + + An import resolves sources, prompts and tools by name and may create them, + so a token restricted on any of those families cannot be held to its + allowlist here. A token restricted to specific agents may update exactly + those; it can never create one. + """ + for family in ("sources", "prompts", "tools", "workflows"): + if allowed_ids(request, family) is not None: + return f"A token restricted to specific {family} cannot import agents" + allowed_agents = allowed_ids(request, "agents") + if allowed_agents is None: + return None + target = _resolve_target(conn, user, doc.get("metadata") or {}) + if target["action"] != "update" or target["agent_id"] not in allowed_agents: + return "This token is restricted to specific agents and may only update those" + return None + + +def _token_denied_response(reason: str): + return make_response( + jsonify({"success": False, "error": "resource_not_allowed", "message": reason}), 403 + ) + + @agents_portability_ns.route("/export_agent") class ExportAgent(Resource): @api.doc(params={"id": "Agent ID"}, description="Export an agent as YAML") @@ -1763,6 +1790,8 @@ class ImportAgentPlan(Resource): return make_response(jsonify({"success": False, "message": str(exc)}), 400) try: with db_readonly() as conn: + if reason := _restricted_token_denial(conn, user, doc): + return _token_denied_response(reason) plan = plan_import(conn, user, doc) except Exception: current_app.logger.error("Agent import plan failed", exc_info=True) @@ -1786,6 +1815,8 @@ class ImportAgent(Resource): return make_response(jsonify({"success": False, "message": str(exc)}), 400) try: with db_session() as conn: + if reason := _restricted_token_denial(conn, user, doc): + return _token_denied_response(reason) result = apply_import(conn, user, doc, resolution) except AgentImportError as exc: # Apply-time rejection of the user's document (e.g. the workflow diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py index 2ddcda5d..757dd933 100644 --- a/docsgpt/api/user/agents/routes.py +++ b/docsgpt/api/user/agents/routes.py @@ -9,6 +9,7 @@ from flask_restx import fields, Namespace, Resource from pydantic import ValidationError as PydanticValidationError from docsgpt.api import api +from docsgpt.api.pat.rules import filter_listing from docsgpt.guardrails.config import AgentConfig from docsgpt.api.user.base import ( copy_agent_image_for_user, @@ -498,7 +499,7 @@ class GetAgents(Resource): except Exception as err: current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - return make_response(jsonify(list_agents), 200) + return make_response(jsonify(filter_listing(request, "agents", list_agents)), 200) @agents_ns.route("/create_agent") diff --git a/docsgpt/api/user/prompts/routes.py b/docsgpt/api/user/prompts/routes.py index d798d01b..af5b9867 100644 --- a/docsgpt/api/user/prompts/routes.py +++ b/docsgpt/api/user/prompts/routes.py @@ -5,6 +5,7 @@ from flask import current_app, jsonify, make_response, request from flask_restx import fields, Namespace, Resource from docsgpt.api import api +from docsgpt.api.pat.rules import filter_listing from docsgpt.api.user.team_sharing import team_access_for, visible_with_access from docsgpt.storage.db.repositories.prompts import PromptsRepository from docsgpt.prompts.composer import compose_preset, is_composed_preset @@ -91,7 +92,8 @@ class GetPrompts(Resource): except Exception as err: current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - return make_response(jsonify(list_prompts), 200) + # Presets (default/creative/strict) have no row id and stay visible to a restricted token. + return make_response(jsonify(filter_listing(request, "prompts", list_prompts)), 200) @prompts_ns.route("/get_single_prompt") diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py index dc675063..2708b387 100644 --- a/docsgpt/api/user/sources/routes.py +++ b/docsgpt/api/user/sources/routes.py @@ -10,6 +10,7 @@ from pydantic import ValidationError from docsgpt.agents.tools.path_utils import validate_tool_path from docsgpt.api import api +from docsgpt.api.pat.rules import filter_listing from docsgpt.api.user.tasks import ( convert_source_to_wiki, extract_graph, @@ -130,7 +131,7 @@ class CombinedJson(Resource): except Exception as err: current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - return make_response(jsonify(data), 200) + return make_response(jsonify(filter_listing(request, "sources", data)), 200) @sources_ns.route("/sources/paginated") diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py index ecd6f719..8b70abac 100644 --- a/docsgpt/api/user/tools/routes.py +++ b/docsgpt/api/user/tools/routes.py @@ -16,6 +16,7 @@ from docsgpt.agents.default_tools import ( from docsgpt.agents.tools.spec_parser import parse_spec from docsgpt.agents.tools.tool_manager import ToolManager from docsgpt.api import api +from docsgpt.api.pat.rules import filter_listing from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access from docsgpt.core.settings import settings @@ -265,6 +266,9 @@ class GetTools(Resource): shaped = _shape_tool(row, ownership="team", force_strip_secret=True) shaped["team_access"] = team_shared.get(str(row["id"])) user_tools.append(shaped) + # A resource-restricted token sees only its allowed tools; the + # default and builtin rows appended below belong to no user. + user_tools = filter_listing(request, "tools", user_tools) # ``scheduler`` is dual-registered (default chat tool + agent- # selectable builtin) and resolves to the same synthetic uuid5 id. diff --git a/docsgpt/app.py b/docsgpt/app.py index 4a11937e..88e2d5cd 100644 --- a/docsgpt/app.py +++ b/docsgpt/app.py @@ -22,8 +22,11 @@ from docsgpt.api.devices import devices_bp # noqa: E402 from docsgpt.api.internal.routes import internal # noqa: E402 from docsgpt.api.oidc import oidc_bp # noqa: E402 from docsgpt.api.oidc.denylist import is_denied as oidc_session_denied # noqa: E402 +from docsgpt.api.pat.routes import pat_ns # noqa: E402 +from docsgpt.api.pat.rules import authorize as authorize_pat # noqa: E402 +from docsgpt.api.pat.tokens import is_pat # noqa: E402 from docsgpt.api.scim import scim_bp # noqa: E402 -from docsgpt.api.user.authz import resolve_roles # noqa: E402 +from docsgpt.api.user.authz import ROLE_USER, resolve_roles # noqa: E402 from docsgpt.api.user.routes import user # noqa: E402 from docsgpt.api.connector.routes import connector # noqa: E402 from docsgpt.api.v1 import v1_bp # noqa: E402 @@ -109,6 +112,8 @@ app.register_blueprint(v1_bp) # first app and raise "add_url_rule can no longer be called". if admin_ns not in api.namespaces: api.add_namespace(admin_ns) +if pat_ns not in api.namespaces: + api.add_namespace(pat_ns) app.config.update( UPLOAD_FOLDER="inputs", CELERY_BROKER_URL=settings.CELERY_BROKER_URL, @@ -317,6 +322,18 @@ def authenticate_request(): request.decoded_token = None elif "error" in decoded_token: return jsonify(decoded_token), 401 + elif is_pat(decoded_token): + # Scopes and resource restrictions are enforced here, centrally and + # deny by default (docsgpt/api/pat/rules.py). A token never carries + # admin, whatever its owner holds, and the session denylist does not + # apply: the token lookup already excludes revoked tokens and + # deactivated users. + denied = authorize_pat(request, decoded_token) + if denied is not None: + body, status = denied + return jsonify(body), status + decoded_token["roles"] = [ROLE_USER] + request.decoded_token = decoded_token elif settings.AUTH_TYPE == "oidc" and oidc_session_denied(decoded_token): # Back-channel logout / SCIM deactivation revoked this session. return ( diff --git a/tests/api/test_agent_portability.py b/tests/api/test_agent_portability.py index 4f64cbd9..a3cc85f6 100644 --- a/tests/api/test_agent_portability.py +++ b/tests/api/test_agent_portability.py @@ -822,3 +822,72 @@ def test_api_tool_without_actions_imports_with_warning(pg_conn, monkeypatch): agent = AgentsRepository(pg_conn).get(result["agent_id"], user) row = UserToolsRepository(pg_conn).get_any(agent["tools"][0], user) assert row["config"]["actions"] == {} + + +# --- resource-restricted personal access tokens ------------------------------ + + +def _pat_request(app, resource_filter): + from flask import request + + ctx = app.test_request_context("/api/import_agent", method="POST") + ctx.push() + request.decoded_token = { + "sub": "u_pat_import", + "auth_method": "pat", + "scopes": ["agents:read", "agents:write"], + "resource_filter": resource_filter, + } + return ctx + + +@pytest.fixture +def flask_ctx_app(): + from flask import Flask + + return Flask(__name__) + + +def test_restricted_token_may_update_only_its_agents(pg_conn, flask_ctx_app): + from docsgpt.api.user.agents.portability import _restricted_token_denial + + user = "u_pat_import" + allowed = _make_agent(pg_conn, user, slug="allowed") + other = _make_agent(pg_conn, user, slug="other") + + ctx = _pat_request(flask_ctx_app, {"agents": [str(allowed["id"])]}) + try: + assert _restricted_token_denial(pg_conn, user, _doc(_slug="allowed")) is None + assert "only update" in _restricted_token_denial(pg_conn, user, _doc(_slug="other")) + assert "only update" in _restricted_token_denial( + pg_conn, user, {"metadata": {"id": str(other["id"])}} + ) + # A slug that matches nothing would create a new agent: never for a restricted token. + assert "only update" in _restricted_token_denial(pg_conn, user, _doc(_slug="brand-new")) + finally: + ctx.pop() + + +@pytest.mark.parametrize("family", ["sources", "prompts", "tools", "workflows"]) +def test_token_restricted_on_referenced_families_cannot_import(pg_conn, flask_ctx_app, family): + from docsgpt.api.user.agents.portability import _restricted_token_denial + + ctx = _pat_request(flask_ctx_app, {family: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]}) + try: + assert family in _restricted_token_denial(pg_conn, "u_pat_import", _doc()) + finally: + ctx.pop() + + +def test_unrestricted_token_and_sessions_import_freely(pg_conn, flask_ctx_app): + from flask import request + + from docsgpt.api.user.agents.portability import _restricted_token_denial + + ctx = _pat_request(flask_ctx_app, {}) + try: + assert _restricted_token_denial(pg_conn, "u_pat_import", _doc()) is None + request.decoded_token = {"sub": "u_pat_import"} + assert _restricted_token_denial(pg_conn, "u_pat_import", _doc()) is None + finally: + ctx.pop() diff --git a/tests/api/test_pat_routes.py b/tests/api/test_pat_routes.py new file mode 100644 index 00000000..cd157284 --- /dev/null +++ b/tests/api/test_pat_routes.py @@ -0,0 +1,244 @@ +"""Endpoint tests for personal access token management (/api/user/tokens, admin).""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from unittest.mock import patch + +import pytest +from sqlalchemy import text + +from docsgpt.api.pat import routes as pat_routes +from docsgpt.api.pat import tokens as pat_tokens + +AGENT_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + + +@pytest.fixture +def client(): + from docsgpt.app import app + + app.config["TESTING"] = True + return app.test_client() + + +@pytest.fixture(autouse=True) +def _policy(monkeypatch): + monkeypatch.setattr(pat_tokens.settings, "AUTH_TYPE", "oidc") + monkeypatch.setattr(pat_tokens.settings, "PAT_ENABLED", True) + monkeypatch.setattr(pat_tokens.settings, "PAT_DEFAULT_LIFETIME_DAYS", 90) + monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_LIFETIME_DAYS", 365) + monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False) + monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_PER_USER", 25) + + +@pytest.fixture +def db(pg_conn): + """Route every session the token code opens onto the test's rolled-back connection.""" + + @contextmanager + def _yield_conn(): + yield pg_conn + + with patch.object(pat_routes, "db_session", _yield_conn), patch.object( + pat_routes, "db_readonly", _yield_conn + ), patch.object(pat_tokens, "db_session", _yield_conn), patch.object( + pat_tokens, "db_readonly", _yield_conn + ): + yield pg_conn + + +@contextmanager +def _session(sub="alice", roles=("user",)): + with patch("docsgpt.app.handle_auth", return_value={"sub": sub}), patch( + "docsgpt.app.resolve_roles", return_value=list(roles) + ), patch("docsgpt.app.oidc_session_denied", return_value=False): + yield + + +def _create(client, **body): + body.setdefault("name", "ci") + body.setdefault("scopes", ["agents:write"]) + with _session(): + return client.post("/api/user/tokens", json=body) + + +class TestCreate: + def test_returns_the_secret_once_and_stores_only_its_hash(self, client, db): + response = _create(client) + assert response.status_code == 201 + body = json.loads(response.data) + token = body["token"] + assert token.startswith("dgpt_pat_") + public = body["personal_access_token"] + assert public["token_prefix"] == token[:15] + assert "token" not in public and "token_hash" not in public + stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one() + assert stored == pat_tokens.hash_token(token) + assert token not in stored + + with _session(): + listed = json.loads(client.get("/api/user/tokens").data) + assert [t["name"] for t in listed["tokens"]] == ["ci"] + assert token not in json.dumps(listed) + + def test_default_expiry_applies(self, client, db): + body = json.loads(_create(client).data) + assert body["personal_access_token"]["expires_at"] is not None + + def test_non_expiring_needs_operator_opt_in(self, client, db, monkeypatch): + assert _create(client, expires_in_days=0).status_code == 400 + monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True) + response = _create(client, expires_in_days=0) + assert response.status_code == 201 + assert json.loads(response.data)["personal_access_token"]["expires_at"] is None + + def test_lifetime_cap(self, client, db): + assert _create(client, expires_in_days=366).status_code == 400 + + def test_resource_filter_is_stored(self, client, db): + response = _create(client, resource_filter={"agents": [AGENT_A]}) + assert json.loads(response.data)["personal_access_token"]["resource_filter"] == { + "agents": [AGENT_A] + } + + @pytest.mark.parametrize( + "body", + [ + {"name": ""}, + {"name": "x" * 101}, + {"scopes": []}, + {"scopes": ["admin:all"]}, + {"resource_filter": {"agents": ["nope"]}}, + {"resource_filter": {"sources": [AGENT_A]}}, + {"expires_in_days": "soon"}, + ], + ) + def test_validation(self, client, db, body): + assert _create(client, **body).status_code == 400 + + def test_duplicate_name_conflicts(self, client, db): + assert _create(client).status_code == 201 + assert _create(client).status_code == 409 + + def test_per_user_cap(self, client, db, monkeypatch): + monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_PER_USER", 1) + assert _create(client, name="one").status_code == 201 + assert _create(client, name="two").status_code == 409 + + @pytest.mark.parametrize("auth_type", ["simple_jwt", "session_jwt"]) + def test_unavailable_without_a_stable_identity(self, client, db, monkeypatch, auth_type): + monkeypatch.setattr(pat_tokens.settings, "AUTH_TYPE", auth_type) + assert _create(client).status_code == 403 + + def test_disabled_by_operator(self, client, db, monkeypatch): + monkeypatch.setattr(pat_tokens.settings, "PAT_ENABLED", False) + assert _create(client).status_code == 403 + with _session(): + assert json.loads(client.get("/api/user/tokens").data)["policy"]["enabled"] is False + + def test_requires_a_session(self, client, db): + with patch("docsgpt.app.handle_auth", return_value=None): + assert client.post("/api/user/tokens", json={"name": "x", "scopes": ["agents:read"]}).status_code == 401 + + def test_audited(self, client, db): + _create(client) + event, metadata = db.execute( + text("SELECT event, metadata FROM auth_events WHERE user_id = 'alice'") + ).one() + assert event == "pat_created" + assert metadata["scopes"] == ["agents:write"] + assert "dgpt_pat_" not in json.dumps(metadata) + + +class TestList: + def test_includes_scope_catalog_and_policy(self, client, db): + with _session(): + body = json.loads(client.get("/api/user/tokens").data) + assert {s["name"] for s in body["scopes"]} == set(pat_tokens.SCOPES) + assert body["policy"] == { + "enabled": True, + "default_lifetime_days": 90, + "max_lifetime_days": 365, + "allow_non_expiring": False, + "max_per_user": 25, + "filterable_families": list(pat_tokens.FILTERABLE_FAMILIES), + } + + def test_is_owner_scoped(self, client, db): + _create(client) + with _session(sub="bob"): + assert json.loads(client.get("/api/user/tokens").data)["tokens"] == [] + + +class TestEndToEnd: + def test_created_token_authenticates_and_revocation_is_immediate(self, client, db): + created = json.loads(_create(client, scopes=["prompts:read"]).data) + headers = {"Authorization": f"Bearer {created['token']}"} + + me = client.get("/api/user/me", headers=headers) + assert me.status_code == 200 + body = json.loads(me.data) + assert body["user_id"] == "alice" + assert body["token"]["scopes"] == ["prompts:read"] + + # Scoped: cannot list agents, cannot manage tokens. + assert client.get("/api/get_agents", headers=headers).status_code == 403 + assert client.get("/api/user/tokens", headers=headers).status_code == 403 + assert client.post("/api/user/tokens", headers=headers, json={}).status_code == 403 + + with _session(): + token_id = created["personal_access_token"]["id"] + assert client.delete(f"/api/user/tokens/{token_id}").status_code == 200 + assert client.get("/api/user/me", headers=headers).status_code == 401 + + def test_tampered_token_is_rejected(self, client, db): + token = json.loads(_create(client).data)["token"] + response = client.get("/api/user/me", headers={"Authorization": f"Bearer {token}x"}) + assert response.status_code == 401 + + +class TestRevoke: + def test_cannot_revoke_someone_elses_token(self, client, db): + token_id = json.loads(_create(client).data)["personal_access_token"]["id"] + with _session(sub="bob"): + assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404 + + def test_unknown_and_malformed_ids(self, client, db): + with _session(): + assert client.delete(f"/api/user/tokens/{AGENT_A}").status_code == 404 + assert client.delete("/api/user/tokens/not-a-uuid").status_code == 404 + + +class TestAdmin: + def test_requires_admin(self, client, db): + with _session(): + assert client.get("/api/admin/users/alice/tokens").status_code == 403 + assert client.delete(f"/api/admin/tokens/{AGENT_A}").status_code == 403 + + def test_admin_can_list_and_revoke_any_token(self, client, db): + created = json.loads(_create(client).data) + token_id = created["personal_access_token"]["id"] + with _session(sub="root", roles=("admin", "user")): + listed = json.loads(client.get("/api/admin/users/alice/tokens").data) + assert [t["id"] for t in listed["tokens"]] == [token_id] + assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 200 + assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 404 + headers = {"Authorization": f"Bearer {created['token']}"} + assert client.get("/api/user/me", headers=headers).status_code == 401 + + def test_revoke_sessions_also_revokes_tokens(self, client, db): + from docsgpt.api.admin import routes as admin_routes + + @contextmanager + def _yield_conn(): + yield db + + created = json.loads(_create(client).data) + with _session(sub="root", roles=("admin", "user")), patch.object( + admin_routes, "db_session", _yield_conn + ), patch.object(admin_routes.denylist, "deny_user", return_value=True): + assert client.post("/api/admin/users/alice/revoke-sessions").status_code == 200 + headers = {"Authorization": f"Bearer {created['token']}"} + assert client.get("/api/user/me", headers=headers).status_code == 401 diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py new file mode 100644 index 00000000..3a3cd75a --- /dev/null +++ b/tests/api/test_pat_rules.py @@ -0,0 +1,294 @@ +"""The PAT rule table: route classification and enforcement at the Flask chokepoint.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +from docsgpt.api.pat import rules +from docsgpt.api.pat.tokens import SCOPES, expand_scopes + +AGENT_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +AGENT_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +SOURCE_A = "cccccccc-cccc-cccc-cccc-cccccccccccc" +SOURCE_B = "dddddddd-dddd-dddd-dddd-dddddddddddd" + + +@pytest.fixture(scope="module") +def flask_app(): + from docsgpt.app import app + + app.config["TESTING"] = True + return app + + +@pytest.fixture +def client(flask_app): + return flask_app.test_client() + + +def _claims(scopes, resource_filter=None): + return { + "sub": "alice", + "auth_method": "pat", + "pat_id": "t1", + "pat_name": "ci", + "scopes": sorted(expand_scopes(scopes)), + "resource_filter": resource_filter or {}, + } + + +def _call(client, method, path, claims, **kwargs): + """Send a request as a PAT; returns the response, short-circuiting the view.""" + with patch("docsgpt.app.handle_auth", return_value=claims): + return client.open(path, method=method, **kwargs) + + +def _denied(response): + """The rule table refused (as opposed to the view answering 4xx itself).""" + if response.status_code != 403: + return None + return (json.loads(response.data) or {}).get("error") + + +@pytest.mark.unit +class TestClassification: + def test_every_route_is_classified(self, flask_app): + unclassified = [] + for url_rule in flask_app.url_map.iter_rules(): + for method in sorted(url_rule.methods - {"HEAD", "OPTIONS"}): + if (url_rule.rule, method) in rules.RULES: + continue + if rules.is_denied(url_rule.rule, method): + continue + unclassified.append(f"{method} {url_rule.rule}") + assert not unclassified, ( + "New routes must be given a scope in docsgpt/api/pat/rules.py RULES, " + f"or listed in DENIED: {unclassified}" + ) + + def test_no_stale_rules(self, flask_app): + registered = { + (r.rule, m) for r in flask_app.url_map.iter_rules() for m in r.methods + } + assert not [key for key in rules.RULES if key not in registered] + known_rules = {r.rule for r in flask_app.url_map.iter_rules()} + assert not [rule for rule in rules.DENIED if rule not in known_rules] + + def test_a_route_is_never_both_allowed_and_denied(self): + assert not [key for key in rules.RULES if rules.is_denied(*key)] + + def test_rules_only_name_real_scopes(self): + for key, rule in rules.RULES.items(): + for scope in rule.scopes: + assert scope in SCOPES, (key, scope) + + @pytest.mark.parametrize( + "rule,method", + [ + ("/api/user/tokens", "POST"), + ("/api/user/tokens", "GET"), + ("/api/user/tokens/", "DELETE"), + ("/api/admin/users", "GET"), + ("/api/admin/tokens/", "DELETE"), + ("/api/generate_token", "GET"), + ("/api/devices/pairings", "POST"), + ("/api/connectors/auth", "GET"), + ("/api/mcp_server/callback", "GET"), + ], + ) + def test_sensitive_routes_are_never_token_reachable(self, rule, method): + assert (rule, method) not in rules.RULES + assert rules.is_denied(rule, method) + + +@pytest.mark.unit +class TestScopeEnforcement: + def test_unlisted_route_is_refused(self, client): + response = _call(client, "GET", "/api/user/tokens", _claims(list(SCOPES))) + assert _denied(response) == "not_available_to_tokens" + + def test_admin_routes_are_refused_even_with_every_scope(self, client): + response = _call(client, "GET", "/api/admin/users", _claims(list(SCOPES))) + assert _denied(response) == "not_available_to_tokens" + + def test_missing_scope_is_refused_and_names_the_scope(self, client): + response = _call(client, "GET", "/api/get_agents", _claims(["sources:read"])) + assert _denied(response) == "insufficient_scope" + assert json.loads(response.data)["required_scope"] == "agents:read" + + def test_read_scope_cannot_write(self, client): + response = _call( + client, "DELETE", f"/api/delete_agent?id={AGENT_A}", _claims(["agents:read"]) + ) + assert _denied(response) == "insufficient_scope" + + def test_write_scope_can_read(self, client): + response = _call(client, "GET", "/api/get_agents", _claims(["agents:write"])) + assert _denied(response) is None + + def test_agent_key_regeneration_needs_its_own_scope(self, client): + response = _call( + client, "POST", f"/api/regenerate_agent_key/{AGENT_A}", _claims(["agents:write"]) + ) + assert _denied(response) == "insufficient_scope" + + def test_any_token_can_identify_itself(self, client): + response = _call(client, "GET", "/api/user/me", _claims(["prompts:read"])) + assert response.status_code == 200 + body = json.loads(response.data) + assert body["auth_method"] == "pat" + assert body["token"]["scopes"] == ["prompts:read"] + assert body["roles"] == ["user"] + + def test_token_never_carries_admin(self, client): + with patch("docsgpt.app.resolve_roles", return_value=["admin", "user"]) as resolver: + response = _call(client, "GET", "/api/user/me", _claims(["agents:read"])) + resolver.assert_not_called() + assert json.loads(response.data)["roles"] == ["user"] + + def test_session_callers_bypass_the_table(self, client): + with patch("docsgpt.app.handle_auth", return_value={"sub": "alice"}), patch( + "docsgpt.app.resolve_roles", return_value=["user"] + ), patch("docsgpt.app.authorize_pat") as authorize: + client.get("/api/user/me") + authorize.assert_not_called() + + def test_invalid_token_is_401(self, client): + with patch( + "docsgpt.app.handle_auth", + return_value={"error": "invalid_token", "message": "Authentication error: invalid token"}, + ): + assert client.get("/api/get_agents").status_code == 401 + + +@pytest.mark.unit +class TestResourceRestrictions: + def _restricted(self, scopes, **families): + return _claims(scopes, resource_filter=families) + + def test_allowed_id_passes_and_other_id_is_refused(self, client): + claims = self._restricted(["agents:read"], agents=[AGENT_A]) + assert _denied(_call(client, "GET", f"/api/get_agent?id={AGENT_A}", claims)) is None + assert ( + _denied(_call(client, "GET", f"/api/get_agent?id={AGENT_B}", claims)) + == "resource_not_allowed" + ) + + def test_id_comparison_ignores_case(self, client): + claims = self._restricted(["agents:read"], agents=[AGENT_A]) + assert _denied(_call(client, "GET", f"/api/get_agent?id={AGENT_A.upper()}", claims)) is None + + def test_view_arg_ids(self, client): + claims = self._restricted(["agents:write"], agents=[AGENT_A]) + ok = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"name": "x"}) + bad = _call(client, "PUT", f"/api/update_agent/{AGENT_B}", claims, json={"name": "x"}) + assert _denied(ok) is None + assert _denied(bad) == "resource_not_allowed" + + def test_json_body_ids_including_lists(self, client): + claims = self._restricted(["agents:write"], agents=[AGENT_A]) + ok = _call(client, "POST", "/api/agents/folders/bulk_move", claims, json={"agent_ids": [AGENT_A]}) + bad = _call( + client, "POST", "/api/agents/folders/bulk_move", claims, json={"agent_ids": [AGENT_A, AGENT_B]} + ) + assert _denied(ok) is None + assert _denied(bad) == "resource_not_allowed" + + def test_missing_id_is_refused_for_a_restricted_token(self, client): + claims = self._restricted(["agents:read"], agents=[AGENT_A]) + assert _denied(_call(client, "GET", "/api/get_agent", claims)) == "resource_not_allowed" + + def test_restricted_token_cannot_create(self, client): + claims = self._restricted(["agents:write"], agents=[AGENT_A]) + response = _call(client, "POST", "/api/create_agent", claims, json={"name": "new"}) + assert _denied(response) == "resource_not_allowed" + + def test_unrestricted_family_is_untouched(self, client): + claims = self._restricted(["agents:write", "prompts:write"], agents=[AGENT_A]) + response = _call(client, "POST", "/api/create_prompt", claims, json={}) + assert _denied(response) is None + + def test_cross_family_references_are_checked(self, client): + claims = self._restricted(["agents:write", "sources:read"], sources=[SOURCE_A]) + ok = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"source": SOURCE_A}) + bad = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"sources": [SOURCE_A, SOURCE_B]}) + form = _call( + client, "PUT", f"/api/update_agent/{AGENT_A}", claims, + data={"sources": json.dumps([SOURCE_B])}, + ) + assert _denied(ok) is None + assert _denied(bad) == "resource_not_allowed" + assert _denied(form) == "resource_not_allowed" + + def test_routes_hanging_off_a_restricted_family_are_blocked(self, client): + claims = self._restricted(["agents:read", "schedules:read", "analytics:read"], agents=[AGENT_A]) + assert _denied(_call(client, "GET", "/api/schedules/s1", claims)) == "resource_not_allowed" + assert _denied(_call(client, "POST", "/api/get_token_analytics", claims, json={})) == "resource_not_allowed" + assert _denied(_call(client, "GET", f"/api/agents/{AGENT_A}/schedules", claims)) is None + assert _denied(_call(client, "GET", f"/api/agents/{AGENT_B}/schedules", claims)) == "resource_not_allowed" + + def test_sql_paged_listing_is_closed_to_restricted_tokens(self, client): + claims = self._restricted(["sources:read"], sources=[SOURCE_A]) + assert _denied(_call(client, "GET", "/api/sources/paginated", claims)) == "resource_not_allowed" + + +@pytest.mark.unit +class TestChatRestrictions: + def _chat(self, client, claims, body): + return _denied(_call(client, "POST", "/api/answer", claims, json=body)) + + def test_unrestricted_token_can_chat_any_way(self, client): + claims = _claims(["chat:run"]) + assert self._chat(client, claims, {"question": "hi", "api_key": "k"}) is None + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_B}) is None + + def test_agent_restricted_token_must_name_an_allowed_agent(self, client): + claims = _claims(["chat:run"], {"agents": [AGENT_A]}) + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_A}) is None + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_B}) == "resource_not_allowed" + assert self._chat(client, claims, {"question": "hi"}) == "resource_not_allowed" + assert self._chat(client, claims, {"question": "hi", "api_key": "k"}) == "resource_not_allowed" + + def test_restricted_token_cannot_run_an_inline_workflow(self, client): + claims = _claims(["chat:run"], {"agents": [AGENT_A]}) + body = {"question": "hi", "agent_id": AGENT_A, "workflow": {"nodes": []}} + assert self._chat(client, claims, body) == "resource_not_allowed" + + def test_source_restricted_token_cannot_reach_other_sources_through_an_agent(self, client): + claims = _claims(["chat:run"], {"sources": [SOURCE_A]}) + assert self._chat(client, claims, {"question": "hi", "active_docs": SOURCE_A}) is None + assert self._chat(client, claims, {"question": "hi", "active_docs": [SOURCE_A, SOURCE_B]}) == "resource_not_allowed" + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_A}) == "resource_not_allowed" + + def test_retrieval_test_honours_the_source_allowlist(self, client): + claims = _claims(["chat:run"], {"sources": [SOURCE_A]}) + ok = _call(client, "POST", f"/api/sources/{SOURCE_A}/search", claims, json={"query": "q"}) + bad = _call(client, "POST", f"/api/sources/{SOURCE_B}/search", claims, json={"query": "q"}) + assert _denied(ok) is None + assert _denied(bad) == "resource_not_allowed" + + +@pytest.mark.unit +class TestListingFilter: + def test_unrestricted_and_session_callers_see_everything(self, flask_app): + from flask import request + + items = [{"id": AGENT_A}, {"id": AGENT_B}] + with flask_app.test_request_context("/"): + request.decoded_token = {"sub": "alice"} + assert rules.filter_listing(request, "agents", items) == items + request.decoded_token = _claims(["agents:read"]) + assert rules.filter_listing(request, "agents", items) == items + + def test_restricted_token_sees_only_its_rows_plus_builtin_presets(self, flask_app): + from flask import request + + items = [{"id": AGENT_A}, {"id": AGENT_B}, {"id": "default"}] + with flask_app.test_request_context("/"): + request.decoded_token = _claims(["prompts:read"], {"prompts": [AGENT_A]}) + assert rules.filter_listing(request, "prompts", items) == [{"id": AGENT_A}, {"id": "default"}] + assert rules.allowed_ids(request, "agents") is None + assert rules.allowed_ids(request, "prompts") == {AGENT_A} From 225d9d3065f2723aaadb829064da2e07ac45b550 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 05/13] feat(frontend): access tokens settings tab Settings > Access Tokens lists a user's personal access tokens and lets them create and revoke tokens: scopes grouped by family, optional restriction to specific resources, and an expiry bounded by the server's policy. The secret is shown once after creation and never stored client side. Removes the unused legacy api-key endpoints, wrappers, type and locale block. --- frontend/src/api/endpoints.ts | 5 +- frontend/src/api/services/patService.test.ts | 156 ++++++ frontend/src/api/services/patService.ts | 110 +++++ frontend/src/api/services/userService.ts | 6 - frontend/src/locale/accessTokens.test.ts | 59 +++ frontend/src/locale/de.json | 108 +++- frontend/src/locale/en.json | 108 +++- frontend/src/locale/es.json | 108 +++- frontend/src/locale/jp.json | 108 +++- frontend/src/locale/ru.json | 128 ++++- frontend/src/locale/zh-TW.json | 108 +++- frontend/src/locale/zh.json | 108 +++- .../src/modals/AccessTokenCreatedModal.tsx | 104 ++++ .../src/modals/CreateAccessTokenModal.tsx | 462 ++++++++++++++++++ .../src/settings/PersonalAccessTokens.tsx | 443 +++++++++++++++++ .../src/settings/accessTokenUtils.test.ts | 289 +++++++++++ frontend/src/settings/accessTokenUtils.ts | 224 +++++++++ frontend/src/settings/index.tsx | 7 + frontend/src/settings/types/index.ts | 15 +- 19 files changed, 2563 insertions(+), 93 deletions(-) create mode 100644 frontend/src/api/services/patService.test.ts create mode 100644 frontend/src/api/services/patService.ts create mode 100644 frontend/src/locale/accessTokens.test.ts create mode 100644 frontend/src/modals/AccessTokenCreatedModal.tsx create mode 100644 frontend/src/modals/CreateAccessTokenModal.tsx create mode 100644 frontend/src/settings/PersonalAccessTokens.tsx create mode 100644 frontend/src/settings/accessTokenUtils.test.ts create mode 100644 frontend/src/settings/accessTokenUtils.ts diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index b6c00973..cd19899c 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -10,9 +10,6 @@ const endpoints = { MODELS: '/api/models', DOCS: '/api/sources', DOCS_PAGINATED: '/api/sources/paginated', - API_KEYS: '/api/get_api_keys', - CREATE_API_KEY: '/api/create_api_key', - DELETE_API_KEY: '/api/delete_api_key', AGENT: (id: string) => `/api/get_agent?id=${id}`, AGENTS: '/api/get_agents', GUARDRAIL_CATALOG: '/api/guardrails/catalog', @@ -155,6 +152,8 @@ const endpoints = { DEVICE_PAIRINGS: '/api/devices/pairings', DEVICE_PAIRING: (deviceCode: string) => `/api/devices/pairings/${deviceCode}`, + ACCESS_TOKENS: '/api/user/tokens', + ACCESS_TOKEN: (id: string) => `/api/user/tokens/${id}`, }, V1: { CHAT_COMPLETIONS: '/v1/chat/completions', diff --git a/frontend/src/api/services/patService.test.ts b/frontend/src/api/services/patService.test.ts new file mode 100644 index 00000000..63b7a88e --- /dev/null +++ b/frontend/src/api/services/patService.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import apiClient from '../client'; +import patService, { AccessTokenApiError } from './patService'; + +afterEach(() => vi.restoreAllMocks()); + +const response = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }) as unknown as Response; + +const TOKEN_ROW = { + id: '7f1c2c1e-0f59-4d0a-9d55-0f6a3d1d7a11', + name: 'ci', + token_prefix: 'dgpt_pat_ab12cd', + scopes: ['agents:read'], + resource_filter: {}, + status: 'active', + expires_at: null, + last_used_at: null, + last_used_ip: null, + created_at: '2026-09-01T10:00:00+00:00', + revoked_at: null, +}; + +describe('patService.list', () => { + it('GETs /api/user/tokens with the session token and returns the body', async () => { + const body = { + success: true, + tokens: [TOKEN_ROW], + scopes: [{ name: 'agents:read', description: 'View agents' }], + policy: { enabled: true }, + }; + const spy = vi.spyOn(apiClient, 'get').mockResolvedValue(response(body)); + + const result = await patService.list('session-jwt'); + + expect(spy).toHaveBeenCalledWith('/api/user/tokens', 'session-jwt'); + expect(result.tokens).toEqual([TOKEN_ROW]); + expect(result.scopes[0].name).toBe('agents:read'); + }); + + it('throws with the server message on a non-2xx response', async () => { + vi.spyOn(apiClient, 'get').mockResolvedValue( + response({ success: false, message: 'Authentication required' }, 401), + ); + + await expect(patService.list(null)).rejects.toMatchObject({ + name: 'AccessTokenApiError', + message: 'Authentication required', + status: 401, + }); + }); +}); + +describe('patService.create', () => { + it('POSTs the payload and returns the one-time plaintext token', async () => { + const spy = vi.spyOn(apiClient, 'post').mockResolvedValue( + response( + { + success: true, + token: 'dgpt_pat_secret', + personal_access_token: TOKEN_ROW, + }, + 201, + ), + ); + const payload = { + name: 'ci', + scopes: ['agents:read'], + resource_filter: { agents: [TOKEN_ROW.id] }, + expires_in_days: 30, + }; + + const result = await patService.create(payload, 'session-jwt'); + + expect(spy).toHaveBeenCalledWith( + '/api/user/tokens', + payload, + 'session-jwt', + ); + expect(result.token).toBe('dgpt_pat_secret'); + expect(result.personal_access_token.id).toBe(TOKEN_ROW.id); + }); + + it.each([ + [400, 'Unknown scopes: nope:read'], + [403, 'Personal access tokens are not available on this server'], + [409, 'A token with this name already exists'], + ])('surfaces the %i error message', async (status, message) => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message }, status), + ); + + const error = await patService + .create({ name: 'ci', scopes: ['agents:read'] }, 'session-jwt') + .catch((e) => e); + + expect(error).toBeInstanceOf(AccessTokenApiError); + expect(error.message).toBe(message); + expect(error.status).toBe(status); + }); + + it('throws an empty message when the error body is not JSON', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + } as unknown as Response); + + await expect( + patService.create({ name: 'ci', scopes: ['agents:read'] }, null), + ).rejects.toMatchObject({ message: '', status: 502 }); + }); + + it('treats success:false on a 2xx as a failure', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message: 'nope' }, 200), + ); + + await expect( + patService.create({ name: 'ci', scopes: ['agents:read'] }, null), + ).rejects.toThrow('nope'); + }); +}); + +describe('patService.revoke', () => { + it('DELETEs the token by id', async () => { + const spy = vi + .spyOn(apiClient, 'delete') + .mockResolvedValue(response({ success: true })); + + await expect( + patService.revoke(TOKEN_ROW.id, 'session-jwt'), + ).resolves.toEqual({ success: true }); + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}`, + 'session-jwt', + ); + }); + + it('throws when the token is not found', async () => { + vi.spyOn(apiClient, 'delete').mockResolvedValue( + response({ success: false, message: 'Token not found' }, 404), + ); + + await expect(patService.revoke('missing', null)).rejects.toThrow( + 'Token not found', + ); + }); +}); diff --git a/frontend/src/api/services/patService.ts b/frontend/src/api/services/patService.ts new file mode 100644 index 00000000..09eb197a --- /dev/null +++ b/frontend/src/api/services/patService.ts @@ -0,0 +1,110 @@ +import apiClient from '../client'; +import endpoints from '../endpoints'; + +export interface PersonalAccessToken { + id: string; + name: string; + /** Display prefix only (e.g. `dgpt_pat_ab12cd`); never the full secret. */ + token_prefix: string; + scopes: string[]; + /** `{family: [resource ids]}`; a missing family means "all resources". */ + resource_filter: Record; + status: string; + expires_at: string | null; + last_used_at: string | null; + last_used_ip: string | null; + created_at: string | null; + revoked_at: string | null; +} + +export interface AccessTokenScope { + name: string; + description: string; +} + +export interface AccessTokenPolicy { + enabled: boolean; + default_lifetime_days: number; + max_lifetime_days: number; + allow_non_expiring: boolean; + max_per_user: number; + filterable_families: string[]; +} + +export interface AccessTokenListResponse { + tokens: PersonalAccessToken[]; + scopes: AccessTokenScope[]; + policy: AccessTokenPolicy; +} + +export interface CreateAccessTokenPayload { + name: string; + scopes: string[]; + resource_filter?: Record; + /** `null`/omitted = server default, `0` = never expires. */ + expires_in_days?: number | null; +} + +export interface CreateAccessTokenResponse { + /** Plaintext secret. The server returns it exactly once. */ + token: string; + personal_access_token: PersonalAccessToken; +} + +/** Error carrying the server's user-facing `message` and the HTTP status. */ +export class AccessTokenApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'AccessTokenApiError'; + this.status = status; + } +} + +// apiClient resolves to the raw fetch Response (the app convention). Parse it +// here and turn `{success:false, message}` / non-2xx into a thrown error so +// callers can show the server's message inline. +const parse = async (response: Response): Promise => { + let body: { success?: boolean; message?: unknown } | null = null; + try { + body = await response.json(); + } catch { + body = null; + } + if (!response.ok || body?.success === false) { + throw new AccessTokenApiError( + typeof body?.message === 'string' ? body.message : '', + response.status, + ); + } + return body as T; +}; + +const patService = { + list: async (token: string | null): Promise => + parse( + await apiClient.get(endpoints.USER.ACCESS_TOKENS, token), + ), + + create: async ( + payload: CreateAccessTokenPayload, + token: string | null, + ): Promise => + parse( + await apiClient.post(endpoints.USER.ACCESS_TOKENS, payload, token), + ), + + revoke: async ( + id: string, + token: string | null, + ): Promise<{ success: boolean }> => + parse<{ success: boolean }>( + await apiClient.delete( + endpoints.USER.ACCESS_TOKEN(encodeURIComponent(id)), + token, + ), + ), +}; + +export default patService; diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 585a1ef3..b9de3bc0 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -19,12 +19,6 @@ const userService = { apiClient.get(`${endpoints.USER.DOCS}`, token), getDocsWithPagination: (query: string, token: string | null): Promise => apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`, token), - getAPIKeys: (token: string | null): Promise => - apiClient.get(endpoints.USER.API_KEYS, token), - createAPIKey: (data: any, token: string | null): Promise => - apiClient.post(endpoints.USER.CREATE_API_KEY, data, token), - deleteAPIKey: (data: any, token: string | null): Promise => - apiClient.post(endpoints.USER.DELETE_API_KEY, data, token), getAgent: (id: string, token: string | null): Promise => throttledApiClient.get(endpoints.USER.AGENT(id), token), getAgents: (token: string | null): Promise => diff --git a/frontend/src/locale/accessTokens.test.ts b/frontend/src/locale/accessTokens.test.ts new file mode 100644 index 00000000..5415f4ce --- /dev/null +++ b/frontend/src/locale/accessTokens.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import de from './de.json'; +import en from './en.json'; +import es from './es.json'; +import jp from './jp.json'; +import ru from './ru.json'; +import zhTW from './zh-TW.json'; +import zh from './zh.json'; + +type Tree = { [key: string]: string | Tree }; + +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; + +// Plural categories differ per language (ru adds few/many), so compare the +// keys with the suffix stripped. +const flatten = (tree: Tree, prefix = ''): string[] => + Object.entries(tree).flatMap(([key, value]) => + typeof value === 'string' + ? [prefix + key.replace(PLURAL_SUFFIX, '')] + : flatten(value, `${prefix}${key}.`), + ); + +const keysOf = (locale: { settings: object }): string[] => { + const block = (locale.settings as Tree).accessTokens as Tree; + return Array.from(new Set(flatten(block))).sort(); +}; + +const LOCALES = { es, de, jp, ru, zh, zhTW }; + +describe('settings.accessTokens locale block', () => { + it.each(Object.entries(LOCALES))( + '%s has the same keys as en', + (_name, locale) => { + expect(keysOf(locale)).toEqual(keysOf(en)); + }, + ); + + it.each(Object.entries(LOCALES))( + '%s is translated, not an English copy', + (_name, locale) => { + const block = (locale.settings as Tree).accessTokens as Tree; + const source = (en.settings as Tree).accessTokens as Tree; + expect(block.label).not.toBe(source.label); + expect((block.created as Tree).warning).not.toBe( + (source.created as Tree).warning, + ); + }, + ); + + it('every locale provides both plural forms i18next falls back through', () => { + [en, ...Object.values(LOCALES)].forEach((locale) => { + const relative = ((locale.settings as Tree).accessTokens as Tree) + .relative as Tree; + expect(relative.days_one).toBeTruthy(); + expect(relative.days_other).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index e64ed52f..7291a332 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Name", - "key": "API-Schlüssel", - "sourceDoc": "Quelldokument", - "createNew": "Neu erstellen", - "noData": "Keine vorhandenen Chatbots", - "deleteConfirmation": "Bist du sicher, dass du den API-Schlüssel '{{name}}' löschen möchtest?", - "description": "Hier kannst du deine Chatbots erstellen und verwalten. Chatbots können als Widgets auf Websites eingebunden oder in deinen Anwendungen verwendet werden." - }, "analytics": { "label": "Analytik", "subtitle": "Nachrichtenvolumen, Token-Verbrauch und Nutzerfeedback in deinem Konto verfolgen", @@ -747,6 +737,104 @@ "testFailed": "Verbindungstest fehlgeschlagen" } }, + "accessTokens": { + "label": "Zugriffstoken", + "subtitle": "Mit persönlichen Zugriffstoken können Skripte, die CLI und CI-Pipelines die DocsGPT-API in deinem Namen aufrufen. Gib jedem Token nur die Berechtigungen, die es braucht.", + "createToken": "Token erstellen", + "disabledNotice": "Persönliche Zugriffstoken sind auf diesem Server nicht verfügbar. Sie setzen eine kontobasierte Anmeldung voraus und müssen von deinem Administrator aktiviert werden.", + "limitReached_one": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.", + "limitReached_other": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.", + "loadError": "Zugriffstoken konnten nicht geladen werden. Bitte versuche es erneut.", + "revokeError": "Das Token konnte nicht widerrufen werden. Bitte versuche es erneut.", + "empty": "Noch keine Zugriffstoken", + "emptyHint": "Erstelle ein Token, um die DocsGPT-API aus Skripten und Kommandozeilen-Tools zu nutzen.", + "name": "Name", + "scopes": "Berechtigungen", + "resources": "Ressourcen", + "createdAt": "Erstellt", + "lastUsed": "Zuletzt verwendet", + "expires": "Läuft ab", + "actions": "Aktionen", + "allResources": "Alle Ressourcen", + "never": "Nie", + "expired": "Abgelaufen am {{date}}", + "expiresSoon": "Läuft ab am {{date}}", + "moreScopes": "+{{count}} weitere", + "showLess": "Weniger anzeigen", + "revoke": "Widerrufen", + "revokeAria": "Token {{name}} widerrufen", + "revokeWarning": "Token „{{name}}“ widerrufen? Alles, was es noch verwendet, funktioniert sofort nicht mehr. Das kann nicht rückgängig gemacht werden.", + "restrictionCount": { + "agents_one": "{{count}} Agent", + "agents_other": "{{count}} Agenten", + "sources_one": "{{count}} Quelle", + "sources_other": "{{count}} Quellen", + "prompts_one": "{{count}} Prompt", + "prompts_other": "{{count}} Prompts", + "tools_one": "{{count}} Tool", + "tools_other": "{{count}} Tools", + "workflows_one": "{{count}} Workflow", + "workflows_other": "{{count}} Workflows" + }, + "relative": { + "now": "Gerade eben", + "minutes_one": "Vor {{count}} Minute", + "minutes_other": "Vor {{count}} Minuten", + "hours_one": "Vor {{count}} Stunde", + "hours_other": "Vor {{count}} Stunden", + "days_one": "Vor {{count}} Tag", + "days_other": "Vor {{count}} Tagen" + }, + "families": { + "agents": "Agenten", + "sources": "Quellen", + "prompts": "Prompts", + "tools": "Tools", + "models": "Modelle", + "workflows": "Workflows", + "schedules": "Zeitpläne", + "conversations": "Unterhaltungen", + "analytics": "Analysen", + "teams": "Teams", + "chat": "Chat" + }, + "create": { + "title": "Zugriffstoken erstellen", + "subtitle": "Das Token handelt in deinem Namen, beschränkt auf die Berechtigungen und Ressourcen, die du hier auswählst.", + "name": "Name", + "namePlaceholder": "z. B. CI-Pipeline", + "expiration": "Ablauf", + "expiryDays_one": "{{count}} Tag", + "expiryDays_other": "{{count}} Tage", + "noExpiration": "Kein Ablaufdatum", + "noExpirationHint": "Token ohne Ablaufdatum sind riskanter, falls sie in falsche Hände geraten. Ein Ablaufdatum ist die bessere Wahl.", + "expiresOn": "Läuft ab am {{date}}", + "scopes": "Berechtigungen", + "scopesHint": "Lege fest, was dieses Token darf. Schreibzugriff schließt Lesezugriff ein.", + "impliedByWrite": "Im Schreibzugriff enthalten", + "restrict": "Auf bestimmte Ressourcen beschränken", + "restrictHint": "Optional. Beschränke das Token auf die ausgewählten Elemente. Lass eine Liste leer, um alle zuzulassen.", + "restrictNoFamilies": "Wähle zuerst eine Berechtigung für Agenten, Quellen, Prompts, Tools oder Chat, um deren Ressourcen zu beschränken.", + "allSelected": "Alle (keine Beschränkung)", + "noResources": "Nichts gefunden.", + "searchResources": "Suchen...", + "resourcesError": "Die Liste konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "cancel": "Abbrechen", + "submit": "Token erstellen", + "submitting": "Wird erstellt", + "error": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." + }, + "created": { + "title": "Token erstellt", + "subtitle": "Dein neues Token „{{name}}“ ist einsatzbereit.", + "warning": "Kopiere das Token jetzt und bewahre es an einem sicheren Ort auf. Aus Sicherheitsgründen wird es nicht noch einmal angezeigt.", + "tokenLabel": "Dein Token", + "usageTitle": "So verwendest du es", + "usageHint": "Exportiere es als Umgebungsvariable und sende es als Bearer-Token im Authorization-Header.", + "done": "Fertig" + } + }, "scrollTabsLeft": "Tabs nach links scrollen", "tabsAriaLabel": "Einstellungs-Tabs", "scrollTabsRight": "Tabs nach rechts scrollen" diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 03f0f0dd..a5118073 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -325,16 +325,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Name", - "key": "API Key", - "sourceDoc": "Source Document", - "createNew": "Create New", - "noData": "No existing Chatbots", - "deleteConfirmation": "Are you sure you want to delete the API key '{{name}}'?", - "description": "Here you can create and manage your chatbots. Chatbots can be deployed to websites as widgets or used inside your applications." - }, "analytics": { "label": "Analytics", "subtitle": "Track message volume, token usage, and user feedback across your account", @@ -752,6 +742,104 @@ "testFailed": "Connection test failed" } }, + "accessTokens": { + "label": "Access Tokens", + "subtitle": "Personal access tokens let scripts, the CLI and CI pipelines call the DocsGPT API on your behalf. Give each token only the scopes it needs.", + "createToken": "Create token", + "disabledNotice": "Personal access tokens are not available on this server. They require account-based sign-in and have to be enabled by your administrator.", + "limitReached_one": "You have reached the limit of {{count}} active token. Revoke one to create another.", + "limitReached_other": "You have reached the limit of {{count}} active tokens. Revoke one to create another.", + "loadError": "Failed to load access tokens. Please try again.", + "revokeError": "Failed to revoke the token. Please try again.", + "empty": "No access tokens yet", + "emptyHint": "Create a token to use the DocsGPT API from scripts and command-line tools.", + "name": "Name", + "scopes": "Scopes", + "resources": "Resources", + "createdAt": "Created", + "lastUsed": "Last used", + "expires": "Expires", + "actions": "Actions", + "allResources": "All resources", + "never": "Never", + "expired": "Expired {{date}}", + "expiresSoon": "Expires {{date}}", + "moreScopes": "+{{count}} more", + "showLess": "Show less", + "revoke": "Revoke", + "revokeAria": "Revoke token {{name}}", + "revokeWarning": "Revoke the token \"{{name}}\"? Anything still using it will stop working immediately. This cannot be undone.", + "restrictionCount": { + "agents_one": "{{count}} agent", + "agents_other": "{{count}} agents", + "sources_one": "{{count}} source", + "sources_other": "{{count}} sources", + "prompts_one": "{{count}} prompt", + "prompts_other": "{{count}} prompts", + "tools_one": "{{count}} tool", + "tools_other": "{{count}} tools", + "workflows_one": "{{count}} workflow", + "workflows_other": "{{count}} workflows" + }, + "relative": { + "now": "Just now", + "minutes_one": "{{count}} minute ago", + "minutes_other": "{{count}} minutes ago", + "hours_one": "{{count}} hour ago", + "hours_other": "{{count}} hours ago", + "days_one": "{{count}} day ago", + "days_other": "{{count}} days ago" + }, + "families": { + "agents": "Agents", + "sources": "Sources", + "prompts": "Prompts", + "tools": "Tools", + "models": "Models", + "workflows": "Workflows", + "schedules": "Schedules", + "conversations": "Conversations", + "analytics": "Analytics", + "teams": "Teams", + "chat": "Chat" + }, + "create": { + "title": "Create access token", + "subtitle": "The token acts as you, limited to the scopes and resources you pick here.", + "name": "Name", + "namePlaceholder": "e.g. CI pipeline", + "expiration": "Expiration", + "expiryDays_one": "{{count}} day", + "expiryDays_other": "{{count}} days", + "noExpiration": "No expiration", + "noExpirationHint": "Tokens that never expire are riskier if leaked. Prefer an expiration date.", + "expiresOn": "Expires on {{date}}", + "scopes": "Scopes", + "scopesHint": "Choose what this token may do. Write access includes read access.", + "impliedByWrite": "Included with write access", + "restrict": "Restrict to specific resources", + "restrictHint": "Optional. Limit the token to the items you select. Leave a list empty to allow all of them.", + "restrictNoFamilies": "Select a scope for agents, sources, prompts, tools or chat first to restrict its resources.", + "allSelected": "All (no restriction)", + "noResources": "Nothing found.", + "searchResources": "Search...", + "resourcesError": "Failed to load the list.", + "retry": "Retry", + "cancel": "Cancel", + "submit": "Create token", + "submitting": "Creating", + "error": "Failed to create the token. Please try again." + }, + "created": { + "title": "Token created", + "subtitle": "Your new token \"{{name}}\" is ready.", + "warning": "Copy the token now and store it somewhere safe. For security reasons it won't be shown again.", + "tokenLabel": "Your token", + "usageTitle": "How to use it", + "usageHint": "Export it as an environment variable, then send it as a Bearer token in the Authorization header.", + "done": "Done" + } + }, "scrollTabsLeft": "Scroll tabs left", "tabsAriaLabel": "Settings tabs", "scrollTabsRight": "Scroll tabs right" diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index c7b4f794..c6f7fc92 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Nombre", - "key": "Clave de API", - "sourceDoc": "Documento Fuente", - "createNew": "Crear Nuevo", - "noData": "No hay chatbots existentes", - "deleteConfirmation": "¿Estás seguro de que quieres eliminar la clave API '{{name}}'?", - "description": "Aquí puede crear y gestionar sus chatbots. Los chatbots se pueden implementar en sitios web como widgets o utilizarse dentro de sus aplicaciones." - }, "analytics": { "label": "Analítica", "subtitle": "Realiza un seguimiento del volumen de mensajes, uso de tokens y comentarios de usuarios en tu cuenta", @@ -747,6 +737,104 @@ "testFailed": "La prueba de conexión falló" } }, + "accessTokens": { + "label": "Tokens de acceso", + "subtitle": "Los tokens de acceso personal permiten que scripts, la CLI y pipelines de CI llamen a la API de DocsGPT en tu nombre. Da a cada token solo los permisos que necesite.", + "createToken": "Crear token", + "disabledNotice": "Los tokens de acceso personal no están disponibles en este servidor. Requieren inicio de sesión con cuenta y deben ser habilitados por tu administrador.", + "limitReached_one": "Has alcanzado el límite de {{count}} token activo. Revoca uno para crear otro.", + "limitReached_other": "Has alcanzado el límite de {{count}} tokens activos. Revoca uno para crear otro.", + "loadError": "No se pudieron cargar los tokens de acceso. Inténtalo de nuevo.", + "revokeError": "No se pudo revocar el token. Inténtalo de nuevo.", + "empty": "Aún no hay tokens de acceso", + "emptyHint": "Crea un token para usar la API de DocsGPT desde scripts y herramientas de línea de comandos.", + "name": "Nombre", + "scopes": "Permisos", + "resources": "Recursos", + "createdAt": "Creado", + "lastUsed": "Último uso", + "expires": "Caduca", + "actions": "Acciones", + "allResources": "Todos los recursos", + "never": "Nunca", + "expired": "Caducó el {{date}}", + "expiresSoon": "Caduca el {{date}}", + "moreScopes": "+{{count}} más", + "showLess": "Mostrar menos", + "revoke": "Revocar", + "revokeAria": "Revocar el token {{name}}", + "revokeWarning": "¿Revocar el token \"{{name}}\"? Todo lo que aún lo use dejará de funcionar de inmediato. Esta acción no se puede deshacer.", + "restrictionCount": { + "agents_one": "{{count}} agente", + "agents_other": "{{count}} agentes", + "sources_one": "{{count}} fuente", + "sources_other": "{{count}} fuentes", + "prompts_one": "{{count}} prompt", + "prompts_other": "{{count}} prompts", + "tools_one": "{{count}} herramienta", + "tools_other": "{{count}} herramientas", + "workflows_one": "{{count}} flujo de trabajo", + "workflows_other": "{{count}} flujos de trabajo" + }, + "relative": { + "now": "Ahora mismo", + "minutes_one": "Hace {{count}} minuto", + "minutes_other": "Hace {{count}} minutos", + "hours_one": "Hace {{count}} hora", + "hours_other": "Hace {{count}} horas", + "days_one": "Hace {{count}} día", + "days_other": "Hace {{count}} días" + }, + "families": { + "agents": "Agentes", + "sources": "Fuentes", + "prompts": "Prompts", + "tools": "Herramientas", + "models": "Modelos", + "workflows": "Flujos de trabajo", + "schedules": "Programaciones", + "conversations": "Conversaciones", + "analytics": "Analíticas", + "teams": "Equipos", + "chat": "Chat" + }, + "create": { + "title": "Crear token de acceso", + "subtitle": "El token actúa en tu nombre, limitado a los permisos y recursos que elijas aquí.", + "name": "Nombre", + "namePlaceholder": "p. ej. Pipeline de CI", + "expiration": "Caducidad", + "expiryDays_one": "{{count}} día", + "expiryDays_other": "{{count}} días", + "noExpiration": "Sin caducidad", + "noExpirationHint": "Los tokens que nunca caducan son más peligrosos si se filtran. Es preferible fijar una fecha de caducidad.", + "expiresOn": "Caduca el {{date}}", + "scopes": "Permisos", + "scopesHint": "Elige qué puede hacer este token. El acceso de escritura incluye el de lectura.", + "impliedByWrite": "Incluido con el acceso de escritura", + "restrict": "Restringir a recursos específicos", + "restrictHint": "Opcional. Limita el token a los elementos que selecciones. Deja una lista vacía para permitirlos todos.", + "restrictNoFamilies": "Selecciona primero un permiso de agentes, fuentes, prompts, herramientas o chat para restringir sus recursos.", + "allSelected": "Todos (sin restricción)", + "noResources": "No se encontró nada.", + "searchResources": "Buscar...", + "resourcesError": "No se pudo cargar la lista.", + "retry": "Reintentar", + "cancel": "Cancelar", + "submit": "Crear token", + "submitting": "Creando", + "error": "No se pudo crear el token. Inténtalo de nuevo." + }, + "created": { + "title": "Token creado", + "subtitle": "Tu nuevo token \"{{name}}\" está listo.", + "warning": "Copia el token ahora y guárdalo en un lugar seguro. Por motivos de seguridad no se volverá a mostrar.", + "tokenLabel": "Tu token", + "usageTitle": "Cómo usarlo", + "usageHint": "Expórtalo como variable de entorno y envíalo como token Bearer en la cabecera Authorization.", + "done": "Listo" + } + }, "scrollTabsLeft": "Desplazar pestañas a la izquierda", "tabsAriaLabel": "Pestañas de configuración", "scrollTabsRight": "Desplazar pestañas a la derecha" diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index dc022fe8..8c0bfc73 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "チャットボット", - "name": "名前", - "key": "APIキー", - "sourceDoc": "ソースドキュメント", - "createNew": "新規作成", - "noData": "既存のチャットボットはありません", - "deleteConfirmation": "APIキー '{{name}}' を削除してもよろしいですか?", - "description": "ここでチャットボットを作成・管理できます。チャットボットはウィジェットとしてウェブサイトに導入したり、アプリケーション内で使用したりすることができます。" - }, "analytics": { "label": "分析", "subtitle": "アカウント全体のメッセージ量、トークン使用量、ユーザーフィードバックを追跡", @@ -747,6 +737,104 @@ "testFailed": "接続テストに失敗しました" } }, + "accessTokens": { + "label": "アクセストークン", + "subtitle": "個人用アクセストークンを使うと、スクリプト、CLI、CIパイプラインがあなたに代わってDocsGPT APIを呼び出せます。各トークンには必要なスコープだけを付与してください。", + "createToken": "トークンを作成", + "disabledNotice": "このサーバーでは個人用アクセストークンを利用できません。アカウントでのサインインが必要で、管理者が有効にする必要があります。", + "limitReached_one": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。", + "limitReached_other": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。", + "loadError": "アクセストークンを読み込めませんでした。もう一度お試しください。", + "revokeError": "トークンを取り消せませんでした。もう一度お試しください。", + "empty": "アクセストークンはまだありません", + "emptyHint": "トークンを作成すると、スクリプトやコマンドラインツールからDocsGPT APIを利用できます。", + "name": "名前", + "scopes": "スコープ", + "resources": "リソース", + "createdAt": "作成日", + "lastUsed": "最終使用", + "expires": "有効期限", + "actions": "操作", + "allResources": "すべてのリソース", + "never": "なし", + "expired": "{{date}}に期限切れ", + "expiresSoon": "{{date}}に期限切れ予定", + "moreScopes": "他{{count}}件", + "showLess": "折りたたむ", + "revoke": "取り消し", + "revokeAria": "トークン{{name}}を取り消す", + "revokeWarning": "トークン「{{name}}」を取り消しますか?このトークンを使用しているものはすぐに動作しなくなります。この操作は元に戻せません。", + "restrictionCount": { + "agents_one": "エージェント{{count}}件", + "agents_other": "エージェント{{count}}件", + "sources_one": "ソース{{count}}件", + "sources_other": "ソース{{count}}件", + "prompts_one": "プロンプト{{count}}件", + "prompts_other": "プロンプト{{count}}件", + "tools_one": "ツール{{count}}件", + "tools_other": "ツール{{count}}件", + "workflows_one": "ワークフロー{{count}}件", + "workflows_other": "ワークフロー{{count}}件" + }, + "relative": { + "now": "たった今", + "minutes_one": "{{count}}分前", + "minutes_other": "{{count}}分前", + "hours_one": "{{count}}時間前", + "hours_other": "{{count}}時間前", + "days_one": "{{count}}日前", + "days_other": "{{count}}日前" + }, + "families": { + "agents": "エージェント", + "sources": "ソース", + "prompts": "プロンプト", + "tools": "ツール", + "models": "モデル", + "workflows": "ワークフロー", + "schedules": "スケジュール", + "conversations": "会話", + "analytics": "分析", + "teams": "チーム", + "chat": "チャット" + }, + "create": { + "title": "アクセストークンを作成", + "subtitle": "トークンはあなたとして動作し、ここで選択したスコープとリソースに制限されます。", + "name": "名前", + "namePlaceholder": "例: CIパイプライン", + "expiration": "有効期限", + "expiryDays_one": "{{count}}日", + "expiryDays_other": "{{count}}日", + "noExpiration": "無期限", + "noExpirationHint": "無期限のトークンは漏洩した場合のリスクが高くなります。有効期限の設定をおすすめします。", + "expiresOn": "{{date}}に期限切れ", + "scopes": "スコープ", + "scopesHint": "このトークンに許可する操作を選択します。書き込み権限には読み取り権限が含まれます。", + "impliedByWrite": "書き込み権限に含まれます", + "restrict": "特定のリソースに制限する", + "restrictHint": "任意。選択した項目だけにトークンを制限します。リストを空のままにすると、すべて許可されます。", + "restrictNoFamilies": "リソースを制限するには、まずエージェント、ソース、プロンプト、ツール、またはチャットのスコープを選択してください。", + "allSelected": "すべて(制限なし)", + "noResources": "見つかりませんでした。", + "searchResources": "検索...", + "resourcesError": "リストを読み込めませんでした。", + "retry": "再試行", + "cancel": "キャンセル", + "submit": "トークンを作成", + "submitting": "作成中", + "error": "トークンを作成できませんでした。もう一度お試しください。" + }, + "created": { + "title": "トークンを作成しました", + "subtitle": "新しいトークン「{{name}}」の準備ができました。", + "warning": "今すぐトークンをコピーして、安全な場所に保管してください。セキュリティ上の理由から、再度表示されることはありません。", + "tokenLabel": "あなたのトークン", + "usageTitle": "使い方", + "usageHint": "環境変数としてエクスポートし、AuthorizationヘッダーのBearerトークンとして送信します。", + "done": "完了" + } + }, "scrollTabsLeft": "タブを左にスクロール", "tabsAriaLabel": "設定タブ", "scrollTabsRight": "タブを右にスクロール" diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index dd7aef1e..ff29d056 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "API ключи", - "name": "Название", - "key": "API ключ", - "sourceDoc": "Источник документа", - "createNew": "Создать новый", - "noData": "Нет существующих чатботов", - "deleteConfirmation": "Вы уверены, что хотите удалить API ключ '{{name}}'?", - "description": "Здесь вы можете создавать и управлять чат-ботами. Чат-боты могут быть развернуты на веб-сайтах в виде виджетов или использоваться внутри ваших приложений." - }, "analytics": { "label": "Аналитика", "subtitle": "Отслеживайте объём сообщений, использование токенов и отзывы пользователей в вашем аккаунте", @@ -747,6 +737,124 @@ "testFailed": "Проверка соединения не удалась" } }, + "accessTokens": { + "label": "Токены доступа", + "subtitle": "Персональные токены доступа позволяют скриптам, CLI и CI-конвейерам обращаться к API DocsGPT от вашего имени. Выдавайте каждому токену только необходимые права.", + "createToken": "Создать токен", + "disabledNotice": "Персональные токены доступа недоступны на этом сервере. Для них нужен вход через учётную запись, и их должен включить администратор.", + "limitReached_one": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_few": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_many": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_other": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "loadError": "Не удалось загрузить токены доступа. Попробуйте ещё раз.", + "revokeError": "Не удалось отозвать токен. Попробуйте ещё раз.", + "empty": "Токенов доступа пока нет", + "emptyHint": "Создайте токен, чтобы использовать API DocsGPT из скриптов и инструментов командной строки.", + "name": "Название", + "scopes": "Права", + "resources": "Ресурсы", + "createdAt": "Создан", + "lastUsed": "Последнее использование", + "expires": "Истекает", + "actions": "Действия", + "allResources": "Все ресурсы", + "never": "Никогда", + "expired": "Истёк {{date}}", + "expiresSoon": "Истекает {{date}}", + "moreScopes": "ещё {{count}}", + "showLess": "Свернуть", + "revoke": "Отозвать", + "revokeAria": "Отозвать токен {{name}}", + "revokeWarning": "Отозвать токен «{{name}}»? Всё, что его использует, сразу перестанет работать. Это действие нельзя отменить.", + "restrictionCount": { + "agents_one": "{{count}} агент", + "agents_few": "{{count}} агента", + "agents_many": "{{count}} агентов", + "agents_other": "{{count}} агента", + "sources_one": "{{count}} источник", + "sources_few": "{{count}} источника", + "sources_many": "{{count}} источников", + "sources_other": "{{count}} источника", + "prompts_one": "{{count}} промпт", + "prompts_few": "{{count}} промпта", + "prompts_many": "{{count}} промптов", + "prompts_other": "{{count}} промпта", + "tools_one": "{{count}} инструмент", + "tools_few": "{{count}} инструмента", + "tools_many": "{{count}} инструментов", + "tools_other": "{{count}} инструмента", + "workflows_one": "{{count}} рабочий процесс", + "workflows_few": "{{count}} рабочих процесса", + "workflows_many": "{{count}} рабочих процессов", + "workflows_other": "{{count}} рабочего процесса" + }, + "relative": { + "now": "Только что", + "minutes_one": "{{count}} минуту назад", + "minutes_few": "{{count}} минуты назад", + "minutes_many": "{{count}} минут назад", + "minutes_other": "{{count}} минуты назад", + "hours_one": "{{count}} час назад", + "hours_few": "{{count}} часа назад", + "hours_many": "{{count}} часов назад", + "hours_other": "{{count}} часа назад", + "days_one": "{{count}} день назад", + "days_few": "{{count}} дня назад", + "days_many": "{{count}} дней назад", + "days_other": "{{count}} дня назад" + }, + "families": { + "agents": "Агенты", + "sources": "Источники", + "prompts": "Промпты", + "tools": "Инструменты", + "models": "Модели", + "workflows": "Рабочие процессы", + "schedules": "Расписания", + "conversations": "Диалоги", + "analytics": "Аналитика", + "teams": "Команды", + "chat": "Чат" + }, + "create": { + "title": "Создать токен доступа", + "subtitle": "Токен действует от вашего имени в пределах прав и ресурсов, которые вы выберете здесь.", + "name": "Название", + "namePlaceholder": "например, CI-конвейер", + "expiration": "Срок действия", + "expiryDays_one": "{{count}} день", + "expiryDays_few": "{{count}} дня", + "expiryDays_many": "{{count}} дней", + "expiryDays_other": "{{count}} дня", + "noExpiration": "Бессрочно", + "noExpirationHint": "Бессрочные токены опаснее при утечке. Лучше задать срок действия.", + "expiresOn": "Истекает {{date}}", + "scopes": "Права", + "scopesHint": "Выберите, что разрешено этому токену. Право на запись включает право на чтение.", + "impliedByWrite": "Входит в право на запись", + "restrict": "Ограничить определёнными ресурсами", + "restrictHint": "Необязательно. Ограничьте токен выбранными элементами. Оставьте список пустым, чтобы разрешить все.", + "restrictNoFamilies": "Сначала выберите право для агентов, источников, промптов, инструментов или чата, чтобы ограничить их ресурсы.", + "allSelected": "Все (без ограничений)", + "noResources": "Ничего не найдено.", + "searchResources": "Поиск...", + "resourcesError": "Не удалось загрузить список.", + "retry": "Повторить", + "cancel": "Отмена", + "submit": "Создать токен", + "submitting": "Создание", + "error": "Не удалось создать токен. Попробуйте ещё раз." + }, + "created": { + "title": "Токен создан", + "subtitle": "Ваш новый токен «{{name}}» готов.", + "warning": "Скопируйте токен сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.", + "tokenLabel": "Ваш токен", + "usageTitle": "Как использовать", + "usageHint": "Экспортируйте его как переменную окружения и передавайте как Bearer-токен в заголовке Authorization.", + "done": "Готово" + } + }, "scrollTabsLeft": "Прокрутить вкладки влево", "tabsAriaLabel": "Вкладки настроек", "scrollTabsRight": "Прокрутить вкладки вправо" diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 16236cec..1952b6e5 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "聊天機器人", - "name": "名稱", - "key": "API 金鑰", - "sourceDoc": "來源文件", - "createNew": "建立新的", - "noData": "沒有現有的聊天機器人", - "deleteConfirmation": "您確定要刪除 API 金鑰 '{{name}}' 嗎?", - "description": "在這裡,您可以創建和管理您的聊天機器人。聊天機器人可以作為小部件部署到網站上,或在您的應用程序中使用。" - }, "analytics": { "label": "分析", "subtitle": "追蹤您賃戶的訊息量、Token 使用量和使用者回饋", @@ -747,6 +737,104 @@ "testFailed": "連線測試失敗" } }, + "accessTokens": { + "label": "存取權杖", + "subtitle": "個人存取權杖可讓指令碼、CLI 和 CI 管線以您的身分呼叫 DocsGPT API。請只授予每個權杖所需的權限範圍。", + "createToken": "建立權杖", + "disabledNotice": "此伺服器不支援個人存取權杖。此功能需要使用帳戶登入,並須由管理員啟用。", + "limitReached_one": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。", + "limitReached_other": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。", + "loadError": "載入存取權杖失敗,請再試一次。", + "revokeError": "撤銷權杖失敗,請再試一次。", + "empty": "尚無存取權杖", + "emptyHint": "建立權杖後,即可在指令碼和命令列工具中使用 DocsGPT API。", + "name": "名稱", + "scopes": "權限範圍", + "resources": "資源", + "createdAt": "建立時間", + "lastUsed": "上次使用", + "expires": "到期時間", + "actions": "操作", + "allResources": "所有資源", + "never": "從不", + "expired": "已於 {{date}} 到期", + "expiresSoon": "將於 {{date}} 到期", + "moreScopes": "另外 {{count}} 項", + "showLess": "收合", + "revoke": "撤銷", + "revokeAria": "撤銷權杖 {{name}}", + "revokeWarning": "確定要撤銷權杖「{{name}}」嗎?所有仍在使用它的程式將立即失效,且此操作無法復原。", + "restrictionCount": { + "agents_one": "{{count}} 個代理", + "agents_other": "{{count}} 個代理", + "sources_one": "{{count}} 個來源", + "sources_other": "{{count}} 個來源", + "prompts_one": "{{count}} 個提示詞", + "prompts_other": "{{count}} 個提示詞", + "tools_one": "{{count}} 個工具", + "tools_other": "{{count}} 個工具", + "workflows_one": "{{count}} 個工作流程", + "workflows_other": "{{count}} 個工作流程" + }, + "relative": { + "now": "剛剛", + "minutes_one": "{{count}} 分鐘前", + "minutes_other": "{{count}} 分鐘前", + "hours_one": "{{count}} 小時前", + "hours_other": "{{count}} 小時前", + "days_one": "{{count}} 天前", + "days_other": "{{count}} 天前" + }, + "families": { + "agents": "代理", + "sources": "來源", + "prompts": "提示詞", + "tools": "工具", + "models": "模型", + "workflows": "工作流程", + "schedules": "排程", + "conversations": "對話", + "analytics": "分析", + "teams": "團隊", + "chat": "聊天" + }, + "create": { + "title": "建立存取權杖", + "subtitle": "權杖將以您的身分操作,但僅限於您在此選擇的權限範圍和資源。", + "name": "名稱", + "namePlaceholder": "例如:CI 管線", + "expiration": "有效期限", + "expiryDays_one": "{{count}} 天", + "expiryDays_other": "{{count}} 天", + "noExpiration": "永不到期", + "noExpirationHint": "永不到期的權杖一旦外洩風險更高,建議設定到期時間。", + "expiresOn": "將於 {{date}} 到期", + "scopes": "權限範圍", + "scopesHint": "選擇此權杖可以執行的操作。寫入權限包含讀取權限。", + "impliedByWrite": "已包含在寫入權限中", + "restrict": "限制為特定資源", + "restrictHint": "選填。將權杖限制為您選擇的項目。清單留空表示允許全部。", + "restrictNoFamilies": "請先選擇代理、來源、提示詞、工具或聊天的權限範圍,才能限制其資源。", + "allSelected": "全部(不限制)", + "noResources": "找不到任何內容。", + "searchResources": "搜尋...", + "resourcesError": "載入清單失敗。", + "retry": "重試", + "cancel": "取消", + "submit": "建立權杖", + "submitting": "建立中", + "error": "建立權杖失敗,請再試一次。" + }, + "created": { + "title": "權杖已建立", + "subtitle": "您的新權杖「{{name}}」已就緒。", + "warning": "請立即複製權杖並妥善保存。基於安全考量,它不會再次顯示。", + "tokenLabel": "您的權杖", + "usageTitle": "使用方式", + "usageHint": "將其匯出為環境變數,然後在 Authorization 標頭中作為 Bearer 權杖傳送。", + "done": "完成" + } + }, "scrollTabsLeft": "向左捲動標籤", "tabsAriaLabel": "設定標籤", "scrollTabsRight": "向右捲動標籤" diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 5b4cf5a6..74316ec5 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "聊天机器人", - "name": "名称", - "key": "API 密钥", - "sourceDoc": "源文档", - "createNew": "创建新的", - "noData": "没有现有的聊天机器人", - "deleteConfirmation": "您确定要删除 API 密钥 '{{name}}' 吗?", - "description": "在这里,您可以创建和管理您的聊天机器人。聊天机器人可以作为小部件部署到网站上,或在您的应用程序中使用。" - }, "analytics": { "label": "分析", "subtitle": "跟踪您账户的消息量、令牌使用情况和用户反馈", @@ -747,6 +737,104 @@ "testFailed": "连接测试失败" } }, + "accessTokens": { + "label": "访问令牌", + "subtitle": "个人访问令牌可让脚本、CLI 和 CI 流水线以您的身份调用 DocsGPT API。请只为每个令牌授予其所需的权限范围。", + "createToken": "创建令牌", + "disabledNotice": "此服务器不支持个人访问令牌。该功能需要使用账户登录,并须由管理员启用。", + "limitReached_one": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。", + "limitReached_other": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。", + "loadError": "加载访问令牌失败,请重试。", + "revokeError": "撤销令牌失败,请重试。", + "empty": "暂无访问令牌", + "emptyHint": "创建令牌后,即可在脚本和命令行工具中使用 DocsGPT API。", + "name": "名称", + "scopes": "权限范围", + "resources": "资源", + "createdAt": "创建时间", + "lastUsed": "上次使用", + "expires": "过期时间", + "actions": "操作", + "allResources": "所有资源", + "never": "从不", + "expired": "已于 {{date}} 过期", + "expiresSoon": "将于 {{date}} 过期", + "moreScopes": "另外 {{count}} 项", + "showLess": "收起", + "revoke": "撤销", + "revokeAria": "撤销令牌 {{name}}", + "revokeWarning": "确定要撤销令牌“{{name}}”吗?所有仍在使用它的程序将立即失效,且此操作无法撤回。", + "restrictionCount": { + "agents_one": "{{count}} 个代理", + "agents_other": "{{count}} 个代理", + "sources_one": "{{count}} 个来源", + "sources_other": "{{count}} 个来源", + "prompts_one": "{{count}} 个提示词", + "prompts_other": "{{count}} 个提示词", + "tools_one": "{{count}} 个工具", + "tools_other": "{{count}} 个工具", + "workflows_one": "{{count}} 个工作流", + "workflows_other": "{{count}} 个工作流" + }, + "relative": { + "now": "刚刚", + "minutes_one": "{{count}} 分钟前", + "minutes_other": "{{count}} 分钟前", + "hours_one": "{{count}} 小时前", + "hours_other": "{{count}} 小时前", + "days_one": "{{count}} 天前", + "days_other": "{{count}} 天前" + }, + "families": { + "agents": "代理", + "sources": "来源", + "prompts": "提示词", + "tools": "工具", + "models": "模型", + "workflows": "工作流", + "schedules": "计划任务", + "conversations": "对话", + "analytics": "分析", + "teams": "团队", + "chat": "聊天" + }, + "create": { + "title": "创建访问令牌", + "subtitle": "令牌将以您的身份操作,但仅限于您在此选择的权限范围和资源。", + "name": "名称", + "namePlaceholder": "例如:CI 流水线", + "expiration": "有效期", + "expiryDays_one": "{{count}} 天", + "expiryDays_other": "{{count}} 天", + "noExpiration": "永不过期", + "noExpirationHint": "永不过期的令牌一旦泄露风险更高,建议设置过期时间。", + "expiresOn": "将于 {{date}} 过期", + "scopes": "权限范围", + "scopesHint": "选择此令牌可以执行的操作。写入权限包含读取权限。", + "impliedByWrite": "已包含在写入权限中", + "restrict": "限制为特定资源", + "restrictHint": "可选。将令牌限制为您选择的项目。列表留空表示允许全部。", + "restrictNoFamilies": "请先选择代理、来源、提示词、工具或聊天的权限范围,才能限制其资源。", + "allSelected": "全部(不限制)", + "noResources": "未找到任何内容。", + "searchResources": "搜索...", + "resourcesError": "加载列表失败。", + "retry": "重试", + "cancel": "取消", + "submit": "创建令牌", + "submitting": "正在创建", + "error": "创建令牌失败,请重试。" + }, + "created": { + "title": "令牌已创建", + "subtitle": "您的新令牌“{{name}}”已就绪。", + "warning": "请立即复制令牌并妥善保存。出于安全考虑,它不会再次显示。", + "tokenLabel": "您的令牌", + "usageTitle": "使用方法", + "usageHint": "将其导出为环境变量,然后在 Authorization 请求头中作为 Bearer 令牌发送。", + "done": "完成" + } + }, "scrollTabsLeft": "向左滚动标签", "tabsAriaLabel": "设置标签", "scrollTabsRight": "向右滚动标签" diff --git a/frontend/src/modals/AccessTokenCreatedModal.tsx b/frontend/src/modals/AccessTokenCreatedModal.tsx new file mode 100644 index 00000000..66026edc --- /dev/null +++ b/frontend/src/modals/AccessTokenCreatedModal.tsx @@ -0,0 +1,104 @@ +import { TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { baseURL } from '../api/client'; +import CopyButton from '../components/CopyButton'; +import { Button } from '../components/ui/button'; +import { Modal } from '../components/ui/modal'; + +interface AccessTokenCreatedModalProps { + /** Plaintext secret; `null` keeps the modal closed. Held only in the parent's component state. */ + token: string | null; + name: string; + onClose: () => void; +} + +export default function AccessTokenCreatedModal({ + token, + name, + onClose, +}: AccessTokenCreatedModalProps) { + const { t } = useTranslation(); + const exportSnippet = `export DOCSGPT_TOKEN=${token ?? ''}`; + const curlSnippet = `curl -H "Authorization: Bearer $DOCSGPT_TOKEN" ${baseURL}/api/get_agents`; + + return ( + !o && onClose()} + hideTitle + title={t('settings.accessTokens.created.title')} + size="lg" + mobileVariant="sheet" + // The secret cannot be shown again, so a stray click outside must not + // dismiss it; closing takes the explicit button (or Esc). + isPerformingTask + footer={ + + } + > +
+
+

+ {t('settings.accessTokens.created.title')} +

+

+ {t('settings.accessTokens.created.subtitle', { name })} +

+
+ +
+
+ +
+

+ {t('settings.accessTokens.created.tokenLabel')} +

+
+ + {token} + + {token && } +
+
+ +
+

+ {t('settings.accessTokens.created.usageTitle')} +

+

+ {t('settings.accessTokens.created.usageHint')} +

+ {[exportSnippet, curlSnippet].map((snippet, index) => ( +
+
+                {snippet}
+              
+ +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/modals/CreateAccessTokenModal.tsx b/frontend/src/modals/CreateAccessTokenModal.tsx new file mode 100644 index 00000000..438403a8 --- /dev/null +++ b/frontend/src/modals/CreateAccessTokenModal.tsx @@ -0,0 +1,462 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenPolicy, + AccessTokenScope, + CreateAccessTokenResponse, +} from '../api/services/patService'; +import userService from '../api/services/userService'; +import Spinner from '../components/Spinner'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; +import { Modal } from '../components/ui/modal'; +import { MultiSelect } from '../components/ui/multi-select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { Switch } from '../components/ui/switch'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + buildResourceFilter, + defaultExpiry, + eligibleFilterFamilies, + expiryOptions, + groupScopesByFamily, + isScopeImplied, + NO_EXPIRY, + PICKER_FAMILIES, + ResourceOption, + scopesToSubmit, + toResourceOptions, +} from '../settings/accessTokenUtils'; +import { formatDateOnly } from '../utils/dateTimeUtils'; + +const MAX_NAME_LENGTH = 100; +const DAY_MS = 24 * 60 * 60 * 1000; + +type ResourceState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; options: ResourceOption[] }; + +const RESOURCE_FETCHERS: Record< + string, + (token: string | null) => Promise +> = { + agents: (token) => userService.getAgents(token), + sources: (token) => userService.getDocs(token), + prompts: (token) => userService.getPrompts(token), + tools: (token) => userService.getUserTools(token), +}; + +interface CreateAccessTokenModalProps { + open: boolean; + onClose: () => void; + scopes: AccessTokenScope[]; + policy: AccessTokenPolicy; + onCreated: (created: CreateAccessTokenResponse) => void; +} + +export default function CreateAccessTokenModal({ + open, + onClose, + scopes, + policy, + onCreated, +}: CreateAccessTokenModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + + const [name, setName] = React.useState(''); + const [selectedScopes, setSelectedScopes] = React.useState([]); + const [expiry, setExpiry] = React.useState(() => + defaultExpiry(policy), + ); + const [restrict, setRestrict] = React.useState(false); + const [resourceSelection, setResourceSelection] = React.useState< + Record + >({}); + const [resources, setResources] = React.useState< + Record + >({}); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + // Start from a clean form on every open; resource lists are refetched so a + // freshly created agent/source shows up without a page reload. + React.useEffect(() => { + if (!open) return; + setName(''); + setSelectedScopes([]); + setExpiry(defaultExpiry(policy)); + setRestrict(false); + setResourceSelection({}); + setResources({}); + setSubmitting(false); + setError(null); + }, [open, policy]); + + const scopeGroups = React.useMemo( + () => groupScopesByFamily(scopes), + [scopes], + ); + const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]); + const pickerFamilies = React.useMemo( + () => + eligibleFilterFamilies(selectedScopes, policy.filterable_families).filter( + (family) => PICKER_FAMILIES.includes(family), + ), + [selectedScopes, policy.filterable_families], + ); + + const loadResources = React.useCallback( + (family: string) => { + const fetcher = RESOURCE_FETCHERS[family]; + if (!fetcher) return; + setResources((prev) => ({ ...prev, [family]: { status: 'loading' } })); + fetcher(token) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const options = toResourceOptions(family, await response.json()); + setResources((prev) => ({ + ...prev, + [family]: { status: 'ready', options }, + })); + }) + .catch((err) => { + console.error(`Failed to load ${family}:`, err); + setResources((prev) => ({ ...prev, [family]: { status: 'error' } })); + }); + }, + [token], + ); + + // Lazy: a family's list is only fetched once its picker is actually shown. + React.useEffect(() => { + if (!open || !restrict) return; + pickerFamilies.forEach((family) => { + if (!resources[family]) loadResources(family); + }); + }, [open, restrict, pickerFamilies, resources, loadResources]); + + const toggleScope = (scope: string) => { + setError(null); + setSelectedScopes((prev) => + prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope], + ); + }; + + const familyLabel = (family: string) => + t(`settings.accessTokens.families.${family}`, { defaultValue: family }); + + const trimmedName = name.trim(); + const canSubmit = + trimmedName.length > 0 && selectedScopes.length > 0 && !submitting; + + const handleClose = () => { + if (submitting) return; + onClose(); + }; + + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + const created = await patService.create( + { + name: trimmedName, + scopes: scopesToSubmit(selectedScopes, scopes), + resource_filter: restrict + ? buildResourceFilter(resourceSelection, pickerFamilies) + : undefined, + expires_in_days: expiry, + }, + token, + ); + onCreated(created); + } catch (err) { + const message = err instanceof Error ? err.message : ''; + setError(message || t('settings.accessTokens.create.error')); + } finally { + setSubmitting(false); + } + }; + + const expiryLabel = (days: number) => + days === NO_EXPIRY + ? t('settings.accessTokens.create.noExpiration') + : t('settings.accessTokens.create.expiryDays', { count: days }); + + return ( + !o && handleClose()} + hideTitle + title={t('settings.accessTokens.create.title')} + size="lg" + mobileVariant="sheet" + isPerformingTask={submitting} + contentClassName="max-h-[65vh]" + footer={ + <> + + + + } + > +
{ + e.preventDefault(); + handleSubmit(); + }} + > +
+

+ {t('settings.accessTokens.create.title')} +

+

+ {t('settings.accessTokens.create.subtitle')} +

+
+ +
+
+ + { + setName(e.target.value); + setError(null); + }} + placeholder={t('settings.accessTokens.create.namePlaceholder')} + className="rounded-xl" + autoComplete="off" + /> +
+
+ + +

+ {expiry === NO_EXPIRY + ? t('settings.accessTokens.create.noExpirationHint') + : t('settings.accessTokens.create.expiresOn', { + date: formatDateOnly( + new Date(Date.now() + expiry * DAY_MS).toISOString(), + ), + })} +

+
+
+ +
+ + {t('settings.accessTokens.create.scopes')} + * + +

+ {t('settings.accessTokens.create.scopesHint')} +

+
+ {scopeGroups.map((group) => ( +
+

+ {familyLabel(group.family)} +

+
+ {group.scopes.map((scope) => { + const implied = isScopeImplied(scope.name, selectedScopes); + const checked = + implied || selectedScopes.includes(scope.name); + const id = `pat-scope-${scope.name}`; + return ( + + ); + })} +
+
+ ))} +
+
+ + {policy.filterable_families.length > 0 && ( +
+
+
+ +

+ {t('settings.accessTokens.create.restrictHint')} +

+
+ +
+ {restrict && pickerFamilies.length === 0 && ( +

+ {t('settings.accessTokens.create.restrictNoFamilies')} +

+ )} + {restrict && + pickerFamilies.map((family) => { + const state = resources[family]; + return ( +
+ + {!state || state.status === 'loading' ? ( +
+ +
+ ) : state.status === 'error' ? ( +
+ + {t('settings.accessTokens.create.resourcesError')} + + +
+ ) : ( + + setResourceSelection((prev) => ({ + ...prev, + [family]: ids, + })) + } + placeholder={t( + 'settings.accessTokens.create.allSelected', + )} + emptyText={t( + 'settings.accessTokens.create.noResources', + )} + searchPlaceholder={t( + 'settings.accessTokens.create.searchResources', + )} + className="rounded-xl" + /> + )} +
+ ); + })} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx new file mode 100644 index 00000000..d81416b0 --- /dev/null +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -0,0 +1,443 @@ +import { TriangleAlert } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenPolicy, + AccessTokenScope, + CreateAccessTokenResponse, + PersonalAccessToken, +} from '../api/services/patService'; +import NoFilesDarkIcon from '../assets/no-files-dark.svg'; +import NoFilesIcon from '../assets/no-files.svg'; +import SkeletonLoader from '../components/SkeletonLoader'; +import { Alert, AlertDescription } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { useDarkTheme } from '../hooks'; +import AccessTokenCreatedModal from '../modals/AccessTokenCreatedModal'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import CreateAccessTokenModal from '../modals/CreateAccessTokenModal'; +import { ActiveState } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; +import { + countLiveTokens, + expiryStatus, + relativeTime, + restrictionCounts, +} from './accessTokenUtils'; + +const VISIBLE_SCOPES = 3; + +function ScopeChips({ scopes }: { scopes: string[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = React.useState(false); + const visible = expanded ? scopes : scopes.slice(0, VISIBLE_SCOPES); + const hidden = scopes.length - visible.length; + + return ( +
+ {visible.map((scope) => ( + + {scope} + + ))} + {scopes.length > VISIBLE_SCOPES && ( + + )} +
+ ); +} + +export default function PersonalAccessTokens() { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [isDarkTheme] = useDarkTheme(); + + const [tokens, setTokens] = React.useState([]); + const [scopes, setScopes] = React.useState([]); + const [policy, setPolicy] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [createOpen, setCreateOpen] = React.useState(false); + // The plaintext secret lives only here, and only until the modal closes. + const [created, setCreated] = + React.useState(null); + const [revokeState, setRevokeState] = React.useState('INACTIVE'); + const [tokenToRevoke, setTokenToRevoke] = + React.useState(null); + + const loadTokens = React.useCallback( + async (showLoader: boolean) => { + if (showLoader) setLoading(true); + try { + const data = await patService.list(token); + setTokens(data.tokens ?? []); + setScopes(data.scopes ?? []); + setPolicy(data.policy ?? null); + setError(null); + } catch (err) { + console.error('Failed to load access tokens:', err); + setError(t('settings.accessTokens.loadError')); + } finally { + setLoading(false); + } + }, + [token, t], + ); + + React.useEffect(() => { + loadTokens(true); + }, [loadTokens]); + + const handleCreated = (response: CreateAccessTokenResponse) => { + setCreateOpen(false); + setCreated(response); + setTokens((prev) => [response.personal_access_token, ...prev]); + }; + + const requestRevoke = (item: PersonalAccessToken) => { + setTokenToRevoke(item); + setRevokeState('ACTIVE'); + }; + + const confirmRevoke = async () => { + if (!tokenToRevoke) return; + const target = tokenToRevoke; + try { + await patService.revoke(target.id, token); + setTokens((prev) => prev.filter((item) => item.id !== target.id)); + setError(null); + } catch (err) { + console.error('Failed to revoke access token:', err); + setError( + (err instanceof Error && err.message) || + t('settings.accessTokens.revokeError'), + ); + } finally { + setTokenToRevoke(null); + } + }; + + const limitReached = + !!policy && countLiveTokens(tokens) >= policy.max_per_user; + + const renderRestrictions = (item: PersonalAccessToken) => { + const counts = restrictionCounts(item.resource_filter); + if (counts.length === 0) return t('settings.accessTokens.allResources'); + return counts + .map(({ family, count }) => + t(`settings.accessTokens.restrictionCount.${family}`, { + count, + defaultValue: `${count} ${family}`, + }), + ) + .join(', '); + }; + + const renderLastUsed = (item: PersonalAccessToken) => { + const relative = relativeTime(item.last_used_at); + if (!relative || !item.last_used_at) { + return t('settings.accessTokens.never'); + } + const label = + relative.unit === 'date' + ? formatDateOnly(item.last_used_at) + : relative.unit === 'now' + ? t('settings.accessTokens.relative.now') + : t(`settings.accessTokens.relative.${relative.unit}`, { + count: relative.count, + }); + const details = [formatDateTime(item.last_used_at), item.last_used_ip] + .filter(Boolean) + .join(' · '); + return {label}; + }; + + const renderExpiry = (item: PersonalAccessToken) => { + const status = expiryStatus(item.expires_at); + if (status === 'never' || !item.expires_at) { + return t('settings.accessTokens.never'); + } + const date = formatDateOnly(item.expires_at); + if (status === 'ok') return date; + const expired = status === 'expired'; + return ( + + + ); + }; + + const renderRevokeButton = (item: PersonalAccessToken) => ( + + ); + + const renderPrefix = (item: PersonalAccessToken) => ( + + {item.token_prefix}… + + ); + + const renderEmptyState = () => ( +
+ +

+ {t('settings.accessTokens.empty')} +

+ {policy?.enabled && ( +

+ {t('settings.accessTokens.emptyHint')} +

+ )} +
+ ); + + const mobileField = (label: string, value: React.ReactNode) => ( +
+ {label} + + {value} + +
+ ); + + return ( +
+
+
+

+ {t('settings.accessTokens.subtitle')} +

+ {policy?.enabled && ( + + )} +
+ + {policy && !policy.enabled && ( + + + {t('settings.accessTokens.disabledNotice')} + + + )} + {policy?.enabled && limitReached && ( +

+ {t('settings.accessTokens.limitReached', { + count: policy.max_per_user, + })} +

+ )} + {error && ( + + {error} + + )} + +
+ + {loading ? ( + + ) : tokens.length === 0 ? ( + !error && renderEmptyState() + ) : ( + <> + {/* Desktop: table */} + + + + + {t('settings.accessTokens.name')} + + {t('settings.accessTokens.scopes')} + + + {t('settings.accessTokens.resources')} + + + {t('settings.accessTokens.createdAt')} + + + {t('settings.accessTokens.lastUsed')} + + + {t('settings.accessTokens.expires')} + + + + {t('settings.accessTokens.actions')} + + + + + + {tokens.map((item) => ( + + +

+ {item.name} +

+ {renderPrefix(item)} +
+ + + + + {renderRestrictions(item)} + + + {item.created_at + ? formatDateOnly(item.created_at) + : '-'} + + + {renderLastUsed(item)} + + + {renderExpiry(item)} + + + {renderRevokeButton(item)} + +
+ ))} +
+
+
+ + {/* Mobile / tablet: cards */} +
    + {tokens.map((item) => ( +
  • +
    +
    +

    + {item.name} +

    + {renderPrefix(item)} +
    + {renderRevokeButton(item)} +
    + +
    + {mobileField( + t('settings.accessTokens.resources'), + renderRestrictions(item), + )} + {mobileField( + t('settings.accessTokens.createdAt'), + item.created_at ? formatDateOnly(item.created_at) : '-', + )} + {mobileField( + t('settings.accessTokens.lastUsed'), + renderLastUsed(item), + )} + {mobileField( + t('settings.accessTokens.expires'), + renderExpiry(item), + )} +
    +
  • + ))} +
+ + )} +
+ + {policy && ( + setCreateOpen(false)} + scopes={scopes} + policy={policy} + onCreated={handleCreated} + /> + )} + setCreated(null)} + /> + +
+ ); +} diff --git a/frontend/src/settings/accessTokenUtils.test.ts b/frontend/src/settings/accessTokenUtils.test.ts new file mode 100644 index 00000000..f58f95e7 --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildResourceFilter, + countLiveTokens, + defaultExpiry, + eligibleFilterFamilies, + expiryOptions, + expiryStatus, + groupScopesByFamily, + isScopeImplied, + isUuid, + NO_EXPIRY, + relativeTime, + restrictionCounts, + scopesToSubmit, + toResourceOptions, +} from './accessTokenUtils'; + +const CATALOG = [ + { name: 'agents:read', description: 'View agents' }, + { name: 'agents:write', description: 'Edit agents' }, + { name: 'agents:keys', description: 'Agent keys' }, + { name: 'sources:read', description: 'View sources' }, + { name: 'analytics:read', description: 'View analytics' }, + { name: 'chat:run', description: 'Ask agents' }, +]; +const FAMILIES = ['agents', 'sources', 'prompts', 'tools', 'workflows']; +const POLICY = { + default_lifetime_days: 90, + max_lifetime_days: 365, + allow_non_expiring: false, +}; +const NOW = Date.parse('2026-09-19T12:00:00Z'); +const DAY = 24 * 60 * 60 * 1000; +const ID_A = '11111111-1111-4111-8111-111111111111'; +const ID_B = '22222222-2222-4222-8222-222222222222'; + +describe('groupScopesByFamily', () => { + it('groups by family in catalog order', () => { + const groups = groupScopesByFamily(CATALOG); + expect(groups.map((g) => g.family)).toEqual([ + 'agents', + 'sources', + 'analytics', + 'chat', + ]); + expect(groups[0].scopes.map((s) => s.name)).toEqual([ + 'agents:read', + 'agents:write', + 'agents:keys', + ]); + }); +}); + +describe('scope implication', () => { + it('write implies read of the same family only', () => { + expect(isScopeImplied('agents:read', ['agents:write'])).toBe(true); + expect(isScopeImplied('sources:read', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:keys', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:write', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:read', ['agents:read'])).toBe(false); + }); + + it('scopesToSubmit drops implied reads and keeps catalog order', () => { + expect( + scopesToSubmit( + ['chat:run', 'agents:write', 'agents:read', 'sources:read'], + CATALOG, + ), + ).toEqual(['agents:write', 'sources:read', 'chat:run']); + }); + + it('scopesToSubmit ignores scopes missing from the catalog', () => { + expect(scopesToSubmit(['bogus:read', 'agents:read'], CATALOG)).toEqual([ + 'agents:read', + ]); + }); +}); + +describe('eligibleFilterFamilies', () => { + it('only offers families that have a selected scope', () => { + expect(eligibleFilterFamilies(['sources:read'], FAMILIES)).toEqual([ + 'sources', + ]); + expect(eligibleFilterFamilies(['analytics:read'], FAMILIES)).toEqual([]); + expect(eligibleFilterFamilies([], FAMILIES)).toEqual([]); + }); + + it('chat:run additionally permits agents and sources', () => { + expect(eligibleFilterFamilies(['chat:run'], FAMILIES)).toEqual([ + 'agents', + 'sources', + ]); + }); + + it('respects the families the server says are filterable', () => { + expect(eligibleFilterFamilies(['chat:run'], ['sources'])).toEqual([ + 'sources', + ]); + }); +}); + +describe('buildResourceFilter', () => { + it('drops empty and ineligible families', () => { + expect( + buildResourceFilter({ agents: [ID_A], sources: [], tools: [ID_B] }, [ + 'agents', + 'sources', + ]), + ).toEqual({ agents: [ID_A] }); + }); + + it('returns undefined when nothing is restricted', () => { + expect(buildResourceFilter({ agents: [] }, ['agents'])).toBeUndefined(); + expect(buildResourceFilter({}, [])).toBeUndefined(); + }); +}); + +describe('restrictionCounts', () => { + it('counts ids per family', () => { + expect( + restrictionCounts({ agents: [ID_A, ID_B], sources: [ID_A] }), + ).toEqual([ + { family: 'agents', count: 2 }, + { family: 'sources', count: 1 }, + ]); + }); + + it('is empty ("all resources") for no filter', () => { + expect(restrictionCounts({})).toEqual([]); + expect(restrictionCounts(null)).toEqual([]); + expect(restrictionCounts({ agents: [] })).toEqual([]); + }); +}); + +describe('expiryOptions / defaultExpiry', () => { + it('offers every preset up to the maximum', () => { + expect(expiryOptions(POLICY)).toEqual([7, 30, 60, 90, 180, 365]); + expect(defaultExpiry(POLICY)).toBe(90); + }); + + it('filters presets above max_lifetime_days', () => { + expect(expiryOptions({ ...POLICY, max_lifetime_days: 90 })).toEqual([ + 7, 30, 60, 90, + ]); + }); + + it('adds a non-preset default in sorted position', () => { + const policy = { ...POLICY, default_lifetime_days: 45 }; + expect(expiryOptions(policy)).toEqual([7, 30, 45, 60, 90, 180, 365]); + expect(defaultExpiry(policy)).toBe(45); + }); + + it('appends "no expiration" only when allowed', () => { + const options = expiryOptions({ ...POLICY, allow_non_expiring: true }); + expect(options[options.length - 1]).toBe(NO_EXPIRY); + expect(expiryOptions(POLICY)).not.toContain(NO_EXPIRY); + }); + + it('falls back to the longest allowed lifetime for an out-of-range default', () => { + const policy = { + ...POLICY, + default_lifetime_days: 400, + max_lifetime_days: 60, + }; + expect(expiryOptions(policy)).toEqual([7, 30, 60]); + expect(defaultExpiry(policy)).toBe(60); + }); + + it('never returns an empty list when the maximum is below every preset', () => { + const policy = { + ...POLICY, + default_lifetime_days: 90, + max_lifetime_days: 3, + }; + expect(expiryOptions(policy)).toEqual([3]); + expect(defaultExpiry(policy)).toBe(3); + }); +}); + +describe('expiryStatus', () => { + const at = (offset: number) => new Date(NOW + offset).toISOString(); + + it('classifies expiry relative to now', () => { + expect(expiryStatus(null, NOW)).toBe('never'); + expect(expiryStatus(at(-1000), NOW)).toBe('expired'); + expect(expiryStatus(at(3 * DAY), NOW)).toBe('expiringSoon'); + expect(expiryStatus(at(7 * DAY), NOW)).toBe('expiringSoon'); + expect(expiryStatus(at(7 * DAY + 60_000), NOW)).toBe('ok'); + }); + + it('does not flag an unparseable date', () => { + expect(expiryStatus('not-a-date', NOW)).toBe('ok'); + }); + + it('countLiveTokens skips expired tokens, like the server cap', () => { + expect( + countLiveTokens( + [ + { expires_at: null }, + { expires_at: at(DAY) }, + { expires_at: at(-DAY) }, + ], + NOW, + ), + ).toBe(2); + }); +}); + +describe('relativeTime', () => { + const ago = (ms: number) => new Date(NOW - ms).toISOString(); + + it('buckets past timestamps', () => { + expect(relativeTime(null, NOW)).toBeNull(); + expect(relativeTime('garbage', NOW)).toBeNull(); + expect(relativeTime(ago(20_000), NOW)).toEqual({ unit: 'now' }); + expect(relativeTime(ago(5 * 60_000), NOW)).toEqual({ + unit: 'minutes', + count: 5, + }); + expect(relativeTime(ago(3 * 3_600_000), NOW)).toEqual({ + unit: 'hours', + count: 3, + }); + expect(relativeTime(ago(2 * DAY), NOW)).toEqual({ unit: 'days', count: 2 }); + expect(relativeTime(ago(45 * DAY), NOW)).toEqual({ unit: 'date' }); + }); + + it('treats clock skew into the future as "now"', () => { + expect(relativeTime(ago(-30_000), NOW)).toEqual({ unit: 'now' }); + }); +}); + +describe('toResourceOptions', () => { + it('accepts only UUID ids', () => { + expect(isUuid(ID_A)).toBe(true); + expect(isUuid('default')).toBe(false); + expect(isUuid(undefined)).toBe(false); + }); + + it('keeps own items with UUID ids, sorted by label', () => { + expect( + toResourceOptions('sources', [ + { id: ID_B, name: 'Zeta' }, + { id: 'default', name: 'Default' }, + { id: ID_A, name: 'Alpha', ownership: 'user' }, + { + id: '33333333-3333-4333-8333-333333333333', + name: 'Team', + ownership: 'team', + }, + ]), + ).toEqual([ + { value: ID_A, label: 'Alpha' }, + { value: ID_B, label: 'Zeta' }, + ]); + }); + + it('skips built-in and team prompts', () => { + expect( + toResourceOptions('prompts', [ + { id: 'default', name: 'default', type: 'public' }, + { id: ID_A, name: 'Mine', type: 'private' }, + { id: ID_B, name: 'Shared', type: 'team' }, + ]), + ).toEqual([{ value: ID_A, label: 'Mine' }]); + }); + + it('reads tools from the {tools: []} envelope and prefers the custom name', () => { + expect( + toResourceOptions('tools', { + success: true, + tools: [ + { + id: ID_A, + name: 'brave', + displayName: 'Brave', + customName: 'My search', + }, + ], + }), + ).toEqual([{ value: ID_A, label: 'My search' }]); + }); + + it('returns nothing for an error body', () => { + expect(toResourceOptions('agents', { success: false })).toEqual([]); + }); +}); diff --git a/frontend/src/settings/accessTokenUtils.ts b/frontend/src/settings/accessTokenUtils.ts new file mode 100644 index 00000000..134d347d --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.ts @@ -0,0 +1,224 @@ +import type { + AccessTokenPolicy, + AccessTokenScope, +} from '../api/services/patService'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Lifetimes (in days) offered in the create form, before policy filtering. */ +export const EXPIRY_PRESETS = [7, 30, 60, 90, 180, 365]; +/** `expires_in_days` value the server reads as "never expires". */ +export const NO_EXPIRY = 0; +/** Tokens expiring within this many days get a warning style. */ +export const EXPIRY_WARNING_DAYS = 7; +/** Families `chat:run` acts on, so they may be restricted alongside it. */ +const CHAT_FAMILIES = ['agents', 'sources']; + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** The server only accepts UUIDs in `resource_filter` (built-ins like `default` are not). */ +export const isUuid = (value: unknown): value is string => + typeof value === 'string' && UUID_REGEX.test(value); + +export const scopeFamily = (scope: string): string => scope.split(':')[0]; +export const scopeAction = (scope: string): string => scope.split(':')[1] ?? ''; + +export interface ScopeGroup { + family: string; + scopes: AccessTokenScope[]; +} + +/** Group the server's scope catalog by family, keeping the server's order. */ +export function groupScopesByFamily(catalog: AccessTokenScope[]): ScopeGroup[] { + const groups: ScopeGroup[] = []; + const byFamily = new Map(); + catalog.forEach((scope) => { + const family = scopeFamily(scope.name); + let group = byFamily.get(family); + if (!group) { + group = { family, scopes: [] }; + byFamily.set(family, group); + groups.push(group); + } + group.scopes.push(scope); + }); + return groups; +} + +/** `x:read` is implied (granted server-side) whenever `x:write` is selected. */ +export function isScopeImplied(scope: string, selected: string[]): boolean { + return ( + scopeAction(scope) === 'read' && + selected.includes(`${scopeFamily(scope)}:write`) + ); +} + +/** + * Scopes to send: the selection minus reads that a selected write already + * implies, in catalog order. The raw selection is kept separately so + * unticking write restores whatever read was before. + */ +export function scopesToSubmit( + selected: string[], + catalog: AccessTokenScope[], +): string[] { + const chosen = new Set(selected); + return catalog + .map((scope) => scope.name) + .filter((name) => chosen.has(name) && !isScopeImplied(name, selected)); +} + +/** Filterable families the selected scopes allow restricting, in policy order. */ +export function eligibleFilterFamilies( + selected: string[], + filterableFamilies: string[], +): string[] { + const families = new Set(selected.map(scopeFamily)); + if (families.has('chat')) CHAT_FAMILIES.forEach((f) => families.add(f)); + return filterableFamilies.filter((family) => families.has(family)); +} + +/** Drop empty and ineligible families; `undefined` when nothing is restricted. */ +export function buildResourceFilter( + selection: Record, + eligibleFamilies: string[], +): Record | undefined { + const out: Record = {}; + eligibleFamilies.forEach((family) => { + const ids = selection[family]; + if (ids && ids.length > 0) out[family] = [...ids]; + }); + return Object.keys(out).length > 0 ? out : undefined; +} + +export interface RestrictionCount { + family: string; + count: number; +} + +/** Per-family counts of a token's `resource_filter`; empty means "all resources". */ +export function restrictionCounts( + resourceFilter: Record | null | undefined, +): RestrictionCount[] { + return Object.entries(resourceFilter ?? {}) + .filter(([, ids]) => Array.isArray(ids) && ids.length > 0) + .map(([family, ids]) => ({ family, count: ids.length })); +} + +type ExpiryPolicy = Pick< + AccessTokenPolicy, + 'default_lifetime_days' | 'max_lifetime_days' | 'allow_non_expiring' +>; + +/** + * Lifetimes (days) to offer: presets within the server maximum, the server + * default when it isn't a preset, and `NO_EXPIRY` last when allowed. + */ +export function expiryOptions(policy: ExpiryPolicy): number[] { + const max = policy.max_lifetime_days; + const days = new Set(EXPIRY_PRESETS.filter((d) => d <= max)); + const fallback = policy.default_lifetime_days; + if (fallback > 0 && fallback <= max) days.add(fallback); + // Never leave the select empty (e.g. max below the smallest preset). + if (days.size === 0 && max > 0) days.add(max); + const options = Array.from(days).sort((a, b) => a - b); + if (policy.allow_non_expiring) options.push(NO_EXPIRY); + return options; +} + +/** The option preselected in the form: the server default, clamped to what's offered. */ +export function defaultExpiry(policy: ExpiryPolicy): number { + const options = expiryOptions(policy); + if (options.includes(policy.default_lifetime_days)) { + return policy.default_lifetime_days; + } + const finite = options.filter((d) => d !== NO_EXPIRY); + return finite.length > 0 ? finite[finite.length - 1] : NO_EXPIRY; +} + +export type ExpiryStatus = 'never' | 'expired' | 'expiringSoon' | 'ok'; + +export function expiryStatus( + expiresAt: string | null | undefined, + now: number = Date.now(), +): ExpiryStatus { + if (!expiresAt) return 'never'; + const at = Date.parse(expiresAt); + if (Number.isNaN(at)) return 'ok'; + if (at <= now) return 'expired'; + if (at - now <= EXPIRY_WARNING_DAYS * DAY_MS) return 'expiringSoon'; + return 'ok'; +} + +/** Tokens that count against `max_per_user`: the server ignores expired ones. */ +export function countLiveTokens( + tokens: { expires_at: string | null }[], + now: number = Date.now(), +): number { + return tokens.filter((tk) => expiryStatus(tk.expires_at, now) !== 'expired') + .length; +} + +export type RelativeTime = + | { unit: 'now' } + | { unit: 'minutes' | 'hours' | 'days'; count: number } + | { unit: 'date' }; + +/** Bucket a past timestamp for display; older than 30 days falls back to a date. */ +export function relativeTime( + value: string | null | undefined, + now: number = Date.now(), +): RelativeTime | null { + if (!value) return null; + const at = Date.parse(value); + if (Number.isNaN(at)) return null; + const minutes = Math.floor(Math.max(0, now - at) / 60_000); + if (minutes < 1) return { unit: 'now' }; + if (minutes < 60) return { unit: 'minutes', count: minutes }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { unit: 'hours', count: hours }; + const days = Math.floor(hours / 24); + if (days <= 30) return { unit: 'days', count: days }; + return { unit: 'date' }; +} + +export interface ResourceOption { + value: string; + label: string; +} + +/** Families the create form can offer a picker for (workflows have no list endpoint). */ +export const PICKER_FAMILIES = ['agents', 'sources', 'prompts', 'tools']; + +/** + * Turn a list-endpoint payload into picker options: only the caller's own + * items (not team-shared or built-in ones) with ids the server will accept. + */ +export function toResourceOptions( + family: string, + payload: unknown, +): ResourceOption[] { + const envelope = payload as { tools?: unknown } | null; + const rows: unknown[] = Array.isArray(payload) + ? payload + : family === 'tools' && Array.isArray(envelope?.tools) + ? envelope.tools + : []; + return (rows as (Record | null)[]) + .filter( + (row): row is Record => + !!row && + isUuid(row.id) && + row.ownership !== 'team' && + !( + family === 'prompts' && + (row.type === 'public' || row.type === 'team') + ), + ) + .map((row) => ({ + value: row.id as string, + label: String(row.customName || row.displayName || row.name || row.id), + })) + .sort((a, b) => a.label.localeCompare(b.label)); +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 1087ce30..f7ccf5f0 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -28,6 +28,7 @@ import CustomModels from './CustomModels'; import Sources from './Sources'; import General from './General'; import Logs from './Logs'; +import PersonalAccessTokens from './PersonalAccessTokens'; import Tools from './Tools'; type HiddenGradientType = 'left' | 'right' | undefined; @@ -47,6 +48,8 @@ export default function Settings() { if (path.includes('/settings/tools')) return t('settings.tools.label'); if (path.includes('/settings/custom-models')) return t('settings.customModels.label'); + if (path.includes('/settings/access-tokens')) + return t('settings.accessTokens.label'); return t('settings.general.label'); }; @@ -58,6 +61,7 @@ export default function Settings() { t('settings.logs.label'), t('settings.tools.label'), t('settings.customModels.label'), + t('settings.accessTokens.label'), ]; const [hiddenGradient, setHiddenGradient] = useState('left'); @@ -93,6 +97,8 @@ export default function Settings() { else if (tab === t('settings.tools.label')) navigate('/settings/tools'); else if (tab === t('settings.customModels.label')) navigate('/settings/custom-models'); + else if (tab === t('settings.accessTokens.label')) + navigate('/settings/access-tokens'); }; React.useEffect(() => { @@ -198,6 +204,7 @@ export default function Settings() { element={} /> } /> + } /> } />
diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 3b1139e2..fe662e7b 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -6,21 +6,8 @@ export type ChunkType = { metadata: { [key: string]: string }; }; -export type APIKeyData = { - id: string; - name: string; - key: string; - source: string; - prompt_id: string; - chunks: string; -}; - export type LogEventType = - | 'chat' - | 'schedule' - | 'webhook' - | 'workflow' - | 'system'; + 'chat' | 'schedule' | 'webhook' | 'workflow' | 'system'; export type LogData = { id: string; From 17d66c061d2815bfdb7558ec32a88c63d2a96485 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:58 +0100 Subject: [PATCH 06/13] docs: personal access tokens guide --- docs/content/Extensions/_meta.js | 4 + .../Extensions/personal-access-tokens.mdx | 181 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/content/Extensions/personal-access-tokens.mdx diff --git a/docs/content/Extensions/_meta.js b/docs/content/Extensions/_meta.js index c265957e..af42438c 100644 --- a/docs/content/Extensions/_meta.js +++ b/docs/content/Extensions/_meta.js @@ -3,6 +3,10 @@ export default { "title": "🔑 Getting API key", "href": "/Extensions/api-key-guide" }, + "personal-access-tokens": { + "title": "🎟️ Personal Access Tokens", + "href": "/Extensions/personal-access-tokens" + }, "chat-widget": { "title": "💬️ Chat Widget", "href": "/Extensions/chat-widget" diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx new file mode 100644 index 00000000..24ab642d --- /dev/null +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -0,0 +1,181 @@ +--- +title: Personal Access Tokens +description: Scoped, revocable API tokens for managing agents, sources and other DocsGPT resources from the CLI, scripts and CI/CD pipelines. +--- + +# Personal Access Tokens + +A personal access token (PAT) lets a script, the [DocsGPT CLI](https://github.com/arc53/DocsGPT-cli) or a CI/CD pipeline act on your account without a browser session. Unlike an [agent API key](/Extensions/api-key-guide), which can only talk to one agent, a PAT manages resources: it can create and update agents, upload sources, edit prompts and tools, and run agents for benchmarking. + +Every token is limited in three ways: + +- **Scopes** decide which parts of the API the token may call. +- **Resource restrictions** (optional) narrow a token to specific agents, sources, prompts, tools or workflows. +- **Expiry** ends the token's life automatically. + +## Creating a token + +1. Open **Settings → Access Tokens** in the DocsGPT web app. +2. Choose **Create token**, give it a name, and select the scopes it needs. +3. Optionally restrict it to specific resources and pick an expiry. +4. Copy the token. It starts with `dgpt_pat_` and is shown **once**. DocsGPT stores only a hash of it, so a lost token cannot be recovered. Revoke it and create a new one. + +Tokens can only be created and revoked from a signed-in session. A token cannot create, list or revoke tokens, so a leaked token cannot mint a replacement for itself. + +## Using a token + +Send the token as a bearer credential: + +```bash +export DOCSGPT_URL=https://docsgpt.example.com +export DOCSGPT_TOKEN=dgpt_pat_... + +curl -H "Authorization: Bearer $DOCSGPT_TOKEN" "$DOCSGPT_URL/api/user/me" +``` + +`GET /api/user/me` works with any valid token and reports what the token may do, which makes it a convenient first step in a pipeline: + +```json +{ + "success": true, + "user_id": "alice@example.com", + "roles": ["user"], + "auth_method": "pat", + "token": { + "id": "0b6c...", + "name": "ci-deploy", + "scopes": ["agents:read", "agents:write"], + "resource_filter": {} + } +} +``` + +### Applying agent definitions + +Agents can be exported to YAML and applied back, which makes them reviewable and deployable like any other configuration. With the CLI: + +```bash +docsgpt-cli agents export -o support-bot.agent.yaml +docsgpt-cli agents apply -f support-bot.agent.yaml --dry-run +docsgpt-cli agents apply -f support-bot.agent.yaml +``` + +Or with the API directly (`agents:write`): + +```bash +curl -X POST "$DOCSGPT_URL/api/import_agent/plan" \ + -H "Authorization: Bearer $DOCSGPT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -Rs '{yaml: .}' support-bot.agent.yaml)" +``` + +`/api/import_agent/plan` is a dry run that reports whether the agent would be created or updated and how each referenced source, tool and prompt resolves. `/api/import_agent` applies it. An agent is matched by `metadata.id`, then `metadata.slug`; when nothing matches, a new draft agent is created. + +### GitHub Actions example + +```yaml +jobs: + deploy-agents: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Apply agent definitions + env: + DOCSGPT_URL: ${{ vars.DOCSGPT_URL }} + DOCSGPT_TOKEN: ${{ secrets.DOCSGPT_TOKEN }} + run: docsgpt-cli agents apply -f agents/ +``` + +## Scopes + +A `write` scope includes the matching `read` scope. + +| Scope | Allows | +| --- | --- | +| `agents:read` | View agents, folders, guardrail events and export agent definitions | +| `agents:write` | Create, update, delete, share and import (apply) agents and folders | +| `agents:keys` | Regenerate agent API keys and read incoming webhook URLs | +| `sources:read` | View sources, their files, chunks and ingestion task status | +| `sources:write` | Upload, ingest, sync, edit and delete sources and chunks | +| `prompts:read` / `prompts:write` | View / create, update and delete prompts | +| `tools:read` / `tools:write` | View / create, update and delete tools and MCP servers | +| `models:read` / `models:write` | View models / manage custom models | +| `workflows:read` / `workflows:write` | View / create, update and delete workflows | +| `schedules:read` / `schedules:write` | View / create, update, run and delete agent schedules | +| `conversations:read` / `conversations:write` | View / rename, delete and rate conversations | +| `analytics:read` | View usage analytics and logs | +| `teams:read` | View teams, members and resource shares | +| `chat:run` | Ask agents and search sources (`/api/answer`, `/stream`, `/api/search`); used for benchmarking | + +`agents:keys` is separate from `agents:write` on purpose: a deployment token that updates agents does not need to be able to read or rotate the secrets other systems use to call them. + +Some parts of the API are never available to a token, whatever its scopes: token management, the admin API, team management, sign-in flows, device pairing, and the interactive OAuth handshakes used by connectors and MCP servers. A token also never carries the `admin` role, even when its owner is an admin. + +Authorization is deny by default. An endpoint that is not explicitly mapped to a scope cannot be called with a token, and answers `403` with `"error": "not_available_to_tokens"`. A mapped endpoint called without the scope answers `403` with `"error": "insufficient_scope"` and names the `required_scope`. + +## Resource restrictions + +A token can be narrowed to specific resources in any of these families: `agents`, `sources`, `prompts`, `tools`, `workflows`. A family that is not listed stays unrestricted within the token's scopes. + +```json +{ + "name": "support-bot-deploy", + "scopes": ["agents:write", "chat:run"], + "resource_filter": { "agents": ["3f0e8f0c-5a53-4f0e-9a39-0e5f4f8d2c11"] }, + "expires_in_days": 30 +} +``` + +For a restricted family the token: + +- can read, update and delete only the listed resources, and listings show only those; +- **cannot create** new resources of that family, since a new resource would be outside the list; +- cannot attach a resource outside the list to something else, for example set an agent's source to a source the token may not use; +- is refused (`403`, `"error": "resource_not_allowed"`) wherever DocsGPT cannot prove the request stays inside the list. Schedules, conversations and analytics are closed to agent-restricted tokens for that reason, and `/api/sources/paginated` is closed to source-restricted tokens (use `/api/sources`). + +Restrictions and chat (`chat:run`): + +- A token restricted to specific **agents** must pass `agent_id` in the request body, and it must be one of the listed agents. An agent `api_key` in the body is refused. +- A token restricted to specific **sources** (but not agents) may chat against those sources with `active_docs`. It cannot run agents, because an agent brings its own sources. Restrict the token to agents instead to allow that. + +Restrictions and `agents apply`: a token restricted to specific agents can apply a definition only when it updates one of those agents. A token restricted on sources, prompts, tools or workflows cannot import agents at all, because an import resolves those references by name and may create them. + +## Expiry and revocation + +- A token created without an explicit lifetime expires after `PAT_DEFAULT_LIFETIME_DAYS` (90 by default). Users can choose any lifetime up to `PAT_MAX_LIFETIME_DAYS` (365 by default). +- Non-expiring tokens are available only when the operator sets `PAT_ALLOW_NON_EXPIRING=true`. +- Revoking a token in **Settings → Access Tokens** takes effect on the next request. +- Admins can list a user's tokens with `GET /api/admin/users//tokens` and revoke any token with `DELETE /api/admin/tokens/`. The admin **revoke sessions** action also revokes all of that user's tokens. +- Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated. +- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins. + +Each user may hold up to `PAT_MAX_PER_USER` live tokens (25 by default). The token list shows when and from which IP address each token was last used. + +## Operator settings + +| Setting | Default | Purpose | +| --- | --- | --- | +| `PAT_ENABLED` | `true` | Allow users to create and use personal access tokens | +| `PAT_DEFAULT_LIFETIME_DAYS` | `90` | Lifetime of a token created without an explicit expiry | +| `PAT_MAX_LIFETIME_DAYS` | `365` | Longest lifetime a user may request | +| `PAT_ALLOW_NON_EXPIRING` | `false` | Let users create tokens that never expire | +| `PAT_MAX_PER_USER` | `25` | Maximum number of live tokens per user | + +Personal access tokens need a stable user identity, so they are available with `AUTH_TYPE=oidc` and with authentication disabled (single-user self-hosting). They are not available with `simple_jwt` or `session_jwt`. See the [Settings Reference](/Deploying/Settings-Reference) for details. + +## Management API + +These endpoints need a signed-in session and cannot be called with a token. + +| Endpoint | Purpose | +| --- | --- | +| `GET /api/user/tokens` | List your tokens, the scope catalog and the server's token policy | +| `POST /api/user/tokens` | Create a token. Body: `name`, `scopes`, optional `resource_filter`, optional `expires_in_days` (`0` = never, when allowed). The response carries the plaintext `token` once | +| `DELETE /api/user/tokens/` | Revoke a token | + +## Good practice + +- Give each pipeline its own token with the narrowest scopes that work, and name it after where it is used. +- Store tokens in your CI system's secret store. Never commit them. The `dgpt_pat_` prefix lets secret scanners recognise them. +- Prefer short lifetimes for tokens used by automation you can easily re-provision. +- Revoke a token as soon as it is no longer needed or may have been exposed. From 934ae1afb31df5a44a43ecd4f5df81ab6d467260 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:54:35 +0100 Subject: [PATCH 07/13] fix(pat): close message replay to restricted tokens, filter builtin tools, reject non-object bodies Message tail and the ASGI reconnect stream cannot tie a message to an allowlist, so any token with a resource filter is refused there. The tools listing now applies the allowlist to default and builtin rows as well. Token creation answers 400 instead of 500 for a JSON body that is not an object. --- docs/content/Extensions/personal-access-tokens.mdx | 2 +- docsgpt/api/asgi_auth.py | 14 +++++++++++++- docsgpt/api/pat/routes.py | 6 +++++- docsgpt/api/pat/rules.py | 5 ++++- docsgpt/api/user/tools/routes.py | 6 +++--- tests/api/test_asgi_auth.py | 8 ++++++++ tests/api/test_pat_routes.py | 5 +++++ tests/api/test_pat_rules.py | 6 ++++++ 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx index 24ab642d..c533b528 100644 --- a/docs/content/Extensions/personal-access-tokens.mdx +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -131,7 +131,7 @@ For a restricted family the token: - can read, update and delete only the listed resources, and listings show only those; - **cannot create** new resources of that family, since a new resource would be outside the list; - cannot attach a resource outside the list to something else, for example set an agent's source to a source the token may not use; -- is refused (`403`, `"error": "resource_not_allowed"`) wherever DocsGPT cannot prove the request stays inside the list. Schedules, conversations and analytics are closed to agent-restricted tokens for that reason, and `/api/sources/paginated` is closed to source-restricted tokens (use `/api/sources`). +- is refused (`403`, `"error": "resource_not_allowed"`) wherever DocsGPT cannot prove the request stays inside the list. Schedules, conversations and analytics are closed to agent-restricted tokens for that reason, the message replay endpoints (`/api/messages//tail` and `/api/messages//events`) are closed to every restricted token, and `/api/sources/paginated` is closed to source-restricted tokens (use `/api/sources`). Restrictions and chat (`chat:run`): diff --git a/docsgpt/api/asgi_auth.py b/docsgpt/api/asgi_auth.py index aa2ac5bd..a65e83ab 100644 --- a/docsgpt/api/asgi_auth.py +++ b/docsgpt/api/asgi_auth.py @@ -31,7 +31,8 @@ async def authenticate( request: The incoming Starlette request. pat_scope: Scope 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``). + the Flask rule table in ``docsgpt/api/pat/rules.py``). A token with + a resource filter is always rejected. Returns: tuple: ``(claims, None)`` for an authenticated caller, ``(None, None)`` @@ -52,6 +53,17 @@ async def authenticate( {"success": False, "message": "Token lacks the required scope", "error": "insufficient_scope"}, status_code=403, ) + if decoded.get("resource_filter"): + # These routes sit outside the Flask rule table and cannot tie what + # they serve to an allowlist, so a restricted token is kept out. + return None, JSONResponse( + { + "success": False, + "message": "This endpoint is not available to a resource-restricted token", + "error": "resource_not_allowed", + }, + status_code=403, + ) return decoded, None # The denylist is a sync Redis read; keep it off the event loop. if settings.AUTH_TYPE == "oidc" and await anyio.to_thread.run_sync(oidc_session_denied, decoded): diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py index 84360a1b..ad22cf6d 100644 --- a/docsgpt/api/pat/routes.py +++ b/docsgpt/api/pat/routes.py @@ -113,7 +113,11 @@ class PersonalAccessTokens(Resource): if not auth_type_supports_pats(): return _error("Personal access tokens are not available on this server", 403) - body = request.get_json(silent=True) or {} + 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) diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index f340d698..6794390b 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -53,6 +53,8 @@ def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] = return Rule(scopes=scopes, family=family, ids=tuple(ids), **kwargs) +_ALL_FAMILIES = ("agents", "sources", "prompts", "tools", "workflows") + # Ids of other families that agent create/update accept in their JSON-or-form body. _AGENT_BODY_REFS = ( ("sources", (BODY, "source")), @@ -216,8 +218,9 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), ("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), ("/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//tail", "GET"): _rule( - any_of=("conversations:read", "chat:run"), family=None + any_of=("conversations:read", "chat:run"), 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",)), diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py index 8b70abac..ddc56916 100644 --- a/docsgpt/api/user/tools/routes.py +++ b/docsgpt/api/user/tools/routes.py @@ -266,9 +266,6 @@ class GetTools(Resource): shaped = _shape_tool(row, ownership="team", force_strip_secret=True) shaped["team_access"] = team_shared.get(str(row["id"])) user_tools.append(shaped) - # A resource-restricted token sees only its allowed tools; the - # default and builtin rows appended below belong to no user. - user_tools = filter_listing(request, "tools", user_tools) # ``scheduler`` is dual-registered (default chat tool + agent- # selectable builtin) and resolves to the same synthetic uuid5 id. @@ -298,6 +295,9 @@ class GetTools(Resource): builtin_copy.get("name") in WORKFLOW_ONLY_BUILTINS ) user_tools.append(builtin_copy) + # A resource-restricted token sees only its allowed tools. Default + # and builtin rows have ids too, so they follow the same allowlist. + user_tools = filter_listing(request, "tools", user_tools) except Exception as err: current_app.logger.error(f"Error getting user tools: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) diff --git a/tests/api/test_asgi_auth.py b/tests/api/test_asgi_auth.py index 341ded0d..d40fae0e 100644 --- a/tests/api/test_asgi_auth.py +++ b/tests/api/test_asgi_auth.py @@ -156,3 +156,11 @@ class TestPersonalAccessTokens: decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run") denied.assert_not_called() assert error is None and decoded is not None + + async def test_restricted_token_is_refused_even_with_the_scope(self): + claims = dict(_PAT_CLAIMS, resource_filter={"agents": ["a1"]}) + with patch.object(asgi_auth, "handle_auth", return_value=claims): + decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run") + assert decoded is None + assert error.status_code == 403 + assert json.loads(error.body)["error"] == "resource_not_allowed" diff --git a/tests/api/test_pat_routes.py b/tests/api/test_pat_routes.py index cd157284..be07b7d5 100644 --- a/tests/api/test_pat_routes.py +++ b/tests/api/test_pat_routes.py @@ -118,6 +118,11 @@ class TestCreate: def test_validation(self, client, db, body): assert _create(client, **body).status_code == 400 + @pytest.mark.parametrize("body", [[1], "text", 5]) + def test_non_object_body_is_a_client_error(self, client, db, body): + with _session(): + assert client.post("/api/user/tokens", json=body).status_code == 400 + def test_duplicate_name_conflicts(self, client, db): assert _create(client).status_code == 201 assert _create(client).status_code == 409 diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py index 3a3cd75a..c6c9f0b8 100644 --- a/tests/api/test_pat_rules.py +++ b/tests/api/test_pat_rules.py @@ -230,6 +230,12 @@ class TestResourceRestrictions: assert _denied(_call(client, "GET", f"/api/agents/{AGENT_A}/schedules", claims)) is None assert _denied(_call(client, "GET", f"/api/agents/{AGENT_B}/schedules", claims)) == "resource_not_allowed" + @pytest.mark.parametrize("family", ["agents", "sources", "prompts", "tools", "workflows"]) + def test_message_tail_is_closed_to_any_restricted_token(self, client, family): + claims = _claims(["chat:run"], {family: [AGENT_A]}) + assert _denied(_call(client, "GET", "/api/messages/m1/tail", claims)) == "resource_not_allowed" + assert _denied(_call(client, "GET", "/api/messages/m1/tail", _claims(["chat:run"]))) is None + def test_sql_paged_listing_is_closed_to_restricted_tokens(self, client): claims = self._restricted(["sources:read"], sources=[SOURCE_A]) assert _denied(_call(client, "GET", "/api/sources/paginated", claims)) == "resource_not_allowed" From 7ddb5f64003f1f3b90ae49c9a248fbbdca1d054f Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:12:42 +0100 Subject: [PATCH 08/13] 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. --- docs/content/Deploying/Settings-Reference.mdx | 2 +- .../Extensions/personal-access-tokens.mdx | 14 +++++++++++--- docsgpt/api/asgi_auth.py | 9 +++++---- docsgpt/api/async_sse.py | 4 +++- docsgpt/api/pat/routes.py | 1 + docsgpt/api/pat/rules.py | 19 +++++++++++++++---- docsgpt/core/settings/auth.py | 6 ++++-- .../db/repositories/personal_access_tokens.py | 17 +++++++++++++++++ tests/api/test_asgi_auth.py | 16 ++++++++++++++++ tests/api/test_pat_routes.py | 9 +++++++++ tests/api/test_pat_rules.py | 5 +++++ .../test_personal_access_tokens.py | 18 ++++++++++++++++++ 12 files changed, 105 insertions(+), 15 deletions(-) diff --git a/docs/content/Deploying/Settings-Reference.mdx b/docs/content/Deploying/Settings-Reference.mdx index 9266a90e..2229afc4 100644 --- a/docs/content/Deploying/Settings-Reference.mdx +++ b/docs/content/Deploying/Settings-Reference.mdx @@ -136,7 +136,7 @@ Bearer token for IdP SCIM clients (required when SCIM is enabled). Type `bool`, default `true`. -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` diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx index c533b528..909b9ef6 100644 --- a/docs/content/Extensions/personal-access-tokens.mdx +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -69,6 +69,8 @@ curl -X POST "$DOCSGPT_URL/api/import_agent/plan" \ -d "$(jq -Rs '{yaml: .}' support-bot.agent.yaml)" ``` +Sources are matched by **name**, and when several of your sources share a name the oldest one wins. A pipeline that re-uploads documentation on every push should therefore upload with `docsgpt-cli sources upload ... --wait --replace` (which removes the older same-named sources) and run `agents apply` afterwards, or the agent stays bound to the first upload. + `/api/import_agent/plan` is a dry run that reports whether the agent would be created or updated and how each referenced source, tool and prompt resolves. `/api/import_agent` applies it. An agent is matched by `metadata.id`, then `metadata.slug`; when nothing matches, a new draft agent is created. ### GitHub Actions example @@ -83,7 +85,9 @@ jobs: env: DOCSGPT_URL: ${{ vars.DOCSGPT_URL }} DOCSGPT_TOKEN: ${{ secrets.DOCSGPT_TOKEN }} - run: docsgpt-cli agents apply -f agents/ + run: | + docsgpt-cli sources upload docs/*.md --name "Product docs" --wait --replace + docsgpt-cli agents apply -f agents/ ``` ## Scopes @@ -149,19 +153,23 @@ Restrictions and `agents apply`: a token restricted to specific agents can apply - Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated. - Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins. +An expired token's name can be reused: creating a token with that name retires the expired one. + Each user may hold up to `PAT_MAX_PER_USER` live tokens (25 by default). The token list shows when and from which IP address each token was last used. ## Operator settings | Setting | Default | Purpose | | --- | --- | --- | -| `PAT_ENABLED` | `true` | Allow users to create and use personal access tokens | +| `PAT_ENABLED` | `true` | Master switch. When `false`, tokens cannot be created **and every existing token stops authenticating immediately** | | `PAT_DEFAULT_LIFETIME_DAYS` | `90` | Lifetime of a token created without an explicit expiry | | `PAT_MAX_LIFETIME_DAYS` | `365` | Longest lifetime a user may request | | `PAT_ALLOW_NON_EXPIRING` | `false` | Let users create tokens that never expire | | `PAT_MAX_PER_USER` | `25` | Maximum number of live tokens per user | -Personal access tokens need a stable user identity, so they are available with `AUTH_TYPE=oidc` and with authentication disabled (single-user self-hosting). They are not available with `simple_jwt` or `session_jwt`. See the [Settings Reference](/Deploying/Settings-Reference) for details. +Personal access tokens need a stable user identity, so they are available with `AUTH_TYPE=oidc` and with authentication disabled (single-user self-hosting). They are not available with `simple_jwt` or `session_jwt`. + +Turning `PAT_ENABLED` off, or switching `AUTH_TYPE` to `simple_jwt` or `session_jwt`, is not limited to the settings page: every pipeline that uses a token starts getting `401` right away. Tokens are not deleted and work again once the setting is restored. See the [Settings Reference](/Deploying/Settings-Reference) for details. ## Management API diff --git a/docsgpt/api/asgi_auth.py b/docsgpt/api/asgi_auth.py index a65e83ab..7c6c2cb9 100644 --- a/docsgpt/api/asgi_auth.py +++ b/docsgpt/api/asgi_auth.py @@ -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, diff --git a/docsgpt/api/async_sse.py b/docsgpt/api/async_sse.py index 1bb3af1d..e3ae15d8 100644 --- a/docsgpt/api/async_sse.py +++ b/docsgpt/api/async_sse.py @@ -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//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 diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py index ad22cf6d..bb3ea8c5 100644 --- a/docsgpt/api/pat/routes.py +++ b/docsgpt/api/pat/routes.py @@ -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( diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index 6794390b..88f1df76 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -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//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//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: diff --git a/docsgpt/core/settings/auth.py b/docsgpt/core/settings/auth.py index 7471dc5a..706a3339 100644 --- a/docsgpt/core/settings/auth.py +++ b/docsgpt/core/settings/auth.py @@ -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( diff --git a/docsgpt/storage/db/repositories/personal_access_tokens.py b/docsgpt/storage/db/repositories/personal_access_tokens.py index 56fb5caa..efbe250b 100644 --- a/docsgpt/storage/db/repositories/personal_access_tokens.py +++ b/docsgpt/storage/db/repositories/personal_access_tokens.py @@ -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( diff --git a/tests/api/test_asgi_auth.py b/tests/api/test_asgi_auth.py index d40fae0e..fa1768ec 100644 --- a/tests/api/test_asgi_auth.py +++ b/tests/api/test_asgi_auth.py @@ -164,3 +164,19 @@ class TestPersonalAccessTokens: assert decoded is None assert error.status_code == 403 assert json.loads(error.body)["error"] == "resource_not_allowed" + + async def test_any_of_several_scopes_admits_the_token(self): + claims = dict(_PAT_CLAIMS, scopes=["conversations:read"]) + with patch.object(asgi_auth, "handle_auth", return_value=claims): + decoded, error = await asgi_auth.authenticate( + _request(), pat_scope=("conversations:read", "chat:run") + ) + assert error is None and decoded["sub"] == "alice" + + async def test_message_events_accepts_the_same_scopes_as_message_tail(self): + from docsgpt.api import async_sse + from docsgpt.api.pat import rules + + tail = rules.RULES[("/api/messages//tail", "GET")] + assert tail.scopes == rules.MESSAGE_REPLAY_SCOPES + assert async_sse.MESSAGE_REPLAY_SCOPES is rules.MESSAGE_REPLAY_SCOPES diff --git a/tests/api/test_pat_routes.py b/tests/api/test_pat_routes.py index be07b7d5..f7592cf4 100644 --- a/tests/api/test_pat_routes.py +++ b/tests/api/test_pat_routes.py @@ -127,6 +127,15 @@ class TestCreate: assert _create(client).status_code == 201 assert _create(client).status_code == 409 + def test_expired_token_does_not_reserve_its_name(self, client, db): + assert _create(client).status_code == 201 + db.execute(text("UPDATE personal_access_tokens SET expires_at = now() - interval '1 day'")) + assert _create(client).status_code == 201 + rows = db.execute( + text("SELECT status, revoke_reason FROM personal_access_tokens ORDER BY created_at") + ).all() + assert [tuple(r) for r in rows] == [("revoked", "expired"), ("active", None)] + def test_per_user_cap(self, client, db, monkeypatch): monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_PER_USER", 1) assert _create(client, name="one").status_code == 201 diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py index c6c9f0b8..4bdc7070 100644 --- a/tests/api/test_pat_rules.py +++ b/tests/api/test_pat_rules.py @@ -114,6 +114,11 @@ class TestScopeEnforcement: response = _call(client, "GET", "/api/admin/users", _claims(list(SCOPES))) assert _denied(response) == "not_available_to_tokens" + def test_unknown_path_and_wrong_method_keep_their_own_status(self, client): + claims = _claims(list(SCOPES)) + assert _call(client, "GET", "/api/no_such_route", claims).status_code == 404 + assert _call(client, "DELETE", "/api/get_agents", claims).status_code == 405 + def test_missing_scope_is_refused_and_names_the_scope(self, client): response = _call(client, "GET", "/api/get_agents", _claims(["sources:read"])) assert _denied(response) == "insufficient_scope" diff --git a/tests/storage/db/repositories/test_personal_access_tokens.py b/tests/storage/db/repositories/test_personal_access_tokens.py index 0102e625..8a6848a3 100644 --- a/tests/storage/db/repositories/test_personal_access_tokens.py +++ b/tests/storage/db/repositories/test_personal_access_tokens.py @@ -64,6 +64,24 @@ class TestUniqueness: assert not repo.name_in_use("u1", "ci") assert _create(repo, token_hash="h2")["name"] == "ci" + def test_retire_expired_name_only_touches_expired_tokens_with_that_name(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + past = datetime.now(timezone.utc) - timedelta(days=1) + _create(repo, name="ci", token_hash="h1", expires_at=past) + _create(repo, name="other", token_hash="h2", expires_at=past) + _create(repo, user_id="u2", name="ci", token_hash="h3", expires_at=past) + assert repo.retire_expired_name("u1", "ci") == 1 + assert not repo.name_in_use("u1", "ci") + assert repo.name_in_use("u1", "other") and repo.name_in_use("u2", "ci") + + def test_retire_expired_name_leaves_live_tokens(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + _create(repo, token_hash="h1") + _create(repo, name="later", token_hash="h2", expires_at=datetime.now(timezone.utc) + timedelta(days=1)) + assert repo.retire_expired_name("u1", "ci") == 0 + assert repo.retire_expired_name("u1", "later") == 0 + assert repo.name_in_use("u1", "ci") and repo.name_in_use("u1", "later") + def test_same_name_for_other_user_is_fine(self, pg_conn): repo = PersonalAccessTokensRepository(pg_conn) _create(repo, user_id="u1", token_hash="h1") From b1ff8f516e60fc787014feb6c49cf6ee27410865 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:32:46 +0100 Subject: [PATCH 09/13] fix(frontend): access tokens dates and names render unescaped, align the scopes block i18next HTML-escaped interpolated values, so expiry dates showed as 26/09/2026 and token names containing & or quotes were mangled in the created and revoke dialogs; React already escapes on render, so those strings opt out like utils/streamingStatusUtils does. The scopes fieldset drops the browser's default padding so it lines up with the other fields, and the native checkboxes follow the dark colour scheme. --- frontend/src/modals/AccessTokenCreatedModal.tsx | 6 +++++- frontend/src/modals/CreateAccessTokenModal.tsx | 6 ++++-- frontend/src/settings/PersonalAccessTokens.tsx | 11 ++++++++--- frontend/src/settings/accessTokenUtils.test.ts | 7 +++++++ frontend/src/settings/accessTokenUtils.ts | 8 ++++++++ 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/src/modals/AccessTokenCreatedModal.tsx b/frontend/src/modals/AccessTokenCreatedModal.tsx index 66026edc..2b627f93 100644 --- a/frontend/src/modals/AccessTokenCreatedModal.tsx +++ b/frontend/src/modals/AccessTokenCreatedModal.tsx @@ -5,6 +5,7 @@ import { baseURL } from '../api/client'; import CopyButton from '../components/CopyButton'; import { Button } from '../components/ui/button'; import { Modal } from '../components/ui/modal'; +import { NO_ESCAPE } from '../settings/accessTokenUtils'; interface AccessTokenCreatedModalProps { /** Plaintext secret; `null` keeps the modal closed. Held only in the parent's component state. */ @@ -49,7 +50,10 @@ export default function AccessTokenCreatedModal({ {t('settings.accessTokens.created.title')}

- {t('settings.accessTokens.created.subtitle', { name })} + {t('settings.accessTokens.created.subtitle', { + name, + ...NO_ESCAPE, + })}

diff --git a/frontend/src/modals/CreateAccessTokenModal.tsx b/frontend/src/modals/CreateAccessTokenModal.tsx index 438403a8..87e39a2d 100644 --- a/frontend/src/modals/CreateAccessTokenModal.tsx +++ b/frontend/src/modals/CreateAccessTokenModal.tsx @@ -30,6 +30,7 @@ import { expiryOptions, groupScopesByFamily, isScopeImplied, + NO_ESCAPE, NO_EXPIRY, PICKER_FAMILIES, ResourceOption, @@ -306,12 +307,13 @@ export default function CreateAccessTokenModal({ date: formatDateOnly( new Date(Date.now() + expiry * DAY_MS).toISOString(), ), + ...NO_ESCAPE, })}

-
+
{t('settings.accessTokens.create.scopes')} * @@ -355,7 +357,7 @@ export default function CreateAccessTokenModal({ checked={checked} disabled={implied} onChange={() => toggleScope(scope.name)} - className="accent-primary mt-0.5 size-4 shrink-0 rounded-sm border-gray-300 bg-transparent" + className="accent-primary mt-0.5 size-4 shrink-0 rounded-sm border-gray-300 bg-transparent dark:[color-scheme:dark]" /> diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx index d81416b0..7439e567 100644 --- a/frontend/src/settings/PersonalAccessTokens.tsx +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -33,6 +33,7 @@ import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; import { countLiveTokens, expiryStatus, + NO_ESCAPE, relativeTime, restrictionCounts, } from './accessTokenUtils'; @@ -197,8 +198,8 @@ export default function PersonalAccessTokens() { > ); }; @@ -210,7 +211,10 @@ export default function PersonalAccessTokens() { size="sm" className="text-destructive hover:text-destructive border-destructive/40 hover:bg-destructive/10 rounded-full px-4" onClick={() => requestRevoke(item)} - aria-label={t('settings.accessTokens.revokeAria', { name: item.name })} + aria-label={t('settings.accessTokens.revokeAria', { + name: item.name, + ...NO_ESCAPE, + })} > {t('settings.accessTokens.revoke')} @@ -431,6 +435,7 @@ export default function PersonalAccessTokens() { { expect(toResourceOptions('agents', { success: false })).toEqual([]); }); }); + +describe('NO_ESCAPE', () => { + it('turns off i18next HTML escaping so dates and names render as typed', () => { + expect(NO_ESCAPE).toEqual({ interpolation: { escapeValue: false } }); + }); +}); diff --git a/frontend/src/settings/accessTokenUtils.ts b/frontend/src/settings/accessTokenUtils.ts index 134d347d..9e7e56c3 100644 --- a/frontend/src/settings/accessTokenUtils.ts +++ b/frontend/src/settings/accessTokenUtils.ts @@ -222,3 +222,11 @@ export function toResourceOptions( })) .sort((a, b) => a.label.localeCompare(b.label)); } + +/** + * i18next HTML-escapes interpolated values by default, which turns a date like + * 26/09/2026 into `26/09/2026` and mangles token names containing + * `&` or `'`. React already escapes on render, so opt out for those values + * (same approach as utils/streamingStatusUtils.ts). + */ +export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const; From 94b5924e367d9d2e1f5ebba6eef6d62d952a294d Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:46:43 +0100 Subject: [PATCH 10/13] fix(pat): close restricted-token paths through workflows, chat, schedules and conversations A resource restriction was checked on ids in the request, not on what the addressed row pulls in or belongs to. Closed: - Workflow writes for tokens restricted on sources, tools or prompts (a graph names those inside its nodes), and attaching a workflow to an agent unless the token is restricted on workflows too. - Chat for tools-restricted tokens (chat executes tools; rejected at token creation as well), and agent-less chat for tokens restricted on prompts or workflows. - conversation_id on chat: it must belong to the agent being run, or to no agent for agent-less chat. Otherwise the server continued, appended to, or resumed pending tool calls of another agent's conversation. - Schedules for tokens restricted on anything but agents; schedule-id routes for every restricted token. - Conversations and analytics for every restricted token, not only agent-restricted ones. Also: create, first publish and adopt return the agent API key masked to a token without agents:keys; token ids must be canonical UUIDs (urn:uuid: gave a 500); an expired token is reported as expired; token creation takes a per-user advisory lock so the cap cannot be raced; admin revoke-sessions writes a pat_revoked event per token. The UI drops a row whose revoke returns 404 and does not offer a tools restriction next to chat:run. --- .../Extensions/personal-access-tokens.mdx | 20 ++- docsgpt/api/admin/routes.py | 13 +- docsgpt/api/pat/routes.py | 23 ++- docsgpt/api/pat/rules.py | 154 +++++++++++++----- docsgpt/api/pat/tokens.py | 3 + docsgpt/api/user/agents/routes.py | 16 +- .../db/repositories/personal_access_tokens.py | 14 +- .../src/settings/PersonalAccessTokens.tsx | 8 + .../src/settings/accessTokenUtils.test.ts | 11 ++ frontend/src/settings/accessTokenUtils.ts | 6 +- tests/api/test_admin_dashboard.py | 13 +- tests/api/test_pat_routes.py | 27 ++- tests/api/test_pat_rules.py | 142 ++++++++++++++++ tests/api/test_pat_tokens.py | 5 + .../test_personal_access_tokens.py | 2 +- 15 files changed, 393 insertions(+), 64 deletions(-) diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx index 909b9ef6..47d3624d 100644 --- a/docs/content/Extensions/personal-access-tokens.mdx +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -111,7 +111,7 @@ A `write` scope includes the matching `read` scope. | `teams:read` | View teams, members and resource shares | | `chat:run` | Ask agents and search sources (`/api/answer`, `/stream`, `/api/search`); used for benchmarking | -`agents:keys` is separate from `agents:write` on purpose: a deployment token that updates agents does not need to be able to read or rotate the secrets other systems use to call them. +`agents:keys` is separate from `agents:write` on purpose. Creating, publishing or adopting an agent mints its API key; a token without `agents:keys` gets the key back masked (`1234...90ab`), because an agent key keeps working after the token that saw it is revoked. Put differently: a deployment token that updates agents does not need to be able to read or rotate the secrets other systems use to call them. Some parts of the API are never available to a token, whatever its scopes: token management, the admin API, team management, sign-in flows, device pairing, and the interactive OAuth handshakes used by connectors and MCP servers. A token also never carries the `admin` role, even when its owner is an admin. @@ -135,12 +135,23 @@ For a restricted family the token: - can read, update and delete only the listed resources, and listings show only those; - **cannot create** new resources of that family, since a new resource would be outside the list; - cannot attach a resource outside the list to something else, for example set an agent's source to a source the token may not use; -- is refused (`403`, `"error": "resource_not_allowed"`) wherever DocsGPT cannot prove the request stays inside the list. Schedules, conversations and analytics are closed to agent-restricted tokens for that reason, the message replay endpoints (`/api/messages//tail` and `/api/messages//events`) are closed to every restricted token, and `/api/sources/paginated` is closed to source-restricted tokens (use `/api/sources`). +- is refused (`403`, `"error": "resource_not_allowed"`) wherever DocsGPT cannot prove the request stays inside the list: + - **Conversations, analytics and message replay** (`/api/messages//tail`, `/api/messages//events`) are closed to every restricted token. They span all agents and contain cited source text and tool output. + - **Schedules** run an agent with a free-form instruction and store the output. A token restricted to agents can list and create schedules for its agents; every other schedule route, and schedules altogether for tokens restricted on another family, are closed. + - **Workflow writes** are closed to tokens restricted on sources, tools or prompts, because a workflow graph names those inside its nodes. Such a token also cannot attach a workflow to an agent unless it is restricted on workflows too, in which case only the listed workflows can be attached. + - `/api/sources/paginated` is closed to source-restricted tokens (use `/api/sources`). + +A restriction covers what the token *asks for*, not what an allowed resource already contains: an agent on the list runs with its own sources, prompt and tools even when the token is also restricted on those families. List an agent only if you are happy for the token to use everything that agent uses. Restrictions and chat (`chat:run`): -- A token restricted to specific **agents** must pass `agent_id` in the request body, and it must be one of the listed agents. An agent `api_key` in the body is refused. -- A token restricted to specific **sources** (but not agents) may chat against those sources with `active_docs`. It cannot run agents, because an agent brings its own sources. Restrict the token to agents instead to allow that. +- A token restricted to specific **agents** must pass exactly one `agent_id` in the request body, and it must be one of the listed agents. An agent `api_key` or an inline workflow in the body is refused. +- A token restricted to specific **sources** only may chat against those sources with `active_docs`. It cannot run agents, because an agent brings its own sources. Restrict the token to agents instead to allow that. +- A token restricted on **prompts** or **workflows** must also be restricted to agents to chat. +- A token restricted on **tools** cannot use chat at all, and a tools restriction cannot be combined with `chat:run` when the token is created. Chat executes tools (an agent's own, or your default tools when there is no agent) and those cannot be held to a list. +- A `conversation_id` must belong to the agent being run (or to no agent, for agent-less chat). Otherwise the server would continue, append to, or resume pending tool calls of another agent's conversation. + +`agents:write` and import: applying an agent definition can create the prompt and tools it references and rewrite the agent's workflow, all under `agents:write` alone. It does not need `prompts:write`, `tools:write` or `workflows:write`, so treat `agents:write` as able to create those through an import. Restrictions and `agents apply`: a token restricted to specific agents can apply a definition only when it updates one of those agents. A token restricted on sources, prompts, tools or workflows cannot import agents at all, because an import resolves those references by name and may create them. @@ -151,6 +162,7 @@ Restrictions and `agents apply`: a token restricted to specific agents can apply - Revoking a token in **Settings → Access Tokens** takes effect on the next request. - Admins can list a user's tokens with `GET /api/admin/users//tokens` and revoke any token with `DELETE /api/admin/tokens/`. The admin **revoke sessions** action also revokes all of that user's tokens. - Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated. +- `GET /api/user/tokens` reports a token past its expiry as `"status": "expired"`. - Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins. An expired token's name can be reused: creating a token with that name retires the expired one. diff --git a/docsgpt/api/admin/routes.py b/docsgpt/api/admin/routes.py index 0bad4772..3d178904 100644 --- a/docsgpt/api/admin/routes.py +++ b/docsgpt/api/admin/routes.py @@ -250,9 +250,18 @@ class AdminUserSessionsResource(Resource): ok = denylist.deny_user(user_id) with db_session() as conn: # A forced logout that left API credentials alive would not be one. - tokens_revoked = PersonalAccessTokensRepository(conn).revoke_all_for_user( + revoked_token_ids = PersonalAccessTokensRepository(conn).revoke_all_for_user( user_id, reason="admin_sessions_revoked" ) + # One pat_revoked event per token, like every other revocation path. + for token_id in revoked_token_ids: + AuthEventsRepository(conn).insert( + user_id, + "pat_revoked", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={"token_id": token_id, "by": _actor(), "via": "admin_sessions_revoked"}, + ) AuthEventsRepository(conn).insert( user_id, "admin_sessions_revoked", @@ -262,7 +271,7 @@ class AdminUserSessionsResource(Resource): "by": _actor(), "via": "admin_api", "persisted": ok, - "personal_access_tokens_revoked": tokens_revoked, + "personal_access_tokens_revoked": len(revoked_token_ids), }, ) return make_response(jsonify({"success": True, "revoked": ok}), 200) diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py index bb3ea8c5..7c2ecc36 100644 --- a/docsgpt/api/pat/routes.py +++ b/docsgpt/api/pat/routes.py @@ -9,6 +9,7 @@ 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 @@ -50,21 +51,35 @@ def _session_user_id(): def _valid_uuid(value: str) -> bool: + """Canonical form only: ``uuid.UUID`` also accepts ``urn:uuid:…`` and braces, which Postgres does not.""" try: - uuid.UUID(str(value)) + return str(uuid.UUID(str(value))) == str(value).lower() except (ValueError, AttributeError, TypeError): return False - return True + + +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": row["status"], + "status": status, "expires_at": row.get("expires_at"), "last_used_at": row.get("last_used_at"), "last_used_ip": row.get("last_used_ip"), @@ -135,6 +150,8 @@ class PersonalAccessTokens(Resource): 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 diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index 88f1df76..784f764c 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -44,7 +44,7 @@ class Rule: open: bool = False in_route: bool = False blocked_by: tuple[str, ...] = () - check: Optional[Callable[[Any, dict], Optional[str]]] = None + check: Optional[Callable[[Any, dict, Optional[str]], Optional[str]]] = None def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] = (), **kwargs) -> Rule: @@ -61,6 +61,9 @@ def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] = MESSAGE_REPLAY_SCOPES = ("conversations:read", "chat:run") _ALL_FAMILIES = ("agents", "sources", "prompts", "tools", "workflows") +_WORKFLOW_CONTENT_FAMILIES = ("sources", "tools", "prompts") +# A route reached through an agent id can prove the agent; nothing else about it. +_NON_AGENT_FAMILIES = ("sources", "prompts", "tools", "workflows") # Ids of other families that agent create/update accept in their JSON-or-form body. _AGENT_BODY_REFS = ( @@ -72,15 +75,41 @@ _AGENT_BODY_REFS = ( ) -def _chat_check(request, resource_filter: dict) -> Optional[str]: +def _conversation_agent_id(conversation_id: str, user_id: Optional[str]) -> tuple[bool, str]: + """``(found, agent_id)`` for a conversation the user can reach; ``agent_id`` is "" when it has none.""" + from docsgpt.storage.db.repositories.conversations import ConversationsRepository + from docsgpt.storage.db.session import db_readonly + + if not user_id: + return False, "" + try: + with db_readonly() as conn: + row = ConversationsRepository(conn).get_any(str(conversation_id), user_id) + except Exception: + return False, "" + if not row: + return False, "" + return True, str(row.get("agent_id") or "") + + +def _chat_check(request, resource_filter: dict, user_id: Optional[str]) -> Optional[str]: """Keep a restricted token's chat traffic inside its allowlists. An agent brings its own sources, prompt and tools, which this table cannot - see, so a token restricted on any family must name an allowed agent (or, - when only sources are restricted, chat against allowed sources directly). - An agent ``api_key`` in the body would swap in an arbitrary agent. + see, so a restricted token must name an allowed agent. The one exception is + a token restricted on sources only, which may chat against allowed sources + directly. Everything that could swap in another agent or another set of + resources is refused: an agent ``api_key``, an inline workflow, and a + ``conversation_id`` that belongs to a different agent (the server would + otherwise continue, append to, or resume tool calls of that conversation). + + Chat executes tools: an agent's own, or the user's defaults when there is + no agent. Neither can be held to a tools allowlist from here, so a token + restricted on tools cannot chat at all. """ body = _json_body(request) + if "tools" in resource_filter: + return "A token restricted to specific tools cannot use chat endpoints" if body.get("api_key"): return "A restricted token cannot chat with an agent API key; pass agent_id" if body.get("workflow"): @@ -88,11 +117,33 @@ def _chat_check(request, resource_filter: dict) -> Optional[str]: return "A restricted token cannot run an inline workflow" agent_ids = _as_ids(body.get("agent_id")) if "agents" in resource_filter: - if not agent_ids: + if len(agent_ids) != 1: return "This token is restricted to specific agents; pass agent_id" - return None # the agent id itself is verified through ``refs`` - if agent_ids: - return "This token is restricted to specific resources and cannot run arbitrary agents" + # the agent id itself is verified through ``refs`` + elif set(resource_filter) - {"sources"}: + return "Restrict this token to specific agents to use chat endpoints" + elif agent_ids: + return "This token is restricted to specific sources and cannot run agents" + conversation_id = body.get("conversation_id") + if conversation_id: + found, conversation_agent = _conversation_agent_id(conversation_id, user_id) + expected = agent_ids[0] if agent_ids else "" + if not found or _canonical(conversation_agent) != _canonical(expected): + return "This conversation does not belong to the agent this token may use" + return None + + +def _agent_body_check(request, resource_filter: dict, user_id: Optional[str]) -> Optional[str]: + """A workflow pulls in its own sources, tools and prompts, which a reference check cannot see. + + A token restricted on any of those may attach a workflow to an agent only + when it is also restricted on workflows, so the workflow is one its owner + chose (``refs`` then verifies the id). + """ + if "workflows" in resource_filter or not set(resource_filter) & {"sources", "tools", "prompts"}: + return None + if _read(request, (BODY, "workflow")): + return "A token restricted to specific sources, tools or prompts cannot attach a workflow to an agent" return None @@ -124,9 +175,9 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/guardrails/summary", "GET"): _rule("agents:read", (QUERY, "agent_id")), ("/api/agents/folders/", "GET"): _rule("agents:read", open=True), ("/api/agents/folders/", "GET"): _rule("agents:read"), - ("/api/create_agent", "POST"): _rule("agents:write", refs=_AGENT_BODY_REFS), + ("/api/create_agent", "POST"): _rule("agents:write", refs=_AGENT_BODY_REFS, check=_agent_body_check), ("/api/update_agent/", "PUT"): _rule( - "agents:write", (VIEW, "agent_id"), refs=_AGENT_BODY_REFS + "agents:write", (VIEW, "agent_id"), refs=_AGENT_BODY_REFS, check=_agent_body_check ), ("/api/delete_agent", "DELETE"): _rule("agents:write", (QUERY, "id")), ("/api/adopt_agent", "POST"): _rule("agents:write"), @@ -142,22 +193,25 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/agents/folders/bulk_move", "POST"): _rule("agents:write", (JSON, "agent_ids")), ("/api/regenerate_agent_key/", "POST"): _rule("agents:keys", (VIEW, "agent_id")), ("/api/agent_webhook", "GET"): _rule("agents:keys", (QUERY, "id")), - # Schedules hang off an agent. + # Schedules hang off an agent, and a schedule runs that agent with a free-form + # instruction and stores the output. The agent id proves the agent and nothing + # else, so tokens restricted on any other family are kept out; routes that + # carry only a schedule id prove nothing and are closed to every restricted token. ("/api/agents//schedules", "GET"): _rule( - "schedules:read", refs=(("agents", (VIEW, "agent_id")),) + "schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES ), ("/api/agents//schedules", "POST"): _rule( - "schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=("tools",) + "schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES ), - ("/api/schedules/", "GET"): _rule("schedules:read", blocked_by=("agents",)), - ("/api/schedules//runs", "GET"): _rule("schedules:read", blocked_by=("agents",)), + ("/api/schedules/", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES), + ("/api/schedules//runs", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES), ("/api/schedules//runs/", "GET"): _rule( - "schedules:read", blocked_by=("agents",) + "schedules:read", blocked_by=_ALL_FAMILIES ), - ("/api/schedules/", "PUT"): _rule("schedules:write", blocked_by=("agents", "tools")), - ("/api/schedules/", "PATCH"): _rule("schedules:write", blocked_by=("agents", "tools")), - ("/api/schedules/", "DELETE"): _rule("schedules:write", blocked_by=("agents",)), - ("/api/schedules//run", "POST"): _rule("schedules:write", blocked_by=("agents",)), + ("/api/schedules/", "PUT"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), + ("/api/schedules/", "PATCH"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), + ("/api/schedules/", "DELETE"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), + ("/api/schedules//run", "POST"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), # Sources ("/api/sources", "GET"): _rule("sources:read", listing=True), # Counted and paged in SQL, so it cannot be narrowed here; restricted tokens use /api/sources. @@ -217,28 +271,32 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/user/models/test", "POST"): _rule("models:write"), ("/api/user/models//test", "POST"): _rule("models:write"), # Workflows - ("/api/workflows", "POST"): _rule("workflows:write"), + # A workflow graph names sources, tools and prompts inside its nodes, out of reach of ``refs``. + ("/api/workflows", "POST"): _rule("workflows:write", blocked_by=_WORKFLOW_CONTENT_FAMILIES), ("/api/workflows/", "GET"): _rule("workflows:read", (VIEW, "workflow_id")), - ("/api/workflows/", "PUT"): _rule("workflows:write", (VIEW, "workflow_id")), + ("/api/workflows/", "PUT"): _rule( + "workflows:write", (VIEW, "workflow_id"), blocked_by=_WORKFLOW_CONTENT_FAMILIES + ), ("/api/workflows/", "DELETE"): _rule("workflows:write", (VIEW, "workflow_id")), - # Conversations and analytics span every agent, so an agent-restricted token is kept out. - ("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), - ("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)), - ("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=("agents",)), + # Conversations and analytics span every agent and carry cited source text and tool + # output, so they are closed to every restricted token. + ("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), + ("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), + ("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), # A message cannot be tied to an allowlist from here, so any restricted token is kept out. ("/api/messages//tail", "GET"): _rule( 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",)), - ("/api/update_conversation_name", "POST"): _rule("conversations:write", blocked_by=("agents",)), - ("/api/feedback", "POST"): _rule("conversations:write", blocked_by=("agents",)), - ("/api/get_message_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), - ("/api/get_token_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), - ("/api/get_feedback_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), - ("/api/get_tool_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), - ("/api/get_schedule_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)), - ("/api/get_user_logs", "POST"): _rule("analytics:read", blocked_by=("agents",)), + ("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), + ("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), + ("/api/update_conversation_name", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), + ("/api/feedback", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), + ("/api/get_message_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), + ("/api/get_token_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), + ("/api/get_feedback_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), + ("/api/get_tool_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), + ("/api/get_schedule_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), + ("/api/get_user_logs", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), # Teams (read only) ("/api/teams", "GET"): _rule("teams:read"), ("/api/teams/", "GET"): _rule("teams:read"), @@ -395,18 +453,18 @@ def authorize(request, decoded_token: dict) -> Optional[tuple[dict, int]]: resource_filter = decoded_token.get("resource_filter") or {} if not resource_filter: return None - reason = _check_resources(request, rule, resource_filter) + reason = _check_resources(request, rule, resource_filter, decoded_token.get("sub")) if reason is None: return None return ({"success": False, "error": "resource_not_allowed", "message": reason}, 403) -def _check_resources(request, rule: Rule, resource_filter: dict) -> Optional[str]: +def _check_resources(request, rule: Rule, resource_filter: dict, user_id: Optional[str] = None) -> Optional[str]: for family in rule.blocked_by: if family in resource_filter: return f"This endpoint is not available to a token restricted to specific {family}" if rule.check is not None: - reason = rule.check(request, resource_filter) + reason = rule.check(request, resource_filter, user_id) if reason: return reason for family, locator in rule.refs: @@ -459,3 +517,19 @@ def _is_uuid(value: str) -> bool: except (ValueError, AttributeError, TypeError): return False return True + + +def may_see_agent_keys(request) -> bool: + """False for a token without ``agents:keys``: it must not receive a plaintext agent API key. + + Create, first publish and adopt all mint a key and used to return it, which + handed a deploy token a secret that outlives the token's own revocation. + """ + decoded = getattr(request, "decoded_token", None) + if not is_pat(decoded): + return True + return "agents:keys" in (decoded.get("scopes") or []) + + +def mask_agent_key(key: Optional[str]) -> str: + return f"{key[:4]}...{key[-4:]}" if key else "" diff --git a/docsgpt/api/pat/tokens.py b/docsgpt/api/pat/tokens.py index fa4cb9d0..6e4978bd 100644 --- a/docsgpt/api/pat/tokens.py +++ b/docsgpt/api/pat/tokens.py @@ -121,6 +121,9 @@ def normalize_resource_filter(raw: Any, scopes: list[str]) -> dict[str, list[str # chat:run acts on agents and sources, so both may be restricted alongside it. if "chat" in families: families.update({"agents", "sources"}) + if "tools" in raw and "chat:run" in scopes: + # Chat executes tools (an agent's own, or the user's defaults), which cannot be held to an allowlist. + raise ValueError("resource_filter.tools cannot be combined with the chat:run scope") out: dict[str, list[str]] = {} for family, ids in raw.items(): if family not in FILTERABLE_FAMILIES: diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py index 757dd933..6a0ec2b0 100644 --- a/docsgpt/api/user/agents/routes.py +++ b/docsgpt/api/user/agents/routes.py @@ -9,7 +9,7 @@ from flask_restx import fields, Namespace, Resource from pydantic import ValidationError as PydanticValidationError from docsgpt.api import api -from docsgpt.api.pat.rules import filter_listing +from docsgpt.api.pat.rules import filter_listing, mask_agent_key, may_see_agent_keys from docsgpt.guardrails.config import AgentConfig from docsgpt.api.user.base import ( copy_agent_image_for_user, @@ -763,7 +763,9 @@ class CreateAgent(Resource): except Exception as err: current_app.logger.error(f"Error creating agent: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - return make_response(jsonify({"id": new_id, "key": key}), 201) + # A token without agents:keys never receives the plaintext agent key. + visible_key = key if may_see_agent_keys(request) else mask_agent_key(key) + return make_response(jsonify({"id": new_id, "key": visible_key}), 201) @agents_ns.route("/update_agent/") @@ -1307,7 +1309,11 @@ class UpdateAgent(Resource): "message": "Agent updated successfully", } if newly_generated_key: - response_data["key"] = newly_generated_key + response_data["key"] = ( + newly_generated_key + if may_see_agent_keys(request) + else mask_agent_key(newly_generated_key) + ) return make_response(jsonify(response_data), 200) @@ -1811,7 +1817,9 @@ class AdoptAgent(Resource): ) response_agent = _format_agent_output(new_agent, include_key_masked=False) - response_agent["key"] = new_key + response_agent["key"] = ( + new_key if may_see_agent_keys(request) else mask_agent_key(new_key) + ) return make_response( jsonify({"success": True, "agent": response_agent}), 200 ) diff --git a/docsgpt/storage/db/repositories/personal_access_tokens.py b/docsgpt/storage/db/repositories/personal_access_tokens.py index efbe250b..12bd80cc 100644 --- a/docsgpt/storage/db/repositories/personal_access_tokens.py +++ b/docsgpt/storage/db/repositories/personal_access_tokens.py @@ -76,6 +76,13 @@ class PersonalAccessTokensRepository: result = self._conn.execute(text(sql), {"user_id": user_id}) return [row_to_dict(r) for r in result.fetchall()] + def lock_user(self, user_id: str) -> None: + """Hold a per-user advisory lock until the transaction ends.""" + self._conn.execute( + text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"), + {"key": f"personal_access_tokens:{user_id}"}, + ) + def count_active(self, user_id: str) -> int: """Live tokens only: revoked and expired rows don't count against the per-user cap.""" return self._conn.execute( @@ -161,13 +168,14 @@ class PersonalAccessTokensRepository: params["user_id"] = user_id return self._conn.execute(text(sql), params).rowcount > 0 - def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> int: + def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> list[str]: + """Revoke every live token of a user. Returns the revoked token ids (for the audit trail).""" result = self._conn.execute( text( "UPDATE personal_access_tokens " "SET status = 'revoked', revoked_at = now(), revoke_reason = :reason " - "WHERE user_id = :user_id AND status = 'active'" + "WHERE user_id = :user_id AND status = 'active' RETURNING id" ), {"user_id": user_id, "reason": reason}, ) - return result.rowcount + return [str(row[0]) for row in result.fetchall()] diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx index 7439e567..f417200c 100644 --- a/frontend/src/settings/PersonalAccessTokens.tsx +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import patService, { + AccessTokenApiError, AccessTokenPolicy, AccessTokenScope, CreateAccessTokenResponse, @@ -134,6 +135,13 @@ export default function PersonalAccessTokens() { setTokens((prev) => prev.filter((item) => item.id !== target.id)); setError(null); } catch (err) { + if (err instanceof AccessTokenApiError && err.status === 404) { + // Already revoked elsewhere (another tab, an admin): it is gone, so + // drop the stale row instead of reporting a failure. + setTokens((prev) => prev.filter((item) => item.id !== target.id)); + setError(null); + return; + } console.error('Failed to revoke access token:', err); setError( (err instanceof Error && err.message) || diff --git a/frontend/src/settings/accessTokenUtils.test.ts b/frontend/src/settings/accessTokenUtils.test.ts index c820fc8d..a9df9ed5 100644 --- a/frontend/src/settings/accessTokenUtils.test.ts +++ b/frontend/src/settings/accessTokenUtils.test.ts @@ -294,3 +294,14 @@ describe('NO_ESCAPE', () => { expect(NO_ESCAPE).toEqual({ interpolation: { escapeValue: false } }); }); }); + +describe('eligibleFilterFamilies with chat:run', () => { + const all = ['agents', 'sources', 'prompts', 'tools', 'workflows']; + it('never offers a tools restriction next to chat:run', () => { + expect(eligibleFilterFamilies(['tools:write', 'chat:run'], all)).toEqual([ + 'agents', + 'sources', + ]); + expect(eligibleFilterFamilies(['tools:write'], all)).toEqual(['tools']); + }); +}); diff --git a/frontend/src/settings/accessTokenUtils.ts b/frontend/src/settings/accessTokenUtils.ts index 9e7e56c3..ddfc4094 100644 --- a/frontend/src/settings/accessTokenUtils.ts +++ b/frontend/src/settings/accessTokenUtils.ts @@ -75,7 +75,11 @@ export function eligibleFilterFamilies( filterableFamilies: string[], ): string[] { const families = new Set(selected.map(scopeFamily)); - if (families.has('chat')) CHAT_FAMILIES.forEach((f) => families.add(f)); + const chat = families.has('chat'); + if (chat) CHAT_FAMILIES.forEach((f) => families.add(f)); + // Chat executes tools, which cannot be held to an allowlist, so the server + // rejects a tools restriction on a token that also has chat:run. + if (chat) families.delete('tools'); return filterableFamilies.filter((family) => families.has(family)); } diff --git a/tests/api/test_admin_dashboard.py b/tests/api/test_admin_dashboard.py index 50f7dfe0..59c67e4c 100644 --- a/tests/api/test_admin_dashboard.py +++ b/tests/api/test_admin_dashboard.py @@ -178,13 +178,20 @@ class TestUserLifecycle: def test_force_logout(self, client): events = Mock() - with _admin(AuthEventsRepository=Mock(return_value=events)), patch( - "docsgpt.api.admin.routes.denylist" - ) as dl: + tokens = Mock() + tokens.revoke_all_for_user.return_value = ["t1", "t2"] + with _admin( + AuthEventsRepository=Mock(return_value=events), + PersonalAccessTokensRepository=Mock(return_value=tokens), + ), patch("docsgpt.api.admin.routes.denylist") as dl: dl.deny_user.return_value = True resp = client.post("/api/admin/users/bob/revoke-sessions") assert resp.status_code == 200 dl.deny_user.assert_called_once_with("bob") + # A forced logout also revokes the user's API tokens, one audit event each. + tokens.revoke_all_for_user.assert_called_once_with("bob", reason="admin_sessions_revoked") + recorded = [call.args[1] for call in events.insert.call_args_list] + assert recorded == ["pat_revoked", "pat_revoked", "admin_sessions_revoked"] def test_user_detail(self, client): users = Mock() diff --git a/tests/api/test_pat_routes.py b/tests/api/test_pat_routes.py index f7592cf4..cf21c8c5 100644 --- a/tests/api/test_pat_routes.py +++ b/tests/api/test_pat_routes.py @@ -166,6 +166,16 @@ class TestCreate: assert "dgpt_pat_" not in json.dumps(metadata) +class TestExpiredStatus: + def test_expired_token_is_reported_as_expired_not_active(self, client, db): + _create(client) + with _session(): + assert json.loads(client.get("/api/user/tokens").data)["tokens"][0]["status"] == "active" + db.execute(text("UPDATE personal_access_tokens SET expires_at = now() - interval '1 day'")) + with _session(): + assert json.loads(client.get("/api/user/tokens").data)["tokens"][0]["status"] == "expired" + + class TestList: def test_includes_scope_catalog_and_policy(self, client, db): with _session(): @@ -219,10 +229,16 @@ class TestRevoke: with _session(sub="bob"): assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404 - def test_unknown_and_malformed_ids(self, client, db): + @pytest.mark.parametrize( + "token_id", + [AGENT_A, "not-a-uuid", f"urn:uuid:{AGENT_A}", "{" + AGENT_A + "}", AGENT_A.replace("-", "")], + ) + def test_unknown_and_malformed_ids(self, client, db, token_id): + # uuid.UUID() accepts urn:/braced/bare-hex spellings that Postgres rejects; none may reach the cast. with _session(): - assert client.delete(f"/api/user/tokens/{AGENT_A}").status_code == 404 - assert client.delete("/api/user/tokens/not-a-uuid").status_code == 404 + assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404 + with _session(sub="root", roles=("admin", "user")): + assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 404 class TestAdmin: @@ -256,3 +272,8 @@ class TestAdmin: assert client.post("/api/admin/users/alice/revoke-sessions").status_code == 200 headers = {"Authorization": f"Bearer {created['token']}"} assert client.get("/api/user/me", headers=headers).status_code == 401 + events = db.execute( + text("SELECT metadata FROM auth_events WHERE user_id = 'alice' AND event = 'pat_revoked'") + ).all() + assert [e[0]["token_id"] for e in events] == [created["personal_access_token"]["id"]] + assert events[0][0]["via"] == "admin_sessions_revoked" diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py index 4bdc7070..69e532a3 100644 --- a/tests/api/test_pat_rules.py +++ b/tests/api/test_pat_rules.py @@ -246,6 +246,102 @@ class TestResourceRestrictions: assert _denied(_call(client, "GET", "/api/sources/paginated", claims)) == "resource_not_allowed" +WORKFLOW_A = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" +TOOL_A = "ffffffff-ffff-ffff-ffff-ffffffffffff" + + +@pytest.mark.unit +class TestRelationshipsBeyondIds: + """Rows whose content or parent the table cannot see are closed to restricted tokens.""" + + @pytest.mark.parametrize("family", ["sources", "tools", "prompts"]) + def test_workflow_writes_are_closed_to_tokens_restricted_on_what_a_graph_can_name(self, client, family): + claims = _claims(["workflows:write"], {family: [SOURCE_A]}) + assert _denied(_call(client, "POST", "/api/workflows", claims, json={})) == "resource_not_allowed" + assert ( + _denied(_call(client, "PUT", f"/api/workflows/{WORKFLOW_A}", claims, json={})) + == "resource_not_allowed" + ) + assert _denied(_call(client, "GET", f"/api/workflows/{WORKFLOW_A}", claims)) is None + + def test_workflow_restricted_token_can_still_edit_its_workflows(self, client): + claims = _claims(["workflows:write"], {"workflows": [WORKFLOW_A]}) + assert _denied(_call(client, "PUT", f"/api/workflows/{WORKFLOW_A}", claims, json={})) is None + + @pytest.mark.parametrize("family", ["sources", "tools", "prompts"]) + def test_agent_cannot_be_pointed_at_a_workflow_by_a_token_restricted_on_its_contents(self, client, family): + claims = _claims(["agents:write"], {family: [SOURCE_A]}) + for body in ({"workflow": WORKFLOW_A}, {"workflow": {"id": WORKFLOW_A}}): + response = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json=body) + assert _denied(response) == "resource_not_allowed" + form = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, data={"workflow": WORKFLOW_A}) + assert _denied(form) == "resource_not_allowed" + assert _denied(_call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"name": "x"})) is None + + def test_workflow_allowlist_lets_the_agent_use_those_workflows_only(self, client): + claims = _claims(["agents:write"], {"sources": [SOURCE_A], "workflows": [WORKFLOW_A]}) + ok = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"workflow": WORKFLOW_A}) + bad = _call(client, "PUT", f"/api/update_agent/{AGENT_A}", claims, json={"workflow": SOURCE_B}) + assert _denied(ok) is None + assert _denied(bad) == "resource_not_allowed" + + @pytest.mark.parametrize("family", ["sources", "prompts", "tools", "workflows"]) + def test_schedules_are_closed_to_tokens_restricted_on_anything_but_agents(self, client, family): + claims = _claims(["schedules:write"], {family: [SOURCE_A]}) + for method, path in ( + ("GET", f"/api/agents/{AGENT_A}/schedules"), + ("POST", f"/api/agents/{AGENT_A}/schedules"), + ("GET", "/api/schedules/s1"), + ("POST", "/api/schedules/s1/run"), + ("GET", "/api/schedules/s1/runs"), + ): + assert _denied(_call(client, method, path, claims, json={})) == "resource_not_allowed", path + + @pytest.mark.parametrize("family", ["agents", "sources", "prompts", "tools", "workflows"]) + def test_conversations_and_analytics_are_closed_to_every_restricted_token(self, client, family): + claims = _claims(["conversations:write", "analytics:read"], {family: [SOURCE_A]}) + for method, path in ( + ("GET", "/api/get_conversations"), + ("GET", "/api/get_single_conversation?id=c1"), + ("GET", "/api/search_conversations?q=x"), + ("POST", "/api/delete_conversation"), + ("POST", "/api/feedback"), + ("POST", "/api/get_message_analytics"), + ("POST", "/api/get_user_logs"), + ): + assert _denied(_call(client, method, path, claims, json={})) == "resource_not_allowed", path + + +@pytest.mark.unit +class TestAgentKeyVisibility: + def _request(self, flask_app, claims): + from flask import request + + ctx = flask_app.test_request_context("/") + ctx.push() + request.decoded_token = claims + return ctx, request + + def test_sessions_and_tokens_with_the_keys_scope_see_the_key(self, flask_app): + for claims in ({"sub": "alice"}, _claims(["agents:write", "agents:keys"])): + ctx, request = self._request(flask_app, claims) + try: + assert rules.may_see_agent_keys(request) is True + finally: + ctx.pop() + + def test_token_without_the_keys_scope_does_not(self, flask_app): + ctx, request = self._request(flask_app, _claims(["agents:write"])) + try: + assert rules.may_see_agent_keys(request) is False + finally: + ctx.pop() + + def test_mask(self): + assert rules.mask_agent_key("12345678-aaaa-bbbb-cccc-1234567890ab") == "1234...90ab" + assert rules.mask_agent_key("") == "" and rules.mask_agent_key(None) == "" + + @pytest.mark.unit class TestChatRestrictions: def _chat(self, client, claims, body): @@ -274,6 +370,52 @@ class TestChatRestrictions: assert self._chat(client, claims, {"question": "hi", "active_docs": [SOURCE_A, SOURCE_B]}) == "resource_not_allowed" assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_A}) == "resource_not_allowed" + @pytest.mark.parametrize("extra", [{}, {"agents": [AGENT_A]}]) + def test_tools_restricted_token_cannot_chat_at_all(self, client, extra): + claims = _claims(["chat:run"], {"tools": [TOOL_A], **extra}) + assert self._chat(client, claims, {"question": "hi"}) == "resource_not_allowed" + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_A}) == "resource_not_allowed" + + @pytest.mark.parametrize("family", ["prompts", "workflows"]) + def test_agentless_chat_needs_an_agent_restriction_unless_only_sources_are_restricted(self, client, family): + claims = _claims(["chat:run"], {family: [SOURCE_A]}) + assert self._chat(client, claims, {"question": "hi"}) == "resource_not_allowed" + assert self._chat(client, claims, {"question": "hi", "agent_id": AGENT_A}) == "resource_not_allowed" + + def test_conversation_must_belong_to_the_agent_being_run(self, client): + claims = _claims(["chat:run"], {"agents": [AGENT_A]}) + body = {"question": "hi", "agent_id": AGENT_A, "conversation_id": "c1"} + with patch.object(rules, "_conversation_agent_id", return_value=(True, AGENT_A.upper())) as lookup: + assert self._chat(client, claims, body) is None + lookup.assert_called_once_with("c1", "alice") + for result in ((True, AGENT_B), (True, ""), (False, "")): + with patch.object(rules, "_conversation_agent_id", return_value=result): + assert self._chat(client, claims, body) == "resource_not_allowed", result + + def test_conversation_resume_with_tool_actions_is_held_to_the_same_rule(self, client): + claims = _claims(["chat:run"], {"agents": [AGENT_A]}) + body = {"agent_id": AGENT_A, "conversation_id": "c1", "tool_actions": [{"call_id": "x"}]} + with patch.object(rules, "_conversation_agent_id", return_value=(True, AGENT_B)): + assert self._chat(client, claims, body) == "resource_not_allowed" + + def test_agentless_token_cannot_continue_an_agent_conversation(self, client): + claims = _claims(["chat:run"], {"sources": [SOURCE_A]}) + body = {"question": "hi", "active_docs": SOURCE_A, "conversation_id": "c1"} + with patch.object(rules, "_conversation_agent_id", return_value=(True, AGENT_B)): + assert self._chat(client, claims, body) == "resource_not_allowed" + with patch.object(rules, "_conversation_agent_id", return_value=(True, "")): + assert self._chat(client, claims, body) is None + + def test_unrestricted_token_never_pays_for_the_conversation_lookup(self, client): + with patch.object(rules, "_conversation_agent_id") as lookup: + self._chat(client, _claims(["chat:run"]), {"question": "hi", "conversation_id": "c1"}) + lookup.assert_not_called() + + def test_conversation_lookup_fails_closed(self): + with patch("docsgpt.storage.db.session.db_readonly", side_effect=RuntimeError("db down")): + assert rules._conversation_agent_id("c1", "alice") == (False, "") + assert rules._conversation_agent_id("c1", None) == (False, "") + def test_retrieval_test_honours_the_source_allowlist(self, client): claims = _claims(["chat:run"], {"sources": [SOURCE_A]}) ok = _call(client, "POST", f"/api/sources/{SOURCE_A}/search", claims, json={"query": "q"}) diff --git a/tests/api/test_pat_tokens.py b/tests/api/test_pat_tokens.py index 1e38d0ce..24f6a5bb 100644 --- a/tests/api/test_pat_tokens.py +++ b/tests/api/test_pat_tokens.py @@ -113,6 +113,11 @@ class TestResourceFilter: ) assert set(out) == {"agents", "sources"} + def test_tools_restriction_cannot_be_combined_with_chat(self): + with pytest.raises(ValueError, match="chat:run"): + tokens.normalize_resource_filter({"tools": [self.UUID_A]}, ["tools:read", "chat:run"]) + assert tokens.normalize_resource_filter({"tools": [self.UUID_A]}, ["tools:read"]) + @pytest.mark.parametrize( "raw,scopes", [ diff --git a/tests/storage/db/repositories/test_personal_access_tokens.py b/tests/storage/db/repositories/test_personal_access_tokens.py index 8a6848a3..0f4732f0 100644 --- a/tests/storage/db/repositories/test_personal_access_tokens.py +++ b/tests/storage/db/repositories/test_personal_access_tokens.py @@ -150,7 +150,7 @@ class TestRevoke: _create(repo, name="a", token_hash="h1") _create(repo, name="b", token_hash="h2") _create(repo, user_id="u2", token_hash="h3") - assert repo.revoke_all_for_user("u1") == 2 + assert len(repo.revoke_all_for_user("u1")) == 2 assert repo.list_for_user("u1") == [] assert len(repo.list_for_user("u2")) == 1 From 3e38dc19eca562b8b3e27005e965bd0eebe533b2 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:47:26 +0100 Subject: [PATCH 11/13] docs: key CI source uploads by commit so a revert is ingested again --- docs/content/Extensions/personal-access-tokens.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx index 47d3624d..7e5be596 100644 --- a/docs/content/Extensions/personal-access-tokens.mdx +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -86,7 +86,7 @@ jobs: DOCSGPT_URL: ${{ vars.DOCSGPT_URL }} DOCSGPT_TOKEN: ${{ secrets.DOCSGPT_TOKEN }} run: | - docsgpt-cli sources upload docs/*.md --name "Product docs" --wait --replace + docsgpt-cli sources upload docs/*.md --name "Product docs" --wait --replace --idempotency-key "docs-${{ github.sha }}" docsgpt-cli agents apply -f agents/ ``` From 91f2ec2f092a583655e4440b1b6685fcb94f99dd Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:32:17 +0100 Subject: [PATCH 12/13] feat(pat): regenerate a token's secret and reset its expiry POST /api/user/tokens//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. --- .../Extensions/personal-access-tokens.mdx | 6 +- .../versions/0032_personal_access_tokens.py | 4 +- docsgpt/api/pat/routes.py | 62 ++++++++++ docsgpt/api/pat/rules.py | 1 + docsgpt/api/pat/tokens.py | 27 +++++ docsgpt/storage/db/models.py | 1 + .../db/repositories/personal_access_tokens.py | 37 +++++- tests/api/test_pat_routes.py | 114 ++++++++++++++++++ tests/api/test_pat_rules.py | 1 + .../test_personal_access_tokens.py | 26 ++++ 10 files changed, 275 insertions(+), 4 deletions(-) diff --git a/docs/content/Extensions/personal-access-tokens.mdx b/docs/content/Extensions/personal-access-tokens.mdx index 7e5be596..e3ae3647 100644 --- a/docs/content/Extensions/personal-access-tokens.mdx +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -20,7 +20,7 @@ Every token is limited in three ways: 3. Optionally restrict it to specific resources and pick an expiry. 4. Copy the token. It starts with `dgpt_pat_` and is shown **once**. DocsGPT stores only a hash of it, so a lost token cannot be recovered. Revoke it and create a new one. -Tokens can only be created and revoked from a signed-in session. A token cannot create, list or revoke tokens, so a leaked token cannot mint a replacement for itself. +Tokens can only be created, regenerated and revoked from a signed-in session. A token cannot create, list, regenerate or revoke tokens, so a leaked token cannot mint a replacement for itself. ## Using a token @@ -159,11 +159,12 @@ Restrictions and `agents apply`: a token restricted to specific agents can apply - A token created without an explicit lifetime expires after `PAT_DEFAULT_LIFETIME_DAYS` (90 by default). Users can choose any lifetime up to `PAT_MAX_LIFETIME_DAYS` (365 by default). - Non-expiring tokens are available only when the operator sets `PAT_ALLOW_NON_EXPIRING=true`. +- **Regenerate** in **Settings → Access Tokens** issues a new secret for the same token and resets its expiry. The name, scopes and restrictions stay; the old secret stops working immediately, so update whatever uses it. The new lifetime defaults to the one the token was last issued with, and an expired token can be renewed this way (a revoked one cannot). This is the way to rotate a secret or extend a token without rebuilding its scopes. - Revoking a token in **Settings → Access Tokens** takes effect on the next request. - Admins can list a user's tokens with `GET /api/admin/users//tokens` and revoke any token with `DELETE /api/admin/tokens/`. The admin **revoke sessions** action also revokes all of that user's tokens. - Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated. - `GET /api/user/tokens` reports a token past its expiry as `"status": "expired"`. -- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins. +- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_regenerated`, `pat_revoked`), visible to admins. An expired token's name can be reused: creating a token with that name retires the expired one. @@ -191,6 +192,7 @@ These endpoints need a signed-in session and cannot be called with a token. | --- | --- | | `GET /api/user/tokens` | List your tokens, the scope catalog and the server's token policy | | `POST /api/user/tokens` | Create a token. Body: `name`, `scopes`, optional `resource_filter`, optional `expires_in_days` (`0` = never, when allowed). The response carries the plaintext `token` once | +| `POST /api/user/tokens//regenerate` | New secret and new expiry for the same token. Optional body `expires_in_days`; omitted = the lifetime it was last issued with. The response carries the plaintext `token` once | | `DELETE /api/user/tokens/` | Revoke a token | ## Good practice diff --git a/docsgpt/alembic/versions/0032_personal_access_tokens.py b/docsgpt/alembic/versions/0032_personal_access_tokens.py index 7d5090bf..6135bc3d 100644 --- a/docsgpt/alembic/versions/0032_personal_access_tokens.py +++ b/docsgpt/alembic/versions/0032_personal_access_tokens.py @@ -10,7 +10,8 @@ user can tell their tokens apart in the UI. itself). ``resource_filter`` optionally narrows a resource family to specific ids, e.g. ``{"agents": [""]}``; an absent family is unrestricted within the token's scopes. ``expires_at`` is NULL only when the operator allows -non-expiring tokens. +non-expiring tokens. Regenerating a token swaps its secret in place and stamps +``regenerated_at``; the row, its name, scopes and restrictions stay. ``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and ``user_roles`` so a token row never blocks user deletion. @@ -47,6 +48,7 @@ def upgrade() -> None: last_used_at TIMESTAMPTZ, last_used_ip TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + regenerated_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoke_reason TEXT ); diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py index 7c2ecc36..b8a9e00d 100644 --- a/docsgpt/api/pat/routes.py +++ b/docsgpt/api/pat/routes.py @@ -23,6 +23,7 @@ from docsgpt.api.pat.tokens import ( is_pat, normalize_resource_filter, normalize_scopes, + renewal_lifetime_days, resolve_expiry, ) from docsgpt.api.user.authz import admin_required @@ -84,6 +85,7 @@ def serialize_token(row: dict) -> dict: "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"), } @@ -214,6 +216,66 @@ class PersonalAccessToken(Resource): 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 diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index 784f764c..9b69d1a7 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -321,6 +321,7 @@ DENIED: dict[str, tuple[str, ...]] = { "/": ("*",), "/api/user/tokens": ("*",), "/api/user/tokens/": ("*",), + "/api/user/tokens//regenerate": ("*",), "/api/generate_token": ("*",), "/api/combine": ("*",), "/api/download": ("*",), diff --git a/docsgpt/api/pat/tokens.py b/docsgpt/api/pat/tokens.py index 6e4978bd..6a2050a9 100644 --- a/docsgpt/api/pat/tokens.py +++ b/docsgpt/api/pat/tokens.py @@ -169,6 +169,33 @@ def resolve_expiry(expires_in_days: Any) -> Optional[datetime]: return datetime.now(timezone.utc) + timedelta(days=days) +def _parse_moment(value: Any) -> Optional[datetime]: + if not value: + return None + try: + moment = value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + except ValueError: + return None + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def renewal_lifetime_days(row: dict) -> Optional[int]: + """The lifetime a token was last issued with, for renewing it on the same terms. + + ``0`` for a non-expiring token, ``None`` when it cannot be derived (the + caller then falls back to the default). The result is clamped to today's + maximum, since the policy may have tightened since the token was issued. + """ + issued = _parse_moment(row.get("regenerated_at")) or _parse_moment(row.get("created_at")) + expires = _parse_moment(row.get("expires_at")) + if expires is None: + return 0 if settings.PAT_ALLOW_NON_EXPIRING else None + if issued is None: + return None + days = round((expires - issued).total_seconds() / 86400) + return max(1, min(days, settings.PAT_MAX_LIFETIME_DAYS)) + + def _client_ip(request) -> Optional[str]: # Flask exposes remote_addr; Starlette exposes client.host. ip = getattr(request, "remote_addr", None) diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index 65527c76..a0df1a06 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -1098,6 +1098,7 @@ personal_access_tokens_table = Table( Column("last_used_at", DateTime(timezone=True)), Column("last_used_ip", Text), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("regenerated_at", DateTime(timezone=True)), Column("revoked_at", DateTime(timezone=True)), Column("revoke_reason", Text), CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"), diff --git a/docsgpt/storage/db/repositories/personal_access_tokens.py b/docsgpt/storage/db/repositories/personal_access_tokens.py index 12bd80cc..8d161bf4 100644 --- a/docsgpt/storage/db/repositories/personal_access_tokens.py +++ b/docsgpt/storage/db/repositories/personal_access_tokens.py @@ -14,7 +14,7 @@ from docsgpt.storage.db.base_repository import row_to_dict # token_hash never leaves the repository except through find_active_by_hash. _PUBLIC_COLUMNS = ( "id, user_id, name, token_prefix, scopes, resource_filter, status, " - "expires_at, last_used_at, last_used_ip, created_at, revoked_at, revoke_reason" + "expires_at, last_used_at, last_used_ip, created_at, regenerated_at, revoked_at, revoke_reason" ) @@ -155,6 +155,41 @@ class PersonalAccessTokensRepository: {"id": token_id, "ip": ip, "min_interval": min_interval_seconds}, ) + def regenerate( + self, + token_id: str, + user_id: str, + *, + token_hash: str, + token_prefix: str, + expires_at: Optional[datetime], + ) -> Optional[dict]: + """Swap a live token's secret and expiry in place. The old secret stops matching at once. + + Expired tokens qualify (renewal is the point); revoked ones do not. + Usage fields are cleared because they described the old secret. + """ + row = self._conn.execute( + text( + f""" + UPDATE personal_access_tokens + SET token_hash = :token_hash, token_prefix = :token_prefix, + expires_at = :expires_at, regenerated_at = now(), + last_used_at = NULL, last_used_ip = NULL + WHERE id = CAST(:id AS uuid) AND user_id = :user_id AND status = 'active' + RETURNING {_PUBLIC_COLUMNS} + """ + ), + { + "id": token_id, + "user_id": user_id, + "token_hash": token_hash, + "token_prefix": token_prefix, + "expires_at": expires_at, + }, + ).fetchone() + return row_to_dict(row) if row is not None else None + def revoke(self, token_id: str, user_id: Optional[str] = None, *, reason: str = "user_revoked") -> bool: """Revoke one token. ``user_id=None`` is the admin path (any owner).""" sql = ( diff --git a/tests/api/test_pat_routes.py b/tests/api/test_pat_routes.py index cf21c8c5..f4530553 100644 --- a/tests/api/test_pat_routes.py +++ b/tests/api/test_pat_routes.py @@ -223,6 +223,120 @@ class TestEndToEnd: assert response.status_code == 401 +class TestRegenerate: + def _regen(self, client, token_id, **body): + with _session(): + return client.post(f"/api/user/tokens/{token_id}/regenerate", json=body) + + def test_new_secret_works_old_one_stops_and_the_rest_is_kept(self, client, db): + created = json.loads( + _create(client, scopes=["prompts:read"], resource_filter={"prompts": [AGENT_A]}).data + ) + old, token_id = created["token"], created["personal_access_token"]["id"] + response = self._regen(client, token_id) + assert response.status_code == 200 + body = json.loads(response.data) + new, public = body["token"], body["personal_access_token"] + assert new.startswith("dgpt_pat_") and new != old + assert public["id"] == token_id and public["name"] == "ci" + assert public["scopes"] == ["prompts:read"] + assert public["resource_filter"] == {"prompts": [AGENT_A]} + assert public["token_prefix"] == new[:15] + assert public["regenerated_at"] is not None + assert public["last_used_at"] is None + + assert client.get("/api/user/me", headers={"Authorization": f"Bearer {old}"}).status_code == 401 + me = client.get("/api/user/me", headers={"Authorization": f"Bearer {new}"}) + assert me.status_code == 200 + assert json.loads(me.data)["token"]["id"] == token_id + stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one() + assert stored == pat_tokens.hash_token(new) + + def test_expiry_is_reset_to_the_original_lifetime(self, client, db): + token_id = json.loads(_create(client, expires_in_days=30).data)["personal_access_token"]["id"] + # 20 days in: 10 days left. + db.execute( + text( + "UPDATE personal_access_tokens SET created_at = now() - interval '20 days', " + "expires_at = now() + interval '10 days'" + ) + ) + self._regen(client, token_id) + days_left = db.execute( + text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens") + ).scalar_one() + assert 29.9 < float(days_left) < 30.1 + + def test_explicit_lifetime_is_honoured_and_capped(self, client, db): + token_id = json.loads(_create(client).data)["personal_access_token"]["id"] + assert self._regen(client, token_id, expires_in_days=7).status_code == 200 + days_left = db.execute( + text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens") + ).scalar_one() + assert 6.9 < float(days_left) < 7.1 + assert self._regen(client, token_id, expires_in_days=366).status_code == 400 + assert self._regen(client, token_id, expires_in_days=0).status_code == 400 + + def test_an_expired_token_can_be_renewed(self, client, db): + created = json.loads(_create(client, expires_in_days=30).data) + db.execute( + text( + "UPDATE personal_access_tokens SET created_at = now() - interval '31 days', " + "expires_at = now() - interval '1 day'" + ) + ) + old_headers = {"Authorization": f"Bearer {created['token']}"} + assert client.get("/api/user/me", headers=old_headers).status_code == 401 + body = json.loads(self._regen(client, created["personal_access_token"]["id"]).data) + assert body["personal_access_token"]["status"] == "active" + headers = {"Authorization": f"Bearer {body['token']}"} + assert client.get("/api/user/me", headers=headers).status_code == 200 + + def test_non_expiring_token_stays_non_expiring_only_while_the_operator_allows_it( + self, client, db, monkeypatch + ): + monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True) + token_id = json.loads(_create(client, expires_in_days=0).data)["personal_access_token"]["id"] + assert json.loads(self._regen(client, token_id).data)["personal_access_token"]["expires_at"] is None + monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False) + renewed = json.loads(self._regen(client, token_id).data)["personal_access_token"] + assert renewed["expires_at"] is not None # falls back to the default lifetime + + def test_revoked_foreign_unknown_and_malformed_tokens_are_not_found(self, client, db): + token_id = json.loads(_create(client).data)["personal_access_token"]["id"] + with _session(sub="bob"): + assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 404 + with _session(): + client.delete(f"/api/user/tokens/{token_id}") + assert self._regen(client, token_id).status_code == 404 + assert self._regen(client, AGENT_A).status_code == 404 + assert self._regen(client, f"urn:uuid:{AGENT_A}").status_code == 404 + + def test_a_token_cannot_regenerate_itself_or_others(self, client, db): + created = json.loads(_create(client, scopes=list(pat_tokens.SCOPES)).data) + headers = {"Authorization": f"Bearer {created['token']}"} + token_id = created["personal_access_token"]["id"] + response = client.post(f"/api/user/tokens/{token_id}/regenerate", headers=headers) + assert response.status_code == 403 + assert json.loads(response.data)["error"] == "not_available_to_tokens" + + def test_requires_a_session_and_an_object_body(self, client, db): + token_id = json.loads(_create(client).data)["personal_access_token"]["id"] + with patch("docsgpt.app.handle_auth", return_value=None): + assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 401 + with _session(): + assert client.post(f"/api/user/tokens/{token_id}/regenerate", json=[1]).status_code == 400 + + def test_audited_without_the_secret(self, client, db): + token_id = json.loads(_create(client).data)["personal_access_token"]["id"] + self._regen(client, token_id) + metadata = db.execute( + text("SELECT metadata FROM auth_events WHERE event = 'pat_regenerated'") + ).scalar_one() + assert metadata["token_id"] == token_id + assert "dgpt_pat_" not in json.dumps(metadata) + + class TestRevoke: def test_cannot_revoke_someone_elses_token(self, client, db): token_id = json.loads(_create(client).data)["personal_access_token"]["id"] diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py index 69e532a3..a1bb0977 100644 --- a/tests/api/test_pat_rules.py +++ b/tests/api/test_pat_rules.py @@ -91,6 +91,7 @@ class TestClassification: ("/api/user/tokens", "POST"), ("/api/user/tokens", "GET"), ("/api/user/tokens/", "DELETE"), + ("/api/user/tokens//regenerate", "POST"), ("/api/admin/users", "GET"), ("/api/admin/tokens/", "DELETE"), ("/api/generate_token", "GET"), diff --git a/tests/storage/db/repositories/test_personal_access_tokens.py b/tests/storage/db/repositories/test_personal_access_tokens.py index 0f4732f0..8d2ca531 100644 --- a/tests/storage/db/repositories/test_personal_access_tokens.py +++ b/tests/storage/db/repositories/test_personal_access_tokens.py @@ -179,3 +179,29 @@ class TestCountAndUsage: assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.1" repo.touch_last_used(str(row["id"]), "10.0.0.3", min_interval_seconds=0) assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.3" + + +class TestRegenerate: + def test_swaps_the_secret_and_resets_expiry_and_usage(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo, expires_at=datetime.now(timezone.utc) - timedelta(days=1)) + repo.touch_last_used(str(row["id"]), "10.0.0.1") + future = datetime.now(timezone.utc) + timedelta(days=30) + renewed = repo.regenerate( + str(row["id"]), "u1", token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=future + ) + assert renewed["id"] == row["id"] and renewed["name"] == "ci" + assert renewed["token_prefix"] == "dgpt_pat_new123" + assert renewed["regenerated_at"] is not None + assert renewed["last_used_at"] is None and renewed["last_used_ip"] is None + assert repo.find_active_by_hash("h1") is None + assert repo.find_active_by_hash("h-new")["id"] == row["id"] + + def test_owner_scoped_and_never_revives_a_revoked_token(self, pg_conn): + repo = PersonalAccessTokensRepository(pg_conn) + row = _create(repo) + kwargs = dict(token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=None) + assert repo.regenerate(str(row["id"]), "someone-else", **kwargs) is None + repo.revoke(str(row["id"]), "u1") + assert repo.regenerate(str(row["id"]), "u1", **kwargs) is None + assert repo.find_active_by_hash("h-new") is None From 52069e3f8a8a2aec4a22234712b84c33e2510d68 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:32:17 +0100 Subject: [PATCH 13/13] feat(frontend): regenerate access tokens; fix resource pickers inside the create modal Each token gets a Regenerate action: a confirmation with the new expiration (preselecting the lifetime the token was issued with), then the one-time secret view. The "restrict to specific resources" pickers could not be scrolled and did not close on an outside click. Inside a Modal a non-modal popover is portalled outside the dialog, so the dialog's scroll lock swallowed the wheel, and Radix defers its outside-click dismissal to the document click, which Modal stops from propagating. MultiSelect takes a `modal` prop for that case. --- frontend/src/api/endpoints.ts | 2 + frontend/src/api/services/patService.test.ts | 44 ++++ frontend/src/api/services/patService.ts | 20 ++ frontend/src/components/ui/multi-select.tsx | 12 +- frontend/src/locale/de.json | 14 ++ frontend/src/locale/en.json | 14 ++ frontend/src/locale/es.json | 14 ++ frontend/src/locale/jp.json | 14 ++ frontend/src/locale/ru.json | 14 ++ frontend/src/locale/zh-TW.json | 14 ++ frontend/src/locale/zh.json | 14 ++ .../src/modals/AccessTokenCreatedModal.tsx | 12 +- .../src/modals/CreateAccessTokenModal.tsx | 1 + .../src/modals/RegenerateAccessTokenModal.tsx | 191 ++++++++++++++++++ .../src/settings/PersonalAccessTokens.tsx | 53 ++++- .../src/settings/accessTokenUtils.test.ts | 57 ++++++ frontend/src/settings/accessTokenUtils.ts | 25 +++ 17 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 frontend/src/modals/RegenerateAccessTokenModal.tsx diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index cd19899c..6f5f36e6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -154,6 +154,8 @@ const endpoints = { `/api/devices/pairings/${deviceCode}`, ACCESS_TOKENS: '/api/user/tokens', ACCESS_TOKEN: (id: string) => `/api/user/tokens/${id}`, + ACCESS_TOKEN_REGENERATE: (id: string) => + `/api/user/tokens/${id}/regenerate`, }, V1: { CHAT_COMPLETIONS: '/v1/chat/completions', diff --git a/frontend/src/api/services/patService.test.ts b/frontend/src/api/services/patService.test.ts index 63b7a88e..e6f479c9 100644 --- a/frontend/src/api/services/patService.test.ts +++ b/frontend/src/api/services/patService.test.ts @@ -154,3 +154,47 @@ describe('patService.revoke', () => { ); }); }); + +describe('patService.regenerate', () => { + it('POSTs the chosen lifetime to the regenerate endpoint', async () => { + const body = { + success: true, + token: 'dgpt_pat_newsecret', + personal_access_token: TOKEN_ROW, + }; + const spy = vi.spyOn(apiClient, 'post').mockResolvedValue(response(body)); + + const result = await patService.regenerate(TOKEN_ROW.id, 30, 'session-jwt'); + + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}/regenerate`, + { expires_in_days: 30 }, + 'session-jwt', + ); + expect(result.token).toBe('dgpt_pat_newsecret'); + }); + + it('sends an empty body to keep the original lifetime', async () => { + const spy = vi + .spyOn(apiClient, 'post') + .mockResolvedValue(response({ success: true, token: 't' })); + + await patService.regenerate(TOKEN_ROW.id, undefined, null); + + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}/regenerate`, + {}, + null, + ); + }); + + it('surfaces the server message', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message: 'Token not found' }, 404), + ); + + await expect(patService.regenerate('missing', 30, null)).rejects.toThrow( + 'Token not found', + ); + }); +}); diff --git a/frontend/src/api/services/patService.ts b/frontend/src/api/services/patService.ts index 09eb197a..fda7017f 100644 --- a/frontend/src/api/services/patService.ts +++ b/frontend/src/api/services/patService.ts @@ -14,6 +14,8 @@ export interface PersonalAccessToken { last_used_at: string | null; last_used_ip: string | null; created_at: string | null; + /** Set once the secret has been regenerated; the lifetime then counts from here. */ + regenerated_at?: string | null; revoked_at: string | null; } @@ -95,6 +97,24 @@ const patService = { await apiClient.post(endpoints.USER.ACCESS_TOKENS, payload, token), ), + /** + * New secret for the same token (name, scopes and restrictions stay); the old + * secret stops working at once. `expiresInDays` omitted = the lifetime the + * token was last issued with. + */ + regenerate: async ( + id: string, + expiresInDays: number | undefined, + token: string | null, + ): Promise => + parse( + await apiClient.post( + endpoints.USER.ACCESS_TOKEN_REGENERATE(encodeURIComponent(id)), + expiresInDays === undefined ? {} : { expires_in_days: expiresInDays }, + token, + ), + ), + revoke: async ( id: string, token: string | null, diff --git a/frontend/src/components/ui/multi-select.tsx b/frontend/src/components/ui/multi-select.tsx index 826c658d..e436dd8f 100644 --- a/frontend/src/components/ui/multi-select.tsx +++ b/frontend/src/components/ui/multi-select.tsx @@ -32,6 +32,15 @@ interface MultiSelectProps { emptyText?: string; searchPlaceholder?: string; className?: string; + /** + * Set when the MultiSelect sits inside a Modal. A non-modal popover there + * cannot scroll (the dialog's scroll lock swallows the wheel, since the + * dropdown is portalled outside it) and never closes on an outside click + * (Radix defers that to the document `click`, which Modal stops from + * propagating). A modal popover owns its own scroll lock and dismisses on + * pointerdown instead. + */ + modal?: boolean; } export function MultiSelect({ @@ -42,6 +51,7 @@ export function MultiSelect({ emptyText = 'No results found.', searchPlaceholder = 'Search...', className, + modal = false, }: MultiSelectProps) { const [open, setOpen] = React.useState(false); @@ -63,7 +73,7 @@ export function MultiSelect({ .map((option) => option.label); return ( - + + + + } + > +
+
+

+ {t('settings.accessTokens.regenerate.title')} +

+

+ {t('settings.accessTokens.regenerate.warning', { + name: item?.name ?? '', + ...NO_ESCAPE, + })} +

+
+ +
+ + +

+ {expiry === NO_EXPIRY + ? t('settings.accessTokens.create.noExpirationHint') + : t('settings.accessTokens.create.expiresOn', { + date: formatDateOnly( + new Date(Date.now() + expiry * DAY_MS).toISOString(), + ), + ...NO_ESCAPE, + })} +

+
+ + {error && ( +

+ {error} +

+ )} +
+ + ); +} diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx index f417200c..27cebbb0 100644 --- a/frontend/src/settings/PersonalAccessTokens.tsx +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -28,6 +28,7 @@ import { useDarkTheme } from '../hooks'; import AccessTokenCreatedModal from '../modals/AccessTokenCreatedModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import CreateAccessTokenModal from '../modals/CreateAccessTokenModal'; +import RegenerateAccessTokenModal from '../modals/RegenerateAccessTokenModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; @@ -89,6 +90,10 @@ export default function PersonalAccessTokens() { // The plaintext secret lives only here, and only until the modal closes. const [created, setCreated] = React.useState(null); + // True while `created` holds a regenerated (not brand-new) secret. + const [createdByRegenerate, setCreatedByRegenerate] = React.useState(false); + const [tokenToRegenerate, setTokenToRegenerate] = + React.useState(null); const [revokeState, setRevokeState] = React.useState('INACTIVE'); const [tokenToRevoke, setTokenToRevoke] = React.useState(null); @@ -116,8 +121,20 @@ export default function PersonalAccessTokens() { loadTokens(true); }, [loadTokens]); + const handleRegenerated = (response: CreateAccessTokenResponse) => { + const updated = response.personal_access_token; + setTokenToRegenerate(null); + setCreatedByRegenerate(true); + setCreated(response); + setTokens((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)), + ); + setError(null); + }; + const handleCreated = (response: CreateAccessTokenResponse) => { setCreateOpen(false); + setCreatedByRegenerate(false); setCreated(response); setTokens((prev) => [response.personal_access_token, ...prev]); }; @@ -212,6 +229,23 @@ export default function PersonalAccessTokens() { ); }; + const renderRegenerateButton = (item: PersonalAccessToken) => + policy?.enabled ? ( + + ) : null; + const renderRevokeButton = (item: PersonalAccessToken) => (