Architecture — Backend (shiftit-backend)

Executive summary

A modular monolith REST API in Rust/Axum. Multi-tenant via PostgreSQL Row-Level Security: every tenant-scoped query runs inside a transaction with app.tenant_id set, and per-table <table>_isolation policies enforce isolation at the database — application code cannot leak across tenants. Zitadel is the OIDC issuer; the backend is a pure resource server. Self-hosted vs SaaS is a compile-time --features saas flag that toggles ed25519 license validation.

The codebase is organised by bounded context, not by layer. Each domain (auth, shifts, time_tracking, forms, notifications, tenants) owns its model.rs (DTOs), db.rs or db/ (queries), routes.rs or routes/ (HTTP handlers). Cross-cutting concerns live in shared/.


Technology stack

Concern Choice Version Notes

Language

Rust (stable)

edition 2021

Backend only — frontend is on nightly

HTTP

axum

0.8

{id} path syntax (not :id)

Async

tokio

1.x

full features

Middleware

tower / tower-http

0.5 / 0.6

cors, trace, request-id

DB

sqlx

0.8

runtime-tokio-rustls, postgres, uuid, chrono, json, migrate. .sqlx/ offline cache committed

RDBMS

PostgreSQL

16

RLS policies for tenant isolation

OpenAPI

utoipa + utoipa-swagger-ui

5 / 9

#[utoipa::path] per handler, ServingSwagger UI at /api/docs/

JWT

jsonwebtoken

10

Validates Zitadel-issued tokens

HTTP client

reqwest (rustls)

0.13

JWKS fetch + Nominatim geocoding

Object storage

aws-sdk-s3

1.x

force_path_style(true) for RustFS

Web Push

web-push

0.11

VAPID-signed payloads

License

ed25519-dalek

2.x

Compile-time public key, runtime .lic file

Time zones

chrono-tz

0.10

For BulkGenerateRequest (tenant-local shift generation)

Concurrency

dashmap

6.x

Process-local rate limiter

Logging

tracing + tracing-subscriber

0.1 / 0.3

EnvFilter; ⚠️ no OTLP/Prometheus yet (P2 follow-up)

Testing

sqlx::test + axum-test (unused)

0.8 / 20

DB-layer tests are 80 strong; HTTP-level tests are a P2 follow-up


Architecture pattern: modular monolith with bounded contexts

src/
├── main.rs              # Composition root: connect_db, create_router, axum::serve
├── openapi.rs           # OpenAPI doc derive — central path/schema registry
├── auth/                # Cross-cutting: JWT validation, JWKS cache, license, middleware
├── shared/              # Cross-cutting primitives: ApiError, TenantContext, Page<T>
├── tenants/             # Tenant settings (the only domain whose data is the tenant itself)
├── shifts/              # Largest domain: roles, locations, users, events, shifts, slots, assignments
├── time_tracking/       # Time entries, overtime balances, payouts
├── forms/               # Customer forms, submissions, event documents (S3 upload URLs)
├── notifications/       # In-app notifications + Web Push subscriptions + dispatcher
└── events/              # Tag-grouping module — event documents (lives separately for OpenAPI tag grouping)

Each domain module exposes:

  • mod.rs — module wiring; usually re-exports

  • model.rs — DTOs with Serialize, Deserialize, ToSchema

  • db.rs (or db/) — sqlx query functions, all taking &TenantContext as their first argument and routing through ctx.rls_transaction(&pool)

  • routes.rs (or routes/) — Axum handlers with #[utoipa::path] annotations

There is no service layer — handlers call DB functions directly. This is fine at current scope; if business logic complexity grows, intermediate service.rs files (already used in notifications/) are the established pattern.


Tenant isolation: how RLS actually works

This is the single most important thing to understand about the backend.

Three roles in play

  1. Connecting role — the user in DATABASE_URL. Typically a superuser in dev. Bypasses RLS by default.

  2. shiftit_app — NOLOGIN role created by migration 0001. DML grants only, no DDL. Subject to RLS.

  3. Migration role — same as connecting role; needs DDL grants. Migrations run on a one-shot connection, then the runtime pool is built.

The connect_db() two-phase setup (in main.rs)

// Phase 1: privileged one-shot connection runs migrations
let mut mig = PgConnection::connect(&url).await?;
sqlx::migrate!("./migrations").run(&mut mig).await?;
mig.close().await?;

// Phase 2: runtime pool with after_connect hook
let pool = PgPoolOptions::new()
    .after_connect(|conn, _meta| Box::pin(async move {
        sqlx::query("SET ROLE shiftit_app").execute(conn).await?;
        Ok(())
    }))
    .connect(&url).await?;

Every physical connection in the runtime pool issues SET ROLE shiftit_app before being handed out. The connecting (superuser) role is never used at runtime. There is a unit test (runtime_pool_runs_as_shiftit_app in main.rs) that proves this.

