agnes-the-ai-analyst/app/api/access.py
minasarustamyan 9de679c714
System plugins (schema v39) + marketplace UX polish + drop legacy pages (#241)
* System plugin tier with mark/unmark fanout (schema v39)

Adds a mandatory plugin tier so admins can pin a small set of curated
plugins into every user's stack from day one. Marking a plugin via the
new toggle on /admin/marketplaces materializes resource_grants for every
group and user_plugin_optouts subscriptions for every user, so the
existing resolver pulls the plugin into every served set without a new
filter layer. Hooks on user-create (Google OAuth, magic-link, admin
POST, scheduler) and group-create propagate the same materialization to
new principals. UI locks: /admin/access disables the checkbox with a
SYSTEM pill; /marketplace cards swap the "In stack" green pill for an
amber "Required" badge with shield icon; the plugin detail install
button reads "Required by your org"; /my-ai-stack toggle is disabled.
Bypass paths return 409 (DELETE /api/admin/grants for system grants,
PUT /api/my-stack/curated/.../{enabled:false}, DELETE
/api/marketplace/curated/.../install). Unmark only flips the flag —
materialized rows persist so admins curate cleanup at their leisure
through the now-unlocked /admin/access checkboxes.

* Marketplace UX polish + drop legacy /store and /my-ai-stack pages

Two-part cleanup post-v39:

(1) Page deletion. /store and /my-ai-stack were already replaced by
/marketplace?tab=flea and /marketplace?tab=my respectively, but the
standalone routes lingered. Hard delete in dev mode — no redirects,
stale bookmarks 404. The /store/new upload wizard, the flea
detail/edit pages, the admin queue, and all /api/store/* +
/api/my-stack endpoints (CLI consumers) stay. Internal hardcoded
hrefs in the upload wizard's Cancel button and the advanced-setup
page repointed to the marketplace tabs.

(2) Detail-page install button rework. The single button that morphed
between "+ Add to my stack" and "✓ In your stack" did not
communicate uninstall affordance. The installed state now renders an
inline white status label *before* a separate red-bordered
"✕ Remove from stack" button on the same row, both at identical
height to avoid layout shift. System plugins keep their locked amber
"✓ Required by your org" pill (no Remove button — API refuses 409).
The post-action hint panel now fires on remove too with the title
flipped to "✓ Removed from your stack" — Claude Code needs the same
/update-agnes-plugins refresh either way.

Also: /admin/marketplaces Details modal "Mark as system" toggle
redesigned. The button was near-invisible (matched neutral row
metadata). It's now a balanced amber-toned chip with shield icon
and a structured confirm modal replacing the native confirm() dialog
that summarizes fanout consequences before commit.

* Move stack-hint inside hero with glass-on-gradient styling

The post-action hint card ("✓ Added to your stack" with the
/update-agnes-plugins recipe) used to live below the hero in
panel-what (gray card on white page body). Clicking add/remove
inserted/removed it between the hero and content, shifting the
panels below — a noticeable scroll jump.

The hint is now anchored inside the hero's top-right corner alongside
the install/remove buttons, both as flex children of an absolutely
positioned .actions container. The card uses a translucent
white-on-glass treatment that adopts the hero's kind color (blue for
plugin, green for skill, purple for agent) without per-kind branching.
Hero is always tall enough (160px photo) to contain the action+hint
stack without overflow, so toggling the hint visibility doesn't grow
the hero or shift body content.

The hero-head grid reserves a third 300px column for the absolute
actions overlay so meta gets the proper 1fr free space instead of
being squeezed by a padding-right hack. Responsive breakpoint at
1100px reflows the actions stack below hero-head when the viewport
isn't wide enough to keep meta + actions side-by-side comfortably.

* Add optional -DataPath bind mount to run-local-dev.ps1

When the operator wants to inspect DuckDB files (system.duckdb, extracts,
marketplaces, store/, …) directly from Windows Explorer, the named volume
inside the Docker Desktop WSL VM isn't reachable. The new -DataPath param
generates a transient compose override that rebinds /data on app, scheduler,
extract (and Caddy's /srv:ro mirror) to a Windows host folder.

Fully additive — when -DataPath is omitted everything behaves exactly as
before: no override file is generated, $composeFiles array is unchanged,
finally cleanup is a no-op. Existing positional invocations
(.\run-local-dev.ps1 up | down | logs) keep binding to $Action because
$DataPath is a named-only parameter with no Position attribute.

The override is written via [System.IO.File]::WriteAllText so the YAML is
BOM-less across PS 5.1 / 7+ — Compose rejects BOM-prefixed YAML on Windows.
The override file is unique per PID and removed in the script's finally
block so concurrent invocations and crashes don't leak files.

* factor mark_system fanout into UserCuratedSubscriptionsRepository

The endpoint imported UserCuratedSubscriptionsRepository, ignored it
(noqa: F841), then duplicated the user-side fanout SQL inline. Adds
fanout_system_for_plugin() symmetric to the existing
fanout_system_for_user() and routes mark_plugin_system through it —
removes the dead import + 14 lines of inline SQL, returns the same
`affected_users` delta count, no behavior change.

* drop customer-specific path from .ps1 example

Per CLAUDE.md vendor-agnostic OSS rule: replaced
C:\\Business\\Groupon\\Agnes\\agnes-data with the generic
C:\\Users\\<you>\\agnes-data placeholder so the docstring
example reads cleanly on any reviewer's box.

* release: 0.48.0 + parallelize Release-workflow pytest

Cuts the release shipped via #228 #230 #231 #232 #233 #234 #236 #237 #238
#239 #240 plus this PR (#241). Major changes:

- System plugin tier (schema v39) — admins mark a plugin mandatory; fans
  out RBAC grants + subscriptions to every existing user/group plus
  hooks for new principals
- BREAKING: removed standalone /store + /my-ai-stack page routes
  (replaced by /marketplace?tab=flea + /marketplace?tab=my)
- Setup-prompt + bootstrap recovery fixes (#240)
- DuckDB CHECKPOINT-on-shutdown + 60s compose grace (#235)
- Marketplace + flea-market UX polish, agnes-metadata.json enrichment

Bonus: switch release.yml test step to `-n auto` (matches ci.yml).
Single-threaded was 15-20 min and frequently the bottleneck on PR
mergeability — now ~6 min.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-10 19:15:41 +00:00

1038 lines
37 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 is_user_admin, 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()
# ---------------------------------------------------------------------------
# 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)),
"created_by": g.get("created_by"),
# Same origin / google-management surface as `/api/admin/groups`
# so the /admin/access sidebar can render the identical pill +
# subtitle treatment without a second source of truth.
"origin": _derive_origin(g),
"is_google_managed": _is_google_managed(g),
"mapped_email": _mapped_email(g),
"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
# 'system' | 'custom' | 'google_sync'. ``custom`` = created by an admin
# via the UI/CLI (no system marker, no google-sync marker on
# ``created_by``). Mapped Admin/Everyone (system row wired to a
# Workspace group via AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL) report
# 'google_sync' here — Workspace is the authoritative source of
# membership for those rows, so the chip should advertise that, not
# the seed mechanism. Unmapped Admin/Everyone stay 'system'.
origin: str = "custom"
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
# When the row is the seeded Admin / Everyone system group AND the
# corresponding env-mapping is configured, this is the upstream
# Workspace group email that funnels members in. The admin UI renders
# it as a subtitle under the canonical name (`Admin / admins@...`)
# so operators can see *which* Workspace group is wired to the system
# row. Null for regular google_sync rows (their email is already in
# `name`) and for unmapped system rows.
mapped_email: Optional[str] = None
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 existing user_groups columns.
- mapped via ``AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL`` → 'google_sync'
(the seed badge is suppressed when the row is wired to Workspace —
Workspace is the authoritative source of membership)
- ``is_system=TRUE`` (otherwise) → 'system'
- ``created_by`` starts with 'system:google''google_sync'
- other ``system:`` prefixed creator → 'system'
- else → 'custom'
(admin-created via UI/CLI — the value is named after the *origin*,
not the creator's role, so it doesn't visually clash with the
seeded `Admin` system row in the chip layer)
"""
is_system = bool(g.get("is_system"))
cb = g.get("created_by") or ""
name = g.get("name") or ""
if is_system:
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP
admin_email = os.environ.get("AGNES_GROUP_ADMIN_EMAIL", "").strip()
everyone_email = os.environ.get("AGNES_GROUP_EVERYONE_EMAIL", "").strip()
if (admin_email and name == SYSTEM_ADMIN_GROUP) or (
everyone_email and name == SYSTEM_EVERYONE_GROUP
):
return "google_sync"
return "system"
if cb.startswith("system:google"):
return "google_sync"
if cb.startswith("system:"):
return "system"
return "custom"
def _mapped_email(g: dict) -> Optional[str]:
"""The Workspace group email that funnels members into a system row.
Only returns a value when the row is the seeded ``Admin`` / ``Everyone``
system group AND the matching env var is configured. Null otherwise —
regular google_sync rows already carry the email in ``name``, and
unmapped system rows have nothing to show.
"""
if not g.get("is_system"):
return None
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP
name = g.get("name")
if name == SYSTEM_ADMIN_GROUP:
v = os.environ.get("AGNES_GROUP_ADMIN_EMAIL", "").strip()
return v or None
if name == SYSTEM_EVERYONE_GROUP:
v = os.environ.get("AGNES_GROUP_EVERYONE_EMAIL", "").strip()
return v or None
return None
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),
mapped_email=_mapped_email(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)
# Last-admin guard: refuse to remove anyone from the seeded Admin group
# when they are the only active admin — recovery from zero admins
# requires direct DB access. Same protection as delete_user / update_user
# (active=False) in app/api/users.py.
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 is_user_admin(user_id, conn)
and UserRepository(conn).count_admins(active_only=True) <= 1
):
raise HTTPException(
status_code=409,
detail="Cannot remove the last admin — at least one user must remain in the Admin group",
)
# 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")
# v39: refuse to revoke a grant whose underlying plugin is system-marked.
# The mark_system endpoint materializes per-group rows precisely so the
# plugin reaches every user; allowing per-group revoke here would punch
# a hole in the mandatory tier silently. Admin must unmark on
# /admin/marketplaces first, then revoke individual groups.
if existing["resource_type"] == "marketplace_plugin":
rid = existing["resource_id"] or ""
if "/" in rid:
mp_id, plugin_name = rid.split("/", 1)
sys_row = conn.execute(
"SELECT is_system FROM marketplace_plugins "
"WHERE marketplace_id = ? AND name = ?",
[mp_id, plugin_name],
).fetchone()
if sys_row and bool(sys_row[0]):
raise HTTPException(
status_code=409,
detail="cannot_revoke_system_grant",
)
grants.delete(grant_id)
# v24: re-grant of the same plugin must reset every user to the default
# (subscribed). Drop matching subscriptions at the same time we drop the
# grant so state stays consistent — see
# src/repositories/user_curated_subscriptions.py.
optouts_dropped = 0
if existing["resource_type"] == "marketplace_plugin":
rid = existing["resource_id"] or ""
if "/" in rid:
mp_id, plugin_name = rid.split("/", 1)
from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
optouts_dropped = UserCuratedSubscriptionsRepository(
conn
).delete_for_plugin(mp_id, plugin_name)
try:
from app.marketplace_server import packager
packager.invalidate_etag_cache()
except Exception:
pass
_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"],
"optouts_dropped": optouts_dropped,
},
)
# ---------------------------------------------------------------------------
# User-centric views — back the /admin/users/{id} detail page.
# ---------------------------------------------------------------------------
class UserMembershipResponse(BaseModel):
group_id: str
group_name: str
is_system: bool = False
# 'system' | 'custom' | 'google_sync' — same shared helper as
# /api/admin/groups + /api/users so the user detail page colors the
# membership chips identically to the user list and the groups page.
origin: str = "custom"
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,
g.created_by, 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]),
origin=_derive_origin(
{"is_system": bool(r[2]), "name": r[1], "created_by": r[3]}
),
source=r[4],
added_at=str(r[5]) if r[5] else None,
added_by=r[6],
)
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)),
origin=_derive_origin(
{
"is_system": bool(group.get("is_system", False)),
"name": group["name"],
"created_by": group.get("created_by"),
}
),
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 anyone from Admin
when they are the only active admin — recovery from zero admins
requires direct DB access.
"""
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 is_user_admin(user_id, conn)
and UserRepository(conn).count_admins(active_only=True) <= 1
):
raise HTTPException(
status_code=409,
detail="Cannot remove the last admin — at least one user must remain in the Admin group",
)
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. ``is_admin`` reflects the real Admin-group check but
no longer short-circuits the response — admins get the same explicit
grant breakdown as everyone else, so the admin viewing a target user
can see precisely what's been granted via which group rather than a
flat "Full access" pill that hides the wiring.
Note: actual authorization at runtime still gives Admin-group members
god-mode (see ``app.auth.access.is_user_admin``); this endpoint is a
debugging/audit view of the explicit grant graph, not the enforcement
surface.
"""
if not UserRepository(conn).get_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
# 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=is_user_admin(user_id, conn),
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. Admins get the
same explicit grant breakdown as everyone else (no short-circuit) so
the profile page audits the actual grant graph; runtime authorization
still gives Admin god-mode regardless of this list."""
user_id = user["id"]
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=is_user_admin(user_id, conn),
items=list(grouped.values()),
)