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:
226
docs/AUTH.md
Normal file
226
docs/AUTH.md
Normal 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
|
||||
Reference in New Issue
Block a user