Per-request scope

// In a handler:
let mut tx = ctx.rls_transaction(&pool).await?;
// → BEGIN; SELECT set_config('app.tenant_id', ctx.tenant_id, true);
// queries here are filtered by every <table>_isolation policy
sqlx::query!(...).execute(&mut *tx).await?;
tx.commit().await?;

Cross-tenant queries don’t return rows — RLS makes them invisible. This is provably tested in tenants/db.rs::rls_blocks_cross_tenant_reads.

The single bypass

customer_forms_lookup_by_slug (migration 0006) is a SECURITY DEFINER SQL function that runs as the function owner (which is RLS-exempt). It’s the only RLS bypass in the codebase. The handler that calls it (forms::routes::get_form_for_customer) verifies the returned tenant_id against ctx.tenant_id before treating the form as accessible.


Auth pipeline

Bearer token in Authorization header
        │
        ▼
auth_middleware (auth/middleware.rs)
        │
        ├─ DEV_AUTH_* set in env? → build synthetic TenantContext, skip JWT validation
        │
        ├─ Otherwise: validate via JwksCache (auth/jwks.rs)
        │     ├─ kid match in cache → verify signature
        │     └─ unknown kid → refresh JWKS once (P1: single-flight)
        │
        ├─ Decode claims (auth/claims.rs)
        ├─ Look up tenant by zitadel_org_id
        ├─ Upsert user (placeholder email — P1: real userinfo call)
        ├─ Build TenantContext { user_id, tenant_id, role }
        └─ Insert into request extensions
        │
        ▼
Handler extracts TenantContext via `axum::extract::Extension` (or via FromRequestParts impl)
        │
        ▼
DB layer takes &TenantContext, calls ctx.rls_transaction(&pool)

UserRole is enum { Admin, Employee, Customer } (shared/tenant_context.rs). Role enforcement is per-handler today — there’s no global guard layer. Admin endpoints typically open with if !matches!(ctx.role, UserRole::Admin) { return Err(ApiError::forbidden()); }. A reusable require_admin extractor would be a nice cleanup.


OpenAPI

Single source of truth: backend/src/openapi.rs::ApiDoc. The derive macro takes:

  • paths(...) — every #[utoipa::path]-annotated function (39 today)

  • components(schemas(...)) — every ToSchema DTO (36 today)

  • tags(...) — 12 domain tags for the Swagger sidebar

  • modifiers(&SecurityAddon) — adds the bearer HTTP-Bearer security scheme

Smoke-tests in openapi.rs::tests guard against accidental shrinkage (>= 30 paths) and missing security scheme.

When adding a new route, three places must update:

  1. #[utoipa::path] on the handler

  2. The route registration in main.rs::create_router

  3. The paths(...) list in openapi.rs

Forgetting (3) is the easy mistake — the route works but doesn’t appear in /api/docs/.


Operational concerns

Concern Status Reference

Liveness probe

GET /health (outside /api/v1)

main.rs::create_router

Tracing

tracing::info! only

P2: OTLP/Prometheus follow-up

Request id

tower-http::request-id feature is enabled but not layered into the router

 — 

Rate limiting

Process-local DashMap, only on form submissions

forms::rate_limit

Graceful shutdown

axum::serve(...).with_graceful_shutdown(shutdown_signal())

main.rs::shutdown_signal

Configuration

dotenvy::dotenv() + raw std::env::var calls

.env.example lists keys

Background jobs

None

The notification dispatcher runs synchronously inside the request that triggers it


Testing

  • 80 sqlx tests at the DB layer in */db.rs files. Each uses #[sqlx::test(migrations = "../backend/migrations")] which spins up a fresh per-test database with migrations applied. Tests build their own runtime pool (since sqlx::test doesn’t apply our after_connect hook).

  • Cross-tenant isolation test: tenants::db::rls_blocks_cross_tenant_reads proves the policies actually enforce.

  • OpenAPI smoke tests: openapi.rs::tests.

  • No HTTP-level tests yet. axum-test is in [dev-dependencies] but unused — see "HTTP-level integration tests for routes" follow-up.

  • No JWKS integration tests: unit-tested only — see "JWKS integration tests with wiremock" follow-up.


Known design constraints

  1. shift_slots and shift_assignments use subquery RLS — fail-closed but with per-row cost under load. Migration 0003.

  2. claim_slot race — capacity check + insert in one transaction at READ COMMITTED, but two concurrent claimers can each see filled = N-1 and both insert. P1 follow-up.

  3. record_time_entry double-credits balance on edit — P1 follow-up.

  4. PATCH /tenant/settings is full-replace — no If-Match, no partial body. P2 follow-up.

  5. License check panics on missing SHIFTIT_PUBLIC_KEY in non-saas dev builds — P2 follow-up to accept a runtime path.

All five are tracked in docs/follow-ups.md.