Skip to content

Identity Providers

Cloud-Native DCS does not keep a user directory. It authenticates against an OIDC provider or an LDAP directory the plant already runs, and it decides what each user may do from the groups that identity arrives carrying. This page is about the join between those two halves: getting a specific provider to emit a group claim, and getting the values in that claim to line up with the roles this deployment defines.

The settings themselves are documented next door. Security Hardening § 2 owns the gateway configuration block, and Security Operations § Authentication owns the flows and the exempt routes. What is here is the mapping, the failure it produces when it is wrong, and the two places where identity configuration reaches into plant operation: the site a user is scoped to, and whether an operator can sign.

What the gateway reads from a token

The browser UI and the dcs CLI both send the provider's ID token as the bearer credential, and the gateway verifies it against the configured issuer with the configured audience. An access token is never sent and never verified, which matters on providers that let you put a groups claim in one and not the other.

The gateway reads these claims and ignores everything else in the token:

Claim Used for
sub The subject recorded on every audit record and electronic signature
email, name Display identity in the UI header and the audit trail
groups Role resolution and site assignment — the subject of this page
jti The session identifier audit records correlate on, and that an administrator revokes
iat Token age, which the electronic-signature endpoints require to be recent
exp Expiry, re-checked on every request and every 50 seconds on a live stream

When groups is absent or empty, the gateway falls back to a single namespaced claim, https://dcs.io/groups, for providers whose custom claims must carry a namespace. There is no other fallback and no configurable claim name. A provider that emits memberships under roles, wids, resource_access, or any other name is emitting them where nothing reads them, and the user resolves to no permissions at all.

That last sentence has a Keycloak-shaped trap in it. Keycloak client roles land in resource_access.<client-id>.roles and Keycloak realm roles land in realm_access.roles. A realm configured with roles in place of groups therefore produces a token that reads correctly to a human and grants nothing here. Use groups on Keycloak, or map the roles into a claim named groups.

A group value is a role name, spelled exactly

On the OIDC path there is no mapping table. Each value in the groups claim is compared for exact equality against the names in the deployment's role table, and a value that matches nothing is discarded silently. The comparison is byte-for-byte. DCS-Operator does not match dcs-operator, and Keycloak's full group path /dcs-operator does not match dcs-operator either.

The shipped role table defines six human roles (ADR 0005):

Role name Grants
dcs-admin read, operate, operate-lead, engineer, supervise, admin, interlock:bypass
dcs-supervisor read, operate, operate-lead, engineer, supervise
dcs-engineer read, operate, operate-lead, engineer, interlock:bypass
dcs-lead-operator read, operate, operate-lead
dcs-operator read, operate
dcs-viewer read

Two more group names are product-defined and cannot be redefined. A group named dcs-site-<name> assigns the user to that ISA-88 site and confines every site-scoped surface to it, and dcs-mes-integration is the shape an MES integration key carries. Both are matched by the gateway itself, so both have to arrive as literal strings in the claim.

Role names are deployment configuration, which is the release valve when a directory cannot produce the names above. A roles file may name its roles whatever the provider actually emits, including opaque identifiers:

gateway:
  auth:
    roles:
      # Microsoft Entra ID emits group object IDs unless the groups were
      # synced from on-premises AD. Naming the role after the object ID is
      # a supported configuration, not a workaround.
      "11111111-2222-3333-4444-555555555555":
        permissions: [read, operate]
      "66666666-7777-8888-9999-000000000000":
        permissions: [read, operate, operate-lead, engineer, supervise, admin, interlock:bypass]

A non-empty roles map replaces the shipped table entirely, so re-declare every role the deployment still wants. The gateway refuses to start on an unknown permission name, and it logs a warning when no role grants admin. Keep the file under change control: it is the document that says which directory group runs the plant.

The site prefix has no such release valve. dcs-site-<name> is matched by prefix in product code, so a provider that can only emit opaque identifiers can carry roles but cannot carry site assignment. On such a provider, either arrange for the group names to be emitted (see Entra ID below) or accept that users are unscoped and rely on the role table alone.

Per-provider mapping

Provider Can it emit group names? What to configure
Keycloak Yes Group Membership mapper, claim name groups, full group path off
Microsoft Entra ID Only for AD-synced groups, or for cloud groups assigned to the app Groups optional claim on the ID token, or roles named by object ID
Okta Yes Groups claim on the OpenID Connect ID token, filtered to dcs-
PingOne Yes Custom ID token attribute groups from memberOfGroupNames
PingFederate Yes, after a transform OIDC policy contract extension delivered in the ID token
Google Workspace No Not usable directly; front it with a broker
Auth0 Yes, namespaced An Action adding https://dcs.io/groups

