Reduce standard project apps to mgmtsuite only

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 14:00:57 -07:00
parent 59c5bcde1b
commit e21c386349
8 changed files with 30 additions and 11 deletions

View File

@@ -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.

View File

@@ -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"],
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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<string, typeof Plus> = {
privileges_changed: Pencil,
file_downloaded: Download,
file_uploaded: Upload,
member_added: KeyRound,
member_removed: KeyRound,
};
const actionColors: Record<string, string> = {
@@ -55,6 +58,8 @@ const actionColors: Record<string, string> = {
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) {

View File

@@ -60,6 +60,10 @@ function formatUserEntry(entry: {
return <><span className="font-medium">{entry.user}</span> <span className="text-muted-foreground">sponsored by</span> <span className="font-medium">{String(d.project_key ?? "a project")}</span></>;
case "released":
return <><span className="font-medium">{entry.user}</span> <span className="text-muted-foreground">sponsorship released from</span> <span className="font-medium">{String(d.project_key ?? "a project")}</span></>;
case "member_added":
return <><span className="text-muted-foreground">was granted permission</span> <span className="font-medium">{String(d.group_name ?? "a group")}</span> <span className="text-muted-foreground">by</span> <span className="font-medium">{entry.user}</span></>;
case "member_removed":
return <><span className="text-muted-foreground">had permission</span> <span className="font-medium">{String(d.group_name ?? "a group")}</span> <span className="text-muted-foreground">revoked by</span> <span className="font-medium">{entry.user}</span></>;
default:
return <><span className="font-medium">{entry.user}</span> <span className="text-muted-foreground">{entry.action.replace(/_/g, " ")}</span></>;
}

View File

@@ -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"],
}