diff --git a/.gitignore b/.gitignore index 3eef457a..a16646df 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,12 @@ celerybeat.pid .env .env.local .env.*.local +# Backups of env files (e.g. .env.local.bak, .env.bak.1781108407) +*.env.bak +*.env.bak.* +.env.bak +.env.bak.* +.env.local.bak .venv # Machine-specific Claude Code guidance (see CLAUDE.md preamble) CLAUDE.md diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index f197f762..3a4b107e 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -842,9 +842,31 @@ class ToolExecutor: ) else: tool_config = tool_data["config"].copy() if tool_data["config"] else {} - if tool_config.get("encrypted_credentials") and self.user: + # Credentials are PBKDF2-bound to the tool OWNER's sub, not the + # invoker's. Decrypt with the tool row's user_id so a team member + # running an owner's shared tool authenticates with the owner's + # credentials (deliberate delegation — see teams-spec OQ2), and so + # the long-standing agent-key path (tools resolved by owner) stops + # silently decrypt-failing. Falls back to self.user for the + # agentless path where the tool row carries no user_id. + tool_owner = tool_data.get("user_id") or self.user + if tool_config.get("encrypted_credentials") and tool_owner: + if tool_owner != self.user: + # Credential delegation: the invoker is running a shared + # tool with the owner's secrets. Audit it (the agent-run + # authorization upstream is the access boundary). + logger.info( + "tool_credential_delegation", + extra={ + "invoker": self.user, + "tool_owner": tool_owner, + "tool_id": str(tool_data.get("id") or tool_id), + "tool_name": tool_data.get("name"), + "agent_id": self.agent_id, + }, + ) decrypted = decrypt_credentials( - tool_config["encrypted_credentials"], self.user + tool_config["encrypted_credentials"], tool_owner ) tool_config.update(decrypted) tool_config["auth_credentials"] = decrypted diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py index 0ac56106..ec53b109 100644 --- a/application/agents/tools/internal_search.py +++ b/application/agents/tools/internal_search.py @@ -72,7 +72,17 @@ class InternalSearchTool(Tool): active_docs = [active_docs] decoded_token = self.config.get("decoded_token") or {} - user_id = decoded_token.get("sub") if decoded_token else None + # Resolve the agent's sources as their OWNER: for a team-shared + # agent run by a member, the sources belong to the owner, so using + # the member's sub would 404. ``source_owner_id`` is the agent owner + # (set at config-build time); fall back to the BYOM model_user_id, + # then the invoker. Running the agent already authorized these + # sources. + user_id = ( + self.config.get("source_owner_id") + or self.config.get("model_user_id") + or (decoded_token.get("sub") if decoded_token else None) + ) merged_structure = {} with db_readonly() as conn: @@ -439,6 +449,7 @@ def build_internal_tool_config( doc_token_limit: int = 50000, model_id: str = "docsgpt-local", model_user_id: Optional[str] = None, + source_owner_id: Optional[str] = None, user_api_key: Optional[str] = None, agent_id: Optional[str] = None, llm_name: str = None, @@ -454,6 +465,10 @@ def build_internal_tool_config( "doc_token_limit": doc_token_limit, "model_id": model_id, "model_user_id": model_user_id, + # The agent owner — the sources belong to them, so directory-structure + # resolution uses this (a team member running a shared agent has a + # different sub). Independent of the BYOM ``model_user_id``. + "source_owner_id": source_owner_id, "user_api_key": user_api_key, "agent_id": agent_id, "llm_name": llm_name or settings.LLM_PROVIDER, diff --git a/application/alembic/versions/0021_teams.py b/application/alembic/versions/0021_teams.py new file mode 100644 index 00000000..515272d1 --- /dev/null +++ b/application/alembic/versions/0021_teams.py @@ -0,0 +1,181 @@ +"""0021 teams — multi-team membership, team-scoped roles, resource sharing. + +Adds the whole Teams feature in one migration: + +* ``teams`` — a team. ``owner_id`` is the creator's auth ``sub`` and the durable + "who can delete the team" anchor. No FK/trigger on it (mirroring ``user_roles`` + / ``auth_events``): an ``ON DELETE RESTRICT`` FK would block user deletion + (SCIM/GDPR) and force an owner-reassignment dance on every delete. +* ``team_members`` — membership + team-scoped role grant, field-for-field on + ``user_roles``. ``(team_id, user_id, role, source)`` PK so a manual grant and a + future IdP-derived grant coexist and revoke independently. The ``source`` CHECK + already carries ``oidc_group``/``scim`` so those phases need no migration. +* ``team_resource_grants`` — one polymorphic share table covering all four + shareable resource types. ``owner_id`` is denormalised owner-at-share-time so + visibility queries never re-join the resource table. ``target_user_id`` is NULL + for a whole-team share or a member's ``sub`` for a per-member share; the dedup + index is functional over ``COALESCE(target_user_id, '')`` so a whole-team grant + and any number of per-member grants for the same (team, resource) coexist. + Sharing is additive visibility, never ownership transfer. + +Grant rows have no cross-table FK (the resource_id is polymorphic), so an +``AFTER DELETE`` trigger on each resource table scrubs dangling grants — covering +the non-route delete paths (reconciler / sync) that bypass app cleanup. + +Also adds ``users.email`` (populated from the OIDC email claim at login) so a +team admin can add a member by email instead of pasting a raw ``sub``. + +Revision ID: 0021_teams +Revises: 0020_user_roles +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0021_teams" +down_revision: Union[str, None] = "0020_user_roles" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_RESOURCE_TABLES = ("agents", "sources", "prompts", "user_tools") +_RESOURCE_TYPES = { + "agents": "agent", + "sources": "source", + "prompts": "prompt", + "user_tools": "tool", +} + + +def upgrade() -> None: + # --- teams --------------------------------------------------------------- + op.execute( + """ + CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug CITEXT NOT NULL, + description TEXT, + owner_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + """ + ) + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS teams_slug_uidx ON teams (slug);") + op.execute("CREATE INDEX IF NOT EXISTS teams_owner_idx ON teams (owner_id);") + # Reuse the shared set_updated_at() trigger fn defined in 0001. + op.execute("DROP TRIGGER IF EXISTS teams_set_updated_at ON teams;") + op.execute( + """ + CREATE TRIGGER teams_set_updated_at + BEFORE UPDATE ON teams + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + """ + ) + + # --- team_members -------------------------------------------------------- + op.execute( + """ + CREATE TABLE IF NOT EXISTS team_members ( + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + role TEXT NOT NULL + CHECK (role IN ('team_admin', 'team_member')), + source TEXT NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'oidc_group', 'scim')), + granted_by TEXT, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (team_id, user_id, role, source) + ); + """ + ) + op.execute( + "CREATE INDEX IF NOT EXISTS team_members_user_idx ON team_members (user_id);" + ) + + # --- team_resource_grants ------------------------------------------------ + op.execute( + """ + CREATE TABLE IF NOT EXISTS team_resource_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + resource_type TEXT NOT NULL + CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')), + resource_id UUID NOT NULL, + owner_id TEXT NOT NULL, + access_level TEXT NOT NULL DEFAULT 'viewer' + CHECK (access_level IN ('viewer', 'editor')), + target_user_id TEXT, + granted_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + """ + ) + # Functional dedup: whole-team (target NULL → '') and each per-member grant + # are distinct keys. + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS team_resource_grants_dedup_uidx + ON team_resource_grants + (team_id, resource_type, resource_id, COALESCE(target_user_id, '')); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS team_resource_grants_team_type_idx + ON team_resource_grants (team_id, resource_type); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS team_resource_grants_resource_idx + ON team_resource_grants (resource_type, resource_id); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS team_resource_grants_target_idx + ON team_resource_grants (target_user_id) + WHERE target_user_id IS NOT NULL; + """ + ) + + # --- dangling-grant cleanup --------------------------------------------- + op.execute( + """ + CREATE OR REPLACE FUNCTION cleanup_team_resource_grants() RETURNS trigger AS $$ + BEGIN + DELETE FROM team_resource_grants + WHERE resource_type = TG_ARGV[0] AND resource_id = OLD.id; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + """ + ) + for table in _RESOURCE_TABLES: + trig = f"{table}_cleanup_team_grants" + op.execute(f"DROP TRIGGER IF EXISTS {trig} ON {table};") + op.execute( + f"CREATE TRIGGER {trig} AFTER DELETE ON {table} " + f"FOR EACH ROW EXECUTE FUNCTION cleanup_team_resource_grants('{_RESOURCE_TYPES[table]}');" + ) + + # --- users.email (add-member-by-email) ----------------------------------- + op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;") + op.execute( + "CREATE INDEX IF NOT EXISTS users_email_lower_idx ON users (lower(email));" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS users_email_lower_idx;") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS email;") + for table in _RESOURCE_TABLES: + op.execute(f"DROP TRIGGER IF EXISTS {table}_cleanup_team_grants ON {table};") + op.execute("DROP FUNCTION IF EXISTS cleanup_team_resource_grants();") + op.execute("DROP TABLE IF EXISTS team_resource_grants;") + op.execute("DROP TABLE IF EXISTS team_members;") + op.execute("DROP TABLE IF EXISTS teams;") diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index bdd0a176..cb5b18a9 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -28,6 +28,7 @@ from application.storage.db.repositories.agents import AgentsRepository from application.storage.db.repositories.attachments import AttachmentsRepository from application.storage.db.repositories.prompts import PromptsRepository from application.storage.db.repositories.sources import SourcesRepository +from application.storage.db.repositories.team_scope import TeamScopeRepository from application.storage.db.repositories.user_tools import UserToolsRepository from application.storage.db.repositories.users import UsersRepository from application.storage.db.session import db_readonly, db_session @@ -427,7 +428,7 @@ class StreamProcessor: with db_readonly() as conn: # Lookup without user scoping — access control is done # against ``user_id`` / ``shared_with`` / ``shared`` flags - # right below, matching the legacy Mongo semantics. + # below, matching the legacy Mongo semantics. repo = AgentsRepository(conn) agent = None if looks_like_uuid(str(agent_id)): @@ -442,13 +443,29 @@ class StreamProcessor: agent = row_to_dict(row) if agent is None: agent = repo.get_by_legacy_id(str(agent_id)) - if agent is None: - raise Exception("Agent not found") - agent_owner = agent.get("user_id") - is_owner = agent_owner == user_id - is_shared_with_user = bool(agent.get("shared", False)) + if agent is None: + raise Exception("Agent not found") + agent_owner = agent.get("user_id") + is_owner = agent_owner == user_id + is_shared_with_user = bool(agent.get("shared", False)) - if not (is_owner or is_shared_with_user): + # Team-shared agents are runnable by any member with a grant + # (viewer is enough to run). Resolved live against team_members + # on the SAME connection so a revoked grant/membership denies on + # the next call; resolution failure fails closed. + is_team_shared = False + if not (is_owner or is_shared_with_user) and user_id: + try: + is_team_shared = TeamScopeRepository(conn).can_read( + user_id, "agent", str(agent["id"]) + ) + except Exception: + logger.error( + "team access check failed for agent run", exc_info=True + ) + is_team_shared = False + + if not (is_owner or is_shared_with_user or is_team_shared): raise Exception("Unauthorized access to the agent") if is_owner: now = datetime.datetime.now(datetime.timezone.utc) @@ -1362,6 +1379,10 @@ class StreamProcessor: ), "model_id": self.model_id, "model_user_id": self.model_user_id, + # Agent owner — internal_search resolves the agent's sources as + # their owner so a team member running a shared agent can read + # nested-source structure (the sources aren't theirs). + "source_owner_id": self.agent_config.get("user_id"), "user_api_key": self.agent_config["user_api_key"], "agent_id": self.agent_id, "llm_name": provider or settings.LLM_PROVIDER, diff --git a/application/api/oidc/routes.py b/application/api/oidc/routes.py index dede3801..1d26939f 100644 --- a/application/api/oidc/routes.py +++ b/application/api/oidc/routes.py @@ -230,7 +230,13 @@ def _gate_and_audit_login(user_id: str, effective: dict, groups: list[str]) -> b ) else: if row is None: - users.upsert(user_id) + # New user: provision with the email claim so a team admin + # can later add them by email. + users.upsert(user_id, email=effective.get("email")) + else: + # Existing user: targeted email backfill only (don't re-run + # the full upsert — preserves the no-upsert-on-login path). + users.set_email(user_id, effective.get("email")) AuthEventsRepository(conn).insert( user_id, "oidc_login", diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py index fa097e51..a58f526c 100644 --- a/application/api/user/agents/routes.py +++ b/application/api/user/agents/routes.py @@ -10,6 +10,8 @@ from flask_restx import fields, Namespace, Resource from application.api import api from application.api.user.base import ( handle_image_upload, + resolve_prompt_name, + resolve_source_details, resolve_tool_details, storage, ) @@ -19,6 +21,11 @@ from application.core.json_schema_utils import ( ) from application.core.settings import settings from application.storage.db.base_repository import looks_like_uuid +from application.api.user.team_sharing import ( + can_access, + team_access_for, + visible_with_access, +) from application.storage.db.repositories.agent_folders import AgentFoldersRepository from application.storage.db.repositories.agents import AgentsRepository from application.storage.db.repositories.users import UsersRepository @@ -165,13 +172,25 @@ def _resolve_folder_id(conn, folder_id, user): return str(folder["id"]), None -def _format_agent_output(agent: dict, *, pinned: bool = False, include_key_masked: bool = True) -> dict: +def _format_agent_output( + agent: dict, + *, + pinned: bool = False, + include_key_masked: bool = True, + ownership: str = "user", + team_access: str | None = None, + resolve_names: bool = False, +) -> dict: """Shape a PG agent row into the outward API response dict. Translates PG snake_case columns to the camelCase/frontend keys that the React client expects, preserving ``source``/``sources`` naming on the response even though storage uses ``source_id`` / ``extra_source_ids``. + + ``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for + ones shared with a team they're in; ``team_access`` (``viewer``/``editor``) + is set on team-shared agents so the UI can gate edit controls. """ source_id = agent.get("source_id") extra_source_ids = agent.get("extra_source_ids") or [] @@ -214,7 +233,23 @@ def _format_agent_output(agent: dict, *, pinned: bool = False, include_key_maske "allow_system_prompt_override": bool( agent.get("allow_system_prompt_override", False) ), + "ownership": ownership, + "team_access": team_access, } + # Resolve prompt/source NAMES by id (owner-agnostic) so a team member + # viewing a shared agent sees the owner's prompt + source names instead of + # a blank prompt / "External KB" (the client otherwise resolves these from + # the caller's own lists, which don't contain the owner's resources). + if resolve_names: + out["prompt_name"] = resolve_prompt_name(agent.get("prompt_id")) + out["source_details"] = resolve_source_details( + ([source_id] if source_id else []) + list(extra_source_ids) + ) + # Never expose the owner's share/API secrets to a team grantee — the + # public ``shared_token`` and the (masked) agent ``key`` are owner-only. + if ownership == "team": + out["shared_token"] = "" + return out if include_key_masked: key_val = agent.get("key") or "" out["key"] = ( @@ -303,11 +338,24 @@ class GetAgent(Resource): return {"success": False, "message": "ID required"}, 400 try: user = decoded_token["sub"] + ownership, team_access = "user", None with db_readonly() as conn: - agent = AgentsRepository(conn).get_any(agent_id, user) + repo = AgentsRepository(conn) + agent = repo.get_any(agent_id, user) + if not agent: + # Team fallback: only after a grant check, fetch ownerless. + team_access = team_access_for(conn, user, "agent", agent_id) + if team_access: + agent = repo.get_by_id(agent_id) + ownership = "team" if not agent: return {"status": "Not found"}, 404 - data = _format_agent_output(agent) + data = _format_agent_output( + agent, + ownership=ownership, + team_access=team_access, + resolve_names=True, + ) return make_response(jsonify(data), 200) except Exception as e: current_app.logger.error(f"Agent fetch error: {e}", exc_info=True) @@ -330,16 +378,35 @@ class GetAgents(Resource): if isinstance(user_doc.get("agent_preferences"), dict) else [] ) - agents = AgentsRepository(conn).list_for_user(user) - list_agents = [ - _format_agent_output( - agent, pinned=str(agent["id"]) in pinned_ids, + agents_repo = AgentsRepository(conn) + agents = agents_repo.list_for_user(user) + owned_ids = {str(a["id"]) for a in agents} + # Append agents shared with the caller's teams (dedup vs owned). + team_shared = visible_with_access(conn, user, "agent") + shared_ids = [aid for aid in team_shared if aid not in owned_ids] + shared_agents = agents_repo.list_by_ids(shared_ids) + + def _is_runnable(agent: dict) -> bool: + return bool( + agent.get("source_id") + or (agent.get("extra_source_ids") or []) + or agent.get("retriever") + or agent.get("agent_type") == "workflow" ) + + list_agents = [ + _format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids) for agent in agents - if agent.get("source_id") - or (agent.get("extra_source_ids") or []) - or agent.get("retriever") - or agent.get("agent_type") == "workflow" + if _is_runnable(agent) + ] + list_agents += [ + _format_agent_output( + agent, + ownership="team", + team_access=team_shared.get(str(agent["id"])), + ) + for agent in shared_agents + if _is_runnable(agent) ] except Exception as err: current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True) @@ -550,6 +617,27 @@ class CreateAgent(Resource): if source_value and source_value != "default" and looks_like_uuid(source_value): source_id_resolved = source_value + # Team-sharing write gate: you may reference sources/prompts you + # own or that a team has shared with you directly. (Transitive + # access through a shared agent is a separate run-time concept, + # intentionally not gated here.) + referenced_sources = ( + [source_id_resolved] if source_id_resolved else [] + ) + extra_source_ids + for sid in referenced_sources: + if not can_access(conn, "source", sid, user): + return make_response( + jsonify({"success": False, "message": "Source not accessible"}), + 403, + ) + prompt_ref = data.get("prompt_id") + if prompt_ref and prompt_ref != "default" and looks_like_uuid(prompt_ref): + if not can_access(conn, "prompt", prompt_ref, user): + return make_response( + jsonify({"success": False, "message": "Prompt not accessible"}), + 403, + ) + build_data = dict(data) build_data["folder_id"] = pg_folder_id build_data["workflow_id"] = pg_workflow_id @@ -699,7 +787,23 @@ class UpdateAgent(Resource): try: with db_session() as conn: agents_repo = AgentsRepository(conn) + is_team_editor = False existing_agent = agents_repo.get_any(agent_id, user) + if not existing_agent: + # Team write path: only an 'editor' grant may modify a + # team-shared agent; a 'viewer' is read-only. Fetch the + # ownerless row only AFTER confirming editor access. + access = team_access_for(conn, user, "agent", agent_id) + if access == "editor": + existing_agent = agents_repo.get_by_id(agent_id) + is_team_editor = True + elif access == "viewer": + return make_response( + jsonify( + {"success": False, "message": "Read-only: editor access required"} + ), + 403, + ) if not existing_agent: return make_response( jsonify( @@ -1066,8 +1170,100 @@ class UpdateAgent(Resource): newly_generated_key = str(uuid.uuid4()) update_fields["key"] = newly_generated_key - # Apply update. - updated = agents_repo.update(pg_agent_id, user, update_fields) + # Re-validate referenced sources/prompt on write — a team editor + # must not ATTACH a source/prompt they can't access. References + # already on the agent (set by the owner) are exempt: an editor + # may keep them even when they aren't independently shared, so a + # plain save of an owner-configured agent isn't rejected — only + # NEWLY added refs need a grant. Owners pass can_access for their + # own resources regardless, so this carve-out is a no-op for them + # and the gate stays effective against an editor adding new refs. + # Mirrors the unchanged-tool carve-out below. + existing_source_refs = { + str(s) + for s in ( + [existing_agent.get("source_id")] + + list(existing_agent.get("extra_source_ids") or []) + ) + if s + } + referenced_sources = ( + ([update_fields["source_id"]] if update_fields.get("source_id") else []) + + (update_fields.get("extra_source_ids") or []) + ) + for sid in referenced_sources: + if str(sid) in existing_source_refs: + continue + if not can_access(conn, "source", sid, user): + return make_response( + jsonify({"success": False, "message": "Source not accessible"}), 403 + ) + new_prompt_id = update_fields.get("prompt_id") + if ( + new_prompt_id + and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "") + and not can_access(conn, "prompt", new_prompt_id, user) + ): + return make_response( + jsonify({"success": False, "message": "Prompt not accessible"}), 403 + ) + # A team editor must not attach tools they can't access onto a + # shared agent: at run time the agent-key path resolves+decrypts + # tools as the OWNER, so an unchecked tool here would let an + # editor invoke arbitrary owner credentials. Owners are + # unrestricted (they own their tools). Default/builtin synthetic + # tool ids belong to no one and are always allowed. + if is_team_editor and "tools" in update_fields: + from application.agents.default_tools import is_synthesized_tool_id + + existing_tools = { + str(t) for t in (existing_agent.get("tools") or []) + } + for tid in update_fields["tools"] or []: + tid_s = str(tid) + if tid_s in existing_tools or is_synthesized_tool_id(tid_s): + continue + if not can_access(conn, "tool", tid_s, user): + return make_response( + jsonify( + {"success": False, "message": "Tool not accessible"} + ), + 403, + ) + + # Per-agent quota lives on the row and is pooled across all + # members; only the owner may resize that shared pool, so a + # team editor's quota changes are dropped. + if is_team_editor: + for _q in ( + "token_limit", "request_limit", + "limited_token_mode", "limited_request_mode", + ): + update_fields.pop(_q, None) + + # Apply update. Owner writes use the dual-key guard; team-editor + # writes go by-id (already authorized) with an optimistic-lock + # check when the client supplies the row's expected updated_at. + if is_team_editor: + result = agents_repo.update_by_id( + pg_agent_id, + update_fields, + expected_updated_at=data.get("expected_updated_at"), + ) + if result is None: + return make_response( + jsonify( + { + "success": False, + "message": "Agent was modified by someone else", + "code": "stale_write", + } + ), + 409, + ) + updated = bool(result) + else: + updated = agents_repo.update(pg_agent_id, user, update_fields) if not updated: return make_response( jsonify( diff --git a/application/api/user/base.py b/application/api/user/base.py index c571ee2f..5aaa478e 100644 --- a/application/api/user/base.py +++ b/application/api/user/base.py @@ -167,6 +167,58 @@ def resolve_tool_details(tool_ids): ] +_BUILTIN_PROMPT_NAMES = { + "default": "Default", + "creative": "Creative", + "strict": "Strict", +} + + +def resolve_prompt_name(prompt_id) -> Optional[str]: + """Resolve a prompt id to its display name by id (owner-agnostic). + + Mirrors ``resolve_tool_details``: looks the prompt up by id without user + scoping, so a team member viewing a shared agent sees the OWNER's prompt + name rather than nothing. Built-in prompt sentinels map to friendly labels. + Returns None when the prompt is missing/unknown. + """ + if not prompt_id: + return None + pid = str(prompt_id) + if pid in _BUILTIN_PROMPT_NAMES: + return _BUILTIN_PROMPT_NAMES[pid] + if not looks_like_uuid(pid): + return None + with db_readonly() as conn: + result = conn.execute( + _sql_text("SELECT name FROM prompts WHERE id = CAST(:id AS uuid)"), + {"id": pid}, + ) + row = result.fetchone() + return row[0] if row is not None else None + + +def resolve_source_details(source_ids) -> list[dict]: + """Resolve source ids to ``[{"id", "name"}]`` by id (owner-agnostic). + + Order-preserving; an id with no matching source row yields ``name: None`` so + the client can fall back. Lets a team member viewing a shared agent see the + owner's source names instead of a raw id / "External KB". + """ + ids = [str(s) for s in (source_ids or []) if s and looks_like_uuid(str(s))] + if not ids: + return [] + with db_readonly() as conn: + result = conn.execute( + _sql_text( + "SELECT id, name FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": ids}, + ) + by_id = {str(r[0]): r[1] for r in result.fetchall()} + return [{"id": sid, "name": by_id.get(sid)} for sid in ids] + + def get_vector_store(source_id): """ Get the Vector Store for a given source ID. diff --git a/application/api/user/prompts/routes.py b/application/api/user/prompts/routes.py index f4b73da8..da47229c 100644 --- a/application/api/user/prompts/routes.py +++ b/application/api/user/prompts/routes.py @@ -7,6 +7,7 @@ from flask_restx import fields, Namespace, Resource from application.api import api from application.api.user.base import current_dir +from application.api.user.team_sharing import team_access_for, visible_with_access from application.storage.db.repositories.prompts import PromptsRepository from application.storage.db.session import db_readonly, db_session from application.utils import check_required_fields @@ -60,7 +61,12 @@ class GetPrompts(Resource): user = decoded_token.get("sub") try: with db_readonly() as conn: - prompts = PromptsRepository(conn).list_for_user(user) + repo = PromptsRepository(conn) + prompts = repo.list_for_user(user) + owned_ids = {str(p["id"]) for p in prompts} + team_shared = visible_with_access(conn, user, "prompt") + shared_ids = [pid for pid in team_shared if pid not in owned_ids] + shared_prompts = repo.list_by_ids(shared_ids) list_prompts = [ {"id": "default", "name": "default", "type": "public"}, {"id": "creative", "name": "creative", "type": "public"}, @@ -74,6 +80,15 @@ class GetPrompts(Resource): "type": "private", } ) + for prompt in shared_prompts: + list_prompts.append( + { + "id": str(prompt["id"]), + "name": prompt["name"], + "type": "team", + "team_access": team_shared.get(str(prompt["id"])), + } + ) except Exception as err: current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) @@ -115,7 +130,11 @@ class GetSinglePrompt(Resource): chat_reduce_strict = f.read() return make_response(jsonify({"content": chat_reduce_strict}), 200) with db_readonly() as conn: - prompt = PromptsRepository(conn).get_any(prompt_id, user) + repo = PromptsRepository(conn) + prompt = repo.get_any(prompt_id, user) + if not prompt and team_access_for(conn, user, "prompt", prompt_id): + # Team fallback: ownerless fetch only after a grant check. + prompt = repo.get_for_rendering(prompt_id) if not prompt: return make_response( jsonify({"success": False, "message": "Prompt not found"}), 404 @@ -190,12 +209,41 @@ class UpdatePrompt(Resource): with db_session() as conn: repo = PromptsRepository(conn) prompt = repo.get_any(data["id"], user) - if not prompt: - return make_response( - jsonify({"success": False, "message": "Prompt not found"}), - 404, - ) - repo.update(str(prompt["id"]), user, data["name"], data["content"]) + if prompt: + repo.update(str(prompt["id"]), user, data["name"], data["content"]) + else: + # Team editor write path (viewer is read-only). + access = team_access_for(conn, user, "prompt", data["id"]) + if access == "editor": + result = repo.update_by_id( + data["id"], + data["name"], + data["content"], + expected_updated_at=data.get("expected_updated_at"), + ) + if result is None: + return make_response( + jsonify( + { + "success": False, + "message": "Prompt was modified by someone else", + "code": "stale_write", + } + ), + 409, + ) + elif access == "viewer": + return make_response( + jsonify( + {"success": False, "message": "Read-only: editor access required"} + ), + 403, + ) + else: + return make_response( + jsonify({"success": False, "message": "Prompt not found"}), + 404, + ) except Exception as err: current_app.logger.error(f"Error updating prompt: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) diff --git a/application/api/user/routes.py b/application/api/user/routes.py index aa792c93..2d8ed6c7 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -21,6 +21,7 @@ from .prompts import prompts_ns from .schedules import schedules_ns from .sharing import sharing_ns from .sources import sources_chunks_ns, sources_ns, sources_upload_ns +from .teams import teams_ns from .tools import tools_mcp_ns, tools_ns from .workflows import workflows_ns @@ -63,6 +64,9 @@ api.add_namespace(sources_ns) api.add_namespace(sources_chunks_ns) api.add_namespace(sources_upload_ns) +# Teams (CRUD, membership, resource-sharing grants) +api.add_namespace(teams_ns) + # Tools (main, MCP) api.add_namespace(tools_ns) api.add_namespace(tools_mcp_ns) diff --git a/application/api/user/sources/chunks.py b/application/api/user/sources/chunks.py index e0031413..11b3616d 100644 --- a/application/api/user/sources/chunks.py +++ b/application/api/user/sources/chunks.py @@ -5,6 +5,7 @@ from flask_restx import fields, Namespace, Resource from application.api import api from application.api.user.base import get_vector_store +from application.api.user.team_sharing import effective_write_owner from application.storage.db.repositories.sources import SourcesRepository from application.storage.db.session import db_readonly from application.utils import check_required_fields, num_tokens_from_string @@ -23,6 +24,25 @@ def _resolve_source(doc_id: str, user: str): return SourcesRepository(conn).get_any(doc_id, user) +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) + + @sources_chunks_ns.route("/get_chunks") class GetChunks(Resource): @api.doc( @@ -141,14 +161,12 @@ class AddChunk(Resource): metadata["token_count"] = token_count try: - doc = _resolve_source(doc_id, user) + doc = _resolve_source_for_write(doc_id, user) 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 - ) + return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) chunk_id = store.add_chunk(text, metadata) @@ -176,14 +194,12 @@ class DeleteChunk(Resource): chunk_id = request.args.get("chunk_id") try: - doc = _resolve_source(doc_id, user) + doc = _resolve_source_for_write(doc_id, user) 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 - ) + return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) deleted = store.delete_chunk(chunk_id) @@ -245,14 +261,12 @@ class UpdateChunk(Resource): metadata = {} metadata["token_count"] = token_count try: - doc = _resolve_source(doc_id, user) + doc = _resolve_source_for_write(doc_id, user) 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 - ) + return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index 3eab3abf..cbd6ea47 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -8,6 +8,10 @@ from flask_restx import fields, Namespace, Resource from application.api import api from application.api.user.tasks import reingest_source_task, sync_source +from application.api.user.team_sharing import ( + effective_write_owner, + visible_with_access, +) from application.core.settings import settings from application.parser.remote.remote_creator import normalize_remote_data from application.storage.db.repositories.ingest_chunk_progress import ( @@ -61,7 +65,14 @@ class CombinedJson(Resource): try: with db_readonly() as conn: - indexes = SourcesRepository(conn).list_for_user(user) + repo = SourcesRepository(conn) + indexes = repo.list_for_user(user) + owned_ids = {str(i["id"]) for i in indexes} + # Sources shared with the caller's teams — surfaced here so they + # are attachable to the member's own agents (direct sharing). + 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) # 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. @@ -69,22 +80,34 @@ class CombinedJson(Resource): indexes, key=lambda r: r.get("date") or r.get("created_at") or "", reverse=True, ) - for index in indexes: + + def _source_entry(index, *, ownership="user", team_access=None): provider = _get_provider_from_remote_data(index.get("remote_data")) + return { + "id": str(index["id"]), + "name": index.get("name"), + "date": index.get("date"), + "model": settings.EMBEDDINGS_NAME, + "location": "local", + "tokens": index.get("tokens", ""), + "retriever": index.get("retriever", "classic"), + "syncFrequency": index.get("sync_frequency", ""), + "provider": provider, + "is_nested": bool(index.get("directory_structure")), + "type": index.get("type", "file"), + "ownership": ownership, + "team_access": team_access, + } + + for index in indexes: + data.append(_source_entry(index)) + for index in shared_sources: data.append( - { - "id": str(index["id"]), - "name": index.get("name"), - "date": index.get("date"), - "model": settings.EMBEDDINGS_NAME, - "location": "local", - "tokens": index.get("tokens", ""), - "retriever": index.get("retriever", "classic"), - "syncFrequency": index.get("sync_frequency", ""), - "provider": provider, - "is_nested": bool(index.get("directory_structure")), - "type": index.get("type", "file"), - } + _source_entry( + index, + ownership="team", + team_access=team_shared.get(str(index["id"])), + ) ) except Exception as err: current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True) @@ -109,8 +132,13 @@ class PaginatedSources(Resource): try: with db_readonly() as conn: repo = SourcesRepository(conn) + # Include sources shared with the caller's teams so the settings + # list matches the /api/sources dropdown. Unioned into the query + # by id so count/sort/search/pagination stay correct. + team_shared = visible_with_access(conn, user, "source") + extra_ids = list(team_shared.keys()) total_documents = repo.count_for_user( - user, search_term=search_term, + user, search_term=search_term, extra_ids=extra_ids, ) # Prior in-Python implementation returned ``totalPages = 1`` # for empty result sets (``max(1, ceil(0/rows))``); we @@ -125,11 +153,15 @@ class PaginatedSources(Resource): search_term=search_term, sort_field=sort_field, sort_order=sort_order, + extra_ids=extra_ids, ) paginated_docs = [] for doc in window: provider = _get_provider_from_remote_data(doc.get("remote_data")) + # 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) paginated_docs.append( { "id": str(doc["id"]), @@ -145,6 +177,10 @@ class PaginatedSources(Resource): "type": doc.get("type", "file"), # 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"])) + ), } ) response = { @@ -271,12 +307,19 @@ class ManageSync(Resource): with db_session() as conn: repo = SourcesRepository(conn) doc = repo.get_any(source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) - repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency}) + 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}) except Exception as err: current_app.logger.error( f"Error updating sync frequency: {err}", exc_info=True @@ -305,9 +348,18 @@ class SyncSource(Resource): if missing_fields: return missing_fields source_id = data["source_id"] + # Resolve the owner to run the sync AS: ``user`` when they own the + # source, the real owner when ``user`` holds a team ``editor`` grant + # (re-sync is an editor-allowed write). Vector partitions are keyed by + # 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) except Exception as err: current_app.logger.error(f"Error looking up source: {err}", exc_info=True) return make_response( @@ -315,7 +367,7 @@ class SyncSource(Resource): ) if not doc: return make_response( - jsonify({"success": False, "message": "Source not found"}), 404 + jsonify({"success": False, "message": "Source not accessible"}), 403 ) source_type = doc.get("type", "") if source_type and source_type.startswith("connector"): @@ -337,7 +389,7 @@ class SyncSource(Resource): task = sync_source.delay( source_data=source_data, job_name=doc.get("name", ""), - user=user, + user=owner, loader=source_type, sync_frequency=doc.get("sync_frequency", "never"), retriever=doc.get("retriever", "classic"), @@ -374,9 +426,18 @@ class ReingestSource(Resource): if missing_fields: return missing_fields source_id = data["source_id"] + # Run the reingest AS the owner: ``user`` when they own the source, + # the real owner when ``user`` holds a team ``editor`` grant (reingest + # is an editor-allowed write). Vector partitions are keyed by 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) except Exception as err: current_app.logger.error( f"Error looking up source: {err}", exc_info=True @@ -386,7 +447,7 @@ class ReingestSource(Resource): ) if not doc: return make_response( - jsonify({"success": False, "message": "Source not found"}), 404 + jsonify({"success": False, "message": "Source not accessible"}), 403 ) resolved_source_id = str(doc["id"]) # Drop the stale chunk-progress row so the sources list stops @@ -404,8 +465,8 @@ class ReingestSource(Resource): # Scoped key so repeated clicks collapse onto one reingest. task = reingest_source_task.delay( source_id=resolved_source_id, - user=user, - idempotency_key=f"reingest-source:{user}:{resolved_source_id}", + user=owner, + idempotency_key=f"reingest-source:{owner}:{resolved_source_id}", ) except Exception as err: current_app.logger.error( diff --git a/application/api/user/sources/upload.py b/application/api/user/sources/upload.py index 9292406b..74341555 100644 --- a/application/api/user/sources/upload.py +++ b/application/api/user/sources/upload.py @@ -12,6 +12,7 @@ from sqlalchemy import text as sql_text from application.api import api from application.api.user.tasks import ingest, ingest_connector_task, ingest_remote +from application.api.user.team_sharing import effective_write_owner from application.core.settings import settings from application.storage.db.source_ids import derive_source_id as _derive_source_id from application.parser.connectors.connector_creator import ConnectorCreator @@ -541,9 +542,19 @@ class ManageSourceFiles(Resource): ), 400, ) + # Resolve the owner to run the file mutation + reingest AS: ``user`` + # when they own the source, the real owner when ``user`` holds a team + # ``editor`` grant (add/remove files is an editor-allowed write). The + # storage layout and vector partition are keyed by source_id + # (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( @@ -654,7 +665,7 @@ class ManageSourceFiles(Resource): if map_updated: with db_session() as conn: SourcesRepository(conn).update( - resolved_source_id, user, + resolved_source_id, owner, {"file_name_map": dict(file_name_map)}, ) # Trigger re-ingestion pipeline @@ -664,7 +675,7 @@ class ManageSourceFiles(Resource): task = reingest_source_task.apply_async( kwargs={ "source_id": resolved_source_id, - "user": user, + "user": owner, "idempotency_key": scoped_key or idempotency_key, }, task_id=predetermined_task_id, @@ -766,7 +777,7 @@ class ManageSourceFiles(Resource): if map_updated and isinstance(file_name_map, dict): with db_session() as conn: SourcesRepository(conn).update( - resolved_source_id, user, + resolved_source_id, owner, {"file_name_map": dict(file_name_map)}, ) # Trigger re-ingestion pipeline @@ -776,7 +787,7 @@ class ManageSourceFiles(Resource): task = reingest_source_task.apply_async( kwargs={ "source_id": resolved_source_id, - "user": user, + "user": owner, "idempotency_key": scoped_key or idempotency_key, }, task_id=predetermined_task_id, @@ -895,7 +906,7 @@ class ManageSourceFiles(Resource): file_name_map.pop(key, None) with db_session() as conn: SourcesRepository(conn).update( - resolved_source_id, user, + resolved_source_id, owner, {"file_name_map": dict(file_name_map)}, ) @@ -906,7 +917,7 @@ class ManageSourceFiles(Resource): task = reingest_source_task.apply_async( kwargs={ "source_id": resolved_source_id, - "user": user, + "user": owner, "idempotency_key": scoped_key or idempotency_key, }, task_id=predetermined_task_id, diff --git a/application/api/user/team_authz.py b/application/api/user/team_authz.py new file mode 100644 index 00000000..5e7bda79 --- /dev/null +++ b/application/api/user/team_authz.py @@ -0,0 +1,109 @@ +"""Team-scoped authorization — the SECOND authz plane beside global RBAC. + +Mirrors ``application/api/user/authz.py`` (the global admin/user plane) but for +per-team roles. The two planes never mix: a global ``admin`` is a superuser over +all teams (short-circuit below), but a ``team_admin`` is NOT a global admin. + +Key differences from the global resolver, all deliberate: +- Team roles are resolved PER ROUTE (lazy), not eagerly in the app.py chokepoint + — most requests never touch a team route, so we don't pay a team query on the + universal path. +- Team roles are NEVER read from the JWT (same reason as global RBAC: simple_jwt + / session_jwt are self-mintable). +- ``team_id`` is read ONLY from ``request.view_args`` (the URL path), NEVER the + request body — a body-supplied team_id would be a trivial escalation vector. +- Resolution FAILS CLOSED: a DB error while reading membership denies access + (the inverse of the global resolver, which fails open — here, failing open + would grant team access during an outage). +""" + +from __future__ import annotations + +import logging +from functools import wraps + +from flask import jsonify, make_response, request + +from application.api.user.authz import ROLE_ADMIN, has_role +from application.storage.db.repositories.team_members import ( + ROLE_TEAM_ADMIN, + ROLE_TEAM_MEMBER, + TeamMembersRepository, +) +from application.storage.db.session import db_readonly + +logger = logging.getLogger(__name__) + + +def team_role_for(token: dict | None, team_id: str | None) -> str | None: + """Strongest team role the principal holds in ``team_id``, or None. + + Fails closed to None on a DB error so a membership-read outage denies team + access rather than granting it. + """ + if not token or not team_id: + return None + sub = token.get("sub") + if not sub: + return None + try: + with db_readonly() as conn: + return TeamMembersRepository(conn).role_for(sub, team_id) + except Exception: + logger.error( + "team_role_for: team_members read failed for sub=%s team=%s", + sub, + team_id, + exc_info=True, + ) + return None + + +def has_team_role(token: dict | None, team_id: str | None, name: str) -> bool: + """True if the principal satisfies ``name`` for ``team_id``. + + A global ``admin`` satisfies any team role (superuser over all teams). + Otherwise ``team_admin`` implies ``team_member`` (admin ⊇ member). + """ + if has_role(token, ROLE_ADMIN): + return True + role = team_role_for(token, team_id) + if role is None: + return False + if name == ROLE_TEAM_MEMBER: + return True + return role == name + + +def require_team_role(name: str): + """Decorator factory: 401 when unauthenticated, 403 when lacking ``name``. + + Reads ``team_id`` from the route kwargs (URL path) ONLY — never the body. + Fails closed: a missing token, missing team_id, or membership-read error + never passes through. + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + token = getattr(request, "decoded_token", None) + if not token: + return make_response( + jsonify({"success": False, "message": "Authentication required"}), 401 + ) + team_id = kwargs.get("team_id") + if not team_id: + return make_response( + jsonify({"success": False, "message": "Team not specified"}), 400 + ) + if not has_team_role(token, team_id, name): + return make_response(jsonify({"success": False, "message": "Forbidden"}), 403) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +team_admin_required = require_team_role(ROLE_TEAM_ADMIN) +team_member_required = require_team_role(ROLE_TEAM_MEMBER) diff --git a/application/api/user/team_sharing.py b/application/api/user/team_sharing.py new file mode 100644 index 00000000..2d6248c1 --- /dev/null +++ b/application/api/user/team_sharing.py @@ -0,0 +1,124 @@ +"""Cross-cutting helpers for team resource sharing. + +Centralises the polymorphic dispatch over the four shareable resource types so +the grant endpoint and the per-entity list/get/update paths share one source of +truth for "does this user own resource R?" and "what can this user see/edit via +their teams?". Every shareable repo exposes the same ``get_any(id, user_id)`` +ownership accessor, which is what makes the dispatch uniform. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Connection + +from application.storage.db.base_repository import looks_like_uuid +from application.storage.db.repositories.agents import AgentsRepository +from application.storage.db.repositories.prompts import PromptsRepository +from application.storage.db.repositories.sources import SourcesRepository +from application.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, +) +from application.storage.db.repositories.team_scope import TeamScopeRepository +from application.storage.db.repositories.user_tools import UserToolsRepository + +RESOURCE_TYPES = ("agent", "source", "prompt", "tool") + +_REPO_FOR_TYPE = { + "agent": AgentsRepository, + "source": SourcesRepository, + "prompt": PromptsRepository, + "tool": UserToolsRepository, +} + + +def is_valid_resource_type(resource_type: str) -> bool: + return resource_type in _REPO_FOR_TYPE + + +def owns_resource( + conn: Connection, resource_type: str, resource_id: str, user_id: str +) -> bool: + """True if ``user_id`` is the OWNER of the resource. + + Dispatches to the correct repo by ``resource_type`` — this is the guard that + stops a caller registering a grant with a mismatched ``resource_type`` / + ``resource_id`` (the polymorphic grant table has no FK to catch it). Owners + only — team-granted ``editor`` access does NOT make you an owner. + """ + repo_cls = _REPO_FOR_TYPE.get(resource_type) + if repo_cls is None: + return False + return repo_cls(conn).get_any(resource_id, user_id) is not None + + +def team_access_for( + conn: Connection, user_id: str, resource_type: str, resource_id: str +) -> Optional[str]: + """Strongest team access (``editor``/``viewer``) ``user_id`` has on a resource. + + None when no team grant reaches the user (they may still be the owner — that + is a separate dual-key check at the repo). + """ + return TeamScopeRepository(conn).effective_access(user_id, resource_type, resource_id) + + +def visible_ids_via_teams( + conn: Connection, user_id: str, resource_type: str +) -> set[str]: + """The id set of ``resource_type`` shared to any team ``user_id`` belongs to.""" + return TeamScopeRepository(conn).visible_resource_ids(user_id, resource_type) + + +def visible_with_access( + conn: Connection, user_id: str, resource_type: str +) -> dict[str, str]: + """Map ``resource_id -> strongest access`` shared to the user's teams.""" + return TeamScopeRepository(conn).visible_with_access(user_id, resource_type) + + +def effective_write_owner( + conn: Connection, resource_type: str, resource_id: str, user_id: str +) -> Optional[str]: + """The owner id to write a resource AS, or None if the caller can't write. + + Returns ``user_id`` when the caller owns the resource, or the resource's + real ``owner_id`` when the caller holds a team ``editor`` grant — so callers + can pass the result straight to the existing owner-scoped ``update(id, owner, + ...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without + a separate ownerless write path. None means viewer-only or no access → the + route should answer 403/404. Delete is never authorized here — owner-only. + """ + if owns_resource(conn, resource_type, resource_id, user_id): + return user_id + # Past the ownership check, only canonical-UUID resources can carry a team + # grant; a legacy/non-UUID id can't, and casting it would poison the txn. + if not looks_like_uuid(resource_id): + return None + grants = TeamResourceGrantsRepository(conn).list_for_resource( + resource_type, resource_id + ) + if not grants: + return None + if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id): + # All grant rows carry the same denormalised owner_id. + return grants[0].get("owner_id") + return None + + +def can_access( + conn: Connection, resource_type: str, resource_id: str, user_id: str +) -> bool: + """True if ``user_id`` owns the resource OR has any team grant on it (read). + + This is the write-path gate for *referencing* a resource (e.g. attaching a + ``source_id`` to an agent): you may reference what you own or what a team has + shared with you directly. Transitive access *through* a shared agent is a + separate, run-time concept and is intentionally NOT gated here. + """ + if not resource_id: + return True + if owns_resource(conn, resource_type, resource_id, user_id): + return True + return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id) diff --git a/application/api/user/teams/__init__.py b/application/api/user/teams/__init__.py new file mode 100644 index 00000000..fe52f934 --- /dev/null +++ b/application/api/user/teams/__init__.py @@ -0,0 +1,5 @@ +"""Teams API namespace: team CRUD, membership, and resource-sharing grants.""" + +from .routes import teams_ns + +__all__ = ["teams_ns"] diff --git a/application/api/user/teams/routes.py b/application/api/user/teams/routes.py new file mode 100644 index 00000000..5902d39d --- /dev/null +++ b/application/api/user/teams/routes.py @@ -0,0 +1,636 @@ +"""Team management API: CRUD, membership, and resource-sharing grants. + +Authorization model (two planes, see ``team_authz.py``): +- Any authenticated user may create a team (self-serve); the creator becomes a + ``team_admin`` in the same transaction. +- Team detail / member list / grant list require team membership. +- Member management and team edit require ``team_admin``. +- Team deletion and owner transfer are owner-only (a global ``admin`` overrides). +- Sharing a resource requires the caller to OWN it (dispatched by resource_type) + and be a member of the target team. Sharing is additive visibility — the + resource's owner is never changed. + +``team_id`` always comes from the URL path (never the body) — enforced by +``require_team_role`` and by reading ``team_id`` as a route kwarg here. +""" + +from __future__ import annotations + +import logging +import re +import uuid + +from flask import jsonify, make_response, request +from flask_restx import Namespace, Resource + +from application.api.user.authz import ROLE_ADMIN, has_role +from application.api.user.team_authz import ( + has_team_role, + team_admin_required, + team_member_required, +) +from application.api.user.team_sharing import is_valid_resource_type, owns_resource +from application.events.publisher import publish_user_event +from application.storage.db.base_repository import looks_like_uuid +from application.storage.db.repositories.agents import AgentsRepository +from application.storage.db.repositories.auth_events import AuthEventsRepository +from application.storage.db.repositories.prompts import PromptsRepository +from application.storage.db.repositories.sources import SourcesRepository +from application.storage.db.repositories.team_members import ( + ROLE_TEAM_ADMIN, + ROLE_TEAM_MEMBER, + TeamMembersRepository, +) +from application.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, +) +from application.storage.db.repositories.teams import TeamsRepository +from application.storage.db.repositories.user_tools import UserToolsRepository +from application.storage.db.repositories.users import UsersRepository +from application.storage.db.session import db_readonly, db_session + +logger = logging.getLogger(__name__) + +teams_ns = Namespace("teams", description="Team management and resource sharing", path="/api") + +_VALID_TEAM_ROLES = (ROLE_TEAM_ADMIN, ROLE_TEAM_MEMBER) +_VALID_ACCESS_LEVELS = ("viewer", "editor") + + +def _current_user() -> str | None: + token = getattr(request, "decoded_token", None) + return token.get("sub") if isinstance(token, dict) else None + + +def _audit(conn, actor: str | None, event: str, **metadata) -> None: + """Append a team management event to the audit trail (best-effort). + + Runs inside the action's transaction so the audit row commits atomically + with the change. Never raises into the request path — an audit failure must + not fail the operation. + """ + try: + # SAVEPOINT: a failed audit insert poisons the surrounding txn, so + # nest it — on failure only the audit rolls back, not the action. + with conn.begin_nested(): + AuthEventsRepository(conn).insert( + user_id=actor or "unknown", + event=event, + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={k: v for k, v in metadata.items() if v is not None}, + ) + except Exception: + logger.warning("team audit insert failed for event=%s", event, exc_info=True) + + +def _slugify(name: str) -> str: + base = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-") + return base or "team" + + +def _unique_slug(repo: TeamsRepository, base: str) -> str: + """A slug not yet taken: ``base`` if free, else ``base-<4hex>`` until unique.""" + if not repo.slug_exists(base): + return base + for _ in range(8): + candidate = f"{base}-{uuid.uuid4().hex[:4]}" + if not repo.slug_exists(candidate): + return candidate + return f"{base}-{uuid.uuid4().hex}" + + +# --- Notifications (best-effort, fire-and-forget; never fail the request) ---- +# Emitted AFTER the mutation commits, over their own read connection, so a +# name-lookup or Redis hiccup can never roll back the share/membership change. + + +def _resource_display_name(conn, resource_type: str, resource_id: str) -> str | None: + """Best-effort human label for a shared resource (ownerless fetch).""" + try: + if resource_type == "agent": + row = AgentsRepository(conn).get_by_id(resource_id) + elif resource_type == "source": + row = SourcesRepository(conn).get_by_id(resource_id) + elif resource_type == "prompt": + row = PromptsRepository(conn).get_for_rendering(resource_id) + elif resource_type == "tool": + row = UserToolsRepository(conn).get_by_id(resource_id) + else: + return None + if not row: + return None + return row.get("custom_name") or row.get("display_name") or row.get("name") + except Exception: + logger.warning("resource name resolve failed (%s)", resource_type, exc_info=True) + return None + + +def _notify_member_added( + team_id: str, new_user: str | None, role: str, actor: str | None +) -> None: + """Toast the new member that they were added to a team.""" + if not new_user or new_user == actor: + return + try: + with db_readonly() as conn: + team = TeamsRepository(conn).get(team_id) + publish_user_event( + new_user, + "team.member_added", + { + "team_id": str(team_id), + "team_name": team.get("name") if team else None, + "role": role, + "added_by": actor, + }, + scope={"kind": "team", "id": str(team_id)}, + ) + except Exception: + logger.warning("member_added notify failed", exc_info=True) + + +def _notify_resource_shared( + actor: str | None, + team_id: str, + resource_type: str, + resource_id: str, + access_level: str, + target_user_id: str | None, +) -> None: + """Toast the recipient(s) of a share. + + Per-member share notifies just that member; whole-team share notifies every + current member except the sharer. + """ + try: + with db_readonly() as conn: + resource_name = _resource_display_name(conn, resource_type, resource_id) + team = TeamsRepository(conn).get(team_id) + if target_user_id: + recipients = [target_user_id] + else: + members = TeamMembersRepository(conn).list_members(team_id) + recipients = list({m["user_id"] for m in members}) + payload = { + "resource_type": resource_type, + "resource_id": str(resource_id), + "resource_name": resource_name, + "access_level": access_level, + "team_id": str(team_id), + "team_name": team.get("name") if team else None, + "shared_by": actor, + } + for recipient in recipients: + if recipient and recipient != actor: + publish_user_event( + recipient, + "resource.shared", + payload, + scope={"kind": "resource", "id": str(resource_id)}, + ) + except Exception: + logger.warning("resource_shared notify failed", exc_info=True) + + +@teams_ns.route("/teams") +class Teams(Resource): + def get(self): + """List the teams the caller belongs to, each annotated with their role.""" + user = _current_user() + if not user: + return {"success": False}, 401 + try: + with db_readonly() as conn: + teams = TeamsRepository(conn).list_for_user(user) + return make_response(jsonify({"success": True, "teams": teams}), 200) + except Exception as err: + logger.error("List teams failed: %s", err, exc_info=True) + return {"success": False}, 400 + + def post(self): + """Create a team (self-serve). The creator becomes its first team_admin.""" + user = _current_user() + if not user: + return {"success": False}, 401 + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return {"success": False, "message": "name required"}, 400 + description = data.get("description") + try: + with db_session() as conn: + teams = TeamsRepository(conn) + slug = _unique_slug(teams, _slugify(name)) + team = teams.create(name, slug, owner_id=user, description=description) + TeamMembersRepository(conn).add_member( + team["id"], user, role=ROLE_TEAM_ADMIN, source="manual", granted_by=user + ) + _audit(conn, user, "team.create", team_id=team["id"], name=name) + team["member_role"] = ROLE_TEAM_ADMIN + return make_response(jsonify({"success": True, "team": team}), 201) + except Exception as err: + logger.error("Create team failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/teams/") +class Team(Resource): + @team_member_required + def get(self, team_id): + """Team detail with members and the caller's role. Requires membership.""" + user = _current_user() + try: + with db_readonly() as conn: + team = TeamsRepository(conn).get(team_id) + if not team: + return {"success": False, "message": "Not found"}, 404 + members = TeamMembersRepository(conn) + team["members"] = members.list_members(team_id) + team["member_role"] = members.role_for(user, team_id) + return make_response(jsonify({"success": True, "team": team}), 200) + except Exception as err: + logger.error("Get team failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @team_admin_required + def put(self, team_id): + """Update team name/description. Requires team_admin.""" + data = request.get_json(silent=True) or {} + fields = {k: data[k] for k in ("name", "description") if k in data} + if not fields: + return {"success": False, "message": "nothing to update"}, 400 + try: + with db_session() as conn: + updated = TeamsRepository(conn).update(team_id, fields) + return make_response(jsonify({"success": bool(updated)}), 200) + except Exception as err: + logger.error("Update team failed: %s", err, exc_info=True) + return {"success": False}, 400 + + def delete(self, team_id): + """Delete the team. Owner-only (a global admin overrides).""" + user = _current_user() + if not user: + return {"success": False}, 401 + try: + with db_session() as conn: + team = TeamsRepository(conn).get(team_id) + if not team: + return {"success": False, "message": "Not found"}, 404 + token = getattr(request, "decoded_token", None) + if team["owner_id"] != user and not has_role(token, ROLE_ADMIN): + return {"success": False, "message": "Only the team owner can delete"}, 403 + TeamsRepository(conn).delete(team_id) + _audit(conn, user, "team.delete", team_id=team_id) + return make_response(jsonify({"success": True}), 200) + except Exception as err: + logger.error("Delete team failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/teams//members") +class TeamMembers(Resource): + @team_member_required + def get(self, team_id): + """List members. Requires membership.""" + try: + with db_readonly() as conn: + members = TeamMembersRepository(conn).list_members(team_id) + return make_response(jsonify({"success": True, "members": members}), 200) + except Exception as err: + logger.error("List members failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @team_admin_required + def post(self, team_id): + """Add a member by email (preferred) or raw user_id. Requires team_admin.""" + data = request.get_json(silent=True) or {} + new_user = (data.get("user_id") or "").strip() + email = (data.get("email") or "").strip() + role = data.get("role", ROLE_TEAM_MEMBER) + if role not in _VALID_TEAM_ROLES: + return {"success": False, "message": "invalid role"}, 400 + if not new_user and not email: + return {"success": False, "message": "email or user_id required"}, 400 + try: + with db_session() as conn: + # Resolve an email to its sub (the user must have logged in at + # least once for their email to be on file). + if not new_user and email: + user_row = UsersRepository(conn).find_by_email(email) + if not user_row: + return { + "success": False, + "message": "No user found with that email (they must sign in once first)", + }, 404 + new_user = user_row["user_id"] + TeamMembersRepository(conn).set_manual_role( + team_id, new_user, role, granted_by=_current_user() + ) + _audit( + conn, + _current_user(), + "team.member_add", + team_id=team_id, + target_user=new_user, + role=role, + ) + # Post-commit, best-effort: tell the new member they were added. + _notify_member_added(team_id, new_user, role, _current_user()) + return make_response(jsonify({"success": True}), 200) + except Exception as err: + logger.error("Add member failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/teams//members/") +class TeamMember(Resource): + @team_admin_required + def put(self, team_id, member_id): + """Change a member's role. Requires team_admin. Guards the last admin.""" + data = request.get_json(silent=True) or {} + role = data.get("role") + if role not in _VALID_TEAM_ROLES: + return {"success": False, "message": "invalid role"}, 400 + try: + with db_session() as conn: + members = TeamMembersRepository(conn) + if role == ROLE_TEAM_MEMBER and self._would_orphan_admins( + members, team_id, member_id + ): + return { + "success": False, + "message": "Cannot demote the last team admin", + }, 409 + members.set_manual_role(team_id, member_id, role, granted_by=_current_user()) + _audit( + conn, + _current_user(), + "team.member_role", + team_id=team_id, + target_user=member_id, + role=role, + ) + return make_response(jsonify({"success": True}), 200) + except Exception as err: + logger.error("Update member role failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @team_member_required + def delete(self, team_id, member_id): + """Remove a member. team_admin removes anyone; a member may remove self + (leave). Guards the last admin.""" + user = _current_user() + token = getattr(request, "decoded_token", None) + is_self = member_id == user + if not is_self and not has_team_role(token, team_id, ROLE_TEAM_ADMIN): + return {"success": False, "message": "Forbidden"}, 403 + try: + with db_session() as conn: + members = TeamMembersRepository(conn) + if self._would_orphan_admins(members, team_id, member_id): + return { + "success": False, + "message": "Cannot remove the last team admin", + }, 409 + members.remove_member(team_id, member_id) + _audit( + conn, + user, + "team.member_remove", + team_id=team_id, + target_user=member_id, + self_leave=is_self, + ) + return make_response(jsonify({"success": True}), 200) + except Exception as err: + logger.error("Remove member failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @staticmethod + def _would_orphan_admins( + members: TeamMembersRepository, team_id: str, member_id: str + ) -> bool: + """True if removing/demoting ``member_id`` leaves the team with no admin. + + Locks the admin rows (FOR UPDATE) so concurrent demote/remove calls + serialize — preventing two simultaneous removals of distinct admins from + both passing the guard and orphaning the team. + """ + admins = members.lock_admins(team_id) + if member_id not in admins: + return False + return len(admins) <= 1 + + +@teams_ns.route("/teams//grants") +class TeamGrants(Resource): + @team_member_required + def get(self, team_id): + """List resources shared with this team. Requires membership.""" + resource_type = request.args.get("resource_type") + try: + with db_readonly() as conn: + grants = TeamResourceGrantsRepository(conn).list_for_team( + team_id, resource_type + ) + return make_response(jsonify({"success": True, "grants": grants}), 200) + except Exception as err: + logger.error("List grants failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @team_member_required + def post(self, team_id): + """Share a resource the caller OWNS with this team (additive visibility).""" + user = _current_user() + data = request.get_json(silent=True) or {} + resource_type = data.get("resource_type") + resource_id = data.get("resource_id") + access_level = data.get("access_level", "viewer") + # None → share with the whole team; a sub → share with that one member. + target_user_id = (data.get("target_user_id") or "").strip() or None + if ( + not is_valid_resource_type(resource_type) + or not resource_id + or not looks_like_uuid(resource_id) + ): + return {"success": False, "message": "invalid resource"}, 400 + if access_level not in _VALID_ACCESS_LEVELS: + return {"success": False, "message": "invalid access_level"}, 400 + try: + with db_session() as conn: + # Ownership is the security boundary: dispatch by resource_type so + # a mismatched type/id can't register a bogus grant. + if not owns_resource(conn, resource_type, resource_id, user): + return {"success": False, "message": "Not the resource owner"}, 403 + # A per-member share target must actually be a member of the team. + if target_user_id and not TeamMembersRepository(conn).is_member( + target_user_id, team_id + ): + return {"success": False, "message": "Target is not a team member"}, 400 + grant = TeamResourceGrantsRepository(conn).grant( + team_id, + resource_type, + resource_id, + owner_id=user, + granted_by=user, + access_level=access_level, + target_user_id=target_user_id, + ) + _audit( + conn, + user, + "team.share", + team_id=team_id, + resource_type=resource_type, + resource_id=resource_id, + access_level=access_level, + target_user_id=target_user_id, + ) + # Post-commit, best-effort: tell the recipient(s) it was shared. + _notify_resource_shared( + user, team_id, resource_type, resource_id, access_level, target_user_id + ) + return make_response(jsonify({"success": True, "grant": grant}), 201) + except Exception as err: + logger.error("Share resource failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @team_member_required + def delete(self, team_id): + """Unshare a resource. Allowed for the resource owner or a team_admin. + + Identifiers come from query params (some proxies strip DELETE bodies), + with a JSON-body fallback for older clients. + """ + user = _current_user() + token = getattr(request, "decoded_token", None) + data = request.get_json(silent=True) or {} + resource_type = request.args.get("resource_type") or data.get("resource_type") + resource_id = request.args.get("resource_id") or data.get("resource_id") + # Which grant to remove: whole-team (None) or a specific member's. + target_user_id = ( + request.args.get("target_user_id") or data.get("target_user_id") or "" + ).strip() or None + if ( + not is_valid_resource_type(resource_type) + or not resource_id + or not looks_like_uuid(resource_id) + ): + return {"success": False, "message": "invalid resource"}, 400 + try: + with db_session() as conn: + is_owner = owns_resource(conn, resource_type, resource_id, user) + if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN): + return {"success": False, "message": "Forbidden"}, 403 + revoked = TeamResourceGrantsRepository(conn).revoke( + team_id, resource_type, resource_id, target_user_id=target_user_id + ) + if revoked: + _audit( + conn, + user, + "team.unshare", + team_id=team_id, + resource_type=resource_type, + resource_id=resource_id, + target_user_id=target_user_id, + ) + return make_response(jsonify({"success": bool(revoked)}), 200) + except Exception as err: + logger.error("Unshare resource failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/teams//transfer_owner") +class TeamOwnerTransfer(Resource): + def post(self, team_id): + """Transfer team ownership. Owner-only (global admin overrides). + + The new owner must already be a member; they are promoted to team_admin. + """ + user = _current_user() + if not user: + return {"success": False}, 401 + data = request.get_json(silent=True) or {} + new_owner = (data.get("user_id") or "").strip() + if not new_owner: + return {"success": False, "message": "user_id required"}, 400 + token = getattr(request, "decoded_token", None) + try: + with db_session() as conn: + teams = TeamsRepository(conn) + team = teams.get(team_id) + if not team: + return {"success": False, "message": "Not found"}, 404 + if team["owner_id"] != user and not has_role(token, ROLE_ADMIN): + return {"success": False, "message": "Only the team owner can transfer"}, 403 + members = TeamMembersRepository(conn) + if not members.is_member(new_owner, team_id): + return {"success": False, "message": "New owner must be a member"}, 400 + members.set_manual_role(team_id, new_owner, ROLE_TEAM_ADMIN, granted_by=user) + teams.reassign_owner(team_id, new_owner) + _audit( + conn, + user, + "team.transfer_owner", + team_id=team_id, + new_owner=new_owner, + ) + return make_response(jsonify({"success": True}), 200) + except Exception as err: + logger.error("Transfer owner failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/resource_shares") +class ResourceShares(Resource): + def get(self): + """List the teams a resource the caller OWNS is shared with. + + Powers the share dialog (show current shares + unshare). Owner-only so a + non-owner can't enumerate a resource's sharing graph. + """ + user = _current_user() + if not user: + return {"success": False}, 401 + resource_type = request.args.get("resource_type") + resource_id = request.args.get("resource_id") + if ( + not is_valid_resource_type(resource_type) + or not resource_id + or not looks_like_uuid(resource_id) + ): + return {"success": False, "message": "invalid resource"}, 400 + try: + with db_readonly() as conn: + if not owns_resource(conn, resource_type, resource_id, user): + return {"success": False, "message": "Not the resource owner"}, 403 + shares = TeamResourceGrantsRepository(conn).list_for_resource( + resource_type, resource_id + ) + return make_response(jsonify({"success": True, "shares": shares}), 200) + except Exception as err: + logger.error("List resource shares failed: %s", err, exc_info=True) + return {"success": False}, 400 + + +@teams_ns.route("/admin/teams") +class AllTeams(Resource): + method_decorators = [] + + def get(self): + """Global-admin oversight: every team with member counts.""" + token = getattr(request, "decoded_token", None) + if not token: + return {"success": False}, 401 + if not has_role(token, ROLE_ADMIN): + return {"success": False, "message": "Forbidden"}, 403 + try: + with db_readonly() as conn: + teams = TeamsRepository(conn).list_all() + return make_response(jsonify({"success": True, "teams": teams}), 200) + except Exception as err: + logger.error("List all teams failed: %s", err, exc_info=True) + return {"success": False}, 400 diff --git a/application/api/user/tools/routes.py b/application/api/user/tools/routes.py index 8cdbe35b..7897e2a6 100644 --- a/application/api/user/tools/routes.py +++ b/application/api/user/tools/routes.py @@ -15,6 +15,7 @@ from application.agents.default_tools import ( from application.agents.tools.spec_parser import parse_spec from application.agents.tools.tool_manager import ToolManager from application.api import api +from application.api.user.team_sharing import effective_write_owner, visible_with_access from application.core.url_validation import SSRFError, validate_url from application.security.encryption import decrypt_credentials, encrypt_credentials from application.storage.db.repositories.notes import NotesRepository @@ -217,27 +218,43 @@ class GetTools(Resource): return make_response(jsonify({"success": False}), 401) user = decoded_token.get("sub") with db_readonly() as conn: - rows = UserToolsRepository(conn).list_for_user(user) + tools_repo = UserToolsRepository(conn) + rows = tools_repo.list_for_user(user) user_doc = UsersRepository(conn).get(user) + owned_ids = {str(r["id"]) for r in rows} + # Tools shared with the caller's teams. Secrets are stripped + # unconditionally below — a grantee never sees the owner's + # credentials (they only run with the owner's creds server-side). + team_shared = visible_with_access(conn, user, "tool") + shared_ids = [tid for tid in team_shared if tid not in owned_ids] + shared_rows = tools_repo.list_by_ids(shared_ids) user_tools = [] - for row in rows: - tool_copy = _row_to_api(row) + def _shape_tool(row, *, ownership="user", force_strip_secret=False): + tool_copy = _row_to_api(row) config_req = tool_copy.get("configRequirements", {}) if not config_req: tool_instance = tool_manager.tools.get(tool_copy.get("name")) if tool_instance: config_req = tool_instance.get_config_requirements() tool_copy["configRequirements"] = config_req - has_secrets = any( spec.get("secret") for spec in config_req.values() ) if config_req else False - if has_secrets and "encrypted_credentials" in tool_copy.get("config", {}): + if (has_secrets or force_strip_secret) and "encrypted_credentials" in tool_copy.get( + "config", {} + ): tool_copy["config"]["has_encrypted_credentials"] = True tool_copy["config"].pop("encrypted_credentials", None) + tool_copy["ownership"] = ownership + return tool_copy - user_tools.append(tool_copy) + for row in rows: + user_tools.append(_shape_tool(row)) + for row in shared_rows: + shaped = _shape_tool(row, ownership="team", force_strip_secret=True) + shaped["team_access"] = team_shared.get(str(row["id"])) + user_tools.append(shaped) # ``scheduler`` is dual-registered (default chat tool + agent- # selectable builtin) and resolves to the same synthetic uuid5 id. @@ -649,14 +666,18 @@ class UpdateToolActions(Resource): with db_session() as conn: repo = UserToolsRepository(conn) tool_doc = repo.get_any(data["id"], user) - if not tool_doc: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), - 404, - ) - repo.update( - str(tool_doc["id"]), user, {"actions": data["actions"]}, - ) + if tool_doc: + repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]}) + else: + # Team editor write path (secrets stay owner-only — actions + # carry no credentials, so editing them is safe). + owner = effective_write_owner(conn, "tool", data["id"], user) + if not owner: + return make_response( + jsonify({"success": False, "message": "Tool not found"}), + 404, + ) + repo.update(data["id"], owner, {"actions": data["actions"]}) except Exception as err: current_app.logger.error( f"Error updating tool actions: {err}", exc_info=True diff --git a/application/core/models/docsgpt.yaml b/application/core/models/docsgpt.yaml index ca375dc8..3ec2da81 100644 --- a/application/core/models/docsgpt.yaml +++ b/application/core/models/docsgpt.yaml @@ -4,4 +4,4 @@ models: - id: docsgpt-local display_name: DocsGPT Model description: Local model - supports_tools: false + supports_tools: true diff --git a/application/storage/db/models.py b/application/storage/db/models.py index 83da164d..23348332 100644 --- a/application/storage/db/models.py +++ b/application/storage/db/models.py @@ -45,6 +45,9 @@ users_table = Table( metadata, Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), Column("user_id", Text, nullable=False, unique=True), + # Populated from the OIDC email claim at login (migration 0023); used to add + # a team member by email instead of raw sub. Nullable / backfilled on login. + Column("email", Text), Column( "agent_preferences", JSONB, @@ -86,6 +89,97 @@ user_roles_table = Table( CheckConstraint("source IN ('manual', 'oidc_group')", name="user_roles_source_check"), ) +# --- Teams: multi-team membership, team-scoped roles, resource sharing ------- +# See migration 0021. Three additive tables; no existing table is altered. + +# A team. ``owner_id`` is the creator's auth ``sub`` and the deletion anchor — +# intentionally no FK/trigger (like ``user_roles``) so user deletion isn't +# blocked by an ON DELETE RESTRICT. +teams_table = Table( + "teams", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), + Column("name", Text, nullable=False), + Column("slug", CITEXT, nullable=False, unique=True), + Column("description", Text), + Column("owner_id", Text, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) + +Index("teams_owner_idx", teams_table.c.owner_id) + +# Membership + team-scoped role grant, field-for-field on ``user_roles``. +# ``(team_id, user_id, role, source)`` so manual and IdP-derived grants coexist +# and revoke independently. ``user_id`` is the auth ``sub`` (no FK/trigger). +team_members_table = Table( + "team_members", + metadata, + Column("team_id", UUID(as_uuid=True), nullable=False), + Column("user_id", Text, nullable=False), + Column("role", Text, nullable=False), + Column("source", Text, nullable=False, server_default="manual"), + Column("granted_by", Text), + Column("granted_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + PrimaryKeyConstraint("team_id", "user_id", "role", "source"), + CheckConstraint("role IN ('team_admin', 'team_member')", name="team_members_role_check"), + CheckConstraint( + "source IN ('manual', 'oidc_group', 'scim')", name="team_members_source_check" + ), +) + +Index("team_members_user_idx", team_members_table.c.user_id) + +# One polymorphic share table for all four shareable resource types. Sharing is +# additive visibility, never ownership transfer. ``owner_id`` is denormalised +# owner-at-share-time so visibility queries skip the resource-table join. +# ``resource_id`` has no cross-table FK (polymorphic); dangling rows are scrubbed +# by AFTER DELETE triggers on each resource table (see migration 0021). +team_resource_grants_table = Table( + "team_resource_grants", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), + Column("team_id", UUID(as_uuid=True), nullable=False), + Column("resource_type", Text, nullable=False), + Column("resource_id", UUID(as_uuid=True), nullable=False), + Column("owner_id", Text, nullable=False), + Column("access_level", Text, nullable=False, server_default="viewer"), + # NULL = shared with the whole team; a sub = shared with that one member + # (who must also be a team member). See migration 0022. + Column("target_user_id", Text), + Column("granted_by", Text, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + CheckConstraint( + "resource_type IN ('agent', 'source', 'prompt', 'tool')", + name="team_resource_grants_type_check", + ), + CheckConstraint( + "access_level IN ('viewer', 'editor')", name="team_resource_grants_access_check" + ), +) + +# Functional unique dedup: a whole-team grant (target NULL → '') and each +# per-member grant are distinct. Mirrors migration 0022. +Index( + "team_resource_grants_dedup_uidx", + team_resource_grants_table.c.team_id, + team_resource_grants_table.c.resource_type, + team_resource_grants_table.c.resource_id, + func.coalesce(team_resource_grants_table.c.target_user_id, ""), + unique=True, +) +Index( + "team_resource_grants_team_type_idx", + team_resource_grants_table.c.team_id, + team_resource_grants_table.c.resource_type, +) +Index( + "team_resource_grants_resource_idx", + team_resource_grants_table.c.resource_type, + team_resource_grants_table.c.resource_id, +) + + prompts_table = Table( "prompts", metadata, diff --git a/application/storage/db/repositories/agents.py b/application/storage/db/repositories/agents.py index 930c0582..46e39016 100644 --- a/application/storage/db/repositories/agents.py +++ b/application/storage/db/repositories/agents.py @@ -13,7 +13,7 @@ from __future__ import annotations from typing import Optional -from sqlalchemy import Connection, func, text +from sqlalchemy import Connection, cast, func, text from sqlalchemy.dialects.postgresql import insert as pg_insert from application.storage.db.base_repository import looks_like_uuid, row_to_dict @@ -160,6 +160,91 @@ class AgentsRepository: ) return [row_to_dict(r) for r in result.fetchall()] + def get_by_id(self, agent_id: str) -> Optional[dict]: + """Fetch an agent by id with NO ownership scoping. + + Used ONLY after a team-grant authorization check has already confirmed + the caller may see this agent (see team sharing). Never call this on a + raw user-supplied id without that check — it bypasses the dual-key guard. + """ + if not looks_like_uuid(agent_id): + return None + result = self._conn.execute( + text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"), + {"id": agent_id}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + def list_by_ids(self, agent_ids) -> list[dict]: + """Fetch agents whose id is in ``agent_ids`` (team-shared listing path).""" + ids = [str(a) for a in agent_ids if looks_like_uuid(str(a))] + if not ids: + return [] + result = self._conn.execute( + text("SELECT * FROM agents WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"), + {"ids": ids}, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def update_by_id( + self, agent_id: str, fields: dict, expected_updated_at=None + ) -> Optional[bool]: + """Update an agent by id WITHOUT user scoping (team ``editor`` write path). + + The caller MUST have verified the principal holds an ``editor`` grant + before calling. When ``expected_updated_at`` is given, the update is + applied only if the row's ``updated_at`` still matches (optimistic lock); + returns None on a version mismatch so the route can answer 409. + """ + # Reuse the owner-path column allowlist + coercion by delegating, but + # against the id-only predicate. We rebuild the same filtered values. + allowed = { + "name", "description", "agent_type", "status", "key", "slug", "source_id", + "chunks", "retriever", "prompt_id", "tools", "json_schema", "models", + "default_model_id", "folder_id", "workflow_id", + "extra_source_ids", "image", + "limited_token_mode", "token_limit", + "limited_request_mode", "request_limit", + "allow_system_prompt_override", + "shared", "shared_token", "shared_metadata", + "incoming_webhook_token", "last_used_at", + } + filtered = {k: v for k, v in fields.items() if k in allowed} + if not filtered: + return False + values: dict = {} + for col, val in filtered.items(): + if col in ("tools", "json_schema", "models", "shared_metadata"): + values[col] = val + elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"): + values[col] = str(val) if val else None + elif col == "extra_source_ids": + values[col] = [str(x) for x in val] if val else [] + elif col in ( + "limited_token_mode", "limited_request_mode", + "shared", "allow_system_prompt_override", + ): + values[col] = bool(val) + else: + values[col] = self._normalize_unique_text(col, val) + values["updated_at"] = func.now() + + t = agents_table + stmt = t.update().where(t.c.id == agent_id) + if expected_updated_at is not None: + # Cast the (string/datetime) bind to timestamptz so PG compares the + # optimistic-lock token against the column type, not text. + stmt = stmt.where(t.c.updated_at == cast(expected_updated_at, t.c.updated_at.type)) + stmt = stmt.values(**values) + result = self._conn.execute(stmt) + if result.rowcount > 0: + return True + # Distinguish a stale optimistic-lock write (row exists) from a missing row. + if expected_updated_at is not None and self.get_by_id(str(agent_id)) is not None: + return None + return False + def list_templates(self) -> list[dict]: # Template/system agents are seeded under ``__system__`` (migration # 0001 + the seeder); the legacy ``'system'`` value is kept in the diff --git a/application/storage/db/repositories/prompts.py b/application/storage/db/repositories/prompts.py index 02babf5c..e30b4c19 100644 --- a/application/storage/db/repositories/prompts.py +++ b/application/storage/db/repositories/prompts.py @@ -105,6 +105,40 @@ class PromptsRepository: ) return [row_to_dict(r) for r in result.fetchall()] + def list_by_ids(self, prompt_ids) -> list[dict]: + """Fetch prompts whose id is in ``prompt_ids`` (team-shared listing path).""" + ids = [str(p) for p in prompt_ids if looks_like_uuid(str(p))] + if not ids: + return [] + result = self._conn.execute( + text("SELECT * FROM prompts WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"), + {"ids": ids}, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def update_by_id( + self, prompt_id: str, name: str, content: str, expected_updated_at=None + ) -> Optional[bool]: + """Update a prompt by id WITHOUT user scoping (team ``editor`` write path). + + Caller must have verified an ``editor`` grant first. Returns None on an + optimistic-lock version mismatch so the route can answer 409. + """ + sql = ( + "UPDATE prompts SET name = :name, content = :content, updated_at = now() " + "WHERE id = CAST(:id AS uuid)" + ) + params: dict = {"id": prompt_id, "name": name, "content": content} + if expected_updated_at is not None: + sql += " AND updated_at = CAST(:expected AS timestamptz)" + params["expected"] = expected_updated_at + result = self._conn.execute(text(sql), params) + if result.rowcount > 0: + return True + if expected_updated_at is not None and self.get_for_rendering(str(prompt_id)) is not None: + return None + return False + def update(self, prompt_id: str, user_id: str, name: str, content: str) -> None: self._conn.execute( text( diff --git a/application/storage/db/repositories/sources.py b/application/storage/db/repositories/sources.py index 31f54926..b20044c2 100644 --- a/application/storage/db/repositories/sources.py +++ b/application/storage/db/repositories/sources.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +import uuid from typing import Any, Optional -from sqlalchemy import case, Connection, func, select, text +from sqlalchemy import case, Connection, func, or_, select, text from application.storage.db.base_repository import looks_like_uuid, row_to_dict from application.storage.db.models import ingest_chunk_progress_table, sources_table @@ -24,6 +25,37 @@ _ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS _SORTABLE_COLUMNS = {"date", "name", "tokens", "type", "created_at", "updated_at"} +def _coerce_uuid_ids(extra_ids: Optional[list]) -> list: + """Coerce id strings to ``uuid.UUID`` for binding against UUID columns. + + Non-UUID-looking ids (e.g. legacy/synthetic) are dropped — they can never + match a ``sources.id`` value. Used to OR team-shared source ids into the + owner-scoped queries. + """ + if not extra_ids: + return [] + out: list = [] + for raw in extra_ids: + s = str(raw) + if looks_like_uuid(s): + out.append(uuid.UUID(s)) + return out + + +def _owned_or_shared_scope(user_id: str, extra_ids: Optional[list]): + """WHERE predicate matching the owner's rows plus any ``extra_ids``. + + Lets the paginated/count queries include team-shared sources (passed by id) + alongside owned ones in a single query, so count/sort/search/pagination + stay correct across the union. + """ + scope = sources_table.c.user_id == user_id + ids = _coerce_uuid_ids(extra_ids) + if ids: + scope = or_(scope, sources_table.c.id.in_(ids)) + return scope + + def _escape_like(pattern: str) -> str: """Escape wildcards so a user-supplied substring is matched literally. @@ -174,6 +206,32 @@ class SourcesRepository: return row return self.get_by_legacy_id(source_id, user_id) + def get_by_id(self, source_id: str) -> Optional[dict]: + """Fetch a source by id with NO ownership scoping. + + Used ONLY after a team-grant authorization check (team sharing). Never + call on a raw user-supplied id without that check. + """ + if not looks_like_uuid(source_id): + return None + result = self._conn.execute( + text("SELECT * FROM sources WHERE id = CAST(:id AS uuid)"), + {"id": source_id}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + def list_by_ids(self, source_ids) -> list[dict]: + """Fetch sources whose id is in ``source_ids`` (team-shared listing path).""" + ids = [str(s) for s in source_ids if looks_like_uuid(str(s))] + if not ids: + return [] + result = self._conn.execute( + text("SELECT * FROM sources WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"), + {"ids": ids}, + ) + return [row_to_dict(r) for r in result.fetchall()] + def find_by_name(self, user_id: str, name: str) -> Optional[dict]: """Return a user's source whose name matches ``name`` (case-insensitive). @@ -202,6 +260,7 @@ class SourcesRepository: search_term: Optional[str] = None, sort_field: str = "created_at", sort_order: str = "desc", + extra_ids: Optional[list] = None, ) -> list[dict]: """Return sources owned by ``user_id``, paginated and optionally filtered. @@ -243,7 +302,7 @@ class SourcesRepository: == sources_table.c.id, ) ) - .where(sources_table.c.user_id == user_id) + .where(_owned_or_shared_scope(user_id, extra_ids)) ) if search_term: stmt = stmt.where( @@ -272,6 +331,7 @@ class SourcesRepository: user_id: str, *, search_term: Optional[str] = None, + extra_ids: Optional[list] = None, ) -> int: """Return the count of rows that ``list_for_user`` would produce. @@ -289,7 +349,7 @@ class SourcesRepository: stmt = ( select(func.count()) .select_from(sources_table) - .where(sources_table.c.user_id == user_id) + .where(_owned_or_shared_scope(user_id, extra_ids)) ) if search_term: stmt = stmt.where( diff --git a/application/storage/db/repositories/team_members.py b/application/storage/db/repositories/team_members.py new file mode 100644 index 00000000..87d2cc89 --- /dev/null +++ b/application/storage/db/repositories/team_members.py @@ -0,0 +1,197 @@ +"""Repository for the ``team_members`` table (membership + team-scoped roles). + +Field-for-field on ``user_roles``: grants are keyed by +``(team_id, user_id, role, source)`` so a manual grant and a future IdP-derived +grant for the same user coexist and revoke independently. ``user_id`` is the +auth ``sub``. The ``team_member`` role is NOT implicit — every member has at +least one row (unlike the global ``user`` role). All methods take a +``Connection`` and do not manage their own transactions. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + +ROLE_TEAM_ADMIN = "team_admin" +ROLE_TEAM_MEMBER = "team_member" + + +class TeamMembersRepository: + """Team membership grants keyed by the auth ``sub`` (``user_id``).""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + # ------------------------------------------------------------------ + # Reads + # ------------------------------------------------------------------ + def role_for(self, user_id: str, team_id: str) -> Optional[str]: + """Strongest role ``user_id`` holds in ``team_id`` (any source), or None. + + ``team_admin`` outranks ``team_member``. Returns None when the user is + not a member — the caller treats that as "no team access". + """ + if not user_id or not team_id: + return None + result = self._conn.execute( + text( + """ + SELECT CASE WHEN bool_or(role = 'team_admin') THEN 'team_admin' + ELSE 'team_member' END AS role + FROM team_members + WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id + HAVING count(*) > 0 + """ + ), + {"team_id": team_id, "user_id": user_id}, + ) + row = result.fetchone() + return row[0] if row is not None else None + + def is_member(self, user_id: str, team_id: str) -> bool: + return self.role_for(user_id, team_id) is not None + + def list_team_ids_for(self, user_id: str) -> list[str]: + """The distinct team ids ``user_id`` belongs to (hot authz reverse lookup).""" + if not user_id: + return [] + result = self._conn.execute( + text("SELECT DISTINCT team_id FROM team_members WHERE user_id = :user_id"), + {"user_id": user_id}, + ) + return [str(row[0]) for row in result.fetchall()] + + def list_members(self, team_id: str) -> list[dict]: + """One row per (user, role, source) in the team, ordered by grant time. + + Left-joins ``users`` to surface each member's ``email`` (when on file) + so the UI can show a human-readable identity instead of the raw sub. + """ + result = self._conn.execute( + text( + """ + SELECT m.user_id, m.role, m.source, m.granted_by, m.granted_at, + u.email + FROM team_members m + LEFT JOIN users u ON u.user_id = m.user_id + WHERE m.team_id = CAST(:team_id AS uuid) + ORDER BY m.granted_at + """ + ), + {"team_id": team_id}, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def count_admins(self, team_id: str) -> int: + """Distinct users holding ``team_admin`` — for the last-admin guard.""" + result = self._conn.execute( + text( + """ + SELECT count(DISTINCT user_id) FROM team_members + WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin' + """ + ), + {"team_id": team_id}, + ) + return int(result.scalar() or 0) + + def lock_admins(self, team_id: str) -> list[str]: + """Lock the team's ``team_admin`` rows and return the distinct admins. + + ``SELECT ... FOR UPDATE`` inside the caller's write transaction + serializes concurrent demote/remove operations on the team's admins, so + the last-admin guard can't be raced into orphaning the team (two + concurrent removes both seeing count=2). ``DISTINCT`` is incompatible + with ``FOR UPDATE``, so de-dup in Python (a user may hold team_admin via + multiple sources). + """ + result = self._conn.execute( + text( + """ + SELECT user_id FROM team_members + WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin' + FOR UPDATE + """ + ), + {"team_id": team_id}, + ) + return sorted({str(row[0]) for row in result.fetchall()}) + + # ------------------------------------------------------------------ + # Writes + # ------------------------------------------------------------------ + def add_member( + self, + team_id: str, + user_id: str, + role: str = ROLE_TEAM_MEMBER, + source: str = "manual", + granted_by: Optional[str] = None, + ) -> bool: + """Idempotently add a membership grant. Returns True if a row was inserted.""" + result = self._conn.execute( + text( + """ + INSERT INTO team_members (team_id, user_id, role, source, granted_by) + VALUES (CAST(:team_id AS uuid), :user_id, :role, :source, :granted_by) + ON CONFLICT (team_id, user_id, role, source) DO NOTHING + """ + ), + { + "team_id": team_id, + "user_id": user_id, + "role": role, + "source": source, + "granted_by": granted_by, + }, + ) + return bool(result.rowcount) + + def set_manual_role( + self, + team_id: str, + user_id: str, + role: str, + granted_by: Optional[str] = None, + ) -> None: + """Set the user's ``manual`` role to exactly ``role`` (promote/demote). + + Deletes any other manual role row for the user in this team, then adds + the target — keeping the single-manual-role invariant. IdP-sourced rows + are untouched. + """ + self._conn.execute( + text( + """ + DELETE FROM team_members + WHERE team_id = CAST(:team_id AS uuid) + AND user_id = :user_id AND source = 'manual' AND role <> :role + """ + ), + {"team_id": team_id, "user_id": user_id, "role": role}, + ) + self.add_member(team_id, user_id, role=role, source="manual", granted_by=granted_by) + + def remove_member( + self, + team_id: str, + user_id: str, + source: Optional[str] = None, + ) -> bool: + """Remove the user's membership rows from the team. + + When ``source`` is given only that source's rows are removed (so an IdP + sync can't wipe a manual grant); otherwise every role/source row for the + user in the team is removed. Returns True if anything was deleted. + """ + sql = "DELETE FROM team_members WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id" + params: dict = {"team_id": team_id, "user_id": user_id} + if source is not None: + sql += " AND source = :source" + params["source"] = source + result = self._conn.execute(text(sql), params) + return bool(result.rowcount) diff --git a/application/storage/db/repositories/team_resource_grants.py b/application/storage/db/repositories/team_resource_grants.py new file mode 100644 index 00000000..8b3abb69 --- /dev/null +++ b/application/storage/db/repositories/team_resource_grants.py @@ -0,0 +1,167 @@ +"""Repository for the ``team_resource_grants`` table (resource sharing). + +One polymorphic table for all four shareable resource types +(``agent``/``source``/``prompt``/``tool``). A grant makes a resource visible to +a team as additive visibility — never ownership transfer; the resource's +``user_id`` owner is unchanged. ``owner_id`` is denormalised owner-at-share-time. +Per-user effective visibility/access (the JOIN to ``team_members``) lives in +[[team_scope]]. All methods take a ``Connection``. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + +VALID_RESOURCE_TYPES = ("agent", "source", "prompt", "tool") +VALID_ACCESS_LEVELS = ("viewer", "editor") + + +class TeamResourceGrantsRepository: + """Share grants keyed by ``(team_id, resource_type, resource_id)``.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + # ------------------------------------------------------------------ + # Reads + # ------------------------------------------------------------------ + def list_for_team( + self, team_id: str, resource_type: Optional[str] = None + ) -> list[dict]: + """All grants for a team, optionally filtered to one resource type.""" + sql = "SELECT * FROM team_resource_grants WHERE team_id = CAST(:team_id AS uuid)" + params: dict = {"team_id": team_id} + if resource_type is not None: + sql += " AND resource_type = :resource_type" + params["resource_type"] = resource_type + sql += " ORDER BY created_at DESC" + result = self._conn.execute(text(sql), params) + return [row_to_dict(r) for r in result.fetchall()] + + def list_for_resource(self, resource_type: str, resource_id: str) -> list[dict]: + """Which teams a resource is shared with (the owner's share-management view).""" + result = self._conn.execute( + text( + """ + SELECT g.*, t.name AS team_name, t.slug AS team_slug + FROM team_resource_grants g + JOIN teams t ON t.id = g.team_id + WHERE g.resource_type = :resource_type + AND g.resource_id = CAST(:resource_id AS uuid) + ORDER BY t.name + """ + ), + {"resource_type": resource_type, "resource_id": resource_id}, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def get( + self, + team_id: str, + resource_type: str, + resource_id: str, + target_user_id: Optional[str] = None, + ) -> Optional[dict]: + """Fetch one grant. ``target_user_id`` None matches the whole-team grant; + a sub matches that member's grant (NULL/'' coalesced so both compare).""" + result = self._conn.execute( + text( + """ + SELECT * FROM team_resource_grants + WHERE team_id = CAST(:team_id AS uuid) + AND resource_type = :resource_type + AND resource_id = CAST(:resource_id AS uuid) + AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '') + """ + ), + { + "team_id": team_id, + "resource_type": resource_type, + "resource_id": resource_id, + "target_user_id": target_user_id, + }, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + # ------------------------------------------------------------------ + # Writes + # ------------------------------------------------------------------ + def grant( + self, + team_id: str, + resource_type: str, + resource_id: str, + owner_id: str, + granted_by: str, + access_level: str = "viewer", + target_user_id: Optional[str] = None, + ) -> dict: + """Share a resource with a team (or one member), upserting the access level. + + ``target_user_id`` None shares with the whole team; a sub shares with + that one member (the caller must have validated they're a team member). + ``ON CONFLICT`` on the functional dedup index makes re-sharing + last-write-wins on ``access_level``. The caller MUST have verified + ``granted_by`` owns the resource (dispatched by ``resource_type``) — the + polymorphic table has no FK to catch a type/id mismatch. + """ + result = self._conn.execute( + text( + """ + INSERT INTO team_resource_grants + (team_id, resource_type, resource_id, owner_id, access_level, + granted_by, target_user_id) + VALUES + (CAST(:team_id AS uuid), :resource_type, CAST(:resource_id AS uuid), + :owner_id, :access_level, :granted_by, :target_user_id) + ON CONFLICT (team_id, resource_type, resource_id, COALESCE(target_user_id, '')) + DO UPDATE SET access_level = EXCLUDED.access_level, + granted_by = EXCLUDED.granted_by + RETURNING * + """ + ), + { + "team_id": team_id, + "resource_type": resource_type, + "resource_id": resource_id, + "owner_id": owner_id, + "access_level": access_level, + "granted_by": granted_by, + "target_user_id": target_user_id, + }, + ) + return row_to_dict(result.fetchone()) + + def revoke( + self, + team_id: str, + resource_type: str, + resource_id: str, + target_user_id: Optional[str] = None, + ) -> bool: + """Unshare. ``target_user_id`` None removes the whole-team grant; a sub + removes that member's grant. Access is lost on the next request.""" + result = self._conn.execute( + text( + """ + DELETE FROM team_resource_grants + WHERE team_id = CAST(:team_id AS uuid) + AND resource_type = :resource_type + AND resource_id = CAST(:resource_id AS uuid) + AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '') + """ + ), + { + "team_id": team_id, + "resource_type": resource_type, + "resource_id": resource_id, + "target_user_id": target_user_id, + }, + ) + return result.rowcount > 0 + diff --git a/application/storage/db/repositories/team_scope.py b/application/storage/db/repositories/team_scope.py new file mode 100644 index 00000000..691dbc7f --- /dev/null +++ b/application/storage/db/repositories/team_scope.py @@ -0,0 +1,113 @@ +"""Per-user team visibility resolution. + +This is the read-side authorization helper for team sharing: it answers "which +resources of type X can ``user_id`` see via their team memberships?" and "what +access level does ``user_id`` effectively have on resource R?". Membership is +JOINed LIVE against ``team_members`` on every call — never cached, never read +from the JWT — so revoking a membership or a grant drops access on the very next +request (closes the revocation race by construction). All methods take a +``Connection``. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import looks_like_uuid + + +class TeamScopeRepository: + """Live JOIN of ``team_members`` × ``team_resource_grants`` for one user.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def visible_resource_ids(self, user_id: str, resource_type: str) -> set[str]: + """The set of ``resource_type`` ids shared to any team ``user_id`` is in. + + Returned as canonical-string UUIDs so callers can build an + ``id = ANY(:ids)`` union clause against the resource table. + """ + if not user_id: + return set() + result = self._conn.execute( + text( + """ + SELECT DISTINCT g.resource_id + FROM team_resource_grants g + JOIN team_members m ON m.team_id = g.team_id + WHERE m.user_id = :user_id AND g.resource_type = :resource_type + AND (g.target_user_id IS NULL OR g.target_user_id = :user_id) + """ + ), + {"user_id": user_id, "resource_type": resource_type}, + ) + return {str(row[0]) for row in result.fetchall()} + + def visible_with_access(self, user_id: str, resource_type: str) -> dict[str, str]: + """Map of ``resource_id -> strongest access`` shared to the user's teams. + + One query for the whole list path — ``editor`` outranks ``viewer`` per + resource. Keys are canonical-string UUIDs. + """ + if not user_id: + return {} + result = self._conn.execute( + text( + """ + SELECT g.resource_id, + CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor' + ELSE 'viewer' END AS access_level + FROM team_resource_grants g + JOIN team_members m ON m.team_id = g.team_id + WHERE m.user_id = :user_id AND g.resource_type = :resource_type + AND (g.target_user_id IS NULL OR g.target_user_id = :user_id) + GROUP BY g.resource_id + """ + ), + {"user_id": user_id, "resource_type": resource_type}, + ) + return {str(row[0]): row[1] for row in result.fetchall()} + + def effective_access( + self, user_id: str, resource_type: str, resource_id: str + ) -> Optional[str]: + """Strongest access ``user_id`` has on a resource via teams, or None. + + ``editor`` outranks ``viewer``. None means no team grant reaches the user + (they may still be the owner — that is checked separately by the repo's + dual-key path). Used for both read authz (any value → visible) and write + authz (``editor`` → may edit). + """ + # Grant rows only ever hold canonical UUIDs; a non-UUID id (e.g. a + # legacy Mongo ObjectId reaching the single-fetch fallback) can never + # match a grant, and CASTing it would raise and poison the txn — so + # short-circuit to None instead. + if not user_id or not looks_like_uuid(resource_id): + return None + result = self._conn.execute( + text( + """ + SELECT CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor' + ELSE 'viewer' END AS access_level + FROM team_resource_grants g + JOIN team_members m ON m.team_id = g.team_id + WHERE m.user_id = :user_id + AND g.resource_type = :resource_type + AND g.resource_id = CAST(:resource_id AS uuid) + AND (g.target_user_id IS NULL OR g.target_user_id = :user_id) + HAVING count(*) > 0 + """ + ), + {"user_id": user_id, "resource_type": resource_type, "resource_id": resource_id}, + ) + row = result.fetchone() + return row[0] if row is not None else None + + def can_read(self, user_id: str, resource_type: str, resource_id: str) -> bool: + return self.effective_access(user_id, resource_type, resource_id) is not None + + def can_write(self, user_id: str, resource_type: str, resource_id: str) -> bool: + return self.effective_access(user_id, resource_type, resource_id) == "editor" diff --git a/application/storage/db/repositories/teams.py b/application/storage/db/repositories/teams.py new file mode 100644 index 00000000..704e3a0e --- /dev/null +++ b/application/storage/db/repositories/teams.py @@ -0,0 +1,158 @@ +"""Repository for the ``teams`` table. + +A team is owned by ``owner_id`` (the creator's auth ``sub``), which is the +durable "who can delete the team" anchor. Membership and role grants live in +``team_members`` ([[team_members]]); shared resources in ``team_resource_grants``. +All methods take a ``Connection`` and do not manage their own transactions. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + + +class TeamsRepository: + """CRUD for teams keyed by the team UUID.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + # ------------------------------------------------------------------ + # Reads + # ------------------------------------------------------------------ + def get(self, team_id: str) -> Optional[dict]: + result = self._conn.execute( + text("SELECT * FROM teams WHERE id = CAST(:id AS uuid)"), + {"id": team_id}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + def get_by_slug(self, slug: str) -> Optional[dict]: + if not slug: + return None + result = self._conn.execute( + text("SELECT * FROM teams WHERE slug = :slug"), + {"slug": slug}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + def slug_exists(self, slug: str) -> bool: + result = self._conn.execute( + text("SELECT 1 FROM teams WHERE slug = :slug"), + {"slug": slug}, + ) + return result.fetchone() is not None + + def list_for_user(self, user_id: str) -> list[dict]: + """Teams ``user_id`` belongs to, each annotated with the member's role. + + ``role`` is the strongest grant (team_admin > team_member). Ordered by + team name for stable UI listing. + """ + if not user_id: + return [] + result = self._conn.execute( + text( + """ + SELECT t.*, + CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin' + ELSE 'team_member' END AS member_role, + (SELECT count(DISTINCT mm.user_id) FROM team_members mm + WHERE mm.team_id = t.id) AS member_count, + (SELECT count(DISTINCT g.resource_id) + FROM team_resource_grants g + WHERE g.team_id = t.id) AS shared_count + FROM teams t + JOIN team_members m ON m.team_id = t.id + WHERE m.user_id = :user_id + GROUP BY t.id + ORDER BY t.name + """ + ), + {"user_id": user_id}, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def list_all(self) -> list[dict]: + """All teams with member counts — global-admin oversight only.""" + result = self._conn.execute( + text( + """ + SELECT t.*, + (SELECT count(DISTINCT user_id) FROM team_members m + WHERE m.team_id = t.id) AS member_count + FROM teams t + ORDER BY t.created_at DESC + """ + ) + ) + return [row_to_dict(r) for r in result.fetchall()] + + # ------------------------------------------------------------------ + # Writes + # ------------------------------------------------------------------ + def create( + self, + name: str, + slug: str, + owner_id: str, + description: Optional[str] = None, + ) -> dict: + result = self._conn.execute( + text( + """ + INSERT INTO teams (name, slug, owner_id, description) + VALUES (:name, :slug, :owner_id, :description) + RETURNING * + """ + ), + {"name": name, "slug": slug, "owner_id": owner_id, "description": description}, + ) + return row_to_dict(result.fetchone()) + + # Each updatable column maps to a CONSTANT SQL fragment. The SET clause is + # assembled only from these literals (selected by whitelisted keys), so no + # user-controlled string ever reaches the query text — values travel as + # bound parameters. Keeps the query injection-free by construction. + _UPDATABLE_COLUMNS = { + "name": "name = :name", + "description": "description = :description", + } + + def update(self, team_id: str, fields: dict) -> bool: + """Update mutable team fields (``name``, ``description``). updated_at is + bumped by the ``teams_set_updated_at`` trigger.""" + values = {k: v for k, v in fields.items() if k in self._UPDATABLE_COLUMNS} + if not values: + return False + set_clause = ", ".join(self._UPDATABLE_COLUMNS[k] for k in values) + params = dict(values) + params["id"] = team_id + result = self._conn.execute( + text(f"UPDATE teams SET {set_clause} WHERE id = CAST(:id AS uuid)"), + params, + ) + return result.rowcount > 0 + + def reassign_owner(self, team_id: str, new_owner_id: str) -> bool: + """Transfer the deletion-anchor owner to another user (team_admin action).""" + result = self._conn.execute( + text("UPDATE teams SET owner_id = :owner_id WHERE id = CAST(:id AS uuid)"), + {"id": team_id, "owner_id": new_owner_id}, + ) + return result.rowcount > 0 + + def delete(self, team_id: str) -> bool: + """Delete the team. ``team_members`` and ``team_resource_grants`` cascade; + shared resources revert to owner-only visibility.""" + result = self._conn.execute( + text("DELETE FROM teams WHERE id = CAST(:id AS uuid)"), + {"id": team_id}, + ) + return result.rowcount > 0 diff --git a/application/storage/db/repositories/user_tools.py b/application/storage/db/repositories/user_tools.py index 8deb1e2a..a6f4bf9e 100644 --- a/application/storage/db/repositories/user_tools.py +++ b/application/storage/db/repositories/user_tools.py @@ -131,6 +131,32 @@ class UserToolsRepository: return row return self.get_by_legacy_id(tool_id, user_id) + def get_by_id(self, tool_id: str) -> Optional[dict]: + """Fetch a user_tool by id with NO ownership scoping. + + Used ONLY after a team-grant authorization check (team sharing). Callers + MUST strip ``encrypted_credentials`` before returning a non-owner's tool. + """ + if not looks_like_uuid(tool_id): + return None + result = self._conn.execute( + text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid)"), + {"id": tool_id}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + + def list_by_ids(self, tool_ids) -> list[dict]: + """Fetch user_tools whose id is in ``tool_ids`` (team-shared listing path).""" + ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))] + if not ids: + return [] + result = self._conn.execute( + text("SELECT * FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"), + {"ids": ids}, + ) + return [row_to_dict(r) for r in result.fetchall()] + def list_for_user(self, user_id: str) -> list[dict]: result = self._conn.execute( text("SELECT * FROM user_tools WHERE user_id = :user_id ORDER BY created_at"), diff --git a/application/storage/db/repositories/users.py b/application/storage/db/repositories/users.py index b1c71a9f..8a4513d4 100644 --- a/application/storage/db/repositories/users.py +++ b/application/storage/db/repositories/users.py @@ -67,7 +67,7 @@ class UsersRepository: # ------------------------------------------------------------------ # Upsert # ------------------------------------------------------------------ - def upsert(self, user_id: str) -> dict: + def upsert(self, user_id: str, email: Optional[str] = None) -> dict: """Ensure a row exists for ``user_id`` and return it. Matches Mongo's ``find_one_and_update(..., $setOnInsert, upsert=True, @@ -75,24 +75,58 @@ class UsersRepository: are preserved untouched; if it doesn't, a new row is created with default preferences. - The ``DO UPDATE SET user_id = EXCLUDED.user_id`` branch is a - deliberate no-op that lets ``RETURNING *`` fire on both the insert - and conflict paths (``DO NOTHING`` would suppress the returning). + ``email`` (from the OIDC claim) is set on insert and refreshed on + conflict, but only when provided — ``COALESCE`` preserves a previously + stored email when a non-OIDC caller upserts with ``email=None`` (most + upserts come from request paths that don't carry the email claim). + + The conflict branch also updates ``user_id`` (a no-op) so ``RETURNING *`` + fires on both the insert and conflict paths. """ result = self._conn.execute( text( """ - INSERT INTO users (user_id, agent_preferences) - VALUES (:user_id, CAST(:default_prefs AS jsonb)) + INSERT INTO users (user_id, email, agent_preferences) + VALUES (:user_id, :email, CAST(:default_prefs AS jsonb)) ON CONFLICT (user_id) DO UPDATE - SET user_id = EXCLUDED.user_id + SET user_id = EXCLUDED.user_id, + email = COALESCE(EXCLUDED.email, users.email) RETURNING * """ ), - {"user_id": user_id, "default_prefs": _DEFAULT_PREFERENCES}, + {"user_id": user_id, "email": email, "default_prefs": _DEFAULT_PREFERENCES}, ) return row_to_dict(result.fetchone()) + def set_email(self, user_id: str, email: Optional[str]) -> None: + """Set a user's email when provided (targeted update, no full upsert). + + Used to backfill an existing user's email from the OIDC claim on login + without re-running the upsert. No-op when ``email`` is falsy so a login + without the claim never wipes a stored value. + """ + if not email: + return + self._conn.execute( + text("UPDATE users SET email = :email WHERE user_id = :user_id"), + {"user_id": user_id, "email": email}, + ) + + def find_by_email(self, email: str) -> Optional[dict]: + """Return the user row whose email matches ``email`` (case-insensitive). + + Used by the add-team-member-by-email flow to resolve an email to its + ``user_id`` (sub). Returns None when no user has that email. + """ + if not email or not email.strip(): + return None + result = self._conn.execute( + text("SELECT * FROM users WHERE lower(email) = lower(:email) LIMIT 1"), + {"email": email.strip()}, + ) + row = result.fetchone() + return row_to_dict(row) if row is not None else None + # ------------------------------------------------------------------ # Pinned agents # ------------------------------------------------------------------ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ee6542..7e1963c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,8 +22,10 @@ import useTokenAuth from './hooks/useTokenAuth'; import Navigation from './Navigation'; import PageNotFound from './PageNotFound'; import Setting from './settings'; +import Teams from './settings/Teams'; import Notification from './components/Notification'; import ToolApprovalToast from './notifications/ToolApprovalToast'; +import TeamNotificationToast from './notifications/TeamNotificationToast'; function AuthWrapper({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); @@ -89,6 +91,7 @@ function MainLayout() { + ); } @@ -141,6 +144,7 @@ export default function App() { element={} /> } /> + } /> } />
-
{ - if (isMobile) { - setNavOpen(!navOpen); - } - }} - > - - DocsGPT Logo -

