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

Auth & Authorization Model

Alloy uses a two-layer auth model: an HTTP layer (AuthContext in alloy-api) handles token validation and scope enforcement, while a service layer (ActorContext in alloy-core) enforces ownership, role, and org-membership checks without any framework dependency.


Authentication Flow

Every request carries Authorization: Bearer <token>. The AuthContext Axum extractor inspects the token prefix to choose a validation path:

Authorization: Bearer <token>
        │
        ├─ Prefix alloy_live_* or alloy_test_* → API key path
        │   ├─ SHA256-hash the raw key
        │   ├─ Lookup api_keys by key_hash
        │   ├─ Check expiry (if set)
        │   ├─ Update last_used_at
        │   ├─ Fetch user + org membership for role
        │   └─ Build AuthContext (scopes + allowed_project_ids from key)
        │
        └─ Otherwise → JWT path
            ├─ Verify EdDSA (Ed25519) signature
            ├─ Validate exp, iss ("alloy"), aud ("alloy-api")
            └─ Build AuthContext (scopes = "*", no project restriction)

JWT Details

Config struct: JwtConfig in crates/alloy-core/src/jwt.rs

FieldSource env varDefault
Ed25519 private keyALLOY_JWT_PRIVATE_KEY / _FILEAuto-generated in dev
Ed25519 public keyALLOY_JWT_PUBLIC_KEY / _FILEAuto-generated in dev
IssuerALLOY_JWT_ISSUER"alloy"
AudienceALLOY_JWT_AUDIENCE"alloy-api"
TTL (seconds)ALLOY_JWT_TTL_SECONDS3600

Claims (AlloyClaims):

#![allow(unused)]
fn main() {
pub struct AlloyClaims {
    pub sub: Uuid,       // User ID
    pub org_id: Uuid,    // Organization ID
    pub email: String,
    pub role: OrgRole,
    pub iat: i64,        // Issued-at (Unix timestamp)
    pub exp: i64,        // Expiry (Unix timestamp)
    pub iss: String,     // Issuer
    pub aud: String,     // Audience
}
}

JWTs always produce scopes = "*" and allowed_project_ids = "" (unrestricted).

API Key Details

Key format: alloy_live_{32 base62 chars} (production) or alloy_test_* (testing).

Storage: Only the SHA256 hex digest (key_hash) is stored. The raw key is returned once at creation and never stored.

ApiKey model fields:

FieldTypeNotes
idApiKeyIdPrimary key
org_idOrgIdOwning organization
user_idUserIdCreating user
nameStringHuman-readable label
key_prefixStringFirst ~16 chars for identification
key_hashStringSHA256 hex digest for lookup
scopesString"*", or comma-separated: "read,write,admin"
project_idsStringComma-separated UUIDs, or empty for all projects
expires_atOption<DateTime<Utc>>Optional expiry
last_used_atOption<DateTime<Utc>>Updated on each use

AuthContext (HTTP Layer)

Location: crates/alloy-api/src/auth.rs

#![allow(unused)]
fn main() {
pub struct AuthContext {
    pub user_id: UserId,
    pub org_id: OrgId,
    pub email: String,
    pub role: OrgRole,
    pub scopes: String,              // "*" for JWT, comma-separated for API key
    pub allowed_project_ids: String, // empty = unrestricted, else comma-separated UUIDs
}
}

AuthContext Methods

MethodReturnsLogic
has_scope(scope)bool"*" grants all; "admin" grants read+write; "write" implies read
can_access_project(id)booltrue if unrestricted or scopes="*" or id in list
require_write()Result403 if !has_scope("write")
require_project_access(id)Result403 if !can_access_project(id)
require_owner()Result403 if role != Owner
require_admin()Result403 if role < Admin
require_member_or_above()Result403 if role < Member
require_reporter_or_above()Result403 if role < Reporter
is_viewer()boolRole check
is_reporter()boolRole check

Axum Extractor

AuthContext implements FromRequestParts. It reads the Authorization header, delegates to AuthState (which holds JwtConfig + Box<dyn ApiKeyValidator>), and produces the context or returns 401 Unauthorized.


require_role Middleware

Location: crates/alloy-api/src/auth.rs

#![allow(unused)]
fn main() {
pub fn require_role(minimum_role: OrgRole) -> RequireRoleLayer
}

A Tower layer that wraps any handler. It extracts AuthContext from the request and checks auth.role.has_at_least(minimum_role). Returns 403 Forbidden if the role is insufficient.

Usage:

#![allow(unused)]
fn main() {
.route("/admin-only", get(handler))
.layer(require_role(OrgRole::Admin))
}

OrgRole Hierarchy

Location: crates/alloy-core/src/enums.rs

RoleLevelGrants
Owner50Everything
Admin40All except owner-only actions
Member30Standard CRUD on assigned resources
Reporter20Create/update time entries, view projects
Viewer10Read-only access
#![allow(unused)]
fn main() {
pub fn has_at_least(&self, minimum: &OrgRole) -> bool {
    self.level() >= minimum.level()
}
}

ActorContext (Service Layer)

Location: crates/alloy-core/src/services/mod.rs

A framework-free mirror of AuthContext passed into service methods:

#![allow(unused)]
fn main() {
pub struct ActorContext {
    pub user_id: UserId,
    pub org_id: OrgId,
    pub email: String,
    pub role: OrgRole,
    pub scopes: String,
    pub allowed_project_ids: String,
}
}

