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 3221867f..33ea3b53 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`. + +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` + +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/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..e3ae3647 --- /dev/null +++ b/docs/content/Extensions/personal-access-tokens.mdx @@ -0,0 +1,203 @@ +--- +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, 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 + +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)" +``` + +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 + +```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 sources upload docs/*.md --name "Product docs" --wait --replace --idempotency-key "docs-${{ github.sha }}" + 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. 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. + +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: + - **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 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. + +## 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`. +- **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_regenerated`, `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` | 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`. + +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 + +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 | +| `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 + +- 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. 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..6135bc3d --- /dev/null +++ b/docsgpt/alembic/versions/0032_personal_access_tokens.py @@ -0,0 +1,74 @@ +"""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. 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. + +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(), + regenerated_at TIMESTAMPTZ, + 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/api/admin/routes.py b/docsgpt/api/admin/routes.py index 334ad936..3d178904 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,30 @@ 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. + 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", 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": len(revoked_token_ids), + }, ) return make_response(jsonify({"success": True, "revoked": ok}), 200) diff --git a/docsgpt/api/asgi_auth.py b/docsgpt/api/asgi_auth.py index 93f08a23..7c6c2cb9 100644 --- a/docsgpt/api/asgi_auth.py +++ b/docsgpt/api/asgi_auth.py @@ -9,34 +9,63 @@ 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 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: 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 (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. 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. + 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, + ) + 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): return None, JSONResponse( diff --git a/docsgpt/api/async_sse.py b/docsgpt/api/async_sse.py index a5064e2c..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) + # 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/__init__.py b/docsgpt/api/pat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docsgpt/api/pat/routes.py b/docsgpt/api/pat/routes.py new file mode 100644 index 00000000..b8a9e00d --- /dev/null +++ b/docsgpt/api/pat/routes.py @@ -0,0 +1,313 @@ +"""Personal access token management. + +``/api/user/tokens`` lets a signed-in user list, create and revoke their own +tokens; ``/api/admin/...`` lets an admin inspect and revoke anyone's. None of +these routes accept a PAT (see ``docsgpt/api/pat/rules.py``), so a leaked +token can neither mint a replacement nor widen itself. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from flask import jsonify, make_response, request +from flask_restx import Namespace, Resource +from sqlalchemy.exc import IntegrityError + +from docsgpt.api.pat.tokens import ( + FILTERABLE_FAMILIES, + SCOPES, + auth_type_supports_pats, + generate_token, + is_pat, + normalize_resource_filter, + normalize_scopes, + renewal_lifetime_days, + resolve_expiry, +) +from docsgpt.api.user.authz import admin_required +from docsgpt.core.settings import settings +from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository +from docsgpt.storage.db.repositories.personal_access_tokens import ( + PersonalAccessTokensRepository, +) +from docsgpt.storage.db.session import db_readonly, db_session + +pat_ns = Namespace("tokens", description="Personal access tokens", path="/api") + +_MAX_NAME_LENGTH = 100 + + +def _error(message: str, status: int): + return make_response(jsonify({"success": False, "message": message}), status) + + +def _session_user_id(): + """The caller's id, or ``None`` for anonymous and PAT callers alike.""" + decoded = getattr(request, "decoded_token", None) + if not decoded or is_pat(decoded): + return None + return decoded.get("sub") + + +def _valid_uuid(value: str) -> bool: + """Canonical form only: ``uuid.UUID`` also accepts ``urn:uuid:…`` and braces, which Postgres does not.""" + try: + return str(uuid.UUID(str(value))) == str(value).lower() + except (ValueError, AttributeError, TypeError): + return False + + +def _is_expired(expires_at) -> bool: + if not expires_at: + return False + try: + moment = expires_at if isinstance(expires_at, datetime) else datetime.fromisoformat(str(expires_at)) + except ValueError: + return False + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment <= datetime.now(timezone.utc) + + +def serialize_token(row: dict) -> dict: + # The row keeps status 'active' until someone revokes it; report what is true for a caller. + status = "expired" if row["status"] == "active" and _is_expired(row.get("expires_at")) else row["status"] + return { + "id": str(row["id"]), + "name": row["name"], + "token_prefix": row["token_prefix"], + "scopes": list(row.get("scopes") or []), + "resource_filter": row.get("resource_filter") or {}, + "status": status, + "expires_at": row.get("expires_at"), + "last_used_at": row.get("last_used_at"), + "last_used_ip": row.get("last_used_ip"), + "created_at": row.get("created_at"), + "regenerated_at": row.get("regenerated_at"), + "revoked_at": row.get("revoked_at"), + } + + +def _policy() -> dict: + return { + "enabled": auth_type_supports_pats(), + "default_lifetime_days": settings.PAT_DEFAULT_LIFETIME_DAYS, + "max_lifetime_days": settings.PAT_MAX_LIFETIME_DAYS, + "allow_non_expiring": settings.PAT_ALLOW_NON_EXPIRING, + "max_per_user": settings.PAT_MAX_PER_USER, + "filterable_families": list(FILTERABLE_FAMILIES), + } + + +@pat_ns.route("/user/tokens") +class PersonalAccessTokens(Resource): + def get(self): + """List the caller's tokens with the scope catalog and the server's token policy.""" + user_id = _session_user_id() + if not user_id: + return _error("Authentication required", 401) + with db_readonly() as conn: + rows = PersonalAccessTokensRepository(conn).list_for_user(user_id) + return make_response( + jsonify( + { + "success": True, + "tokens": [serialize_token(r) for r in rows], + "scopes": [{"name": k, "description": v} for k, v in SCOPES.items()], + "policy": _policy(), + } + ), + 200, + ) + + def post(self): + """Create a token. The plaintext ``token`` is returned here and never again.""" + user_id = _session_user_id() + if not user_id: + return _error("Authentication required", 401) + if not auth_type_supports_pats(): + return _error("Personal access tokens are not available on this server", 403) + + body = request.get_json(silent=True) + if body is None: + body = {} + if not isinstance(body, dict): + return _error("Request body must be a JSON object", 400) + name = body.get("name") + if not isinstance(name, str) or not name.strip(): + return _error("name is required", 400) + name = name.strip() + if len(name) > _MAX_NAME_LENGTH: + return _error(f"name must be at most {_MAX_NAME_LENGTH} characters", 400) + try: + scopes = normalize_scopes(body.get("scopes")) + resource_filter = normalize_resource_filter(body.get("resource_filter"), scopes) + expires_at = resolve_expiry(body.get("expires_in_days")) + except ValueError as exc: + return _error(str(exc), 400) + + token, token_hash, token_prefix = generate_token() + try: + with db_session() as conn: + repo = PersonalAccessTokensRepository(conn) + # Serialise this user's creates so concurrent requests cannot both pass the cap check. + repo.lock_user(user_id) + if repo.count_active(user_id) >= settings.PAT_MAX_PER_USER: + return _error( + f"Token limit reached ({settings.PAT_MAX_PER_USER}); revoke one first", 409 + ) + repo.retire_expired_name(user_id, name) + if repo.name_in_use(user_id, name): + return _error("A token with this name already exists", 409) + row = repo.create( + user_id, + name, + token_hash=token_hash, + token_prefix=token_prefix, + scopes=scopes, + resource_filter=resource_filter, + expires_at=expires_at, + ) + AuthEventsRepository(conn).insert( + user_id, + "pat_created", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={ + "token_id": str(row["id"]), + "name": name, + "scopes": scopes, + "resource_filter": resource_filter, + "expires_at": row.get("expires_at"), + }, + ) + except IntegrityError: + # Lost a race against a concurrent create with the same name. + return _error("A token with this name already exists", 409) + return make_response( + jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}), + 201, + ) + + +@pat_ns.route("/user/tokens/") +class PersonalAccessToken(Resource): + def delete(self, token_id): + """Revoke one of the caller's tokens. Takes effect on the next request.""" + user_id = _session_user_id() + if not user_id: + return _error("Authentication required", 401) + if not _valid_uuid(token_id): + return _error("Token not found", 404) + with db_session() as conn: + revoked = PersonalAccessTokensRepository(conn).revoke(token_id, user_id) + if revoked: + AuthEventsRepository(conn).insert( + user_id, + "pat_revoked", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={"token_id": token_id, "by": user_id}, + ) + if not revoked: + return _error("Token not found", 404) + return make_response(jsonify({"success": True}), 200) + + +@pat_ns.route("/user/tokens//regenerate") +class PersonalAccessTokenRegenerate(Resource): + def post(self, token_id): + """Issue a new secret for a token and reset its expiry. + + Name, scopes and restrictions stay; the old secret stops working at + once. ``expires_in_days`` is optional and defaults to the lifetime the + token was last issued with. An expired token can be renewed this way; a + revoked one cannot. The plaintext ``token`` is returned here and never again. + """ + user_id = _session_user_id() + if not user_id: + return _error("Authentication required", 401) + if not auth_type_supports_pats(): + return _error("Personal access tokens are not available on this server", 403) + if not _valid_uuid(token_id): + return _error("Token not found", 404) + body = request.get_json(silent=True) + if body is None: + body = {} + if not isinstance(body, dict): + return _error("Request body must be a JSON object", 400) + + token, token_hash, token_prefix = generate_token() + with db_session() as conn: + repo = PersonalAccessTokensRepository(conn) + current = repo.get(token_id, user_id) + if not current or current["status"] != "active": + return _error("Token not found", 404) + requested = body.get("expires_in_days") + if requested is None: + requested = renewal_lifetime_days(current) + try: + expires_at = resolve_expiry(requested) + except ValueError as exc: + return _error(str(exc), 400) + row = repo.regenerate( + token_id, user_id, token_hash=token_hash, token_prefix=token_prefix, expires_at=expires_at + ) + if not row: + # Revoked between the read and the write. + return _error("Token not found", 404) + AuthEventsRepository(conn).insert( + user_id, + "pat_regenerated", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={ + "token_id": token_id, + "name": row["name"], + "expires_at": row.get("expires_at"), + "previous_expires_at": current.get("expires_at"), + }, + ) + return make_response( + jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}), + 200, + ) + + +@pat_ns.route("/admin/users//tokens") +class AdminUserTokens(Resource): + @admin_required + def get(self, user_id): + """List a user's tokens, revoked ones included.""" + with db_readonly() as conn: + rows = PersonalAccessTokensRepository(conn).list_for_user(user_id, include_revoked=True) + return make_response( + jsonify({"success": True, "tokens": [serialize_token(r) for r in rows]}), 200 + ) + + +@pat_ns.route("/admin/tokens/") +class AdminToken(Resource): + @admin_required + def delete(self, token_id): + """Revoke any user's token.""" + if not _valid_uuid(token_id): + return _error("Token not found", 404) + actor = (getattr(request, "decoded_token", None) or {}).get("sub") + with db_session() as conn: + repo = PersonalAccessTokensRepository(conn) + row = repo.get(token_id) + revoked = bool(row) and repo.revoke(token_id, reason="admin_revoked") + if revoked: + AuthEventsRepository(conn).insert( + row["user_id"], + "pat_revoked", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={"token_id": token_id, "by": actor, "via": "admin_api"}, + ) + if not revoked: + return _error("Token not found", 404) + return make_response(jsonify({"success": True}), 200) diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py new file mode 100644 index 00000000..9b69d1a7 --- /dev/null +++ b/docsgpt/api/pat/rules.py @@ -0,0 +1,536 @@ +"""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 + +from docsgpt.api.pat.tokens import is_pat + +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]], 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) + + +#: 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") +_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 = ( + ("sources", (BODY, "source")), + ("sources", (BODY, "sources")), + ("prompts", (BODY, "prompt_id")), + ("tools", (BODY, "tools")), + ("workflows", (BODY, "workflow")), +) + + +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 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"): + # 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 len(agent_ids) != 1: + return "This token is restricted to specific agents; pass agent_id" + # 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 + + +_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, check=_agent_body_check), + ("/api/update_agent/", "PUT"): _rule( + "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"), + ("/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, 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")),), blocked_by=_NON_AGENT_FAMILIES + ), + ("/api/agents//schedules", "POST"): _rule( + "schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES + ), + ("/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=_ALL_FAMILIES + ), + ("/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. + ("/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 + # 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"), blocked_by=_WORKFLOW_CONTENT_FAMILIES + ), + ("/api/workflows/", "DELETE"): _rule("workflows:write", (VIEW, "workflow_id")), + # 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=_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"), + ("/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/user/tokens//regenerate": ("*",), + "/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) + 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 ( + { + "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, 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, 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, user_id) + 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) + if not is_pat(decoded): + 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 + + +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 new file mode 100644 index 00000000..6a2050a9 --- /dev/null +++ b/docsgpt/api/pat/tokens.py @@ -0,0 +1,264 @@ +"""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"}) + 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: + 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 _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) + 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/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..6a0ec2b0 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, mask_agent_key, may_see_agent_keys 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") @@ -762,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/") @@ -1306,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) @@ -1810,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/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/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..ddc56916 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 @@ -294,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/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/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/docsgpt/core/settings/auth.py b/docsgpt/core/settings/auth.py index 85c57b84..706a3339 100644 --- a/docsgpt/core/settings/auth.py +++ b/docsgpt/core/settings/auth.py @@ -85,6 +85,30 @@ 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=( + "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( + 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 +123,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..a0df1a06 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -1079,3 +1079,45 @@ 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("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"), +) + +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..8d161bf4 --- /dev/null +++ b/docsgpt/storage/db/repositories/personal_access_tokens.py @@ -0,0 +1,216 @@ +"""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, regenerated_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 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( + 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 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( + 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 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 = ( + "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") -> 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' RETURNING id" + ), + {"user_id": user_id, "reason": reason}, + ) + return [str(row[0]) for row in result.fetchall()] diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index b6c00973..6f5f36e6 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,10 @@ 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}`, + 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 new file mode 100644 index 00000000..e6f479c9 --- /dev/null +++ b/frontend/src/api/services/patService.test.ts @@ -0,0 +1,200 @@ +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', + ); + }); +}); + +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 new file mode 100644 index 00000000..fda7017f --- /dev/null +++ b/frontend/src/api/services/patService.ts @@ -0,0 +1,130 @@ +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; + /** Set once the secret has been regenerated; the lifetime then counts from here. */ + regenerated_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), + ), + + /** + * 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, + ): 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/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(`${copy}.title`)} +

