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:
@@ -40,6 +40,15 @@ def _get_api_url() -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _get_api_token() -> str:
|
||||
"""Get Silo API token from preferences, falling back to env var."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
token = param.GetString("ApiToken", "")
|
||||
if not token:
|
||||
token = os.environ.get("SILO_API_TOKEN", "")
|
||||
return token
|
||||
|
||||
|
||||
def _get_ssl_verify() -> bool:
|
||||
"""Get SSL verification setting from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
@@ -306,6 +315,9 @@ class SiloClient:
|
||||
"""Make HTTP request to Silo API."""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
@@ -322,6 +334,9 @@ class SiloClient:
|
||||
"""Download a file from MinIO storage."""
|
||||
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
@@ -383,6 +398,9 @@ class SiloClient:
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user