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
|
||||
185
docs/AUTH_MIDDLEWARE.md
Normal file
185
docs/AUTH_MIDDLEWARE.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 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:
|
||||
|
||||
```go
|
||||
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
|
||||
```
|
||||
257
docs/AUTH_USER_GUIDE.md
Normal file
257
docs/AUTH_USER_GUIDE.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Silo Authentication User Guide
|
||||
|
||||
## Logging In
|
||||
|
||||
### Username and Password
|
||||
|
||||
Navigate to the Silo web UI. If authentication is enabled, you'll be redirected to the login page.
|
||||
|
||||
Enter your username and password. This works for both local accounts and LDAP/FreeIPA accounts — Silo tries local authentication first, then LDAP if configured.
|
||||
|
||||
### Keycloak / OIDC
|
||||
|
||||
If your deployment has OIDC enabled, the login page will show a "Sign in with Keycloak" button. Click it to be redirected to your identity provider. After authenticating there, you'll be redirected back to Silo with a session.
|
||||
|
||||
## Roles
|
||||
|
||||
Your role determines what you can do in Silo:
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| **viewer** | Read all items, projects, schemas, BOMs. Manage own API tokens. |
|
||||
| **editor** | Everything a viewer can do, plus: create/update/delete items, upload files, manage BOMs, import CSV, generate part numbers. |
|
||||
| **admin** | Everything an editor can do, plus: user management (future), configuration changes. |
|
||||
|
||||
Your role is shown as a badge next to your name in the header. For LDAP and OIDC users, the role is determined by group membership or token claims and re-evaluated on each login.
|
||||
|
||||
## API Tokens
|
||||
|
||||
API tokens allow the FreeCAD plugin, scripts, and CI pipelines to authenticate with Silo without a browser session. Tokens inherit your role.
|
||||
|
||||
### Creating a Token (Web UI)
|
||||
|
||||
1. Click **Settings** in the navigation bar
|
||||
2. Under **API Tokens**, enter a name (e.g., "FreeCAD workstation") and click **Create Token**
|
||||
3. The raw token is displayed once — copy it immediately
|
||||
4. Store the token securely. It cannot be shown again.
|
||||
|
||||
### Creating a Token (CLI)
|
||||
|
||||
```sh
|
||||
export SILO_API_URL=https://silo.kindred.internal
|
||||
export SILO_API_TOKEN=silo_<your-existing-token>
|
||||
|
||||
silo token create --name "CI pipeline"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Token created: CI pipeline
|
||||
API Token: silo_a1b2c3d4e5f6...
|
||||
Save this token — it will not be shown again.
|
||||
```
|
||||
|
||||
### Listing Tokens
|
||||
|
||||
```sh
|
||||
silo token list
|
||||
```
|
||||
|
||||
### Revoking a Token
|
||||
|
||||
Via the web UI settings page (click **Revoke** next to the token), or via CLI:
|
||||
|
||||
```sh
|
||||
silo token revoke <token-id>
|
||||
```
|
||||
|
||||
Revocation is immediate. Any in-flight requests using the token will fail.
|
||||
|
||||
## FreeCAD Plugin Configuration
|
||||
|
||||
The FreeCAD plugin reads the API token from two sources (checked in order):
|
||||
|
||||
1. **FreeCAD Preferences**: `Tools > Edit parameters > BaseApp/Preferences/Mod/Silo > ApiToken`
|
||||
2. **Environment variable**: `SILO_API_TOKEN`
|
||||
|
||||
To set the token in FreeCAD preferences:
|
||||
|
||||
1. Open FreeCAD
|
||||
2. Go to `Edit > Preferences > General > Macro` or use the parameter editor
|
||||
3. Navigate to `BaseApp/Preferences/Mod/Silo`
|
||||
4. Set `ApiToken` to your token string (e.g., `silo_a1b2c3d4...`)
|
||||
|
||||
Or set the environment variable before launching FreeCAD:
|
||||
|
||||
```sh
|
||||
export SILO_API_TOKEN=silo_a1b2c3d4...
|
||||
freecad
|
||||
```
|
||||
|
||||
The API URL is configured the same way via the `ApiUrl` preference or `SILO_API_URL` environment variable.
|
||||
|
||||
## Default Admin Account
|
||||
|
||||
On first deployment, configure a default admin account to bootstrap access:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
auth:
|
||||
enabled: true
|
||||
local:
|
||||
enabled: true
|
||||
default_admin_username: "admin"
|
||||
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD env var
|
||||
```
|
||||
|
||||
```sh
|
||||
export SILO_ADMIN_PASSWORD=<strong-password>
|
||||
```
|
||||
|
||||
The admin account is created on the first startup if it doesn't already exist. Subsequent startups skip creation. Change the password after first login via the database or a future admin UI.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Minimal (Local Auth Only)
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
session_secret: "" # Set via SILO_SESSION_SECRET
|
||||
|
||||
local:
|
||||
enabled: true
|
||||
default_admin_username: "admin"
|
||||
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
### LDAP / FreeIPA
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
session_secret: ""
|
||||
|
||||
local:
|
||||
enabled: true
|
||||
default_admin_username: "admin"
|
||||
default_admin_password: ""
|
||||
|
||||
ldap:
|
||||
enabled: true
|
||||
url: "ldaps://ipa.kindred.internal"
|
||||
base_dn: "dc=kindred,dc=internal"
|
||||
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
||||
user_attr: "uid"
|
||||
email_attr: "mail"
|
||||
display_attr: "displayName"
|
||||
group_attr: "memberOf"
|
||||
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"
|
||||
tls_skip_verify: false
|
||||
```
|
||||
|
||||
### OIDC / Keycloak
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
session_secret: ""
|
||||
|
||||
local:
|
||||
enabled: true
|
||||
|
||||
oidc:
|
||||
enabled: true
|
||||
issuer_url: "https://keycloak.kindred.internal/realms/silo"
|
||||
client_id: "silo"
|
||||
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
|
||||
redirect_url: "https://silo.kindred.internal/auth/callback"
|
||||
scopes: ["openid", "profile", "email"]
|
||||
admin_role: "silo-admin"
|
||||
editor_role: "silo-editor"
|
||||
default_role: "viewer"
|
||||
```
|
||||
|
||||
### CORS (Production)
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://silo.kindred.internal"
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Config Path |
|
||||
|----------|-------------|-------------|
|
||||
| `SILO_SESSION_SECRET` | Session encryption key | `auth.session_secret` |
|
||||
| `SILO_ADMIN_USERNAME` | Default admin username | `auth.local.default_admin_username` |
|
||||
| `SILO_ADMIN_PASSWORD` | Default admin password | `auth.local.default_admin_password` |
|
||||
| `SILO_OIDC_CLIENT_SECRET` | OIDC client secret | `auth.oidc.client_secret` |
|
||||
| `SILO_LDAP_BIND_PASSWORD` | LDAP service account password | `auth.ldap.bind_password` |
|
||||
| `SILO_API_URL` | API base URL (CLI and FreeCAD) | — |
|
||||
| `SILO_API_TOKEN` | API token (CLI and FreeCAD) | — |
|
||||
|
||||
Environment variables override config file values.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication required" on every request
|
||||
|
||||
- Verify `auth.enabled: true` in your config
|
||||
- Check that the `sessions` table exists in PostgreSQL (migration 009)
|
||||
- Ensure `SILO_SESSION_SECRET` is set (empty string is allowed for dev but not recommended)
|
||||
- Check browser cookies — `silo_session` should be present after login
|
||||
|
||||
### API token returns 401
|
||||
|
||||
- Tokens are case-sensitive. Ensure no trailing whitespace
|
||||
- Check token expiry with `silo token list`
|
||||
- Verify the user account is still active
|
||||
- Ensure the `Authorization` header format is exactly `Bearer silo_<hex>`
|
||||
|
||||
### LDAP login fails
|
||||
|
||||
- Check `ldaps://` URL is reachable from the Silo server
|
||||
- Verify `base_dn` and `user_search_dn` match your FreeIPA tree
|
||||
- Test with `ldapsearch` from the command line first
|
||||
- Set `tls_skip_verify: true` temporarily to rule out certificate issues
|
||||
- Check Silo logs for the specific LDAP error message
|
||||
|
||||
### OIDC redirect loops
|
||||
|
||||
- Verify `redirect_url` matches the Keycloak client configuration exactly
|
||||
- Check that `issuer_url` is reachable from the Silo server
|
||||
- Ensure the Keycloak client has the correct redirect URI registered
|
||||
- Check for clock skew between Silo and Keycloak servers (JWT validation is time-sensitive)
|
||||
|
||||
### Locked out (no admin account)
|
||||
|
||||
If you've lost access to all admin accounts:
|
||||
|
||||
1. Set `auth.local.default_admin_password` to a new password via `SILO_ADMIN_PASSWORD`
|
||||
2. Use a different username (e.g., `default_admin_username: "recovery-admin"`)
|
||||
3. Restart Silo — the new account will be created
|
||||
4. Log in and fix the original accounts
|
||||
|
||||
Or directly update the database:
|
||||
|
||||
```sql
|
||||
-- Reset a local user's password (generate bcrypt hash externally)
|
||||
UPDATE users SET password_hash = '<bcrypt-hash>', is_active = true WHERE username = 'admin';
|
||||
```
|
||||
|
||||
### FreeCAD plugin gets 401
|
||||
|
||||
- Verify the token is set in FreeCAD preferences or `SILO_API_TOKEN`
|
||||
- Check the API URL points to the correct server
|
||||
- Test with curl: `curl -H "Authorization: Bearer silo_..." https://silo.kindred.internal/api/items`
|
||||
Reference in New Issue
Block a user