Skip to content

Security: API/WS hardening + authN/authZ (dual-run) - #33

Merged
dibaunaumh merged 7 commits into
masterfrom
security/api-hardening-and-auth
Jul 31, 2026
Merged

Security: API/WS hardening + authN/authZ (dual-run)#33
dibaunaumh merged 7 commits into
masterfrom
security/api-hardening-and-auth

Conversation

@dibaunaumh

Copy link
Copy Markdown

Summary

Brings the bldg-server to an enterprise-appropriate security baseline. Before this, the entire /v1 API 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

  • Secrets out of source — removed the committed secret_key_base, signing salts, and the vestigial cookie session; all signing material is env-sourced in config/runtime.exs (prod fails loud, dev/test use clearly non-secret defaults).
  • SSRF guard (BldgServer.SafeUrl) — validates every battery callback_url at 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 a BATTERY_URL_ALLOWED_HOSTS allow-list.
  • DGraph injection — namespace passed as a bound DQL variable ($ns); entity_type strict-validated. %/_ escaped in user flr before subtree LIKE queries.
  • PIIemail + session_id dropped from floor-channel broadcasts; cleartext Redis-password boot log and PII IO.inspect dumps removed; filter_parameters + a Sentry before_send scrubber added.
  • Transport/CORSforce_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. The sessions table is the revocation list.
  • Auth plugs + router split (ResidentAuth, BatteryAuth) — public / resident-authenticated (reads + mutations) / battery-authenticated / battery-provisioning scopes.
  • Battery service keysregister (gated by BATTERY_PROVISION_TOKEN) provisions a key; only its SHA-256 hash is stored (battery_credentials, migration included).
  • Authorization + mass-assignmentis_authorized_owner? wired into every bldg/road mutation; owners bound to the authenticated creator; owners/session_id/email stripped from client params. Fixes resident impersonationact binds to the token identity, not the body resident_email.
  • WebSocket — connect verifies the token; FloorChannel.join requires it.
  • Dual-run — the :enforce_auth flag (default false, ENFORCE_AUTH override) gates all rejection: plugs assign identity and log but never reject until flipped.

Deploy / rollout

Auth is inert on deploy, so:

  1. Deploy (nothing breaks).
  2. Migration runs on deploy → creates battery_credentials.
  3. Set Fly secrets: MAGIC_LINK_SALT, AUTH_TOKEN_SALT, BATTERY_PROVISION_TOKEN, and the rotated SECRET_KEY_BASE. Optionally CORS_ORIGINS, BATTERY_URL_ALLOWED_HOSTS.
  4. Update the Unity client + batteries to send tokens/keys.
  5. Watch [auth] unauthenticated … warning logs drop to zero, then set ENFORCE_AUTH=true.

Still open / flag before enforcing

  • Secret rotation + git-history purge is an ops task (not in this PR): rotate the leaked SendGrid/Upstash/Postgres creds and scrub them from history — the code no longer depends on any committed secret.
  • Once enforced, reads and ground-floor creates require ownership; give the ground bldg owners or relax authorize_create if top-level creation should stay open.
  • BldgController.act (battery SAY) still takes resident_email from 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

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
dibaunaumh and others added 6 commits July 28, 2026 19:30
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
@dibaunaumh

Copy link
Copy Markdown
Author

🔒 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 (ResidentAuth.machine?/1). That unblocks the batteries + provisioner, but it's a broad grant.

Risk: per-sprite battery keys (file-system-battery, spreadsheet-data-connector) live inside user-controlled sprites. A user could extract their BATTERY_API_KEY and touch other users' buildings, since the key isn't scoped to their own container.

Proposed: scope each battery_credentials row to the bldg subtree it may act on (it already has owner_email); fetch_current_battery loads the scope; authorize_bldg/authorize_container check the target is within it (reuse Address.ancestor?/2) instead of a blanket :ok. Keep the service key as the sole superuser (or scope per-request too).

Tracking here since repo issues are disabled. Introduced in commit feat(auth): let battery keys authorize bldg read/update routes + the socket.

@dibaunaumh
dibaunaumh merged commit cf75e87 into master Jul 31, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant