CEL role mappings — group names that encode the tenant¶
One mapping per role instead of one per role × tenant, and a dry-run that proves what a login will resolve to before anyone logs in.
A deployment serving three business units. Identity lives in Active Directory and reaches the console through the corporate IdP. Both assert group names that carry the structure:
pki-admins-acme-corp pki-issuers-acme-corp
pki-admins-globex pki-issuers-globex
pki-admins-initech pki-issuers-initech
CN=PKI-Auditors-globex,OU=Groups,DC=corp,DC=example,DC=com
Enumerated as exact-value mappings that is 3 roles × 3 tenants = 9 rows,
growing by three every time a business unit is onboarded — and silently wrong
the day someone creates a group nobody added to the file.
config.yaml writes the rule instead:
- expression: 'groups.exists(g, g.matches("^pki-issuers-[a-z0-9-]+$"))'
tenant_expression: |
groups.filter(g, g.matches("^pki-issuers-[a-z0-9-]+$"))
.map(g, g.replace("pki-issuers-", ""))
roles: ["issuer"]
Onboarding a business unit is then one tenants: row. The mappings already
cover it.
What the example shows¶
| One row per role | pki-admins-<tenant> → admin in that tenant, pki-issuers-<tenant> → issuer, for every tenant |
| A list of tenants | tenant_expression yields a list, so an operator in pki-admins-globex and pki-issuers-acme-corp gets both roles in their own tenants |
| Directory DNs | regex.extract pulls the tenant out of CN=PKI-Auditors-<tenant>,OU=Groups,…, with the OU anchored so a look-alike in OU=Contractors cannot match |
| An identity-field rule | email_verified && email.endsWith("@audit.corp.example.com") grants read-only without any group at all |
| A machine pattern | one anchored subject pattern covers every repository in the GitHub org, for the release environment only |
| The fence | a derived tenant id must name a tenant declared in tenants: — pki-issuers-acme-crop grants nothing |
Walk through it offline¶
No IdP, no directory, no HSM, no database: role-mapping reads the config file
and nothing else, and answers with the same compiled mappers the server builds
at startup.
$ cd examples/role-mapping-cel
$ secsy-ca -config config.yaml role-mapping list
auth.oidc (console provider "default") — groups claim "groups"
# MATCH TENANT ROLES
0 CEL: groups.exists(g, g.matches("^pki-admins-[a-z0-9-]+$")) CEL: groups.filter(g, g.matches("^pki… admin
1 CEL: groups.exists(g, g.matches("^pki-issuers-[a-z0-9-]+$")) CEL: groups.filter(g, g.matches("^pki… issuer
2 groups="pki-platform-admins" <platform-wide> admin
3 CEL: email_verified && email.endsWith("@audit.corp.example.com") <platform-wide> auditor
…
An operator who administers one unit and issues in another. One mapping set, two tenants, no platform-wide authority:
$ secsy-ca -config config.yaml role-mapping test -source default \
-group pki-admins-globex -group pki-issuers-acme-corp
[0] MATCHED expression=groups.exists(g, g.matches("^pki-admins-…")) … roles=[admin]
tenants: globex
[1] MATCHED expression=groups.exists(g, g.matches("^pki-issuers-…")) … roles=[issuer]
tenants: acme-corp
[2] — <groups claim>="pki-platform-admins" tenant=<platform-wide> roles=[admin]
[3] — expression=email_verified && email.endsWith("@audit.corp.example.com") …
=> platform roles: (none)
=> tenant "acme-corp" roles: issuer
=> tenant "globex" roles: admin
A directory auditor. -group is repeatable and taken verbatim — the
commas in a DN are part of it, not separators:
$ secsy-ca -config config.yaml role-mapping test -source ldap \
-group 'CN=PKI-Auditors-initech,OU=Groups,DC=corp,DC=example,DC=com' \
-group 'CN=PKI-Auditors-evil,OU=Contractors,DC=corp,DC=example,DC=com'
[0] MATCHED expression=groups.exists(g, g.matches("^CN=PKI-Auditors-…,OU=Groups,…$")) … roles=[auditor]
tenants: initech
=> tenant "initech" roles: auditor
The contractor-tree group is ignored: the pattern is anchored on OU=Groups,
so a group named to look like a PKI group elsewhere in the directory matches
nothing.
A group naming a tenant that does not exist. The pattern fires, the id does not name a declared tenant, and the mapping therefore grants nothing — said out loud rather than reported as a plain non-match:
$ secsy-ca -config config.yaml role-mapping test -source ldap \
-group 'CN=PKI-Auditors-newco,OU=Groups,DC=corp,DC=example,DC=com'
[0] FIRED expression=groups.exists(…) …
refused tenant "newco": not a declared tenant id, or not a usable one
=> platform roles: (none)
FIRED rather than —: the match half applied and the mapping still granted
nothing. That is the difference between "fix the pattern" and "fix the tenant
expression", and the report says which.
Declare newco under tenants: and the same group starts working. That is the
onboarding step, and it is the only one.
Writing a new expression. Try it before it goes in the file:
$ secsy-ca -config config.yaml 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-globex
And the vocabulary the deployment actually supports — variables with their types, every function family, the cost ceiling:
Add -json to any of these for the structured form: the per-mapping decisions,
the resolved roles, and the identity exactly as the expression saw it.
Two rules worth internalizing¶
An unextracted tenant grants nothing. In static configuration an omitted
tenant: means platform-wide — authority across every tenant. A
tenant_expression that resolved to nothing is deliberately not read that
way; the mapping contributes nothing at all. A pattern that fails to find the
tenant must never escalate out of it.
Make the tenant filter as strict as the match. The two expressions are
evaluated independently. If expression anchors on OU=Groups but
tenant_expression filters on a bare startsWith("CN=PKI-Auditors-"), the
tenant expression will happily extract an id out of a group the match expression
rejected — leaving the declared-tenant list as the only thing between a
contractor group and a role. The config here repeats the full anchored pattern
in both for exactly that reason.
Try it for real¶
# From the repo root, with SoftHSM set up (scripts/setup-softhsm.sh)
$ cd server && go build -tags sqlite -o ../bin/secsy-pki-server ./cmd/server
$ go build -tags sqlite -o ../bin/secsy-ca ./cmd/secsy-ca
# Point the config at your IdP / directory, then
$ ../bin/secsy-pki-server -config ../examples/role-mapping-cel/config.yaml
A mapping whose expression does not compile is a startup 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. An expression that fails at
evaluation (an unguarded claims.x on a token that omits it) is per-request:
that mapping grants nothing, the others still apply, and the server logs a
warning naming the mapping.
Reference: role-mapping expressions · operator authentication · multi-tenant isolation · per-CA authorization
↩ Back to the examples catalogue