Compare commits
6 Commits
a1f54f4775
...
985c06469b
| Author | SHA1 | Date | |
|---|---|---|---|
| 985c06469b | |||
| 220d0587d5 | |||
| bce35c6035 | |||
| 5a70b62182 | |||
| 852686d123 | |||
| 125f097df3 |
@@ -1753,6 +1753,21 @@ def delete_cost_center(center_id):
|
||||
return jsonify({"message": "Deleted"}), 200
|
||||
|
||||
|
||||
@settings_bp.route("/cost-centers", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_all_cost_centers():
|
||||
"""Delete all cost centers."""
|
||||
from app.settings.models import CostCenter
|
||||
count = CostCenter.query.count()
|
||||
CostCenter.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
from app.audit import log_audit
|
||||
log_audit("settings", None, "cost_centers_deleted_all", {"count": count})
|
||||
|
||||
return jsonify({"message": f"Deleted {count} cost centers"}), 200
|
||||
|
||||
|
||||
@settings_bp.route("/cost-centers/upload", methods=["POST"])
|
||||
@admin_required
|
||||
def upload_cost_centers():
|
||||
|
||||
@@ -10,7 +10,41 @@ OSA Suite uses a **service-account client** with Admin API access. You need:
|
||||
2. A client with **Service Accounts Enabled** and the `realm-admin` role
|
||||
3. The client ID and secret
|
||||
|
||||
### Creating the Client in Keycloak
|
||||
### Automated Setup (Recommended)
|
||||
|
||||
The `seed-keycloak.py` script can create everything you need. For production, use `--setup-only` to create just the infrastructure without demo data:
|
||||
|
||||
```bash
|
||||
python scripts/seed-keycloak.py --setup-only \
|
||||
--url https://keycloak.example.com \
|
||||
--admin-user admin \
|
||||
--admin-password changeme \
|
||||
--realm osa \
|
||||
--base-uri https://your-app.example.com
|
||||
```
|
||||
|
||||
This creates:
|
||||
1. The **realm** (`osa` by default)
|
||||
2. **`osa-admin-client`** — service account with `realm-admin` role (for backend Admin API calls)
|
||||
3. **`osa-web`** — OIDC client for browser-based SSO login, with redirect URIs pointed at your `--base-uri`
|
||||
4. The **`sponsor`** custom user attribute in the KC user profile
|
||||
|
||||
The script prints the env vars you need at the end:
|
||||
|
||||
```
|
||||
KEYCLOAK_URL=https://keycloak.example.com
|
||||
KEYCLOAK_REALM=osa
|
||||
KEYCLOAK_CLIENT_ID=osa-admin-client
|
||||
KEYCLOAK_CLIENT_SECRET=<printed>
|
||||
KEYCLOAK_OIDC_CLIENT_ID=osa-web
|
||||
KEYCLOAK_OIDC_CLIENT_SECRET=<printed>
|
||||
```
|
||||
|
||||
The script is idempotent — re-running it will skip existing resources. If you pass `--base-uri` and the OIDC client already exists, it will update the redirect URIs.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you prefer to create the client manually in the Keycloak admin console:
|
||||
|
||||
1. Go to your realm > **Clients** > **Create client**
|
||||
2. Set **Client ID** to `osa-admin-client`
|
||||
@@ -20,6 +54,22 @@ OSA Suite uses a **service-account client** with Admin API access. You need:
|
||||
6. Click **Assign role** > filter by clients > assign `realm-management` > `realm-admin`
|
||||
7. Go to the **Credentials** tab and copy the **Client secret**
|
||||
|
||||
Then create the OIDC client for browser login:
|
||||
|
||||
1. **Clients** > **Create client**
|
||||
2. Set **Client ID** to `osa-web`
|
||||
3. Set **Client authentication** to **On**, **Standard flow** to **On**
|
||||
4. Set **Valid redirect URIs** to `https://your-app.example.com/api/auth/oidc/callback` and `https://your-app.example.com/*`
|
||||
5. Set **Web origins** to `https://your-app.example.com`
|
||||
6. Under **Advanced** > **post.logout.redirect.uris**, set to `https://your-app.example.com/*`
|
||||
7. Copy the **Client secret** from the **Credentials** tab
|
||||
|
||||
Finally, register the `sponsor` custom attribute:
|
||||
|
||||
1. Go to **Realm settings** > **User profile**
|
||||
2. Add attribute: name `sponsor`, display name "Sponsor Project"
|
||||
3. Set permissions: view = admin, edit = admin
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -28,6 +78,8 @@ OSA Suite uses a **service-account client** with Admin API access. You need:
|
||||
| `KEYCLOAK_REALM` | `osa` | Realm name |
|
||||
| `KEYCLOAK_CLIENT_ID` | `osa-admin-client` | Service-account client ID |
|
||||
| `KEYCLOAK_CLIENT_SECRET` | *(empty)* | Service-account client secret |
|
||||
| `KEYCLOAK_OIDC_CLIENT_ID` | `osa-web` | OIDC client ID for browser login |
|
||||
| `KEYCLOAK_OIDC_CLIENT_SECRET` | *(empty)* | OIDC client secret |
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -39,6 +91,8 @@ KEYCLOAK_URL=http://localhost:8180
|
||||
KEYCLOAK_REALM=osa
|
||||
KEYCLOAK_CLIENT_ID=osa-admin-client
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
KEYCLOAK_OIDC_CLIENT_ID=osa-web
|
||||
KEYCLOAK_OIDC_CLIENT_SECRET=your-oidc-secret
|
||||
```
|
||||
|
||||
To run a local Keycloak instance for testing:
|
||||
@@ -51,7 +105,11 @@ docker run -d --name keycloak \
|
||||
quay.io/keycloak/keycloak:26.2 start-dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:8180`, log in as `admin`/`admin`, create the `osa` realm and service-account client as described above.
|
||||
Then seed it automatically:
|
||||
|
||||
```bash
|
||||
python scripts/seed-keycloak.py # creates realm, clients, demo users, groups, etc.
|
||||
```
|
||||
|
||||
Start the app normally:
|
||||
|
||||
@@ -62,9 +120,9 @@ Start the app normally:
|
||||
|
||||
Check the **Admin > Connection Status** panel to verify connectivity.
|
||||
|
||||
### Seeding Sample Data
|
||||
### Seeding Demo Data
|
||||
|
||||
Users are seeded **only in Keycloak** — the backend `seed.py` handles projects, licenses, and certs but not users. A standalone script populates Keycloak with all user data:
|
||||
For local development, run the script **without** `--setup-only` to populate Keycloak with demo users and groups:
|
||||
|
||||
```bash
|
||||
# Local Keycloak with defaults (localhost:8180, admin/admin)
|
||||
@@ -80,12 +138,11 @@ python scripts/seed-keycloak.py \
|
||||
KEYCLOAK_URL=http://keycloak:8080 python scripts/seed-keycloak.py
|
||||
```
|
||||
|
||||
The script creates:
|
||||
- The realm and clients (`osa-admin-client` with `realm-admin` role, `osa-web` for OIDC login)
|
||||
- The `sponsor` custom user attribute
|
||||
In addition to the infrastructure (realm, clients, sponsor attribute), the full seed creates:
|
||||
- All seed users with username, email, first/last name, and password `password`
|
||||
- Project groups and standard child groups (bitbucket, srm, coverity, mgmt × read/write/admin)
|
||||
- Group memberships: each user gets their mgmt child group plus **random additional app child groups** (bitbucket, srm, coverity) with deterministic randomness
|
||||
- `VELA-mgmt-billing` child group for billing access
|
||||
- Group memberships: each user gets their mgmt child group plus random additional app child groups with deterministic randomness
|
||||
- Sponsor attributes on eligible users
|
||||
|
||||
On re-runs, existing users are updated to ensure correct username/email/name fields. It prints the connection env vars at the end.
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useConfirmCostCentersUpload,
|
||||
useAddCostCenter,
|
||||
useDeleteCostCenter,
|
||||
useDeleteAllCostCenters,
|
||||
type CostCenterUploadPreview,
|
||||
} from "@/hooks/use-cost-centers";
|
||||
|
||||
@@ -37,6 +38,7 @@ export function CostCenterManager() {
|
||||
const confirmUpload = useConfirmCostCentersUpload();
|
||||
const addCostCenter = useAddCostCenter();
|
||||
const deleteCostCenter = useDeleteCostCenter();
|
||||
const deleteAll = useDeleteAllCostCenters();
|
||||
|
||||
const [newCc, setNewCc] = useState("");
|
||||
const [newCn, setNewCn] = useState("");
|
||||
@@ -138,6 +140,24 @@ export function CostCenterManager() {
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
{totalCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => {
|
||||
if (!confirm(`Delete all ${totalCount} cost centers?`)) return;
|
||||
deleteAll.mutate(undefined, {
|
||||
onSuccess: () => toast.success("All cost centers deleted"),
|
||||
onError: () => toast.error("Failed to delete"),
|
||||
});
|
||||
}}
|
||||
disabled={deleteAll.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-3.5" />
|
||||
Delete All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -199,7 +219,7 @@ export function CostCenterManager() {
|
||||
No cost centers configured. Upload a CSV or add one manually.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border max-h-64 overflow-y-auto">
|
||||
<div className="rounded-md border max-h-40 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -30,7 +30,9 @@ export function useUploadCostCentersCsv() {
|
||||
mutationFn: async (file) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const { data } = await api.post("/settings/cost-centers/upload", form);
|
||||
const { data } = await api.post("/settings/cost-centers/upload", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -73,3 +75,15 @@ export function useDeleteCostCenter() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAllCostCenters() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, void>({
|
||||
mutationFn: async () => {
|
||||
await api.delete("/settings/cost-centers");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "cost-centers"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function KeycloakDiagnosticsSection() {
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 mt-[23px] h-0.5 w-8 self-start sm:w-12",
|
||||
isOk || isSkipped ? "bg-emerald-400" : "bg-border"
|
||||
isOk ? "bg-emerald-400" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,15 @@ Usage:
|
||||
# Against local Keycloak (defaults)
|
||||
python scripts/seed-keycloak.py
|
||||
|
||||
# Against a remote instance
|
||||
# Production setup — realm + clients only, no demo data
|
||||
python scripts/seed-keycloak.py --setup-only \
|
||||
--url https://keycloak.example.com \
|
||||
--admin-user admin \
|
||||
--admin-password changeme \
|
||||
--realm osa \
|
||||
--base-uri https://myapp.example.com
|
||||
|
||||
# Against a remote instance with demo data
|
||||
python scripts/seed-keycloak.py \
|
||||
--url https://keycloak.example.com \
|
||||
--admin-user admin \
|
||||
@@ -635,8 +643,21 @@ class KeycloakSeeder:
|
||||
|
||||
# ── OIDC client (for browser login) ─────────────────────────
|
||||
|
||||
def ensure_oidc_client(self):
|
||||
def ensure_oidc_client(self, base_uri=None):
|
||||
"""Create the osa-web OIDC client for browser-based login. Returns client secret."""
|
||||
if base_uri:
|
||||
base = base_uri.rstrip("/")
|
||||
redirect_uris = [f"{base}/api/auth/oidc/callback", f"{base}/*"]
|
||||
web_origins = [base]
|
||||
logout_uris = f"{base}/*"
|
||||
else:
|
||||
redirect_uris = [
|
||||
"http://localhost:5001/api/auth/oidc/callback",
|
||||
"http://localhost:5001/*",
|
||||
]
|
||||
web_origins = ["http://localhost:5001", "http://localhost:5173"]
|
||||
logout_uris = "http://localhost:5173/*"
|
||||
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients?clientId={OIDC_CLIENT_ID}"),
|
||||
headers=self._headers(),
|
||||
@@ -646,6 +667,26 @@ class KeycloakSeeder:
|
||||
if clients:
|
||||
client_uuid = clients[0]["id"]
|
||||
print(f" OIDC client '{OIDC_CLIENT_ID}' already exists (id={client_uuid[:8]}...)")
|
||||
if base_uri:
|
||||
# Update redirect URIs and web origins on existing client
|
||||
resp = requests.put(
|
||||
self._admin_url(f"/clients/{client_uuid}"),
|
||||
headers=self._headers(),
|
||||
json={
|
||||
**clients[0],
|
||||
"redirectUris": redirect_uris,
|
||||
"webOrigins": web_origins,
|
||||
"attributes": {
|
||||
**clients[0].get("attributes", {}),
|
||||
"post.logout.redirect.uris": logout_uris,
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 204:
|
||||
print(f" OIDC client redirect URIs updated for {base}")
|
||||
else:
|
||||
print(f" WARN: Failed to update OIDC client URIs: {resp.status_code}")
|
||||
else:
|
||||
resp = requests.post(
|
||||
self._admin_url("/clients"),
|
||||
@@ -660,13 +701,10 @@ class KeycloakSeeder:
|
||||
"standardFlowEnabled": True,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"serviceAccountsEnabled": False,
|
||||
"redirectUris": [
|
||||
"http://localhost:5001/api/auth/oidc/callback",
|
||||
"http://localhost:5001/*",
|
||||
],
|
||||
"webOrigins": ["http://localhost:5001", "http://localhost:5173"],
|
||||
"redirectUris": redirect_uris,
|
||||
"webOrigins": web_origins,
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "http://localhost:5173/*",
|
||||
"post.logout.redirect.uris": logout_uris,
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
@@ -867,40 +905,57 @@ def main():
|
||||
default=os.environ.get("KEYCLOAK_REALM", "osa"),
|
||||
help="Realm name to create/seed (default: $KEYCLOAK_REALM or osa)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--setup-only",
|
||||
action="store_true",
|
||||
help="Only create realm, clients, and sponsor attribute — no demo users/groups/data",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-uri",
|
||||
default=None,
|
||||
help="App base URI for OIDC redirect URIs (e.g. https://myapp.example.com). "
|
||||
"Defaults to localhost for dev.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Seeding Keycloak at {args.url}, realm '{args.realm}'")
|
||||
mode = "setup-only" if args.setup_only else "full seed"
|
||||
print(f"Seeding Keycloak at {args.url}, realm '{args.realm}' ({mode})")
|
||||
if args.base_uri:
|
||||
print(f" OIDC redirect base: {args.base_uri}")
|
||||
print()
|
||||
|
||||
seeder = KeycloakSeeder(args.url, args.admin_user, args.admin_password, args.realm)
|
||||
|
||||
print("[1/9] Realm")
|
||||
total = 4 if args.setup_only else 9
|
||||
|
||||
print(f"[1/{total}] Realm")
|
||||
seeder.ensure_realm()
|
||||
|
||||
print("[2/9] Service client")
|
||||
print(f"[2/{total}] Service client")
|
||||
secret = seeder.ensure_service_client()
|
||||
|
||||
print("[3/9] OIDC client")
|
||||
oidc_secret = seeder.ensure_oidc_client()
|
||||
print(f"[3/{total}] OIDC client")
|
||||
oidc_secret = seeder.ensure_oidc_client(base_uri=args.base_uri)
|
||||
|
||||
print("[4/9] User profile (sponsor attribute)")
|
||||
print(f"[4/{total}] User profile (sponsor attribute)")
|
||||
seeder.ensure_sponsor_attribute()
|
||||
|
||||
print("[5/9] Users")
|
||||
user_map = seeder.create_users()
|
||||
if not args.setup_only:
|
||||
print(f"[5/{total}] Users")
|
||||
user_map = seeder.create_users()
|
||||
|
||||
print("[6/9] User passwords")
|
||||
seeder.set_user_passwords(user_map)
|
||||
print(f"[6/{total}] User passwords")
|
||||
seeder.set_user_passwords(user_map)
|
||||
|
||||
print("[7/9] Groups & child groups")
|
||||
group_map = seeder.create_groups()
|
||||
child_map = seeder.create_child_groups(group_map)
|
||||
print(f"[7/{total}] Groups & child groups")
|
||||
group_map = seeder.create_groups()
|
||||
child_map = seeder.create_child_groups(group_map)
|
||||
|
||||
print("[8/9] Memberships")
|
||||
seeder.assign_memberships(user_map, group_map, child_map)
|
||||
print(f"[8/{total}] Memberships")
|
||||
seeder.assign_memberships(user_map, group_map, child_map)
|
||||
|
||||
print("[9/9] Sponsor attributes")
|
||||
seeder.assign_sponsors(user_map)
|
||||
print(f"[9/{total}] Sponsor attributes")
|
||||
seeder.assign_sponsors(user_map)
|
||||
|
||||
print()
|
||||
print("Done! To connect OSA Suite to this Keycloak instance:")
|
||||
|
||||
Reference in New Issue
Block a user