Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy
Replaces the single shared HTTP Basic service-account credential (which caused a production outage from a username mismatch) with per-user login: Keycloak (already running on this VM for gcnm, now also fronted on auth.friessn.de with its own "homekeeper" realm) authenticates the user once via the landing page, FastAPI verifies the OIDC id_token and mints its own signed session JWT as a cookie, and both Shiny apps forward that per-session token as a Bearer credential instead of a static shared one. Authorization is a simple ALLOWED_USERS allowlist; the old auth.users table and bcrypt seeding are gone entirely. Also carries forward the in-progress rootless Podman/Quadlet migration (gitea, homekeeper, podman roles) and fixes a pre-existing bug where each role's handlers were malformed inside tasks/main.yml instead of their own handlers/main.yml, which broke ansible-playbook entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
This commit is contained in:
@@ -139,8 +139,9 @@ The local `docker-compose.yml` is for development only. Production uses Quadlet
|
||||
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
|
||||
|
||||
**ENV vars (all services read from docker-compose / Quadlet):**
|
||||
- `API_URL`, `API_USER`, `API_PASS` — Shiny apps
|
||||
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API
|
||||
- `API_URL` — Shiny apps (no credentials needed — auth is per-session via cookie, see below)
|
||||
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH` — API
|
||||
- `ENV`, `COOKIE_SECURE`, `PUBLIC_BASE_URL`, `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_JWT_SECRET`, `ALLOWED_USERS` — API auth (see FastAPI section)
|
||||
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
|
||||
- `PORT` — listkeeper only (default 3838)
|
||||
|
||||
@@ -150,7 +151,7 @@ The local `docker-compose.yml` is for development only. Production uses Quadlet
|
||||
|
||||
Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand.
|
||||
|
||||
**Auth:** HTTP Basic. Users stored in `auth.users` (bcrypt). Seeded from `INITIAL_USERS` env var (`"user1:pass1,user2:pass2"`) on first startup.
|
||||
**Auth:** Keycloak (running on the VM for the unrelated `gcnm` app too, exposed on its own subdomain `auth.friessn.de` with a dedicated `homekeeper` realm — see `infrastructure/group_vars/all.yml`) acts as OIDC identity provider. Login happens once, on the landing page (`/`), via `GET /v1/auth/login` → Keycloak OAuth2 → `GET /v1/auth/callback`. FastAPI verifies Keycloak's `id_token` (RS256, via its JWKS — everything driven off the standard `.well-known/openid-configuration` discovery doc, so the code isn't actually Keycloak-specific), checks the username against the `ALLOWED_USERS` allowlist (comma-separated env var — no DB table, stateless), then mints its own signed session JWT (`SESSION_JWT_SECRET`, HS256, 30-day expiry) and sets it as an `hk_session` cookie on the whole domain. Beekeeper/Listkeeper read that cookie from `session$request` and forward it as `Authorization: Bearer <jwt>` on every API call (see `get_session_token()` in each package's `api_client.R`). `get_current_user()` in `app/auth.py` accepts either the Bearer header or the cookie directly, so `Depends(get_current_user)` in routers is unchanged either way. Local dev has no real OIDC app to test against — `GET /v1/auth/dev-login?username=...` mints a session directly, but only when `ENV=development` (404s otherwise).
|
||||
|
||||
**Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows.
|
||||
|
||||
@@ -180,20 +181,23 @@ Plain Shiny — no golem, no rhino. Each package exports one function: `run_app(
|
||||
```r
|
||||
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
||||
log_init()
|
||||
api <- list(
|
||||
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
|
||||
user = Sys.getenv("API_USER", "homestead"),
|
||||
pass = Sys.getenv("API_PASS", "homestead")
|
||||
)
|
||||
server_fn <- function(input, output, session) {
|
||||
log_info("session_start", session_id = session$token)
|
||||
session$onSessionEnded(function() log_info("session_end", session_id = session$token))
|
||||
# api/db is built per-session (not module-level) — the auth token comes
|
||||
# from the browser's hk_session cookie, so it differs per visitor.
|
||||
api <- list(
|
||||
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
|
||||
token = get_session_token(session)
|
||||
)
|
||||
server(input, output, session, db = api)
|
||||
}
|
||||
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
|
||||
}
|
||||
```
|
||||
|
||||
If `db$token` is `NULL` (no cookie — user never logged in via the landing page), `server()` should render a "please log in" screen instead of the normal nav/content (see `server.R` in either package).
|
||||
|
||||
**Module pattern:**
|
||||
```r
|
||||
screen_example_ui <- function(id) {
|
||||
@@ -303,6 +307,6 @@ Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all ev
|
||||
|
||||
List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query).
|
||||
|
||||
### `auth` — API users
|
||||
### Auth
|
||||
|
||||
**`auth.users`**: `id, username, hashed_password, is_active, created_at` — bcrypt, seeded from `INITIAL_USERS` env var.
|
||||
No DB table — auth is stateless. Identity comes from Keycloak (OIDC), authorization is an `ALLOWED_USERS` env var allowlist, and sessions are self-contained signed JWTs (`hk_session` cookie). See the FastAPI Auth section above.
|
||||
|
||||
Reference in New Issue
Block a user