Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture Overview

Alloy is a headless, API-first project management service built in Rust. It supports two database backends (PostgreSQL multi-tenant, SQLite single-tenant) and exposes its capabilities through an HTTP API, a CLI, a TUI, and an MCP server.

Crate Dependency Graph

                    ┌────────────┐
                    │ alloy-core │  (domain models, service traits, repo traits)
                    └─────┬──────┘
                          │
            ┌─────────────┼─────────────────┐
            │             │                 │
            ▼             ▼                 ▼
   ┌────────────┐  ┌──────────────┐  ┌──────────────────┐
   │ alloy-api  │  │ alloy-cli    │  │ alloy-test-utils  │
   │ (Axum,     │  │ (Clap CLI,   │  │ (test builders,   │
   │  SQLx,     │  │  depends on  │  │  seeders,         │
   │  repos)    │  │  alloy-api   │  │  fake data)       │
   └──────┬─────┘  │  + core)     │  └──────────────────┘
          │        └──────────────┘
          │
   ┌──────┴─────┐
   │ alloy-mcp  │  (MCP server — HTTP client to alloy-api)
   └────────────┘
   ┌────────────┐
   │ alloy-tui  │  (Ratatui TUI — HTTP client to alloy-api)
   └────────────┘

Key rules:

  • alloy-core has zero framework dependencies — no Axum, no SQLx, no Tokio. It contains only domain models, enums, service traits, and repository trait definitions.
  • alloy-api depends on alloy-core and provides concrete SQLx-backed repository implementations plus Axum HTTP handlers.
  • alloy-cli depends on both alloy-api and alloy-core (it embeds the server).
  • alloy-mcp and alloy-tui are HTTP clients — they talk to a running alloy-api instance over the network and do not link against it.
  • alloy-test-utils depends on alloy-core and provides shared test helpers.

Request Lifecycle

A typical authenticated API request flows through these layers:

  HTTP Request
       │
       ▼
  ┌──────────────────────┐
  │  Axum Router         │  Route matching
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Rate Limit Layer    │  Token-bucket rate limiting (tower middleware)
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Security Headers    │  CORS, CSP, HSTS, X-Frame-Options
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Tenant Context      │  Extracts org_id from token, sets task-local
  │  Middleware           │  for PostgreSQL RLS (see Tenant Isolation below)
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Auth Extractor      │  Validates JWT or API key from Authorization header
  │  (AuthContext)       │  Produces AuthContext: user_id, org_id, role, scopes
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Handler Function    │  Business-level validation, converts AuthContext
  │  (handlers/*.rs)     │  to ActorContext, calls service methods
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Service Layer       │  Domain logic: permission checks, audit logging,
  │  (alloy-core         │  workflow transitions, business rules
  │   services/*.rs)     │  Operates on trait-based repos (no DB knowledge)
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Repository Impl     │  SQLx queries against PostgreSQL or SQLite
  │  (alloy-api          │  PG repos use begin_tenant_tx() for RLS
  │   repos/*.rs)        │  SQLite repos query directly with org_id filter
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │  Database            │  PostgreSQL 16 or SQLite (WAL mode)
  └──────────────────────┘

Auth Flow Detail

  1. The Authorization: Bearer <token> header is read by the AuthContext extractor (an Axum FromRequestParts implementation).
  2. Tokens prefixed alloy_live_ or alloy_test_ are validated as API keys — the key hash is looked up in the database, and the associated user, org, role, scopes, and project restrictions are loaded.
  3. All other tokens are validated as JWTs — signature verification uses the configured HMAC secret. JWT auth always grants scopes: "*".
  4. The resulting AuthContext carries user_id, org_id, email, role, scopes, and allowed_project_ids for downstream use.
  5. Handlers convert AuthContext into ActorContext (a framework-free struct in alloy-core) before calling service methods.

SQLite vs PostgreSQL Paths

Alloy supports two database backends selected by the ALLOY_DATABASE_URL environment variable:

AspectPostgreSQLSQLite
Use caseMulti-tenant productionSingle-tenant / local dev
Connectionpostgres://...sqlite://path or sqlite::memory:
Tenant isolationRow Level Security (RLS)org_id column filters in queries
Migrationsmigrations/postgres/migrations/sqlite/
Migration runnerrefinery embed_migrations!refinery embed_migrations!
UUID storageNative UUID typeTEXT
TimestampsTIMESTAMPTZTEXT (ISO 8601)
Auto-incrementSERIAL / BIGSERIALINTEGER PRIMARY KEY
Journal modeWAL by defaultWAL enabled at connection
Pool typesqlx::PgPoolsqlx::SqlitePool

The Database enum in alloy-api/src/db.rs wraps both backends behind a uniform interface. Migrations are compiled into the binary via refinery::embed_migrations! and run automatically on startup when ALLOY_AUTO_MIGRATE=true.

Repository traits are defined once in alloy-core/src/repos.rs. Each trait has two implementations in alloy-api/src/repos/:

  • pg_<domain>.rs — PostgreSQL implementation using sqlx::PgPool
  • sqlite_<domain>.rs — SQLite implementation using sqlx::SqlitePool

Tenant Isolation Model

Alloy enforces strict data isolation between organisations (tenants).

PostgreSQL: Row Level Security

  1. Every table has an org_id column.
  2. RLS policies restrict SELECT, INSERT, UPDATE, and DELETE to rows where org_id matches the session variable app.tenant_id.
  3. The tenant context middleware runs before every request. It extracts org_id from the auth token and stores it in a tokio::task_local.
  4. PostgreSQL repository methods call begin_tenant_tx() which starts a transaction and executes SET LOCAL app.tenant_id = '<org_id>'. This activates RLS filtering for the duration of the transaction.
  5. RLS failures are silent — they return fewer rows, not errors. Tests must assert zero results across tenant boundaries, not expect errors.
  Request with Org A token
       │
       ▼
  tenant_context_middleware  ──► task_local CURRENT_TENANT = Org A
       │
       ▼
  PG repo: begin_tenant_tx()
       │
       ▼
  SET LOCAL app.tenant_id = 'org-a-uuid'
       │
       ▼
  SELECT * FROM tickets WHERE ...
  (RLS automatically adds: AND org_id = 'org-a-uuid')

SQLite: Explicit Filtering

SQLite has no RLS. Instead:

  1. Every query includes AND org_id = ? in its WHERE clause.
  2. The org_id parameter comes from the ActorContext passed through the service layer.
  3. This provides defence-in-depth alongside application-level permission checks in the service layer.

Application-Level Checks

Both backends also enforce:

  • Ownership checks in service methods (e.g. only the author of a time entry can submit it).
  • Role-based access via ActorContext.role (owner, admin, member, viewer).
  • Scope restrictions via ActorContext.scopes (API keys can be limited to read-only or specific operations).
  • Project restrictions via ActorContext.allowed_project_ids (API keys can be scoped to specific projects).