Keycloak

Create the client as a public client with Client authentication off and PKCE required. The browser UI performs the authorization code flow with PKCE and holds no client secret, so a confidential client rejects its token request. Enable OAuth 2.0 Device Authorization Grant on the same client for dcs login.

Create the groups in the realm (dcs-admin, dcs-supervisor, dcs-engineer, dcs-lead-operator, dcs-operator, and dcs-viewer if any identity here only reads) plus one dcs-site-<name> group per site, and add a Group Membership protocol mapper to the client with Token Claim Name groups, Full group path off, and Add to ID token on.

Full group path is the setting that goes wrong most often. With it on, the claim carries /dcs-operator in place of dcs-operator, every value fails the equality check, and the user authenticates into a session with no permissions.

The issuer URL is https://<keycloak-host>/realms/<realm>.

Microsoft Entra ID

Register the redirect URI under the Single-page application platform, not Web. Entra enables cross-origin token redemption only for the SPA client type, and a redirect URI registered under Web fails the browser's token request with AADSTS9002326. Set Allow public client flows to yes so dcs login can use the device code flow.

Group claims need both halves of the configuration. Set groupMembershipClaims in the app manifest (SecurityGroup, or ApplicationGroup to emit only groups assigned to this application). Then add the groups optional claim for idToken. The ID token is the credential the gateway verifies, so a claim configured only for accessToken never arrives.

What Entra puts in that claim depends on where the group came from:

  • Groups synced from on-premises Active Directory can be emitted as sam_account_name, dns_domain_and_sam_account_name, or netbios_domain_and_sam_account_name. Name the AD groups dcs-operator and the rest, choose the bare sam_account_name format, and the values match the shipped role names directly.
  • Cloud-only groups carry no sAMAccountName, so by default they are emitted as object IDs. Display names are available through the cloud_displayname property, but only when groupMembershipClaims is ApplicationGroup. In that mode nested groups are not expanded, and each user must be a direct member of a group assigned to the application.
  • Otherwise the claim carries GUIDs, and the deployment names its roles after those GUIDs in the roles file as shown above. Site assignment is unavailable on this path, because dcs-site-<name> is matched by prefix.

Entra also omits the groups claim entirely, replacing it with _claim_names and _claim_sources pointing at Microsoft Graph, once a user is a member of more than 200 groups. The gateway does not follow that pointer. In a large tenant this is not a hypothetical: it strikes exactly the long-tenured staff who have accumulated memberships, and it looks like a per-user fault, though it is a configuration one. The fix is to restrict the claim with ApplicationGroup or a group filter so the count stays under the limit.

Two more Entra settings do not do what their names suggest here. App roles are emitted in a roles claim, which the gateway does not read. The Emit groups as role claims advanced option moves group values out of groups and into roles, which breaks a working configuration. Leave both alone.

The issuer URL is https://login.microsoftonline.com/<tenant-id>/v2.0, and the audience is the application client ID.

Okta

An Okta org authorization server can put a groups claim in an ID token but not in an access token. That suits this product exactly, since the ID token is what the gateway verifies. On the app's Sign On tab, edit OpenID Connect ID Token, set the group claim type to Filter, the claim name to groups, and a regex of ^dcs- so the plant's other groups stay out of the token. A custom authorization server works too and additionally allows the claim in access tokens, which this product does not need.

Create the Okta groups named exactly as the roles are named, dcs-operator through dcs-admin, plus the dcs-site-<name> groups.

The device authorization grant is configured on a Native application in Okta, while the browser flow wants a redirect URI and a trusted origin for the gateway host. The gateway serves one client ID to both the browser and the CLI (see below), so both capabilities have to live on the one app registration. Where an org's app-type policy will not allow that, the browser flow is the one to configure, and CLI users fall back to DCS_AUTH_TOKEN.

The issuer URL is https://<org>.okta.com for the org authorization server, or https://<org>.okta.com/oauth2/<id> for a custom one.

PingOne

PingOne carries group membership on the user profile as memberOfGroupNames. In the application's Attributes configuration, add a custom OIDC attribute named groups mapped to that value, and confirm it is delivered in the ID token itself. The gateway reads claims from the token and never calls userinfo. Where the population carries groups beyond this product, use the expression builder to filter the list down to the dcs- names before it is sent.

PingFederate

