diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1e557..4b651ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `` 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 `

`, `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 diff --git a/CLAUDE.md b/CLAUDE.md index 97d67d9..5b53326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/api/access.py b/app/api/access.py index c06f7dd..045e951 100644 --- a/app/api/access.py +++ b/app/api/access.py @@ -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()), ) diff --git a/app/api/users.py b/app/api/users.py index 4e7c055..148edb6 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -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"]}) diff --git a/app/web/router.py b/app/web/router.py index 53319ff..8d337ab 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -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, diff --git a/app/web/templates/admin_access.html b/app/web/templates/admin_access.html index 14a328c..8b40650 100644 --- a/app/web/templates/admin_access.html +++ b/app/web/templates/admin_access.html @@ -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 @@
-

Select a group

- +
+

Select a group

+ +
+
@@ -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 = `${esc(bigName)}${esc(origin.replace("_"," "))}`; + 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 ? 'system' : ''; + // 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 = `${esc(origin.replace("_"," "))}`; + // 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 = `${esc(g.mapped_email)}`; + } else if (g.is_google_managed) { + bigName = esc(deriveDisplayName(g.name)); + subtitle = `${esc(g.name)}`; + } else { + bigName = esc(g.name); + subtitle = g.description ? `${esc(g.description)}` : ""; + } // 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 = `
- ${esc(g.name)}${sysTag} - ${g.description ? `${esc(g.description)}` : ""} + ${bigName}${originPill} + ${subtitle}
${liveCount} `; diff --git a/app/web/templates/admin_group_detail.html b/app/web/templates/admin_group_detail.html index 9fe10ff..3fa2699 100644 --- a/app/web/templates/admin_group_detail.html +++ b/app/web/templates/admin_group_detail.html @@ -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 }}">
← Back to groups

- {% 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 %} {{ target_group.name }} {% else %} {{ target_group.name }} {% endif %}

- {% if target_group.is_google_managed %} + {% if target_group.mapped_email %} + {{ target_group.mapped_email }} + {% elif target_group.is_google_managed %} {{ target_group.name }} {% endif %}
@@ -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) { diff --git a/app/web/templates/admin_groups.html b/app/web/templates/admin_groups.html index eb19109..3bf1535 100644 --- a/app/web/templates/admin_groups.html +++ b/app/web/templates/admin_groups.html @@ -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 @@

New group

Pick a name that identifies a logical audience (e.g. data-team, engineers).

-
-
- Suggested from your Google account - Click a chip to use the name. -
-
-
- @@ -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() { ? `read-only` : ` `; - // 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 - ? `${esc(deriveDisplayName(g.name))} - ${esc(g.name)}` - : `${esc(g.name)}`; + // 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 = `${esc(g.name)} + ${esc(g.mapped_email)}`; + } else if (isGoogleManaged) { + nameCell = `${esc(deriveDisplayName(g.name))} + ${esc(g.name)}`; + } else { + nameCell = `${esc(g.name)}`; + } tr.innerHTML = ` ${nameCell} ${esc(g.description || "")} @@ -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)} Google`; - 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"); } diff --git a/app/web/templates/admin_marketplaces.html b/app/web/templates/admin_marketplaces.html index a244932..5b7e249 100644 --- a/app/web/templates/admin_marketplaces.html +++ b/app/web/templates/admin_marketplaces.html @@ -248,10 +248,10 @@

Register a git repository. It will be cloned into $DATA_DIR/marketplaces/<slug>/ and fast-forwarded every night at 03:00 UTC.

- + - +
Lower-case alphanumerics, hyphens, and underscores. 1-64 chars, must start with a letter or digit.
diff --git a/app/web/templates/admin_user_detail.html b/app/web/templates/admin_user_detail.html index 44a4f68..bf0d865 100644 --- a/app/web/templates/admin_user_detail.html +++ b/app/web/templates/admin_user_detail.html @@ -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 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 }} -

{{ target_user.email }} · id {{ target_user.id[:8] }}…
@@ -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 = `${esc(userState.email)} 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 ? `· ${esc(fmtDate(m.added_at))}` : ""; + // 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 = ` -
${esc(m.group_name)} + ${esc(display)} ${esc(sourceLabel)}${addedFragment} ${removable} `; @@ -367,7 +404,13 @@ function refreshGroupDropdown() { const memberOf = new Set(memberships.map(m => m.group_id)); sel.innerHTML = ''; 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 = ` -
- 🔑 - - Full access via the Admin group.
- This user can read/write everything regardless of explicit grants. -
-
`; - 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 = `
User has no resource access yet. Add them to a group with grants.
`; return; diff --git a/app/web/templates/admin_users.html b/app/web/templates/admin_users.html index acda980..8de1e60 100644 --- a/app/web/templates/admin_users.html +++ b/app/web/templates/admin_users.html @@ -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 @@