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:
parent
f3d252f17d
commit
fb1573766a
18 changed files with 2025 additions and 249 deletions
31
CHANGELOG.md
31
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 `<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
|||
113
app/api/users.py
113
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"]})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,10 +248,10 @@
|
|||
<p class="sub">Register a git repository. It will be cloned into <code>$DATA_DIR/marketplaces/<slug>/</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
71
src/db.py
71
src/db.py
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
258
tests/test_db.py
258
tests/test_db.py
|
|
@ -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. v17→v18 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()
|
||||
|
|
|
|||
699
tests/test_groups_mapped_email.py
Normal file
699
tests/test_groups_mapped_email.py
Normal 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
|
||||
427
tests/test_users_sso_flag.py
Normal file
427
tests/test_users_sso_flag.py
Normal 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()
|
||||
Loading…
Reference in a new issue