mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 16:14:16 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
@@ -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 = []
|
||||
|
||||
Reference in new issue
Block a user