fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
169 lines
7.4 KiB
Python
169 lines
7.4 KiB
Python
"""Parent-derived authorization helpers for artifact access."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from flask import request
|
|
|
|
from application.storage.db.repositories.agents import AgentsRepository
|
|
from application.storage.db.repositories.artifacts import ArtifactsRepository
|
|
from application.storage.db.repositories.conversations import ConversationsRepository
|
|
from application.storage.db.repositories.shared_conversations import (
|
|
SharedConversationsRepository,
|
|
)
|
|
from application.storage.db.repositories.workflow_runs import WorkflowRunsRepository
|
|
from application.storage.db.session import db_readonly
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Principal:
|
|
"""The resolved caller of an artifact route.
|
|
|
|
A decoded JWT is a full *owner* session (``agent_id`` is ``None``). An
|
|
``api_key`` is a low-trust, publicly-distributed *agent* credential -- it is
|
|
embedded client-side in the widget and accepted from the query string -- so
|
|
it resolves to the owning ``user_id`` but stays **agent-scoped**: it may reach
|
|
an artifact only when the request also carries that artifact's parent
|
|
``conversation_id`` (the unguessable per-visitor secret), confining it to a
|
|
single conversation rather than the agent's whole corpus, and may not perform
|
|
mutating operations.
|
|
"""
|
|
|
|
user_id: Optional[str] = None
|
|
agent_id: Optional[str] = None
|
|
|
|
@property
|
|
def is_agent_scoped(self) -> bool:
|
|
"""True for an api_key (agent) principal that must be confined to its agent."""
|
|
return self.agent_id is not None
|
|
|
|
|
|
def resolve_principal() -> Principal:
|
|
"""Resolve the caller to a :class:`Principal`.
|
|
|
|
Priority: a decoded JWT (owner session) first, then an ``api_key`` query/form
|
|
param (agent-scoped). An unresolvable caller yields an anonymous principal
|
|
(both fields ``None``) that only a valid ``share_token`` can authorize.
|
|
"""
|
|
decoded_token = getattr(request, "decoded_token", None)
|
|
if decoded_token:
|
|
return Principal(user_id=decoded_token.get("sub"))
|
|
|
|
api_key = request.args.get("api_key") or request.form.get("api_key")
|
|
if api_key:
|
|
with db_readonly() as conn:
|
|
agent = AgentsRepository(conn).find_by_key(api_key)
|
|
if agent and agent.get("user_id") and agent.get("id"):
|
|
return Principal(user_id=str(agent["user_id"]), agent_id=str(agent["id"]))
|
|
return Principal()
|
|
|
|
|
|
def _shared_row_for(conn, conversation_id, share_token):
|
|
"""Return the shared_conversations row iff share_token grants this conversation, else None."""
|
|
if not share_token:
|
|
return None
|
|
shared = SharedConversationsRepository(conn).find_by_uuid(share_token)
|
|
if shared and str(shared.get("conversation_id")) == str(conversation_id):
|
|
return shared
|
|
return None
|
|
|
|
|
|
def user_can_access_conversation(
|
|
conn, conversation_id: str, user_id: Optional[str], share_token: Optional[str]
|
|
) -> bool:
|
|
"""Allow if the caller owns/shares the conversation, or holds a valid share token.
|
|
|
|
Conversation-level gate only. Reuses ``ConversationsRepository.get`` (owner OR
|
|
``shared_with``) so artifact access tracks message access, and honours a valid
|
|
share token. A share-token caller reaches the conversation here but must still
|
|
be snapshot-scoped per-artifact by the caller (see ``authorize_artifact``): a
|
|
valid token does NOT imply access to every artifact in the conversation.
|
|
"""
|
|
if user_id:
|
|
if ConversationsRepository(conn).get(conversation_id, user_id) is not None:
|
|
return True
|
|
return _shared_row_for(conn, conversation_id, share_token) is not None
|
|
|
|
|
|
def authorize_artifact(conn, artifact: dict, principal: Principal) -> bool:
|
|
"""Authorize a READ of ``artifact`` for ``principal``; missing parent fails closed.
|
|
|
|
A low-trust agent api_key is confined to a single conversation it proves by
|
|
carrying that artifact's parent ``conversation_id`` query param (owner match +
|
|
matching conversation + agent scope) and never inherits share-link access. A
|
|
JWT owner or ``shared_with`` collaborator gets full access to every artifact of
|
|
the parent; a share-token holder is confined to the shared ``first_n_queries``
|
|
snapshot (an artifact whose ``message_id`` is outside it, or NULL, is denied).
|
|
"""
|
|
if principal.is_agent_scoped:
|
|
# An agent key is not the owner's session and is embedded in public widget
|
|
# JS, so require the artifact to belong to the owner AND the request to
|
|
# carry the parent conversation_id (the unguessable per-visitor secret):
|
|
# the key alone, without that id, cannot download a known artifact.
|
|
if str(artifact.get("user_id")) != str(principal.user_id):
|
|
return False
|
|
req_conv = request.args.get("conversation_id")
|
|
parent = artifact.get("conversation_id")
|
|
if not req_conv or parent is None or str(parent) != str(req_conv):
|
|
return False
|
|
return ArtifactsRepository(conn).artifact_in_agent_scope(
|
|
str(artifact.get("id")), str(principal.agent_id)
|
|
)
|
|
|
|
conversation_id = artifact.get("conversation_id")
|
|
workflow_run_id = artifact.get("workflow_run_id")
|
|
share_token = request.args.get("share_token")
|
|
|
|
if conversation_id is not None:
|
|
# Owner or shared_with collaborator: full access to every artifact.
|
|
if principal.user_id and ConversationsRepository(conn).get(
|
|
str(conversation_id), principal.user_id
|
|
) is not None:
|
|
return True
|
|
# Share-token holder: only artifacts inside the shared first_n_queries snapshot.
|
|
shared = _shared_row_for(conn, str(conversation_id), share_token)
|
|
if shared is None:
|
|
return False
|
|
message_id = artifact.get("message_id")
|
|
if not message_id:
|
|
return False
|
|
first_n = int(shared.get("first_n_queries") or 0)
|
|
return ConversationsRepository(conn).message_in_first_n(
|
|
str(conversation_id), str(message_id), first_n
|
|
)
|
|
if workflow_run_id is not None:
|
|
if not principal.user_id:
|
|
return False
|
|
run = WorkflowRunsRepository(conn).get(str(workflow_run_id))
|
|
return run is not None and run.get("user_id") == principal.user_id
|
|
# No parent row reachable -> deny (e.g. deleted conversation/run).
|
|
return False
|
|
|
|
|
|
def authorize_artifact_write(conn, artifact: dict, principal: Principal) -> bool:
|
|
"""Authorize a *mutating* artifact operation (e.g. delete / restore).
|
|
|
|
Stricter than :func:`authorize_artifact`: a write requires an authenticated
|
|
*owner* of the parent. Low-trust agent api_keys, share links, and
|
|
``shared_with`` collaborators inherit read/download access only, so an
|
|
agent-scoped, anonymous, or share-token-only caller is denied -- they can read
|
|
an artifact but never mutate it.
|
|
"""
|
|
if principal.is_agent_scoped or not principal.user_id:
|
|
return False
|
|
conversation_id = artifact.get("conversation_id")
|
|
workflow_run_id = artifact.get("workflow_run_id")
|
|
|
|
if conversation_id is not None:
|
|
return (
|
|
ConversationsRepository(conn).get_owned(str(conversation_id), principal.user_id)
|
|
is not None
|
|
)
|
|
if workflow_run_id is not None:
|
|
run = WorkflowRunsRepository(conn).get(str(workflow_run_id))
|
|
return run is not None and run.get("user_id") == principal.user_id
|
|
# No parent row reachable -> deny (e.g. deleted conversation/run).
|
|
return False
|