Compare commits

...

6 Commits

Author SHA1 Message Date
985c06469b Update keycloak setup docs with --setup-only workflow and OIDC details
Adds production setup instructions using the automated script, documents
the OIDC client setup (both automated and manual), and adds the missing
OIDC env vars to the reference table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:48:24 -07:00
220d0587d5 Add --setup-only and --base-uri flags to seed-keycloak script
--setup-only creates just the realm, service client, OIDC client, and
sponsor attribute — no demo users, groups, or memberships. Ideal for
production Keycloak setup.

--base-uri sets the OIDC redirect URIs and web origins to the given
app URL instead of localhost. Also updates URIs on existing clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:46:21 -07:00
bce35c6035 Fix Keycloak diagnostics showing green connectors when not connected
Connector lines between steps were green for skipped checks, making it
look like the chain succeeded even with no connection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:41:26 -07:00
5a70b62182 Shrink cost center settings table to show ~3 rows
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:33:24 -07:00
852686d123 Add delete all cost centers button on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:32:21 -07:00
125f097df3 Fix cost center CSV upload by setting multipart/form-data header
The global axios instance defaults to application/json, which prevents
FormData from being sent correctly. Other file uploads already set the
header explicitly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:28:19 -07:00
6 changed files with 197 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
)}
/>
)}

View File

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