Skip to content

Make shared login the only way into the admin - #4

Open
alexeygrigorev wants to merge 1 commit into
mainfrom
security/admin-sso-only
Open

Make shared login the only way into the admin#4
alexeygrigorev wants to merge 1 commit into
mainfrom
security/admin-sso-only

Conversation

@alexeygrigorev

Copy link
Copy Markdown
Member

The bypass

GET /admin/login/?local=1 returned Django's username/password form even on a host
with Cognito configured. relay/urls.py routes admin/login/ through
oidc.admin_login ahead of admin.site.urls, so this is the single door to both
/admin/ and the operator UI (@staff_member_required redirects here).

History: the branch arrived in aaec99c "Add shared Cognito login" together with the
adapter, undocumented and untested. At that moment AUTH_* defaulted to empty
everywhere, so local development was already covered by the configured() check
alone — ?local=1 only ever mattered on a deployment that had an identity
provider. Seven minutes later 5c2fecb "Default production to shared Cognito login"
made non-DEBUG default to the sandbox pool, which is the point at which ?local=1
became the only way to reach a password form on a deployed host. Nothing depends on
it: the callback creates operator accounts with no usable password, no deploy script
or runbook mentions it, no test covered it.

With relay.datatalks.club about to be exposed, that is a password endpoint on the
open internet. DataTalksClub/aws-infra#36 compensates with a WAF rule refusing
local=1; authentication policy belongs in the application, and that rule stops
helping the moment a request reaches the app another way.

What changed

  • relay/oidc.pyadmin_login no longer reads any query parameter. Configured →
    always redirect to /auth/login. Not configured and not DEBUG503, the same
    answer begin() already gives, so a deployed host that loses its auth
    configuration fails closed instead of degrading to passwords. Django's password
    form survives only for DEBUG with no AUTH_*, which is a developer's machine
    with no provider to talk to. Recovery on a deployed host is a shell, not a login page.
  • relay/settings.pyAUTH_* no longer default to relay.dtcdev.click and the
    sandbox pool when DEBUG is off. A deployment that did not set them would have
    sent operators to the sandbox callback and failed there with nothing in the app
    noticing. DEBUG=False with AUTH_BASE_URL, AUTH_CLIENT_ID, AUTH_CALLBACK_URL
    or AUTH_ISSUER unset now raises ImproperlyConfigured at import, the same shape
    as the existing SECRET_KEY rule: it fails at rollout, when someone is watching,
    rather than the first time the owner tries to sign in.
  • scripts/deploy_relay_sandbox.sh — writes the sandbox's own AUTH_* into
    /etc/relay/runtime.env, next to the relay.dtcdev.click values it already sets.
    Without this the sandbox would stop starting.
  • .env.example, docs/relay-deployment.md — document the variables and the
    "no local escape hatch on a deployed host" rule.

Tests

tests/test_oidc_auth.py, 8 new cases:

  • test_admin_login_has_no_local_password_escape_hatch — parametrised over
    ?local=1, ?local=1&next=/admin/, ?next=/admin/&local=1, ?LOCAL=1,
    ?local=true in a production-like configuration (DEBUG=False, AUTH_* set):
    302 to /auth/login, no password input.
  • test_admin_login_fails_closed_when_shared_auth_is_unconfigured_in_production — 503.
  • test_admin_login_keeps_the_password_form_for_local_development — the dev path still works.
  • test_shared_auth_settings_have_no_host_defaults_and_fail_loudly — imports the
    settings module in a subprocess with DEBUG=False and empty AUTH_* and asserts
    it refuses to load, naming the four variables.

Five of these fail against the parent commit. Suite: 520 passed before, 528 passed
after; ruff, makemigrations --check, manage.py check clean.

Terraform lane

main/relay must supply, for relay.datatalks.club:

AUTH_BASE_URL=https://<hosted UI domain of the production pool>
AUTH_CLIENT_ID=<production app client id>
AUTH_CALLBACK_URL=https://relay.datatalks.club/auth/callback
AUTH_ISSUER=https://cognito-idp.<region>.amazonaws.com/<pool id>
AUTH_LOGOUT_URL=https://relay.datatalks.club/     # optional
AUTH_JWKS_URL=<override>                          # optional, derived from AUTH_ISSUER

and the pool client must list that callback as an allowed redirect URI, or sign-in
stops at the provider. Without them the container will not start — deliberately.

The WAF rule refusing local=1 can stay as defence in depth; it is no longer the
control.

Other escape hatches found

  • SES_WEBHOOKS_SIGNATURE_MODE (relay/settings.py) defaults to mock under
    DEBUG or TESTING and is env-overridable, so setting it on a deployed host
    disables SNS signature verification. Not changed here — it is an ingress
    authenticity control, not a login, and belongs in its own change.
  • mailing/management/commands/seed_demo_data.py sets a password but refuses to run
    unless DEBUG/TESTING.
  • /internal/ops/status (mailing/ops_views.py) fails closed: no token, no endpoint.
  • ?u= on the click redirect is validated against the campaign's links.
  • No other route reaches admin.site.urls; no header-based auth branches.

`/admin/login/?local=1` returned Django's password form even where Cognito
was configured. The escape hatch arrived with the OIDC adapter and nothing
ever used it: operator accounts are created by the callback without a usable
password, no deploy script or document mentions it, and no test covered it.
On a public hostname it is a password endpoint on the open internet, which is
why the infrastructure change fronting Relay carries a WAF rule refusing the
parameter. Authentication policy belongs in the application, and that rule
stops helping the moment a request reaches the app another way.

The parameter is gone. Django's password form survives only where there is no
identity provider to talk to: DEBUG with no AUTH_* configured, which is a
developer's machine. A deployed host that has lost its auth configuration now
answers 503 instead of quietly degrading to passwords.

The AUTH_* settings also defaulted to the sandbox host and pool whenever DEBUG
was off, so any other hostname would have sent operators to
relay.dtcdev.click's callback and failed there with nothing in the app
noticing. A deployed host now states its own pool and callback, and Django
refuses to start with DEBUG=False and them unset. The sandbox deploy script
supplies the values it already knew.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant