Files
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01: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 docsgpt.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