# [DEF:ADR-0005:ADR] # @STATUS ACTIVE # @PURPOSE Define the authentication and authorization architecture for ss-tools: local auth, optional ADFS SSO federation, RBAC model, session management, and the security boundary between ss-tools (DevOps privileges) and Superset (BI privileges). # @RELATION DEPENDS_ON -> [ADR-0001:ADR] # @RELATION DEPENDS_ON -> [ADR-0003:ADR] # @RELATION CALLS -> [ADR-0004:ADR] # @RATIONALE ss-tools manages operations that can modify production Superset instances (dashboard migration, backup, deployment). Unauthenticated or under‑authorized access to these operations is a critical security risk. A dedicated RBAC system ensures that DevOps privileges (manage deployments) are cleanly separated from BI privileges (view dashboards) and that actions are auditable. # @RATIONALE Local auth (bcrypt + JWT) is the primary path because ss-tools must work in air‑gapped enterprise deployments where external identity providers are unavailable. ADFS SSO is an optional federation layer for organizations that already have Active Directory. # @RATIONALE Role‑Based Access Control (RBAC) was chosen over Attribute‑Based Access Control (ABAC) because: (a) the system has a small, well‑defined set of operations (deploy, backup, migrate, view), (b) RBAC maps naturally to organizational roles (admin, analyst, operator), (c) ABAC adds complexity without proportional benefit for this scope. # @REJECTED Delegating all auth to Superset — rejected because ss-tools must operate when Superset is down, and Superset's auth model is designed for BI users, not DevOps operators with cross‑environment privileges. # @REJECTED OAuth2 social login (Google, GitHub) as primary path — rejected because enterprise deployments require air‑gapped operation. External OAuth providers are unavailable in offline mode. # @REJECTED Simple API key (no RBAC) — rejected because it cannot express granular permissions (admin vs analyst vs viewer), making auditability and least‑privilege impossible. ## Decision ### Authentication Flow ``` ┌──────────┐ POST /api/auth/login ┌──────────────┐ │ User │ ──────────────────────────────│ FastAPI │ │ (Browser)│ {username, password} │ Backend │ │ │ ◄──────────────────────────── │ │ │ │ {access_token, refresh} │ bcrypt + │ │ │ │ JWT (HS256) │ └──────────┘ └──────┬───────┘ │ ┌───────▼───────┐ │ PostgreSQL │ │ users table │ │ roles table │ └───────────────┘ ``` ### ADFS Federation (Optional) - Enabled via `config.json: auth.adfs_enabled = true` - SAML 2.0 flow through `python3-saml` - ADFS users mapped to local roles via `adfs_group → ss_tools_role` mapping table - Federated sessions still receive JWTs (stateless after initial SAML handshake) ### RBAC Model | Role | Permissions | |------|-------------| | `admin` | All: manage users, manage roles, manage plugins, deploy, backup, migrate, view dashboards, view logs | | `analyst` | View dashboards, run LLM analysis plugins, view reports, view logs (own) | | `operator` | Deploy dashboards, run migrations, manage backups, view logs | | `viewer` | View dashboards, view reports | ### Token Design - **Access token**: JWT (HS256), 15‑minute expiry, contains `user_id`, `roles: list[str]` - **Refresh token**: opaque random string (SHA‑256), 7‑day expiry, stored hashed in DB - **Superset API token per environment**: AES‑256‑GCM encrypted, stored in `connection_configs` table - **Plugin execution token**: JWT, scoped to a single `task_id`, 15‑minute expiry ### Security Constraints 1. Passwords: bcrypt with cost factor 12 (minimum). 2. Rate limiting: 5 failed login attempts per IP per 15 minutes → temporary IP block. 3. Token revocation: admin can revoke all sessions for a user (delete refresh tokens). 4. Audit log: all auth events (login success/failure, role change, token revoke) written to `audit_log` table. 5. Enterprise clean mode: local auth only, ADFS disabled, no external network calls for identity. ### RBAC Enforcement Pattern ```python # backend/src/api/dependencies.py from fastapi import Depends, HTTPException def require_role(required_role: str): def dependency(current_user: User = Depends(get_current_user)): if required_role not in current_user.roles: raise HTTPException(status_code=403, detail="Insufficient permissions") return current_user return dependency # Usage in route: @router.post("/api/dashboards/deploy") async def deploy(..., user: User = Depends(require_role("operator"))): ... ``` # [/DEF:ADR-0005:ADR]