feat: tenant resolution and the first-run wizard - #120
Merged
Conversation
TenantResolutionMiddleware runs FIRST -- before SecurityMiddleware -- because with ALLOWED_HOSTS=['*'] nothing else validates the Host header, and SecurityMiddleware's SSL redirect would build a URL from the raw host. It is now the authoritative host gate. - Console host (OSDS_CONSOLE_HOST) -> request.urlconf = osds.urls_console, no tenant scope. - Tenant host (Tenant.primary_domain, any verification state) -> request.tenant set, ambient scope entered, request.urlconf = osds.urls_tenant. A suspended tenant gets a 503. - Anything else -> plain 404, no scope, no view. In DEBUG, OSDS_DEV_TENANT_SLUG is tried first so localhost reaches a directory. The ambient tenant scope (osds.tenancy) is set before get_response and reset in a finally, so it is torn down on the normal path, on an early 404/503, and when a view raises. No host cache: one lookup on the unique primary_domain index per request. ROOT_URLCONF is now an empty safety net; console and tenant get their own URLconfs, switched per request. The first-run branch (route everything to the wizard until setup is complete) is added with the wizard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5qqrLcxpVZRis5jbN5pKD Signed-off-by: Matthew Wren <info@origindev.com>
Adds the runtime dependency **cryptography** (50.0.1, + cffi, pycparser) for Fernet. Requested explicitly for the Secret model. - InstallSetup: single row (pk=1). token_hash of the first-run setup token, and completed_at. `completed_at IS NULL` is the authoritative "setup still running" gate that host resolution and the wizard read. - Secret: scope (deployment | tenant), nullable tenant FK, key, ciphertext. tenants.secrets does Fernet encrypt/decrypt with a key derived (HKDF-SHA256) from a new OSDS_SECRET_KEY env var, kept separate from Django's SECRET_KEY. get_secret resolves tenant override -> deployment -> ConfigurationError. Plain manager, added to the scoped-manager test allowlist with its reason (resolution spans both scopes). - ensure_setup_token management command: idempotent, mints the token once and prints it, for the container entrypoint to call on every start. Not AppConfig.ready() -- that also runs under manage.py, migrations and tests. Migration tenants/0002. check, makemigrations --check and the full suite (51 tests) pass on Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5qqrLcxpVZRis5jbN5pKD Signed-off-by: Matthew Wren <info@origindev.com>
TenantResolutionMiddleware now routes every host to the wizard (osds.urls_setup) until InstallSetup.completed_at is set -- with one exception, the domain-verification challenge endpoint, so the wizard's own HTTP check can pass. Wizard (tenants/wizard): unlock -> account -> directory -> domain -> storage -> smtp -> claims -> done. Each step writes through tenants/services.py as it completes; the current step is derived from data (tenants/setup_state.next_step), so closing the browser and coming back -- any device, any process -- resumes at the first unfinished step. A fresh session re-enters the log token, then lands past the steps already done. Every view 404s once completed_at is set. Service layer + emit: - audit/outbox.py: emit() writes one OutboxEvent row, called inside the service transaction. - create_tenant emits tenant.created; the superadmin's own admin membership on the first directory emits staff.invited then staff.accepted (spec §4.4); domain and settings writes emit tenant.settings_changed; a passing check emits tenant.domain_verified with method "http". No invented event names. DNS check (tenants/dns_check.py): GET http://<domain>/.well-known/ osds-challenge, compare the body to the tenant's challenge token -- proves DNS and reachability together. Bounded 3s timeout, safe in the request. A failure re-renders the step with the reason and emits nothing; the operator can continue unverified. Repeated re-checks belong on the worker tick (later). Storage/SMTP credentials go through tenants.secrets.set_secret, not the settings JSON. Setup-token entry has a constant-time compare and a session attempt cap; an IP-based limiter is a later cross-cutting concern. No model changes, no migration. check, makemigrations --check and the full suite (74 tests) pass on Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5qqrLcxpVZRis5jbN5pKD Signed-off-by: Matthew Wren <info@origindev.com>
Settings carried a checked-in django-insecure SECRET_KEY, a hard-coded
DEBUG = True, and an OSDS_SECRET_KEY that defaulted to "" and only
failed on first secret use -- an operator would have hit that at the
storage step of the wizard rather than at boot.
- SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] -- raises at import when
unset, like DATABASE_URL. Literal removed.
- OSDS_SECRET_KEY = os.environ['OSDS_SECRET_KEY'] -- same treatment.
- DEBUG = os.environ.get('DJANGO_DEBUG', '') truthy check, default off.
- The OSDS_DEV_TENANT_SLUG fallback in TenantResolutionMiddleware is
now gated on settings.DEBUG at the call site as well as inside
_dev_tenant, so it is inert in production.
- .env.example documents the three required vars.
CI already sets DJANGO_SECRET_KEY and DJANGO_DEBUG. It does NOT set
OSDS_SECRET_KEY; add to the `env:` block in .github/workflows/ci.yml:
OSDS_SECRET_KEY: ci-only-not-a-secret
No test needed fixing: the two tests that depend on DEBUG already pin
it with @override_settings(DEBUG=True). Full suite (74) green on
Postgres with DJANGO_DEBUG unset.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5qqrLcxpVZRis5jbN5pKD
Signed-off-by: Matthew Wren <info@origindev.com>
Settings now raise at import when it is unset, so every manage.py invocation fails without it. Signed-off-by: Matthew Wren <info@origindev.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Host-header tenant resolution with the middleware as the authoritative host gate, ahead of SecurityMiddleware. InstallSetup singleton, Fernet-encrypted Secret with deployment/tenant resolution order, and the ensure_setup_token command. First-run wizard writing through the service layer at each step, resumable from data rather than a cursor. HTTP challenge for domain verification, proving DNS and reachability together. SECRET_KEY, DEBUG and OSDS_SECRET_KEY now come from the environment and raise when unset.