Files
silo/docs/AUTH_MIDDLEWARE.md
Forbes 4f0107f1b2 feat(auth): add authentication, RBAC, API tokens, and default admin
Add a complete authentication and authorization system to Silo with
three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak),
session management, API token support, and role-based access control.

Authentication backends:
- Local: bcrypt (cost 12) password verification against users table
- LDAP: FreeIPA simple bind with group-to-role mapping
- OIDC: Keycloak redirect flow with realm role mapping
- Backends are tried in order; users upserted to DB on first login

Session and token management:
- PostgreSQL-backed sessions via alexedwards/scs + pgxstore
- Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once)
- 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies

Role-based access control (admin > editor > viewer):
- RequireAuth middleware: Bearer token -> session -> redirect/401
- RequireRole middleware: per-route-group minimum role enforcement
- CSRF protection via justinas/nosurf on web forms, API exempt
- CORS locked to configured origins when auth enabled

Route restructuring:
- Public: /health, /ready, /login, /auth/oidc, /auth/callback
- Web (auth + CSRF): /, /projects, /schemas, /settings
- API read (viewer): GET /api/**
- API write (editor): POST/PUT/PATCH/DELETE /api/**

User context wiring:
- created_by/updated_by columns on items, projects, relationships
- All create/update handlers populate tracking fields from context
- CSV and BOM import handlers pass authenticated username
- Revision creation tracks user across all code paths

Default admin account:
- Configurable via auth.local.default_admin_username/password
- Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD
- Idempotent: created on first startup, skipped if exists

CLI and FreeCAD plugin:
- silo token create/list/revoke subcommands (HTTP API client)
- FreeCAD SiloClient sends Bearer token on all requests
- Token read from ApiToken preference or SILO_API_TOKEN env var

Web UI:
- Login page (Catppuccin Mocha themed, OIDC button conditional)
- Settings page with account info and API token management
- User display name, role badge, and logout button in header
- One-time token display banner with copy-to-clipboard

Database (migration 009):
- users table with role, auth_source, oidc_subject, password_hash
- api_tokens table with SHA-256 hash, prefix, expiry, scopes
- sessions table (scs pgxstore schema)
- audit_log table (schema ready for future use)
- created_by/updated_by ALTER on items, relationships, projects

New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3,
justinas/nosurf, golang.org/x/oauth2
2026-01-31 11:20:12 -06:00

6.0 KiB

Silo Auth Middleware

Middleware Chain

Every request passes through this middleware stack in order:

RequestID          Assigns X-Request-ID header
    |
RealIP             Extracts client IP from X-Forwarded-For
    |
RequestLogger      Logs method, path, status, duration (zerolog)
    |
Recoverer          Catches panics, logs stack trace, returns 500
    |
CORS               Validates origin, sets Access-Control headers
    |
SessionLoadAndSave Loads session from PostgreSQL, saves on response
    |
[route group middleware applied per-group below]

Route Groups

Public (no auth)

GET  /health
GET  /ready
GET  /login
POST /login
POST /logout
GET  /auth/oidc
GET  /auth/callback

No authentication or CSRF middleware. The login form includes a CSRF hidden field but nosurf is not enforced on these routes (they are outside the CSRF-protected group).

Web UI (auth + CSRF)

RequireAuth -> CSRFProtect -> Handler

GET  /                       Items page
GET  /projects               Projects page
GET  /schemas                Schemas page
GET  /settings               Settings page (account info, tokens)
POST /settings/tokens        Create API token (form)
POST /settings/tokens/{id}/revoke   Revoke token (form)

Both RequireAuth and CSRFProtect are applied. Form submissions must include a csrf_token hidden field with the value from nosurf.Token(r).

API (auth, no CSRF)

RequireAuth -> [RequireRole where needed] -> Handler

GET    /api/auth/me                  Current user info (viewer)
GET    /api/auth/tokens              List tokens (viewer)
POST   /api/auth/tokens              Create token (viewer)
DELETE /api/auth/tokens/{id}         Revoke token (viewer)

GET    /api/schemas/*                Read schemas (viewer)
POST   /api/schemas/*/segments/*     Modify schema values (editor)

GET    /api/projects/*               Read projects (viewer)
POST   /api/projects                 Create project (editor)
PUT    /api/projects/{code}          Update project (editor)
DELETE /api/projects/{code}          Delete project (editor)

GET    /api/items/*                  Read items (viewer)
POST   /api/items                    Create item (editor)
PUT    /api/items/{partNumber}       Update item (editor)
DELETE /api/items/{partNumber}       Delete item (editor)
POST   /api/items/{partNumber}/file  Upload file (editor)
POST   /api/items/import             CSV import (editor)

GET    /api/items/{partNumber}/bom/* Read BOM (viewer)
POST   /api/items/{partNumber}/bom   Add BOM entry (editor)
PUT    /api/items/{partNumber}/bom/* Update BOM entry (editor)
DELETE /api/items/{partNumber}/bom/* Delete BOM entry (editor)

POST   /api/generate-part-number     Generate PN (editor)

GET    /api/integrations/odoo/*      Read Odoo config (viewer)
PUT    /api/integrations/odoo/*      Modify Odoo config (editor)
POST   /api/integrations/odoo/*      Odoo sync operations (editor)

API routes are exempt from CSRF (they use Bearer token auth). CORS credentials are allowed so browser-based API clients with session cookies work.

RequireAuth

internal/api/middleware.go

Authentication check order:

  1. Auth disabled? Inject synthetic dev user (admin role) and continue
  2. Bearer token? Extract from Authorization: Bearer silo_... header, validate via auth.Service.ValidateToken(). On success, inject user into context
  3. Session cookie? Read user_id from scs session, look up user via auth.Service.GetUserByID(). On success, inject user into context. On stale session (user not found), destroy session
  4. None of the above?
    • API requests (/api/*): Return 401 Unauthorized JSON
    • Web requests: Redirect to /login?next=<current-path>

RequireRole

internal/api/middleware.go

Applied as per-group middleware on routes that require a minimum role:

r.Use(server.RequireRole(auth.RoleEditor))

Checks auth.RoleSatisfies(user.Role, minimum) against the hierarchy admin > editor > viewer. Returns:

  • 401 Unauthorized if no user in context (should not happen after RequireAuth)
  • 403 Forbidden with message "Insufficient permissions: requires <role> role" if role is too low

CSRFProtect

internal/api/middleware.go

Wraps the justinas/nosurf library:

  • Cookie: csrf_token, HttpOnly, SameSite=Lax, Secure when auth enabled
  • Exempt paths: /api/*, /health, /ready
  • Form field name: csrf_token
  • Failure: Returns 403 Forbidden with "CSRF token validation failed"

Templates inject the token via {{.CSRFToken}} which is populated from nosurf.Token(r).

CORS Configuration

Configured in internal/api/routes.go:

Setting Auth Disabled Auth Enabled
Allowed Origins * From auth.cors.allowed_origins config
Allow Credentials false true (needed for session cookies)
Allowed Methods GET, POST, PUT, PATCH, DELETE, OPTIONS Same
Allowed Headers Accept, Authorization, Content-Type, X-CSRF-Token, X-Request-ID Same
Max Age 300 seconds 300 seconds

FreeCAD uses direct HTTP (not browser), so CORS does not affect it. Browser-based tools on other origins need their origin in the allowed list.

Request Flow Examples

Browser Login

GET /projects
  -> RequestLogger
  -> CORS (pass)
  -> Session (loads empty session)
  -> RequireAuth: no token, no session user_id
  -> Redirect 303 /login?next=/projects

POST /login (username=alice, password=...)
  -> RequestLogger
  -> CORS (pass)
  -> Session (loads)
  -> HandleLogin: auth.Authenticate(ctx, "alice", password)
  -> Session: put user_id, renew token
  -> Redirect 303 /projects

GET /projects
  -> Session (loads, has user_id)
  -> RequireAuth: session -> GetUserByID -> inject alice
  -> CSRFProtect: GET request, passes
  -> HandleProjectsPage: renders with alice's info

API Token Request

GET /api/items
  Authorization: Bearer silo_a1b2c3d4...

  -> RequireAuth: Bearer token found
  -> ValidateToken: SHA-256 hash lookup, check expiry, check user active
  -> Inject user into context
  -> HandleListItems: returns JSON