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:
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user