Extend the OpenID Connect policy's attribute contract with a groups attribute, select Override Default Delivery, and check ID Token. Fulfil it from the directory's memberOf attribute.

memberOf yields distinguished names, so the values arrive as cn=dcs-operator,ou=groups,dc=example,dc=com. A bare group name never appears there. Either transform them down to the CN in contract fulfilment, or define the roles file with the full DNs as role names. The second option works and is honest about what the directory emits, but it puts directory topology into the role table. A group that later moves to another OU then silently stops granting anything.

Google Workspace

Google does not emit group membership in an OIDC ID token at all. The hd claim identifies the Workspace domain and nothing identifies the groups within it, and group membership is only reachable through a separate Admin SDK or Cloud Identity API call that the gateway does not make.

Google is also the one common provider that does not implement prompt=login, which is what the re-authentication step of an electronic signature relies on (see below).

Neither gap is repairable in gateway configuration. A Workspace-authenticated deployment puts a broker in front (Keycloak or Dex federating to Google as an upstream) and lets the broker resolve groups and issue the tokens this product verifies. That is the shape our own capture stack runs, with Dex fronting an LDAP directory and emitting bare cn values in the groups claim.

Auth0

Auth0 custom claims are conventionally namespaced, which is why the gateway accepts https://dcs.io/groups as a fallback. Add an Action on the login flow that sets that claim on the ID token from the user's roles or groups. A non-namespaced groups claim also works if the tenant is configured to allow one, and the gateway prefers it when both are present.

When a claim maps to nothing

The characteristic failure is a user who signs in successfully and then cannot do anything. Authentication and authorization are separate steps here: a valid token always produces a session, and a session whose groups match no role holds no permissions. Every request, including a read-only GET, is refused with 403. The UI shows a signed-in identity in the header and empty views beneath it.

Read the resolved identity back from the gateway, because only the gateway holds the deployment's role table a hand-decoded token cannot show:

curl -H "Authorization: Bearer $TOKEN" \
  https://dcs.<plant>.example.com/api/v1/auth/session
{
  "subject": "a4f1…",
  "name": "Priya Nair",
  "email": "priya.nair@example.com",
  "groups": ["/dcs-engineer", "Domain Users"],
  "permissions": [],
  "roles": []
}

That response is the whole diagnosis. groups is what the token carried, and roles is what the gateway matched. The CLI answers the same question from the other side, and says so in as many words when the answer is nothing:

$ dcs auth entitlements
No actions — the identity's groups match no configured role.

The gateway logs every refusal with the deciding layer, so authorization denied … decision=no-grant in the gateway log is the same finding from the server's point of view. Note that refused reads are logged but not written to the audit trail, so an operator who can see nothing leaves no AuthorizationGate record to look for.

What the readback shows Cause Fix
groups empty, provider is Keycloak or Dex The groups scope was not requested Leave gateway.auth.oidc.scopes at its default, which includes groups
groups empty, provider is Entra ID The optional claim was set for accessToken only, or the user exceeds the 200-group limit Add the claim for idToken; restrict to ApplicationGroup
groups empty, provider is Google Google emits no groups claim Front it with a broker
Values carry a leading / Keycloak full group path is on Turn it off, or rename the roles to /dcs-operator
Values are GUIDs Entra cloud-only groups Name the roles after the object IDs
Values are DNs PingFederate memberOf, unfiltered Transform to the CN, or name the roles with the DNs
Values look right but roles is empty Case or spelling mismatch Compare byte-for-byte against dcs auth policy
roles non-empty, permissions empty The roles file grants that role nothing Fix the roles file

dcs auth policy renders the active role table as the running gateway holds it, which is the authoritative list of names to spell the groups after. It requires the supervise permission, so keep one known-good administrator identity outside the directory change you are testing.

One client serves the browser and the CLI

The gateway publishes a single issuer, client ID, and scope set from GET /api/v1/auth/config, and both the browser UI and the dcs CLI configure themselves from it. The client therefore has to be a public client supporting two flows: the authorization code flow with PKCE (S256) for the browser, and the device authorization grant for dcs login.

The browser's redirect URI is the URL of the page that started the flow, and sign-on always starts from the login page. https://dcs.<plant>.example.com/login is therefore the URI to register. Register it for every origin the UI is reached through, including any port a reverse proxy presents, because providers accept registered URIs literally.

Where a provider cannot offer both flows on one client, the browser flow is the one to keep, and DCS_AUTH_TOKEN supplies the CLI with a token obtained some other way:

DCS_AUTH_TOKEN="$(some-provider-cli print-id-token)" dcs batch list --site plant-01

