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 |
|
Async |
tokio |
1.x |
|
Middleware |
tower / tower-http |
0.5 / 0.6 |
|
DB |
sqlx |
0.8 |
|
RDBMS |
PostgreSQL |
16 |
RLS policies for tenant isolation |
OpenAPI |
utoipa + utoipa-swagger-ui |
5 / 9 |
|
JWT |
jsonwebtoken |
10 |
Validates Zitadel-issued tokens |
HTTP client |
reqwest (rustls) |
0.13 |
JWKS fetch + Nominatim geocoding |
Object storage |
aws-sdk-s3 |
1.x |
|
Web Push |
web-push |
0.11 |
VAPID-signed payloads |
License |
ed25519-dalek |
2.x |
Compile-time public key, runtime |
Time zones |
chrono-tz |
0.10 |
For |
Concurrency |
dashmap |
6.x |
Process-local rate limiter |
Logging |
tracing + tracing-subscriber |
0.1 / 0.3 |
|
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 withSerialize,Deserialize,ToSchema -
db.rs(ordb/) — sqlx query functions, all taking&TenantContextas their first argument and routing throughctx.rls_transaction(&pool) -
routes.rs(orroutes/) — 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
-
Connecting role — the user in
DATABASE_URL. Typically a superuser in dev. Bypasses RLS by default. -
shiftit_app— NOLOGIN role created by migration 0001. DML grants only, no DDL. Subject to RLS. -
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(...))— everyToSchemaDTO (36 today) -
tags(...)— 12 domain tags for the Swagger sidebar -
modifiers(&SecurityAddon)— adds thebearerHTTP-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:
-
#[utoipa::path]on the handler -
The route registration in
main.rs::create_router -
The
paths(...)list inopenapi.rs
Forgetting (3) is the easy mistake — the route works but doesn’t appear in /api/docs/.
Operational concerns
| Concern | Status | Reference |
|---|---|---|
Liveness probe |
|
|
Tracing |
|
P2: OTLP/Prometheus follow-up |
Request id |
|
— |
Rate limiting |
Process-local DashMap, only on form submissions |
|
Graceful shutdown |
|
|
Configuration |
|
|
Background jobs |
None |
The notification dispatcher runs synchronously inside the request that triggers it |
Testing
-
80 sqlx tests at the DB layer in
*/db.rsfiles. Each uses#[sqlx::test(migrations = "../backend/migrations")]which spins up a fresh per-test database with migrations applied. Tests build their own runtime pool (sincesqlx::testdoesn’t apply ourafter_connecthook). -
Cross-tenant isolation test:
tenants::db::rls_blocks_cross_tenant_readsproves the policies actually enforce. -
OpenAPI smoke tests:
openapi.rs::tests. -
No HTTP-level tests yet.
axum-testis 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
-
shift_slotsandshift_assignmentsuse subquery RLS — fail-closed but with per-row cost under load. Migration 0003. -
claim_slotrace — capacity check + insert in one transaction at READ COMMITTED, but two concurrent claimers can each seefilled = N-1and both insert. P1 follow-up. -
record_time_entrydouble-credits balance on edit — P1 follow-up. -
PATCH /tenant/settingsis full-replace — noIf-Match, no partial body. P2 follow-up. -
License check panics on missing
SHIFTIT_PUBLIC_KEYin non-saas dev builds — P2 follow-up to accept a runtime path.
All five are tracked in docs/follow-ups.md.