Skip to content

ADR 0005: Role definitions are deployment configuration; the permission vocabulary is the product contract

Status: Accepted Date: 2026-06-12 Issue: #560

Context

The identity architecture is deliberately external-IdP-only: no built-in identity provider, no internal store of human users, every interactive login via the customer's OIDC provider or LDAP (pkg/auth/). That posture is reaffirmed here — it is correct and does not change.

Authorization, however, is compiled in. The five human role groups (dcs-admin, dcs-supervisor, dcs-engineer, dcs-lead-operator, dcs-operator) are constants in pkg/auth/auth.go, and the role→permission mapping is a static RolePermissions table in pkg/auth/authz.go over a six-permission vocabulary (read, operate, operate-lead, engineer, supervise, admin). The only configurable layer is external-group→role-name mapping (--ldap-group-mappings). Every customer staffs differently; today their only lever is which IdP groups map onto our five fixed names — they cannot define a role or tune what a role may do.

Two implementation details make the hardcoding worse than the table alone suggests:

  • About twenty gateway call sites bypass the permission table and compare role names directly (auth.RequireAnyGroup(auth.AdminGroup, auth.SupervisorGroup) for recipe approval and batch-record review, HasGroup(auth.AdminGroup) for the cross-site bypass in internal/gateway/tenant.go, ad-hoc group lists in internal/gateway/mes.go, …). Even if the table became configurable, those paths would still hardwire the shipped names.
  • The frontend duplicates the role names and its own access rules (ENDPOINT_ACCESS in internal/gateway/static/js/auth.js); the session payload carries raw groups, not resolved permissions.

The product-vs-deployment split principle (ADR 0004 applied it to node join) says the product ships interfaces and recommended defaults while the deployment instance owns policy. Who may approve a recipe is plainly policy. docs/compliance/iec62443.md SR 1.3 (Account management) is already marked Partial; under the external-IdP posture, account lifecycle is the IdP's job, but role/permission definition is the product's gap to close.

Decision

Identity and group membership stay in the customer's IdP. Role definitions — named roles and the permission set each grants — become declarative deployment configuration consumed by the gateway, with the current five roles shipped as the built-in default. The permission vocabulary itself remains a fixed, product-owned contract.

Concretely:

  1. Roles are config. A roles document (YAML: role name → list of permissions) is supplied via a gateway flag/file, rendered from a Helm value (gateway.auth.roles) into a ConfigMap. Absent configuration, the gateway uses the built-in default equal to today's RolePermissions table, so existing deployments are unaffected. Customers may add roles, remove shipped ones, and tune permission sets per role; role names are free-form.
  2. Permissions are the contract. The vocabulary (read, operate, operate-lead, engineer, supervise, admin) is defined, tested, and documented by the product and changes only via product releases. The gateway fails fast at startup on unknown permission names and warns when no configured role grants admin.
  3. Product code checks permissions, never role names. Every direct group comparison in handlers migrates to auth.RequirePermission; the cross-site admin bypass keys on the admin permission instead of the dcs-admin name. After the migration, role names appear in product code only where the default table is defined.
  4. The UI learns permissions from the gateway. The auth session payload carries the user's resolved permission set; the frontend gates pages and controls on permissions and drops its hardcoded role-name constants and ENDPOINT_ACCESS table.
  5. Two conventions stay product-defined: the dcs-site-* group prefix (site assignment) and dcs-mes-integration (the integration identity shape enforced by the MES fence). Integration identities are structural, not staffing policy.
  6. No in-product role administration surface. Role definitions change through the deployment's own GitOps/change-management flow (values file → ConfigMap → rollout), not through a UI, API, or CRD.

Alternatives Considered

  • Roles as CRDs (cluster-scoped Role resources watched by the gateway) — declarative and visible to kubectl/dcs. Not chosen: the gateway is the sole policy enforcement point, and making its authorization policy mutable through the very API surface it protects creates a self-referential escalation path (a compromised admin session could rewrite policy live); it adds watch/bootstrap machinery no second consumer needs; and for the pharma target, security-policy changes belong in the customer's change-managed deployment flow, which a values change rides naturally and a live CR edit sidesteps.
  • In-product role-editing UI/API backed by a ConfigMap — most discoverable. Not chosen for the same self-referential concern plus solo-maintainer surface growth (a policy editor must itself be authorized, audited, and validated). Revisit on concrete customer demand as a new ADR; the config schema chosen here would back it.
  • Full policy engine (per-route or attribute-based rules, OPA-style) — maximum flexibility. Not chosen: the test and compliance-evidence matrix explodes when customers can compose arbitrary policies; the fixed permission vocabulary is exactly the tested, documentable middle ground. Customers compose roles from audited capabilities, not rules from scratch.
  • Do nothing (five fixed roles, group-mapping as the only lever) — cheapest. Not chosen: every customer defines operator/supervisor/ engineer boundaries differently; a compiled-in table cannot absorb that variance and forces forks or feature requests for what is configuration.

Consequences

  • Code that moves: pkg/auth/authz.go gains a loader (defaults + validation) and RolePermissions stops being a package-level mutable map; humanRoleGroups/IsMESOnly derive from the loaded config; ~20 gateway call sites migrate from RequireGroup/RequireAnyGroup/HasGroup to RequirePermission; cmd/dcs-gateway grows the flag and file loading; the auth session payload adds resolved permissions (docs/api-reference.md updates per ADR 0003); auth.js re-gates on permissions.
  • Behavior normalization is part of the migration. Some direct group lists are not expressible as a cumulative tier and look like drift (e.g. an MES endpoint admitting operator and engineer but not lead-operator or supervisor). The migration issue must enumerate each delta and confirm it as intended normalization, not silent change.
  • Chart-shape change → release-worthy: new gateway.auth.roles value, ConfigMap template, checksum annotation for rollout on change.
  • Compliance: IEC 62443 SR 1.3 evidence improves (role/permission definition becomes customer-controlled, change-managed configuration); docs/compliance/iec62443.md and docs/security-operations.md update when the implementation lands, including guidance that role-config changes are GxP change-controlled items in the deployment's quality system.
  • Default-path safety: with no roles config supplied, behavior is identical to today; the five shipped roles remain the documented recommended default.
  • Reversibility: high for the config mechanism (defaults preserve behavior; deleting the flag restores the constant table). Low for the contract claim — once customers write roles files against the permission vocabulary, renaming or removing a permission is a breaking config change and must follow deprecation discipline.