LDAP as the non-OIDC path

With gateway.auth.mode: ldap the gateway binds against the directory itself and issues its own session JWT, and the whole OIDC discussion above does not apply. The group mapping is explicit, which makes it the easier path when the directory's group names cannot be changed:

gateway:
  auth:
    mode: ldap
    ldap:
      url: "ldaps://ad.example.com:636"
      bindDN: "cn=dcs-svc,ou=service,dc=example,dc=com"
      bindPasswordSecret: "dcs-ldap-bind"
      userSearchBase: "ou=users,dc=example,dc=com"
      userSearchFilter: "(&(objectClass=person)(sAMAccountName={{.Username}}))"
      groupMappings:
        "cn=plant-operators,ou=groups,dc=example,dc=com": dcs-operator
        "cn=plant-engineers,ou=groups,dc=example,dc=com": dcs-engineer
        "cn=plant-qa,ou=groups,dc=example,dc=com": dcs-supervisor

Three properties of groupMappings are worth knowing before the first login attempt.

The keys are distinguished names. The gateway resolves memberships from the user's memberOf attribute, or from a group search when groupSearchBase is set, and both yield DNs. A key written as dcs-operator or DCS-Admins matches nothing.

The lookup is exact and case-sensitive, while LDAP itself treats DNs as case-insensitive. Copy each key from the directory byte for byte, because CN=Plant-Operators,OU=Groups,DC=example,DC=com and the same DN in lower case are two different keys here, and only one of them is what the server returns.

The mapping is the whole role assignment. A user in a directory group with no entry here authenticates and resolves to no role, and the diagnosis is the same /api/v1/auth/session readback as on the OIDC path, except that groups shows the mapped DCS names in place of the directory's.

Group resolution runs under the service account, because hardened directories deny end users directory-wide read, and a group search bound as the user then returns nothing. Configure bindDN and bindPasswordSecret even where an anonymous bind can find the user. The bind password's rotation procedure is in the rotation runbook, and the LDAP path is the one that enforces consecutive-failed-login lockout, covered in Security Operations.

Session lifetime and the signature window

Session lifecycle (logout, the server-side inactivity timeout, the browser idle lock, and administrative session termination) is owned by Security Hardening § Session Lifecycle and is not restated here. One consequence of provider configuration does belong here, because it is where identity stops being an install-time concern and reaches the plant floor.

Electronic signatures require a token issued within the last five minutes. Recipe approval, release, withdrawal, rejection, batch-record finalization, and ad-hoc execution all check the iat claim before signing, and a token older than that is refused with 401 and reauthentication_required (21 CFR Part 11 §11.200, see the compliance record). The check is on token age alone, so it fires on a session that is perfectly valid and in active use.

That interacts with provider configuration in one direction. A provider issuing long-lived ID tokens is not more convenient here. It is strictly worse. The longer a token lives, the larger the fraction of a shift in which the next signature attempt will be refused, and a supervisor who approves recipes several times a shift will meet the refusal on most of them. Nothing is gained by extending ID token lifetime for this product.

Re-authentication is how the refusal is cleared, and it is worth confirming with the provider before an audit, and never during one:

  • The browser's re-authentication uses prompt=login, which asks the provider to authenticate the user afresh and issue a token with a new iat. A provider that ignores it (Google being the common case) returns the same stale session and the signature fails again.
  • A provider that satisfies prompt=login from an existing SSO session without a credential prompt is a compliance question as well as a functional one. Part 11 §11.200(a)(1) expects the signing to be a deliberate act by the signer.
  • In LDAP mode the gateway's own session JWT lasts sessionDuration (eight hours by default), and its iat is the login time. A signature more than five minutes after login is refused for the same reason.

In the browser the refusal is cleared from the signature dialog itself. The dialog closes and offers Re-authenticate, which sends the operator to the login page carrying the page they were on. In OIDC mode the login page starts the prompt=login flow and exchanges the returned code. In LDAP mode it presents the username and password form, which is the only way to mint a token with a new iat in that mode. Either way the operator lands back where the signature was refused and repeats the action. On the CLI the equivalent is dcs login.

Because re-authentication runs from the login page, it uses the same redirect URI the initial sign-in does. No second entry is needed in the provider's client configuration.

Finally, an API key identity can never sign. API keys carry no iat at all, so every signature endpoint answers them reauthentication_required by construction and on purpose. A signature is a person's act, and an integration credential is not a person. An MES or LIMS integration that needs to land data in a running batch uses the non-signature endpoints for it.