From e21c386349b7d6e3e26c654e83e8928eee645edc Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 24 Jun 2026 14:00:57 -0700 Subject: [PATCH] Reduce standard project apps to mgmtsuite only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop bitbucket, srm, and coverity from STANDARD_APPS so new projects only auto-provision the mgmtsuite-{read,write,admin} child groups. The other three remain fully supported as custom child groups (created via the group-management UI or CSV import) — they're just no longer auto-created per project, and the service account no longer auto-joins their -read groups. Backend reads STANDARD_APPS as the single source of truth (group creation, service-account onboarding, child-group classification), so those adapt automatically. Kept the audit script's mirrored copy in sync (it explicitly must match), updated the project-creation test to assert exactly 3 standard subgroups, and refreshed CLAUDE.md. Existing projects keep their bitbucket/srm/coverity groups; nothing is deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- backend/app/constants.py | 3 --- backend/app/projects/routes.py | 1 + backend/app/users/routes.py | 16 +++++++++++++++- backend/tests/test_projects.py | 5 +++-- frontend/src/components/shared/activity-feed.tsx | 5 +++++ frontend/src/pages/user-detail.tsx | 4 ++++ scripts/audit-kc-groups.py | 5 +---- 8 files changed, 30 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 574b4cb..18a6e0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,7 @@ python scripts/delete-projects.py KEY1 --skip-keycloak # DB only - **Dual auth: local + Keycloak OIDC** — hardcoded admin/admin always works. When `KEYCLOAK_OIDC_CLIENT_SECRET` is set, a "Sign in with Keycloak" button appears on the login page. OIDC callback finds-or-creates a local User by `keycloak_id` then email. - **Admin access** — determined by `VELA-mgmtsuite-admin` Keycloak group membership or hardcoded admin username. Controls sidebar admin link visibility. - **Permission model** — Keycloak child groups (`{PROJECT}-mgmtsuite-read/write/admin`) replace the old `ProjectUser.privileges` column. Permissions are derived at login from KC group memberships and cached in `session["kc_permissions"]`. VELA group membership grants global escalation: VELA read = view all, VELA write = edit all, VELA admin = full admin. Hardcoded admin bypasses all checks. -- **Keycloak group hierarchy** — Top-level groups per project (e.g., `ACME`). Child groups per app and permission: `{PROJECT}-{app}-{level}` (e.g., `ACME-bitbucket-read`, `ACME-mgmtsuite-write`). Standard apps: bitbucket, srm, coverity, mgmtsuite — each with read/write/admin. Custom child groups also supported. +- **Keycloak group hierarchy** — Top-level groups per project (e.g., `ACME`). Child groups per app and permission: `{PROJECT}-{app}-{level}` (e.g., `ACME-mgmtsuite-write`). Standard app: mgmtsuite — with read/write/admin (auto-created per project). Custom child groups (e.g., bitbucket, srm, coverity, or anything else) also supported but not auto-provisioned. - **Sponsorship model** — Keycloak `sponsor` attribute (single-valued) on each user stores the project key that sponsors them. Local `User.unsponsored_since` tracks when sponsorship was cleared. Configurable `unsponsored_deletion_days` setting (default 7; 0 = disabled) controls the grace period. Sponsorship release disables the user in KC, marks `unsponsored_since`, and sends notification email. - **Unsponsored-user reaper** — `app/unsponsored_reaper.py` is a background daemon thread (started in `run.py`, alongside the KC queue / downtime-reminder workers) that runs once daily at `unsponsored_deletion_time` (default 02:00 UTC) and hard-deletes users still unsponsored past `unsponsored_deletion_days`. It deletes the local `User` + `UserPermission` rows and enqueues a `delete_user` KC op (hard delete via the KC queue, same op the manual admin delete-user endpoint uses). Multi-pod safe via `SELECT ... FOR UPDATE SKIP LOCKED`; disabled entirely when the setting is 0. - **Users are Keycloak-only** — Users are seeded exclusively in Keycloak via `scripts/seed-keycloak.py`. The backend `seed.py` only seeds projects, licenses, and certs into the local DB. Local `User` records are created on first OIDC login. The KC seeder assigns users to mgmtsuite child groups plus random additional app child groups (bitbucket, srm, coverity) with deterministic randomness. diff --git a/backend/app/constants.py b/backend/app/constants.py index 9cefb94..0626cab 100644 --- a/backend/app/constants.py +++ b/backend/app/constants.py @@ -1,8 +1,5 @@ # Shared constants — no app imports allowed (used by kc_queue, routes, etc.) STANDARD_APPS = { - "bitbucket": ["read", "write", "admin"], - "srm": ["read", "write", "admin"], - "coverity": ["read", "write", "admin"], "mgmtsuite": ["read", "write", "admin"], } diff --git a/backend/app/projects/routes.py b/backend/app/projects/routes.py index 192cee3..27dbffb 100644 --- a/backend/app/projects/routes.py +++ b/backend/app/projects/routes.py @@ -2252,6 +2252,7 @@ def remove_group_member(key, group_id, kc_user_id): } member = User.query.filter_by(keycloak_id=kc_user_id).first() if member: + details["user_id"] = member.id details["user_name"] = member.name log_audit("keycloak_group", None, "member_removed", details) diff --git a/backend/app/users/routes.py b/backend/app/users/routes.py index 9f27212..aeb6ea2 100644 --- a/backend/app/users/routes.py +++ b/backend/app/users/routes.py @@ -443,7 +443,21 @@ def user_history(user_id): AuditLog.entity_id == user_id, ) - entries = direct_entries.union(membership_entries).union(sponsorship_entries).order_by(AuditLog.created_at.desc()).limit(50).all() + # Permission (Keycloak child group) grants/revocations for this user + permission_entries = AuditLog.query.filter( + AuditLog.entity_type == "keycloak_group", + AuditLog.details.contains(f'"user_id": {user_id}'), + ) + + entries = ( + direct_entries + .union(membership_entries) + .union(sponsorship_entries) + .union(permission_entries) + .order_by(AuditLog.created_at.desc()) + .limit(50) + .all() + ) return jsonify({"entries": [e.to_dict() for e in entries]}), 200 diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 0a82255..0642155 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -353,9 +353,10 @@ class TestCreateProject: data = resp.get_json() assert data["key"] == "TST" # enqueue_create_project_groups produces one create_group (parent) + - # one create_subgroup per (app, perm) in STANDARD_APPS. + # one create_subgroup per (app, perm) in STANDARD_APPS (mgmtsuite + # read/write/admin = 3). assert _queue_count("create_group") == 1 - assert _queue_count("create_subgroup") >= 1 + assert _queue_count("create_subgroup") == 3 def test_create_project_missing_body(self, admin_client): """No JSON body returns 400.""" diff --git a/frontend/src/components/shared/activity-feed.tsx b/frontend/src/components/shared/activity-feed.tsx index 844a3ad..d300e86 100644 --- a/frontend/src/components/shared/activity-feed.tsx +++ b/frontend/src/components/shared/activity-feed.tsx @@ -7,6 +7,7 @@ import { Trash2, Download, Upload, + KeyRound, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -41,6 +42,8 @@ const actionIcons: Record = { privileges_changed: Pencil, file_downloaded: Download, file_uploaded: Upload, + member_added: KeyRound, + member_removed: KeyRound, }; const actionColors: Record = { @@ -55,6 +58,8 @@ const actionColors: Record = { privileges_changed: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400", file_downloaded: "bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-400", file_uploaded: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400", + member_added: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400", + member_removed: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400", }; export function ActivityFeed({ entries, isLoading, formatEntry }: ActivityFeedProps) { diff --git a/frontend/src/pages/user-detail.tsx b/frontend/src/pages/user-detail.tsx index ce49589..1d7f356 100644 --- a/frontend/src/pages/user-detail.tsx +++ b/frontend/src/pages/user-detail.tsx @@ -60,6 +60,10 @@ function formatUserEntry(entry: { return <>{entry.user} sponsored by {String(d.project_key ?? "a project")}; case "released": return <>{entry.user} sponsorship released from {String(d.project_key ?? "a project")}; + case "member_added": + return <>was granted permission {String(d.group_name ?? "a group")} by {entry.user}; + case "member_removed": + return <>had permission {String(d.group_name ?? "a group")} revoked by {entry.user}; default: return <>{entry.user} {entry.action.replace(/_/g, " ")}; } diff --git a/scripts/audit-kc-groups.py b/scripts/audit-kc-groups.py index 12913bc..443212e 100644 --- a/scripts/audit-kc-groups.py +++ b/scripts/audit-kc-groups.py @@ -5,7 +5,7 @@ Read-only. For every project in the local database this script checks: 1. The top-level Keycloak group exists. 2. All standard child groups exist ({KEY}-{app}-{read|write|admin} for - bitbucket, srm, coverity, mgmtsuite). + mgmtsuite). 3. No child groups use legacy/unexpected naming — in particular the old `{KEY}-mgmt-*` convention, which the permission model does not read (users in those groups silently have no app permissions). @@ -43,9 +43,6 @@ KC_CLIENT_SECRET = "" # Must match backend/app/constants.py STANDARD_APPS STANDARD_APPS = { - "bitbucket": ["read", "write", "admin"], - "srm": ["read", "write", "admin"], - "coverity": ["read", "write", "admin"], "mgmtsuite": ["read", "write", "admin"], }