Skip to content

Share the CEL environment, let callers extend it, and add it to SSH - #1116

Closed
joshdrake wants to merge 1 commit into
mariano/celfrom
josh/cel-extensions
Closed

Share the CEL environment, let callers extend it, and add it to SSH#1116
joshdrake wants to merge 1 commit into
mariano/celfrom
josh/cel-extensions

Conversation

@joshdrake

Copy link
Copy Markdown
Contributor

Builds on #1115. Base is mariano/cel, so this diff is just the changes on top.

The environment is built once, not per certificate

newCelFunc ran inside WithTemplate, so every certificate paid to register the native types and initialise seven extension libraries — 141.8µs and 286KB — including for templates containing no cel call at all, which today is all of them. The environment doesn't depend on the certificate; only the activation does. It's now built at most once and shared, with compiled programs cached alongside it since a template's expressions don't change between signatures.

BenchmarkWithTemplate/no_cel_call-16      27813 ns/op    55159 B/op
BenchmarkWithTemplate/one_cel_call-16     36117 ns/op    60940 B/op
BenchmarkWithTemplate/five_cel_calls-16   43867 ns/op    63608 B/op

The no-cel-call case is the pre-#1115 baseline again.

Callers can declare typed variables

The environment can only ever be as good as this library's knowledge of the data. Subject and SANs have types here; a webhook response doesn't, so it's dyn and the checker can't see through it — which is unfortunate, because that's where a CA keeps everything interesting.

celutil.Register lets a caller declare typed variables and supply their values from the template data:

celutil.Register(celutil.Extension{
    Name:       "smallstep",
    EnvOptions: []cel.EnvOption{ /* ext.NativeTypes(...), cel.Variable("device", ...) */ },
    Activation: func(data map[string]any) map[string]any { /* project the webhook response */ },
})

An expression then reads device.serial as a string instead of Webhooks.Agent.Device.Serial as a dyn, and a misspelled field is a compile error rather than a certificate that renders wrongly. One registration covers X.509 and SSH alike. Registering invalidates cached environments rather than being silently ignored, so there's no ordering trap.

The function returns a value

fmt.Sprint(out) made the result unusable in a JSON position: a string rendered bare (example, not "example") and a list rendered as [a, b, c]. Piping to toJson didn't fix it either — correct for a string, and it quietly turns a list into a JSON string. Returning the value means {{ cel "..." | toJson }} is correct for a string, a list or a number.

The one visible consequence: a list rendered bare now prints [a b c] rather than [a, b, c]. The sans test expectation is updated for that.

Also

  • SSH templates get the function, which they didn't have — sshutil has its own getFuncMap that Add a cel function to X.509 templates #1115 didn't touch.
  • CELEnvOptions is exported on both packages, so a caller validating expressions ahead of time builds the environment the renderer will actually use rather than approximating it.
  • The cost limit is settable, and changing it discards programs compiled under the old one.
  • The top-level CR variable is dropped — it was declared but never populated (the data puts it at Insecure.CR), so an expression referencing it compiled and then failed at signing time.

Note on json.encode and the cost limit

Worth knowing independently of this PR: cel-go declares json.encode with an unbounded cost estimate, and when its argument is a literal the cost tracker charges that estimate rather than the real work. So json.encode("wifi") is metered at 2^64-1 and trips CostLimit(1000):

json.encode("wifi")                    cost=18446744073709551615  → cost limit exceeded
json.encode(device.serial)             cost=1
json.encode("prefix-" + device.serial) cost=3

Any template doing {{cel "json.encode({'a': 'b'})"}} fails to sign today. Returning a value sidesteps it for the common case (use | toJson instead), but it's a real trap for anyone reaching for json.encode in a template.

Full go test ./... passes.

Related

  • smallstep/protobufs#740, smallstep/api#145 — the expression field
  • smallstep/gateway — the caller that registers the typed schema

🤖 Generated with Claude Code

https://claude.ai/code/session_016N6LAjGgci2KLKYDbdWVcw

The cel function built a fresh environment inside WithTemplate, so every
certificate paid to register the native types and initialise seven
extension libraries: 141.8us and 286KB, including for the templates that
contain no cel call at all, which today is all of them. The environment
does not depend on the certificate, only the activation does, so it is
now built once and shared. Compiled programs are cached with it, since a
template's expressions do not change between signatures.

The environment could also only ever be as good as this library's
knowledge of the data. Subject and SANs have types here; a webhook
response does not, so it is dyn and the checker cannot see through it --
which is unfortunate, because that is where a CA keeps everything
interesting. celutil.Register lets a caller declare typed variables and
supply their values from the template data, so an expression can read
device.serial as a string instead of Webhooks.Agent.Device.Serial as a
dyn, and a misspelled field is a compile error rather than a certificate
that renders wrongly. One registration covers X.509 and SSH alike.

The function now returns the result as a value rather than formatting it
to a string, so a template can pipe it: {{ cel "..." | toJson }} is
correct for a string, a list or a number. Formatting first made toJson
produce a quoted string for every type, which is wrong everywhere a
template needs a list. The one visible consequence is that a list
rendered bare prints as [a b c] rather than [a, b, c].

Also: SSH templates gain the function, which they did not have;
CELEnvOptions is exported so a caller validating expressions ahead of
time can build the environment the renderer will actually use rather
than approximating it; the cost limit is settable, and changing it
discards programs compiled under the old one; and the top-level CR
variable is dropped, having been declared but never populated -- a
template referencing it compiled and then failed at signing time.

Change-Type: refactor
Release-Note: yes
Audience: developer
Impact: low
Breaking: false
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6LAjGgci2KLKYDbdWVcw
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@joshdrake

Copy link
Copy Markdown
Contributor Author

Superseded. Rather than a public CEL environment in this library, crypto gets a single seam for contributing a template function and the CEL machinery moves to the caller. Replacement PR to follow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants