Merge main (roles and access revamp) into connectors

Main's access model (team editors and viewers, edit_credentials, owner-only
OAuth servers, per-user tool preferences, resource sponsors) now applies to
connection-backed tools and sources. Our migrations are renumbered to
0040_connections and 0041_connection_account_name, after main's
0038_resource_access_settings and 0039_resource_sponsors.

Where the two sides met: MCP tools save and load their connections as the
tool owner, editors may add a key (a new connection on the owner's account)
but never rewrite an existing connection's secret, fixed values stay
owner-only, and a member's own connection is used in member mode.
This commit is contained in:
arc53-machine committed 2026-09-29 14:02:26 +01:00
commit a1789d2ab4
167 files changed
+16909 -2126

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
"""Role checks shared by the source routes (sources, chunks, upload, search).
Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source
endpoint resolves the row the same way and answers denials with the same
JSON shape: 404 when the caller can't see the source, 403 when they can but
their role may not perform the action.
"""
from __future__ import annotations
from typing import Optional
from flask import jsonify, make_response
from sqlalchemy import Connection
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
from docsgpt.storage.db.repositories.sources import SourcesRepository
def load_source(
conn: Connection, source_id: Optional[str], user: str, action: str
) -> tuple[dict, ResourceAccess]:
"""Check ``action`` on a source and return its row with the caller's access.
The row is fetched by the canonical id only after the check passes, so a
team member gets the owner's row without ownership scoping.
Args:
conn: Open database connection.
source_id: Source id from the request (UUID or legacy id).
user: The caller's ``sub``.
action: A ``source`` action from ``resource_access.ACTIONS``.
Returns:
tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``.
Raises:
AccessDenied: 404 when not visible, 403 when the role can't do it.
"""
if not source_id:
raise AccessDenied(404, "Source not found")
ra = require(conn, "source", str(source_id), user, action)
doc = SourcesRepository(conn).get_by_id(ra.resource_id)
if doc is None:
raise AccessDenied(404, "Source not found")
return doc, ra
def denied_response(err: AccessDenied):
"""The JSON error response for an :class:`AccessDenied`."""
return make_response(
jsonify({"success": False, "message": err.message}), err.status
)
+31 -44
View File
@@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
from docsgpt.api.user.base import get_vector_store
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.storage.db.session import db_readonly
from docsgpt.utils import check_required_fields, num_tokens_from_string
from docsgpt.vectorstore.base import InvalidChunkMetadataError
@@ -18,38 +18,27 @@ sources_chunks_ns = Namespace(
)
def _resolve_source(doc_id: str, user: str):
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict:
"""Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on.
Read access = owner or any team grant (viewer/editor). Returns the row
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
``use`` (browse chunks) is open to every role; ``edit`` (add / delete /
update chunks) needs owner or team editor. The vector partition is keyed
by source id, so a team editor's write needs no owner id.
Args:
doc_id: Source id from the request.
user: The caller's ``sub``.
action: ``use`` for reads, ``edit`` for chunk writes.
Returns:
dict: The source row (PG UUID in ``id``).
Raises:
AccessDenied: 404 when not visible, 403 when the role can't do it.
"""
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(doc_id, user)
if doc is not None:
return doc
if not can_access(conn, "source", doc_id, user):
return None
return SourcesRepository(conn).get_by_id(doc_id)
def _resolve_source_for_write(doc_id: str, user: str):
"""Resolve a source the caller may WRITE chunks on.
Returns the row dict when ``user`` owns the source, or when they hold a
team ``editor`` grant (adding/removing/editing documents is editor-allowed
— the vector partition is keyed by source_id, owner-agnostic). Returns
``None`` for viewer-only / no access. Source deletion stays owner-only and
is handled elsewhere.
"""
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(doc_id, user)
if doc is not None:
return doc
owner = effective_write_owner(conn, "source", doc_id, user)
if not owner:
return None
return SourcesRepository(conn).get_any(doc_id, owner)
doc, _ra = load_source(conn, doc_id, user, action)
return doc
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
@@ -193,13 +182,11 @@ class GetChunks(Resource):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
try:
doc = _resolve_source(doc_id, user)
except AccessDenied as err:
return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
resolved_id = str(doc["id"])
try:
store = get_vector_store(resolved_id)
@@ -278,12 +265,12 @@ class AddChunk(Resource):
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
doc = _resolve_source(doc_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunk_id = store.add_chunk(text, metadata)
@@ -311,12 +298,12 @@ class DeleteChunk(Resource):
chunk_id = request.args.get("chunk_id")
try:
doc = _resolve_source_for_write(doc_id, user)
doc = _resolve_source(doc_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
deleted = store.delete_chunk(chunk_id)
@@ -378,12 +365,12 @@ class UpdateChunk(Resource):
metadata = {}
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
doc = _resolve_source(doc_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
+6 -10
View File
@@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource
from pydantic import ValidationError
from docsgpt.api import api
from docsgpt.api.user.sources.routes import _resolve_readable_source
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.core.model_utils import get_default_model_id
from docsgpt.retriever.dispatcher import Dispatcher
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -102,21 +103,16 @@ class SourceSearch(Resource):
# Read access = owner or any team grant (viewer included), matching
# the other source read endpoints (wiki pages, graph).
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
except Exception as e:
# An unresolvable id yields None (→ 404); reaching here means the
# An unresolvable id is AccessDenied (404); reaching here means the
# lookup itself failed, which is ours, not the caller's.
logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Could not resolve source"}), 500
)
if not doc:
return make_response(
jsonify(
{"success": False, "message": "Source not found or access denied"}
),
404,
)
resolved_id = str(doc["id"])
# A supplied config is validated exactly as strictly as a saved one (D7
+153 -183
View File
@@ -3,6 +3,7 @@
import json
import math
import uuid
from typing import Optional
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
@@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import (
reingest_source_task,
sync_source,
)
from docsgpt.api.user.team_sharing import (
can_access,
effective_write_owner,
visible_with_access,
from docsgpt.api.user.resource_access import (
AccessDenied,
delete_settings,
payload_for,
settings_many,
)
from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.core.settings import settings
from docsgpt.graphrag import graphrag_available
from docsgpt.parser.remote.remote_creator import normalize_remote_data
@@ -76,6 +80,31 @@ def _connection_id(row: dict) -> str | None:
return str(value) if value else None
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
"""Add ``access`` + ``allowed_actions`` to a listed source row.
The source's behaviour ``config`` is cut down to its ``kind`` when the
caller's role may not ``view_config`` (a viewer when the owner turned
``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to
pick the wiki / graph view.
Args:
entry: The row as the list endpoint builds it.
access: ``owner`` / ``editor`` / ``viewer``.
switches: The source's owner switches (``settings_many`` output).
Returns:
dict: ``entry`` with the access payload merged in.
"""
payload = payload_for("source", access, switches)
if "view_config" not in payload["allowed_actions"]:
config = entry.get("config")
if config is not None:
entry["config"] = {"kind": (config or {}).get("kind", "classic")}
entry.update(payload)
return entry
@sources_ns.route("/sources")
class CombinedJson(Resource):
@api.doc(description="Provide JSON file with combined available indexes")
@@ -99,6 +128,7 @@ class CombinedJson(Resource):
team_shared = visible_with_access(conn, user, "source")
shared_ids = [sid for sid in team_shared if sid not in owned_ids]
shared_sources = repo.list_by_ids(shared_ids)
switches = settings_many(conn, "source", [*owned_ids, *shared_ids])
# list_for_user sorts by created_at DESC; legacy shape sorted by
# "date" DESC. Both are monotonic on creation so the ordering is
# equivalent for dev; re-sort defensively.
@@ -109,7 +139,7 @@ class CombinedJson(Resource):
def _source_entry(index, *, ownership="user", team_access=None):
provider = _get_provider_from_remote_data(index.get("remote_data"))
return {
entry = {
"id": str(index["id"]),
"name": index.get("name"),
"date": index.get("date"),
@@ -128,6 +158,11 @@ class CombinedJson(Resource):
"team_access": team_access,
"connectionId": _connection_id(index),
}
return _with_access(
entry,
"owner" if ownership == "user" else team_access,
switches.get(str(index["id"])),
)
for index in indexes:
data.append(_source_entry(index))
@@ -185,6 +220,9 @@ class PaginatedSources(Resource):
sort_order=sort_order,
extra_ids=extra_ids,
)
switches = settings_many(
conn, "source", [str(doc["id"]) for doc in window]
)
paginated_docs = []
for doc in window:
@@ -192,33 +230,38 @@ class PaginatedSources(Resource):
# Owner vs team-shared: a row in the window is the caller's own
# when its user_id matches; otherwise it arrived via extra_ids.
owned = str(doc.get("user_id")) == str(user)
entry = {
"id": str(doc["id"]),
"name": doc.get("name", ""),
"date": doc.get("date", ""),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": doc.get("tokens", ""),
"retriever": doc.get("retriever", "classic"),
"syncFrequency": doc.get("sync_frequency", ""),
"provider": provider,
"isNested": bool(doc.get("directory_structure")),
"type": doc.get("type", "file"),
# Lenient read (D7): always emit a fully-defaulted
# config so the edit modal can pre-fill, even for a
# legacy {} row.
"config": SourceConfig.parse(
doc.get("config")
).model_dump(),
# Derived in SourcesRepository.list_for_user.
"ingestStatus": doc.get("ingest_status"),
"ownership": "user" if owned else "team",
"team_access": (
None if owned else team_shared.get(str(doc["id"]))
),
"connectionId": _connection_id(doc),
}
paginated_docs.append(
{
"id": str(doc["id"]),
"name": doc.get("name", ""),
"date": doc.get("date", ""),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": doc.get("tokens", ""),
"retriever": doc.get("retriever", "classic"),
"syncFrequency": doc.get("sync_frequency", ""),
"provider": provider,
"isNested": bool(doc.get("directory_structure")),
"type": doc.get("type", "file"),
# Lenient read (D7): always emit a fully-defaulted
# config so the edit modal can pre-fill, even for a
# legacy {} row.
"config": SourceConfig.parse(
doc.get("config")
).model_dump(),
# Derived in SourcesRepository.list_for_user.
"ingestStatus": doc.get("ingest_status"),
"ownership": "user" if owned else "team",
"team_access": (
None if owned else team_shared.get(str(doc["id"]))
),
"connectionId": _connection_id(doc),
}
_with_access(
entry,
"owner" if owned else team_shared.get(str(doc["id"])),
switches.get(str(doc["id"])),
)
)
response = {
"total": total_documents,
@@ -234,13 +277,16 @@ class PaginatedSources(Resource):
return make_response(jsonify({"success": False}), 400)
def delete_source(user: str, doc: dict) -> bool:
def delete_source(owner: str, doc: dict, *, actor: Optional[str] = None) -> bool:
"""Delete a source's index, stored files and row. Returns whether it worked.
Args:
user: The owner deleting it.
doc: The source row, already authorised for ``user``.
owner: The source's owner; the row is deleted as them.
doc: The source row, already authorised for the caller.
actor: Who asked for the delete (a team editor, say), when not the
owner. Recorded on the audit event.
"""
actor = actor or owner
storage = StorageCreator.get_storage()
resolved_id = str(doc["id"])
source_id = resolved_id
@@ -275,13 +321,17 @@ def delete_source(user: str, doc: dict) -> bool:
return False
try:
with db_session() as conn:
SourcesRepository(conn).delete(resolved_id, user)
# The AFTER DELETE trigger drops the source's team grants; the
# owner switches have no FK, so clear them here.
SourcesRepository(conn).delete(resolved_id, owner)
delete_settings(conn, "source", resolved_id)
record_event(
conn,
"source.deleted",
actor=user,
actor=actor,
source_id=resolved_id,
name=doc.get("name"),
owner=owner if owner != actor else None,
)
except Exception as err:
current_app.logger.error(
@@ -307,15 +357,17 @@ class DeleteOldIndexes(Resource):
return make_response(
jsonify({"success": False, "message": "Missing required fields"}), 400
)
# Owner-only unless the owner turned ``editors_can_delete`` on; the
# row is deleted as the owner either way.
try:
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(source_id, user)
doc, ra = load_source(conn, source_id, user, "delete")
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
if not doc:
return make_response(jsonify({"status": "not found"}), 404)
if not delete_source(user, doc):
if not delete_source(ra.owner_id, doc, actor=user):
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@@ -363,21 +415,13 @@ class ManageSync(Resource):
)
try:
with db_session() as conn:
repo = SourcesRepository(conn)
doc = repo.get_any(source_id, user)
if doc is not None:
repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
else:
# Team editor write path (sync_frequency is metadata, no
# ingestion side effects). Reingest/sync triggers stay
# owner-only pending a cost/side-effect decision.
owner = effective_write_owner(conn, "source", source_id, user)
if not owner:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
repo.update(source_id, owner, {"sync_frequency": sync_frequency})
# Owner or team editor; the write lands as the owner.
doc, ra = load_source(conn, source_id, user, "edit")
SourcesRepository(conn).update(
str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency}
)
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating sync frequency: {err}", exc_info=True
@@ -412,21 +456,15 @@ class SyncSource(Resource):
# source_id (owner-agnostic), so dispatching as the owner is correct.
try:
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(source_id, user)
owner = user
if doc is None:
owner = effective_write_owner(conn, "source", source_id, user)
if owner:
doc = SourcesRepository(conn).get_any(source_id, owner)
doc, ra = load_source(conn, source_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Invalid source ID"}), 400
)
if not doc:
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
owner = ra.owner_id
source_type = doc.get("type", "")
if source_type and source_type.startswith("connector"):
return make_response(
@@ -492,12 +530,9 @@ class ReingestSource(Resource):
# (owner-agnostic), so dispatching as the owner is correct.
try:
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(source_id, user)
owner = user
if doc is None:
owner = effective_write_owner(conn, "source", source_id, user)
if owner:
doc = SourcesRepository(conn).get_any(source_id, owner)
doc, ra = load_source(conn, source_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error looking up source: {err}", exc_info=True
@@ -505,10 +540,7 @@ class ReingestSource(Resource):
return make_response(
jsonify({"success": False, "message": "Invalid source ID"}), 400
)
if not doc:
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
owner = ra.owner_id
resolved_source_id = str(doc["id"])
# Drop the stale chunk-progress row so the sources list stops
# deriving a 'failed' status; reingest never rewrites it itself.
@@ -571,11 +603,7 @@ class DirectoryStructure(Resource):
return make_response(jsonify({"error": "Document ID is required"}), 400)
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, doc_id, user)
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
doc, _ra = load_source(conn, doc_id, user, "use")
directory_structure = doc.get("directory_structure", {})
base_path = doc.get("file_path", "")
@@ -602,6 +630,8 @@ class DirectoryStructure(Resource):
),
200,
)
except AccessDenied as err:
return denied_response(err)
except Exception as e:
current_app.logger.error(
f"Error retrieving directory structure: {e}", exc_info=True
@@ -659,23 +689,9 @@ class SourceConfigResource(Resource):
try:
with db_session() as conn:
repo = SourcesRepository(conn)
# Resolve the owner to write AS: ``user`` when they own the
# source, the real owner when ``user`` holds a team ``editor``
# grant. A viewer / no-access resolves to None → 403.
owner = effective_write_owner(conn, "source", source_id, user)
if not owner:
return make_response(
jsonify(
{"success": False, "message": "Source not accessible"}
),
403,
)
doc = repo.get_any(source_id, owner)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
# Owner or team editor; the write lands as the owner.
doc, ra = load_source(conn, source_id, user, "edit")
owner = ra.owner_id
# Ingest-time fields (config.chunking) only take effect after a
# re-ingest (D8); compare against the current config to decide.
current_config = SourceConfig.parse(doc.get("config"))
@@ -706,6 +722,8 @@ class SourceConfigResource(Resource):
repo.update(
str(doc["id"]), owner, {"config": new_config.model_dump()}
)
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating source config for {source_id}: {err}", exc_info=True
@@ -757,20 +775,6 @@ def _unsupported_retrieval_warnings(config) -> list:
return warnings
def _resolve_readable_source(conn, source_id, user):
"""Return a source dict the caller may READ, or None.
Read access = owner or any team grant (viewer/editor). Resolves the row
without ownership scoping only after the grant check passes.
"""
doc = SourcesRepository(conn).get_any(source_id, user)
if doc is not None:
return doc
if not can_access(conn, "source", source_id, user):
return None
return SourcesRepository(conn).get_by_id(source_id)
def _wiki_page_node(page):
return {
"path": page.get("path"),
@@ -909,12 +913,10 @@ class WikiPages(Resource):
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
try:
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
pages = WikiPagesRepository(conn).list_for_source(str(doc["id"]))
directory_structure = doc.get("directory_structure") or {}
except Exception as err:
@@ -957,12 +959,10 @@ class WikiPage(Resource):
)
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
try:
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path)
except Exception as err:
current_app.logger.error(
@@ -1000,20 +1000,12 @@ class WikiPage(Resource):
expected_version = data.get("expected_version")
try:
with db_session() as conn:
owner = effective_write_owner(conn, "source", source_id, user)
if not owner:
return make_response(
jsonify(
{"success": False, "message": "Source not accessible"}
),
403,
)
doc = SourcesRepository(conn).get_any(source_id, owner)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
# Owner or team editor; writes and re-embeds run as the owner.
try:
doc, ra = load_source(conn, source_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
owner = ra.owner_id
resolved_source_id = str(doc["id"])
try:
page = WikiPagesRepository(conn).upsert(
@@ -1097,20 +1089,12 @@ class ConvertSourceToWiki(Resource):
user = decoded_token.get("sub")
try:
with db_session() as conn:
owner = effective_write_owner(conn, "source", source_id, user)
if not owner:
return make_response(
jsonify(
{"success": False, "message": "Source not accessible"}
),
403,
)
doc = SourcesRepository(conn).get_any(source_id, owner)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
# Owner or team editor; writes and re-embeds run as the owner.
try:
doc, ra = load_source(conn, source_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
owner = ra.owner_id
resolved_source_id = str(doc["id"])
# A mid-ingest source has an incomplete directory structure, so
# it could be mis-detected as blank and wrongly enabled inline.
@@ -1217,20 +1201,12 @@ class EnableSourceGraphRAG(Resource):
user = decoded_token.get("sub")
try:
with db_session() as conn:
owner = effective_write_owner(conn, "source", source_id, user)
if not owner:
return make_response(
jsonify(
{"success": False, "message": "Source not accessible"}
),
403,
)
doc = SourcesRepository(conn).get_any(source_id, owner)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
# Owner or team editor; writes and re-embeds run as the owner.
try:
doc, ra = load_source(conn, source_id, user, "edit")
except AccessDenied as err:
return denied_response(err)
owner = ra.owner_id
resolved_source_id = str(doc["id"])
cfg = SourceConfig.parse(doc.get("config"))
repo = SourcesRepository(conn)
@@ -1337,12 +1313,10 @@ class SourceGraph(Resource):
limit = None
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
try:
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
@@ -1472,12 +1446,10 @@ class SourceGraphNodes(Resource):
type_key = request.args.get("type")
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
try:
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
@@ -1523,12 +1495,10 @@ class SourceGraphNode(Resource):
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
try:
doc, _ra = load_source(conn, source_id, user, "use")
except AccessDenied as err:
return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
+6 -17
View File
@@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text
from docsgpt.api import api
from docsgpt.api.audit import record_event
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
from docsgpt.api.user.team_sharing import effective_write_owner
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.core.settings import settings
from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
@@ -763,22 +764,10 @@ class ManageSourceFiles(Resource):
# (owner-agnostic), so running the ops as the owner is correct.
try:
with db_readonly() as conn:
source = SourcesRepository(conn).get_any(source_id, user)
owner = user
if source is None:
owner = effective_write_owner(conn, "source", source_id, user)
if owner:
source = SourcesRepository(conn).get_any(source_id, owner)
if not source:
return make_response(
jsonify(
{
"success": False,
"message": "Source not found or access denied",
}
),
404,
)
source, ra = load_source(conn, source_id, user, "edit")
owner = ra.owner_id
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
return make_response(