Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

110 lines
3.8 KiB
Python

"""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)