5.2 KiB
5.2 KiB
[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_rolemapping 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_configstable - Plugin execution token: JWT, scoped to a single
task_id, 15‑minute expiry
Security Constraints
- Passwords: bcrypt with cost factor 12 (minimum).
- Rate limiting: 5 failed login attempts per IP per 15 minutes → temporary IP block.
- Token revocation: admin can revoke all sessions for a user (delete refresh tokens).
- Audit log: all auth events (login success/failure, role change, token revoke) written to
audit_logtable. - Enterprise clean mode: local auth only, ADFS disabled, no external network calls for identity.
RBAC Enforcement Pattern
# 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"))):
...