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:
- KeyZero iterates through policies in declaration order.
- The CEL
ruleexpression is executed with variables populated from the verified JWT (via issuer profiles) and the request. If it returnstrue, the policy'seffect(allow or deny) is the final decision. Iffalse, evaluation continues. - Non-boolean results or execution errors cause the policy to be skipped.
- 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).
| Variable | Type | Trust | Description |
|---|---|---|---|
issuer | string | Verified | Matched issuer name |
org | string | Verified | Organizational boundary (e.g. repo owner, namespace, project) |
service | string | Verified | Workload identity (e.g. repository, service account, ARN) |
env | string | Verified | Environment (e.g. production, staging) |
action | string | Verified | What the workload is doing (e.g. workflow name) |
branch | string | Verified | Code version/ref (e.g. refs/heads/main) |
actor | string | Verified | Human who triggered the workload |
groups | list | Verified | Group membership from JWT |
claims.* | map | Verified | Raw JWT claims (e.g. claims.repository, claims.sub) |
ref | string | Request | Requested secret path (matched against resource ref patterns) |
context.* | map | Untrusted | Caller-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:
- Deny rules for blocked identities or compromised services
- Allow rules for specific service/environment/path combinations
- Allow rules for shared or dev resources
- Catch-all deny