diff --git a/CLAUDE.md b/CLAUDE.md index a0b12c1..a1b902a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ frontend/ React + TypeScript lib/ API client (axios), utils, constants chart/mgmt-suite/ Helm chart for Kubernetes deployment -scripts/ Keycloak seeding (seed-keycloak.py — users, groups, memberships, sponsors) +scripts/ Keycloak seeding (seed-keycloak.py) and project deletion (delete-projects.py) ``` ## Running Locally @@ -77,6 +77,11 @@ cd backend && source .venv/bin/activate && python run.py # Frontend cd frontend && npm run dev # Dev server cd frontend && npm run build # Production build (also runs tsc) + +# Project deletion +python scripts/delete-projects.py KEY1 KEY2 --dry-run # Preview +python scripts/delete-projects.py KEY1 KEY2 # Delete (DB + Keycloak) +python scripts/delete-projects.py KEY1 --skip-keycloak # DB only ``` ## Architecture Conventions @@ -159,6 +164,8 @@ cd frontend && npm run build # Production build (also runs tsc) - **Cert encryption** — Fernet key persists to `.fernet_key` file in dev, env var in production. Changing the key makes existing encrypted data unrecoverable. - **Cert passphrase storage** — PKCS12 import passphrases optionally stored encrypted (Fernet) in `passphrase_encrypted` column. Retrieved via separate endpoint for security. PKCS12 export still without passphrase (explicit product decision). - **Downtime Tracker** — Logs application outage events with start/end times, cause, resolution, and lessons learned. Enclave field supports multi-select (IL5, IL6) stored as comma-separated string, serialized as array in API. Scope (disabled/limited) and planned (boolean) classify the nature of outages. Work-hours classification computed client-side using `date-fns-tz` against configurable work hours settings. +- **Project deletion** — No delete API or UI (intentional). Use `scripts/delete-projects.py` for admin-only cleanup. Cascades to: user permissions, feedback, licenses, certs, billing data, KC queue items, KC sync errors, sponsored-user references, and the KC top-level group (which cascades child groups). Supports `--dry-run` and `--skip-keycloak`. +- **Project key format** — 1-10 characters: first character must be an uppercase letter, followed by uppercase letters or digits. Regex: `^[A-Z][A-Z0-9]{0,9}$`. Validated in routes, import service, and frontend form. - **Backend replicas** — scale freely with Postgres. ## Stubs (Not Yet Implemented) diff --git a/scripts/delete-projects.py b/scripts/delete-projects.py new file mode 100755 index 0000000..4e5dbe5 --- /dev/null +++ b/scripts/delete-projects.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Delete projects by key from the local database and enqueue KC group cleanup. + +Usage: + python scripts/delete-projects.py SEED1 SEED2 SEED3 + python scripts/delete-projects.py SEED1 --dry-run + python scripts/delete-projects.py SEED1 --skip-keycloak +""" +import argparse +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from app import create_app +from app.extensions import db +from app.projects.models import Project +from app.users.models import User, UserPermission +from app.feedback.models import Feedback +from app.billing.models import BillingData +from app.licenses.models import License +from app.certs.models import Cert +from app.audit import AuditLog +from app.kc_queue_models import KcQueueItem +from app.settings.models import KcSyncError + + +def delete_project(key, dry_run=False, skip_kc=False): + project = Project.query.filter_by(key=key).first() + if not project: + print(f" [{key}] NOT FOUND — skipping") + return False + + print(f" [{key}] \"{project.name}\" (id={project.id})") + + # Gather counts for summary + perm_count = UserPermission.query.filter_by(project_key=key).count() + feedback_count = Feedback.query.filter_by(project_key=key).count() + license_count = project.licenses.count() + cert_count = project.certs.count() + billing = BillingData.query.filter_by(project_id=project.id).first() + sponsor_count = User.query.filter_by(sponsor_project_key=key).count() + queue_count = KcQueueItem.query.filter_by(project_key=key).count() + sync_err_count = KcSyncError.query.filter_by(project_key=key).count() + + print(f" permissions: {perm_count}, feedback: {feedback_count}, " + f"licenses: {license_count}, certs: {cert_count}") + print(f" billing: {'yes' if billing else 'no'}, " + f"sponsored users: {sponsor_count}, " + f"kc queue items: {queue_count}, sync errors: {sync_err_count}") + + if dry_run: + print(f" DRY RUN — no changes made") + return True + + UserPermission.query.filter_by(project_key=key).delete() + Feedback.query.filter_by(project_key=key).delete() + License.query.filter(License.project_id == project.id).delete() + Cert.query.filter(Cert.project_id == project.id).delete() + if billing: + db.session.delete(billing) + + User.query.filter_by(sponsor_project_key=key).update( + {"sponsor_project_key": None, "unsponsored_since": None} + ) + + KcQueueItem.query.filter_by(project_key=key).delete() + KcSyncError.query.filter_by(project_key=key).delete() + + db.session.delete(project) + db.session.commit() + print(f" DELETED from database") + + if not skip_kc: + try: + from app.keycloak import KeycloakAdmin + kc = KeycloakAdmin() + group_id = kc.find_group_by_name(key) + if group_id: + kc.delete_group(group_id) + print(f" DELETED KC group \"{key}\" ({group_id})") + else: + print(f" KC group \"{key}\" not found — nothing to delete") + except Exception as e: + print(f" WARNING: KC group cleanup failed: {e}") + print(f" You may need to manually remove the \"{key}\" group from Keycloak") + + return True + + +def main(): + parser = argparse.ArgumentParser(description="Delete projects by key") + parser.add_argument("keys", nargs="+", help="Project keys to delete") + parser.add_argument("--dry-run", action="store_true", + help="Show what would be deleted without making changes") + parser.add_argument("--skip-keycloak", action="store_true", + help="Skip Keycloak group deletion (DB only)") + args = parser.parse_args() + + app = create_app() + with app.app_context(): + print(f"{'DRY RUN — ' if args.dry_run else ''}Deleting {len(args.keys)} project(s):\n") + + deleted = 0 + for key in args.keys: + key = key.strip().upper() + if delete_project(key, dry_run=args.dry_run, skip_kc=args.skip_keycloak): + deleted += 1 + print() + + label = "would be deleted" if args.dry_run else "deleted" + print(f"Done. {deleted}/{len(args.keys)} project(s) {label}.") + + +if __name__ == "__main__": + main()