Security: API/WS hardening + authN/authZ (dual-run) - #33
Conversation
Brings the bldg-server to an enterprise-appropriate security baseline. Two tranches, shipped together but independently valuable; the auth layer is inert until ENFORCE_AUTH=true, so this deploys without breaking existing clients. Tranche 1 — client-independent hardening - Secrets out of source: remove committed secret_key_base + signing salts and the vestigial cookie session; all signing material is env-sourced in runtime.exs (prod fails loud, dev/test use clearly non-secret defaults). - SSRF guard (BldgServer.SafeUrl): validate every battery callback_url (changeset, register, and the dispatch sink) — reject non-http(s) and loopback/link-local (incl. 169.254.169.254)/private targets. Gated by :block_private_callback_urls (strict in prod), with a BATTERY_URL_ALLOWED_HOSTS allow-list. - DGraph injection: pass namespace as a bound DQL variable ($ns); strict-validate entity_type. Escape %/_ in user flr before subtree LIKE queries. - PII: drop email + session_id from floor-channel broadcasts (serialize_resident_public); remove the cleartext Redis-password boot log and PII IO.inspect dumps; add filter_parameters and a Sentry before_send scrubber. - Transport/CORS: force_ssl + HSTS in prod; CORS allow-list instead of wildcard. Tranche 2 — authN/authZ (dual-run) - Bearer token (BldgServer.Token) issued on verification_status/login; sent as Authorization: Bearer and as the socket token param; sessions table is the revocation list. - ResidentAuth/BatteryAuth plugs + router split: public / resident-authenticated (reads + mutations) / battery-authenticated / battery-provisioning scopes. - Battery service keys: register (gated by BATTERY_PROVISION_TOKEN) provisions a key; only its sha256 hash is stored (battery_credentials). - Authorization wired into every bldg/road mutation via is_authorized_owner?; owners bound to the authenticated creator; owners/session_id/email stripped from client params (mass-assignment). Fixes resident impersonation — act binds to the token identity, not the body resident_email. - WebSocket connect verifies the token; FloorChannel.join requires it. - Dual-run: :enforce_auth flag (default false, ENFORCE_AUTH override) gates all rejection — plugs assign + log but never reject until flipped. Tests: 226 passing, incl. SSRF ranges, token validation, enforced 401/403, impersonation, socket connect, and battery credential/provisioning. Rollout: deploy (auth inert) -> migrate -> set MAGIC_LINK_SALT/AUTH_TOKEN_SALT/ BATTERY_PROVISION_TOKEN + rotated SECRET_KEY_BASE -> update clients to send tokens -> watch "[auth] unauthenticated" logs drop -> set ENFORCE_AUTH=true. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
Add the new auth signing secrets (MAGIC_LINK_SALT, AUTH_TOKEN_SALT) and the optional vars (ENFORCE_AUTH, BATTERY_PROVISION_TOKEN, CORS_ORIGINS, BATTERY_URL_ALLOWED_HOSTS) to the [env] header comments so a deployer sees exactly what fly secrets are required before the app can boot (it fails loud on a missing signing secret). Non-secret documentation only — no values. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
Both Fly stacks run MIX_ENV=prod, so the previous prod deny-by-default blocked every browser (web + Unity WebGL) client — preflights returned 204 with no Access-Control-Allow-Origin and the browser dropped the request; native Unity (no Origin header) was unaffected. Make CORS opt-in like ENFORCE_AUTH: unset/ blank CORS_ORIGINS = allow any origin (*), a set list locks down to specific web origins. No cookie auth is used (bearer tokens ride the Authorization header), so a permissive default is low-risk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
…-goals alice-in-goals is a first-party backend that provisions residents/bldgs and embeds the Unity web client. It must (a) authenticate its provisioning calls and (b) obtain a bearer token for the web client (which logs in via Google OAuth, not bldg-server's magic link). - Service auth: a request presenting the shared BLDG_SERVER_API_KEY as a bearer token is recognized as the privileged provisioner (fetch_current_service). require_authenticated_resident/battery accept it and authorize_* bypass ownership for it, so alice-in-goals' create/update/staging calls pass under enforcement. - POST /v1/residents/:id/token (service-only, ALWAYS enforced — never relaxed in dual-run since it mints credentials) creates a fresh VERIFIED session and returns a resident bearer token for the web client to present. - service_api_key sourced from BLDG_SERVER_API_KEY env. Tests: service key required for mint_token (401 without), minted token validates, and the service key authorizes a protected mutation under enforcement. 229 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
…ent tokens Repeated mints (dashboard cookie-validate + Unity token-validate both hit render_user_with_profile) would otherwise create+replace sessions each time and invalidate each other's tokens. Reuse the resident's current VERIFIED session when present; create a fresh one only if none exists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
…socket Batteries call bldg read/update endpoints (look/scan/update_bldg/by_bldg_url — resident-auth routes) and the file-system-battery dials the floor channel. The battery key only satisfied battery-auth routes, so those would 401/reject under enforcement. - fetch_current_battery now runs in the base :api pipeline (short-circuits when a resident/service already matched, so resident requests skip the DB lookup). - require_authenticated_resident + authorize_* accept a battery as a machine principal (machine?/1 = service or battery), bypassing per-resident ownership. - UserSocket.connect accepts a battery key (or the service key) as the `token` param; FloorChannel.join accepts machine principals. NOTE: battery keys are currently UNSCOPED — any battery can read/update any bldg, same broad grant as the service key. Scoping battery authorization to the container it's attached to is tracked as a follow-up enhancement. 232 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
…ntial POST /v1/batteries/credentials (service-authenticated) mints a per-battery API key without registering a callback. The alice-in-goals provisioner calls it to create a scoped key for each per-sprite battery and inject it into the sprite, so no shared master secret is placed in a user-controlled sandbox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3
🔒 Follow-up enhancement: scope battery/service authorization (not blocking)The battery-auth widening in this PR lets a battery key (and the service key) authorize any bldg read/update/delete by bypassing per-resident ownership ( Risk: per-sprite battery keys (file-system-battery, spreadsheet-data-connector) live inside user-controlled sprites. A user could extract their Proposed: scope each Tracking here since repo issues are disabled. Introduced in commit |
Summary
Brings the bldg-server to an enterprise-appropriate security baseline. Before this, the entire
/v1API and the WebSocket layer were unauthenticated and unauthorized end-to-end, with an unvalidated SSRF sink, DQL injection, live committed secrets, and PII leaking over broadcasts/logs.The work is two tranches, shipped together but independently valuable. The auth layer is inert until
ENFORCE_AUTH=true, so this is safe to deploy without breaking the existing Unity client or batteries — they keep working while you roll tokens out.226 tests pass (was 199; +27 new security tests).
Tranche 1 — client-independent hardening
secret_key_base, signing salts, and the vestigial cookie session; all signing material is env-sourced inconfig/runtime.exs(prod fails loud, dev/test use clearly non-secret defaults).BldgServer.SafeUrl) — validates every batterycallback_urlat the changeset,register, and dispatch sink; rejects non-http(s)and loopback/link-local (incl.169.254.169.254)/private targets. Gated by:block_private_callback_urls(strict in prod) with aBATTERY_URL_ALLOWED_HOSTSallow-list.$ns);entity_typestrict-validated.%/_escaped in userflrbefore subtreeLIKEqueries.email+session_iddropped from floor-channel broadcasts; cleartext Redis-password boot log and PIIIO.inspectdumps removed;filter_parameters+ a Sentrybefore_sendscrubber added.force_ssl+ HSTS in prod; CORS allow-list instead of wildcard.Tranche 2 — authN/authZ (dual-run)
BldgServer.Token) issued onverification_status/login; sent asAuthorization: Bearerand as the sockettokenparam. Thesessionstable is the revocation list.ResidentAuth,BatteryAuth) — public / resident-authenticated (reads + mutations) / battery-authenticated / battery-provisioning scopes.register(gated byBATTERY_PROVISION_TOKEN) provisions a key; only its SHA-256 hash is stored (battery_credentials, migration included).is_authorized_owner?wired into every bldg/road mutation;ownersbound to the authenticated creator;owners/session_id/emailstripped from client params. Fixes resident impersonation —actbinds to the token identity, not the bodyresident_email.FloorChannel.joinrequires it.:enforce_authflag (defaultfalse,ENFORCE_AUTHoverride) gates all rejection: plugs assign identity and log but never reject until flipped.Deploy / rollout
Auth is inert on deploy, so:
battery_credentials.MAGIC_LINK_SALT,AUTH_TOKEN_SALT,BATTERY_PROVISION_TOKEN, and the rotatedSECRET_KEY_BASE. OptionallyCORS_ORIGINS,BATTERY_URL_ALLOWED_HOSTS.[auth] unauthenticated …warning logs drop to zero, then setENFORCE_AUTH=true.Still open / flag before enforcing
authorize_createif top-level creation should stay open.BldgController.act(battery SAY) still takesresident_emailfrom the body (a battery relays for many residents); it's battery-gated rather than identity-bound.🤖 Generated with Claude Code
https://claude.ai/code/session_016gmoYxN6AShD1j5sjWKCr3