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

185
docs/AUTH_MIDDLEWARE.md Normal file
View 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
View 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`