.warden Language Reference
Complete reference for the Warden declarative DSL — every block, field, expression, operator, and convention.
This page is the canonical reference for the .warden declarative configuration language. For tooling (CLI, LSP, VS Code) see DSL & Tooling. For an introduction in context see Authorization Models.
File anatomy
A .warden file has three layers:
warden config 1 // 1. magic header
tenant acme // optional scope
app api
import "shared/policies.warden" // 2. optional imports
resource document { ... } // 3. declarations
permission "doc:read" (...)
role viewer { ... }
policy "incident-freeze" { ... }
relation document:welcome owner = user:aliceMagic header is required and must be the first non-comment, non-whitespace tokens in the file. The integer is the spec version — currently 1. Future breaking changes increment this number; the loader will run a converter pass for older versions.
Scope (tenant, app) is optional and may be set in any file in a load set. Conflicts between files (one file says tenant a, another says tenant b) produce a diagnostic. Scope can be supplied programmatically via ApplyOptions.TenantID or by CLI flags --tenant / --app, in which case those override what's in source.
Encoding is UTF-8. Line endings are LF or CRLF. Tabs and spaces are equivalent for indentation (the canonical formatter uses 4-space indent).
Lexical structure
Tokens
| Kind | Pattern | Example |
|---|---|---|
| Identifier | [a-z_][a-zA-Z0-9_-]* | viewer, eng-platform, is_system |
| String | "..." with \\, \", \n, \t escapes | "Editor (acme)" |
| Integer | [0-9]+ | 1, 100, 2026 |
| Boolean | true | false | true |
| Punctuation | { } ( ) [ ] , : ; . | # ! / | |
| Operators | = += -> == != <= >= < > + - & =~ | |
| Line comment | // to end of line | // note |
| Block comment | /* ... */ (does not nest) | /* doc */ |
Identifiers
Identifiers are lowercase by convention. The lexer tolerates uppercase letters in identifiers but the resolver rejects them where strict naming rules apply (role slugs, permission names, etc.). See Identifier conventions below.
Keywords
Reserved words that the lexer picks out of the IDENT stream:
warden config tenant app namespace import
resource relation permission role policy
effect allow deny actions resources subjects
when negate grants
name description priority active
is_system is_default max_members metadata
or and not in contains starts_with ends_with
exists ip_in_cidr time_after time_before
all_of any_of
not_before not_after obligations
true falseIdentifiers that collide with keywords cannot be used as plain names. For most domain identifiers (slugs, action names) this isn't a concern; the conflict only matters inside permission expressions, where you'd write a relation called not or all_of.
Comments
// Single-line comment.
permission "doc:read" (document : read)
/*
Block comments do not nest.
They span multiple lines.
*/
role viewer {}Comments are skipped before parsing. The formatter preserves them and attaches each comment to the following declaration.
Variables (template substitution)
Source files can contain ${NAME} placeholders that are expanded before parsing. Names match [A-Za-z_][A-Za-z0-9_]*. Values come from one of three layers (later layers override earlier ones):
- Defaults — passed programmatically via
dsl.WithVariables(...). - Environment — any env var matching
WARDEN_VAR_<NAME>becomes${NAME}. - CLI —
warden lint --var NAME=VALUE …(repeatable).
warden config 1
tenant ${TENANT}
role admin {
name = "Admin (${ENV})"
description = "Tenant ${TENANT} on region ${REGION}"
}# CLI, with env defaulting:
WARDEN_VAR_REGION=us-east-1 \
warden apply -f config/ \
--var TENANT=acme \
--var ENV=prod \
--store postgres://…Substitution is purely textual — placeholders inside string literals and inside comments are substituted just like everywhere else. Use $$ to escape: $${LITERAL} produces ${LITERAL} verbatim with no substitution.
Diagnostics are produced for:
- Undefined variable —
${X}with no matching value. Position points at the$. - Unclosed brace —
${Xwithout a matching}before EOL. - Invalid name —
${1BAD}(must match the identifier regex).
Variables are evaluated before the parser sees any tokens, so they can appear anywhere in source — identifier slots, string values, even keyword positions. The applier still validates the resulting tokens, so tenant ${X} with X="..." (a string) produces a parser error pointing at the substituted text.
Resource types
A resource block declares an entity type that participates in ReBAC. Resource types own relations (typed connections to other entities) and permissions (named expressions over those relations).
resource document {
description = "Tenant document"
relation owner: user
relation editor: user | group#member
relation viewer: user | group#member
relation parent: folder
permission read = viewer or editor or owner or parent->read
permission edit = editor or owner or parent->edit
permission delete = owner
}relation NAME : TYPES — TYPES is a |-separated list of allowed subject types. Each entry is either a bare resource type (user) or a subject set (group#member).
permission NAME = EXPR — EXPR is a permission expression over the resource's relations.
description = "..." — optional human-readable label.
Subject sets
group#member reads as "members of the group". On a check, the engine resolves the relation transitively: a tuple document:d1 viewer = group:eng#member matches any subject that has group:eng member = user:alice.
Permission expressions
The right-hand side of permission X = … is a small algebra over relations. Pratt-style precedence:
| Operator | Precedence | Meaning |
|---|---|---|
or (alias +) | 1 (lowest) | union — match if either side matches |
and (alias &) | 2 | intersection — match if both sides match |
not (aliases !, -) | 3 | exclusion — match if the operand does not |
RELATION | atom | direct relation lookup |
RELATION->RELATION | 4 (highest) | traversal — walk the first relation, then check the second on the result |
(EXPR) | grouping | parens override precedence |
permission read = viewer or editor or owner // any of three
permission edit = editor and not banned // editor AND not banned
permission share = editor or parent->edit // direct or via parent
permission god = (editor or owner) and (not banned) // explicit groupingTraversal targets must be valid: in parent->read, parent must be a relation declared on the current resource, and the relation's target type (folder in the example) must declare a permission named read. The resolver validates this at lint time.
Cycles are bounded at runtime by the engine's MaxGraphDepth (default 10).
Permissions
The permission declaration registers a named permission in the catalog. Two forms:
Long form (with metadata):
permission "doc:read" {
description = "Read a document"
resource = "document"
action = "read"
}Shorthand (most common, links to a resource-type permission):
permission "doc:read" (document : read)
permission "doc:write" (document : edit)The shorthand form binds the catalog entry to the read (or edit) permission expression declared in the resource document { ... } block.
Names follow the format <resource>:<action>. Resources are snake_case identifiers; actions can include * for glob matching at check time.
Roles
A role declaration registers a named role with a slug, optional parent, and a list of granted permissions.
role viewer {
name = "Viewer"
description = "Read-only access"
is_system = false
is_default = false
max_members = 0
grants = ["doc:read", "folder:read"]
metadata = {} // optional, free-form
}
role editor : viewer { // inherits from viewer
name = "Editor"
grants += ["doc:write"] // appends to viewer's grants
}
role admin : editor {
name = "Administrator"
grants += ["doc:delete"]
}Fields
| Field | Type | Default | Notes |
|---|---|---|---|
name | STRING | "" | Display name (≤64 chars recommended). |
description | STRING | "" | Free-form. |
is_system | BOOL | false | System-managed role; --prune will not delete it. |
is_default | BOOL | false | Marks the role as the default for new subjects. |
max_members | INT | 0 (no limit) | Soft limit enforced at assignment time. |
grants | string_list | [] | Permission names. Use += to append to inherited grants. |
metadata | map | {} | Arbitrary key/value pairs. |
Inheritance
role editor : viewer makes editor a child of viewer. The slug after the colon resolves first in the local namespace, then walks up ancestors. To reach across the namespace tree explicitly, use a leading /:
namespace "engineering" {
role platform-admin : eng-viewer { // local — eng-viewer in same ns
}
namespace "platform" {
role sre : /engineering/platform-admin { // absolute path
}
}
}The resolver detects cycles in the parent graph and reports them as diagnostics. Inherited permissions cascade at evaluation time — see authorization models.
Slug rules
Role slugs must match ^[a-z][a-z0-9-]{0,62}$ (kebab-case, ≤63 chars). Slugs are effectively immutable in storage (the API has no Slug on UpdateRoleRequest).
Policies
A policy declaration is an ABAC or PBAC rule with effect, matchers, conditions, and side-effect signals.
policy "business-hours-only" {
description = "Allow writes during business hours only"
effect = allow
priority = 100
active = true
not_before = "2026-01-01T00:00:00Z" // PBAC
not_after = "2026-12-31T23:59:59Z" // PBAC
obligations = ["audit-log"] // PBAC
subjects = [] // empty = match any subject
actions = ["write", "delete"]
resources = ["document"]
metadata = {}
when {
context.time time_after "09:00:00Z"
context.time time_before "17:00:00Z"
subject.attributes.department == "engineering"
}
}Fields
| Field | Type | Default | Notes |
|---|---|---|---|
description | STRING | "" | Free-form. |
effect | allow | deny | required | Decision the policy emits when matched. |
priority | INT | 0 | Lower values evaluate first; explicit deny always wins regardless. |
active | BOOL | true | Manual on/off. PBAC: see also not_before / not_after. |
not_before | STRING (RFC3339) | unset | PBAC: policy is inactive before this instant. |
not_after | STRING (RFC3339) | unset | PBAC: policy is inactive after this instant. |
obligations | string_list | [] | PBAC: named side-effect actions emitted on match. |
subjects | string_list | [] | Subject matchers (glob). Empty = any subject. |
actions | string_list | [] | Action patterns (glob). |
resources | string_list | [] | Resource patterns (glob). |
metadata | map | {} | Arbitrary key/value pairs. |
when { ... } | block | empty | Conditions; see below. |
when blocks
The when block contains zero or more conditions. By default, conditions are AND-merged: all must hold for the policy to match. Use all_of / any_of to override:
when {
// AND: all three must hold
context.ip ip_in_cidr "10.0.0.0/8"
context.time time_after "09:00:00Z"
subject.attributes.role == "admin"
}
when {
any_of {
subject.attributes.department == "engineering"
subject.attributes.department == "platform"
}
context.time time_after "09:00:00Z" // AND-ed with the any_of above
}Conditions are atomic predicates of the form:
FIELD_PATH OPERATOR LITERAL [negate]FIELD_PATH is a dotted path with optional bracketed segments:
subject.idsubject.attributes.departmentsubject.attributes["dept"](bracketed for keys with non-identifier chars)resource.attributes.tiercontext.ipcontext.timeaction.name
negate flips the condition's result, useful when an operator's negation isn't a separate keyword:
when {
context.ip ip_in_cidr "10.0.0.0/8" negate // IP NOT in 10.0.0.0/8
}Condition operators
| Operator | Type | Example |
|---|---|---|
== | equality | subject.attributes.role == "admin" |
!= | inequality | subject.attributes.role != "intern" |
<, >, <=, >= | numeric | subject.attributes.age >= 18 |
in | membership | subject.attributes.country in ["US", "CA"] |
not in | non-membership | subject.attributes.env not in ["prod"] |
contains | substring | subject.email contains "@company.com" |
starts_with | prefix | resource.path starts_with "/api/" |
ends_with | suffix | resource.name ends_with ".pdf" |
=~ | regex | resource.path =~ "^/v[0-9]+/" |
exists | presence | subject.attributes.mfa_verified exists |
not exists | absence | subject.attributes.banned not exists |
ip_in_cidr | network | context.ip ip_in_cidr "10.0.0.0/8" |
time_after | wall-clock comparison | context.time time_after "09:00:00Z" |
time_before | wall-clock comparison | context.time time_before "17:00:00Z" |
Relations
A relation declaration writes a single relation tuple — useful for seed data:
relation document:welcome owner = user:alice
relation document:welcome editor = user:bob
relation document:welcome viewer = group:eng#memberForm: relation OBJ_TYPE:OBJ_ID RELATION = SUBJ_TYPE:SUBJ_ID[#SUBJ_RELATION].
The optional #SUBJ_RELATION suffix creates a subject set — "every member of group:eng is a viewer of document:welcome".
For runtime relation writes (the typical path), use the engine's CreateRelation API or a tuple-write plugin — relation declarations are appropriate for fixtures and bootstrap state, not for tracking high-volume tuples.
Namespaces
Namespaces give cascading scope inheritance: an entity declared at namespace N is visible from N and every descendant of N. Namespaces nest arbitrarily up to the configured max depth (default 8).
Conceptual deep-dive. This section covers DSL syntax. For runtime semantics, scoping a Check call, the ancestor walk, and worked examples, see Namespaces (Nested Tenancy).
warden config 1
tenant acme
permission "audit:read" (audit_log : read)
role super-admin {
is_system = true
grants = ["audit:*", "*:*"]
}
namespace "engineering" {
role eng-viewer {
name = "Engineering Viewer"
grants = ["docs:read"]
}
namespace "platform" {
role platform-admin : eng-viewer { // inherits from eng-viewer at /engineering
name = "Platform Admin"
grants += ["infra:*"]
}
role sre : /engineering/platform-admin { // absolute cross-block reference
name = "SRE"
grants += ["pager:*"]
}
}
namespace "frontend" {
role frontend-developer : eng-viewer { // resolves to /engineering/eng-viewer
grants += ["ui:*"]
}
}
}
namespace "billing" {
resource invoice {
relation owner: user
relation auditor: user
permission read = owner or auditor
permission refund = owner
}
role billing-admin {
grants = ["invoice:*"]
}
}Path syntax
- Segment: matches
^[a-z][a-z0-9-]{0,62}$. - Path separator:
/. Storage canonicalizes paths without leading or trailing slashes (root is""). - Leading
/: only valid in role-parent references — marks an absolute path from the tenant root.role X : /eng/adminjumps across the namespace tree. - Reserved segments:
system,admin,_rootare reserved.
Resolution rules
| Lookup | Behavior |
|---|---|
Role at namespace N (assignment) | Visible from N and all descendants. |
| Role parent (bare slug) | Resolves at the role's own namespace, then walks ancestors. |
Role parent (absolute /.../...) | Resolves at the named namespace, no walk. |
Permission referenced from a role's grants | Resolves at the role's namespace, then walks ancestors. |
| Resource type referenced from a permission shorthand | Resolves at the permission's namespace, then walks ancestors. |
Policy at namespace N | Applies to checks in N and all descendants. |
| Relation tuples | Located at a namespace; do not cascade. |
Sibling references must use absolute paths — a role declared in billing cannot be referenced as a bare slug from engineering.
Imports
warden config 1
import "shared/policies.warden"
import "shared/system-roles.warden"
role admin : super-admin { ... } // super-admin declared in the imported fileimport "path" is a hint to the loader. In practice the directory walk picks up every .warden file already, so explicit imports are mainly cosmetic — they document intent and let the resolver flag stale references when a file is removed.
Multi-file load sets
A directory of .warden files forms one logical program. Cross-file references resolve across all files; conflicting declarations error with both file paths and line numbers.
config/
├── _shared/
│ ├── policies.warden // business-hours, deny-blocklisted-ips
│ └── system-roles.warden
├── billing/
│ ├── resources.warden // invoice, subscription
│ └── roles.warden // billing-admin
├── documents/
│ ├── resources.warden
│ └── roles.warden
└── main.warden // header + scope onlyConflict rules:
| Situation | Behavior |
|---|---|
Two files declare role viewer | Error with both paths/lines. |
Two files declare resource document | Error. |
Two files declare permission "doc:read" | Error. |
Two files declare policy "X" | Error. |
Two files declare the same relation document:d1 owner = user:alice | Silently deduplicated. |
Two files set tenant/app and they disagree | Error; files without scope inherit from those that have it. |
There is no "merge" annotation — to override, change source.
Scope resolution
The same priority chain as the API:
- CLI flag —
warden apply --tenant t1 --app app1. - Forge context — when invoked through the extension.
- Environment —
WARDEN_TENANT_ID,WARDEN_APP_ID. tenant/appdeclared in any file in the load set.
Tenant is optional. When none of the four sources sets a tenant, apply uses the empty string — the global scope. Every entity lands with tenant_id = "", which is also what single-tenant apps see at runtime when they never call warden.WithTenant. So a config that omits tenant and an app that omits WithTenant automatically agree:
warden config 1
// No `tenant` declaration — applies to global scope.
permission "doc:read" (document : read)
role viewer {
name = "Viewer"
grants = ["doc:read"]
}warden apply -f config/ --store sqlite:./warden.db
# No --tenant flag needed.For multi-tenant repos, the recommended layout is one directory per tenant with the scope declared in that tenant's main.warden (or supplied via --tenant / --var TENANT=… per pipeline). Mixing tenant-scoped and global entities in the same load set is allowed but rare — the global bucket is usually system-wide policies (e.g. a super-admin role that exists across every tenant deployment).
Identifier conventions
Validator-enforced (errors unless noted):
| Element | Rule |
|---|---|
| Role slug | ^[a-z][a-z0-9-]{0,62}$ (kebab-case, ≤63 chars) |
| Permission name | <resource>:<action> — resource: ^[a-z][a-z0-9_-]*$; action: ^[a-z0-9_*-]+$ |
| Policy name | ^[a-z][a-z0-9-]{0,62}$ |
| Resource type name | ^[a-z][a-z0-9_]{0,62}$ (snake_case) |
| Resource-type relation name | ^[a-z][a-z0-9_]{0,32}$ |
Display name = "..." | non-empty, ≤64 chars |
| Namespace segment | ^[a-z][a-z0-9-]{0,62}$ |
| Reserved namespace segments | system, admin, _root |
is_system = true reserved on slugs containing system | warning otherwise |
Spec versioning
warden config 1 is the current version. Future breaking changes:
- Loader keeps
convertV<N>ToV<N+1>AST passes so older files keep parsing. warden applyemits a deprecation warning when it converts.- Versions are integers; the spec describes shape, not behavior.
Full grammar (EBNF)
program = header { stmt }
header = "warden" "config" INT [ "tenant" IDENT ] [ "app" IDENT ]
stmt = import_stmt
| namespace_decl
| resource_decl
| permission_decl
| role_decl
| policy_decl
| relation_decl
import_stmt = "import" STRING
(* — Namespaces — *)
namespace_decl = "namespace" (IDENT | STRING) "{" { stmt } "}"
(* — Resource types — *)
resource_decl = "resource" IDENT "{" { rt_member } "}"
rt_member = "relation" IDENT ":" subject_types
| "permission" IDENT "=" expr
| "description" "=" STRING
subject_types = subject_type { "|" subject_type }
subject_type = IDENT [ "#" IDENT ] (* user | group#member *)
(* — Permission expressions — *)
expr = or_expr
or_expr = and_expr { ("or" | "+") and_expr }
and_expr = not_expr { ("and" | "&") not_expr }
not_expr = [ "not" | "!" | "-" ] primary
primary = "(" expr ")"
| traverse
| IDENT
traverse = IDENT { "->" IDENT }
(* — Permission catalog — *)
permission_decl = "permission" STRING "{" { kv } "}"
| "permission" STRING "(" IDENT ":" IDENT ")"
(* — Roles — *)
role_decl = "role" IDENT [ ":" parent_ref ] "{" { role_member } "}"
parent_ref = IDENT
| "/" IDENT { "/" IDENT }
role_member = "name" "=" STRING
| "description" "=" STRING
| "is_system" "=" BOOL
| "is_default" "=" BOOL
| "max_members" "=" INT
| "grants" ("=" | "+=") string_list
| "metadata" "=" map_lit
(* — Policies — *)
policy_decl = "policy" STRING "{" { policy_member } "}"
policy_member = "description" "=" STRING
| "effect" "=" ("allow" | "deny")
| "priority" "=" INT
| "active" "=" BOOL
| "not_before" "=" STRING (* RFC3339 *)
| "not_after" "=" STRING
| "obligations" "=" string_list
| "subjects" "=" string_list
| "actions" "=" string_list
| "resources" "=" string_list
| "metadata" "=" map_lit
| "when" "{" { condition } "}"
condition = field_path operator literal [ "negate" ]
| "all_of" "{" { condition } "}"
| "any_of" "{" { condition } "}"
field_path = IDENT { ("." IDENT | "[" STRING "]") }
operator = "==" | "!=" | "<" | ">" | "<=" | ">=" | "=~"
| "in" | "not" "in"
| "contains" | "starts_with" | "ends_with"
| "exists" | "not" "exists"
| "ip_in_cidr" | "time_after" | "time_before"
(* — Initial relation tuples — *)
relation_decl = "relation" obj_ref relation_name "=" subj_ref
obj_ref = IDENT ":" IDENT
relation_name = IDENT
subj_ref = IDENT ":" IDENT [ "#" IDENT ]
(* — Lexical primitives — *)
IDENT = /[a-z_][a-zA-Z0-9_-]*/
STRING = '"' ... '"' (* with escapes *)
INT = /[0-9]+/
BOOL = "true" | "false"
string_list = "[" [ STRING { "," STRING } [ "," ] ] "]"
map_lit = "{" [ kv { "," kv } ] "}"
kv = IDENT "=" literal
literal = STRING | INT | BOOL | string_listWorked examples
The repository ships a fixture per AuthZ combination under _examples/declarative/:
| File | Demonstrates |
|---|---|
01-rbac-only.warden | roles + permissions, no resource types or policies |
02-rebac-only.warden | resource types + relations + expressions, no roles or policies |
03-abac-only.warden | policies, no roles or resource types |
04-rbac-rebac.warden | roles whose grants include resource-type permissions resolved via traversal |
05-rbac-abac.warden | roles plus business-hours policy overlay |
06-full.warden | all three combined |
07-pbac.warden | time-bound policies + obligations |
Each fixture is wired into the test suite. Run any of them through the CLI:
warden lint _examples/declarative/06-full.warden
warden apply --dry-run -f _examples/declarative/06-full.warden --store memory:Programmatic loading
The CLI is one entry point; the dsl package is the other. dsl.ApplyFile, dsl.ApplyDir, and dsl.ApplyFS cover one-file, directory, and //go:embed use cases respectively. Diagnostics flow back as *dsl.DiagnosticError (extract with errors.As).
For the full helper surface — including a worked //go:embed example and the lower-level LoadFS + Apply path — see DSL & Tooling — Programmatic apply.