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, ornetbios_domain_and_sam_account_name. Name the AD groupsdcs-operatorand the rest, choose the baresam_account_nameformat, 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 thecloud_displaynameproperty, but only whengroupMembershipClaimsisApplicationGroup. 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 newiat. 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=loginfrom 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 itsiatis 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.
Related Documentation¶
- Security Hardening — the gateway configuration block, session lifecycle, and the rest of the production checklist
- Security Operations — authentication flows, exempt routes, the permission and action model, and audit categories
- ADR 0005 — Deployment-defined roles — why role names are configuration, with no product constants baked in
- Web UI Access — what an operator sees on the login page and in the session header
- 21 CFR Part 11 compliance — the signature record and the freshness requirement behind it
- Secret and Cert Rotation — rotating the LDAP bind password and the session signing key