Merge pull request #2812 from arc53-machine/feat/personal-access-tokens

feat: personal access tokens (scoped API tokens for CLI and CI/CD)
This commit is contained in:
Alex authored and GitHub committed 2026-09-21 10:55:33 +01:00
commit 1c1bc2538f
51 files changed
+6467 -109

No files matched your search

+31
View File
@@ -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
+13 -4
View File
@@ -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/<string:agent_id>")
@@ -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
)
+11
View File
@@ -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)
+3 -1
View File
@@ -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")
+2 -1
View File
@@ -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")
+4
View File
@@ -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)