ActorContext Methods

MethodLogic
has_scope(scope)Same as AuthContext.has_scope()
has_project_access(id)Same as AuthContext.can_access_project()
has_role(minimum)Calls role.has_at_least(minimum)

Conversion from AuthContext

Handlers build an ActorContext when calling into the service layer:

#![allow(unused)]
fn main() {
let actor = ActorContext {
    user_id: auth.user_id,
    org_id: auth.org_id,
    email: auth.email.clone(),
    role: auth.role,
    scopes: auth.scopes.clone(),
    allowed_project_ids: auth.allowed_project_ids.clone(),
};
}

This keeps alloy-core free of Axum dependencies.


Project Scoping (allowed_project_ids)

API keys can be restricted to specific projects. The flow:

  1. At key creation: User specifies project_ids (comma-separated UUIDs).
  2. At validation: allowed_project_ids is copied into AuthContext.
  3. At request time: enforce_scopes middleware extracts the project ID from the URL path and calls can_access_project().
  4. In handlers: List endpoints post-filter results through auth.can_access_project(&entry.project_id) for entries spanning multiple projects.

Access logic:

  • Empty allowed_project_ids → unrestricted (all projects)
  • scopes == "*" → unrestricted
  • Otherwise → project ID must appear in the comma-separated list

JWTs always get unrestricted project access.


Ownership Checks in Services

Services enforce two kinds of authorization beyond role checks:

1. Org Membership

Every mutation verifies the resource belongs to the actor’s org:

#![allow(unused)]
fn main() {
if resource.org_id != actor.org_id {
    return Err(ServiceError::PermissionDenied("org membership mismatch".into()));
}
}

2. Owner-or-Admin Pattern

For user-owned resources (time entries, API keys), the standard pattern is:

#![allow(unused)]
fn main() {
// Fetch the resource (produces NotFound naturally if missing)
let entry = repo.get_by_id(id).await?;

// Owner can always act on their own resource; Admin+ can act on anyone's
if entry.user_id != actor.user_id && !actor.has_role(&OrgRole::Admin) {
    return Err(ServiceError::PermissionDenied(
        "only the owner or an admin can modify this resource".into(),
    ));
}
}

ServiceError Variants

#![allow(unused)]
fn main() {
pub enum ServiceError {
    NotFound(String),
    AlreadyExists(String),
    ConstraintViolation(String),
    PermissionDenied(String),
    Validation(String),
    Internal(String),
}
}

ServiceError maps to ApiError in alloy-api:

ServiceErrorHTTP Status
NotFound404
AlreadyExists409
ConstraintViolation422
PermissionDenied403
Validation400
Internal500

Tenant Isolation: SQLite vs PostgreSQL

PostgreSQL (Multi-Tenant with RLS)

PostgreSQL uses Row Level Security to enforce tenant isolation at the database level. Every query runs inside a transaction that sets the tenant context:

BEGIN;
SET LOCAL app.tenant_id = '<org_id>';
-- All subsequent queries in this transaction are filtered by RLS

Helper function (created in V9__enable_rls.sql):

CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS UUID AS $$
  SELECT NULLIF(current_setting('app.tenant_id', true), '')::UUID;
$$ LANGUAGE sql STABLE;

RLS policies per table:

TablePolicy
organizationsid = current_tenant_id()
projectsorg_id = current_tenant_id()
ticketsproject_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id())
teamsorg_id = current_tenant_id()
org_membershipsorg_id = current_tenant_id()

Code path (crates/alloy-api/src/tenant.rs):

#![allow(unused)]
fn main() {
tokio::task_local! { static CURRENT_TENANT: OrgId; }

pub async fn begin_tenant_tx(pool: &PgPool) -> Result<Transaction<'_, Postgres>>
pub fn current_tenant_id() -> Option<OrgId>
pub async fn with_tenant_scope<F: Future>(org_id: OrgId, fut: F) -> F::Output
}

The tenant_context_middleware runs before handlers and sets the task-local from the authenticated org_id.

SQLite (Single-Tenant)

SQLite deployments are single-tenant by design (one org per database file). There is no RLS. The V9__enable_rls.sql migration for SQLite is a no-op.

However, SQLite repositories still include explicit AND org_id = ? filters in queries as defense-in-depth. This prevents data leaks if the application is ever misconfigured with multiple orgs in a single SQLite database.

Comparison

AspectPostgreSQLSQLite
Isolation mechanismRLS policiesExplicit AND org_id = ?
Multi-tenantYesNo (single-tenant)
Tenant contextSET LOCAL app.tenant_idNot needed
Migration V9Creates RLS policies + functionNo-op
Defense-in-depthRLS + application checksApplication checks only

Scope Semantics

ScopeGrantsTypical use
"*"EverythingJWT auth (always)
"admin"read + write + admin actionsFull API key
"write"read + writeCI/CD automation keys
"read"read onlyDashboard/reporting keys

Scope inheritance: adminwriteread.


Middleware Stack (Request Order)

1. CORS / HSTS headers
2. AuthContext extraction (JWT or API key validation)
3. enforce_scopes (write scope + project access for API keys)
4. tenant_context_middleware (sets task-local org_id for PG RLS)
5. require_role (on routes that need it)
6. Handler → ActorContext → Service layer