feat(admin): users/groups UI polish + SSO lock + v18 migration (#142)

Cuts release 0.24.0.

## Highlights
- SSO-managed accounts read-only for password / delete operations (UI + API). New `is_sso_user` flag derived from group memberships.
- Admin/Everyone system rows show `google_sync` chip + Workspace email subtitle when env-mapped.
- Origin pill vocabulary unified across `/admin/groups`, `/admin/access`, `/admin/users`, `/admin/users/{id}`, `/profile` (Admin yellow, Everyone gray, google_sync green, custom purple).
- Effective-access readout no longer short-circuits for admin users — always renders per-resource breakdown.
- Schema migration v18 drops stranded non-google memberships in env-mapped Admin/Everyone groups (cleans up v13's blanket Everyone backfill).

## Devin findings addressed
- _is_sso_user requires source='google_sync' on system-group branches (so v13 system_seed memberships in env-mapped Everyone don't lock out the admin).
- POST add-to-group returns correct origin via _derive_origin (matching GET).
- 8 customer-specific token instances (groupon.com / foundryai) replaced with vendor-neutral placeholders across templates, tests, and CHANGELOG.
- deriveDisplayName name-skip for canonical "Admin"/"Everyone" so an overlapping AGNES_GOOGLE_GROUP_PREFIX doesn't mangle the chip text.

See CHANGELOG [0.24.0] for full notes.
This commit is contained in:
minasarustamyan 2026-04-30 15:16:04 +02:00 committed by GitHub
parent f3d252f17d
commit fb1573766a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2025 additions and 249 deletions

View file

@ -10,8 +10,38 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
## [0.24.0] — 2026-04-30
### Changed
- **Effective-access readout no longer short-circuits for admin users on `/admin/users/{id}` and `/profile`.** Both `GET /api/admin/users/{id}/effective-access` and `GET /api/me/effective-access` previously returned `is_admin=true, items=[]` when the target was in the Admin group, and the UI rendered a flat "Full access via Admin" gold pill — which hid the underlying grant graph. Now both endpoints always run the JOIN, return the explicit per-resource breakdown, and surface `is_admin` only as informational metadata on the response. The UI drops the special pill on both surfaces and renders the same per-resource table everyone else sees. Authorization at runtime still gives Admin god-mode regardless of this list (see `app.auth.access.is_user_admin`); this is purely an audit/debug surface for admins to see *which* Admin-group grants exist via *which* sibling groups.
- **`/profile` group memberships use the same color-coded chip vocabulary as the rest of the admin surface.** Each membership renders as a colored `.group-chip` (Admin yellow, Everyone gray, google_sync green, custom purple) with the same name-shortening rule (`grp_acme_legal@workspace.example.com` → `Legal`, full email on hover via `title`). The Status row in the Account card was removed — same admin signal already appears as the Admin chip in Group memberships, so the pill was redundant. Server-side: the `/profile` route now projects `origin` and `display_name` per membership (computed via the shared `_derive_origin` helper + the `AGNES_GOOGLE_GROUP_PREFIX` strip), so the Jinja template stays env-lookup-free.
- **`/admin/users/{id}` polish: header `Admin` pill removed, "Add to group" dropdown filters out google-managed groups, whole user-cell on the list page is one anchor.** Header pill was redundant — the Group memberships section already shows the Admin chip with the canonical yellow color. The dropdown now skips `is_google_managed` rows (both `created_by='system:google-sync'` and the env-mapped Admin/Everyone) so admins don't see options the API would 409 on anyway. On `/admin/users` the avatar + name + email block became a single `<a class="user-cell">` linking to `/admin/users/{id}` so the entire info area lights up on hover, not just one line; the dedicated `Detail` action button stays for explicit affordance.
- **`/admin/users/{id}` Group memberships table renders chips with the same color + name-shortening rules as the user list.** The Group cell is now a `<a class="group-chip">` colored by `is-admin` (yellow) / `is-everyone` (gray) / `is-google_sync` (green) / `is-custom` (purple) and links through to `/admin/groups/{group_id}`. Google-sync chip text shortens via `deriveDisplayName` (e.g. `grp_acme_legal@workspace.example.com``Legal`); raw email lives on the chip's `title` attribute. Powered by a new `origin` field on `UserMembershipResponse` (`GET /api/admin/users/{id}/memberships`), computed via the same `_derive_origin` helper the rest of the surface uses.
- **`/admin/users` membership chips are color-coded by group origin and shorten Workspace-email names to a friendly form, so a row tells the same story as `/admin/groups` at a glance.** Colors: Admin → yellow, Everyone → gray, other google-synced groups → green, admin-created custom groups → purple. Name match (`Admin` / `Everyone`) takes precedence over origin so an env-mapped Admin/Everyone row (whose API origin is `google_sync`) keeps its canonical color. The chip text for google_sync groups runs through the same `deriveDisplayName` helper used on `/admin/groups`: `grp_acme_legal@workspace.example.com` renders as `Legal` (prefix stripped via `AGNES_GOOGLE_GROUP_PREFIX`, capitalized), and the raw Workspace email goes into the chip's `title` attribute for hover reveal. Custom / Admin / Everyone chip text stays raw — `deriveDisplayName` would over-capitalize names like `data-team`. To support this, `GroupBrief` on `GET /api/users` now carries the same `origin` field as `/api/admin/groups`, computed via the shared `_derive_origin` helper. Replaces the v12-era 2-color layout (yellow Admin, gray for any other system row, blue for everything else, full email always shown) which gave no signal about whether a chip came from Workspace or a manual admin grant and overflowed the cell on long Workspace emails.
- **`/admin/access` sidebar + right-pane title now use the same group display rules as `/admin/groups`.** Each sidebar row renders a multi-color origin pill (`google_sync` / `system` / `custom`) instead of the legacy yellow inline `system` tag, and a monospace subtitle below the name showing the Workspace email when the row is wired to one (`mapped_email` for env-mapped Admin/Everyone, the raw `name` for user-created google-sync groups). The right-pane card head adopts the same treatment when a group is selected. To support this, `GET /api/admin/access-overview` now includes `origin`, `mapped_email`, `is_google_managed`, and `created_by` per group — single source of truth shared with the `GET /api/admin/groups` endpoint via the same helpers (`_derive_origin`, `_mapped_email`, `_is_google_managed`).
- **`GET /api/admin/groups` and `GET /api/admin/access-overview` rename the `origin` value `"admin"``"custom"`.** The label is named after the row's *origin* (admin-created via UI/CLI), not the creator's role, so the pill doesn't visually clash with the seeded `Admin` system group's name. CSS class `.origin-admin``.origin-custom`; same purple swatch. No external consumers (CLI never reads the field). Pydantic default and JS fallbacks updated in lock-step. The previous workaround — a frontend `originLabel()` helper that mapped `admin → Custom` at render time — is gone now that the API value already reads correctly.
- **`/admin/groups` switches the seeded Admin / Everyone rows to a `google_sync` chip and shows the Workspace email as a subtitle when env-mapped.** Previously the mapped Admin row showed `Admin` as the big title with `Admin` repeated as the subtitle (the `deriveDisplayName` strip-and-capitalize chain produced no useful output for a literal canonical name) and a yellow `system` chip — which buried the fact that membership is actually owned by Workspace. Now: when `AGNES_GROUP_ADMIN_EMAIL` / `AGNES_GROUP_EVERYONE_EMAIL` is configured, `GET /api/admin/groups` reports `origin='google_sync'` for the matching seeded row (the system badge is suppressed; Workspace is the authoritative source of membership) and the new `mapped_email` field carries the configured Workspace email. The list view shows the canonical name as the big title with the Workspace email as a monospace subtitle (`Admin / admins@workspace.test`) and a green `google_sync` chip. The `/admin/groups/{id}` detail header mirrors the same — name as `<h1>`, `mapped_email` as the `gd-title-email` subtitle. Unmapped Admin / Everyone rows stay `origin='system'` with no subtitle. Regular google_sync rows (whose `name` is already the Workspace email) keep the existing `deriveDisplayName` rewrite behavior with `mapped_email=null`.
- **SSO-managed accounts are read-only for password / delete operations, both in UI and at the API layer.** Detection is in `app.api.users._is_sso_user`: a user counts as SSO-managed if they belong to any group whose `created_by = 'system:google-sync'`, OR they belong to the seeded `Admin` system group while `AGNES_GROUP_ADMIN_EMAIL` is set, OR the seeded `Everyone` system group while `AGNES_GROUP_EVERYONE_EMAIL` is set. Users with no groups, or only admin-created custom groups, are unaffected. The flag surfaces as `is_sso_user: bool` on every `/api/users` and `/api/users/{id}` response. UI: the `/admin/users` row actions and the `/admin/users/{id}` Account section suppress the Reset / Set pwd / Delete buttons for those rows. Server: `POST /api/users/{id}/reset-password`, `POST /api/users/{id}/set-password`, and `DELETE /api/users/{id}` now return **409** with `detail: "User is managed by an external SSO provider; …"` for SSO targets — so a curl-savvy admin who bypasses the UI guard still cannot reset / set / wipe a Google Workspace account locally. Deactivate stays available so admins can gate access locally even when the upstream account is managed elsewhere. Name is provider-neutral so a future provider (Cloudflare Access, Okta, …) plugs into the same flag without churning the API.
### Fixed
- **`scripts/ops/agnes-tls-rotate.sh` now chowns `/data/state/certs/` to UID 999 (the `agnes` user inside the app image) on every run.** Previously the script only `mkdir -p`'d and `chmod 700`'d the directory, leaving ownership to whoever happened to create it first — root when systemd fired the timer before docker-compose-up, or UID 999 when the container's volume init touched it first. Race-dependent. When root won, the resulting `drwx------ root:root` directory was unreadable by the UID-999 container, `_read_agnes_ca_pem()` returned `None`, and the `/install` setup prompt silently dropped the cross-platform TLS trust block (Step 0 from #137) — operators on those VMs ended up with no client-side cert bootstrap and a broken `claude plugin marketplace add` against the self-signed host. The chown is unconditional + idempotent (`|| true` for hosts where the numeric GID can't be set), so re-running the timer self-heals existing VMs without manual `chown` on the operator's part. Files inside the directory keep their existing modes — `fullchain.pem` is `0644` (world-readable, so root- or 999-owned both work for the agnes container) and `privkey.pem` is `0600` (only Caddy reads it, and Caddy's container runs as root).
- **`_is_sso_user` no longer treats `system_seed` / `admin` memberships in env-mapped Admin/Everyone as SSO (Devin BUG_0002 on PR #142).** Without checking `user_group_members.source`, the v13 migration's blanket Everyone backfill (`source='system_seed'`) flipped every existing local user to `is_sso_user=True` the moment an operator set `AGNES_GROUP_EVERYONE_EMAIL` — locking the admin out of password reset / set / delete on accounts the IdP doesn't actually own (the admin couldn't even un-flag them via "remove from Everyone" because `_guard_google_managed` blocks manual removal once env-mapped). The system-group branches (Admin / Everyone) now additionally require `source='google_sync'`. The created_by branch (`system:google-sync` groups) is unchanged because those groups only exist because of Google sync — every membership in them is IdP-owned regardless of `source`. The v18 migration in this PR also retroactively cleans up the offending `system_seed` rows in env-mapped Admin/Everyone groups; the source-check fix is the runtime guard that keeps future writes safe.
- **`POST /api/admin/users/{id}/memberships` now returns the correct `origin` for the new membership (Devin review round 1 on PR #142).** The handler constructed `UserMembershipResponse` without setting `origin`, so the model default `"custom"` was returned regardless of the target group — while the matching GET endpoint computes `origin` via the shared `_derive_origin` helper. Adding a user to a system group (Admin / Everyone) over POST now reports `origin="system"` (or `"google_sync"` when env-mapped), matching GET. The UI re-fetches after add so visible impact was zero, but any non-UI API consumer got the wrong value.
- **Schema migration v18: drop stranded non-google memberships in google-managed groups (Devin review round 1 on PR #142, partial response).** v13's `_v12_to_v13_finalize` unconditionally backfilled every existing user into Everyone with `source='system_seed'` under the original "Everyone = all users" semantics. The platform design has since shifted: when `AGNES_GROUP_EVERYONE_EMAIL` / `AGNES_GROUP_ADMIN_EMAIL` is configured, those system rows mirror a Workspace group exclusively, and only Google sync should write into them. The leftover `system_seed` rows (a) misrepresent the membership model and (b) cause `_is_sso_user` to flag local users as SSO-managed, blocking password-reset / set / delete via `_reject_if_sso`. v18 deletes: (1) non-google memberships in auto-created `created_by='system:google-sync'` groups (unconditional — those groups only exist because Workspace materialized them), (2) `system_seed` rows in Everyone **only when `AGNES_GROUP_EVERYONE_EMAIL` is set**, (3) `system_seed` rows in Admin **only when `AGNES_GROUP_ADMIN_EMAIL` is set** AND `added_by NOT IN ('app.main:seed_admin', 'auth.bootstrap')` so the bootstrap admin always survives. Env-conditional branches mean a non-Google deployment keeps its local Admin / Everyone semantics intact (system_seed rows there are legitimate, not cruft). Runtime safeguards against future writes from the legacy `users.role` apparatus are tracked in #144.
### Removed
- **`GET /api/admin/group-suggestions` endpoint and the "Suggested from your Google account" picker on the `/admin/groups` create modal.** The picker fetched the calling admin's Workspace groups (via Cloud Identity), filtered out ones already registered as `user_groups` rows, and offered them as one-click name pre-fills. Replaced by the OAuth callback's automatic `google_sync` group materialization (every Workspace group the user belongs to that matches `AGNES_GOOGLE_GROUP_PREFIX` is auto-created on login) — the manual picker became redundant. Cloud Identity calls in the request path are gone with it.
## [0.23.0] — 2026-04-30
@ -40,7 +70,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
- **Side-effect behavior change for unusual cross-project setups in `/api/v2/sample`.** Issue #134. The FROM-clause project for `/sample` is now `data_source.bigquery.project` (the data project) rather than the conflated `billing_project` value — the Phase 1 fix passed `billing_project` (when set) as both the billing target AND the FROM-clause project. Deployments where `billing_project ≠ project` AND the queried table physically lives in `billing_project` (an unusual setup contradicting the documented config semantics) must move the table to the data project or unset `billing_project`. No effect on the standard cross-project setup (table in data project, jobs billed to billing project).
- `scripts/smoke-test.sh`: assertion 8 now hits `/api/admin/registry` (the current admin tables endpoint). The old `/api/admin/tables` URL was renamed long ago and the smoke test was returning 404 on every run — it only surfaced as a deploy failure when the full release pipeline first triggered the rollback path on the post-#137 deploy (run 25151878647). Same stale URL was also fixed in `CLAUDE.md`, `README.md`, and `dev_docs/server.md` — the routes now correctly point at `POST /api/admin/register-table` (create) and `PUT /api/admin/registry/{id}` (update).
- `.github/workflows/release.yml` smoke-test job: added `Log in to GHCR` step. The auto-rollback's `docker push :stable` was hitting `unauthenticated: User cannot be authenticated with the token provided` because the smoke-test job had no GHCR login of its own. Result: a failed deploy left `:stable` pointing at the broken image. The rollback step also got an explicit `GH_TOKEN` env, and the workflow's top-level `permissions` block gained `issues: write`, so its `gh issue create` call actually creates the alert issue (was silently swallowed by the `|| echo` fallback because of both the missing env var AND the missing scope).
## [0.21.0] — 2026-04-30
### Internal

View file

@ -416,7 +416,7 @@ Module sets `lifecycle { ignore_changes = [metadata_startup_script] }` on `googl
## Key Implementation Details
### DuckDB Schema (src/db.py)
- Schema v13 with auto-migration v1→…→v13 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`, v8/v9 added the legacy internal_roles/role-grants tables, v10 added `view_ownership` for cross-connector view-name collision detection (issue #81 Group C), v11 added marketplace_registry + marketplace_plugins + user_groups + plugin_access, v12 added users.groups JSON + user_groups.is_system, **v13 replaces internal_roles/group_mappings/user_role_grants/plugin_access with user_group_members + resource_grants and drops users.groups JSON** — see CHANGELOG and docs/RBAC.md)
- Schema v18 with auto-migration v1→…→v18 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`, v8/v9 added the legacy internal_roles/role-grants tables, v10 added `view_ownership` for cross-connector view-name collision detection (issue #81 Group C), v11 added marketplace_registry + marketplace_plugins + user_groups + plugin_access, v12 added users.groups JSON + user_groups.is_system, **v13 replaces internal_roles/group_mappings/user_role_grants/plugin_access with user_group_members + resource_grants and drops users.groups JSON**, v14 adds FK constraints on user_group_members + resource_grants after orphan cleanup, v15 adds knowledge_items context-engineering columns + contradictions + session_extraction_state, v16 adds verification_evidence, v17 adds knowledge_item_relations, **v18 drops stranded non-google memberships from google-managed groups** — see CHANGELOG and docs/RBAC.md)
- `table_registry`: id, name, source_type, bucket, source_table, query_mode, sync_schedule, etc.
- `sync_state`, `sync_history`: track extraction progress
- `users`, `dataset_permissions`, `audit_log`: auth + RBAC

View file

@ -150,41 +150,6 @@ async def get_resource_types(
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
# ---------------------------------------------------------------------------
@ -219,6 +184,13 @@ async def access_overview(
"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"]),
})
@ -261,7 +233,14 @@ class GroupResponse(BaseModel):
name: str
description: Optional[str] = None
is_system: bool = False
origin: str = "admin" # 'system' | 'admin' | 'google_sync'
# '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
@ -269,6 +248,14 @@ class GroupResponse(BaseModel):
# 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):
@ -282,24 +269,57 @@ class UpdateGroupRequest(BaseModel):
def _derive_origin(g: dict) -> str:
"""Project a 3-value origin tag from the existing user_groups columns.
"""Project a 3-value origin tag from 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.
- 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)
"""
if g.get("is_system"):
return "system"
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 "admin"
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(
@ -318,6 +338,7 @@ def _group_to_response(
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),
)
@ -703,6 +724,10 @@ 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
@ -731,7 +756,7 @@ async def list_user_memberships(
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
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 = ?
@ -743,9 +768,12 @@ async def list_user_memberships(
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],
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
]
@ -791,6 +819,13 @@ async def add_user_to_group(
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"),
@ -858,16 +893,21 @@ async def user_effective_access(
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).
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")
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
@ -893,7 +933,7 @@ async def user_effective_access(
grouped[key].via_groups.append({"group_id": gid, "group_name": gname})
return EffectiveAccessResponse(
is_admin=False,
is_admin=is_user_admin(user_id, conn),
items=list(grouped.values()),
)
@ -916,11 +956,12 @@ async def my_effective_access(
):
"""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."""
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"]
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,
@ -943,6 +984,6 @@ async def my_effective_access(
grouped[key].via_groups.append({"group_id": gid, "group_name": gname})
return EffectiveAccessResponse(
is_admin=False,
is_admin=is_user_admin(user_id, conn),
items=list(grouped.values()),
)

View file

@ -1,5 +1,6 @@
"""User management endpoints (#11)."""
import os
import uuid
from datetime import datetime, timezone
from typing import Optional, List
@ -11,7 +12,7 @@ from argon2 import PasswordHasher
from app.auth.access import is_user_admin, require_admin
from app.auth.dependencies import _get_db
from src.db import SYSTEM_ADMIN_GROUP
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP
from src.repositories.users import UserRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.audit import AuditRepository
@ -61,6 +62,11 @@ class GroupBrief(BaseModel):
id: str
name: str
is_system: bool = False
# Same 'system' | 'custom' | 'google_sync' tag as /api/admin/groups —
# the user list renders membership chips with color-coded backgrounds
# (Admin yellow, Everyone gray, google_sync green, custom purple) and
# needs the origin to pick the right swatch.
origin: str = "custom"
class UserResponse(BaseModel):
@ -69,6 +75,7 @@ class UserResponse(BaseModel):
name: Optional[str]
role: str
is_admin: bool = False
is_sso_user: bool = False
groups: List[GroupBrief] = []
active: bool = True
created_at: Optional[str]
@ -89,17 +96,92 @@ def _user_groups(user_id: str, conn: duckdb.DuckDBPyConnection) -> List[GroupBri
"""Groups the user is a member of, sorted with system groups first.
Inlined into ``/api/users`` responses so the admin list view can show
membership chips per row without an N+1 fetch.
membership chips per row without an N+1 fetch. ``origin`` is computed
via the same ``_derive_origin`` helper /api/admin/groups uses, so
chip colors stay in lock-step across the two surfaces.
"""
from app.api.access import _derive_origin
rows = conn.execute(
"""SELECT g.id, g.name, g.is_system
"""SELECT g.id, g.name, g.is_system, g.created_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 [GroupBrief(id=r[0], name=r[1], is_system=bool(r[2])) for r in rows]
return [
GroupBrief(
id=r[0],
name=r[1],
is_system=bool(r[2]),
origin=_derive_origin(
{"is_system": bool(r[2]), "name": r[1], "created_by": r[3]}
),
)
for r in rows
]
def _is_sso_user(user_id: str, conn: duckdb.DuckDBPyConnection) -> bool:
"""Whether the user is sourced from an external SSO provider.
Today the only SSO provider is Google Workspace, but the name is kept
generic so a future provider (Cloudflare Access, Okta, ) can plug into
the same flag without churning the API surface. The admin UI hides the
password-reset / set-password / delete affordances when this is True
those accounts are managed upstream and editing them here would either
be no-ops (password) or get reverted on next sync (delete).
A user counts as SSO-managed if they are a member of any group where:
1. ``user_groups.created_by = 'system:google-sync'`` the OAuth
callback auto-created this group from a Workspace claim, OR
2. the group is the seeded ``Admin`` system row AND
``AGNES_GROUP_ADMIN_EMAIL`` is set (env-mapped to a Workspace
admin group), OR
3. the group is the seeded ``Everyone`` system row AND
``AGNES_GROUP_EVERYONE_EMAIL`` is set (env-mapped to a Workspace
everyone group).
Users with no groups, or only admin-created custom groups, are NOT
SSO users local accounts are unaffected.
Env values are read per-request so operators flipping the mapping
don't have to restart the process.
"""
rows = conn.execute(
"""SELECT g.name, g.is_system, g.created_by, m.source
FROM user_group_members m
JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = ?""",
[user_id],
).fetchall()
if not rows:
return False
admin_mapped = bool(os.environ.get("AGNES_GROUP_ADMIN_EMAIL", "").strip())
everyone_mapped = bool(os.environ.get("AGNES_GROUP_EVERYONE_EMAIL", "").strip())
for name, is_system, created_by, source in rows:
if created_by == "system:google-sync":
# google-sync groups are always SSO-managed regardless of how
# the individual membership was created — the group itself
# only exists because of Google sync.
return True
# System-group branches (Admin / Everyone): the group accepts
# memberships from MULTIPLE sources (system_seed for v13 backfill,
# admin for manual adds, google_sync from OAuth callback). The
# group being env-mapped to Workspace tells us SSO is *configured*,
# but only memberships whose source is 'google_sync' are actually
# owned by the upstream IdP. system_seed / admin memberships in
# the same group are local-only and must stay locally manageable.
# (Devin BUG_0002 on PR #142: without this check, the v13 migration's
# blanket Everyone backfill flips every local user to SSO the moment
# AGNES_GROUP_EVERYONE_EMAIL is set, locking admins out of password
# reset / delete on accounts the IdP doesn't actually own.)
if is_system and name == SYSTEM_ADMIN_GROUP and admin_mapped and source == "google_sync":
return True
if is_system and name == SYSTEM_EVERYONE_GROUP and everyone_mapped and source == "google_sync":
return True
return False
def _to_response(
@ -115,6 +197,7 @@ def _to_response(
name=u.get("name"),
role=_resolve_role(u, conn),
is_admin=any(g.name == SYSTEM_ADMIN_GROUP for g in groups),
is_sso_user=_is_sso_user(u["id"], conn),
groups=groups,
active=bool(u.get("active", True)),
created_at=str(u.get("created_at", "")),
@ -261,6 +344,25 @@ async def update_user(
return _to_response(repo.get_by_id(user_id), conn)
_SSO_LOCKED_DETAIL = (
"User is managed by an external SSO provider; "
"this operation must be performed in the upstream system"
)
def _reject_if_sso(target_id: str, conn: duckdb.DuckDBPyConnection) -> None:
"""409 if the target is SSO-managed.
The admin UI hides the password / delete affordances for SSO users, but
the UI-only guard is bypassable by anyone who calls /api/users/...
directly with a valid admin token. This is the server-side enforcement
that backs the UI: admins cannot reset / set / wipe a Google-Workspace
account through Agnes those mutations belong upstream.
"""
if _is_sso_user(target_id, conn):
raise HTTPException(status_code=409, detail=_SSO_LOCKED_DETAIL)
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: str,
@ -274,6 +376,7 @@ async def delete_user(
raise HTTPException(status_code=404, detail="User not found")
if target["id"] == user["id"]:
raise HTTPException(status_code=409, detail="Cannot delete yourself")
_reject_if_sso(target["id"], conn)
if is_user_admin(target["id"], conn) and repo.count_admins(active_only=True) <= 1:
raise HTTPException(status_code=409, detail="Cannot delete the last active admin")
repo.delete(user_id)
@ -293,6 +396,7 @@ async def reset_password(
target = repo.get_by_id(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
_reject_if_sso(target["id"], conn)
token = secrets.token_urlsafe(32)
repo.update(
id=user_id,
@ -326,6 +430,7 @@ async def set_password(
target = repo.get_by_id(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
_reject_if_sso(target["id"], conn)
ph = PasswordHasher()
repo.update(id=user_id, password_hash=ph.hash(payload.password))
_audit(conn, user["id"], "user.set_password", user_id, {"email": target["email"]})

View file

@ -854,16 +854,17 @@ async def admin_group_detail_page(
"""Single-group detail page — header + members table. Resource grants
live on /admin/grants (deep-linked from here)."""
from src.repositories.user_groups import UserGroupsRepository
from app.api.access import _is_google_managed
from app.api.access import _is_google_managed, _mapped_email
g = UserGroupsRepository(conn).get(group_id)
if not g:
raise HTTPException(status_code=404, detail="Group not found")
# Project a `is_google_managed` flag onto the dict the template reads,
# using the same rule the API enforces (created_by='system:google-sync'
# OR system + env mapping). Doing it server-side keeps the template
# free of env-var lookups and Python-side logic duplication.
# Project the same flags the API derives so the template avoids env
# lookups: `is_google_managed` (created_by='system:google-sync' OR
# system + env mapping) and `mapped_email` (the Workspace group
# funneling members into the Admin/Everyone system row, when set).
g_view = dict(g)
g_view["is_google_managed"] = _is_google_managed(g)
g_view["mapped_email"] = _mapped_email(g)
ctx = _build_context(request, user=user, target_group=g_view)
return templates.TemplateResponse(request, "admin_group_detail.html", ctx)
@ -944,7 +945,8 @@ async def profile_page(
were added by an admin, by Google sync, or seeded at deploy).
"""
rows = conn.execute(
"""SELECT g.id, g.name, g.description, g.is_system, m.source, m.added_at
"""SELECT g.id, g.name, g.description, g.is_system, g.created_by,
m.source, m.added_at
FROM user_group_members m
JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = ?
@ -953,6 +955,25 @@ async def profile_page(
).fetchall()
cols = [d[0] for d in conn.description]
memberships = [dict(zip(cols, r)) for r in rows]
# Project the same chip metadata the /admin/users/{id} page derives:
# origin (single source of truth via app.api.access._derive_origin),
# plus a display_name that shortens raw Workspace emails for
# google_sync rows (`grp_acme_legal@workspace.example.com` → `Legal`). The
# Jinja template just renders these without env lookups.
from app.api.access import _derive_origin
prefix = os.environ.get("AGNES_GOOGLE_GROUP_PREFIX", "").strip().lower()
for m in memberships:
m["origin"] = _derive_origin(m)
if m["origin"] == "google_sync" and m["name"] and m["name"] not in ("Admin", "Everyone"):
local = m["name"].split("@", 1)[0]
if prefix and local.lower().startswith(prefix):
local = local[len(prefix):]
local = local.lstrip("_- \t")
if not local:
local = m["name"].split("@", 1)[0]
m["display_name"] = local[:1].upper() + local[1:]
else:
m["display_name"] = m["name"]
ctx = _build_context(
request,

View file

@ -47,14 +47,25 @@
background: #cbd5e1; flex-shrink: 0;
}
.group-item.is-active .group-dot { background: var(--primary, #6366f1); }
.group-item.is-system .group-dot { background: #f59e0b; }
.group-meta { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.group-name { font-size: 13px; font-weight: 500; color: var(--text-primary, #111827); }
.group-name .system-tag {
font-size: 9px; padding: 1px 5px; border-radius: 3px;
background: #fef3c7; color: #92400e; margin-left: 6px;
text-transform: uppercase; font-weight: 600; letter-spacing: 0.4px;
.group-name-sub {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 10px; color: var(--text-secondary, #6b7280);
margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.origin-chip {
display: inline-block;
padding: 1px 6px; border-radius: 999px;
font-size: 9px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
margin-left: 6px; vertical-align: middle;
}
.origin-system { background: #fef3c7; color: #92400e; }
.origin-custom { background: #ede9fe; color: #6d28d9; }
.origin-google_sync { background: #dcfce7; color: #166534; }
.group-desc {
font-size: 11px; color: var(--text-secondary, #6b7280);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@ -337,8 +348,11 @@
<!-- RIGHT: Group detail with tabs -->
<div class="ax-card" id="detail-card">
<div class="ax-card-head">
<h3 id="detail-title">Select a group</h3>
<span id="detail-sub" style="font-size:11px; color: var(--text-secondary, #6b7280);"></span>
<div style="display:flex; flex-direction:column; gap:2px; min-width:0; flex:1;">
<h3 id="detail-title" style="display:flex; align-items:center;">Select a group</h3>
<span id="detail-mapped" class="group-name-sub" style="display:none;"></span>
</div>
<span id="detail-sub" style="font-size:11px; color: var(--text-secondary, #6b7280); flex-shrink:0;"></span>
</div>
<!-- Resources panel (no tab strip — this page is grants-only) -->
@ -367,9 +381,24 @@
const OVERVIEW_API = "/api/admin/access-overview";
const GROUPS_API = "/api/admin/groups";
const GRANTS_API = "/api/admin/grants";
// Server-injected so the sidebar can derive a friendly display name from
// google-sync groups whose `name` is the raw Workspace email — same trick
// /admin/groups uses; keeping the surface identical here.
const GOOGLE_GROUP_PREFIX = {{ config.AGNES_GOOGLE_GROUP_PREFIX | tojson }};
function esc(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
function deriveDisplayName(fullEmail) {
if (!fullEmail) return "";
const local = String(fullEmail).split("@")[0] || String(fullEmail);
const px = (GOOGLE_GROUP_PREFIX || "").toLowerCase();
let s = local;
if (px && s.toLowerCase().startsWith(px)) s = s.slice(px.length);
s = s.replace(/^[_\-\s]+/, "");
if (!s) return local;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function toast(msg, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
@ -432,12 +461,15 @@ async function selectGroup(gid) {
function renderDetail() {
const title = document.getElementById("detail-title");
const mapped = document.getElementById("detail-mapped");
const sub = document.getElementById("detail-sub");
const empty = document.getElementById("detail-empty");
const resourcesPane = document.querySelector('[data-pane="resources"]');
if (!state.activeGroupId) {
title.textContent = "Select a group";
mapped.style.display = "none";
mapped.textContent = "";
sub.textContent = "";
resourcesPane.style.display = "none";
empty.style.display = "block";
@ -447,7 +479,32 @@ function renderDetail() {
resourcesPane.style.display = "block";
const group = state.groups.find(g => g.id === state.activeGroupId);
title.textContent = group ? group.name : "Group";
if (group) {
// Mirror the sidebar's title rules: mapped_email present → big name
// stays canonical, email goes to the subtitle line. Plain google-sync
// group → derive a friendly name and put the raw email below.
let bigName = group.name;
let subtitleText = "";
if (group.mapped_email) {
subtitleText = group.mapped_email;
} else if (group.is_google_managed) {
bigName = deriveDisplayName(group.name);
subtitleText = group.name;
}
const origin = group.origin || (group.is_system ? "system" : "custom");
title.innerHTML = `<span>${esc(bigName)}</span><span class="origin-chip origin-${esc(origin)}">${esc(origin.replace("_"," "))}</span>`;
if (subtitleText) {
mapped.textContent = subtitleText;
mapped.style.display = "block";
} else {
mapped.style.display = "none";
mapped.textContent = "";
}
} else {
title.textContent = "Group";
mapped.style.display = "none";
mapped.textContent = "";
}
const grantedCount = state.grants.filter(g => g.group_id === state.activeGroupId).length;
sub.textContent = `${grantedCount} resource${grantedCount === 1 ? "" : "s"} granted`;
@ -467,10 +524,29 @@ function renderGroups() {
for (const g of state.groups) {
const li = document.createElement("li");
li.className = "group-item"
+ (state.activeGroupId === g.id ? " is-active" : "")
+ (g.is_system ? " is-system" : "");
+ (state.activeGroupId === g.id ? " is-active" : "");
li.dataset.id = g.id;
const sysTag = g.is_system ? '<span class="system-tag">system</span>' : '';
// Origin pill — single chip mirroring /admin/groups treatment. Mapped
// Admin/Everyone report origin='google_sync' so the chip color matches
// their actual source of truth (Workspace), not the seed mechanism.
const origin = g.origin || (g.is_system ? "system" : "custom");
const originPill = `<span class="origin-chip origin-${esc(origin)}">${esc(origin.replace("_"," "))}</span>`;
// Big-title / subtitle rules — same logic as the /admin/groups list:
// - mapped_email present → big = canonical name, subtitle = mapped_email
// - google_managed user-created group → big = derived friendly name,
// subtitle = full Workspace email stored as `name`
// - everything else → big = name, subtitle = description (or none)
let bigName, subtitle;
if (g.mapped_email) {
bigName = esc(g.name);
subtitle = `<span class="group-name-sub">${esc(g.mapped_email)}</span>`;
} else if (g.is_google_managed) {
bigName = esc(deriveDisplayName(g.name));
subtitle = `<span class="group-name-sub">${esc(g.name)}</span>`;
} else {
bigName = esc(g.name);
subtitle = g.description ? `<span class="group-desc">${esc(g.description)}</span>` : "";
}
// Compute live from state.grants — g.grant_count is a server-side
// snapshot from /access-overview that goes stale as soon as the user
// toggles a checkbox; reading it here would clobber refreshCounts()
@ -479,8 +555,8 @@ function renderGroups() {
li.innerHTML = `
<span class="group-dot"></span>
<div class="group-meta">
<span class="group-name">${esc(g.name)}${sysTag}</span>
${g.description ? `<span class="group-desc">${esc(g.description)}</span>` : ""}
<span class="group-name">${bigName}${originPill}</span>
${subtitle}
</div>
<span class="group-count" title="Resources granted to this group">${liveCount}</span>
`;

View file

@ -42,7 +42,7 @@
vertical-align: middle; margin-left: 8px;
}
.origin-system { background: #fef3c7; color: #92400e; }
.origin-admin { background: #ede9fe; color: #6d28d9; }
.origin-custom { background: #ede9fe; color: #6d28d9; }
.origin-google_sync { background: #dcfce7; color: #166534; }
.gd-section {
@ -154,19 +154,31 @@
data-group-name="{{ target_group.name }}"
data-is-system="{{ 'true' if target_group.is_system else 'false' }}"
data-is-google-managed="{{ 'true' if target_group.is_google_managed else 'false' }}"
data-mapped-email="{{ target_group.mapped_email or '' }}"
data-google-prefix="{{ config.AGNES_GOOGLE_GROUP_PREFIX }}">
<div class="gd-header">
<a href="/admin/groups" class="gd-back">← Back to groups</a>
<div class="gd-title-block">
<h1 class="gd-title" id="header-title">
{% if target_group.is_google_managed %}
{# Big-title logic mirrors the list view:
- mapped_email set (Admin/Everyone wired to Workspace) → keep
canonical name as the big title and put the Workspace email
below as `gd-title-email`.
- is_google_managed without mapped_email → derived display name
via JS (deriveDisplayName), full email below.
- everything else → the row's name. #}
{% if target_group.mapped_email %}
{{ target_group.name }}
{% elif target_group.is_google_managed %}
<span id="header-display-name">{{ target_group.name }}</span>
{% else %}
{{ target_group.name }}
{% endif %}
<span id="origin-chip" class="origin-chip" style="display:none;"></span>
</h1>
{% if target_group.is_google_managed %}
{% if target_group.mapped_email %}
<span class="gd-title-email">{{ target_group.mapped_email }}</span>
{% elif target_group.is_google_managed %}
<span class="gd-title-email">{{ target_group.name }}</span>
{% endif %}
<div class="gd-subtitle" id="header-sub">
@ -251,6 +263,7 @@ const root = document.querySelector(".gd-page");
const GROUP_ID = root.dataset.groupId;
const IS_SYSTEM = root.dataset.isSystem === "true";
const IS_GOOGLE_MANAGED = root.dataset.isGoogleManaged === "true";
const MAPPED_EMAIL = root.dataset.mappedEmail || "";
const GOOGLE_GROUP_PREFIX = root.dataset.googlePrefix || "";
const GROUP_API = `/api/admin/groups/${encodeURIComponent(GROUP_ID)}`;
const MEMBERS_API = `${GROUP_API}/members`;
@ -266,7 +279,11 @@ function deriveDisplayName(fullEmail) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
if (IS_GOOGLE_MANAGED) {
// When a system row carries mapped_email, the canonical name (Admin /
// Everyone) is the right big title — skip the email-strip rewrite. The
// rewrite only applies to user-created google_sync groups whose `name`
// is the raw Workspace email.
if (IS_GOOGLE_MANAGED && !MAPPED_EMAIL) {
const dn = document.getElementById("header-display-name");
if (dn) dn.textContent = deriveDisplayName(root.dataset.groupName);
}
@ -290,8 +307,9 @@ async function loadGroup() {
if (!r.ok) return;
groupState = await r.json();
const chip = document.getElementById("origin-chip");
chip.textContent = (groupState.origin || "admin").replace("_", " ");
chip.className = "origin-chip origin-" + (groupState.origin || "admin");
const origin = groupState.origin || "custom";
chip.textContent = origin.replace("_", " ");
chip.className = "origin-chip origin-" + origin;
chip.style.display = "inline-block";
document.getElementById("res-count").textContent = groupState.grant_count || 0;
if (!groupState.grant_count) {

View file

@ -68,7 +68,7 @@
text-transform: uppercase; letter-spacing: 0.4px;
}
.origin-system { background: #fef3c7; color: #92400e; }
.origin-admin { background: #ede9fe; color: #6d28d9; }
.origin-custom { background: #ede9fe; color: #6d28d9; }
.origin-google_sync { background: #dcfce7; color: #166534; }
.gp-actions { display: flex; gap: 6px; justify-content: flex-end; }
@ -126,30 +126,6 @@
}
.modal-card textarea { min-height: 60px; resize: vertical; }
/* Suggested groups from Google Workspace (admin's own membership) */
.suggest-block { display: none; margin: 0 0 4px; }
.suggest-block.is-visible { display: block; }
.suggest-label {
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280); margin: 4px 0 8px;
}
.suggest-help { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
.suggest-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.suggest-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;
font-size: 12px; cursor: pointer; font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.05s;
}
.suggest-chip:hover { background: #dcfce7; border-color: #86efac; }
.suggest-chip:active { transform: translateY(1px); }
.suggest-chip .src-tag {
font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.4px;
background: rgba(22,101,52,0.12); color: #166534;
padding: 1px 6px; border-radius: 3px; font-weight: 600;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
@ -216,14 +192,6 @@
<h3 id="group-modal-title">New group</h3>
<p class="sub">Pick a name that identifies a logical audience (e.g. <code>data-team</code>, <code>engineers</code>).</p>
<div class="suggest-block" id="suggest-block">
<div class="suggest-label">
Suggested from your Google account
<span class="suggest-help">Click a chip to use the name.</span>
</div>
<div class="suggest-chips" id="suggest-chips"></div>
</div>
<label for="group-name">Name</label>
<input id="group-name" type="text" autocomplete="off" placeholder="data-team">
<label for="group-desc">Description (optional)</label>
@ -331,7 +299,7 @@ function render() {
const tr = document.createElement("tr");
tr.dataset.id = g.id;
tr.style.cursor = "pointer";
const origin = g.origin || "admin";
const origin = g.origin || "custom";
// Read-only when the row is owned by Google sync OR a non-mapped system
// group (Admin/Everyone canonical name without the env mapping — those
// still cannot be renamed/deleted, but they accept admin-managed members).
@ -343,13 +311,22 @@ function render() {
? `<span style="color:#9ca3af;font-size:11px">read-only</span>`
: `<button class="icon-btn" data-action="edit">Edit</button>
<button class="icon-btn danger" data-action="delete">Delete</button>`;
// For Google-managed rows, the canonical `name` is the full Workspace
// email — render a derived "Finance" big label with the email as a
// monospace subtitle. For everything else, name stays one line.
const nameCell = isGoogleManaged
? `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(deriveDisplayName(g.name))}</a>
<span class="gp-name-sub">${esc(g.name)}</span>`
: `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a>`;
// Subtitle rules for the name cell:
// - mapped_email present (Admin/Everyone wired to a Workspace group)
// → big = canonical name ("Admin"), subtitle = the Workspace email
// - google-managed user-created group → big = derived friendly name
// ("Finance"), subtitle = full Workspace email stored as `name`
// - everything else → single-line name, no subtitle
let nameCell;
if (g.mapped_email) {
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a>
<span class="gp-name-sub">${esc(g.mapped_email)}</span>`;
} else if (isGoogleManaged) {
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(deriveDisplayName(g.name))}</a>
<span class="gp-name-sub">${esc(g.name)}</span>`;
} else {
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a>`;
}
tr.innerHTML = `
<td>${nameCell}</td>
<td><span class="gp-desc">${esc(g.description || "")}</span></td>
@ -377,33 +354,6 @@ function render() {
document.getElementById("search").addEventListener("input", render);
async function loadGroupSuggestions() {
const block = document.getElementById("suggest-block");
const wrap = document.getElementById("suggest-chips");
block.classList.remove("is-visible");
wrap.innerHTML = "";
try {
const r = await fetch("/api/admin/group-suggestions", { credentials: "include" });
if (!r.ok) return;
const items = await r.json();
if (!Array.isArray(items) || items.length === 0) return;
for (const it of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "suggest-chip";
btn.title = `Use "${it.name}" as the group name`;
btn.innerHTML = `${esc(it.name)} <span class="src-tag">Google</span>`;
btn.addEventListener("click", () => {
const input = document.getElementById("group-name");
input.value = it.name;
input.focus();
});
wrap.appendChild(btn);
}
block.classList.add("is-visible");
} catch (_) { /* fail-soft — suggestions are a hint, not required */ }
}
document.getElementById("open-create-btn").addEventListener("click", () => {
editingId = null;
document.getElementById("group-modal-title").textContent = "New group";
@ -411,7 +361,6 @@ document.getElementById("open-create-btn").addEventListener("click", () => {
document.getElementById("group-desc").value = "";
openModal("group-modal");
setTimeout(() => document.getElementById("group-name").focus(), 50);
loadGroupSuggestions();
});
function openEdit(g) {
@ -419,9 +368,6 @@ function openEdit(g) {
document.getElementById("group-modal-title").textContent = "Edit group";
document.getElementById("group-name").value = g.name;
document.getElementById("group-desc").value = g.description || "";
// Suggestions are only useful when creating; hide on edit.
document.getElementById("suggest-block").classList.remove("is-visible");
document.getElementById("suggest-chips").innerHTML = "";
openModal("group-modal");
}

View file

@ -248,10 +248,10 @@
<p class="sub">Register a git repository. It will be cloned into <code>$DATA_DIR/marketplaces/&lt;slug&gt;/</code> and fast-forwarded every night at 03:00 UTC.</p>
<label for="new-name">Display name</label>
<input id="new-name" type="text" placeholder="e.g. Groupon FoundryAI" required autocomplete="off">
<input id="new-name" type="text" placeholder="e.g. Acme Marketplace" required autocomplete="off">
<label for="new-slug">Slug (directory name)</label>
<input id="new-slug" type="text" placeholder="e.g. foundryai" required autocomplete="off" pattern="[a-z0-9][a-z0-9_-]{0,63}">
<input id="new-slug" type="text" placeholder="e.g. acme" required autocomplete="off" pattern="[a-z0-9][a-z0-9_-]{0,63}">
<div class="help">Lower-case alphanumerics, hyphens, and underscores. 1-64 chars, must start with a letter or digit.</div>
<label for="new-url">Git URL (https://)</label>

View file

@ -30,7 +30,6 @@
}
.ud-status-pill.active { background: #dcfce7; color: #166534; }
.ud-status-pill.inactive { background: #fee2e2; color: #991b1b; }
.ud-status-pill.admin { background: #fef3c7; color: #92400e; margin-left: 6px; }
.ud-section {
background: var(--surface, #fff);
@ -94,6 +93,22 @@
}
.group-link:hover { color: var(--primary, #4338ca); text-decoration: underline; }
/* Chip styling for the group cell — same color vocabulary as
/admin/users membership chips. Built as <a> so a click on the
chip lands the admin on the group's detail page. */
.group-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 12px; font-weight: 500;
text-decoration: none;
background: #ede9fe; color: #6d28d9; /* default = custom (purple) */
}
.group-chip:hover { filter: brightness(0.97); }
.group-chip.is-admin { background: #fef3c7; color: #92400e; font-weight: 600; }
.group-chip.is-everyone { background: #f3f4f6; color: #4b5563; }
.group-chip.is-google_sync { background: #dcfce7; color: #166534; }
.group-chip.is-custom { background: #ede9fe; color: #6d28d9; }
.add-member-row {
padding: 12px 18px;
background: var(--border-light, #f9fafb);
@ -117,17 +132,6 @@
padding: 24px 18px; text-align: center;
color: var(--text-secondary, #6b7280); font-size: 13px;
}
.ea-admin-pill {
margin: 18px;
padding: 16px;
background: linear-gradient(135deg, #fef3c7, #fde68a);
border: 1px solid #f59e0b;
border-radius: 8px;
display: flex; align-items: center; gap: 12px;
}
.ea-admin-pill .icon { font-size: 22px; }
.ea-admin-pill .text { font-size: 13px; color: #78350f; line-height: 1.4; }
.ea-admin-pill strong { color: #422006; }
.ea-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.ea-table thead th {
text-align: left; padding: 10px 18px;
@ -178,7 +182,6 @@
{{ target_user.name or target_user.email }}
<span id="status-pill" class="ud-status-pill"
style="display:none; vertical-align: middle; margin-left: 8px;"></span>
<span id="admin-pill" class="ud-status-pill admin" style="display:none; vertical-align:middle;">Admin</span>
</h1>
<div class="ud-subtitle">{{ target_user.email }} · id {{ target_user.id[:8] }}…</div>
</div>
@ -244,11 +247,30 @@ const USER_ID = document.querySelector("[data-user-id]").dataset.userId;
const USER_API = `/api/users/${encodeURIComponent(USER_ID)}`;
const MEMBERSHIPS_API = `/api/admin/users/${encodeURIComponent(USER_ID)}/memberships`;
const EFFECTIVE_API = `/api/admin/users/${encodeURIComponent(USER_ID)}/effective-access`;
// Server-injected env: empty string = no prefix configured. Same shape as
// /admin/groups + /admin/users — used to shorten google-sync chip text
// (`grp_acme_legal@workspace.example.com` → `Legal`) so the membership cell
// stays readable.
const GOOGLE_GROUP_PREFIX = {{ config.AGNES_GOOGLE_GROUP_PREFIX | tojson }};
const GROUPS_API = "/api/admin/groups";
function esc(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
function fmtDate(s) { return s ? String(s).slice(0, 16).replace("T", " ") : "-"; }
// Same logic as /admin/groups + /admin/users: only safe to call on
// google_sync rows whose `name` is the raw Workspace email; calling it
// on a custom group name like "data-team" would over-capitalize it.
function deriveDisplayName(fullEmail) {
if (!fullEmail) return "";
const local = String(fullEmail).split("@")[0] || String(fullEmail);
const px = (GOOGLE_GROUP_PREFIX || "").toLowerCase();
let s = local;
if (px && s.toLowerCase().startsWith(px)) s = s.slice(px.length);
s = s.replace(/^[_\-\s]+/, "");
if (!s) return local;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function toast(msg, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
@ -286,14 +308,19 @@ function renderHeader() {
pill.className = "ud-status-pill active";
pill.style.display = "inline-block";
}
const adminPill = document.getElementById("admin-pill");
const isAdmin = memberships.some(m => m.group_name === "Admin");
adminPill.style.display = isAdmin ? "inline-block" : "none";
}
function renderAccountStatus() {
const node = document.getElementById("account-status-text");
const toggleBtn = document.getElementById("toggle-active-btn");
// SSO-managed accounts (Google Workspace today) hide password / delete
// affordances — those operations are no-ops or get reverted by the next
// sync. Deactivate stays so admins can still gate access locally.
const resetBtn = document.getElementById("reset-pw-btn");
const deleteBtn = document.getElementById("delete-user-btn");
const sso = !!(userState && userState.is_sso_user);
if (resetBtn) resetBtn.style.display = sso ? "none" : "";
if (deleteBtn) deleteBtn.style.display = sso ? "none" : "";
if (!userState) { node.textContent = "—"; return; }
if (userState.active) {
node.innerHTML = `<strong>${esc(userState.email)}</strong> is active.`;
@ -313,7 +340,6 @@ async function loadMemberships() {
}
memberships = await r.json();
renderMemberships();
renderHeader(); // admin pill depends on memberships
}
function renderMemberships() {
@ -343,8 +369,19 @@ function renderMemberships() {
})[m.source] || m.source;
const addedFragment = m.added_at
? `<span class="added">· ${esc(fmtDate(m.added_at))}</span>` : "";
// Same chip color + name-shortening rules as the user list:
// - name match (Admin / Everyone) wins over origin so env-mapped
// system rows keep their canonical color
// - google_sync chip text runs through deriveDisplayName ("Legal"
// instead of "grp_acme_legal@workspace.example.com"), full email in
// the title attribute for hover reveal
const cls = m.group_name === "Admin" ? "is-admin"
: m.group_name === "Everyone" ? "is-everyone"
: `is-${m.origin || "custom"}`;
const display = (m.origin === "google_sync" && m.group_name !== "Admin" && m.group_name !== "Everyone")
? deriveDisplayName(m.group_name) : m.group_name;
tr.innerHTML = `
<td><a class="group-link" href="/admin/groups">${esc(m.group_name)}</a></td>
<td><a class="group-chip ${cls}" href="/admin/groups/${encodeURIComponent(m.group_id)}" title="${esc(m.group_name)}">${esc(display)}</a></td>
<td><span class="source-meta"><span class="label">${esc(sourceLabel)}</span>${addedFragment}</span></td>
<td style="text-align:right">${removable}</td>
`;
@ -367,7 +404,13 @@ function refreshGroupDropdown() {
const memberOf = new Set(memberships.map(m => m.group_id));
sel.innerHTML = '<option value="">— Pick a group —</option>';
for (const g of allGroups) {
if (memberOf.has(g.id)) continue; // already a member, hide
if (memberOf.has(g.id)) continue; // already a member, hide
if (g.is_google_managed) continue; // membership owned by Workspace —
// includes mapped Admin / Everyone when
// AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL is
// set. The API would 409 on POST
// anyway; hiding the option keeps the
// picker honest about what's grantable.
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name + (g.is_system ? " (system)" : "");
@ -426,18 +469,11 @@ async function loadEffectiveAccess() {
}
const data = await r.json();
if (data.is_admin) {
content.innerHTML = `
<div class="ea-admin-pill">
<span class="icon">🔑</span>
<span class="text">
<strong>Full access via the Admin group.</strong><br>
This user can read/write everything regardless of explicit grants.
</span>
</div>`;
return;
}
// We deliberately don't short-circuit on `data.is_admin` anymore —
// admin users get the same explicit grant breakdown as everyone else
// so the operator can audit which Admin-group grants exist (and via
// which sibling groups). Authorization at runtime still grants admin
// god-mode regardless of this list (see `app.auth.access`).
if (!data.items || data.items.length === 0) {
content.innerHTML = `<div class="ea-empty">User has no resource access yet. Add them to a group with grants.</div>`;
return;

View file

@ -44,7 +44,18 @@
.users-table tbody tr.is-deactivated { opacity: 0.55; }
.users-table tbody tr:hover { background: var(--border-light, #fafafa); }
.user-cell { display: flex; align-items: center; gap: 10px; }
/* Whole user-info cell is the click target for the detail page —
anchor wraps avatar + name + email so the entire block lights up
on hover, not just one line. Defaults to inheriting text color so
the cell doesn't render in browser link blue; .name turns primary
blue on hover as the affordance cue. */
.user-cell {
display: flex; align-items: center; gap: 10px;
color: inherit; text-decoration: none;
cursor: pointer;
}
.user-cell:hover .user-meta .name,
.user-cell.no-name:hover .email { color: var(--primary, #4338ca); }
.user-avatar {
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
@ -57,7 +68,14 @@
.user-cell.no-name .name { display: none; }
.user-cell.no-name .email { font-size: 13px; color: var(--text-primary, #111827); font-weight: 500; }
/* Group chip cloud — admin pill is highlighted, system groups have a subtle border */
/* Membership chips — colors match the /admin/groups origin pills so a
user's group cell tells the same story at a glance:
Admin → yellow (seeded admin role)
Everyone → gray (seeded default group, low signal)
google_sync → green (synced from Workspace, not editable here)
custom (default) → purple (admin-created via UI/CLI)
The Admin/Everyone names take precedence over origin so a row mapped
via AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL keeps its canonical color. */
.group-chips {
display: flex; flex-wrap: wrap; gap: 4px;
max-width: 320px;
@ -66,15 +84,13 @@
display: inline-block;
padding: 3px 8px; border-radius: 999px;
font-size: 11px; font-weight: 500;
background: #e0e7ff; color: #3730a3;
white-space: nowrap;
background: #ede9fe; color: #6d28d9; /* default = custom (purple) */
}
.group-chip.is-admin {
background: #fef3c7; color: #92400e; font-weight: 600;
}
.group-chip.is-system {
background: #f3f4f6; color: #4b5563;
}
.group-chip.is-admin { background: #fef3c7; color: #92400e; font-weight: 600; }
.group-chip.is-everyone { background: #f3f4f6; color: #4b5563; }
.group-chip.is-google_sync { background: #dcfce7; color: #166534; }
.group-chip.is-custom { background: #ede9fe; color: #6d28d9; }
.group-chips-empty {
color: var(--text-secondary, #9ca3af);
font-size: 11px; font-style: italic;
@ -306,12 +322,34 @@
<script>
const API = "/api/users";
// Server-injected env: empty string = no prefix configured. Used to
// shorten google-sync group chips (e.g. "grp_acme_legal@workspace.example.com"
// → "Legal") so the membership cell stays readable. Same shape used on
// /admin/groups; keeping the surface identical.
const GOOGLE_GROUP_PREFIX = {{ config.AGNES_GOOGLE_GROUP_PREFIX | tojson }};
function esc(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
// Strip @domain, then the configured prefix (case-insensitive), then any
// leading separators, then capitalize. Falls back to the raw local-part
// when the chain leaves nothing meaningful — better than rendering an
// empty chip. Only safe to apply to google_sync rows (whose `name` is
// the raw Workspace email); calling this on a custom group name like
// "data-team" would over-capitalize it.
function deriveDisplayName(fullEmail) {
if (!fullEmail) return "";
const local = String(fullEmail).split("@")[0] || String(fullEmail);
const px = (GOOGLE_GROUP_PREFIX || "").toLowerCase();
let s = local;
if (px && s.toLowerCase().startsWith(px)) s = s.slice(px.length);
s = s.replace(/^[_\-\s]+/, "");
if (!s) return local;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function fmtDate(s) { return s ? s.slice(0, 16).replace("T", " ") : "—"; }
function initials(u) {
const src = (u.name || u.email || "?").trim();
@ -412,20 +450,30 @@ function renderUsers() {
? `<span class="group-chips-empty">— no groups —</span>`
: `<div class="group-chips">${
groups.map(g => {
const cls = g.name === "Admin" ? "is-admin"
: g.is_system ? "is-system" : "";
return `<span class="group-chip ${cls}">${esc(g.name)}</span>`;
// Name match wins over origin so the env-mapped Admin/Everyone
// (whose origin is 'google_sync') keep their canonical color.
const cls = g.name === "Admin" ? "is-admin"
: g.name === "Everyone" ? "is-everyone"
: `is-${g.origin || "custom"}`;
// Shorten google-sync chip text: the API stores the full
// Workspace email as the group's `name`, but the chip cell
// needs to fit ~5 chips per row. Hover reveals the full
// email via `title`. Custom / Admin / Everyone keep the raw
// name (deriveDisplayName would over-capitalize "data-team").
const display = (g.origin === "google_sync" && g.name !== "Admin" && g.name !== "Everyone")
? deriveDisplayName(g.name) : g.name;
return `<span class="group-chip ${cls}" title="${esc(g.name)}">${esc(display)}</span>`;
}).join("")
}</div>`;
tr.innerHTML = `
<td>
<div class="user-cell ${hasName ? "" : "no-name"}">
<a class="user-cell ${hasName ? "" : "no-name"}" href="/admin/users/${encodeURIComponent(u.id)}">
<div class="user-avatar" style="background:${avatarColor(u.email || u.id)}">${esc(initials(u))}</div>
<div class="user-meta">
<span class="name">${esc(u.name || "")}</span>
<span class="email">${esc(u.email)}</span>
</div>
</div>
</a>
</td>
<td>${chipsHtml}</td>
<td>
@ -440,9 +488,11 @@ function renderUsers() {
<div class="row-actions">
<a class="icon-btn" href="/admin/users/${encodeURIComponent(u.id)}" title="Open detail view: memberships + effective access">Detail</a>
<a class="icon-btn" href="/admin/tokens?user=${encodeURIComponent(u.email || "")}" title="View this user's personal access tokens">Tokens</a>
${u.is_sso_user ? "" : `
<button class="icon-btn" data-action="reset-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Generate a reset link (user picks their own new password)">Reset</button>
<button class="icon-btn" data-action="set-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Assign a password directly">Set pwd</button>
<button class="icon-btn danger" data-action="delete-user" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}">Delete</button>
`}
</div>
</td>`;
tbody.appendChild(tr);

View file

@ -84,20 +84,6 @@
word-break: break-word;
}
.role-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
text-transform: capitalize;
letter-spacing: 0.2px;
background: rgba(0, 115, 209, 0.10);
color: #0073D1;
border: 1px solid rgba(0, 115, 209, 0.25);
}
.groups-list {
list-style: none;
padding: 0;
@ -129,6 +115,19 @@
word-break: break-all;
flex: 1 1 auto;
}
/* Same chip vocabulary as /admin/users + /admin/users/{id}. Name match
wins over origin so an env-mapped Admin/Everyone keeps the canonical
color even when their server-side origin is google_sync. */
.group-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 12px; font-weight: 500;
background: #ede9fe; color: #6d28d9; /* default = custom (purple) */
}
.group-chip.is-admin { background: #fef3c7; color: #92400e; font-weight: 600; }
.group-chip.is-everyone { background: #f3f4f6; color: #4b5563; }
.group-chip.is-google_sync { background: #dcfce7; color: #166534; }
.group-chip.is-custom { background: #ede9fe; color: #6d28d9; }
.empty-state {
padding: 20px 0 4px;
@ -251,14 +250,6 @@
<span class="v">{{ user.email or "—" }}</span>
<span class="k">Name</span>
<span class="v">{{ user.name or "—" }}</span>
<span class="k">Status</span>
<span class="v">
{% if is_admin %}
<span class="role-pill is-core">Admin</span>
{% else %}
User
{% endif %}
</span>
</div>
<div class="tokens-link-row">
Manage personal access tokens at <a href="/tokens">/tokens</a>.
@ -270,11 +261,20 @@
{% if memberships and memberships | length > 0 %}
<ul class="groups-list" role="list">
{% for m in memberships %}
{# Color rule mirrors /admin/users + /admin/users/{id}: name match
on the seeded Admin/Everyone wins over origin so an env-mapped
system row keeps its canonical color. Hover over the chip
reveals the full Workspace email for shortened google_sync
display names. #}
{% if m.name == "Admin" %}
{% set chip_class = "is-admin" %}
{% elif m.name == "Everyone" %}
{% set chip_class = "is-everyone" %}
{% else %}
{% set chip_class = "is-" ~ (m.origin or "custom") %}
{% endif %}
<li class="group-row" role="listitem">
<span class="group-name">
{{ m.name }}
{% if m.is_system %}<span class="role-chip is-core" style="margin-left:6px;">system</span>{% endif %}
</span>
<span class="group-chip {{ chip_class }}" title="{{ m.name }}">{{ m.display_name or m.name }}</span>
<span class="group-id">via {{ m.source }}{% if m.added_at %} · added {{ m.added_at|string|truncate(16, true, '') }}{% endif %}</span>
</li>
{% endfor %}
@ -310,13 +310,11 @@
</div>`;
return;
}
if (data.is_admin) {
host.innerHTML = `<div class="empty-state" style="background:linear-gradient(135deg,#fef3c7,#fde68a);border:1px solid #f59e0b;color:#78350f;">
<div class="empty-title" style="color:#422006;">🔑 Full access via Admin</div>
<div>You can read and write everything in Agnes regardless of explicit grants.</div>
</div>`;
return;
}
// Admins get the same explicit grant breakdown as everyone else —
// the runtime still gives Admin god-mode at authorization time, but
// this readout audits the actual grant graph (which Admin-group
// grants exist, which sibling groups carry them) instead of a flat
// "Full access" pill that hid the wiring.
if (!data.items || data.items.length === 0) {
host.innerHTML = `<div class="empty-state">
<div class="empty-title">No resource access yet</div>

View file

@ -1,6 +1,6 @@
[project]
name = "agnes-the-ai-analyst"
version = "0.23.0"
version = "0.24.0"
description = "Agnes — AI Data Analyst platform for AI analytical systems"
requires-python = ">=3.11,<3.14"
license = "MIT"

View file

@ -39,7 +39,7 @@ def _maybe_instrument(con, db_tag: str):
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
SCHEMA_VERSION = 17
SCHEMA_VERSION = 18
_SYSTEM_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_version (
@ -930,6 +930,10 @@ _V16_TO_V17_MIGRATIONS = [
]
# v17 -> v18: see _v17_to_v18_finalize. Env-conditional, so kept as a Python
# helper rather than a flat SQL list (the migrate-ladder calls it directly).
# Core role seed data — single source of truth. Used by both _seed_core_roles
# (idempotent insert) and the v8→v9 backfill. Order matters: lowest privilege
# first so implies references resolve cleanly when expand_implies does BFS.
@ -1305,6 +1309,69 @@ def _v13_to_v14_finalize(conn: duckdb.DuckDBPyConnection) -> None:
raise
def _v17_to_v18_finalize(conn: duckdb.DuckDBPyConnection) -> None:
"""Drop stranded non-google memberships from google-managed groups.
Two classes of cruft:
1. Auto-created google_sync groups (``created_by='system:google-sync'``)
only exist because Google sync materialized them on a Workspace claim.
Anyone in such a group whose membership is NOT ``source='google_sync'``
got there by an obsolete code path; drop them unconditionally the
Workspace state is the source of truth for these rows.
2. Seeded ``Admin`` / ``Everyone`` rows are env-conditional. When
``AGNES_GROUP_ADMIN_EMAIL`` / ``AGNES_GROUP_EVERYONE_EMAIL`` is set
the row mirrors a Workspace group exclusively, and v13's
``system:v13-backfill`` writes (one row per existing user into
Everyone, one per ``core.admin``-grantee into Admin) are stranded
cruft that ``_is_sso_user`` mis-classifies as SSO membership. Drop
those ``system_seed`` rows. The bootstrap admin's Admin membership
is preserved by the ``added_by`` allow-list it must survive so
the operator never loses console access.
When the env mapping is absent, those system rows are LOCAL groups,
and ``system:v13-backfill`` rows are legitimate (the user's
core.admin grant was migrated into Admin-group membership, and
every user is auto-broadcast into Everyone). Touching them would
remove admin privileges or empty Everyone so the env-conditional
branches are skipped.
Env vars are read at migration time via os.environ operators
flipping the mapping later don't need a fresh migration.
"""
# Non-google memberships in auto-created google_sync groups: always cruft.
conn.execute(
"""DELETE FROM user_group_members
WHERE source != 'google_sync'
AND group_id IN (
SELECT id FROM user_groups
WHERE created_by = 'system:google-sync'
)"""
)
if os.environ.get("AGNES_GROUP_EVERYONE_EMAIL", "").strip():
conn.execute(
"""DELETE FROM user_group_members
WHERE source = 'system_seed'
AND group_id IN (
SELECT id FROM user_groups
WHERE name = 'Everyone' AND is_system
)"""
)
if os.environ.get("AGNES_GROUP_ADMIN_EMAIL", "").strip():
conn.execute(
"""DELETE FROM user_group_members
WHERE source = 'system_seed'
AND added_by NOT IN ('app.main:seed_admin', 'auth.bootstrap')
AND group_id IN (
SELECT id FROM user_groups
WHERE name = 'Admin' AND is_system
)"""
)
def _seed_core_roles(conn: duckdb.DuckDBPyConnection) -> None:
"""Idempotently insert/refresh the four core.* hierarchy roles.
@ -1561,6 +1628,8 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
if current < 17:
for sql in _V16_TO_V17_MIGRATIONS:
conn.execute(sql)
if current < 18:
_v17_to_v18_finalize(conn)
conn.execute(
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
[SCHEMA_VERSION],

View file

@ -41,11 +41,14 @@ class TestSchemaV17:
assert "knowledge_item_relations" in tables
conn.close()
def test_schema_version_is_17(self, tmp_path, monkeypatch):
def test_schema_version_at_target(self, tmp_path, monkeypatch):
"""Fresh install lands at the current SCHEMA_VERSION target. Not
pinned to v17 the relations table was introduced there but the
schema has moved on (v18 dropped stranded google memberships)."""
conn = _fresh_db(tmp_path, monkeypatch)
from src.db import SCHEMA_VERSION, get_schema_version
assert SCHEMA_VERSION == 17
assert get_schema_version(conn) == 17
assert get_schema_version(conn) == SCHEMA_VERSION
assert SCHEMA_VERSION >= 17, "knowledge_item_relations was added at v17"
conn.close()
def test_relations_table_columns(self, tmp_path, monkeypatch):

View file

@ -1465,3 +1465,261 @@ class TestV13ToV14Migration:
assert get_schema_version(conn) == SCHEMA_VERSION
finally:
conn.close()
class TestV17ToV18Migration:
"""Cleanup of stranded non-google memberships in google-managed groups.
v13's _v12_to_v13_finalize unconditionally backfilled every existing user
into Everyone with source='system_seed'. The platform design has since
shifted: Workspace-mapped Admin/Everyone groups receive memberships
exclusively from Google sync. v17v18 deletes the cruft, preserving the
bootstrap admin (the only legitimate non-google occupant of Admin).
Env-conditional: only fires for env-mapped Admin/Everyone leaves the
rows intact when those system groups are LOCAL (no Workspace mapping)
so a non-Google deployment keeps its admin / broadcast semantics.
"""
def test_v17_to_v18_drops_stranded_and_preserves_bootstrap(
self, tmp_path, monkeypatch,
):
import uuid
from src.db import close_system_db, get_schema_version, get_system_db
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv(
"JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!",
)
# Env mapping must be active for the Admin/Everyone branches to fire.
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
close_system_db()
# Open at full target version, then plant stranded rows + a synthetic
# google_sync group, then reset schema_version to 17 so the v17→v18
# migration runs on the next open.
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = 'Admin' AND is_system",
).fetchone()[0]
everyone_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = 'Everyone' AND is_system",
).fetchone()[0]
gsync_gid = str(uuid.uuid4())
conn.execute(
"INSERT INTO user_groups (id, name, is_system, created_by) "
"VALUES (?, ?, FALSE, 'system:google-sync')",
[gsync_gid, "legal@workspace.test"],
)
bootstrap_uid = str(uuid.uuid4())
stranded_admin_uid = str(uuid.uuid4())
stranded_everyone_uid = str(uuid.uuid4())
stranded_gsync_uid = str(uuid.uuid4())
real_gsync_uid = str(uuid.uuid4())
for uid, email in [
(bootstrap_uid, "boot@x"),
(stranded_admin_uid, "ghost-admin@x"),
(stranded_everyone_uid, "ghost-everyone@x"),
(stranded_gsync_uid, "ghost-gsync@x"),
(real_gsync_uid, "real@workspace.test"),
]:
conn.execute(
"INSERT INTO users (id, email, name, role) "
"VALUES (?, ?, ?, 'analyst')",
[uid, email, email],
)
# 1. Bootstrap admin in Admin → KEEP (added_by allow-list).
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'system_seed', 'auth.bootstrap')",
[bootstrap_uid, admin_gid],
)
# 2. Stranded system_seed in Admin → DROP.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'system_seed', 'migration:v13')",
[stranded_admin_uid, admin_gid],
)
# 3. Stranded system_seed in Everyone → DROP.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'system_seed', 'migration:v13')",
[stranded_everyone_uid, everyone_gid],
)
# 4. Non-google source in google-sync group → DROP.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'admin', 'admin@x')",
[stranded_gsync_uid, gsync_gid],
)
# 5. Real google_sync in google-sync group → KEEP.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'google_sync', 'system:google-sync')",
[real_gsync_uid, gsync_gid],
)
conn.execute("UPDATE schema_version SET version = 17")
finally:
conn.close()
close_system_db()
# Re-open → migration v17→v18 runs.
conn = get_system_db()
try:
from src.db import SCHEMA_VERSION
assert get_schema_version(conn) == SCHEMA_VERSION
def _has_membership(uid: str) -> bool:
return bool(
conn.execute(
"SELECT 1 FROM user_group_members WHERE user_id = ?",
[uid],
).fetchone(),
)
# Bootstrap admin survives.
assert _has_membership(bootstrap_uid), \
"bootstrap admin must survive cleanup"
# Real google_sync member survives.
assert _has_membership(real_gsync_uid), \
"google_sync members must survive cleanup"
# Stranded rows are gone.
assert not _has_membership(stranded_admin_uid), \
"stranded system_seed in Admin must be dropped"
assert not _has_membership(stranded_everyone_uid), \
"stranded system_seed in Everyone must be dropped"
assert not _has_membership(stranded_gsync_uid), \
"non-google source in google_sync group must be dropped"
finally:
conn.close()
close_system_db()
def test_v17_to_v18_skips_admin_everyone_when_env_unmapped(
self, tmp_path, monkeypatch,
):
"""Without env mapping, Admin/Everyone are LOCAL groups — v18 must
leave their `system_seed` rows intact (those rows represent legitimate
local admins / broadcast membership, not stranded Workspace cruft).
Only the auto-created google_sync branch fires unconditionally.
"""
import uuid
from src.db import close_system_db, get_schema_version, get_system_db
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv(
"JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!",
)
monkeypatch.delenv("AGNES_GROUP_ADMIN_EMAIL", raising=False)
monkeypatch.delenv("AGNES_GROUP_EVERYONE_EMAIL", raising=False)
close_system_db()
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = 'Admin' AND is_system",
).fetchone()[0]
everyone_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = 'Everyone' AND is_system",
).fetchone()[0]
gsync_gid = str(uuid.uuid4())
conn.execute(
"INSERT INTO user_groups (id, name, is_system, created_by) "
"VALUES (?, ?, FALSE, 'system:google-sync')",
[gsync_gid, "legal@workspace.test"],
)
local_admin_uid = str(uuid.uuid4())
local_everyone_uid = str(uuid.uuid4())
stranded_gsync_uid = str(uuid.uuid4())
for uid, email in [
(local_admin_uid, "local-admin@x"),
(local_everyone_uid, "local-user@x"),
(stranded_gsync_uid, "ghost-gsync@x"),
]:
conn.execute(
"INSERT INTO users (id, email, name, role) "
"VALUES (?, ?, ?, 'analyst')",
[uid, email, email],
)
# system_seed rows in unmapped Admin/Everyone — must SURVIVE.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'system_seed', 'system:v13-backfill')",
[local_admin_uid, admin_gid],
)
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'system_seed', 'system:v13-backfill')",
[local_everyone_uid, everyone_gid],
)
# Non-google source in google-sync group — must be DROPPED
# regardless of env state.
conn.execute(
"INSERT INTO user_group_members (user_id, group_id, source, added_by) "
"VALUES (?, ?, 'admin', 'admin@x')",
[stranded_gsync_uid, gsync_gid],
)
conn.execute("UPDATE schema_version SET version = 17")
finally:
conn.close()
close_system_db()
conn = get_system_db()
try:
from src.db import SCHEMA_VERSION
assert get_schema_version(conn) == SCHEMA_VERSION
def _has_membership(uid: str) -> bool:
return bool(
conn.execute(
"SELECT 1 FROM user_group_members WHERE user_id = ?",
[uid],
).fetchone(),
)
assert _has_membership(local_admin_uid), \
"system_seed in unmapped Admin must survive"
assert _has_membership(local_everyone_uid), \
"system_seed in unmapped Everyone must survive"
assert not _has_membership(stranded_gsync_uid), \
"non-google source in google_sync group must drop unconditionally"
finally:
conn.close()
close_system_db()
def test_v17_to_v18_idempotent(self, tmp_path, monkeypatch):
"""Running migrations on an already-v18 DB is a no-op."""
from src.db import close_system_db, get_schema_version, get_system_db
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv(
"JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!",
)
close_system_db()
conn = get_system_db()
try:
from src.db import SCHEMA_VERSION
assert get_schema_version(conn) == SCHEMA_VERSION
finally:
conn.close()
close_system_db()
# Second open should not regress version or error.
conn = get_system_db()
try:
from src.db import SCHEMA_VERSION
assert get_schema_version(conn) == SCHEMA_VERSION
finally:
conn.close()
close_system_db()

View file

@ -0,0 +1,699 @@
"""Tests for /api/admin/groups origin + mapped_email surface.
Covers the admin-UI rule: when AGNES_GROUP_ADMIN_EMAIL /
AGNES_GROUP_EVERYONE_EMAIL map a Workspace group onto the seeded Admin /
Everyone system row, the row carries:
- ``origin = 'google_sync'`` (the seed badge is suppressed
Workspace is the authoritative source for membership)
- ``mapped_email`` = the Workspace group email
so the list / detail templates can render `Admin / admins@workspace.test`
with a green `google_sync` chip instead of `Admin / Admin` with the
yellow system chip. Without the env mapping, the same row stays a plain
`'system'` with no mapped_email.
"""
import tempfile
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
from src.db import close_system_db
close_system_db()
yield tmp
close_system_db()
def _seed_admin():
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
try:
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email="admin@test", name="Admin", role="admin")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
UserGroupMembersRepository(conn).add_member(uid, admin_gid, source="system_seed")
return uid, create_access_token(user_id=uid, email="admin@test", role="admin")
finally:
conn.close()
def _groups_by_name(client: TestClient, token: str) -> dict:
"""Fetch /api/admin/groups, return {name: row} for assertion brevity."""
resp = client.get("/api/admin/groups", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200, resp.text
return {g["name"]: g for g in resp.json()}
def test_admin_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
"""When AGNES_GROUP_ADMIN_EMAIL is set, the seeded Admin row reports
origin='google_sync' the system badge is suppressed because
Workspace is the authoritative source of membership for this row."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
admin = groups["Admin"]
assert admin["origin"] == "google_sync"
assert admin["mapped_email"] == "admins@workspace.test"
assert admin["is_google_managed"] is True
def test_everyone_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
everyone = groups["Everyone"]
assert everyone["origin"] == "google_sync"
assert everyone["mapped_email"] == "everyone@workspace.test"
assert everyone["is_google_managed"] is True
def test_admin_row_is_plain_system_without_env_mapping(fresh_db):
"""Without AGNES_GROUP_ADMIN_EMAIL set, the seeded Admin row is just a
regular system row system chip, no mapped_email."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
admin = groups["Admin"]
assert admin["origin"] == "system"
assert admin["mapped_email"] is None
assert admin["is_google_managed"] is False
def test_user_created_google_sync_group_origin(fresh_db):
"""A Workspace-derived group whose `name` is the email itself reports
origin='google_sync' and has null mapped_email the email is already
the canonical name."""
from app.main import app
from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository
conn = get_system_db()
try:
UserGroupsRepository(conn).create(
name="finance@workspace.test",
created_by="system:google-sync",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
g = groups["finance@workspace.test"]
assert g["origin"] == "google_sync"
assert g["mapped_email"] is None
assert g["is_google_managed"] is True
def test_admin_created_custom_group_origin(fresh_db):
"""Admin-created groups report origin='custom' — the value is named
after the *origin* of the row, not the creator's role, so the chip
doesn't visually clash with the seeded `Admin` system group."""
from app.main import app
from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository
conn = get_system_db()
try:
UserGroupsRepository(conn).create(name="data-team", created_by="admin@test")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
g = groups["data-team"]
assert g["origin"] == "custom"
assert g["mapped_email"] is None
assert g["is_google_managed"] is False
# ── UI ────────────────────────────────────────────────────────────────────
def test_admin_groups_template_uses_mapped_email_in_subtitle(fresh_db):
"""List view JS must consult `g.mapped_email` for the subtitle so
mapped Admin/Everyone show the Workspace email under the canonical
name instead of `Admin / Admin`."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/groups",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200
body = resp.text
assert "g.mapped_email" in body
def test_access_overview_returns_origin_and_mapped_email(fresh_db, monkeypatch):
"""`/api/admin/access-overview` powers the /admin/access sidebar; the
groups payload must carry the same origin / mapped_email / is_google_managed
fields the dedicated /api/admin/groups endpoint exposes, so the sidebar
can render the identical pill + subtitle treatment."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/api/admin/access-overview",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
by_name = {g["name"]: g for g in data["groups"]}
admin = by_name["Admin"]
assert admin["origin"] == "google_sync"
assert admin["mapped_email"] == "admins@workspace.test"
assert admin["is_google_managed"] is True
everyone = by_name["Everyone"]
assert everyone["origin"] == "system"
assert everyone["mapped_email"] is None
assert everyone["is_google_managed"] is False
def test_admin_access_template_renders_origin_pill_and_mapped_email(fresh_db, monkeypatch):
"""The /admin/access page JS must read `origin` / `mapped_email` from
each group so the sidebar gets the same pill + subtitle as
/admin/groups. Pin the JS contract so a renderer regression that
drops the consult on these fields fails CI."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/access",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# JS reads these fields per group when rendering the sidebar.
assert "g.origin" in body
assert "g.mapped_email" in body
assert "g.is_google_managed" in body
# Origin chip CSS classes (multi-color) must be present so the pill renders.
assert ".origin-google_sync" in body
assert ".origin-system" in body
assert ".origin-custom" in body
def test_user_groups_payload_carries_origin(fresh_db, monkeypatch):
"""`/api/users` returns each membership chip's origin so the user-list
page can color the pill (yellow / gray / green / purple) without a
second fetch."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
# Target user belongs to: Admin (mapped → google_sync), Everyone
# (system, unmapped), data-team (custom), eng@workspace.test (google_sync).
ug_repo = UserGroupsRepository(conn)
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
gsync_g = ug_repo.create(name="eng@workspace.test", created_by="system:google-sync")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
everyone_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_EVERYONE_GROUP]
).fetchone()[0]
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T", role="analyst",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="google_sync")
members.add_member(target_uid, everyone_gid, source="admin")
members.add_member(target_uid, custom_g["id"], source="admin")
members.add_member(target_uid, gsync_g["id"], source="google_sync")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get("/api/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
target = next(u for u in resp.json() if u["id"] == target_uid)
by_name = {g["name"]: g for g in target["groups"]}
# Admin row is env-mapped → origin='google_sync' (matches /api/admin/groups).
assert by_name["Admin"]["origin"] == "google_sync"
# Everyone has no env mapping → stays 'system'.
assert by_name["Everyone"]["origin"] == "system"
# Custom + google-sync user-created groups carry their respective tags.
assert by_name["data-team"]["origin"] == "custom"
assert by_name["eng@workspace.test"]["origin"] == "google_sync"
def test_user_memberships_payload_carries_origin(fresh_db, monkeypatch):
"""`/api/admin/users/{id}/memberships` must carry `origin` so the
user detail page can color-code the membership chips identically to
the user list."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
ug_repo = UserGroupsRepository(conn)
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
gsync_g = ug_repo.create(name="legal@workspace.test", created_by="system:google-sync")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T", role="analyst",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="google_sync")
members.add_member(target_uid, custom_g["id"], source="admin")
members.add_member(target_uid, gsync_g["id"], source="google_sync")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/api/admin/users/{target_uid}/memberships",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
by_name = {m["group_name"]: m for m in resp.json()}
# env-mapped Admin → google_sync (matches /api/admin/groups behavior)
assert by_name["Admin"]["origin"] == "google_sync"
assert by_name["data-team"]["origin"] == "custom"
assert by_name["legal@workspace.test"]["origin"] == "google_sync"
def test_add_user_to_group_response_carries_origin(fresh_db):
"""POST /api/admin/users/{id}/memberships must compute `origin` the
same way GET does. Without this, any caller relying on the POST
response (or rendering the chip optimistically before the GET
re-fetch) sees `'custom'` even when adding to the seeded Admin /
Everyone system rows.
"""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T", role="analyst",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
headers = {"Authorization": f"Bearer {token}"}
# System group (no env mapping) → origin must be 'system', not the
# default 'custom'.
resp = client.post(
f"/api/admin/users/{target_uid}/memberships",
headers=headers,
json={"group_id": admin_gid},
)
assert resp.status_code == 201, resp.text
assert resp.json()["origin"] == "system"
# Custom admin-created group → origin stays 'custom'.
resp = client.post(
f"/api/admin/users/{target_uid}/memberships",
headers=headers,
json={"group_id": custom_g["id"]},
)
assert resp.status_code == 201, resp.text
assert resp.json()["origin"] == "custom"
def test_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
"""`/api/admin/users/{id}/effective-access` no longer short-circuits
for admins they get the same per-resource breakdown as everyone
else, so an operator auditing a target user can see precisely which
grants the Admin group carries via which group, instead of a flat
"Full access" pill that hides the wiring. Authorization at runtime
still gives Admin god-mode regardless of this list."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
# Set up: a target admin user belongs to the Admin group AND a
# custom "data-team" group. The Admin group has no explicit grants;
# data-team has one. The endpoint should list the data-team grant.
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t-admin@test", name="T", role="admin",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="admin")
members.add_member(target_uid, custom_g["id"], source="admin")
ResourceGrantsRepository(conn).create(
group_id=custom_g["id"],
resource_type="plugin",
resource_id="agnes/foo",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/api/admin/users/{target_uid}/effective-access",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
# is_admin still reflects reality; the UI just doesn't short-circuit on it.
assert data["is_admin"] is True
# The actual grant list is no longer empty — `data-team` carries one.
rids = [it["resource_id"] for it in data["items"]]
assert "agnes/foo" in rids
def test_profile_template_renders_color_coded_membership_chips(fresh_db, monkeypatch):
"""The /profile page must render group memberships with the same
chip vocabulary used on the user list / detail pages: a colored
.group-chip with class derived from name (Admin / Everyone) first
and origin (google_sync / custom) second. google_sync chip text is
shortened via the prefix-strip logic and the raw email sits on the
chip's title attribute for hover reveal."""
monkeypatch.setenv("AGNES_GOOGLE_GROUP_PREFIX", "grp_acme_")
from app.main import app
from src.db import get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
admin_uid, token = _seed_admin()
conn = get_system_db()
try:
# Add the seeded admin to a Workspace-derived group so the
# rendered profile page actually has a green chip we can grep
# for. The chip text should be "Legal" (prefix stripped,
# capitalized); the title attribute should keep the raw email.
ug_repo = UserGroupsRepository(conn)
gsync = ug_repo.create(
name="grp_acme_legal@workspace.test",
created_by="system:google-sync",
)
UserGroupMembersRepository(conn).add_member(
admin_uid, gsync["id"], source="google_sync",
)
finally:
conn.close()
client = TestClient(app)
resp = client.get(
"/profile",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# Chip CSS classes from the shared vocabulary.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# Admin row gets the canonical-name chip class (server-side rendered).
assert 'class="group-chip is-admin"' in body
# Workspace-derived group's chip text is the shortened display name;
# the raw email lives in the title attribute for hover reveal.
assert ">Legal<" in body
assert 'title="grp_acme_legal@workspace.test"' in body
def test_my_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
"""`/api/me/effective-access` (the /profile page surface) mirrors
/api/admin/users/{id}/effective-access admins see their explicit
grant breakdown rather than a flat "Full access" short-circuit. Same
rationale as the admin-side endpoint: audit the grant graph, not the
runtime god-mode."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
# _seed_admin already adds the admin to the Admin group; we layer on
# a custom group with one grant so the response actually has items.
admin_uid, token = _seed_admin()
conn = get_system_db()
try:
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
UserGroupMembersRepository(conn).add_member(
admin_uid, custom_g["id"], source="admin",
)
ResourceGrantsRepository(conn).create(
group_id=custom_g["id"],
resource_type="plugin",
resource_id="agnes/foo",
)
finally:
conn.close()
client = TestClient(app)
resp = client.get(
"/api/me/effective-access",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["is_admin"] is True
rids = [it["resource_id"] for it in data["items"]]
assert "agnes/foo" in rids
def test_profile_template_drops_full_access_pill(fresh_db):
"""The /profile page no longer renders the gold "Full access via
Admin" empty-state for admin users — it should fall through to the
grant list (or the generic "no resource access" message). Pinning
the absence of the old branch."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/profile",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
assert "Full access via Admin" not in body
assert "You can read and write everything in Agnes regardless" not in body
def test_admin_user_detail_template_drops_full_access_pill(fresh_db):
"""The `ea-admin-pill` short-circuit branch is gone — a regression
that re-adds a special-case render for admins would slip through if
we don't pin its absence."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("v3@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# No longer references the "Full access via the Admin group" branch.
assert "Full access via the Admin group" not in body
assert "ea-admin-pill" not in body
def test_user_detail_dropdown_hides_google_managed_groups(fresh_db):
"""The "Add to group" dropdown on /admin/users/{id} must skip any
row with `is_google_managed=true` membership for those groups is
owned by Workspace and the API 409s on POST anyway. Pin the JS
contract so a regression that drops the filter (and floods the
picker with un-grantable options) surfaces in CI."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("victim2@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# The filter sits inside the picker-population loop.
assert "g.is_google_managed" in body
def test_admin_user_detail_template_uses_color_coded_chips(fresh_db):
"""Detail page must declare the same chip CSS classes + reference
`m.origin` and `deriveDisplayName` in the membership renderer so a
regression that drops the rebuild surfaces in CI."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("victim@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# Color classes match the user list's chip vocabulary.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-everyone" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# JS reads m.origin to pick the chip class.
assert "m.origin" in body
# google_sync chip text runs through deriveDisplayName.
assert "deriveDisplayName" in body
def _create_user(email: str) -> str:
"""Inline helper for the membership UI test — not reused above."""
import uuid as _uuid
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
uid = str(_uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name="V", role="analyst")
return uid
finally:
conn.close()
def test_admin_users_template_renders_color_coded_chips(fresh_db):
"""Pin the JS contract: the user list assigns chip classes based on
name (Admin / Everyone) first and falls back to `is-${origin}` so
google_sync chips go green and custom chips go purple. A renderer
regression that drops the consult on g.origin would surface here.
Also pin the deriveDisplayName shortening for google-sync chips
they must show "Legal" rather than the raw Workspace email so the
membership cell stays readable."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/users",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200
body = resp.text
# The four chip-color classes that style the pills.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-everyone" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# JS reads g.origin to pick the class for non-Admin / non-Everyone rows.
assert "g.origin" in body
# google_sync chips run their name through deriveDisplayName so the
# cell shows "Legal" rather than the full Workspace email; the raw
# email goes into the chip's `title` (hover reveal).
assert "deriveDisplayName" in body
def test_admin_group_detail_template_uses_mapped_email_subtitle(fresh_db, monkeypatch):
"""Detail page Jinja must render `mapped_email` as the subtitle when
the row is the env-mapped Admin/Everyone, instead of the canonical
name (which would yield `Admin / Admin`)."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/admin/groups/{admin_gid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# The mapped Workspace email shows up as the gd-title-email subtitle.
assert "admins@workspace.test" in body
# The data attribute the JS reads to skip the deriveDisplayName rewrite.
assert 'data-mapped-email="admins@workspace.test"' in body

View file

@ -0,0 +1,427 @@
"""Tests for the ``is_sso_user`` flag on /api/users.
Pins the rules used by the admin UI to hide password / delete affordances
for accounts managed by an external SSO provider (Google Workspace today):
1. Group with ``created_by = 'system:google-sync'`` SSO
2. ``Admin`` system group + AGNES_GROUP_ADMIN_EMAIL set SSO
3. ``Everyone`` system group + AGNES_GROUP_EVERYONE_EMAIL SSO
4. No groups, or only admin-created custom groups NOT SSO
Also pins the JS-side guard in ``admin_users.html`` so a renderer regression
that drops the conditional shows up in CI.
"""
import tempfile
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
from src.db import close_system_db
close_system_db()
yield tmp
close_system_db()
def _seed_admin():
"""Create an admin user (Admin system-group member) and return (id, jwt)."""
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
try:
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email="admin@test", name="Admin", role="admin")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
UserGroupMembersRepository(conn).add_member(uid, admin_gid, source="system_seed")
return uid, create_access_token(user_id=uid, email="admin@test", role="admin")
finally:
conn.close()
def _create_user(email: str) -> str:
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0], role="analyst")
return uid
finally:
conn.close()
def _add_to_group(user_id: str, group_id: str, source: str) -> None:
from src.db import get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
conn = get_system_db()
try:
UserGroupMembersRepository(conn).add_member(user_id, group_id, source=source)
finally:
conn.close()
def _create_group(name: str, created_by: str | None) -> str:
from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository
conn = get_system_db()
try:
return UserGroupsRepository(conn).create(name=name, created_by=created_by)["id"]
finally:
conn.close()
def _system_group_id(name: str) -> str:
from src.db import get_system_db
conn = get_system_db()
try:
return conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [name]
).fetchone()[0]
finally:
conn.close()
def _user_payload(client: TestClient, token: str, user_id: str) -> dict:
"""Fetch the list, return the row for the given user_id."""
resp = client.get(
"/api/users", headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
matches = [u for u in resp.json() if u["id"] == user_id]
assert matches, f"user {user_id} not in /api/users response"
return matches[0]
def test_user_with_no_groups_is_not_sso(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False
def test_user_with_only_custom_admin_group_is_not_sso(fresh_db):
"""Admin-created (created_by != 'system:google-sync') custom group does
not flip the user to SSO local accounts in custom groups stay local."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
gid = _create_group("data-team", created_by="admin@test")
_add_to_group(uid, gid, source="admin")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False
def test_user_with_google_sync_group_is_sso(fresh_db):
"""Group whose ``created_by = 'system:google-sync'`` flips the user."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("g@test")
gid = _create_group("eng@workspace.test", created_by="system:google-sync")
_add_to_group(uid, gid, source="google_sync")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is True
def test_admin_system_group_alone_is_not_sso_without_env_mapping(fresh_db):
"""The Admin system row exists on every install. Without the env
mapping, membership in it is *not* an SSO signal local admins exist."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local-admin@test")
_add_to_group(uid, _system_group_id(SYSTEM_ADMIN_GROUP), source="admin")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False
def test_admin_system_group_is_sso_when_env_mapped(fresh_db, monkeypatch):
"""AGNES_GROUP_ADMIN_EMAIL set → Admin membership counts as SSO."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("g-admin@test")
_add_to_group(uid, _system_group_id(SYSTEM_ADMIN_GROUP), source="google_sync")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is True
def test_everyone_system_group_is_sso_when_env_mapped(fresh_db, monkeypatch):
"""AGNES_GROUP_EVERYONE_EMAIL set → Everyone membership counts as SSO."""
from app.main import app
from src.db import SYSTEM_EVERYONE_GROUP
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("g-user@test")
_add_to_group(uid, _system_group_id(SYSTEM_EVERYONE_GROUP), source="google_sync")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is True
def test_everyone_system_group_alone_is_not_sso_without_env_mapping(fresh_db):
"""Without the env mapping, Everyone membership stays local."""
from app.main import app
from src.db import SYSTEM_EVERYONE_GROUP
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
_add_to_group(uid, _system_group_id(SYSTEM_EVERYONE_GROUP), source="admin")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False
def test_system_seed_membership_in_env_mapped_everyone_is_not_sso(fresh_db, monkeypatch):
"""Devin BUG_0002 on PR #142: the v13 migration backfills every existing
user into the Everyone system group with source='system_seed'. If an
operator later sets AGNES_GROUP_EVERYONE_EMAIL, the system-group branch
of _is_sso_user would (without the source check) flip every backfilled
user to is_sso_user=True locking the admin out of password reset /
delete on accounts the IdP doesn't actually own. The source='google_sync'
requirement on the system-group branches keeps system_seed memberships
locally manageable even when the group is env-mapped."""
from app.main import app
from src.db import SYSTEM_EVERYONE_GROUP
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("v13-backfilled@test")
# The v13 migration uses source='system_seed' for the Everyone backfill.
_add_to_group(uid, _system_group_id(SYSTEM_EVERYONE_GROUP), source="system_seed")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False, (
"system_seed membership in an env-mapped Everyone group must not "
"flip is_sso_user — the IdP doesn't own this membership"
)
def test_admin_source_membership_in_env_mapped_admin_is_not_sso(fresh_db, monkeypatch):
"""Mirror of the Everyone case for the Admin system group: a manually-
added (source='admin') membership in env-mapped Admin must not be
treated as SSO only google_sync source is owned by the IdP."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local-admin-in-mapped@test")
_add_to_group(uid, _system_group_id(SYSTEM_ADMIN_GROUP), source="admin")
payload = _user_payload(client, token, uid)
assert payload["is_sso_user"] is False
def test_admin_users_template_gates_password_buttons_on_is_sso_user(fresh_db):
"""Pin the JS-side guard: list-view template must wrap the Reset /
Set pwd / Delete buttons in a ``u.is_sso_user`` ternary so a renderer
regression that drops the conditional surfaces in CI."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/users",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200
body = resp.text
assert "u.is_sso_user" in body, "list-view template must reference u.is_sso_user"
# All three gated buttons must sit inside the conditional branch.
assert 'data-action="reset-password"' in body
assert 'data-action="set-password"' in body
assert 'data-action="delete-user"' in body
def test_admin_user_detail_template_gates_password_buttons_on_is_sso_user(fresh_db):
"""Detail page must hide reset-pw-btn / delete-user-btn for SSO users."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("victim@test")
resp = client.get(
f"/admin/users/{uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# The JS reads userState.is_sso_user and toggles display on the two buttons.
assert "userState.is_sso_user" in body
assert 'id="reset-pw-btn"' in body
assert 'id="delete-user-btn"' in body
# ── Server-side enforcement ──────────────────────────────────────────────
# UI hides the buttons; these tests pin that the API rejects the same
# operations even when called directly with a valid admin token. Without
# this, a curl-savvy admin could bypass the UI guard and reset a Google
# Workspace account's password locally.
def _make_sso_user(email: str) -> str:
"""Create a user and stamp them with a google_sync-sourced custom group."""
uid = _create_user(email)
gid = _create_group(f"{email}-team@workspace.test", created_by="system:google-sync")
_add_to_group(uid, gid, source="google_sync")
return uid
def test_reset_password_rejects_sso_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
sso_uid = _make_sso_user("g@test")
resp = client.post(
f"/api/users/{sso_uid}/reset-password",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409, resp.text
assert "sso" in resp.json()["detail"].lower()
def test_reset_password_allows_local_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
resp = client.post(
f"/api/users/{uid}/reset-password",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
assert "reset_url" in resp.json()
def test_set_password_rejects_sso_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
sso_uid = _make_sso_user("g@test")
resp = client.post(
f"/api/users/{sso_uid}/set-password",
headers={"Authorization": f"Bearer {token}"},
json={"password": "supersecret123"},
)
assert resp.status_code == 409, resp.text
assert "sso" in resp.json()["detail"].lower()
def test_set_password_allows_local_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
resp = client.post(
f"/api/users/{uid}/set-password",
headers={"Authorization": f"Bearer {token}"},
json={"password": "supersecret123"},
)
assert resp.status_code == 204, resp.text
def test_delete_user_rejects_sso_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
sso_uid = _make_sso_user("g@test")
resp = client.delete(
f"/api/users/{sso_uid}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409, resp.text
assert "sso" in resp.json()["detail"].lower()
def test_delete_user_allows_local_user(fresh_db):
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
uid = _create_user("local@test")
resp = client.delete(
f"/api/users/{uid}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 204, resp.text
def test_delete_user_rejects_sso_user_in_admin_group_when_env_mapped(
fresh_db, monkeypatch
):
"""Pin the env-mapping branch end-to-end: a user in `Admin` *because*
AGNES_GROUP_ADMIN_EMAIL maps it from Google must also be locked from
deletion via the server-side guard."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
client = TestClient(app)
_, token = _seed_admin()
# Second admin so the last-active-admin safeguard does not fire first.
uid = _create_user("g-admin@test")
_add_to_group(uid, _system_group_id(SYSTEM_ADMIN_GROUP), source="google_sync")
resp = client.delete(
f"/api/users/{uid}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409, resp.text
assert "sso" in resp.json()["detail"].lower()