Files
cve-dashboard/docs/architecture/ad-saml-integration.md
Jordan Ramos a95fd03f5e Rebrand STEAM → AEGIS, fix BU drift checker previous_bu bug
- Replace all STEAM branding with AEGIS (Advanced Engineering Group
  Intelligence System) across login, header, nav drawer, manifest, and
  browser title
- Add shield logo to login page, main header, and nav drawer
- Fix BU drift checker recording incorrect previous_bu values by
  building a previousBuMap snapshot BEFORE the upsert/delete cycle
  instead of querying the DB after rows are already gone
- Clean 526 bogus BU history entries generated by the broken logic
- Add docs and scripts from prior session
2026-06-17 14:40:38 -06:00

355 lines
19 KiB
Markdown

# AD/SAML Integration Architecture
## Overview
This document describes the architecture for integrating Active Directory (AD) authentication via SAML 2.0 into the STEAM Security Dashboard. The integration adds Single Sign-On (SSO) as the primary authentication method while retaining local password login as a break-glass fallback for administrators. AD group memberships drive automatic permission assignment and BU team scoping through a configurable mapping layer.
---
## Authentication Model
The dashboard supports two authentication paths simultaneously:
| Path | Users | Mechanism | Session |
|---|---|---|---|
| Local | Break-glass admins, service accounts | Username + bcrypt password | Cookie-based, PostgreSQL sessions table |
| SAML SSO | All AD users | SP-initiated SAML 2.0 via AD FS | Same cookie-based session (identical to local) |
Both paths produce the same session artifact — an httpOnly cookie containing a `session_id` that maps to a row in the `sessions` table. Downstream middleware (`requireAuth`, `requireGroup`) is unaware of how the session was created.
---
## SAML 2.0 Authentication Flow
### SP-Initiated Login (Success Path)
```mermaid
sequenceDiagram
participant B as Browser
participant SP as Dashboard (SP)
participant IdP as AD FS (IdP)
participant DB as PostgreSQL
B->>SP: GET /api/auth/saml/login
SP->>SP: Generate AuthnRequest XML
SP->>B: HTTP 302 Redirect to IdP SSO URL (with AuthnRequest)
B->>IdP: Follow redirect (user sees AD FS login page)
IdP->>IdP: Authenticate user against AD
IdP->>IdP: Build assertion (NameID, email, groups)
IdP->>IdP: Sign assertion with IdP private key
IdP->>B: HTTP 200 with auto-submit form (POST to SP callback)
B->>SP: POST /api/auth/saml/callback (SAMLResponse in body)
SP->>SP: Base64-decode SAMLResponse
SP->>SP: Validate XML signature against IdP certificate
SP->>SP: Check NotBefore/NotOnOrAfter (120s clock skew tolerance)
SP->>SP: Extract NameID, email, displayName, group claims
SP->>DB: Look up user by external_id (NameID)
alt New user (no matching external_id)
SP->>DB: INSERT into users (JIT provisioning)
SP->>DB: INSERT audit_log (saml_user_provisioned)
else Existing user
SP->>DB: UPDATE user group, teams, email
SP->>DB: INSERT audit_log (saml_user_updated) if changed
end
SP->>DB: INSERT into sessions (session_id, user_id, expires_at)
SP->>DB: INSERT audit_log (saml_login)
SP->>B: Set-Cookie: session_id=xxx; HttpOnly; SameSite=Lax
SP->>B: HTTP 302 Redirect to /?saml_success=true
B->>SP: GET /api/auth/me (with cookie)
SP->>B: 200 { user: { id, username, group, teams, authSource } }
```
### Assertion Rejection Path
```mermaid
sequenceDiagram
participant B as Browser
participant SP as Dashboard (SP)
participant IdP as AD FS (IdP)
B->>SP: GET /api/auth/saml/login
SP->>B: HTTP 302 Redirect to IdP
B->>IdP: Authenticate
IdP->>B: POST assertion to SP callback
B->>SP: POST /api/auth/saml/callback
SP->>SP: Validate assertion
alt Invalid signature
SP->>SP: Log audit (saml_auth_failed, reason: invalid_signature)
SP->>B: Redirect /?saml_error=Invalid+assertion+signature
else Expired assertion
SP->>SP: Log audit (saml_auth_failed, reason: assertion_expired)
SP->>B: Redirect /?saml_error=Assertion+expired
else Account disabled
SP->>SP: Log audit (saml_auth_failed, reason: account_disabled)
SP->>B: Redirect /?saml_error=Account+is+disabled
end
```
---
## Component Architecture
```
┌───────────────────────────────────────────────────────────────────┐
│ Express Backend (port 3001) │
│ │
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ routes/saml.js │ │ routes/auth.js │ │ middleware/auth.js│ │
│ │ │ │ │ │ │ │
│ │ GET /status │ │ POST /login │ │ requireAuth() │ │
│ │ GET /login │ │ POST /logout │ │ requireGroup() │ │
│ │ POST /callback │ │ GET /me │ │ │ │
│ │ GET /metadata │ │ POST /change-pw │ │ (unchanged — │ │
│ └───────┬────────┘ └────────┬────────┘ │ reads session │ │
│ │ │ │ cookie only) │ │
│ │ │ └──────────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ helpers/samlProvisioning.js │ │
│ │ │ │
│ │ resolveGroup(adGroups, config) → dashboardGroup │ │
│ │ resolveTeams(adGroups, config) → "STEAM,..." │ │
│ │ deriveUsername(nameId) → username │ │
│ │ provisionOrUpdateUser(assertion, config, ip) │ │
│ └───────────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ helpers/samlConfig.js │ │
│ │ │ │
│ │ loadGroupMappingConfig() → validated config obj │ │
│ │ (reads config/adGroupMapping.json or env var) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
```
### Key Design Decisions
1. **Session reuse**: SAML login creates the exact same session format as local login. No changes to `requireAuth()` middleware.
2. **Feature flag isolation**: When `SAML_ENABLED=false`, SAML routes return 404 and no SAML library is loaded. Zero runtime cost when disabled.
3. **Config-driven mapping**: AD group names are externalized in `config/adGroupMapping.json`. Changing the mapping requires only a file edit and backend restart — no code changes.
4. **JIT provisioning**: Users are created on first login, updated on each subsequent login. AD is the source of truth for SSO users.
5. **Separation of concerns**: The provisioning logic (`samlProvisioning.js`) is a pure module with no HTTP dependencies — fully unit-testable without a web server.
---
## AD Group-to-Permission Mapping
### Configuration Structure
```json
{
"groups": {
"<AD-GROUP-CN>": "<Dashboard-Group>",
"CVE-Dashboard-Admins": "Admin",
"CVE-Dashboard-Users": "Standard_User",
"CVE-Dashboard-Leadership": "Leadership",
"CVE-Dashboard-ReadOnly": "Read_Only"
},
"teams": {
"<AD-GROUP-CN>": "<BU-Team-ID>",
"NTS-AEO-STEAM": "STEAM",
"NTS-AEO-ACCESS-ENG": "ACCESS-ENG",
"NTS-AEO-ACCESS-OPS": "ACCESS-OPS",
"NTS-AEO-INTELDEV": "INTELDEV"
},
"groupPriority": ["Admin", "Standard_User", "Leadership", "Read_Only"],
"defaultGroup": "Read_Only",
"attributes": {
"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"displayName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"groups": "http://schemas.xmlsoap.org/claims/Group"
}
}
```
### Privilege Hierarchy
When a user belongs to multiple AD groups that map to different dashboard groups, the highest-privilege group wins:
```
Admin > Standard_User > Leadership > Read_Only
```
### Multi-Team Assignment
When a user belongs to multiple AD groups that map to BU teams, all matching teams are assigned:
```
AD Groups: ["NTS-AEO-STEAM", "NTS-AEO-ACCESS-ENG", "CVE-Dashboard-Users"]
→ user_group: "Standard_User"
→ bu_teams: "ACCESS-ENG,STEAM" (sorted alphabetically, deduplicated)
```
### Placeholder Group Names
The AD group names in the `groups` and `teams` sections (e.g., "CVE-Dashboard-Admins") are placeholders. When real group CNs are obtained from the AD administrators, update only this configuration file. No code changes required.
---
## Database Schema Changes
### Migration: `add_saml_auth_columns.js`
| Column | Type | Nullable | Default | Purpose |
|---|---|---|---|---|
| `auth_source` | VARCHAR(10) | NOT NULL | `'local'` | Discriminates local vs SSO users |
| `external_id` | VARCHAR(256) | NULL | NULL | SAML NameID for IdP correlation |
Additional changes:
- `password_hash` becomes nullable (SAML users have no local password)
- Partial unique index on `external_id WHERE external_id IS NOT NULL`
### Impact on Existing Data
- All existing users receive `auth_source = 'local'` and `external_id = NULL`
- No existing functionality is affected
- Migration is idempotent (safe to re-run)
---
## Environment Variables
| Variable | Required | Description | Example |
|---|---|---|---|
| `SAML_ENABLED` | Always | Master feature flag for SAML authentication | `false` |
| `SAML_IDP_METADATA_URL` | When SAML_ENABLED=true | AD FS federation metadata endpoint | `https://adfs.corp.local/FederationMetadata/2007-06/FederationMetadata.xml` |
| `SAML_SP_ENTITY_ID` | When SAML_ENABLED=true | Unique identifier for this Service Provider | `http://71.85.90.6:3001` |
| `SAML_SP_CALLBACK_URL` | When SAML_ENABLED=true | Assertion consumer service URL | `http://71.85.90.6:3001/api/auth/saml/callback` |
| `SAML_IDP_CERT_PATH` | When SAML_ENABLED=true | File path to IdP signing certificate (PEM format) | `/etc/cve-dashboard/idp-cert.pem` |
| `SESSION_LIFETIME_HOURS` | Optional | Session duration (1-720 hours, default: 24) | `8` |
| `AD_GROUP_MAPPING_JSON` | Optional | JSON string override for adGroupMapping.json | `{"groups":{...},"teams":{...}}` |
### Startup Validation
When `SAML_ENABLED=true`, the server validates at startup:
1. All required SAML env vars are set (fails with descriptive error if missing)
2. Certificate file exists and is readable (fails if not)
3. Group mapping config parses as valid JSON (fails if not)
4. All mapped team names exist in KNOWN_TEAMS (fails if not)
5. All mapped dashboard groups are valid (fails if not)
This fail-fast approach prevents silent misconfiguration in production.
---
## Build vs Wait: Phase Breakdown
### Phase 1 — Build Now (No AD Access Required)
| Component | File | Test Strategy |
|---|---|---|
| Database migration | `backend/migrations/add_saml_auth_columns.js` | Run against test DB, verify columns |
| Group mapping config | `backend/config/adGroupMapping.json` | Startup validation tests |
| Config loader | `backend/helpers/samlConfig.js` | Unit test with mock JSON files |
| JIT provisioner | `backend/helpers/samlProvisioning.js` | Unit test all paths with mock pool |
| SAML routes (skeleton) | `backend/routes/saml.js` | Integration test: feature flag, status, metadata |
| Session lifetime | `server.js` (startup block) | Unit test env var parsing |
| Auth route changes | `backend/routes/auth.js` | Integration test: SAML user login rejection |
| User route changes | `backend/routes/users.js` | Integration test: auth_source in responses, password block |
| Frontend SSO button | `frontend/src/components/LoginForm.js` | Render test: button hidden when flag=false |
| Admin auth_source badges | `frontend/src/components/UserManagement.js` | Render test: badge displays |
| Architecture doc | `docs/architecture/ad-saml-integration.md` | N/A (documentation) |
### Phase 2 — Requires Live AD FS Connection
| Component | Dependency | Who Provides It |
|---|---|---|
| SAML library installation | Package selection (`@node-saml/passport-saml` or `saml2-js`) | Development team |
| IdP metadata URL | AD FS federation metadata endpoint | AD administrators |
| IdP signing certificate | Token-signing cert exported from AD FS | AD administrators |
| SP registration | Relying party trust created in AD FS console | AD administrators |
| Real AD group names | Actual CNs of permission/team groups | AD administrators |
| Assertion parsing implementation | Fill in `routes/saml.js` callback | Development team |
| End-to-end flow testing | Working AD user accounts | AD administrators |
| Session lifetime tuning | AD FS token lifetime policy value | AD administrators |
---
## Security Considerations
### Certificate Management
- The IdP signing certificate is stored on disk at `SAML_IDP_CERT_PATH`
- When the IdP rotates its certificate, replace the file and restart the backend
- No database migration required for certificate rotation
- Consider monitoring certificate expiry dates (AD FS certs typically rotate annually)
### Assertion Replay Prevention
- Each SAML assertion is consumed exactly once by the callback handler
- The JIT provisioner's idempotent update pattern means replayed assertions would simply re-update the same user record (no escalation possible)
- For additional protection in Phase 2, implement InResponseTo validation and a short-lived assertion ID cache
### Trust Boundary
```
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Trust Zone A │ │ Trust Zone B │
│ │ │ │
│ Dashboard (SP) │ │ AD FS (IdP) │
│ - Validates assertions │ │ - Authenticates users │
│ - Trusts ONLY signed assertions │ │ - Signs assertions with │
│ - Creates local sessions │ │ private key │
│ - Enforces local authorization │ │ - Asserts group memberships │
│ │ │ │
└────────────────┬─────────────────┘ └────────────────┬────────────────┘
│ │
└─── SAML 2.0 over HTTPS (HTTP-POST) ────┘
```
- The SP trusts assertions only when cryptographically signed by the IdP
- Group memberships in the assertion drive permission assignment — the SP does not query AD directly
- If the IdP is compromised, an attacker could forge assertions. Mitigate with certificate pinning and monitoring assertion patterns in audit logs.
- The SP never sends credentials to the IdP — authentication happens entirely on the IdP side
### Break-Glass Protection
- The last local Admin account cannot be deleted or deactivated
- If the IdP is unavailable, local Admin users can still log in with username/password
- SAML users cannot authenticate via password (and vice versa) — the two paths are isolated per user record
### Transport Security
- Production deployments should serve the SP callback over HTTPS
- The SAMLResponse is transmitted via HTTP-POST binding (browser-mediated, not direct server-to-server)
- The assertion is signed — even if transmitted over HTTP, it cannot be tampered with without detection
- For defense in depth, HTTPS prevents assertion interception by network observers
---
## Hybrid Login User Experience
```
┌─────────────────────────────────────────────────────────────┐
│ Login Page │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Sign in with SSO │ │ │
│ │ │ (redirects to AD FS) │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ ─── or sign in with local account ─── │ │
│ │ │ │
│ │ Username: [________________] │ │
│ │ Password: [________________] │ │
│ │ │ │
│ │ [Sign In] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Note: SSO button only visible when SAML_ENABLED=true │
│ Local login always available (break-glass for admins) │
└─────────────────────────────────────────────────────────────┘
```
---
## Related Specifications
- `.kiro/specs/ad-saml-integration/requirements.md` — detailed acceptance criteria
- `.kiro/specs/ad-saml-integration/design.md` — implementation design with code examples
- `.kiro/specs/group-based-access-control/requirements.md` — existing RBAC system
- `.kiro/specs/multi-bu-tenancy/design.md` — BU team scoping (leveraged by AD integration)