Writing Policies

How to write CEL policies for access control -- evaluation model, context variables, and practical examples

Policies are CEL (Common Expression Language) expressions that control which callers can access which resources. They are evaluated top-down, first-match-wins with an implicit deny at the end.

How Evaluation Works

For each resolve request:

  1. KeyZero iterates through policies in declaration order.
  2. The CEL rule expression is executed with variables populated from the verified JWT (via issuer profiles) and the request. If it returns true, the policy's effect (allow or deny) is the final decision. If false, evaluation continues.
  3. Non-boolean results or execution errors cause the policy to be skipped.
  4. If no policy matches, the request is denied (implicit deny, policy name: default-deny).

CEL Context Variables

Variables are populated automatically from the JWT via the issuer's built-in profile (see Bundle Reference for profile mappings per issuer type).

VariableTypeTrustDescription
issuerstringVerifiedMatched issuer name
orgstringVerifiedOrganizational boundary (e.g. repo owner, namespace, project)
servicestringVerifiedWorkload identity (e.g. repository, service account, ARN)
envstringVerifiedEnvironment (e.g. production, staging)
actionstringVerifiedWhat the workload is doing (e.g. workflow name)
branchstringVerifiedCode version/ref (e.g. refs/heads/main)
actorstringVerifiedHuman who triggered the workload
groupslistVerifiedGroup membership from JWT
claims.*mapVerifiedRaw JWT claims (e.g. claims.repository, claims.sub)
refstringRequestRequested secret path (matched against resource ref patterns)
context.*mapUntrustedCaller-declared fields -- never trust without additional checks

A note on trust levels

Verified variables come from the JWT and are cryptographically validated. Request variables come from the resolve request itself. Untrusted variables (context.*) are arbitrary key-value pairs sent by the caller -- always combine them with verified checks.

Practical Examples

1. Team-Based Access

Allow a specific org to access backend secrets using ref.matches() for path scoping:

- name: allow-backend-team
  description: "Backend team can access backend secrets"
  rule: "org == 'myorg' && ref.matches('secret/data/backend/**')"
  effect: allow

2. Environment-Scoped Access

Restrict production secrets to a specific deployment workflow:

- name: prod-deploy-only
  description: "Only the deploy workflow can access prod secrets"
  rule: "env == 'production' && action == 'deploy.yml' && ref.matches('secret/data/prod/**')"
  effect: allow

3. Using Raw Claims

Check a specific JWT claim value directly when the built-in profile variables are not enough:

- name: allow-specific-repo
  rule: "claims.repository == 'myorg/backend'"
  effect: allow

4. Deny a Specific Service

Block a compromised workload identity before broader allow rules:

- name: block-compromised
  rule: "service == 'compromised-service'"
  effect: deny

Place this before your allow rules so it fires first.

5. Group-Based Access

Use group membership for break-glass access patterns:

- name: oncall-break-glass
  description: "On-call SREs can access break-glass secrets"
  rule: "'oncall-sre' in groups && ref.matches('secret/data/break-glass/**')"
  effect: allow

6. Context Fields (Untrusted)

Use caller-declared context for audit or additional gating -- but always combine with verified variables:

- name: require-ticket-for-prod
  description: "Require a ticket reference for prod access"
  rule: "context.ticket != '' && ref.matches('secret/data/prod/**')"
  effect: allow

Since context.* is untrusted, consider pairing this with a verified check (e.g. org == 'myorg') in production.

Common Patterns

Order matters. Policies are evaluated top-down. Place specific rules (especially deny rules) before broad allow rules.

Use ref.matches() for scoping. Glob patterns in ref.matches() replace the old tag-based pre-filters. Match secret paths directly in your CEL expressions.

Keep rules simple. Each rule should test one condition. Compose access control through multiple policies rather than complex single expressions.

Typical ordering:

  1. Deny rules for blocked identities or compromised services
  2. Allow rules for specific service/environment/path combinations
  3. Allow rules for shared or dev resources
  4. Catch-all deny