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
This commit is contained in:
Forbes
2026-01-31 11:20:12 -06:00
parent a5fba7bf99
commit 4f0107f1b2
32 changed files with 3538 additions and 138 deletions

226
docs/AUTH.md Normal file
View File

@@ -0,0 +1,226 @@
# 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.
```yaml
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.
```yaml
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.
```yaml
auth:
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Use SILO_ADMIN_PASSWORD env var
```
Set via environment variables for production:
```sh
export SILO_ADMIN_USERNAME=admin
export SILO_ADMIN_PASSWORD=<strong-password>
```
## API Token Security
- Raw token: `silo_` + 64 hex characters (32 bytes of `crypto/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_at` is 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`
- `created_by` fields 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_verify` is 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