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

116 lines
4.3 KiB
Python

"""Repository for the ``user_roles`` RBAC grant table.
The table stores only elevated grants; the ``user`` role is implicit. Grants
are keyed by ``(user_id, role, source)`` so a manual grant and an
OIDC-group-derived grant for the same user coexist and revoke independently.
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
VALID_SOURCES = ("manual", "oidc_group")
class UserRolesRepository:
"""Persisted role grants keyed by the auth ``sub`` (``user_id``)."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def role_names_for(self, user_id: str) -> list[str]:
"""Return the distinct elevated roles granted to ``user_id`` (any source)."""
if not user_id:
return []
result = self._conn.execute(
text("SELECT DISTINCT role FROM user_roles WHERE user_id = :user_id"),
{"user_id": user_id},
)
return [row[0] for row in result.fetchall()]
def list_admins(self) -> list[dict]:
"""Return one row per user holding ``admin``, with earliest grant + sources."""
result = self._conn.execute(
text(
"""
SELECT user_id,
min(granted_at) AS granted_at,
array_agg(DISTINCT source ORDER BY source) AS sources
FROM user_roles
WHERE role = 'admin'
GROUP BY user_id
ORDER BY min(granted_at)
"""
)
)
return [
{"user_id": row[0], "granted_at": row[1], "sources": list(row[2])}
for row in result.fetchall()
]
def list_for(self, user_id: str) -> list[dict]:
"""Return all grant rows for ``user_id`` (for audit/inspection)."""
result = self._conn.execute(
text("SELECT * FROM user_roles WHERE user_id = :user_id ORDER BY granted_at"),
{"user_id": user_id},
)
return [row_to_dict(row) for row in result.fetchall()]
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def grant(
self,
user_id: str,
role: str = "admin",
source: str = "manual",
granted_by: Optional[str] = None,
) -> bool:
"""Idempotently grant ``role`` to ``user_id`` from ``source``.
Returns True when a new row was inserted, False when it already existed.
"""
result = self._conn.execute(
text(
"""
INSERT INTO user_roles (user_id, role, source, granted_by)
VALUES (:user_id, :role, :source, :granted_by)
ON CONFLICT (user_id, role, source) DO NOTHING
"""
),
{"user_id": user_id, "role": role, "source": source, "granted_by": granted_by},
)
return bool(result.rowcount)
def revoke(self, user_id: str, role: str = "admin", source: str = "manual") -> bool:
"""Revoke ``role`` from ``user_id`` for ``source``. Returns True if removed."""
result = self._conn.execute(
text(
"""
DELETE FROM user_roles
WHERE user_id = :user_id AND role = :role AND source = :source
"""
),
{"user_id": user_id, "role": role, "source": source},
)
return bool(result.rowcount)
def reconcile_oidc_admin(self, user_id: str, is_admin: bool) -> Optional[str]:
"""Sync the ``oidc_group`` admin grant for ``user_id``; never touches manual grants.
Returns ``"granted"`` / ``"revoked"`` when a change occurred, else ``None``.
"""
if is_admin:
granted = self.grant(user_id, "admin", source="oidc_group", granted_by="oidc")
return "granted" if granted else None
revoked = self.revoke(user_id, "admin", source="oidc_group")
return "revoked" if revoked else None