feat: add multi-tenant routing for Traefik nodes #2

Merged
ryan.mcgee merged 9 commits from feat/core-routing into main 2026-10-15 18:06:06 +00:00
Owner

Summary

  • First feature milestone: multi-tenant routing of many Traefik nodes from one control plane
  • An admin signs in, works inside an organization, registers Traefik nodes, defines HTTP resources with load-balanced targets, and publishes them on chosen nodes; each node polls its own authenticated endpoint and serves the routes
  • Multi-tenancy is built in from the start: users belong to many organizations with per-organization roles, tenant data is only reachable through a membership-verified scope, and server admins get no implicit tenant access

Changes

  • Accounts: email + password (Argon2), no SMTP needed. The first user becomes server admin; later signup is invite_only (default) or open via TRAEFIK_CONTROL_SIGNUP
  • Organizations and memberships (admin / member); invitations bound to an email, single-use, expiring, token shown once and stored as SHA-256; member management with a last-admin guard
  • Nodes: tenant-owned, bearer token stored as SHA-256 and shown once, regenerate to revoke, online status from last_seen_at (throttled conditional UPDATE)
  • Resources, targets (http/https/h2c) and node assignment; strict hostname validation because domains are interpolated into Traefik rules, re-validated by the renderer as the injection sink
  • GET /api/traefik/v1/config: pure DynamicConfig.render/2, uniform 401, organization derived from the node, never the request
  • Dev stack: Traefik uses the HTTP provider, idempotent :dev-only seed, whoami backend, all ports on loopback; README rewritten
  • SQLite and PostgreSQL stay supported; migrations are portable

Verification

  • mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict (no issues), mix dialyzer (0 errors): pass
  • mix test: 274 passed on SQLite and 274 on PostgreSQL (includes a cross-tenant isolation suite)
  • podman-compose up against real Traefik: node API 401 without/with a wrong token and 200 with the node token; Traefik installed the router and service from the http provider; curl -H 'Host: whoami.localhost' returned the whoami response and an unknown host returned 404
  • Browser (Chromium): login, node shows online, created a resource and target in the UI and Traefik served it about 6 s later; removing the target returned it to 404 while the other route kept working; invite link registered a new user who landed as a read-only member (no New/Edit/Delete, no invite form); an unknown organization shows the same message as a non-member one
  • Dev seed is idempotent across an app restart (row counts unchanged)
  • Real bugs found by this process and fixed: malformed node_ids crashed on PostgreSQL only (fixed with Ecto.UUID); bytea size modifier invalid on PostgreSQL; numeric-final-label hostnames rejected; missing UserToken.t() type
## Summary - First feature milestone: multi-tenant routing of many Traefik nodes from one control plane - An admin signs in, works inside an organization, registers Traefik nodes, defines HTTP resources with load-balanced targets, and publishes them on chosen nodes; each node polls its own authenticated endpoint and serves the routes - Multi-tenancy is built in from the start: users belong to many organizations with per-organization roles, tenant data is only reachable through a membership-verified scope, and server admins get no implicit tenant access ## Changes - Accounts: email + password (Argon2), no SMTP needed. The first user becomes server admin; later signup is `invite_only` (default) or `open` via `TRAEFIK_CONTROL_SIGNUP` - Organizations and memberships (`admin` / `member`); invitations bound to an email, single-use, expiring, token shown once and stored as SHA-256; member management with a last-admin guard - Nodes: tenant-owned, bearer token stored as SHA-256 and shown once, regenerate to revoke, online status from `last_seen_at` (throttled conditional UPDATE) - Resources, targets (http/https/h2c) and node assignment; strict hostname validation because domains are interpolated into Traefik rules, re-validated by the renderer as the injection sink - `GET /api/traefik/v1/config`: pure `DynamicConfig.render/2`, uniform 401, organization derived from the node, never the request - Dev stack: Traefik uses the HTTP provider, idempotent `:dev`-only seed, `whoami` backend, all ports on loopback; README rewritten - SQLite and PostgreSQL stay supported; migrations are portable ## Verification - `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict` (no issues), `mix dialyzer` (0 errors): pass - `mix test`: 274 passed on SQLite and 274 on PostgreSQL (includes a cross-tenant isolation suite) - `podman-compose up` against real Traefik: node API 401 without/with a wrong token and 200 with the node token; Traefik installed the router and service from the `http` provider; `curl -H 'Host: whoami.localhost'` returned the whoami response and an unknown host returned 404 - Browser (Chromium): login, node shows online, created a resource and target in the UI and Traefik served it about 6 s later; removing the target returned it to 404 while the other route kept working; invite link registered a new user who landed as a read-only member (no New/Edit/Delete, no invite form); an unknown organization shows the same message as a non-member one - Dev seed is idempotent across an app restart (row counts unchanged) - Real bugs found by this process and fixed: malformed `node_ids` crashed on PostgreSQL only (fixed with `Ecto.UUID`); `bytea` size modifier invalid on PostgreSQL; numeric-final-label hostnames rejected; missing `UserToken.t()` type
- add email + password accounts via argon2; no SMTP or magic links
- first registered user becomes the server admin
- later registration is invite_only (default) or open, set with
  TRAEFIK_CONTROL_SIGNUP; create_user/1 is the invite entry point
