CEL issuance restrictions¶
Operator-written rules, evaluated before every signature, that say why a certificate was refused.
The other pre-issuance gates each encode somebody else's rules: the lint gate the CA/Browser Forum's, CAA the domain owner's, name constraints the issuing CA's own, the key-quality gate the Baseline Requirements'. This one encodes yours — the policy an enterprise invents that no fixed configuration field anticipated:
- a wildcard only for a caller whose token carries the
pki.wildcardentitlement; - DNS names only under the namespace the requester's directory group encodes;
- a two-year lifetime only with an
O=that matches the tenant; - IP SANs only inside
10.0.0.0/8; - a change ticket in the request body, or no certificate.
Each of those is a relationship between facts that live in different places — the CSR, the token, the HTTP request — which is exactly what a declarative allow-list cannot express. Enumerating them as configuration fields would mean a new field, a new release and a new deploy for every policy anyone invents.
At a glance¶
| Rule kind | certificate (the default). The other kind, signing, gates everything else this PKI signs — see CEL signing restrictions |
| Where it runs | ca.Manager.buildLeaf, last of the pre-issuance gates, before any HSM signature |
| Which paths | every one: REST, gRPC, CLI, ACME, EST, SCEP, CMP, MS-WSTEP, BRSKI, bulk, PKCS#12, SPIFFE SVID, the canary — and the low-level POST /api/keys/{id}/sign-x509, which bypasses every other gate |
| Where rules live | the cel_policy_rules table — editable at runtime, no restart, no config reload |
| Who may edit | ca:configure (admin). Reading and dry-running: any role |
| Console | the Signing Policy view: table, editor with a compile check, dry-run, vocabulary reference |
| CLI | secsy-ca issuance-policy list\|show\|add\|update\|enable\|disable\|delete\|test\|reference |
| REST | /api/issuance-policies (+ /{id}, /{id}/enabled, /validate, /test, /reference) |
| Refusal | HTTP 422 with {error, code: "policy_denied", violations: [{rule, message}]} |
| Audit | cert.policy (the gate), policy.rule.create / .update / .delete (administration) |
| Metrics | secsy_certificate_policy_checks_total, …_violations_total, …_rule_errors_total |
| Failure mode | fail-closed — a rule that cannot be evaluated refuses the request |
Why CEL¶
CEL (Common Expression Language) is non-Turing-complete by construction: no loops, no recursion, no I/O, no mutation. A rule is a total function of the facts it is handed, it terminates, and its cost is bounded before it runs. Those properties are what make it safe to evaluate operator-written text on the signing path, where an embedded interpreter would be a remote-code-execution surface.
Three consequences worth knowing:
- A field typo is a load error, not a denied certificate. The request, the
URL and the caller are declared as typed objects, so
request.dsn_namesfails to compile with a column number instead of failing at issuance time. - An expression cannot reach ambient state. There is no way to read a file,
open a socket, read the clock or see anything other than the variables it was
handed. (Evaluation time arrives as the
nowvariable, read by the server.) - A pathological expression cannot burn the signing path. Every evaluation is bounded at 500,000 CEL cost units; exceeding that aborts and — fail-closed — refuses the request.
The same engine and the same function library back the
role-mapping expressions and the
signing restrictions — the second kind of
rule in the same table, which gates the signatures that are not certificates
(OpenSSH certificates, artifact signatures, raw signatures with a named key).
A rule declares its kind, and the kind decides which facts it sees, which gate
consults it and which environment it is compiled in; a certificate rule that
mentions signing.key.name does not compile, which is the error an author wants
rather than a rule that silently never fires.
What a rule sees¶
| Variable | Type | Holds |
|---|---|---|
request |
object | the certificate request: subject, every SAN type, requested validity, key, profile, issuing CA |
jwt |
map | every verified claim of the token the caller presented; empty when they presented none |
url |
object | the HTTP request: method, scheme, host, port, path, segments, query, raw_query, client_ip, present |
path |
string | the full request path — the same value as url.path, for the common case |
body |
map | the decoded request body parameters, by name |
principal |
object | the authenticated caller: subject, email, roles, tenant_roles, tenants, groups, is_root, authenticated |
now |
timestamp | evaluation time, UTC |
The authoritative list — including every field of each object, with its type — is served by the running server and rendered by the console and the CLI:
request¶
The certificate facts are read from the to-be-signed template, not from any one surface's request shape. That is what makes a rule true of the certificate rather than of one protocol's JSON: a validity clamped to the profile maximum, a mailbox normalized by the S/MIME gate, a Must-Staple extension stamped by the profile — a rule sees the values the certificate will actually carry.
| Field | Type | Notes |
|---|---|---|
source |
string | the surface: rest, grpc, acme, est, scep, cmp, wstep, bulk, pkcs12, spiffe, canary, cli, preview, rawsign, unknown |
operation |
string | issue, renew, preview |
profile |
string | the issuance profile's name |
ca |
object | id, label, tenant, subject (RFC 2253), not_after |
subject |
object | common_name, organization, organizational_unit, country, locality, province, street_address, postal_code, serial_number, dn |
dns_names, ip_addresses, email_addresses, uris, upns |
list(string) | the SANs; IPs and URIs rendered as strings so ip()/cidr() and the string functions consume them |
san_count |
int | the total across every SAN type |
wildcard |
bool | true when any DNS name is a leading-label wildcard |
key_usages, ext_key_usages |
list(string) | named as a profile names them (digitalSignature, serverAuth) |
extension_oids |
list(string) | dotted OIDs of the extra extensions the template carries |
is_ca, max_path_len |
bool, int | max_path_len is -1 when the request sets none |
must_staple |
bool | the resolved RFC 7633 decision |
not_before, not_after |
timestamp | the resolved validity window |
validity |
duration | not_after - not_before, exactly |
validity_days |
int | the same in whole days, truncated — the figure the preview and the API report |
key |
object | algorithm (RSA/ECDSA/Ed25519/unknown), size, curve, fingerprint (SHA256:…) |
algorithm |
string | the signature family: classical, pqc, hybrid |
serial |
string | the allocated serial, decimal |
requested_by |
string | the identity the issuance is attributed to |
Durations in a hand-written fact set
The dry-run's fact set accepts a duration the way you say it —
"validity": "90m" — or as a whole number of nanoseconds; both decode, and
the echoed facts use the readable form. A malformed duration is an error
rather than a silent zero.
validity_days truncates
A template's notBefore is backdated by a few minutes of clock skew, so a
90-day request spans 90 days and change. validity_days truncates to whole
days — the same arithmetic the preview response and the API's
validity_days use — so request.validity_days <= 90 does not refuse the
very request the profile was configured to allow. Compare
request.validity (a duration) when you need the exact window.
The low-level raw-signing endpoint
POST /api/keys/{id}/sign-x509 hands a CSR straight to the CA key and
bypasses buildLeaf — so none of the other pre-issuance gates run on it.
These rules do: the facts are derived from the CSR, request.source is
rawsign, and request.profile is empty (there is no profile), so a
profile-scoped rule does not apply while every unscoped and CA-scoped one
does. A rule can single the endpoint out with
request.source == "rawsign".
jwt¶
Only claims a verifier accepted reach a rule. sub, email, iss and
aud are filled in from what the verifier resolved (and aud is always a list,
even when the token carried a bare string), so those four can be relied on;
everything else is the raw claim set, which is what lets a rule inspect
jwt.groups, jwt.repository or whatever entitlement vocabulary the identity
provider uses without this PKI enumerating them.
A map lookup of a key that is not present is a runtime error, which fail-closed means refused. Guard it:
url, path and body¶
Captured at the HTTP edge for every request and carried to the gate on the
request context. body is decoded when the body is a JSON object or an HTML
form, and only up to 256 KiB — a binary enrollment payload (PKCS#10, PKCS#7,
CMP) or an oversize body is passed through untouched and exposes no parameters,
because a truncated view would be worse than none. client_ip is the peer
address, deliberately not X-Forwarded-For: a header the client sets must
not be what a restriction rule trusts.
An automated enrollment carries no token, no JSON body and no console URL. Those
facts are then empty and url.present is false — so a rule that requires an
entitlement refuses such a request rather than passing it. That is the
correct reading, and request.source is how a rule treats a protocol enrollment
differently on purpose.
What a rule says¶
A rule's expression may evaluate to one of three shapes, and the shape is how it reports itself:
| Result | Meaning |
|---|---|
bool |
the condition that must hold (effect require) or must not (effect deny). The reason reported is the rule's message |
string |
the reason itself: "" means satisfied, any other text refuses and is what the requester is shown |
list(string) |
several reasons at once — one per offending name. An empty list means satisfied |
The string forms are why this engine exists in an enterprise PKI. Compare:
# string: the requester learns the number they exceeded
request.validity_days <= 90 ? "" :
"requested %d days of validity; this profile allows at most 90".format([request.validity_days])
# list(string): the requester learns every offending name at once
request.dns_names
.filter(d, !(d.endsWith(".example.com") || d == "example.com"))
.map(d, "DNS name " + d + " is outside example.com, which this CA may not certify")
Every applicable rule is evaluated — a requester who got three things wrong is
told three things, rather than discovering them one retry at a time. priority
(ascending) fixes the order the reasons are read in.
Rule fields¶
| Field | Default | Meaning |
|---|---|---|
name |
— | unique, whitespace-free. It appears in the refusal and in the audit record, and resolves the rule through GET /api/issuance-policies/{name} |
expression |
— | the CEL text |
message |
— | the reason a boolean rule reports; ignored when the expression produces its own text. Falls back to description, then to a sentence naming the rule |
effect |
require |
require — the condition must hold; deny — it must not. Boolean results only |
mode |
enforce |
enforce — refuse; monitor — record the finding and issue anyway |
profiles |
all | profile names or globs the rule applies to, case-insensitive |
cas |
all | issuing CA ids or labels or globs, case-insensitive |
tenant_id |
all | restrict the rule to one tenant's CAs |
priority |
0 |
ascending evaluation order; fixes the order reasons are reported in |
enabled |
true (API) / false (console form) |
a disabled rule is kept, not deleted |
fail_open |
false |
permit issuance when this rule's own evaluation fails |
Fail-closed is the default, and it is the point
A rule that errors at runtime, returns an unusable type or blows the cost
ceiling refuses the request, with a message that says the rule could not
be checked rather than blaming the requester. A restriction engine that
permits what it could not check is not a restriction engine. fail_open
exists for a rule an operator deliberately accepts as best-effort.
Writing a rule safely¶
A wrong rule refuses every certificate under its scope, so the workflow is write disabled → dry-run → enable (or monitor → measure → enforce).
# 1. Write it, switched off.
secsy-ca issuance-policy add -name max-lifetime -disabled \
-expr 'request.validity_days <= 90 ? "" :
"requested %d days of validity; this profile allows at most 90".format([request.validity_days])'
# 2. Dry-run it against a real CSR under the real CA and profile. The facts are
# derived by the same code the gate uses, so this is not an approximation.
secsy-ca issuance-policy test -rule max-lifetime \
-ca issuing -profile server -csr request.pem -validity-days 400
# WOULD BE REFUSED — 1 rule(s) evaluated, 0 skipped
# Reasons:
# - max-lifetime: requested 397 days of validity; this profile allows at most 90
# 3. Switch it on.
secsy-ca issuance-policy enable max-lifetime
issuance-policy test exits non-zero when the request would be refused, so a
pipeline can gate on it.
The dry-run also accepts a JSON fact set for everything a CSR cannot carry — the token's claims, the request URL, the body parameters, the caller's identity:
cat > facts.json <<'JSON'
{
"request": { "profile": "server", "dns_names": ["*.example.com"] },
"jwt": { "sub": "ops@example.com", "entitlements": ["pki.issue"] },
"url": { "method": "POST", "path": "/api/ca/ca-1/issue", "present": true },
"body": { "reason": "CHG-10482" },
"principal": { "subject": "ops@example.com", "roles": ["issuer"], "authenticated": true }
}
JSON
secsy-ca issuance-policy test -rule wildcard-entitlement -facts facts.json
Monitor mode¶
-mode monitor records what a rule would have refused — in the decision, in
the cert.policy audit event (with ResultSuccess, because the certificate was
issued) and in secsy_certificate_policy_violations_total{mode="monitor"} — and
issues anyway. It is the difference between a policy an enterprise can adopt and
one it dare not switch on. Measure, then flip the rule to enforce; the next
request is governed, with no restart.
The preview agrees with the gate¶
secsy-ca issue -dry-run, POST
/api/ca/{id}/certificates:preview and the console's preview button all report a
cel_policy gate verdict, evaluated through the same evaluator the issuance path
enforces — pass, fail (with one finding per reason), warn (monitor-mode
findings only), or skipped when no rule applies. A dry-run that says "accept"
is not promising something a rule will then refuse.
What the requester sees¶
POST /api/ca/ca-1/issue
→ 422 Unprocessable Content
{
"error": "requested 365 days of validity; this profile allows at most 30 (policy rule \"max-lifetime\"); DNS name a.evil.test is outside example.com (policy rule \"dns-suffix\")",
"code": "policy_denied",
"violations": [
{ "rule": "max-lifetime", "message": "requested 365 days of validity; this profile allows at most 30", "mode": "enforce" },
{ "rule": "dns-suffix", "message": "DNS name a.evil.test is outside example.com", "mode": "enforce" }
]
}
422, not 403. The caller's credentials were accepted and their permission to
issue was never in question — the content of the request is what policy
refused. A client that tells the two apart can fix the request rather than go
hunting for a missing role. The reasons travel twice: once as a sentence in
error, for a human or a CLI, and once per rule in violations, for a client
that renders them.
On the protocol paths the refusal becomes that protocol's error, carrying the
same text — an ACME urn:ietf:params:acme:error:* problem document, an EST/SCEP
failure response.
Worked examples¶
The console offers these as insertable starting points, and the test suite
evaluates every one of them, so an example copied out of the console compiles.
secsy-ca issuance-policy reference prints the current set.
Restrict DNS names to a suffix — and name the ones that do not fit:
request.dns_names
.filter(d, !(d.endsWith(".example.com") || d == "example.com"))
.map(d, "DNS name " + d + " is outside example.com, which this CA may not certify")
Wildcards need an entitlement in the token (message: a wildcard
certificate requires the pki.wildcard entitlement in your token):
Forbid internal names on a public CA (effect: deny):
request.dns_names.exists(d, d.endsWith(".internal") || d.endsWith(".local"))
|| request.subject.common_name.endsWith(".internal")
IP SANs must be inside the corporate ranges — containment written with the
network library rather than a string comparison that gets 10.0.0.0/8 subtly
wrong:
request.ip_addresses
.filter(a, !(cidr("10.0.0.0/8").containsIP(a) || cidr("192.168.0.0/16").containsIP(a)))
.map(a, "IP address " + a + " is outside the corporate ranges 10.0.0.0/8 and 192.168.0.0/16")
Minimum key strength:
(request.key.algorithm == "RSA" && request.key.size >= 3072) ||
(request.key.algorithm == "ECDSA" && request.key.size >= 256) ||
request.key.algorithm == "Ed25519"
Service accounts may only name their own namespace — the group name encodes it, a regex extracts it:
cel.bind(ns,
has(jwt.groups)
? jwt.groups.map(g, regex.extract(g, "^pki-ns-([a-z0-9-]+)$").orValue("")).filter(s, s != "")
: [],
ns.size() == 0
? "your token carries no pki-ns-<namespace> group, so no name can be certified for you"
: (request.dns_names.all(d, ns.exists(n, d.endsWith("." + n + ".svc.cluster.local")))
? ""
: "every DNS name must sit under one of your namespaces: " + ns.join(", ")))
Only an admin may mint a subordinate CA:
Automated enrollment may not request long-lived certificates:
The request body must carry a change ticket — a parameter the API accepts but the certificate does not carry:
The function library¶
The CEL standard library plus nine upstream extension libraries. The
authoritative list is GET /api/issuance-policies/reference; the summary:
| Group | Functions |
|---|---|
| operators, macros | == != < <= > >= && \|\| ! in, a ? b : c, has(), size(), all / exists / exists_one / map / filter |
| two-variable comprehensions | all(i, x, p), exists(i, x, p), existsOne(i, x, p), transformList, transformMap — the element index a rule otherwise cannot see |
| bindings | cel.bind(name, init, expr) — name a subexpression so it is evaluated once |
| strings | contains, startsWith, endsWith, matches (RE2), charAt, indexOf, lastIndexOf, lowerAscii, upperAscii, replace, split, substring, trim, reverse, join, strings.quote, format |
| regex | regex.replace, regex.extract → optional<string>, regex.extractAll; orValue, hasValue, value |
| lists, sets | distinct, flatten, reverse, slice, sort, sortBy, lists.range, sets.contains, sets.equivalent, sets.intersects |
| math | math.greatest, math.least, math.abs, math.sign, math.ceil, math.floor, math.round, math.trunc, math.isInf/isNaN/isFinite, the bit operations |
| network | isIP, ip, ip.isCanonical, <ip>.family/isLoopback/isGlobalUnicast/isLinkLocal*/isUnspecified, isCIDR, cidr, <cidr>.containsIP/containsCIDR/ip/masked/isMask/prefixLength |
| encoding | base64.encode, base64.decode |
| time | timestamp(), duration(), arithmetic on both, getFullYear/getMonth/getDayOfWeek/getHours… |
Operating it¶
Audit¶
cert.policy— written whenever a rule produced a finding.ResultErroris a blocked issuance (including a rule that failed closed);ResultSuccessa monitor-mode finding that let the certificate through. The detail carries the surface, the profile, the number of rules evaluated and every finding with its rule name and mode — the same text the requester was given, so the log answers "why was this refused" on its own.policy.rule.create/.update/.delete— administration, with the rule's name and its expression. A restriction engine is only as trustworthy as the record of who changed it.
Metrics¶
| Series | Labels | Use |
|---|---|---|
secsy_certificate_policy_checks_total |
result = pass|deny|monitor|error |
is the gate running, and how often does it refuse |
secsy_certificate_policy_violations_total |
rule, mode |
which rule is actually refusing traffic |
secsy_certificate_policy_rule_errors_total |
rule |
alert on this — a non-zero rate means a rule is refusing for a reason that has nothing to do with the requests |
Multi-tenancy¶
A rule with no tenant_id applies to every tenant's CAs; one with a tenant_id
applies only to that tenant's. The gate loads the deployment-wide rules plus the
issuing CA's own tenant's, so a tenant's policy cannot reach another tenant's
authorities. See multi-tenancy.
Performance¶
Rules are read from the database on every issuance — one indexed query — and compiled programs are memoized by their expression text, so the steady state is a map lookup per rule. Editing a rule in the console is therefore immediate and needs no invalidation: new text is simply a new key. Evaluation is bounded at 500,000 cost units per rule.
Store migration¶
cel_policy_rules is copied by secsy-ca db migrate, so a SQLite → PostgreSQL
migration carries the policy with it. A migrated store that had lost its rules
would issue certificates the source store refused.
↩ Back to issuance policy & pre-issuance gates · CEL signing restrictions · documentation map