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
8.4 KiB
Silo Authentication Architecture
Overview
Silo supports three authentication backends that can be enabled independently or in combination:
| Backend | Use Case | Config Key |
|---|---|---|
| Local | Username/password stored in Silo's database (bcrypt) | auth.local |
| LDAP | FreeIPA / Active Directory via LDAP bind | auth.ldap |
| OIDC | Keycloak or any OpenID Connect provider (redirect flow) | auth.oidc |
When authentication is disabled (auth.enabled: false), all routes are open and a synthetic developer user with the admin role is injected into every request.
Authentication Flow
Browser (Session-Based)
User -> /login (GET) -> Renders login form
User -> /login (POST) -> Validates credentials via backends
-> Creates server-side session (PostgreSQL)
-> Sets silo_session cookie
-> Redirects to / or ?next= URL
User -> /auth/oidc (GET) -> Generates state, stores in session
-> Redirects to Keycloak authorize endpoint
Keycloak -> /auth/callback -> Verifies state, exchanges code for token
-> Extracts claims, upserts user in DB
-> Creates session, redirects to /
API Client (Token-Based)
Client -> Authorization: Bearer silo_<hex> -> SHA-256 hash lookup
-> Validates expiry + user active
-> Injects user into context
Both paths converge at the RequireAuth middleware, which injects an auth.User into the request context. All downstream handlers use auth.UserFromContext(ctx) to access the authenticated user.
Database Schema
users
The single identity table that all backends resolve to. LDAP and OIDC users are upserted on first login.
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
username |
TEXT | Unique. FreeIPA uid or OIDC preferred_username |
display_name |
TEXT | Human-readable name |
email |
TEXT | Not unique-constrained |
password_hash |
TEXT | NULL for LDAP/OIDC-only users (bcrypt cost 12) |
auth_source |
TEXT | local, ldap, or oidc |
oidc_subject |
TEXT | Stable OIDC sub claim (unique partial index) |
role |
TEXT | admin, editor, or viewer |
is_active |
BOOLEAN | Deactivated users are rejected at middleware |
last_login_at |
TIMESTAMPTZ | Updated on each successful login |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
api_tokens
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | FK to users, CASCADE on delete |
name |
TEXT | Human-readable label |
token_hash |
TEXT | SHA-256 of raw token (unique) |
token_prefix |
TEXT | silo_ + 8 hex chars for display |
scopes |
TEXT[] | Reserved for future fine-grained permissions |
last_used_at |
TIMESTAMPTZ | Updated asynchronously on use |
expires_at |
TIMESTAMPTZ | NULL = never expires |
created_at |
TIMESTAMPTZ |
Raw token format: silo_ + 64 hex characters (32 random bytes). Shown once at creation. Only the SHA-256 hash is stored.
sessions
Required by alexedwards/scs pgxstore:
| Column | Type | Notes |
|---|---|---|
token |
TEXT | Primary key (session ID) |
data |
BYTEA | Serialized session data |
expiry |
TIMESTAMPTZ | Indexed for cleanup |
Session data contains user_id and username. Cookie: silo_session, HttpOnly, SameSite=Lax, 24h lifetime. Secure flag is set when auth.enabled is true.
audit_log
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
timestamp |
TIMESTAMPTZ | Indexed DESC |
user_id |
UUID | FK to users, SET NULL on delete |
username |
TEXT | Preserved after user deletion |
action |
TEXT | create, update, delete, login, etc. |
resource_type |
TEXT | item, revision, project, relationship |
resource_id |
TEXT | |
details |
JSONB | Arbitrary structured data |
ip_address |
TEXT |
User Tracking Columns
Migration 009 adds created_by and updated_by TEXT columns to:
items(created_by, updated_by)relationships(created_by, updated_by)projects(created_by)sync_log(triggered_by)
These store the username string (not a foreign key) so audit records survive user deletion and dev mode uses "dev".
Role Model
Three roles with a strict hierarchy:
admin > editor > viewer
| Permission | viewer | editor | admin |
|---|---|---|---|
| Read items, projects, schemas, BOMs | Yes | Yes | Yes |
| Create/update items and revisions | No | Yes | Yes |
| Upload files | No | Yes | Yes |
| Manage BOMs | No | Yes | Yes |
| Create/update projects | No | Yes | Yes |
| Import CSV / BOM CSV | No | Yes | Yes |
| Generate part numbers | No | Yes | Yes |
| Manage own API tokens | Yes | Yes | Yes |
| User management (future) | No | No | Yes |
Role enforcement uses auth.RoleSatisfies(userRole, minimumRole) which checks the hierarchy. A user with admin satisfies any minimum role.
Role Mapping from External Sources
LDAP/FreeIPA: Mapped from group membership. Checked in priority order (admin > editor > viewer). First match wins.
ldap:
role_mapping:
admin:
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
editor:
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
viewer:
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
OIDC/Keycloak: Mapped from realm_access.roles in the ID token.
oidc:
admin_role: "silo-admin"
editor_role: "silo-editor"
default_role: "viewer"
Roles are re-evaluated on every external login. If an LDAP user's group membership changes in FreeIPA, their Silo role updates on their next login.
User Lifecycle
| Event | What Happens |
|---|---|
| First LDAP login | User row created with auth_source=ldap, role from group mapping |
| First OIDC login | User row created with auth_source=oidc, oidc_subject set |
| Subsequent external login | display_name, email, role updated; last_login_at updated |
| Local account creation | Admin creates user with username, password, role |
| Deactivation | is_active=false — sessions and tokens rejected at middleware |
| Password change | Only for auth_source=local users |
Default Admin Account
On startup, if auth.local.default_admin_username and auth.local.default_admin_password are both set, the daemon checks for an existing user with that username. If none exists, it creates a local admin account. This is idempotent — subsequent startups skip creation.
auth:
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Use SILO_ADMIN_PASSWORD env var
Set via environment variables for production:
export SILO_ADMIN_USERNAME=admin
export SILO_ADMIN_PASSWORD=<strong-password>
API Token Security
- Raw token:
silo_+ 64 hex characters (32 bytes ofcrypto/rand) - Storage: SHA-256 hash only — the raw token cannot be recovered from the database
- Display prefix:
silo_+ first 8 hex characters (for identification in UI) - Tokens inherit the owning user's role. If the user is deactivated, all their tokens stop working
- Revocation is immediate (row deletion)
last_used_atis updated asynchronously to avoid slowing down API requests
Dev Mode
When auth.enabled: false:
- No login is required
- A synthetic user is injected into every request:
- Username:
dev - Role:
admin - Auth source:
local
- Username:
created_byfields are set to"dev"- CORS allows all origins
- Session cookies are not marked
Secure
Security Considerations
- Password hashing: bcrypt with cost factor 12
- Token entropy: 256 bits (32 bytes from
crypto/rand) - LDAP: Always use
ldaps://(TLS).tls_skip_verifyis available but should only be used for testing - OIDC state parameter: 128 bits, stored server-side in session, verified on callback
- Session cookies: HttpOnly, SameSite=Lax, Secure when auth enabled
- CSRF protection: nosurf library on all web form routes. API routes exempt (use Bearer tokens instead)
- CORS: Locked down to configured origins when auth is enabled. Credentials allowed for session cookies