mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 02:16:08 +00:00
feat(pat): enforce scopes and resource restrictions, deny by default
A central rule table maps each route and method to the scope a token needs; a route that is not listed cannot be called with a token, and a test fails when a registered route is left unclassified. Token management, admin, team management, sign-in, device pairing and OAuth handshakes are never token reachable, and a token never carries the admin role. A token restricted to specific agents, sources, prompts, tools or workflows is held to its allowlist: ids are checked wherever a route carries them, listings are filtered, creation is refused, and routes whose rows cannot be tied to the allowlist are closed. Agent import checks the resolved target.
This commit is contained in:
1 parent
3a74aa23f0
commit
82ec6cba0e
10 files changed
+1114
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
from docsgpt.api.user.base import (
|
||||
copy_agent_image_for_user,
|
||||
@@ -498,7 +499,7 @@ class GetAgents(Resource):
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify(list_agents), 200)
|
||||
return make_response(jsonify(filter_listing(request, "agents", list_agents)), 200)
|
||||
|
||||
|
||||
@agents_ns.route("/create_agent")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -16,6 +16,7 @@ from docsgpt.agents.default_tools import (
|
||||
from docsgpt.agents.tools.spec_parser import parse_spec
|
||||
from docsgpt.agents.tools.tool_manager import ToolManager
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -265,6 +266,9 @@ class GetTools(Resource):
|
||||
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
|
||||
shaped["team_access"] = team_shared.get(str(row["id"]))
|
||||
user_tools.append(shaped)
|
||||
# A resource-restricted token sees only its allowed tools; the
|
||||
# default and builtin rows appended below belong to no user.
|
||||
user_tools = filter_listing(request, "tools", user_tools)
|
||||
|
||||
# ``scheduler`` is dual-registered (default chat tool + agent-
|
||||
# selectable builtin) and resolves to the same synthetic uuid5 id.
|
||||
|
||||
Reference in new issue
Block a user