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
| Field | Source env var | Default |
|---|---|---|
| Ed25519 private key | ALLOY_JWT_PRIVATE_KEY / _FILE | Auto-generated in dev |
| Ed25519 public key | ALLOY_JWT_PUBLIC_KEY / _FILE | Auto-generated in dev |
| Issuer | ALLOY_JWT_ISSUER | "alloy" |
| Audience | ALLOY_JWT_AUDIENCE | "alloy-api" |
| TTL (seconds) | ALLOY_JWT_TTL_SECONDS | 3600 |
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:
| Field | Type | Notes |
|---|---|---|
id | ApiKeyId | Primary key |
org_id | OrgId | Owning organization |
user_id | UserId | Creating user |
name | String | Human-readable label |
key_prefix | String | First ~16 chars for identification |
key_hash | String | SHA256 hex digest for lookup |
scopes | String | "*", or comma-separated: "read,write,admin" |
project_ids | String | Comma-separated UUIDs, or empty for all projects |
expires_at | Option<DateTime<Utc>> | Optional expiry |
last_used_at | Option<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
| Method | Returns | Logic |
|---|---|---|
has_scope(scope) | bool | "*" grants all; "admin" grants read+write; "write" implies read |
can_access_project(id) | bool | true if unrestricted or scopes="*" or id in list |
require_write() | Result | 403 if !has_scope("write") |
require_project_access(id) | Result | 403 if !can_access_project(id) |
require_owner() | Result | 403 if role != Owner |
require_admin() | Result | 403 if role < Admin |
require_member_or_above() | Result | 403 if role < Member |
require_reporter_or_above() | Result | 403 if role < Reporter |
is_viewer() | bool | Role check |
is_reporter() | bool | Role 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
| Role | Level | Grants |
|---|---|---|
Owner | 50 | Everything |
Admin | 40 | All except owner-only actions |
Member | 30 | Standard CRUD on assigned resources |
Reporter | 20 | Create/update time entries, view projects |
Viewer | 10 | Read-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
| Method | Logic |
|---|---|
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:
- At key creation: User specifies
project_ids(comma-separated UUIDs). - At validation:
allowed_project_idsis copied intoAuthContext. - At request time:
enforce_scopesmiddleware extracts the project ID from the URL path and callscan_access_project(). - 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:
| ServiceError | HTTP Status |
|---|---|
NotFound | 404 |
AlreadyExists | 409 |
ConstraintViolation | 422 |
PermissionDenied | 403 |
Validation | 400 |
Internal | 500 |
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:
| Table | Policy |
|---|---|
organizations | id = current_tenant_id() |
projects | org_id = current_tenant_id() |
tickets | project_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id()) |
teams | org_id = current_tenant_id() |
org_memberships | org_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
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Isolation mechanism | RLS policies | Explicit AND org_id = ? |
| Multi-tenant | Yes | No (single-tenant) |
| Tenant context | SET LOCAL app.tenant_id | Not needed |
| Migration V9 | Creates RLS policies + function | No-op |
| Defense-in-depth | RLS + application checks | Application checks only |
Scope Semantics
| Scope | Grants | Typical use |
|---|---|---|
"*" | Everything | JWT auth (always) |
"admin" | read + write + admin actions | Full API key |
"write" | read + write | CI/CD automation keys |
"read" | read only | Dashboard/reporting keys |
Scope inheritance: admin ⊃ write ⊃ read.
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