- store only session tokens; portable migration (no collate or
  bytea size modifiers) for SQLite and PostgreSQL
- tests for policy, validation, sessions and the login flows
- add organizations and memberships (admin/member) for many-to-many
  tenancy; users can belong to any number of organizations
- Scope carries a membership-verified organization and role; the
  :assign_organization on_mount resolves /orgs/:org and answers
  identically for missing and non-member organizations
- server admins get no implicit tenant access
- organization creation: server admins, or any user when signup is
  open; organization slug is unique and immutable
- replace the Phoenix landing page with first-run/login/org routing
  and a product header
- register the organization scope for generators
- nodes belong to exactly one organization; every query is scoped
  to the caller's verified organization and cross-tenant ids 404
- bearer tokens: 256-bit random, stored as SHA-256 only, shown once
  at create/regenerate; regenerating revokes the old token
- reads open to members, writes (create/edit/delete/regenerate)
  limited to organization admins, enforced in the context and UI
- last_seen_at is touched with one conditional UPDATE at most every
  15 seconds; a node is online if it polled within 60 seconds
- node-facing authenticate_token/1 for the upcoming provider endpoint
- resources (unique domain per organization) with load-balanced
  targets (http/https/h2c) and a many-to-many node assignment
- strict Hostnames validation: domains and target hosts are
  interpolated into Traefik rules/URLs, so anything outside LDH
  labels and IP literals is rejected (backticks, parens, whitespace,
  newlines, ports, wildcards, zone ids, numeric TLDs)
- a resource can only be assigned to nodes of its own organization;
  forged or malformed node ids are validation errors
- targets are reachable only through their organization's resource
- reads open to members; writes limited to organization admins
- GET /api/traefik/v1/config, authenticated by a node bearer token;
  the organization is derived from the node, never the request
- pure DynamicConfig.render/2: routers, services and an HTTPS
  redirect, named by resource id; TLS router with optional cert
  resolver; load-balanced targets (http/https/h2c, IPv6 bracketed)
- the renderer re-validates domains and hosts and drops anything
  unsafe, so rows written around the changesets never reach Traefik
- only enabled resources of the node's organization that are
  assigned to the node are loaded
- show the Traefik static-config snippet on the node pages
- uniform 401 for every authentication failure
- admins invite an email with a role; the 256-bit token is stored as
  SHA-256 only, shown once as a link, expires after 7 days, and is
  bound to the invited address (no SMTP required)
- redemption is single-use: one conditional UPDATE claims the invite
  in the same transaction that creates the membership, so concurrent
  or failed redemptions cannot double-use or burn it
- invitees without an account register through the link, even when
  public registration is closed; existing accounts must log in as the
  invited address
- members page: list members; admins change roles, remove members and
  manage pending invitations; an organization always keeps one admin
- two fully populated tenants; every org page, tenant id, the node
  API, invitations and member management checked from the outside
- covers users in both tenants (independent roles) and server admins
  who belong to neither
- Traefik uses the control plane's HTTP provider with a dev node token
- dev seed (:dev only, idempotent): admin, "dev" organization, node
  and a whoami.localhost resource routed to the whoami container
- add a whoami service; publish every dev port on loopback only since
  the seed creates a known administrator
- README: multi-tenant model, onboarding, security notes, configuration,
  connecting a Traefik, implemented vs planned features
- update_user_password/2's spec referenced UserToken.t(), which did
  not exist; dialyzer reported an unknown type
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
White_Ghost_Cyber_Systems/traefik-control!2
No description provided.