Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libffi-dev \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libssl-dev \
pkg-config \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*

Expand Down
2 changes: 2 additions & 0 deletions alembic/versions/0001_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
# Modules whose schema initializer is the source of truth for baseline DDL.
_INIT_TARGETS: tuple[tuple[str, str], ...] = (
("modules.tenants.store", "init_db"),
("modules.auth.saml", "init_db"),
("modules.auth.scim", "init_db"),
("modules.identity.attestation_store", "init_db"),
("modules.identity.uis_store", "init_db"),
("modules.identity.trust_graph", "init_db"),
Expand Down
27 changes: 24 additions & 3 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ async def dashboard():
return FileResponse(_DASHBOARD_PATH)


# ── Enterprise SAML SSO (alpha) ───────────────────────────────────────────────
# ── Enterprise SAML SSO ───────────────────────────────────────────────────────


@app.get("/saml/metadata", response_class=Response)
Expand Down Expand Up @@ -409,21 +409,42 @@ async def saml_acs(request: Request):
from modules.auth.saml import parse_assertion, SAMLError
form = await request.form()
saml_response = form.get("SAMLResponse")
relay_state = form.get("RelayState")
if not saml_response:
raise HTTPException(status_code=400, detail="SAMLResponse missing")
try:
assertion = parse_assertion(str(saml_response))
assertion = parse_assertion(str(saml_response), relay_state=str(relay_state or ""))
except SAMLError as exc:
log_event(
AuditEventType.AUTH_FAILURE,
AuditOutcome.FAILURE,
subject="saml",
resource="/saml/acs",
detail={"error": str(exc)},
)
raise HTTPException(status_code=401, detail=str(exc))
log_event(
AuditEventType.AUTH_SUCCESS,
AuditOutcome.SUCCESS,
subject=assertion.name_id,
resource="/saml/acs",
detail={
"issuer": assertion.issuer,
"session_index": assertion.session_index,
"in_response_to": assertion.in_response_to,
"assertion_id": assertion.assertion_id,
},
)
return {
"name_id": assertion.name_id,
"attributes": assertion.attributes,
"issuer": assertion.issuer,
"session_index": assertion.session_index,
"in_response_to": assertion.in_response_to,
}


# ── SCIM 2.0 (alpha) ──────────────────────────────────────────────────────────
# ── SCIM 2.0 ──────────────────────────────────────────────────────────────────


def _scim_response(body: dict, status: int = 200):
Expand Down
20 changes: 20 additions & 0 deletions docs/PRODUCTION_ROLLOUT_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ This validates critical environment variables and recommended settings:
the TokenDNA-specific names should be present in production manifests.
- storage modules use the shared backend abstraction; any remaining direct
`sqlite3.connect` usage is reported as a production blocker.
- if SAML is enabled, HTTPS SP URLs, IdP SSO URL, IdP signing certificate,
RelayState host allowlist, disabled-by-default IdP-initiated login, and
the `python3-saml` runtime dependency are all present.
- the Postgres smoke test can create/query tenant API keys, usage metering,
UIS events, policy bundles, decision audits, and staged-rollout grants.

Expand All @@ -45,6 +48,23 @@ process to refuse to start. See `docs/ops/backup-dr.md` for key
provisioning and rotation, and `docs/ops/external-engagements.md` for
the pen-test and compliance gates that follow this checklist.

## 1.1) Customer IdP SAML/SCIM validation

For Okta, Microsoft Entra ID, OneLogin, or another customer IdP, run the
repeatable TokenDNA-side harness after preflight:

```bash
python3 scripts/idp_ga_validation.py \
--provider okta \
--base-url https://tokendna.customer.example \
--api-key "$TOKENDNA_TENANT_API_KEY"
```

Retain the JSON report with the customer evidence packet. The browser SAML
login, replay rejection, signing certificate fingerprint, and IdP app
configuration screenshots are still live-tenant evidence requirements.
See `docs/ops/saml-scim.md`.

## 2) Key rotation drill (staging first)

Run:
Expand Down
196 changes: 118 additions & 78 deletions docs/ops/saml-scim.md
Original file line number Diff line number Diff line change
@@ -1,114 +1,154 @@
# TokenDNA SAML SSO + SCIM 2.0 (alpha)
# TokenDNA SAML SSO and SCIM 2.0

Enterprise customers expect both SAML SSO and SCIM provisioning before
they will sign procurement. This page is the integration playbook.
This is the enterprise IdP integration runbook for TokenDNA local control-plane deployments. The implementation is intended to run where the customer's identities and agents already live: on the customer's network, appliance, cluster, or tenant-owned cloud account.

> **Status: alpha.** The API surface is stable, but signature
> verification still requires the `python3-saml` optional dependency to
> be installed (a follow-up sprint replaces this with a vendored,
> tighter implementation).
## Status

---
The SAML and SCIM surfaces are production-gated by code, storage migrations, and preflight checks.

## 1. SAML 2.0
GA readiness still requires a customer-specific live validation run because every IdP tenant has different certificate rotation, attribute mapping, group push, and app assignment settings. Do not mark a customer environment complete until the validation matrix at the end of this page has a green report for that customer's Okta, Microsoft Entra ID, OneLogin, or equivalent tenant.

### 1.1 Endpoints
## SAML 2.0 SSO

### Endpoints

| Path | Verb | Purpose |
|------|------|---------|
| `/saml/metadata` | GET | TokenDNA SP metadata XML — upload to your IdP. |
| `/saml/login` | GET | Returns redirect URL + RelayState for SP-initiated SSO. |
| `/saml/acs` | POST | Assertion Consumer Service. Validates SAMLResponse. |
| `/saml/metadata` | GET | TokenDNA SP metadata XML to upload to the IdP. |
| `/saml/login` | GET | Starts SP-initiated SSO and returns the IdP redirect URL plus RelayState. |
| `/saml/acs` | POST | Assertion Consumer Service. Validates the signed SAMLResponse. |

### Required environment

```bash
SAML_SP_ENTITY_ID=https://tokendna.customer.example/sp
SAML_SP_ACS_URL=https://tokendna.customer.example/saml/acs
SAML_IDP_SSO_URL=https://customer-idp.example/sso
SAML_IDP_X509_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
SAML_ALLOWED_RELAY_STATE_HOSTS=tokendna.customer.example
```

### 1.2 Configuration env vars
Optional controls:

```
SAML_SP_ENTITY_ID=https://app.tokendna.io/sp
SAML_SP_ACS_URL=https://app.tokendna.io/saml/acs
SAML_IDP_SSO_URL=https://idp.example.com/sso
SAML_IDP_X509_CERT="-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----"
```bash
SAML_NAME_ID_FORMAT=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
SAML_REQUEST_TTL_SECONDS=300
SAML_CLOCK_SKEW_SECONDS=180
SAML_ALLOW_IDP_INITIATED=false
```

### 1.3 Setup steps
### Security behavior

1. Operator visits `https://app.tokendna.io/saml/metadata`, downloads
the XML, and uploads it to the IdP (Okta, Azure AD, OneLogin, etc.).
2. Operator copies the IdP's SSO URL and signing certificate into the
TokenDNA env (or secret manager).
3. End user hits `/saml/login`, is redirected to the IdP, signs in, and
the IdP POSTs a SAMLResponse to `/saml/acs`.
4. TokenDNA verifies the assertion and issues an internal session.
TokenDNA requires signed assertions and refuses SAML assertion parsing when `python3-saml` is unavailable. The production preflight fails if SAML is configured without the IdP signing certificate, IdP SSO URL, HTTPS SP URLs, RelayState host allowlist, or runtime SAML dependency.

### 1.4 Hardening checklist
SP-initiated login stores AuthnRequest state durably in the shared SQLite/Postgres backend. `/saml/acs` consumes `InResponseTo` exactly once, validates RelayState binding, checks Destination and Recipient against the configured ACS URL, records assertion IDs for replay defense, and audits both SAML successes and failures.

* `WantAssertionsSigned="true"` — set in metadata; do not relax.
* `python3-saml` installed in production. Without it, `/saml/acs`
refuses to parse and returns `503` rather than trusting an unsigned
assertion.
* IdP-initiated flows: also accept `SAMLResponse` POST without prior
AuthnRequest. RelayState is required and must be validated against
the customer's allowed return URLs.
* Replay protection: every `InResponseTo` is consumed once.
IdP-initiated SAML is disabled by default. Enable it only by explicit customer exception and keep RelayState constrained to approved HTTPS return hosts.

---
### IdP setup

## 2. SCIM 2.0
1. Load `/saml/metadata` in TokenDNA and upload the XML into the IdP SAML application.
2. Configure the IdP ACS URL to `SAML_SP_ACS_URL`.
3. Configure the IdP audience/entity ID to `SAML_SP_ENTITY_ID`.
4. Require signed assertions. Signed responses are acceptable, but signed assertions are mandatory.
5. Map NameID to the customer identity key, usually email or immutable user principal name.
6. Add attribute mappings for email, display name, groups, and any customer-required role claims.
7. Copy the IdP SSO URL and active signing certificate into TokenDNA secrets.
8. Run `python3 scripts/preflight_prod.py --environment production` before live user testing.

### 2.1 Endpoints
## SCIM 2.0 Provisioning

### Endpoints

| Path | Verb | Purpose |
|------|------|---------|
| `/scim/v2/ServiceProviderConfig` | GET | Capability advertisement. |
| `/scim/v2/ResourceTypes` | GET | Schemas exposed (User, Group). |
| `/scim/v2/Users` | POST | Create user. |
| `/scim/v2/Users/{id}` | GET / PUT / PATCH / DELETE | User CRUD + RFC 7644 PatchOp. |
| `/scim/v2/Users` | GET | List with `startIndex` / `count` pagination + `filter=`. |
| `/scim/v2/Groups` | POST / GET | Group create / list (with `filter=`). |
| `/scim/v2/Groups/{id}` | GET / PATCH / DELETE | Group lookup / patch / delete. |
| `/scim/v2/ResourceTypes` | GET | User and Group resource declarations. |
| `/scim/v2/Users` | POST / GET | Create users and list users with pagination/filtering. |
| `/scim/v2/Users/{id}` | GET / PUT / PATCH / DELETE | User lookup, replace, patch, and delete. |
| `/scim/v2/Groups` | POST / GET | Create groups and list groups with filtering. |
| `/scim/v2/Groups/{id}` | GET / PATCH / DELETE | Group lookup, patch, and delete. |

### Auth and tenant isolation

SCIM calls use the normal TokenDNA tenant boundary. For Okta, Microsoft
Entra ID, OneLogin, and most SCIM clients, configure bearer-token auth with
the tenant API key value:

```http
Authorization: Bearer <tenant-api-key>
```

Direct API callers can also send the same key with:

### 2.2 Auth
```http
X-API-Key: <tenant-api-key>
```

OIDC JWT bearer auth remains supported for non-SCIM API callers.

Every SCIM read and write is tenant-scoped. Users and groups are stored durably in the shared backend, have weak ETag-style versions, and emit audit events for create, update, patch, and delete actions.

Bearer-token auth (`Authorization: Bearer <token>`). Tokens are scoped
per tenant and rotated by the operator via the admin console. SCIM
requests carry the same tenant context as the rest of the TokenDNA API.
### Supported SCIM behavior

### 2.3 Schemas
| Capability | Status |
|------------|--------|
| User CRUD | Supported. |
| Group CRUD | Supported. |
| Pagination | Supported for users. |
| Filtering | Supported for core scalar paths and dotted paths. |
| PatchOp | Supported for simple `add`, `replace`, and `remove` operations. |
| ETag advertisement | Supported via `ServiceProviderConfig`. |
| Bulk | Not supported; advertised as disabled. |
| Sort | Not supported; advertised as disabled. |
| Value-filtered multi-valued paths | Returns `501` until a customer IdP requires it. |

* User — `urn:ietf:params:scim:schemas:core:2.0:User`
* Group — `urn:ietf:params:scim:schemas:core:2.0:Group`
## Provider Validation Matrix

Custom enterprise extension schema (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`) is not yet supported. Most IdPs degrade
gracefully when the extension is absent.
Run this matrix for every customer IdP tenant before declaring the integration complete.

### 2.4 What is intentionally NOT supported (yet)
| Provider | Required validation |
|----------|---------------------|
| Okta | SP metadata import, SP-initiated login, assertion signature validation, bad RelayState rejection, replay rejection, user create/update/deactivate/delete, group create/member update/delete. |
| Microsoft Entra ID | Enterprise app SAML setup, certificate rollover check, assigned user login, group claim mapping, provisioning job create/update/disable/delete, group push where licensed. |
| OneLogin | SAML connector import, signed assertion enforcement, NameID and attribute mappings, SCIM bearer auth, user lifecycle, group membership patch. |

| Feature | Status |
|---------|--------|
| `PATCH` operations (simple paths) | **Supported.** `add` / `replace` / `remove` on top-level scalars and dotted sub-attributes (`name.givenName`). |
| `PATCH` value-filtered paths | Returns `501`. e.g. `path = emails[type eq "work"].value` — added after observing real customer traffic. |
| `bulk` operations | `bulk.supported = false`. |
| `sort` parameter | Not honored. |
| `filter` expressions | **Supported.** `eq`, `ne`, `sw`, `ew`, `co`, `gt`, `lt`, `ge`, `le`, `pr`; `and` / `or` / `not`; parens. Dotted paths (`name.givenName`, `meta.lastModified`). Multi-valued bracketed filters (`emails[...]`) return 501. |
| ETag / `If-Match` | Not honored. |
Evidence to retain for GA:

ETag and bulk are the remaining gaps before GA. The two we just shipped
— PATCH and filter — close the largest customer-blocking surface from
the original alpha scope.
1. Preflight JSON report with `passed=true`.
2. Migration status showing the SAML and SCIM schemas applied.
3. SAML login trace with success audit event and no unsigned assertion acceptance.
4. Replay test showing a second POST of the same assertion is rejected.
5. SCIM provisioning transcript for create, update, deactivate, group membership patch, and delete.
6. IdP screenshots or exported app configuration with ACS URL, entity ID, signing requirement, and certificate fingerprint.

---
The repeatable TokenDNA-side harness is:

```bash
python3 scripts/idp_ga_validation.py \
--provider okta \
--base-url https://tokendna.customer.example \
--bearer "$TOKENDNA_TENANT_API_KEY"
```

## 3. Integration test plan
Use `--provider entra` or `--provider onelogin` for those customer tenants. The script verifies metadata, SP-initiated SAML request generation, SCIM discovery, user lifecycle, group lifecycle, and group membership patching, then emits a JSON evidence report. The browser login, assertion replay, and certificate-fingerprint evidence remain customer-tenant manual checks because they require the live IdP admin console and a real assigned user.

For each new IdP:
## Local Deployment Flow

1. Apply the chart with the IdP's SSO URL + signing cert.
2. Run a single end-to-end SAML login from a real user.
3. Provision a user via SCIM `POST /scim/v2/Users` and verify it shows
up in the admin console.
4. Suspend the user via `PUT /scim/v2/Users/{id}` (`active: false`)
and verify the user can no longer authenticate.
5. Delete the user via SCIM and verify cleanup.
```bash
set -a
. ./.env
set +a

A green run on Okta + Azure AD + OneLogin is the gate before this
module flips from alpha to GA.
python3 scripts/preflight_prod.py --environment production
python3 scripts/migrate_storage.py apply
python3 scripts/postgres_smoke.py
```

For Docker Compose appliance deployments:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d postgres
docker compose -f docker-compose.yml -f docker-compose.production.yml run --rm tokendna-deployment-gate
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d tokendna
```
Loading
Loading