Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+313 -40
View File
@@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``):
- 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.
- Sharing a resource requires the ``share`` action on it (the owner, or an
editor when the owner turned on ``editors_can_share``; see
``resource_access.py``) and membership of the target team. Unsharing needs
``share`` (no membership required) or ``team_admin`` of the team. Sharing is
additive visibility — the resource's owner is never changed.
- The team owner can't be demoted or removed; they transfer ownership first.
``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.
@@ -22,14 +25,25 @@ import uuid
from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
from sqlalchemy import text
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
from docsgpt.api.user.resource_access import (
RESOURCE_TYPES,
AccessDenied,
ResourceAccess,
build,
public_settings,
require,
set_settings,
settings_many,
)
from docsgpt.api.user.team_authz import (
has_team_role,
team_admin_required,
team_member_required,
)
from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource
from docsgpt.api.user.team_sharing import is_valid_resource_type
from docsgpt.events.publisher import publish_user_event
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import (
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.teams import TeamsRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
@@ -62,6 +77,99 @@ def _current_user() -> str | None:
return token.get("sub") if isinstance(token, dict) else None
def _denied(err: AccessDenied):
"""JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed)."""
return make_response(jsonify({"success": False, "message": err.message}), err.status)
def _team_payload(team: dict, user: str | None) -> dict:
"""Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer)."""
team["is_owner"] = bool(user) and team.get("owner_id") == user
return team
# Name + owner of each shareable resource, looked up unscoped by id (the grant
# row already proves it was shared; the caller's own access is computed below).
_RESOURCE_ROW_SQL = {
"agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))",
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
"tool": (
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, "
"user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
),
}
def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]:
"""``(type, id) -> {name, user_id}`` for every resource the grants point at."""
ids_by_type: dict[str, set[str]] = {}
for g in grants:
ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"]))
out: dict[tuple[str, str], dict] = {}
for rtype, ids in ids_by_type.items():
sql = _RESOURCE_ROW_SQL.get(rtype)
if not sql:
continue
for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall():
out[(rtype, str(rid))] = {"name": name, "user_id": owner}
return out
def _caller_access(
conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict]
) -> dict[tuple[str, str], ResourceAccess]:
"""The caller's live access to each granted resource, in a few bulk queries.
Same answer as ``resource_access.resolve`` per resource (owner by the
resource's ``user_id``, else the strongest team grant reaching the caller),
without four queries per row.
"""
scope = TeamScopeRepository(conn)
out: dict[tuple[str, str], ResourceAccess] = {}
for rtype in {g["resource_type"] for g in grants}:
ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype})
via_teams = scope.visible_with_access(user, rtype)
settings = settings_many(conn, rtype, ids)
for rid in ids:
row = rows.get((rtype, rid))
if row is None:
continue # dangling grant: the resource is gone
if row["user_id"] == user:
level = "owner"
else:
level = via_teams.get(rid)
if level is None:
continue
out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid])
return out
def _user_labels(conn, user_ids: set[str]) -> dict[str, str]:
"""``user_id -> email`` for the users on file with an email."""
ids = [u for u in user_ids if u]
if not ids:
return {}
rows = conn.execute(
text(
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
"AND email IS NOT NULL AND email <> ''"
),
{"ids": ids},
).fetchall()
return {uid: email for uid, email in rows}
def _valid_resource(resource_type, resource_id) -> bool:
"""A known shareable type and a canonical-UUID id (grants are UUID-only)."""
return (
is_valid_resource_type(resource_type)
and bool(resource_id)
and isinstance(resource_id, str)
and looks_like_uuid(resource_id)
)
# Metadata keys naming the user a team event acted on, most specific first.
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
@@ -218,6 +326,7 @@ class Teams(Resource):
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_for_user(user)
teams = [_team_payload(t, user) for t in teams]
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List teams failed: %s", err, exc_info=True)
@@ -243,6 +352,7 @@ class Teams(Resource):
)
_audit(conn, user, "team.create", team_id=team["id"], name=name)
team["member_role"] = ROLE_TEAM_ADMIN
_team_payload(team, user)
return make_response(jsonify({"success": True, "team": team}), 201)
except Exception as err:
logger.error("Create team failed: %s", err, exc_info=True)
@@ -263,6 +373,7 @@ class Team(Resource):
members = TeamMembersRepository(conn)
team["members"] = members.list_members(team_id)
team["member_role"] = members.role_for(user, team_id)
_team_payload(team, user)
return make_response(jsonify({"success": True, "team": team}), 200)
except Exception as err:
logger.error("Get team failed: %s", err, exc_info=True)
@@ -370,6 +481,10 @@ class TeamMember(Resource):
return {"success": False, "message": "invalid role"}, 400
try:
with db_session() as conn:
if role == ROLE_TEAM_MEMBER:
blocked = self._owner_guard(conn, team_id, member_id, "demote")
if blocked is not None:
return blocked
members = TeamMembersRepository(conn)
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
members, team_id, member_id
@@ -403,6 +518,9 @@ class TeamMember(Resource):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_session() as conn:
blocked = self._owner_guard(conn, team_id, member_id, "remove")
if blocked is not None:
return blocked
members = TeamMembersRepository(conn)
if self._would_orphan_admins(members, team_id, member_id):
return {
@@ -423,6 +541,38 @@ class TeamMember(Resource):
logger.error("Remove member failed: %s", err, exc_info=True)
return {"success": False}, 400
@staticmethod
def _owner_guard(conn, team_id: str, member_id: str, change: str):
"""Refuse to demote or remove the team owner.
Another admin gets 403; the owner themselves gets 400 telling them to
transfer ownership first (the team would otherwise have an owner who
isn't an admin, or isn't a member at all).
Args:
conn: Open connection.
team_id: Team from the URL path.
member_id: The member being changed.
change: ``"demote"`` or ``"remove"`` (for the message).
Returns:
A response to return, or None when the change may proceed.
"""
team = TeamsRepository(conn).get(team_id)
if not team or team.get("owner_id") != member_id:
return None
if member_id == _current_user():
message = (
"Transfer team ownership to another member before you leave the team"
if change == "remove"
else "Transfer team ownership to another member before you step down as admin"
)
return make_response(jsonify({"success": False, "message": message}), 400)
verb = "removed" if change == "remove" else "demoted"
return make_response(
jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403
)
@staticmethod
def _would_orphan_admins(
members: TeamMembersRepository, team_id: str, member_id: str
@@ -443,21 +593,66 @@ class TeamMember(Resource):
class TeamGrants(Resource):
@team_member_required
def get(self, team_id):
"""List resources shared with this team. Requires membership."""
"""List resources shared with this team. Requires membership.
Each row carries the resource's name, owner and people labels (email
when on file, else null) and ``caller`` — the caller's own live
``{access, allowed_actions}`` on that resource (null if none). A plain
member sees whole-team grants and grants aimed at them; a team_admin,
or someone with ``share`` on the resource, sees every grant.
"""
user = _current_user()
token = getattr(request, "decoded_token", None)
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)
team_role = TeamMembersRepository(conn).role_for(user, team_id)
sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN)
rows = _resource_rows(conn, grants)
access = _caller_access(conn, user, grants, rows)
visible = []
for g in grants:
key = (g["resource_type"], str(g["resource_id"]))
ra = access.get(key)
target = g.get("target_user_id")
if not (sees_all or not target or target == user or (ra and ra.can("share"))):
continue
row = rows.get(key) or {}
g["resource_name"] = row.get("name")
g["owner_id"] = row.get("user_id") or g.get("owner_id")
g["caller"] = ra.payload() if ra else None
visible.append(g)
labels = _user_labels(
conn,
{
u
for g in visible
for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by"))
if u
},
)
for g in visible:
g["owner_label"] = labels.get(g.get("owner_id"))
g["target_user_label"] = labels.get(g.get("target_user_id"))
g["granted_by_label"] = labels.get(g.get("granted_by"))
return make_response(
jsonify({"success": True, "grants": visible, "team_role": team_role}), 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)."""
"""Share a resource with this team, or change an existing grant's level.
Needs ``share`` on the resource (the owner, or an editor when the owner
turned on ``editors_can_share``) and membership of the team. The grant
records the real owner as ``owner_id`` and the caller as ``granted_by``.
"""
user = _current_user()
data = request.get_json(silent=True) or {}
resource_type = data.get("resource_type")
@@ -465,20 +660,18 @@ class TeamGrants(Resource):
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)
):
if not _valid_resource(resource_type, 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
# ``require`` dispatches by resource_type, so a mismatched
# type/id can't register a bogus grant (the table has no FK).
try:
ra = require(conn, resource_type, resource_id, user, "share")
except AccessDenied as denied:
return _denied(denied)
# 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
@@ -487,8 +680,8 @@ class TeamGrants(Resource):
grant = TeamResourceGrantsRepository(conn).grant(
team_id,
resource_type,
resource_id,
owner_id=user,
ra.resource_id,
owner_id=ra.owner_id,
granted_by=user,
access_level=access_level,
target_user_id=target_user_id,
@@ -512,15 +705,21 @@ class TeamGrants(Resource):
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.
"""Unshare a resource from this team.
Identifiers come from query params (some proxies strip DELETE bodies),
with a JSON-body fallback for older clients.
Allowed with ``share`` on the resource — no membership needed, so an
owner who left the team can still pull their resource back — or for a
team_admin of this team. ``target_user_id`` picks one member's grant
(absent → the whole-team grant). Identifiers come from query params
(some proxies strip DELETE bodies), with a JSON-body fallback.
"""
user = _current_user()
token = getattr(request, "decoded_token", None)
if not user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
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")
@@ -528,17 +727,15 @@ class TeamGrants(Resource):
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)
):
if not _valid_resource(resource_type, 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
try:
require(conn, resource_type, resource_id, user, "share")
except AccessDenied:
if 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
)
@@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource):
@teams_ns.route("/resource_shares")
class ResourceShares(Resource):
def get(self):
"""List the teams a resource the caller OWNS is shared with.
"""List the teams a resource is shared with. Needs ``share`` on it.
Powers the share dialog (show current shares + unshare). Owner-only so a
non-owner can't enumerate a resource's sharing graph.
Powers the share dialog (current shares + unshare), so only someone who
may change the sharing can enumerate it: 404 when the resource isn't
visible, 403 when the caller's role can't share.
"""
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)
):
if not _valid_resource(resource_type, 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
try:
require(conn, resource_type, resource_id, user, "share")
except AccessDenied as denied:
return _denied(denied)
shares = TeamResourceGrantsRepository(conn).list_for_resource(
resource_type, resource_id
)
@@ -631,6 +827,83 @@ class ResourceShares(Resource):
return {"success": False}, 400
def _settings_response(ra: ResourceAccess):
"""The ``resource_settings`` body: switches plus the caller's access."""
return make_response(
jsonify(
{
"success": True,
"resource_type": ra.resource_type,
"resource_id": ra.resource_id,
"settings": public_settings(ra.resource_type, ra.settings),
**ra.payload(),
}
),
200,
)
@teams_ns.route("/resource_settings")
class ResourceSettings(Resource):
def get(self):
"""A resource's sharing switches. Anyone with access may read them.
Query: ``resource_type``, ``resource_id``. Returns ``settings`` as
``[{key, value, default}]`` in display order, plus the caller's
``access`` and ``allowed_actions``.
"""
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 resource_type not in RESOURCE_TYPES or not resource_id:
return {"success": False, "message": "invalid resource"}, 400
try:
with db_readonly() as conn:
try:
ra = require(conn, resource_type, resource_id, user, "use")
except AccessDenied as denied:
return _denied(denied)
return _settings_response(ra)
except Exception as err:
logger.error("Get resource settings failed: %s", err, exc_info=True)
return {"success": False}, 400
def put(self):
"""Change a resource's sharing switches. Needs ``manage_settings`` (owner).
Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``.
An unknown key or a non-boolean value is a 400. Returns the GET shape.
"""
user = _current_user()
if not user:
return {"success": False}, 401
data = request.get_json(silent=True) or {}
resource_type = data.get("resource_type")
resource_id = data.get("resource_id")
changes = data.get("settings")
if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str):
return {"success": False, "message": "invalid resource"}, 400
if not isinstance(changes, dict):
return {"success": False, "message": "settings must be an object"}, 400
try:
with db_session() as conn:
try:
ra = require(conn, resource_type, resource_id, user, "manage_settings")
except AccessDenied as denied:
return _denied(denied)
try:
merged = set_settings(conn, resource_type, ra.resource_id, changes, user)
except ValueError as bad:
return {"success": False, "message": str(bad)}, 400
updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged)
return _settings_response(updated)
except Exception as err:
logger.error("Update resource settings failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/admin/teams")
class AllTeams(Resource):
method_decorators = []