Three new env vars wire the Google OAuth callback to a configurable Workspace prefix and route admin/everyone Workspace groups onto the seeded system rows: AGNES_GOOGLE_GROUP_PREFIX, AGNES_GROUP_ADMIN_EMAIL, AGNES_GROUP_EVERYONE_EMAIL. Login gate redirects users with no prefix-matching group to /login?error=not_in_allowed_group. BREAKING: auto-Everyone membership for new users removed. Admin UI/API are read-only on Google-managed groups. See docs/auth-groups.md.
948 lines
33 KiB
Python
948 lines
33 KiB
Python
"""Unified admin REST API for user_groups, members, and resource_grants.
|
|
|
|
Replaces ``app.api.role_management`` and ``app.api.plugin_access`` with a
|
|
single namespace under ``/api/admin``:
|
|
|
|
- ``GET/POST/DELETE /api/admin/groups``
|
|
- ``GET/POST/DELETE /api/admin/groups/{group_id}/members``
|
|
- ``GET/POST/DELETE /api/admin/grants``
|
|
- ``GET /api/admin/resource-types``
|
|
|
|
Every endpoint is gated by ``require_admin``. Audit log entries are written
|
|
for every mutation so an admin's group/grant changes are traceable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Any, List, Optional
|
|
|
|
import duckdb
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.auth.access import require_admin
|
|
from app.auth.dependencies import _get_db, get_current_user
|
|
from app.resource_types import RESOURCE_TYPES, ResourceType, list_resource_types
|
|
from src.repositories.audit import AuditRepository
|
|
from src.repositories.user_groups import (
|
|
SystemGroupProtected,
|
|
UserGroupsRepository,
|
|
)
|
|
from src.repositories.resource_grants import ResourceGrantsRepository
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["access"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _audit(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
actor_id: str,
|
|
action: str,
|
|
resource: str,
|
|
params: Optional[dict] = None,
|
|
) -> None:
|
|
try:
|
|
safe = None
|
|
if params:
|
|
safe = {
|
|
k: (v.isoformat() if isinstance(v, datetime) else v)
|
|
for k, v in params.items()
|
|
}
|
|
AuditRepository(conn).log(
|
|
user_id=actor_id, action=action, resource=resource, params=safe
|
|
)
|
|
except Exception:
|
|
# Audit failures must never break the mutation. Logged at WARN.
|
|
logger.warning("audit log failed for %s/%s", action, resource)
|
|
|
|
|
|
def _is_google_managed(g: dict) -> bool:
|
|
"""Whether a group row is owned by Google sync — admin UI/API treat such
|
|
rows as read-only.
|
|
|
|
Two ways a group can be Google-managed:
|
|
|
|
1. ``created_by='system:google-sync'`` — auto-created by the OAuth
|
|
callback when the user belonged to a prefix-matching Workspace
|
|
group; ``name`` is the full Workspace email.
|
|
2. ``is_system=TRUE`` AND the group's name matches the env-configured
|
|
admin/everyone Workspace email — the OAuth callback routes
|
|
memberships from those Workspace groups into the seeded system
|
|
row instead of creating a separate user_groups row, so the system
|
|
row effectively *becomes* a Google-synced row in this deployment.
|
|
Without the env mapping, system groups stay regular admin-managed
|
|
rows (renaming Admin is still blocked separately by
|
|
``UserGroupsRepository`` for code-reference safety).
|
|
"""
|
|
if (g.get("created_by") or "") == "system:google-sync":
|
|
return True
|
|
if g.get("is_system"):
|
|
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP
|
|
admin_email = os.environ.get(
|
|
"AGNES_GROUP_ADMIN_EMAIL", ""
|
|
).strip().lower()
|
|
everyone_email = os.environ.get(
|
|
"AGNES_GROUP_EVERYONE_EMAIL", ""
|
|
).strip().lower()
|
|
if admin_email and g.get("name") == SYSTEM_ADMIN_GROUP:
|
|
return True
|
|
if everyone_email and g.get("name") == SYSTEM_EVERYONE_GROUP:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _guard_google_managed(g: dict) -> None:
|
|
"""Raise 409 google_managed_readonly when the group is Google-managed."""
|
|
if _is_google_managed(g):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "google_managed_readonly",
|
|
"message": (
|
|
"This group is managed by Google Workspace and is "
|
|
"read-only here. Add or remove members via "
|
|
"admin.google.com, or sign in again to refresh."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
def _validate_resource_type(value: str) -> ResourceType:
|
|
try:
|
|
return ResourceType(value)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"Unknown resource_type {value!r}. Known types: "
|
|
f"{[rt.value for rt in ResourceType]}"
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resource types (read-only, from Python enum)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/resource-types", response_model=List[dict])
|
|
async def get_resource_types(
|
|
user: dict = Depends(require_admin),
|
|
):
|
|
"""List the resource types defined in app.resource_types.
|
|
|
|
No DB call — these come from the ``ResourceType`` StrEnum. The shape
|
|
is ``[{key, display_name, description, id_format}]`` so the admin UI
|
|
can render the create-grant form's resource_type dropdown plus a
|
|
placeholder hint for the ``resource_id`` input.
|
|
"""
|
|
return list_resource_types()
|
|
|
|
|
|
@router.get("/group-suggestions", response_model=List[dict])
|
|
async def get_group_suggestions(
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Suggest Google Workspace group names the calling admin belongs to that
|
|
are *not yet* registered as ``user_groups`` rows.
|
|
|
|
Powers the "Suggested from your Google account" picker on the
|
|
/admin/groups create modal — click a chip → name input is pre-filled.
|
|
|
|
Fail-soft: returns ``[]`` if the Cloud Identity call errors. Off-VM the
|
|
call falls through to the real path and bails out empty unless
|
|
``GOOGLE_ADMIN_SDK_MOCK_GROUPS`` is set.
|
|
"""
|
|
from app.auth.group_sync import fetch_user_groups
|
|
|
|
email = user.get("email") or ""
|
|
if not email:
|
|
return []
|
|
try:
|
|
google_names = fetch_user_groups(email)
|
|
except Exception as e: # noqa: BLE001 - fail-soft by design
|
|
logger.warning("group-suggestions fetch failed for %s: %s", email, e)
|
|
return []
|
|
if not google_names:
|
|
return []
|
|
existing = {g["name"] for g in UserGroupsRepository(conn).list_all()}
|
|
return [
|
|
{"name": n, "source": "google"}
|
|
for n in google_names
|
|
if n and n not in existing
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Access overview — single-shot payload for the /admin/access page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/access-overview", response_model=dict)
|
|
async def access_overview(
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""One-shot snapshot for the /admin/access page.
|
|
|
|
Returns:
|
|
- ``groups``: every user_group with member + grant counts
|
|
- ``grants``: every (group_id, resource_type, resource_id) row
|
|
- ``resources``: per-resource-type hierarchical layout, where each
|
|
type has a list of *blocks* (parent entities, e.g. a marketplace)
|
|
and each block has *items* (concrete grantable resources).
|
|
|
|
UI stitches the three pieces into the two-column layout: groups on
|
|
the left, resources tree on the right with per-item checkboxes whose
|
|
state derives from ``grants``.
|
|
"""
|
|
groups_rows = UserGroupsRepository(conn).list_all()
|
|
members_repo = UserGroupMembersRepository(conn)
|
|
grants_repo = ResourceGrantsRepository(conn)
|
|
|
|
groups = []
|
|
for g in groups_rows:
|
|
groups.append({
|
|
"id": g["id"],
|
|
"name": g["name"],
|
|
"description": g.get("description"),
|
|
"is_system": bool(g.get("is_system", False)),
|
|
"member_count": members_repo.count_members(g["id"]),
|
|
"grant_count": grants_repo.count_for_group(g["id"]),
|
|
})
|
|
|
|
grants = [
|
|
{
|
|
"id": r["id"],
|
|
"group_id": r["group_id"],
|
|
"resource_type": r["resource_type"],
|
|
"resource_id": r["resource_id"],
|
|
}
|
|
for r in grants_repo.list_all()
|
|
]
|
|
|
|
# Per-resource-type hierarchies. Driven by the registry in
|
|
# app.resource_types — adding a new type there is the one place that
|
|
# surfaces here, no extra wiring. Disabled types (e.g. TABLE without
|
|
# AGNES_ENABLE_TABLE_GRANTS) are skipped so the admin UI does not
|
|
# render a chip for grants the runtime cannot enforce yet.
|
|
from app.resource_types import enabled_resource_types
|
|
resources = [
|
|
{
|
|
"type_key": spec.key.value,
|
|
"type_display": spec.display_name,
|
|
"blocks": spec.list_blocks(conn),
|
|
}
|
|
for spec in enabled_resource_types()
|
|
]
|
|
|
|
return {"groups": groups, "grants": grants, "resources": resources}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User groups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class GroupResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
is_system: bool = False
|
|
origin: str = "admin" # 'system' | 'admin' | 'google_sync'
|
|
created_at: Optional[str] = None
|
|
created_by: Optional[str] = None
|
|
member_count: int = 0
|
|
grant_count: int = 0
|
|
# True iff the row is owned by Google sync — admin UI hides edit/delete
|
|
# affordances and the API rejects mutations with 409 google_managed_readonly.
|
|
is_google_managed: bool = False
|
|
|
|
|
|
class CreateGroupRequest(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
|
|
|
|
class UpdateGroupRequest(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
|
|
def _derive_origin(g: dict) -> str:
|
|
"""Project a 3-value origin tag from the existing user_groups columns.
|
|
|
|
- ``is_system=TRUE`` → 'system' (Admin / Everyone)
|
|
- ``created_by`` starts with 'system:' → 'google_sync' (or other auto)
|
|
- else → 'admin' (created via UI/CLI)
|
|
|
|
The OAuth callback stamps ``created_by='system:google-sync'`` when it
|
|
auto-creates a group from a Cloud Identity claim, so the origin is
|
|
derivable without a new column.
|
|
"""
|
|
if g.get("is_system"):
|
|
return "system"
|
|
cb = g.get("created_by") or ""
|
|
if cb.startswith("system:google"):
|
|
return "google_sync"
|
|
if cb.startswith("system:"):
|
|
return "system"
|
|
return "admin"
|
|
|
|
|
|
def _group_to_response(
|
|
g: dict,
|
|
members_repo: UserGroupMembersRepository,
|
|
grants_repo: ResourceGrantsRepository,
|
|
) -> GroupResponse:
|
|
return GroupResponse(
|
|
id=g["id"],
|
|
name=g["name"],
|
|
description=g.get("description"),
|
|
is_system=bool(g.get("is_system", False)),
|
|
origin=_derive_origin(g),
|
|
created_at=str(g["created_at"]) if g.get("created_at") else None,
|
|
created_by=g.get("created_by"),
|
|
member_count=members_repo.count_members(g["id"]),
|
|
grant_count=grants_repo.count_for_group(g["id"]),
|
|
is_google_managed=_is_google_managed(g),
|
|
)
|
|
|
|
|
|
@router.get("/groups", response_model=List[GroupResponse])
|
|
async def list_groups(
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
groups = UserGroupsRepository(conn).list_all()
|
|
members_repo = UserGroupMembersRepository(conn)
|
|
grants_repo = ResourceGrantsRepository(conn)
|
|
return [_group_to_response(g, members_repo, grants_repo) for g in groups]
|
|
|
|
|
|
@router.get("/groups/{group_id}", response_model=GroupResponse)
|
|
async def get_group(
|
|
group_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Single-group payload for the /admin/groups/{id} detail page header."""
|
|
g = UserGroupsRepository(conn).get(group_id)
|
|
if not g:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
members_repo = UserGroupMembersRepository(conn)
|
|
grants_repo = ResourceGrantsRepository(conn)
|
|
return _group_to_response(g, members_repo, grants_repo)
|
|
|
|
|
|
@router.post("/groups", response_model=GroupResponse, status_code=201)
|
|
async def create_group(
|
|
payload: CreateGroupRequest,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
name = payload.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Group name is required")
|
|
repo = UserGroupsRepository(conn)
|
|
if repo.get_by_name(name):
|
|
raise HTTPException(status_code=409, detail=f"Group {name!r} already exists")
|
|
g = repo.create(
|
|
name=name,
|
|
description=payload.description,
|
|
created_by=user.get("email"),
|
|
)
|
|
_audit(
|
|
conn, user["id"], "user_group.created", f"group:{g['id']}",
|
|
{"name": name},
|
|
)
|
|
members_repo = UserGroupMembersRepository(conn)
|
|
grants_repo = ResourceGrantsRepository(conn)
|
|
return _group_to_response(g, members_repo, grants_repo)
|
|
|
|
|
|
@router.patch("/groups/{group_id}", response_model=GroupResponse)
|
|
async def update_group(
|
|
group_id: str,
|
|
payload: UpdateGroupRequest,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
repo = UserGroupsRepository(conn)
|
|
g = repo.get(group_id)
|
|
if not g:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(g)
|
|
if g.get("is_system") and payload.name is not None and payload.name.strip() != g["name"]:
|
|
# System groups: block renames (the canonical names "Admin" /
|
|
# "Everyone" are referenced from app.auth.access and the
|
|
# marketplace filter), but description edits are cosmetic and
|
|
# allowed (admins curate them in /admin/access). The repo
|
|
# layer's narrowed guard (src/repositories/user_groups.py) is
|
|
# the second line of defense.
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="System groups cannot be renamed",
|
|
)
|
|
updates: dict = {}
|
|
if payload.name is not None and payload.name.strip() != g["name"]:
|
|
updates["name"] = payload.name.strip()
|
|
if payload.description is not None:
|
|
updates["description"] = payload.description
|
|
if updates:
|
|
try:
|
|
repo.update(group_id, **updates)
|
|
except SystemGroupProtected:
|
|
raise HTTPException(
|
|
status_code=409, detail="System groups cannot be renamed",
|
|
)
|
|
_audit(conn, user["id"], "user_group.updated", f"group:{group_id}", updates)
|
|
g = repo.get(group_id)
|
|
members_repo = UserGroupMembersRepository(conn)
|
|
grants_repo = ResourceGrantsRepository(conn)
|
|
return _group_to_response(g, members_repo, grants_repo)
|
|
|
|
|
|
@router.delete("/groups/{group_id}", status_code=204)
|
|
async def delete_group(
|
|
group_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
repo = UserGroupsRepository(conn)
|
|
g = repo.get(group_id)
|
|
if not g:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(g)
|
|
if g.get("is_system"):
|
|
raise HTTPException(status_code=409, detail="Cannot delete a system group")
|
|
# Cascade members + grants atomically with the group row so a partial
|
|
# failure cannot leave orphans pointing at a deleted group_id. There are
|
|
# no FK constraints (group_id is plain VARCHAR), so the application is
|
|
# responsible for the invariant — wrap in an explicit transaction.
|
|
try:
|
|
conn.execute("BEGIN TRANSACTION")
|
|
conn.execute(
|
|
"DELETE FROM user_group_members WHERE group_id = ?", [group_id]
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM resource_grants WHERE group_id = ?", [group_id]
|
|
)
|
|
repo.delete(group_id)
|
|
conn.execute("COMMIT")
|
|
except SystemGroupProtected:
|
|
conn.execute("ROLLBACK")
|
|
raise HTTPException(status_code=409, detail="Cannot delete a system group")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
_audit(
|
|
conn, user["id"], "user_group.deleted", f"group:{group_id}",
|
|
{"name": g["name"]},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group members
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MemberResponse(BaseModel):
|
|
user_id: str
|
|
email: str
|
|
name: Optional[str] = None
|
|
active: bool = True
|
|
source: str
|
|
added_at: Optional[str] = None
|
|
added_by: Optional[str] = None
|
|
|
|
|
|
class AddMemberRequest(BaseModel):
|
|
email: str
|
|
|
|
|
|
@router.get("/groups/{group_id}/members", response_model=List[MemberResponse])
|
|
async def list_members(
|
|
group_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
if not UserGroupsRepository(conn).get(group_id):
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
rows = UserGroupMembersRepository(conn).list_members_for_group(group_id)
|
|
return [
|
|
MemberResponse(
|
|
user_id=r["id"],
|
|
email=r["email"],
|
|
name=r.get("name"),
|
|
active=bool(r.get("active", True)),
|
|
source=r["source"],
|
|
added_at=str(r["added_at"]) if r.get("added_at") else None,
|
|
added_by=r.get("added_by"),
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/groups/{group_id}/members", response_model=MemberResponse, status_code=201)
|
|
async def add_member(
|
|
group_id: str,
|
|
payload: AddMemberRequest,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
g = UserGroupsRepository(conn).get(group_id)
|
|
if not g:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(g)
|
|
target = UserRepository(conn).get_by_email(payload.email)
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail=f"User {payload.email!r} not found")
|
|
members = UserGroupMembersRepository(conn)
|
|
if members.has_membership(target["id"], group_id):
|
|
raise HTTPException(status_code=409, detail="User already a member")
|
|
members.add_member(
|
|
user_id=target["id"],
|
|
group_id=group_id,
|
|
source="admin",
|
|
added_by=user.get("email"),
|
|
)
|
|
_audit(
|
|
conn, user["id"], "user_group.member_added",
|
|
f"group:{group_id}",
|
|
{"user_email": payload.email},
|
|
)
|
|
return MemberResponse(
|
|
user_id=target["id"],
|
|
email=target["email"],
|
|
name=target.get("name"),
|
|
active=bool(target.get("active", True)),
|
|
source="admin",
|
|
added_at=None,
|
|
added_by=user.get("email"),
|
|
)
|
|
|
|
|
|
@router.delete("/groups/{group_id}/members/{user_id}", status_code=204)
|
|
async def remove_member(
|
|
group_id: str,
|
|
user_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
members = UserGroupMembersRepository(conn)
|
|
# Block removing yourself from Admin if you're the last admin — same
|
|
# protection as the user-management endpoints.
|
|
group = UserGroupsRepository(conn).get(group_id)
|
|
if not group:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(group)
|
|
if group["name"] == "Admin" and user_id == user["id"]:
|
|
if UserRepository(conn).count_admins(active_only=True) <= 1:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot remove yourself from Admin — you are the last admin",
|
|
)
|
|
# Only delete admin-source rows from this endpoint. Google-sync rows
|
|
# rebuild themselves on next login; system_seed rows survive deploys.
|
|
removed = members.remove_member(user_id, group_id, require_source="admin")
|
|
if not removed:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="No admin-managed membership for this user in this group",
|
|
)
|
|
_audit(
|
|
conn, user["id"], "user_group.member_removed",
|
|
f"group:{group_id}",
|
|
{"user_id": user_id},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resource grants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class GrantResponse(BaseModel):
|
|
id: str
|
|
group_id: str
|
|
group_name: str
|
|
resource_type: str
|
|
resource_id: str
|
|
assigned_at: Optional[str] = None
|
|
assigned_by: Optional[str] = None
|
|
|
|
|
|
class CreateGrantRequest(BaseModel):
|
|
group_id: str
|
|
resource_type: str
|
|
resource_id: str
|
|
|
|
|
|
def _grant_to_response(g: dict) -> GrantResponse:
|
|
return GrantResponse(
|
|
id=g["id"],
|
|
group_id=g["group_id"],
|
|
group_name=g.get("group_name", ""),
|
|
resource_type=g["resource_type"],
|
|
resource_id=g["resource_id"],
|
|
assigned_at=str(g["assigned_at"]) if g.get("assigned_at") else None,
|
|
assigned_by=g.get("assigned_by"),
|
|
)
|
|
|
|
|
|
@router.get("/grants", response_model=List[GrantResponse])
|
|
async def list_grants(
|
|
resource_type: Optional[str] = None,
|
|
group_id: Optional[str] = None,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
if resource_type:
|
|
_validate_resource_type(resource_type)
|
|
rows = ResourceGrantsRepository(conn).list_all(
|
|
resource_type=resource_type, group_id=group_id,
|
|
)
|
|
return [_grant_to_response(r) for r in rows]
|
|
|
|
|
|
@router.post("/grants", response_model=GrantResponse, status_code=201)
|
|
async def create_grant(
|
|
payload: CreateGrantRequest,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
rt = _validate_resource_type(payload.resource_type)
|
|
# Feature gate: refuse to mint grants for resource types whose runtime
|
|
# enforcement is not wired up yet (e.g. ResourceType.TABLE without
|
|
# AGNES_ENABLE_TABLE_GRANTS). Listing + deleting existing rows still
|
|
# works so operators can clean up legacy data.
|
|
from app.resource_types import is_resource_type_enabled
|
|
if not is_resource_type_enabled(rt):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=(
|
|
f"resource_type {rt.value!r} is not currently enabled. "
|
|
"Set AGNES_ENABLE_TABLE_GRANTS=1 to opt in once the runtime "
|
|
"enforcement is in place (see docs/TODO-rbac-data-enforcement.md)."
|
|
),
|
|
)
|
|
if not payload.resource_id.strip():
|
|
raise HTTPException(status_code=400, detail="resource_id is required")
|
|
if not UserGroupsRepository(conn).get(payload.group_id):
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
grants = ResourceGrantsRepository(conn)
|
|
try:
|
|
grant_id = grants.create(
|
|
group_id=payload.group_id,
|
|
resource_type=rt.value,
|
|
resource_id=payload.resource_id,
|
|
assigned_by=user.get("email"),
|
|
)
|
|
except duckdb.ConstraintException:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Grant already exists for this group/resource_type/resource_id",
|
|
)
|
|
_audit(
|
|
conn, user["id"], "resource_grant.created",
|
|
f"grant:{grant_id}",
|
|
{
|
|
"group_id": payload.group_id,
|
|
"resource_type": rt.value,
|
|
"resource_id": payload.resource_id,
|
|
},
|
|
)
|
|
# Re-read with the group name joined for the response.
|
|
rows = grants.list_all()
|
|
fresh = next((r for r in rows if r["id"] == grant_id), None)
|
|
if not fresh:
|
|
raise HTTPException(status_code=500, detail="Grant created but lookup failed")
|
|
return _grant_to_response(fresh)
|
|
|
|
|
|
@router.delete("/grants/{grant_id}", status_code=204)
|
|
async def delete_grant(
|
|
grant_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
grants = ResourceGrantsRepository(conn)
|
|
existing = grants.get(grant_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Grant not found")
|
|
grants.delete(grant_id)
|
|
_audit(
|
|
conn, user["id"], "resource_grant.deleted", f"grant:{grant_id}",
|
|
{
|
|
"group_id": existing["group_id"],
|
|
"resource_type": existing["resource_type"],
|
|
"resource_id": existing["resource_id"],
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User-centric views — back the /admin/users/{id} detail page.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class UserMembershipResponse(BaseModel):
|
|
group_id: str
|
|
group_name: str
|
|
is_system: bool = False
|
|
source: str
|
|
added_at: Optional[str] = None
|
|
added_by: Optional[str] = None
|
|
|
|
|
|
class AddUserToGroupRequest(BaseModel):
|
|
group_id: str
|
|
|
|
|
|
@router.get(
|
|
"/users/{user_id}/memberships",
|
|
response_model=List[UserMembershipResponse],
|
|
)
|
|
async def list_user_memberships(
|
|
user_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Groups a user belongs to, joined with group metadata for display.
|
|
|
|
Includes ``source`` so the UI can distinguish admin-managed memberships
|
|
(deletable from this page) from Google-synced or system-seeded ones
|
|
(read-only — managed by their own writer).
|
|
"""
|
|
if not UserRepository(conn).get_by_id(user_id):
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
rows = conn.execute(
|
|
"""SELECT m.group_id, g.name AS group_name, g.is_system,
|
|
m.source, m.added_at, m.added_by
|
|
FROM user_group_members m
|
|
JOIN user_groups g ON g.id = m.group_id
|
|
WHERE m.user_id = ?
|
|
ORDER BY g.is_system DESC, g.name""",
|
|
[user_id],
|
|
).fetchall()
|
|
return [
|
|
UserMembershipResponse(
|
|
group_id=r[0],
|
|
group_name=r[1],
|
|
is_system=bool(r[2]),
|
|
source=r[3],
|
|
added_at=str(r[4]) if r[4] else None,
|
|
added_by=r[5],
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post(
|
|
"/users/{user_id}/memberships",
|
|
response_model=UserMembershipResponse,
|
|
status_code=201,
|
|
)
|
|
async def add_user_to_group(
|
|
user_id: str,
|
|
payload: AddUserToGroupRequest,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Add a user to a group from the user-centric page.
|
|
|
|
Mirror of POST /api/admin/groups/{id}/members but keyed on the user.
|
|
Always writes ``source='admin'`` so the row survives Google sync.
|
|
"""
|
|
if not UserRepository(conn).get_by_id(user_id):
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
group = UserGroupsRepository(conn).get(payload.group_id)
|
|
if not group:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(group)
|
|
members = UserGroupMembersRepository(conn)
|
|
if members.has_membership(user_id, payload.group_id):
|
|
raise HTTPException(status_code=409, detail="Already a member")
|
|
members.add_member(
|
|
user_id=user_id,
|
|
group_id=payload.group_id,
|
|
source="admin",
|
|
added_by=user.get("email"),
|
|
)
|
|
_audit(
|
|
conn, user["id"], "user_group.member_added",
|
|
f"user:{user_id}",
|
|
{"group_id": payload.group_id, "group_name": group["name"]},
|
|
)
|
|
return UserMembershipResponse(
|
|
group_id=payload.group_id,
|
|
group_name=group["name"],
|
|
is_system=bool(group.get("is_system", False)),
|
|
source="admin",
|
|
added_at=None,
|
|
added_by=user.get("email"),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/users/{user_id}/memberships/{group_id}",
|
|
status_code=204,
|
|
)
|
|
async def remove_user_from_group(
|
|
user_id: str,
|
|
group_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Remove a user from a group from the user-centric page.
|
|
|
|
Only deletes admin-source rows (Google-sync / system-seed managed
|
|
elsewhere). Last-admin guard: refuse to remove yourself from Admin
|
|
when you'd be the only remaining admin — keeps the system unlockable.
|
|
"""
|
|
group = UserGroupsRepository(conn).get(group_id)
|
|
if not group:
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
_guard_google_managed(group)
|
|
if group["name"] == "Admin" and user_id == user["id"]:
|
|
if UserRepository(conn).count_admins(active_only=True) <= 1:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot remove yourself from Admin — you are the last admin",
|
|
)
|
|
members = UserGroupMembersRepository(conn)
|
|
removed = members.remove_member(user_id, group_id, require_source="admin")
|
|
if not removed:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="No admin-managed membership for this user in this group",
|
|
)
|
|
_audit(
|
|
conn, user["id"], "user_group.member_removed",
|
|
f"user:{user_id}",
|
|
{"group_id": group_id, "group_name": group["name"]},
|
|
)
|
|
|
|
|
|
class EffectiveAccessItem(BaseModel):
|
|
resource_type: str
|
|
resource_id: str
|
|
via_groups: List[dict] # [{group_id, group_name}]
|
|
|
|
|
|
class EffectiveAccessResponse(BaseModel):
|
|
is_admin: bool
|
|
items: List[EffectiveAccessItem]
|
|
|
|
|
|
@router.get(
|
|
"/users/{user_id}/effective-access",
|
|
response_model=EffectiveAccessResponse,
|
|
)
|
|
async def user_effective_access(
|
|
user_id: str,
|
|
user: dict = Depends(require_admin),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""List resources the user effectively has access to, with which group
|
|
grants each one. Admin short-circuits — if the user is in Admin, the
|
|
response sets ``is_admin=true`` and an empty items list (UI renders a
|
|
"Full access via Admin" pill instead of the per-resource breakdown).
|
|
"""
|
|
if not UserRepository(conn).get_by_id(user_id):
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
from app.auth.access import is_user_admin
|
|
if is_user_admin(user_id, conn):
|
|
return EffectiveAccessResponse(is_admin=True, items=[])
|
|
|
|
# JOIN user's group memberships with their grants. group_concat-style
|
|
# aggregation isn't worth it — render side-by-side rows and let the UI
|
|
# collapse same (resource_type, resource_id) into a single line.
|
|
rows = conn.execute(
|
|
"""SELECT rg.resource_type, rg.resource_id,
|
|
g.id AS group_id, g.name AS group_name
|
|
FROM user_group_members m
|
|
JOIN user_groups g ON g.id = m.group_id
|
|
JOIN resource_grants rg ON rg.group_id = m.group_id
|
|
WHERE m.user_id = ?
|
|
ORDER BY rg.resource_type, rg.resource_id, g.name""",
|
|
[user_id],
|
|
).fetchall()
|
|
|
|
grouped: dict[tuple[str, str], EffectiveAccessItem] = {}
|
|
for rt, rid, gid, gname in rows:
|
|
key = (rt, rid)
|
|
if key not in grouped:
|
|
grouped[key] = EffectiveAccessItem(
|
|
resource_type=rt, resource_id=rid, via_groups=[],
|
|
)
|
|
grouped[key].via_groups.append({"group_id": gid, "group_name": gname})
|
|
|
|
return EffectiveAccessResponse(
|
|
is_admin=False,
|
|
items=list(grouped.values()),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Self-service: /api/me/effective-access — non-admin can view their own.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Separate router so it bypasses the admin gate. Mounted at /api/me/...
|
|
me_router = APIRouter(prefix="/api/me", tags=["me"])
|
|
|
|
|
|
@me_router.get(
|
|
"/effective-access",
|
|
response_model=EffectiveAccessResponse,
|
|
)
|
|
async def my_effective_access(
|
|
user: dict = Depends(get_current_user),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Same payload as /api/admin/users/{id}/effective-access but scoped to
|
|
the calling user. Drives the /profile page's read-only access summary —
|
|
so non-admin callers can self-audit without elevation."""
|
|
user_id = user["id"]
|
|
from app.auth.access import is_user_admin
|
|
if is_user_admin(user_id, conn):
|
|
return EffectiveAccessResponse(is_admin=True, items=[])
|
|
|
|
rows = conn.execute(
|
|
"""SELECT rg.resource_type, rg.resource_id,
|
|
g.id AS group_id, g.name AS group_name
|
|
FROM user_group_members m
|
|
JOIN user_groups g ON g.id = m.group_id
|
|
JOIN resource_grants rg ON rg.group_id = m.group_id
|
|
WHERE m.user_id = ?
|
|
ORDER BY rg.resource_type, rg.resource_id, g.name""",
|
|
[user_id],
|
|
).fetchall()
|
|
|
|
grouped: dict[tuple[str, str], EffectiveAccessItem] = {}
|
|
for rt, rid, gid, gname in rows:
|
|
key = (rt, rid)
|
|
if key not in grouped:
|
|
grouped[key] = EffectiveAccessItem(
|
|
resource_type=rt, resource_id=rid, via_groups=[],
|
|
)
|
|
grouped[key].via_groups.append({"group_id": gid, "group_name": gname})
|
|
|
|
return EffectiveAccessResponse(
|
|
is_admin=False,
|
|
items=list(grouped.values()),
|
|
)
|