- DocsGPT -

-
+
+ { + if (isMobile || isTablet) { + setNavOpen(false); + } + }} + />
- - DocsGPT Logo + { + if (isMobile || isTablet) { + setNavOpen(false); + } + }} /> -

- DocsGPT -

-
+
void; variant: 'default' | 'destructive'; @@ -63,6 +66,7 @@ export default function AgentCard({ const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const [moveModalState, setMoveModalState] = useState('INACTIVE'); + const [shareModalOpen, setShareModalOpen] = useState(false); const menuOptionsConfig: Record = { template: [ @@ -116,6 +120,23 @@ export default function AgentCard({ iconWidth: 14, iconHeight: 14, }, + // Sharing is an owner-only action: only show it for agents the user + // owns ('user'), not agents shared into their workspace by a team. + ...(agent.ownership === 'user' + ? [ + { + icon: Users, + label: t('agents.shareWithTeam'), + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setShareModalOpen(true); + }, + variant: 'default' as const, + iconWidth: 14, + iconHeight: 14, + }, + ] + : []), ...(agent.status === 'published' ? [ { @@ -154,6 +175,42 @@ export default function AgentCard({ iconHeight: 13, }, ], + // Agents shared with the user via a team. They don't own it, so only + // non-destructive, non-owner actions are offered: open the config + // (editors can save, viewers see it read-only) and pin for quick access. + // Logs / Export / Share / Move-to-folder / Delete stay owner-only. + team: [ + { + icon: Edit, + label: 'Edit', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + if (agent.agent_type === 'workflow') { + navigate(`/agents/workflow/edit/${agent.id}`); + } else { + navigate(`/agents/edit/${agent.id}`); + } + }, + variant: 'default', + iconWidth: 14, + iconHeight: 14, + }, + ...(agent.status === 'published' + ? [ + { + icon: agent.pinned ? UnPin : Pin, + label: agent.pinned ? 'Unpin' : 'Pin agent', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + togglePin(); + }, + variant: 'default' as const, + iconWidth: 18, + iconHeight: 18, + }, + ] + : []), + ], shared: [ { icon: Link, @@ -193,7 +250,9 @@ export default function AgentCard({ const menuOptions = menuOptionsConfig[section] || []; const handleClick = () => { - if (section === 'user') { + // Team-shared agents open/run exactly like the user's own published + // agents (the run + GetAgent routes authorize team grantees server-side). + if (section === 'user' || section === 'team') { if (agent.status === 'published') { dispatch(setSelectedAgent(agent)); navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); @@ -335,17 +394,38 @@ export default function AgentCard({ option.onClick(event as unknown as SyntheticEvent); }} > - + {typeof option.icon === 'string' ? ( + + ) : ( +