This squashes 13 commits from ma/staging plus a small docstring translation
into a single coherent unit. Three workstreams.
== RBAC v13 redesign ==
- Drops core.viewer/analyst/km_admin/admin hierarchy and the
internal_roles / group_mappings / user_role_grants / plugin_access tables.
- Replaced by user_group_members + resource_grants. Atomic v12→v13 backfill
wrapped in BEGIN/COMMIT; ROLLBACK leaves schema_version at 12 for retry.
- Two authorization primitives in app.auth.access:
require_admin — Admin-group god-mode
require_resource_access(rt, "{path}") — entity-scoped grants
Single DB lookup per request; no session cache; no implies BFS.
- /admin/access UI (single page) replaces /admin/role-mapping +
/admin/plugin-access. CLI `da admin group/grant *` replaces
`da admin role/mapping/grant-role/revoke-role/effective-roles`.
- ResourceType.TABLE listing-only — admins can record table grants,
runtime enforcement still flows through legacy dataset_permissions
(migration plan in docs/TODO-rbac-data-enforcement.md).
== Claude Code marketplace ==
- Aggregated /marketplace.zip + /marketplace.git/* (PAT-gated,
RBAC-filtered, content-addressed cache via dulwich).
- Admin god-mode dropped on the marketplace surface — admins curate
their own view via grants like everyone else.
- Bare-repo cache materializes per RBAC-filtered ETag; stale entries
not pruned in this iteration (disclaimed in git_backend.py docstring).
== #81 #83 #44 security/ops hardening ==
- #81 Group A — orchestrator ATTACH allow-listing (extension/url/alias).
- #81 Group B — Keboola extractor 3-state exit codes:
0 success / 1 total fail / 2 PARTIAL fail
Sync API logs PARTIAL FAILURE alert on exit 2. Operators with binary
alerting must teach it the new partial signal.
- #81 Group C — schema v10 view_ownership; rejects silent overwrite
of a prior connector's view name on collision.
- #81 Group D — extractor-side identifier validation.
- #83 — Jira webhook fail-closed when JIRA_WEBHOOK_SECRET unset
+ path-traversal fix.
- #44 — entire /api/scripts/* surface is admin-only (planted-script +
sandbox-bypass risk closed).
== Web UI polish + deploy fix ==
- /admin/access: live grant-count badges (no stale snapshot revert),
shared-header CSS link added to /catalog and /admin/{tables,permissions},
per-resource-type colored stripes.
- docker-compose.host-mount.yml: bind,rbind so dual-disk hosts don't
silently shadow sub-mounts and write state to the wrong disk.
== OSS vendor-neutralization (waves 1+2) ==
- scripts/grpn/ → scripts/ops/. Customer-specific identifiers
(project IDs, internal hostnames, dev/prod VM IPs, brand names)
replaced with placeholders across code, docs, Terraform, Caddyfile,
OAuth probe, and planning docs. Downstream infra repos that copied
scripts/grpn/agnes-tls-rotate.sh or agnes-auto-upgrade.sh must
update the path.
== Translation ==
- src/repositories/user_groups.py::ensure_system docstring translated
from Czech to English for codebase consistency.
Co-authored-by: Mina Rustamyan <mina@keboola.com>
869 lines
29 KiB
Python
869 lines
29 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
|
|
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 _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.
|
|
resources = [
|
|
{
|
|
"type_key": spec.key.value,
|
|
"type_display": spec.display_name,
|
|
"blocks": spec.list_blocks(conn),
|
|
}
|
|
for spec in RESOURCE_TYPES.values()
|
|
]
|
|
|
|
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
|
|
|
|
|
|
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"]),
|
|
)
|
|
|
|
|
|
@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")
|
|
if g.get("is_system"):
|
|
# System groups are immutable end-to-end: the canonical names
|
|
# 'Admin' / 'Everyone' are referenced from the codebase, and the
|
|
# admin UI hides the Edit button entirely (admin_group_detail.html).
|
|
# Reject any modification at the API layer with a clear message
|
|
# rather than the misleading repository error that surfaced when
|
|
# the previous "rename rejected, description allowed" contract
|
|
# diverged from the repository's "all mutations rejected"
|
|
# behavior.
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="System groups are immutable",
|
|
)
|
|
updates: dict = {}
|
|
if payload.name is not None:
|
|
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 are immutable",
|
|
)
|
|
_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")
|
|
if g.get("is_system"):
|
|
raise HTTPException(status_code=409, detail="Cannot delete a system group")
|
|
# Atomic delete: cascade child rows then the group itself, all in one
|
|
# transaction. group_id has no FK constraints in the schema — the
|
|
# cascade is application-level — so an unhandled mid-flight failure
|
|
# without the BEGIN/COMMIT wrap could leave orphaned member / grant
|
|
# rows that the admin UI cannot clean up (the parent group_id is
|
|
# gone). With the transaction, either all three deletes commit or
|
|
# none do.
|
|
conn.execute("BEGIN TRANSACTION")
|
|
try:
|
|
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),
|
|
):
|
|
if not UserGroupsRepository(conn).get(group_id):
|
|
raise HTTPException(status_code=404, detail="Group not found")
|
|
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")
|
|
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)
|
|
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")
|
|
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")
|
|
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()),
|
|
)
|