+

+ {t(`${copy}.subtitle`, { + name, + ...NO_ESCAPE, + })} +

+
+ +
+
+ +
+

+ {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..db64674e --- /dev/null +++ b/frontend/src/modals/CreateAccessTokenModal.tsx @@ -0,0 +1,465 @@ +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_ESCAPE, + 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(), + ), + ...NO_ESCAPE, + })} +

+
+
+ +
+ + {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" + modal + /> + )} +
+ ); + })} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/frontend/src/modals/RegenerateAccessTokenModal.tsx b/frontend/src/modals/RegenerateAccessTokenModal.tsx new file mode 100644 index 00000000..1b217dbb --- /dev/null +++ b/frontend/src/modals/RegenerateAccessTokenModal.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenApiError, + AccessTokenPolicy, + CreateAccessTokenResponse, + PersonalAccessToken, +} from '../api/services/patService'; +import Spinner from '../components/Spinner'; +import { Button } from '../components/ui/button'; +import { Label } from '../components/ui/label'; +import { Modal } from '../components/ui/modal'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + expiryOptions, + NO_ESCAPE, + NO_EXPIRY, + renewalExpiry, +} from '../settings/accessTokenUtils'; +import { formatDateOnly } from '../utils/dateTimeUtils'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +interface RegenerateAccessTokenModalProps { + /** Token to regenerate; `null` keeps the modal closed. */ + item: PersonalAccessToken | null; + policy: AccessTokenPolicy; + onClose: () => void; + onRegenerated: (response: CreateAccessTokenResponse) => void; +} + +export default function RegenerateAccessTokenModal({ + item, + policy, + onClose, + onRegenerated, +}: RegenerateAccessTokenModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [expiry, setExpiry] = React.useState(NO_EXPIRY); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]); + + // Preselect the lifetime the token was last issued with. + React.useEffect(() => { + if (!item) return; + setExpiry(renewalExpiry(item, policy)); + setSubmitting(false); + setError(null); + }, [item, policy]); + + const handleClose = () => { + if (!submitting) onClose(); + }; + + const handleSubmit = async () => { + if (!item || submitting) return; + setSubmitting(true); + setError(null); + try { + onRegenerated(await patService.regenerate(item.id, expiry, token)); + } catch (err) { + console.error('Failed to regenerate access token:', err); + setError( + (err instanceof AccessTokenApiError && err.message) || + t('settings.accessTokens.regenerate.error'), + ); + 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.regenerate.title')} + size="md" + mobileVariant="sheet" + isPerformingTask={submitting} + footer={ + <> + + + + } + > +
+
+

