- 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
19 KiB
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)
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
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
- Session reuse: SAML login creates the exact same session format as local login. No changes to
requireAuth()middleware. - Feature flag isolation: When
SAML_ENABLED=false, SAML routes return 404 and no SAML library is loaded. Zero runtime cost when disabled. - 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. - JIT provisioning: Users are created on first login, updated on each subsequent login. AD is the source of truth for SSO users.
- 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
{
"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_hashbecomes 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'andexternal_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:
- All required SAML env vars are set (fails with descriptive error if missing)
- Certificate file exists and is readable (fails if not)
- Group mapping config parses as valid JSON (fails if not)
- All mapped team names exist in KNOWN_TEAMS (fails if not)
- 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)