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
This commit is contained in:
354
docs/architecture/ad-saml-integration.md
Normal file
354
docs/architecture/ad-saml-integration.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user