Skip to content

feat: tenant resolution and the first-run wizard - #120

Merged
OriginDevIT merged 5 commits into
mainfrom
feat/tenant-resolution-wizard
Sep 6, 2026
Merged

feat: tenant resolution and the first-run wizard#120
OriginDevIT merged 5 commits into
mainfrom
feat/tenant-resolution-wizard

Conversation

@OriginDevIT

Copy link
Copy Markdown
Owner

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.

OriginDevIT and others added 5 commits September 5, 2026 23:00
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>
@OriginDevIT
OriginDevIT merged commit 5b49a4d into main Sep 6, 2026
1 check passed
@OriginDevIT
OriginDevIT deleted the feat/tenant-resolution-wizard branch September 6, 2026 13:48
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