# 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_ -> 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= ``` ## 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