From c940593a90396e454c1f6be7947594c9211dda29 Mon Sep 17 00:00:00 2001 From: minasarustamyan <156230623+minasarustamyan@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:08:04 +0200 Subject: [PATCH] feat(auth): Google Workspace group prefix filter + system mapping (#131) Three new env vars wire the Google OAuth callback to a configurable Workspace prefix and route admin/everyone Workspace groups onto the seeded system rows: AGNES_GOOGLE_GROUP_PREFIX, AGNES_GROUP_ADMIN_EMAIL, AGNES_GROUP_EVERYONE_EMAIL. Login gate redirects users with no prefix-matching group to /login?error=not_in_allowed_group. BREAKING: auto-Everyone membership for new users removed. Admin UI/API are read-only on Google-managed groups. See docs/auth-groups.md. --- CHANGELOG.md | 15 + app/api/access.py | 65 +++- app/api/users.py | 4 +- app/auth/access.py | 23 +- app/auth/group_sync.py | 173 ++++++--- app/auth/providers/google.py | 108 ++++-- app/web/router.py | 24 +- app/web/templates/admin_group_detail.html | 84 ++++- app/web/templates/admin_groups.html | 42 ++- app/web/templates/login.html | 16 + docs/auth-groups.md | 272 +++++++++++--- src/marketplace_filter.py | 20 +- src/repositories/user_group_members.py | 28 +- src/repositories/users.py | 32 +- tests/test_google_group_prefix_sync.py | 432 ++++++++++++++++++++++ tests/test_group_sync.py | 200 +++++----- tests/test_marketplace_filter.py | 19 +- tests/test_marketplace_server_zip.py | 7 +- tests/test_repositories.py | 14 +- 19 files changed, 1285 insertions(+), 293 deletions(-) create mode 100644 tests/test_google_group_prefix_sync.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2ed14..2edd255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,28 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C - `POST /api/admin/register-table/precheck` — validation-only sibling of register-table. Returns `{"ok": true, "table": {rows, size_bytes, columns, …}}` for BQ rows after round-tripping `get_table`; surfaces NotFound → 404, Forbidden → 403, anything else → 400 with the GCP error verbatim. Also runs Pydantic validation for non-BQ source types so the CLI / UI gets a single endpoint shape. - `--dry-run` flag on `da admin register-table` — calls `/precheck` and pretty-prints rows / bytes / columns; exits 0 on `ok`, 1 on validation or source-side error. - Audit-log entries on every `register_table` / `update_table` / `unregister_table` mutation — closes the asymmetry where instance-config saves audited but registry mutations didn't (Decision 4 in #108). Secret-named fields in the request payload are masked as `***`; `description` is logged raw. +- **Google Workspace group prefix filter + system-group mapping.** Three new env vars wire the OAuth callback's group sync to a configurable Workspace prefix and route the admin/everyone Workspace groups into the seeded system rows. + - `AGNES_GOOGLE_GROUP_PREFIX` — when set (e.g. `grp_acme_`), only Workspace groups whose email local part starts with the prefix are mirrored into `user_group_members`. Empty = legacy behavior (mirror every fetched group). + - `AGNES_GROUP_ADMIN_EMAIL` — Workspace group email that maps onto the seeded `Admin` system row instead of creating a fresh `user_groups` entry. Members of that Workspace group land in `Admin` directly. + - `AGNES_GROUP_EVERYONE_EMAIL` — same mechanism for `Everyone`. +- **Login gate.** When `AGNES_GOOGLE_GROUP_PREFIX` is set and the user's Workspace fetch returned a non-empty list with zero prefix matches, the callback redirects to `/login?error=not_in_allowed_group` with a friendly inline banner. Empty fetch results (transient Cloud Identity failures) preserve the cached membership and let the login proceed — fail-soft only the soft-fail path; an explicit no-match still blocks. New error code `group_check_unavailable` is wired through the login banner for future use. +- **Admin UI subtitle for synced groups.** The `/admin/groups` table and the `/admin/groups/{id}` detail page render a derived display name (prefix stripped, `@domain` removed, capitalized) above a small monospace subtitle showing the full Workspace email. Edit / Delete affordances are hidden on Google-managed rows, and a "managed by Google Workspace — read-only here" banner appears on the detail page. ### Changed +- **BREAKING** Auto-`Everyone` membership for new users was removed. `UserRepository.create` no longer writes a `user_group_members` row, and `app.auth.access._user_group_ids` no longer adds a virtual `Everyone` id to the result. Every membership now traces to a real source row (`admin`, `google_sync`, or an explicit `system_seed`). If you relied on the implicit-Everyone behavior for plugin visibility, grant the plugin to a real group (e.g. an `everyone@example.com` Workspace group mapped via `AGNES_GROUP_EVERYONE_EMAIL`). +- **Admin UI / API are read-only on Google-managed groups.** `created_by='system:google-sync'` rows, plus the seeded `Admin` / `Everyone` rows when the matching email-mapping env var is set, return `409` with body `{"detail": {"code": "google_managed_readonly", ...}}` from `PATCH /api/admin/groups/{id}`, `DELETE /api/admin/groups/{id}`, `POST /api/admin/groups/{id}/members`, `DELETE /api/admin/groups/{id}/members/{user_id}`, `POST /api/admin/users/{id}/memberships`, `DELETE /api/admin/users/{id}/memberships/{group_id}`. Edit through admin.google.com, then sign in again to refresh. - **Audit action names for corporate-memory operations renamed** from `km_` to `corporate_memory.` to match the 0.15.0 CHANGELOG documentation. The audit-tab filter accepts both prefixes for back-compat with rows already in the audit log (no historical-row rewrite). Issue #62. - **`onDomainChange()` UX bug fixed** on `/corporate-memory`: domain and category filters now compose instead of resetting each other when either changes. Issue #62. - `POST /api/memory/admin/edit` continues to accept title/content as before; the new `PATCH /api/memory/admin/{id}` is the recommended path for everything else (including title/content). The legacy endpoint is kept one release for back-compat. +### Internal + +- New env vars surfaced into `ConfigProxy` so templates can derive the friendly display name client-side. +- New `is_google_managed: bool` field on `GroupResponse` (the API surface for the admin UI's group list/detail). +- New `UserGroupMembersRepository.has_any_google_sync_membership` helper (currently diagnostic; kept for a future tightening of the gate). +- New tests in `tests/test_google_group_prefix_sync.py`; `tests/test_repositories.py::TestUserRepositoryEveryoneAutoMember` renamed to `TestUserRepositoryNoAutoMembership` with inverted assertion; two `tests/test_marketplace_filter.py` tests adapted to the no-implicit-Everyone semantics. See `docs/auth-groups.md` for the full reference. + ### Fixed - `PATCH /api/memory/admin/{id}` now switches from `model_dump(exclude_none=True)` to `exclude_unset=True`, so an explicit `null` in the request body clears the field (e.g. `{"audience": null}` resets a previously-set audience to NULL). Pre-fix nulls were silently dropped, leaving no path to clear `audience` and only the empty-string short-circuit for `domain`. The endpoint now distinguishes "field absent from body" (untouched) from "field explicitly set to null" (cleared). Both `PATCH /api/memory/admin/{id}` and `POST /api/memory/admin/bulk-update` now reject an explicit `null` for `title` (NOT NULL in the schema) at the boundary with HTTP 400 instead of bubbling up as a 500 (PATCH) or per-item Constraint Error (bulk). Issue #62 / PR #126 review. diff --git a/app/api/access.py b/app/api/access.py index d4f1675..c06f7dd 100644 --- a/app/api/access.py +++ b/app/api/access.py @@ -15,6 +15,7 @@ for every mutation so an admin's group/grant changes are traceable. from __future__ import annotations import logging +import os from datetime import datetime from typing import Any, List, Optional @@ -66,6 +67,57 @@ def _audit( logger.warning("audit log failed for %s/%s", action, resource) +def _is_google_managed(g: dict) -> bool: + """Whether a group row is owned by Google sync — admin UI/API treat such + rows as read-only. + + Two ways a group can be Google-managed: + + 1. ``created_by='system:google-sync'`` — auto-created by the OAuth + callback when the user belonged to a prefix-matching Workspace + group; ``name`` is the full Workspace email. + 2. ``is_system=TRUE`` AND the group's name matches the env-configured + admin/everyone Workspace email — the OAuth callback routes + memberships from those Workspace groups into the seeded system + row instead of creating a separate user_groups row, so the system + row effectively *becomes* a Google-synced row in this deployment. + Without the env mapping, system groups stay regular admin-managed + rows (renaming Admin is still blocked separately by + ``UserGroupsRepository`` for code-reference safety). + """ + if (g.get("created_by") or "") == "system:google-sync": + return True + if g.get("is_system"): + from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP + admin_email = os.environ.get( + "AGNES_GROUP_ADMIN_EMAIL", "" + ).strip().lower() + everyone_email = os.environ.get( + "AGNES_GROUP_EVERYONE_EMAIL", "" + ).strip().lower() + if admin_email and g.get("name") == SYSTEM_ADMIN_GROUP: + return True + if everyone_email and g.get("name") == SYSTEM_EVERYONE_GROUP: + return True + return False + + +def _guard_google_managed(g: dict) -> None: + """Raise 409 google_managed_readonly when the group is Google-managed.""" + if _is_google_managed(g): + raise HTTPException( + status_code=409, + detail={ + "code": "google_managed_readonly", + "message": ( + "This group is managed by Google Workspace and is " + "read-only here. Add or remove members via " + "admin.google.com, or sign in again to refresh." + ), + }, + ) + + def _validate_resource_type(value: str) -> ResourceType: try: return ResourceType(value) @@ -214,6 +266,9 @@ class GroupResponse(BaseModel): created_by: Optional[str] = None member_count: int = 0 grant_count: int = 0 + # True iff the row is owned by Google sync — admin UI hides edit/delete + # affordances and the API rejects mutations with 409 google_managed_readonly. + is_google_managed: bool = False class CreateGroupRequest(BaseModel): @@ -262,6 +317,7 @@ def _group_to_response( created_by=g.get("created_by"), member_count=members_repo.count_members(g["id"]), grant_count=grants_repo.count_for_group(g["id"]), + is_google_managed=_is_google_managed(g), ) @@ -328,6 +384,7 @@ async def update_group( g = repo.get(group_id) if not g: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(g) if g.get("is_system") and payload.name is not None and payload.name.strip() != g["name"]: # System groups: block renames (the canonical names "Admin" / # "Everyone" are referenced from app.auth.access and the @@ -368,6 +425,7 @@ async def delete_group( g = repo.get(group_id) if not g: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(g) if g.get("is_system"): raise HTTPException(status_code=409, detail="Cannot delete a system group") # Cascade members + grants atomically with the group row so a partial @@ -445,8 +503,10 @@ async def add_member( user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - if not UserGroupsRepository(conn).get(group_id): + g = UserGroupsRepository(conn).get(group_id) + if not g: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(g) target = UserRepository(conn).get_by_email(payload.email) if not target: raise HTTPException(status_code=404, detail=f"User {payload.email!r} not found") @@ -488,6 +548,7 @@ async def remove_member( group = UserGroupsRepository(conn).get(group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(group) if group["name"] == "Admin" and user_id == user["id"]: if UserRepository(conn).count_admins(active_only=True) <= 1: raise HTTPException( @@ -711,6 +772,7 @@ async def add_user_to_group( group = UserGroupsRepository(conn).get(payload.group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(group) members = UserGroupMembersRepository(conn) if members.has_membership(user_id, payload.group_id): raise HTTPException(status_code=409, detail="Already a member") @@ -754,6 +816,7 @@ async def remove_user_from_group( group = UserGroupsRepository(conn).get(group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + _guard_google_managed(group) if group["name"] == "Admin" and user_id == user["id"]: if UserRepository(conn).count_admins(active_only=True) <= 1: raise HTTPException( diff --git a/app/api/users.py b/app/api/users.py index 19fe02c..4e7c055 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -179,8 +179,8 @@ async def create_user( import secrets user_id = str(uuid.uuid4()) repo.create(id=user_id, email=payload.email, name=payload.name, role=payload.role) - # If the requested role is admin, add to Admin group. Anything else is just - # a member of Everyone (added implicitly by repo.create). + # If the requested role is admin, add to Admin group. Non-admin users start + # with no group memberships — admin-managed grants must be explicit. if (payload.role or "").lower() == "admin": _set_admin_membership(user_id, True, user.get("email"), conn) _audit(conn, user["id"], "user.create", user_id, {"email": payload.email, "role": payload.role}) diff --git a/app/auth/access.py b/app/auth/access.py index ef46e76..95d0cd7 100644 --- a/app/auth/access.py +++ b/app/auth/access.py @@ -36,7 +36,7 @@ from fastapi import Depends, HTTPException, Request, status from app.auth.dependencies import _get_db, get_current_user from app.resource_types import ResourceType -from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP +from src.db import SYSTEM_ADMIN_GROUP logger = logging.getLogger(__name__) @@ -52,24 +52,21 @@ def _get_group_id_by_name(name: str, conn: duckdb.DuckDBPyConnection) -> Optiona def _user_group_ids(user_id: str, conn: duckdb.DuckDBPyConnection) -> set[str]: - """Set of group_ids the user is in. Always includes Everyone. + """Set of group_ids the user is in. - Membership rows live in ``user_group_members``; Everyone is added - unconditionally so callers don't have to special-case it. If the - Everyone group is missing (impossible in healthy installs but seen in - fresh-test scenarios), the helper logs once and proceeds with the - explicit memberships. + Returns only the rows present in ``user_group_members``. The implicit + "every user is in Everyone" virtual row was removed when Google-prefix + mapping landed — every membership is now sourced from a concrete row + (``admin``, ``google_sync``, or ``system_seed``) so an operator + auditing /admin/access sees the same set the authorization layer + enforces. Callers that want Everyone-style "always granted" plugins + must grant them to a real group the user is a member of. """ rows = conn.execute( "SELECT group_id FROM user_group_members WHERE user_id = ?", [user_id], ).fetchall() - group_ids: set[str] = {r[0] for r in rows} - - everyone_id = _get_group_id_by_name(SYSTEM_EVERYONE_GROUP, conn) - if everyone_id is not None: - group_ids.add(everyone_id) - return group_ids + return {r[0] for r in rows} def is_user_admin(user_id: str, conn: duckdb.DuckDBPyConnection) -> bool: diff --git a/app/auth/group_sync.py b/app/auth/group_sync.py index f4a4c57..556becd 100644 --- a/app/auth/group_sync.py +++ b/app/auth/group_sync.py @@ -1,50 +1,71 @@ -"""Sync a user's Google Workspace group membership into users.groups. +"""Sync a user's Google Workspace group membership at OAuth callback. -Called from `app/auth/providers/google.py` in the OAuth callback. Uses the -Cloud Identity API (searchTransitiveGroups — returns nested group -memberships too) with Application Default Credentials from the VM metadata -server. No JSON key, no domain-wide delegation. +Called from `app/auth/providers/google.py`. Uses keyless Domain-Wide +Delegation: the VM service account signs the impersonation JWT via the IAM +``signJwt`` API (no private key on disk), then exchanges that JWT for a +short-lived OAuth token scoped to ``admin.directory.group.readonly``. The +Admin SDK ``groups.list?userKey=`` endpoint returns the user's static AND +dynamic group memberships in one call. -Required one-off Workspace setup: - - Assign Groups Admin admin role to the VM service account. - - See docs/google-workspace-groups-request.md. +Required GCP setup (one-off): -Required VM config: - - `cloud-platform` access scope on the VM (already set on - grpn-sa-foundryai-execution) — covers `cloud-identity.groups.readonly`. - - Cloud Identity API enabled on the project. + - The VM SA grants itself ``roles/iam.serviceAccountTokenCreator`` so it + can call ``IAMCredentials.signJwt`` for its own identity. + - A Domain-Wide Delegation entry exists in admin.google.com → Security → + API controls → Domain-wide Delegation, mapping the VM SA's numeric + Unique ID to scope ``admin.directory.group.readonly``. + +Required env on the VM: + + - ``GOOGLE_ADMIN_SDK_SUBJECT`` — the Workspace admin email the SA + impersonates. Must be a real Workspace user with directory read + privileges. When unset, this module fails soft and returns ``[]``. + - ``GOOGLE_ADMIN_SDK_SA_EMAIL`` (optional) — explicit SA email override. + When unset, the SA is auto-detected from the GCE metadata server, i.e. + whichever SA the VM is currently running as. Useful off-VM (CI, tests). Local dev / CI: - Set GOOGLE_ADMIN_SDK_MOCK_GROUPS to a comma-separated list. ADC from the - metadata server doesn't exist off-VM; without this flag local runs fall - through to the real-path and bail out with an empty list (fail-soft). + + Set ``GOOGLE_ADMIN_SDK_MOCK_GROUPS`` to a comma-separated list of group + emails to bypass all Google calls. Empty value → empty list. Unset → + the real keyless-DWD path. """ from __future__ import annotations import logging import os +import urllib.error +import urllib.request from typing import List logger = logging.getLogger(__name__) -SCOPE = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" - -# CEL label filter — regular Workspace email groups (grp_*, eng-team@..., etc). -# Skips security groups, dynamic groups, POSIX groups, which we don't use for -# plugin RBAC. -_GROUP_LABEL_DISCUSSION = "cloudidentity.googleapis.com/groups.discussion_forum" - -# Env var that, when set, bypasses the real API entirely. Value is comma- -# separated group names. Empty string → empty list. Unset → real API path. +# Bypass real API entirely. Comma-separated group emails. Empty → []. Unset → +# real keyless-DWD path. MOCK_ENV = "GOOGLE_ADMIN_SDK_MOCK_GROUPS" +# Required: the Workspace admin email impersonated through DWD. +SUBJECT_ENV = "GOOGLE_ADMIN_SDK_SUBJECT" + +# Optional: SA email override. When unset, auto-detect from GCE metadata. +SA_EMAIL_ENV = "GOOGLE_ADMIN_SDK_SA_EMAIL" + +SCOPE = "https://www.googleapis.com/auth/admin.directory.group.readonly" + +_METADATA_SA_URL = ( + "http://metadata.google.internal/computeMetadata/v1/instance/" + "service-accounts/default/email" +) + def fetch_user_groups(email: str) -> List[str]: - """Return the list of group names (emails) the user belongs to. + """Return the list of group emails ``email`` is a member of. - Fail-soft: returns [] on any error. Caller must treat this as a soft - signal (login proceeds, users.groups stays whatever it was before). + Fail-soft: returns ``[]`` on any error (missing config, metadata server + unreachable, API 4xx/5xx, network outage). The caller in the OAuth + callback treats ``[]`` as "no data" and leaves the previous membership + snapshot intact — so a transient outage does not wipe a user's groups. """ mock = os.environ.get(MOCK_ENV) if mock is not None: @@ -52,51 +73,93 @@ def fetch_user_groups(email: str) -> List[str]: return _fetch_real(email) +def _detect_sa_email() -> str | None: + """Return the SA email this process should impersonate as. + + Order of resolution: + 1. ``GOOGLE_ADMIN_SDK_SA_EMAIL`` env var — explicit override. + 2. GCE metadata server — the SA the VM is attached to. + + Returns ``None`` when neither is available (off-VM with no override). + """ + explicit = os.environ.get(SA_EMAIL_ENV, "").strip() + if explicit: + return explicit + try: + req = urllib.request.Request( + _METADATA_SA_URL, + headers={"Metadata-Flavor": "Google"}, + ) + with urllib.request.urlopen(req, timeout=2) as resp: + return resp.read().decode("ascii").strip() + except (urllib.error.URLError, urllib.error.HTTPError, OSError): + return None + + def _fetch_real(email: str) -> List[str]: try: - from google.auth import default + from google.auth import default, iam + from google.auth.transport.requests import Request + from google.oauth2 import service_account from googleapiclient.discovery import build except ImportError: logger.warning( - "google-api-python-client not installed; skipping group fetch" + "google-api-python-client / google-auth not installed; " + "skipping group fetch" + ) + return [] + + subject = os.environ.get(SUBJECT_ENV, "").strip() + if not subject: + logger.warning( + "%s not set; skipping group fetch (keyless DWD requires an " + "admin email to impersonate)", + SUBJECT_ENV, + ) + return [] + + sa_email = _detect_sa_email() + if not sa_email: + logger.warning( + "Could not determine VM service account email " + "(metadata server unreachable and %s not set); " + "skipping group fetch", + SA_EMAIL_ENV, ) return [] try: - creds, _ = default(scopes=[SCOPE]) + source, _ = default() + signer = iam.Signer(Request(), source, sa_email) + creds = service_account.Credentials( + signer=signer, + service_account_email=sa_email, + token_uri="https://oauth2.googleapis.com/token", + scopes=[SCOPE], + subject=subject, + ) service = build( - "cloudidentity", "v1", credentials=creds, cache_discovery=False + "admin", "directory_v1", + credentials=creds, + cache_discovery=False, ) except Exception as e: # noqa: BLE001 - fail-soft by design - logger.warning("Google client init failed: %s", e) + logger.warning("Admin SDK init failed: %s", e) return [] - # Escape single quotes in the email to keep the CEL query well-formed even - # if a user has a quote in their login (rare, but defensive). - safe_email = email.replace("'", "\\'") - query = ( - f"member_key_id == '{safe_email}' && " - f"'{_GROUP_LABEL_DISCUSSION}' in labels" - ) - groups: List[str] = [] - page_token = None + page_token: str | None = None try: while True: - resp = ( - service.groups() - .memberships() - .searchTransitiveGroups( - parent="groups/-", - query=query, - pageToken=page_token, - ) - .execute() - ) - for m in resp.get("memberships", []): - gkey = m.get("groupKey", {}).get("id") - if gkey: - groups.append(gkey) + resp = service.groups().list( + userKey=email, + maxResults=200, + pageToken=page_token, + ).execute() + for g in resp.get("groups", []): + gid = g.get("email") + if gid: + groups.append(gid) page_token = resp.get("nextPageToken") if not page_token: break diff --git a/app/auth/providers/google.py b/app/auth/providers/google.py index 674692a..40140c2 100644 --- a/app/auth/providers/google.py +++ b/app/auth/providers/google.py @@ -91,13 +91,30 @@ async def google_callback(request: Request): # Find or create user, sync Workspace group memberships into # user_group_members. - from src.db import get_system_db + from src.db import ( + get_system_db, + SYSTEM_ADMIN_GROUP, + SYSTEM_EVERYONE_GROUP, + ) from src.repositories.users import UserRepository from src.repositories.user_groups import UserGroupsRepository from src.repositories.user_group_members import UserGroupMembersRepository from app.auth.group_sync import fetch_user_groups import uuid + # Optional Workspace-group prefix filter + system-group mapping. Read + # per-request so test fixtures and operators can flip via env without + # restarting the process. Empty prefix = legacy behavior (mirror all). + prefix = os.environ.get( + "AGNES_GOOGLE_GROUP_PREFIX", "" + ).strip().lower() + admin_email = os.environ.get( + "AGNES_GROUP_ADMIN_EMAIL", "" + ).strip().lower() + everyone_email = os.environ.get( + "AGNES_GROUP_EVERYONE_EMAIL", "" + ).strip().lower() + conn = get_system_db() try: repo = UserRepository(conn) @@ -112,38 +129,81 @@ async def google_callback(request: Request): # Sync Workspace groups → user_group_members (source='google_sync'). # Fail-soft: any error leaves the previous membership snapshot in # place; admin-added rows survive regardless. + members_repo = UserGroupMembersRepository(conn) try: group_names = fetch_user_groups(email) # `fetch_user_groups` is fail-soft and returns [] for both # "user genuinely has no groups" and "transient API failure". - # We can't distinguish, so empty is treated as "no change": - # don't call replace_google_sync_groups (which would - # DELETE...source='google_sync' then INSERT zero, wiping - # all of the user's Workspace-synced memberships on a - # transient hiccup). Trade-off: a user whose Workspace - # groups were genuinely cleared keeps stale memberships - # until the next non-empty sync. Admin-added rows - # (source='admin') are unaffected either way. - if group_names: - ug_repo = UserGroupsRepository(conn) - members_repo = UserGroupMembersRepository(conn) - group_ids: list[str] = [] - for group_name in group_names: - g = ug_repo.ensure(group_name) - group_ids.append(g["id"]) - members_repo.replace_google_sync_groups( - user["id"], group_ids, added_by="system:google-sync", - ) - logger.info( - "Google group sync for %s: %d group(s) [%s]", - email, len(group_ids), ", ".join(group_names), - ) - else: + # Empty result is treated as "no change": preserve the + # previous snapshot rather than wiping it on a transient + # hiccup. Admin-added rows survive regardless. + if not group_names: logger.info( "Google group sync for %s: empty result, " "preserving existing memberships", email, ) + else: + # Lower-cased Workspace email of each group; comparisons + # against admin_email/everyone_email/prefix are all + # case-insensitive. + fetched = [g.lower() for g in group_names] + + if prefix: + relevant = [g for g in fetched if g.startswith(prefix)] + else: + relevant = list(fetched) + + # Login gate: prefix is set AND fetch returned a + # non-empty list AND none of those groups match the + # prefix → user is signed into Google but is not a + # member of any group permitted to use this Agnes + # instance. Pass-through-on-empty-fetch is preserved + # above (transient API failures must not lock users + # out), so this branch fires only when we got a real + # answer that excluded them. + if prefix and not relevant: + logger.info( + "Google login denied for %s: no group with " + "prefix %r in %s", + email, prefix, fetched, + ) + return RedirectResponse( + url="/login?error=not_in_allowed_group" + ) + + ug_repo = UserGroupsRepository(conn) + group_ids: list[str] = [] + for email_addr in relevant: + if admin_email and email_addr == admin_email: + sys_admin = ug_repo.get_by_name( + SYSTEM_ADMIN_GROUP + ) + if sys_admin: + group_ids.append(sys_admin["id"]) + continue + if everyone_email and email_addr == everyone_email: + sys_everyone = ug_repo.get_by_name( + SYSTEM_EVERYONE_GROUP + ) + if sys_everyone: + group_ids.append(sys_everyone["id"]) + continue + # Regular synced group: name = full email. ensure() + # is get-or-create-by-name and stamps + # created_by='system:google-sync' on first create. + g = ug_repo.ensure(email_addr) + group_ids.append(g["id"]) + + members_repo.replace_google_sync_groups( + user["id"], group_ids, added_by="system:google-sync", + ) + logger.info( + "Google group sync for %s: %d group(s) " + "(filtered from %d fetched, prefix=%r) [%s]", + email, len(group_ids), len(fetched), prefix, + ", ".join(relevant), + ) except Exception as sync_err: # noqa: BLE001 - fail-soft by design logger.warning( "Google group sync failed for %s: %s", email, sync_err diff --git a/app/web/router.py b/app/web/router.py index 891b7f9..c5087ec 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -150,6 +150,21 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di DEBUG_AUTH_ENABLED = os.environ.get("AGNES_DEBUG_AUTH", "").strip().lower() in ( "1", "true", "yes", ) + # Google Workspace prefix-mapping config — surfaced into templates + # so client-side JS can derive a friendly display name from the + # full Workspace email stored as the group's `name` (admin UI + # strips the prefix and `@domain` for the big line, keeps the + # full email as subtitle). Read at template render time so an + # operator can flip these via env without an image rebuild. + AGNES_GOOGLE_GROUP_PREFIX = os.environ.get( + "AGNES_GOOGLE_GROUP_PREFIX", "" + ) + AGNES_GROUP_ADMIN_EMAIL = os.environ.get( + "AGNES_GROUP_ADMIN_EMAIL", "" + ) + AGNES_GROUP_EVERYONE_EMAIL = os.environ.get( + "AGNES_GROUP_EVERYONE_EMAIL", "" + ) @staticmethod def theme_overrides(): @@ -728,10 +743,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 g = UserGroupsRepository(conn).get(group_id) if not g: raise HTTPException(status_code=404, detail="Group not found") - ctx = _build_context(request, user=user, target_group=g) + # 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. + g_view = dict(g) + g_view["is_google_managed"] = _is_google_managed(g) + ctx = _build_context(request, user=user, target_group=g_view) return templates.TemplateResponse(request, "admin_group_detail.html", ctx) diff --git a/app/web/templates/admin_group_detail.html b/app/web/templates/admin_group_detail.html index 42e04a2..9fe10ff 100644 --- a/app/web/templates/admin_group_detail.html +++ b/app/web/templates/admin_group_detail.html @@ -18,7 +18,23 @@ .gd-back:hover { color: var(--text-primary, #111827); } .gd-title-block { flex: 1; } .gd-title { font-size: 22px; font-weight: 600; margin: 0; } + .gd-title-email { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; font-weight: 400; color: var(--text-secondary, #6b7280); + margin-top: 2px; + } .gd-subtitle { font-size: 13px; color: var(--text-secondary, #6b7280); margin-top: 2px; } + .gd-managed-banner { + margin-bottom: 16px; + padding: 12px 16px; + background: #ecfdf5; + border: 1px solid #86efac; + color: #166534; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + } .origin-chip { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 10px; font-weight: 600; @@ -133,19 +149,31 @@ .toast.error { background: #b91c1c; } -
+
← Back to groups
-

- {{ target_group.name }} +

+ {% if target_group.is_google_managed %} + {{ target_group.name }} + {% else %} + {{ target_group.name }} + {% endif %}

+ {% if target_group.is_google_managed %} + {{ target_group.name }} + {% endif %}
{{ target_group.description or "—" }}
- {% if not target_group.is_system %} + {% if not target_group.is_system and not target_group.is_google_managed %}
@@ -153,6 +181,14 @@ {% endif %}
+ {% if target_group.is_google_managed %} +
+ This group is managed by Google Workspace — read-only here. + Add or remove members via admin.google.com, + or sign in again to refresh. +
+ {% endif %} +
@@ -168,10 +204,12 @@ + {% if not target_group.is_google_managed %}
+ {% endif %}
@@ -212,9 +250,27 @@ 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 GOOGLE_GROUP_PREFIX = root.dataset.googlePrefix || ""; const GROUP_API = `/api/admin/groups/${encodeURIComponent(GROUP_ID)}`; const MEMBERS_API = `${GROUP_API}/members`; +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); +} + +if (IS_GOOGLE_MANAGED) { + const dn = document.getElementById("header-display-name"); + if (dn) dn.textContent = deriveDisplayName(root.dataset.groupName); +} + 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", " ") : "-"; } function toast(msg, kind = "") { @@ -316,13 +372,19 @@ async function addMember() { loadMembers(); } -document.getElementById("add-btn").addEventListener("click", addMember); -document.getElementById("add-email").addEventListener("input", e => { - document.getElementById("add-btn").disabled = !e.target.value.trim(); -}); -document.getElementById("add-email").addEventListener("keydown", e => { - if (e.key === "Enter") { e.preventDefault(); if (e.target.value.trim()) addMember(); } -}); +// Add-member affordance is hidden server-side on google-managed rows; bind +// the listeners only when the elements actually exist. +const addBtnEl = document.getElementById("add-btn"); +const addEmailEl = document.getElementById("add-email"); +if (addBtnEl && addEmailEl) { + addBtnEl.addEventListener("click", addMember); + addEmailEl.addEventListener("input", e => { + addBtnEl.disabled = !e.target.value.trim(); + }); + addEmailEl.addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); if (e.target.value.trim()) addMember(); } + }); +} async function removeMember(userId, label) { if (!confirm(`Remove ${label} from this group?`)) return; diff --git a/app/web/templates/admin_groups.html b/app/web/templates/admin_groups.html index 926a00b..eb19109 100644 --- a/app/web/templates/admin_groups.html +++ b/app/web/templates/admin_groups.html @@ -47,6 +47,12 @@ text-decoration: none; } .gp-name:hover { color: var(--primary, #4338ca); text-decoration: underline; } + .gp-name-sub { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 11px; font-weight: 400; color: var(--text-secondary, #6b7280); + margin-top: 2px; + } .gp-desc { color: var(--text-secondary, #6b7280); font-size: 12px; max-width: 380px; @@ -245,10 +251,28 @@