+ {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 new file mode 100644 index 00000000..27cebbb0 --- /dev/null +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -0,0 +1,505 @@ +import { TriangleAlert } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenApiError, + 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 RegenerateAccessTokenModal from '../modals/RegenerateAccessTokenModal'; +import { ActiveState } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; +import { + countLiveTokens, + expiryStatus, + NO_ESCAPE, + 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); + // 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); + + 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 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]); + }; + + 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) { + 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) || + 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 renderRegenerateButton = (item: PersonalAccessToken) => + policy?.enabled ? ( + + ) : null; + + 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)} + + +
+ {renderRegenerateButton(item)} + {renderRevokeButton(item)} +
+
+
+ ))} +
+
+
+ + {/* Mobile / tablet: cards */} +
    + {tokens.map((item) => ( +
  • +
    +
    +

    + {item.name} +

    + {renderPrefix(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), + )} +
    +
    + {renderRegenerateButton(item)} + {renderRevokeButton(item)} +
    +
  • + ))} +
+ + )} +
+ + {policy && ( + setCreateOpen(false)} + scopes={scopes} + policy={policy} + onCreated={handleCreated} + /> + )} + setCreated(null)} + /> + {policy && ( + setTokenToRegenerate(null)} + onRegenerated={handleRegenerated} + /> + )} + +
+ ); +} diff --git a/frontend/src/settings/accessTokenUtils.test.ts b/frontend/src/settings/accessTokenUtils.test.ts new file mode 100644 index 00000000..8b04866e --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildResourceFilter, + countLiveTokens, + defaultExpiry, + eligibleFilterFamilies, + expiryOptions, + expiryStatus, + groupScopesByFamily, + isScopeImplied, + isUuid, + NO_ESCAPE, + NO_EXPIRY, + relativeTime, + renewalExpiry, + 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([]); + }); +}); + +describe('NO_ESCAPE', () => { + it('turns off i18next HTML escaping so dates and names render as typed', () => { + 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']); + }); +}); + +describe('renewalExpiry', () => { + const policy = { + default_lifetime_days: 90, + max_lifetime_days: 365, + allow_non_expiring: false, + }; + const issued = '2026-09-01T10:00:00+00:00'; + const plusDays = (days: number) => + new Date(Date.parse(issued) + days * 86400000).toISOString(); + + it('preselects the lifetime the token was issued with', () => { + expect( + renewalExpiry({ created_at: issued, expires_at: plusDays(30) }, policy), + ).toBe(30); + }); + + it('counts from the last regeneration, not from creation', () => { + expect( + renewalExpiry( + { + created_at: '2026-01-01T00:00:00+00:00', + regenerated_at: issued, + expires_at: plusDays(7), + }, + policy, + ), + ).toBe(7); + }); + + it('falls back to the default for a lifetime the policy no longer offers', () => { + expect( + renewalExpiry({ created_at: issued, expires_at: plusDays(45) }, policy), + ).toBe(90); + expect( + renewalExpiry( + { created_at: issued, expires_at: plusDays(365) }, + { ...policy, max_lifetime_days: 180 }, + ), + ).toBe(90); + }); + + it('keeps a non-expiring token non-expiring only while that is allowed', () => { + const item = { created_at: issued, expires_at: null }; + expect(renewalExpiry(item, policy)).toBe(90); + expect(renewalExpiry(item, { ...policy, allow_non_expiring: true })).toBe( + NO_EXPIRY, + ); + }); + + it('falls back to the default on unparseable dates', () => { + expect( + renewalExpiry({ created_at: null, expires_at: plusDays(30) }, policy), + ).toBe(90); + }); +}); diff --git a/frontend/src/settings/accessTokenUtils.ts b/frontend/src/settings/accessTokenUtils.ts new file mode 100644 index 00000000..8ebe70e8 --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.ts @@ -0,0 +1,261 @@ +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)); + 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)); +} + +/** 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; +} + +/** + * Lifetime to preselect when regenerating: what the token was last issued + * with, when the policy still offers it; otherwise the policy default. + */ +export function renewalExpiry( + item: { + created_at: string | null; + regenerated_at?: string | null; + expires_at: string | null; + }, + policy: ExpiryPolicy, +): number { + const options = expiryOptions(policy); + if (!item.expires_at) { + return options.includes(NO_EXPIRY) ? NO_EXPIRY : defaultExpiry(policy); + } + const issued = Date.parse(item.regenerated_at || item.created_at || ''); + const expires = Date.parse(item.expires_at); + if (Number.isNaN(issued) || Number.isNaN(expires)) { + return defaultExpiry(policy); + } + const days = Math.round((expires - issued) / DAY_MS); + return options.includes(days) ? days : defaultExpiry(policy); +} + +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)); +} + +/** + * 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; 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; 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_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_asgi_auth.py b/tests/api/test_asgi_auth.py index cf72eb43..fa1768ec 100644 --- a/tests/api/test_asgi_auth.py +++ b/tests/api/test_asgi_auth.py @@ -114,3 +114,69 @@ 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 + + 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" + + 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 new file mode 100644 index 00000000..f4530553 --- /dev/null +++ b/tests/api/test_pat_routes.py @@ -0,0 +1,393 @@ +"""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 + + @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 + + 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 + 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 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(): + 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 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"] + with _session(sub="bob"): + assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404 + + @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/{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: + 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 + 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 new file mode 100644 index 00000000..a1bb0977 --- /dev/null +++ b/tests/api/test_pat_rules.py @@ -0,0 +1,448 @@ +"""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/user/tokens//regenerate", "POST"), + ("/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_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" + 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" + + @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" + + +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): + 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" + + @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"}) + 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} diff --git a/tests/api/test_pat_tokens.py b/tests/api/test_pat_tokens.py new file mode 100644 index 00000000..24f6a5bb --- /dev/null +++ b/tests/api/test_pat_tokens.py @@ -0,0 +1,280 @@ +"""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"} + + 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", + [ + ("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"} 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..8d2ca531 --- /dev/null +++ b/tests/storage/db/repositories/test_personal_access_tokens.py @@ -0,0 +1,207 @@ +"""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_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") + _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 len(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" + + +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