Role-mapping expressions (CEL)¶
Mapping an identity provider's group names to RBAC roles when the group names encode structure — without a config change per team.
A role mapping says which RBAC roles an identity gets. The simplest form names one claim value:
That is the right answer for a handful of named groups, and it should stay the default. It stops being the right answer the moment the organization's group names carry the structure:
pki-issuers-acme-corp pki-issuers-globex pki-issuers-initech
CN=PKI-Admins-emea,OU=Groups,DC=corp,DC=example,DC=com
repo:example-org/app:environment:release
Enumerating those means one mapping per team per environment, a config change and a deploy for every new team, and an enumeration that silently drifts from the directory the day someone adds a group. A CEL expression says the rule instead of the instances:
role_mappings:
# Any group named pki-issuers-<tenant> grants issuer in THAT tenant.
- expression: 'groups.exists(g, g.startsWith("pki-issuers-"))'
tenant_expression: |
groups.filter(g, g.startsWith("pki-issuers-"))
.map(g, g.replace("pki-issuers-", ""))
roles: ["issuer"]
A new team creates pki-issuers-newco in the IdP, the tenant newco exists in
this deployment, and the mapping already covers it.
Where this applies. Every
role_mappings:list takes the same fields:auth.oidc.role_mappingsand eachauth.oidc.providers[].role_mappings(console login), eachoidc.providers[].role_mappings(machine Bearer tokens), andauth.ldap.role_mappings(directory login). One implementation serves all of them, so an expression behaves identically whichever mechanism authenticated the caller.
1. Why CEL, and not a scripting language¶
CEL (Common Expression Language) is non-Turing-complete by construction: no loops, no recursion, no I/O, no mutation, no state. An expression is a total function of the inputs it is handed — it terminates, and its cost is bounded before it runs.
That is what makes it safe to evaluate operator-written text on the authentication path. The expression cannot read a file, open a socket, read the clock, or reach anything other than the variables below; embedding a general interpreter there would be a remote-code-execution surface wearing a config field's clothing.
Three further guards apply:
- Expressions compile at startup, not at login. A syntax error, an undeclared variable, or an expression whose result is not the right type is a load error with a line and a caret in it. The server refuses to start rather than coming up and denying the operators it was configured to admit.
- Every evaluation is cost-bounded (500,000 CEL cost units). An anchored
groups.exists(g, g.matches(…))costs ~10 units per group inspected; the heaviest shape a mapping has reason to write — filter the groups, extract a tenant id from each survivor — costs ~87 units per group, so ~44,000 over a 500-group principal. What the ceiling stops is a quadratic comprehension over a claim whose size the caller controls. - Every failure is fail-closed.
false, an evaluation error, or a cost overrun all mean this mapping grants nothing. A broken mapping never denies what the other mappings authorize, and it never grants more than it matched.
2. The variables¶
An expression sees the authenticated identity, whichever mechanism produced it:
| Variable | Type | Holds |
|---|---|---|
subject |
string |
The stable principal id — the OIDC sub claim, or the directory subject |
email |
string |
The asserted address, "" when there is none |
email_verified |
bool |
Whether the IdP (or the directory) vouched for that address |
name |
string |
Display name, "" when there is none |
username |
string |
Directory username / preferred_username, "" when there is none |
issuer |
string |
The token issuer (iss); "" for a directory login |
provider |
string |
The configured name of the provider that authenticated this caller |
groups |
list(string) |
Group memberships: the configured groups_claim, the directory groups, and the internal groups the store records — the union the login resolved |
claims |
map(string, dyn) |
Every verified claim, by name |
The scalar fields are always present and default to empty, so the common expression needs no guard:
Anything outside that fixed set is reached through claims, where a missing key
is an error rather than a null — so guard it with has():
An unguarded claims.department on a token that omits the claim is an
evaluation error: the mapping grants nothing and the server logs a warning. That
is deliberate — silently reading a typo'd claim name as "no match" is how a
mapping comes to look correct and do nothing.
Print the live list, with types, from the deployment itself:
3. The functions¶
The CEL standard library, plus nine upstream extension libraries. Nothing else:
| Family | Functions |
|---|---|
| Operators | == != < <= > >= && \|\| ! in, ternary a ? b : c |
| Macros | has(claims.x), l.all(x, p), l.exists(x, p), l.exists_one(x, p), l.map(x, f), l.filter(x, p) |
| Comprehensions (two-variable) | l.all(i, x, p), l.exists(i, x, p), l.existsOne(i, x, p), l.transformList(i, x, f), l.transformMap(i, x, f) — the element index a mapping otherwise cannot see |
| Bindings | cel.bind(name, init, expr) — name a subexpression so it is evaluated once |
| Strings (standard) | size(s), s.contains(sub), s.startsWith(p), s.endsWith(p), s.matches(re2) |
| Strings (ext) | s.charAt(i), s.indexOf(sub), s.lastIndexOf(sub), s.lowerAscii(), s.upperAscii(), s.replace(old, new), s.split(sep), s.substring(i, j), s.trim(), s.reverse(), l.join(sep), strings.quote(s), s.format([…]) |
| Regex | regex.replace(s, re, repl), regex.extract(s, re) → optional<string>, regex.extractAll(s, re) |
| Optionals | opt.orValue(default), opt.hasValue(), opt.value() |
| Sets | sets.contains(a, b), sets.equivalent(a, b), sets.intersects(a, b) |
| Lists | l.distinct(), l.flatten(), l.reverse(), l.slice(i, j), l.sort(), l.sortBy(x, k), lists.range(n) |
| Math | math.greatest(…), math.least(…), math.abs(x), math.sign(x), math.ceil(x), math.floor(x), math.round(x), math.trunc(x), math.isInf/isNaN/isFinite(x), the bit operations |
| Network | isIP(s), ip(s), ip.isCanonical(s), <ip>.family()/isLoopback()/isGlobalUnicast()/isLinkLocal*()/isUnspecified(), isCIDR(s), cidr(s), <cidr>.containsIP(x)/containsCIDR(x)/ip()/masked()/isMask()/prefixLength() |
| Encoding | base64.encode(bytes), base64.decode(s) |
Regular expressions are RE2 (Go's regexp): linear-time matching, no
backtracking, so a pattern cannot be made to run away. regex.replace takes
numeric capture-group references (\1…\9); regex.extract returns an
optional, so no match is an explicit none and .orValue("") is how the
expression states what it wants to happen then.
4. Deriving the tenant¶
tenant_expression yields the tenant id — or a list of them, so one mapping
can grant the same role in every tenant a principal's groups encode:
# Group names: pki-issuers-acme-corp, pki-issuers-globex
- expression: 'groups.exists(g, g.startsWith("pki-issuers-"))'
tenant_expression: |
groups.filter(g, g.startsWith("pki-issuers-"))
.map(g, g.replace("pki-issuers-", ""))
roles: ["issuer"]
# Active Directory group DNs: CN=PKI-Admins-emea,OU=Groups,DC=corp,DC=example,DC=com
- expression: 'groups.exists(g, g.matches("^CN=PKI-Admins-[a-z0-9-]+,OU=Groups,DC=corp,DC=example,DC=com$"))'
tenant_expression: |
groups.filter(g, g.startsWith("CN=PKI-Admins-"))
.map(g, regex.extract(g, "^CN=PKI-Admins-([a-z0-9-]+),").orValue(""))
roles: ["admin"]
Two rules make this safe to write:
- An empty result grants nothing. In static configuration an omitted
tenant:means platform-wide — authority across every tenant. Atenant_expressionthat extracted nothing is not read that way: the mapping contributes nothing at all. A pattern that fails to find the tenant must never escalate out of it. - A derived id must name a declared tenant. When the deployment declares
tenants:, a derived id outside that set grants nothing — sopki-issuers-ACMEagainst a tenant calledacmeis a visible no-grant rather than roles in a tenant that does not exist and would start granting the day somebody creates it. A derived id must also look like an id ([A-Za-z0-9][A-Za-z0-9._:@-]{0,63}): unlike a literal in the config file, it is a function of claim values the IdP controls, and it goes on to key role maps, log lines and audit records.
tenant and tenant_expression are mutually exclusive, as are value and
expression — a mapping that matched two ways would be a mapping nobody can
read.
5. Worked example: a CI pipeline¶
A machine-token provider can key on the workload identity itself. GitHub
Actions puts the repository, the ref and the environment in sub:
oidc:
providers:
- name: github
issuer_url: "https://token.actions.githubusercontent.com"
client_id: "secsy-pki-signing" # the audience the workflow requests
role_mappings:
# Any repository in the org, but only its `release` environment.
- expression: 'subject.matches("^repo:example-org/[a-z0-9-]+:environment:release$")'
roles: ["signer"]
signer and issuer are deliberately separate roles, so a pipeline credential
that signs artifacts cannot mint certificates. See
GitHub OIDC signing for the
full workflow, and multiple OIDC providers
for why a provider's mappings are evaluated only for tokens it verified.
6. Try it before you ship it¶
secsy-ca role-mapping reads the config file and nothing else — no database, no
HSM, no reachable IdP — and answers with the same compiled mappers the server
builds at startup.
$ secsy-ca role-mapping list
auth.oidc (console provider "corp") — groups claim "groups"
# MATCH TENANT ROLES
0 groups="pki-admins" <platform-wide> admin
1 CEL: groups.exists(g, g.startsWith("pki-issuers-")) CEL: groups.filter(g, g.startsWith("pki… issuer
Dry-run an identity. -group is repeatable and is taken verbatim (the
commas in a DN are part of it, not separators):
$ secsy-ca role-mapping test \
-email alice@corp.example.com -email-verified \
-group pki-issuers-globex \
-group 'CN=PKI-Admins-acme-corp,OU=Groups,DC=corp,DC=example,DC=com'
auth.oidc (console provider "corp") — groups claim "groups"
[0] — groups="pki-admins" tenant=<platform-wide> roles=[admin]
[1] MATCHED expression=groups.exists(…) tenant_expression=groups.filter(…) roles=[issuer]
tenants: globex
[2] MATCHED expression=groups.exists(g, g.matches("^CN=PKI-Admins-…")) roles=[admin]
tenants: acme-corp
=> platform roles: (none)
=> tenant "acme-corp" roles: admin
=> tenant "globex" roles: issuer
A mapping is reported MATCHED only when it actually grants something. When the
match half applied and the mapping still granted nothing, it is reported
FIRED instead, with the reason — the refused tenant id, the evaluation error
that ResolveIdentity swallows at login, or a tenant expression that resolved
to nothing. That distinction is the difference between "fix the pattern" and
"fix the tenant expression":
$ secsy-ca role-mapping test -group pki-issuers-typo
[1] FIRED expression=groups.exists(…) …
refused tenant "typo": not a declared tenant id, or not a usable one
=> platform roles: (none)
Write an expression without touching the config file at all:
$ secsy-ca role-mapping test \
-expression 'groups.exists(g, g.startsWith("pki-signers-"))' \
-tenant-expression 'groups.filter(g, g.startsWith("pki-signers-")).map(g, g.replace("pki-signers-", ""))' \
-roles signer -group pki-signers-acme-corp
Add -json to any of these for the structured form — the decisions, the
resolved roles, and the identity exactly as the expression saw it. -claims
'{"…": …}' (or -claims @file.json) supplies the full claim set when an
expression reads claims.
7. Writing expressions that cannot be gamed¶
An expression decides who administers the PKI. The group names it reads are asserted by the IdP, and in some directories a user can influence them.
- Anchor every pattern.
g.matches("^CN=PKI-Admins-[a-z0-9-]+,OU=Groups,DC=corp,DC=example,DC=com$")— notg.contains("PKI-Admins"), which also matchesCN=PKI-Admins-x,OU=Contractors,…andCN=Not-PKI-Admins-x,…. The regression test for this feature asserts exactly those near misses. - Constrain the extracted part.
[a-z0-9-]+, not.*: the capture becomes a tenant id. The declared-tenant check is a backstop, not the fence. - Prefer
email_verifiedoveremailalone. An IdP where a user sets their own address otherwise lets them claim another principal's roles — the same rule the centralrbacemail assignments enforce. - Scope cross-issuer trust deliberately. Mappings written under
auth.oidc.role_mappingsapply to every console provider; a mapping that should only honour one IdP belongs on that provider. - Keep platform-wide grants few. A platform role spans all tenants. Prefer a
tenantor atenant_expression, and let the centralrbacblock name the handful of platform operators.
Roles resolved from mappings are unioned with the central
rbac.subjects / rbac.groups assignments and with the
resource grants; mappings can only ever add. An operator
who resolves to no role at all is denied at login unless
auth.oidc.allow_zero_role is set.
8. Where it lives¶
| Concern | Code |
|---|---|
| The CEL environment, function library, compile + cost ceiling | server/internal/celexpr |
| Mapping evaluation, tenant derivation, fail-closed rules | server/internal/authn/mapping.go |
Load-time validation of every role_mappings list |
server/internal/config/config.go (validateClaimMappings) |
| Startup wiring, per-provider mappers, error logging | server/cmd/server/auth.go (newClaimMapper) |
| The dry-run CLI | server/cmd/secsy-ca/rolemapping.go |
The same expression layer — the same compiler, the same function library, the same cost ceiling — also backs CEL issuance restrictions, which apply it to certificate generation rather than to authentication: rules over the certificate request, the caller's verified token, the request URL and its body parameters, evaluated before every signature. A function added to the library is therefore available to both, which is why the table above says "nothing else" rather than listing a per-feature subset.
↩ Back to security & governance · operator authentication · documentation map