The Problem Statement
Our ReBAC v2 model from Post 4 is powerful—it handles hierarchies, inheritance, and complex boolean combinations of relations. But it has a critical limitation: all permissions are static. Once you grant Alice access to a document, she has it forever (or until you explicitly revoke it).
Real-world systems need context-aware permissions:
"Alice can view the report, but only during business hours (9 AM to 5 PM)." "Bob can access the database from the office network, but not from home." "Carol can edit the document, but only until December 31st."
These aren't new relations or roles—they're conditions on existing permissions. The permission exists, but it's only valid under certain circumstances.
The core problem: Our current model can't express "this permission is granted, but only if X is true right now."
Why Post 4's Model Isn't Enough
Let's see what goes wrong if we try to solve this with Post 4's tools:
Approach 1: Create Time-Based Roles
// Try to create a role for "users who are currently in business hours"
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "role", ObjectID: "business_hours_users", Relation: "member"},
Subject: &DirectSubject{Object: &ObjectRef{Namespace: "user", ObjectID: "alice"}},
})
// Then grant access to that role
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
Subject: &SubjectSet{
Object: &ObjectAndRelation{Namespace: "role", ObjectID: "business_hours_users", Relation: "member"},
},
})Problem: This tuple is static. Alice is either in the role or not. We'd need to:
Add her at 9 AM
Remove her at 5 PM
Repeat every day
This is write-heavy, error-prone, and doesn't scale. You'd need background jobs constantly updating tuples.
Approach 2: Application-Layer Checks
// Grant Alice access unconditionally
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
Subject: &DirectSubject{Object: &ObjectRef{Namespace: "user", ObjectID: "alice"}},
})
// Then check time in application code
if isBusinessHours(time.Now()) {
// Allow access
} else {
// Deny access
}
Problem: Authorization logic leaks into the application. Different services might implement the check differently. You lose the single source of truth for authorization.
The Core Insight: Caveats
Instead of storing time-based roles or checking conditions in application code, we introduce caveats: conditions attached to permissions.
A caveat is:
Defined once in the schema (e.g., "business_hours")
Attached to tuples when granting access (e.g., "Alice can view this document [business_hours]")
Evaluated at check time with runtime context (e.g., "Is it currently 9-5?")
This keeps authorization logic centralized, testable, and deterministic.
Attribution: Caveats are inspired by Zanzibar's "caveated subjects" pattern, which enables conditional permissions in Google's authorization system.
Extending the Model: Caveats
Step 1: Caveat Definition (Schema-Level)
A caveat is defined in the schema with:
Name: Unique identifier (e.g., "business_hours")
Parameters: Expected runtime values (e.g., "now_utc", "tz")
Expression: Boolean logic to evaluate (e.g., "local_hour(now_utc, tz) >= 9 AND local_hour(now_utc, tz) < 17")
type CaveatDefinition struct {
Name string // "business_hours"
Parameters []CaveatParameter // [now_utc: timestamp, tz: string]
Expression *BooleanExpr // local_hour(now_utc, tz) >= 9 AND < 17
Metadata map[string]any // Optional metadata
}
// CaveatParameter is an interface for type-safe parameter definitions
type CaveatParameter interface {
GetName() string
GetType() CaveatParameterType
}
// ScalarParameter represents simple scalar types (bool, int, string, etc.)
type ScalarParameter struct {
Name string
Type CaveatParameterType
}
func (p *ScalarParameter) GetName() string { return p.Name }
func (p *ScalarParameter) GetType() CaveatParameterType { return p.Type }
// ListParameter represents a list with a specific element type
type ListParameter struct {
Name string
ElementType CaveatParameterType
}
func (p *ListParameter) GetName() string { return p.Name }
func (p *ListParameter) GetType() CaveatParameterType { return CAV_T_LIST }
type MapParameter struct {
Name string
KeyType CaveatParameterType
ValueType CaveatParameterType
}
func (p *MapParameter) GetName() string { return p.Name }
func (p *MapParameter) GetType() CaveatParameterType { return CAV_T_MAP }
type CaveatParameterType int32
const (
CAV_T_BOOL CaveatParameterType = iota
CAV_T_INT
CAV_T_UINT
CAV_T_DOUBLE
CAV_T_STRING
CAV_T_BYTES
CAV_T_DURATION
CAV_T_TIMESTAMP
CAV_T_LIST // Use ListParameter to specify element type
CAV_T_MAP // Use MapParameter to specify key/value types
)Type-safe parameter definitions:
The interface-based design ensures compile-time type safety:
// Scalar parameters (simple types)
&ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP}
&ScalarParameter{Name: "tz", Type: CAV_T_STRING}
&ScalarParameter{Name: "current_hour", Type: CAV_T_INT}
// List parameter with element type
&ListParameter{
Name: "allowed_ips",
ElementType: CAV_T_STRING, // List of strings
}
// Map parameter with key and value types
&MapParameter{
Name: "user_quotas",
KeyType: CAV_T_STRING, // String keys
ValueType: CAV_T_INT, // Integer values
}Why this design?
Type safety at compile time: You can't create a
ListParameterwithout specifyingElementTypeClear intent:
ListParametervsScalarParametermakes the type structure explicitExtensibility: Easy to add new parameter types (e.g.,
StructParameter,UnionParameter)No invalid states: Can't accidentally set
Elemon a scalar or forget it on a list
Type safety for CMP_IN operator:
The CMP_IN operator requires type compatibility:
elem(Type X) IN list(Type X)— element type must match list element typekey(Type X) IN map(Type X, Type Y)— key type must match map key type
Example:
// Valid: string IN list<string>
Predicate{
Left: &IdentifierExpr{Name: "request_ip"}, // Type: STRING
Op: CMP_IN,
Right: &IdentifierExpr{Name: "allowed_ips"}, // Type: LIST<STRING>
}
// Invalid: int IN list<string> → schema validation error
Predicate{
Left: &LiteralExpr{Value: 42, Type: CAV_T_INT},
Op: CMP_IN,
Right: &IdentifierExpr{Name: "allowed_ips"}, // Type: LIST<STRING>
}Expressions
Before we build the full caveat expression, we need to understand the building blocks: caveat expressions. These are the atomic units that make up predicates and boolean logic.
High-level structure:
BooleanExpr: Top-level boolean expression (AND, OR, NOT, or a single predicate)
Predicate: Comparison between two expressions (e.g.,
current_hour >= 9)CaveatExpr: Atomic expressions (Identifier, Literal, or Call)
There are three types of caveat expressions, all implementing the CaveatExpr interface:
1. IdentifierExpr — Parameter Reference
An identifier references a parameter from the caveat context (runtime values).
type IdentifierExpr struct {
Name string // e.g., "current_hour"
}Purpose: Look up a runtime value by name.
Example:
&IdentifierExpr{Name: "current_hour"}
// At evaluation time, this looks up the value of "current_hour" from the context
// If context has {current_hour: 14}, this evaluates to 14Use cases:
Comparing against runtime values: "Is the current hour >= 9?"
Matching user attributes: "Does user_department match?"
Checking request properties: "Is request_ip in allowed_ips?"
2. LiteralExpr — Constant Value
A literal is a constant value with an explicit type. This is crucial for safety.
type LiteralExpr struct {
Value any // e.g., 9
Type CaveatParameterType // e.g., CAV_T_INT
}Purpose: Represent a constant value with compile-time type checking.
Example:
&LiteralExpr{Value: 9, Type: CAV_T_INT}
// This is the constant 9 (as an integer)
&LiteralExpr{Value: "office", Type: CAV_T_STRING}
// This is the constant "office" (as a string)
&LiteralExpr{Value: []string{"US", "CA"}, Type: CAV_T_LIST}
// This is a list of strings
Why typed literals?
Type safety: Prevents comparing apples to oranges (e.g., "9" string vs 9 integer)
Injection prevention: No free-form strings that could be interpreted as code
Compile-time validation: Schema validation catches type mismatches early
Determinism: Same literal always evaluates the same way
3. CallExpr — Function Calls (Future Extensibility)
A call expression invokes a function with arguments. This is reserved for future use (Post 7+).
type CallExpr struct {
FnName string // e.g., "now"
Args []CaveatExpr // Arguments (can be IdentifierExpr, LiteralExpr, or nested CallExpr)
}Purpose: Support extensible functions like now(), contains(), startsWith(), etc.
Example (Post 7+):
&CallExpr{
FnName: "now",
Args: []CaveatExpr{},
}
// Evaluates to the current timestamp
&CallExpr{
FnName: "contains",
Args: []CaveatExpr{
&IdentifierExpr{Name: "allowed_ips"},
&IdentifierExpr{Name: "request_ip"},
},
}
// Checks if allowed_ips contains request_ipWhy separate CallExpr?
Enables extensibility without changing the core model
Allows composition (functions can call other functions)
Keeps the schema deterministic (only whitelisted functions allowed)
Important: Determinism Guarantee
All functions must be pure and parameterized—no ambient side-effects:
❌ Bad: now() — non-deterministic, depends on system clock ✅ Good: local_hour(now_utc, tz) — deterministic, caller provides now_utc parameter
This ensures:
Same inputs always produce same outputs
Evaluation is reproducible for auditing
No hidden dependencies on system state
Why Three Types?
Type | Purpose | Example | When Used |
|---|---|---|---|
IdentifierExpr | Reference runtime parameter |
| Comparing against context values |
LiteralExpr | Constant with explicit type |
| Hardcoded thresholds, allowlists |
CallExpr | Function call (future) |
| Dynamic computations (Post 7+) |
Key insight: By restricting to these three types, we prevent injection attacks and ensure deterministic evaluation. You can't sneak arbitrary code into a caveat—only parameters, constants, and whitelisted functions.
Predicate — Comparison Operations
Now that we understand the three types of caveat expressions, we need to understand how they're used in predicates. A predicate is a comparison between two expressions that evaluates to true or false.
type Predicate struct {
Left CaveatExpr // Left-hand side expression
Op CompareOp // Comparison operator
Right CaveatExpr // Right-hand side expression
}
Purpose: Compare two expressions using a comparison operator.
Supported comparison operators:
type CompareOp int32
const (
CMP_EQ // == (equals)
CMP_NE // != (not equals)
CMP_LT // < (less than)
CMP_LE // <= (less than or equal)
CMP_GT // > (greater than)
CMP_GE // >= (greater than or equal)
CMP_IN // in (list membership)
CMP_STARTS_WITH // starts_with (string prefix)
CMP_ENDS_WITH // ends_with (string suffix)
CMP_CONTAINS // contains (string substring)
)
Examples:
// current_hour >= 9
&Predicate{
Left: &IdentifierExpr{Name: "current_hour"},
Op: CMP_GE,
Right: &LiteralExpr{Value: 9, Type: CAV_T_INT},
}
// user_department == "HR"
&Predicate{
Left: &IdentifierExpr{Name: "user_department"},
Op: CMP_EQ,
Right: &LiteralExpr{Value: "HR", Type: CAV_T_STRING},
}
// request_ip IN allowed_ips
&Predicate{
Left: &IdentifierExpr{Name: "request_ip"},
Op: CMP_IN,
Right: &IdentifierExpr{Name: "allowed_ips"},
}
Why predicates matter:
Type safety: Both sides must have compatible types for the comparison
Deterministic: Same inputs always produce the same result
Composable: Predicates are combined using boolean logic (AND, OR, NOT)
Example: Business Hours Caveat (Using CaveatExpr Types)
Now let's see how the three CaveatExpr types work together in a real caveat:
businessHoursCaveat := &CaveatDefinition{
Name: "business_hours",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP}, // UTC timestamp
&ScalarParameter{Name: "tz", Type: CAV_T_STRING}, // IANA timezone (e.g., "America/New_York")
},
Expression: &BooleanExpr{
Op: BOOL_AND, // Boolean logic: AND two predicates
Children: []*BooleanExpr{
// Predicate 1: local_hour(now_utc, tz) >= 9
{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &CallExpr{ // ← CallExpr: pure function call
FnName: "local_hour",
Args: []CaveatExpr{
&IdentifierExpr{Name: "now_utc"},
&IdentifierExpr{Name: "tz"},
},
},
Op: CMP_GE,
Right: &LiteralExpr{Value: 9, Type: CAV_T_INT},
},
},
// Predicate 2: local_hour(now_utc, tz) < 17
{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &CallExpr{ // ← CallExpr: pure function call
FnName: "local_hour",
Args: []CaveatExpr{
&IdentifierExpr{Name: "now_utc"},
&IdentifierExpr{Name: "tz"},
},
},
Op: CMP_LT,
Right: &LiteralExpr{Value: 17, Type: CAV_T_INT},
},
},
},
},
}
// Note: local_hour is a pure, whitelisted function that:
// - Takes UTC timestamp and IANA timezone string
// - Returns the hour (0-23) in the specified timezone
// - Is deterministic: same inputs always produce same output
// - Handles DST transitions correctly
Breaking it down:
CallExpr (
local_hour(now_utc, tz)): Pure function that converts UTC to local hourIdentifierExpr (
now_utc,tz): Looks up runtime values from contextLiteralExpr (
9,17): Hardcoded constants with explicit typesPredicate: Compares two CaveatExpr using a CompareOp
BooleanExpr: Combines predicates with boolean logic (AND, OR, NOT)
At evaluation time:
If context has
{now_utc: 1640000000, tz: "America/New_York"}:local_hour(1640000000, "America/New_York")→ 1414 >= 9 AND 14 < 17→ TRUE
If context has
{now_utc: 1640000000, tz: "America/Los_Angeles"}:local_hour(1640000000, "America/Los_Angeles")→ 1111 >= 9 AND 11 < 17→ TRUE
If context missing
now_utcortz: REQUIRES_CONTEXT (missing parameter)
Why this approach is better:
✅ Timezone-aware: Handles different timezones correctly
✅ DST-safe:
local_hourfunction handles daylight saving transitions✅ Deterministic: Same UTC timestamp + timezone always produces same result
✅ Testable: Can test with specific timestamps, not dependent on system clock
Performance characteristics:
Time complexity: O(n) where n = number of nodes in expression tree
Space complexity: O(d) where d = maximum depth of expression tree
Evaluation cost: ~1-10μs for typical business rules (measured on modern hardware)
Optimization: Short-circuit evaluation stops on first FALSE in AND, first TRUE in OR
Step 2: Schema-Level Caveats (Relation Constraints)
Relations can require caveats at the schema level, applying to all grants of that relation:
type SubjectConstraint struct {
RequiredCaveat *CaveatRef // Caveat that must be satisfied
}
type CaveatRef struct {
CaveatName string
Context *CaveatContext // Optional pre-bound values
}
type NamespaceRelationDefinition struct {
Name string
AllowedSubjects []AllowedSubject // From Post 4
Constraints []SubjectConstraint // NEW: Schema-level caveat requirements
// ... other fields from Post 4
}
Example: All viewers must satisfy business_hours
documentNamespace := &NamespaceDefinition{
Name: "document",
Relations: []*NamespaceRelationDefinition{
{
Name: "viewer",
AllowedSubjects: []AllowedSubject{
{Namespace: "user", Relation: ""},
},
Constraints: []SubjectConstraint{
{
RequiredCaveat: &CaveatRef{
CaveatName: "business_hours",
},
},
},
},
},
}
Combination rule: Schema-level caveats AND tuple-level caveats are combined:
If schema requires
business_hoursAND tuple hasip_allowlistEffective caveat =
business_hours AND ip_allowlistBoth must evaluate to TRUE for access to be granted
Step 3: Contextualized Caveat (Tuple-Level)
When granting access, you can attach additional caveats to specific tuples:
type ContextualizedCaveat struct {
CaveatName string // Reference to CaveatDefinition
Context *CaveatContext // Optional pre-bound values
}
type CaveatContext struct {
Values map[string]any // Parameter name → value
Protected map[string]bool // Which keys are protected from override
}Example: Grant Alice Access with Business Hours Caveat
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "report_2024",
Relation: "viewer",
},
Subject: &DirectSubject{
Object: &ObjectAndRelation{
Namespace: "user",
ObjectID: "alice",
},
Caveat: &ContextualizedCaveat{
CaveatName: "business_hours",
Context: nil, // No pre-bound values; provided at check time
},
},
})Step 4: Tri-State Caveat Evaluation
Caveats evaluate to three states, not just true/false:
type CaveatState int32
const (
CAVEAT_FALSE CaveatState = iota
CAVEAT_TRUE
CAVEAT_REQUIRES_CONTEXT
)
type CaveatEvalResult struct {
State CaveatState
MissingParams []string // Which parameters were missing
}
Why three states?
CAVEAT_TRUE: All parameters provided, expression evaluates to true → Grant access
CAVEAT_FALSE: All parameters provided, expression evaluates to false → Deny access
CAVEAT_REQUIRES_CONTEXT: Some parameters missing → Can't decide yet
The third state is crucial. If a caveat requires "now_utc" but the check request doesn't provide it, we can't evaluate the caveat. We return REQUIRES_CONTEXT to signal that the caller must provide more information.
Step 5: Context Provenance & Security
Critical security consideration: Not all context values should be settable by application callers. Some values must come from trusted sources to prevent spoofing.
Context value categories:
Identity claims (populated by authentication layer):
user.department,user.clearance_level,user.roleSource: Identity provider (IdP) or authentication service
Protection: Application callers cannot override these values
Example: After authentication, the auth layer injects verified user attributes
Request context (injected by trusted gateway):
request.ip,request.method,request.user_agentSource: API gateway or reverse proxy
Protection: Application callers cannot override these values
Example: Gateway reads client IP from TCP connection, not from headers
Resource attributes (provided by application):
document.classification,document.required_departmentSource: Application layer (fetched from database)
Protection: Application is responsible for accuracy
Example: App fetches document metadata and provides it
Temporal context (provided by system):
now_utc,tzSource: System clock or trusted time service
Protection: Application can provide for testing, but production uses system time
Example: Authorization service injects current UTC timestamp
Implementation pattern:
type CaveatContext struct {
Values map[string]any
Protected map[string]bool // Which keys are protected from override
}
// Identity layer sets protected values
func (ctx *CaveatContext) SetProtected(key string, value any) {
ctx.Values[key] = value
ctx.Protected[key] = true
}
// Application layer can only set unprotected values
func (ctx *CaveatContext) SetValue(key string, value any) error {
if ctx.Protected[key] {
return fmt.Errorf("cannot override protected key: %s", key)
}
ctx.Values[key] = value
return nil
}Security guarantee: Caveats that reference identity claims (e.g., user.department) can trust that these values came from the authentication layer, not from a potentially malicious caller.
Testing Conditional Access
Note: All scenarios below use the namespace definition from Post 4 (document and folder namespaces), extended with caveats. We'll test how caveat evaluation works in different scenarios.
Scenario 1: Business Hours Access (Happy Path)
Setup:
// Define business_hours caveat in schema
schema.AddCaveat(businessHoursCaveat)
// Grant Alice access with business_hours caveat
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
},
})Check at 2 PM EST (14:00):
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200)) // 2021-12-20 14:00:00 EST (19:00:00 UTC)
ctx.SetValue("tz", "America/New_York")
result := Check(
&ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
&DirectSubject{Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"}},
ctx,
)
// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: local_hour(1640023200, "America/New_York") = 14
// 14 >= 9 AND 14 < 17 → TRUE
Check at 8 PM EST (20:00):
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800)) // 2021-12-20 20:00:00 EST (01:00:00 UTC next day)
ctx.SetValue("tz", "America/New_York")
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: local_hour(1640044800, "America/New_York") = 20
// 20 >= 9 AND 20 < 17 → FALSEScenario 2: Missing Context (REQUIRES_CONTEXT)
Setup: Same as Scenario 1.
Check without providing required parameters:
ctx := NewCaveatContext()
// No values set
result := Check(
&ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
&DirectSubject{Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"}},
ctx,
)
// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["business_hours.now_utc", "business_hours.tz"]
// Reason: Can't evaluate without now_utc and tzCaller's responsibility: The caller should:
Log that context was required
Optionally retry with context
Or deny access (fail-safe default)
Scenario 3: Multiple Caveats (Composition)
Setup: Grant access with both business hours AND IP allowlist requirements.
// Define IP allowlist caveat with parametric list type
ipAllowlistCaveat := &CaveatDefinition{
Name: "ip_allowlist",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "request_ip", Type: CAV_T_STRING},
&ListParameter{
Name: "allowed_ips",
ElementType: CAV_T_STRING, // List of strings
},
},
Expression: &BooleanExpr{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "request_ip"}, // Type: STRING
Op: CMP_IN,
Right: &IdentifierExpr{Name: "allowed_ips"}, // Type: LIST<STRING>
},
},
}
// Grant access with BOTH caveats (schema-level + tuple-level)
// Schema requires business_hours for all viewers
// Tuple adds ip_allowlist for this specific grant
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "sensitive", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{
CaveatName: "ip_allowlist",
Context: &CaveatContext{
Values: map[string]any{
"allowed_ips": []string{"192.168.1.100", "10.0.0.50"},
},
},
},
},
})Check with full context:
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200))
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "192.168.1.100") // IP allowlist: OK
result := Check(...)
// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: Both business_hours AND ip_allowlist evaluate to TRUE
Check with missing IP context:
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200))
ctx.SetValue("tz", "America/New_York")
// Missing request_ip
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["ip_allowlist.request_ip"]
// Reason: business_hours=TRUE AND ip_allowlist=REQUIRES_CONTEXT = REQUIRES_CONTEXTKey insight: Multiple caveats are combined with AND logic. ALL must be satisfied for access to be granted.
Scenario 4: Wrong Parameter Type
Setup: Same as Scenario 1.
Check with wrong type:
ctx := NewCaveatContext()
ctx.SetValue("now_utc", "2021-12-20T14:00:00Z") // String instead of timestamp
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_FALSE, ErrorCode=ERR_TYPE_MISMATCH
// Reason: Type mismatch → deny access (fail-safe)
Scenario 5: Error Handling and Debugging
Common issues and how to handle them:
Unknown caveat name:
// Tuple references non-existent caveat
store.Add(RelationTuple{
Subject: &DirectSubject{
Caveat: &ContextualizedCaveat{CaveatName: "nonexistent_caveat"},
},
})
// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: Unknown caveat → fail-safe deny
Malformed expression tree:
// Expression with missing predicate
badExpression := &BooleanExpr{
Op: BOOL_PREDICATE,
Pred: nil, // Missing predicate!
}
// Result: Schema validation error at compile time
// Reason: Malformed expressions caught early
Debugging caveat evaluation:
// Enable detailed logging for troubleshooting
result := CheckWithOptions(resource, subject, context, &CheckOptions{
ExplainTrace: true, // Include evaluation trace
})
// Example trace output:
// TRACE: Evaluating caveat 'business_hours'
// TRACE: Parameter 'current_hour' = 14 (int)
// TRACE: Predicate 'current_hour >= 9' = true
// TRACE: Predicate 'current_hour < 17' = true
// TRACE: BooleanExpr 'AND' = true
// TRACE: Final result: CAVEAT_TRUE
Best practices for error handling:
Validate schemas at compile time: Catch malformed expressions early
Log caveat evaluation failures: Include parameter values and missing context
Use structured errors: Return specific error codes for different failure modes
Fail-safe defaults: Unknown caveats and type mismatches should deny access
Provide clear feedback: Tell callers exactly which parameters are missing
Scenario 6: Expiration Caveat (Time-Limited Access)
Setup: Grant temporary access that expires
// Define expiration caveat
expirationCaveat := &CaveatDefinition{
Name: "expires_at",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP},
&ScalarParameter{Name: "expires_at", Type: CAV_T_TIMESTAMP},
},
Expression: &BooleanExpr{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "now_utc"},
Op: CMP_LE, // Less than or equal
Right: &IdentifierExpr{Name: "expires_at"},
},
},
}
// Grant Alice access that expires on Dec 31, 2024
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "temp_report", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{
CaveatName: "expires_at",
Context: &CaveatContext{
Values: map[string]any{
"expires_at": int64(1735689600), // 2024-12-31 23:59:59 UTC
},
},
},
},
})Check before expiration:
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640000000)) // 2021-12-20
result := Check(...)
// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: 1640000000 <= 1735689600 → TRUE
Check after expiration:
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1736000000)) // 2025-01-04
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: 1736000000 <= 1735689600 → FALSE (expired)
Scenario 7: Multi-Grant Union (Multiple Tuples with Different Caveats)
Setup: Alice has two grants with different caveats
// Grant 1: Access during business hours
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
},
})
// Grant 2: Access from office IP (anytime)
store.Add(RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{
CaveatName: "ip_allowlist",
Context: &CaveatContext{
Values: map[string]any{
"allowed_ips": []string{"192.168.1.100"},
},
},
},
},
})Check at 8 PM from office (Grant 1 = FALSE, Grant 2 = TRUE):
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800)) // 8 PM
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "192.168.1.100")
result := Check(...)
// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: Grant 1 (business_hours) = FALSE (after hours)
// Grant 2 (ip_allowlist) = TRUE (office IP)
// FALSE OR TRUE = TRUE (Kleene-OR aggregation)
Check at 8 PM from home (Grant 1 = FALSE, Grant 2 = FALSE):
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800)) // 8 PM
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "203.0.113.50") // Home IP
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: Grant 1 (business_hours) = FALSE (after hours)
// Grant 2 (ip_allowlist) = FALSE (not office IP)
// FALSE OR FALSE = FALSE
Check at 8 PM without IP context (Grant 1 = FALSE, Grant 2 = REQUIRES_CONTEXT):
ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800)) // 8 PM
ctx.SetValue("tz", "America/New_York")
// Missing request_ip
result := Check(...)
// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["ip_allowlist.request_ip"]
// Reason: Grant 1 (business_hours) = FALSE (after hours)
// Grant 2 (ip_allowlist) = REQUIRES_CONTEXT (missing IP)
// FALSE OR REQUIRES_CONTEXT = REQUIRES_CONTEXT (might grant if IP provided)Key insight: Multiple grants are combined with Kleene-OR. If ANY grant evaluates to TRUE, access is granted. This enables flexible access policies where users can access resources through multiple paths.
In Post 4, we had the Check algorithm that evaluates structural permissions (tuples + relations + edge traversal). In Post 5, we extend this to support contextual conditions through caveats.
What's New in Post 5
From Post 4, we had:
Check algorithm with 6 operations (DIRECT, COMPUTED_SUBJECTS, UNION, INTERSECTION, EXCLUSION, EDGE)
Boolean output: TRUE or FALSE
No context parameter
In Post 5, we add:
Context parameter:
CaveatContextfor runtime valuesTri-state output: TRUE, FALSE, or REQUIRES_CONTEXT
Caveat evaluation: At each tuple match, evaluate attached caveats
Caveat combination: Schema-level AND tuple-level caveats
Check Algorithm with Caveat Support
ALGORITHM: Check(subject, resource#relation, context) - With Caveat Support
Input:
- subject: DirectSubject or SubjectSet (e.g., "user:alice")
- resource: ObjectAndRelation (e.g., "document:1#viewer")
- context: CaveatContext (runtime values like current_hour, request_ip)
Output:
- CheckResult {
Granted: bool
CaveatState: CAVEAT_TRUE | CAVEAT_FALSE | CAVEAT_REQUIRES_CONTEXT
MissingParams: []string // Namespaced as "caveat_name.param"
ErrorCode: *ErrorCode // Optional: ERR_TYPE_MISMATCH, ERR_UNKNOWN_CAVEAT, etc.
}
Steps:
1. Look up the relation definition in schema:
- Find NamespaceDefinition for resource.namespace
- Get NamespaceRelationDefinition for relation
- Extract schema-level required caveat (if any) from SubjectConstraints
2. Check direct tuples (with aggregation):
- Query ALL matching tuples: (subject, resource#relation)
- For each matching tuple:
a) Get tuple-level caveat (if any) from tuple.Subject.Caveat
b) Compute effective caveat:
- If schema requires caveat AND tuple has caveat:
effectiveCaveat = SchemaCaveat AND TupleCaveat
- If only schema requires caveat:
effectiveCaveat = SchemaCaveat
- If only tuple has caveat:
effectiveCaveat = TupleCaveat
- If neither:
effectiveCaveat = nil
c) Evaluate effective caveat:
- If effectiveCaveat is nil → tupleResult = CAVEAT_TRUE
- Else → tupleResult = EvaluateCaveat(effectiveCaveat, context, schema)
- Combine all tuple results using Kleene-OR:
* If ANY tuple evaluates to CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE} (short-circuit)
* Else if NO tuples are TRUE but at least one is REQUIRES_CONTEXT:
- Collect union of all MissingParams (namespaced as "caveat_name.param")
- Return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
* Else → all tuples are FALSE → continue to step 3
3. Evaluate SubjectsComputationExpression (from Post 4):
- Expression can be: DIRECT, COMPUTED_SUBJECTS, UNION, INTERSECTION, EXCLUSION, EDGE
- For each operation, recursively call Check(subject, computed_resource#computed_relation, context)
- Each recursive call returns a CheckResult with tri-state CaveatState
4. Combine results using Kleene tri-state logic:
- For UNION: Return TRUE if ANY child is TRUE (short-circuit on TRUE)
- For INTERSECTION: Return FALSE if ANY child is FALSE (short-circuit on FALSE)
- For EXCLUSION: Evaluate left, then right, apply exclusion logic
- Collect all MissingParams from REQUIRES_CONTEXT results
5. Return final CheckResult
---
OPERATION EVALUATION (Extended from Post 4 with Tri-State Logic):
OP_DIRECT:
- No computation; only direct tuples (already checked in step 2)
- Return {Granted: false, CaveatState: CAVEAT_FALSE}
OP_COMPUTED_SUBJECTS(computed_relation):
- Recursively check: Check(subject, resource#computed_relation, context)
- Return result (tri-state)
OP_UNION(children):
- For each child expression:
- result = Evaluate child recursively
- If result.CaveatState == CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE}
- Collect all MissingParams from REQUIRES_CONTEXT results
- If any child returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
- Return {Granted: false, CaveatState: CAVEAT_FALSE}
OP_INTERSECTION(children):
- For each child expression:
- result = Evaluate child recursively
- If result.CaveatState == CAVEAT_FALSE → return {Granted: false, CaveatState: CAVEAT_FALSE}
- Collect all MissingParams from REQUIRES_CONTEXT results
- If any child returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
- Return {Granted: true, CaveatState: CAVEAT_TRUE}
OP_EXCLUSION(left, right):
- leftResult = Evaluate left recursively
- If leftResult.CaveatState == CAVEAT_FALSE → return {Granted: false, CaveatState: CAVEAT_FALSE}
- rightResult = Evaluate right recursively
- If rightResult.CaveatState == CAVEAT_TRUE → return {Granted: false, CaveatState: CAVEAT_FALSE}
- Collect MissingParams from both if either is REQUIRES_CONTEXT
- If either returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
- Return {Granted: true, CaveatState: CAVEAT_TRUE}
OP_EDGE(edge_from, edge_to):
- Query tuple store for all tuples: (resource, O'#edge_from)
- For each O' found:
- result = Check(subject, O'#edge_to.relation, context)
- If result.CaveatState == CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE}
- Collect all MissingParams from REQUIRES_CONTEXT results
- If any returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
- Return {Granted: false, CaveatState: CAVEAT_FALSE}Key changes from Post 4:
✅ Added context parameter: Passed through all recursive calls
✅ Tri-state output: CheckResult with CaveatState (TRUE/FALSE/REQUIRES_CONTEXT)
✅ Caveat evaluation: At step 2c, evaluate effective caveat
✅ Kleene logic: All operations extended to handle tri-state results
✅ MissingParams tracking: Collect and return missing parameters
Backward compatibility: If no caveats are attached to tuples and no context is provided, the algorithm behaves exactly like Post 4 (returns TRUE or FALSE).
Caveat Evaluation Algorithm
ALGORITHM: EvaluateCaveat(caveat, checkContext, schema)
Input:
- caveat: ContextualizedCaveat (CaveatName + optional pre-bound Context)
- checkContext: CaveatContext (runtime values from check request)
- schema: CompiledSchema (caveat definitions)
Output:
- CaveatEvalResult (State + MissingParams)
Steps:
1. Look up caveat definition by name
- If not found → return CAVEAT_FALSE (unknown caveat → deny)
2. Merge contexts
- Start with caveat.Context (pre-bound values)
- Overlay checkContext (runtime values)
- Result: mergedContext
3. Evaluate expression tree
- For each parameter in definition:
- If not in mergedContext → add to missingParams
- If missingParams not empty → return CAVEAT_REQUIRES_CONTEXT
4. Evaluate boolean expression recursively
- Start at root BooleanExpr node
- For BOOL_PREDICATE nodes:
* Evaluate left and right CaveatExpr
* Apply comparison operator
* Return boolean result
- For BOOL_AND nodes:
* Evaluate all children
* Return TRUE only if ALL children are TRUE
- For BOOL_OR nodes:
* Evaluate all children
* Return TRUE if ANY child is TRUE
- For BOOL_NOT nodes:
* Evaluate single child
* Return logical negation
- Convert final boolean to CAVEAT_TRUE or CAVEAT_FALSE
5. Return result
- If missingParams: CAVEAT_REQUIRES_CONTEXT
- Else: CAVEAT_TRUE or CAVEAT_FALSE
Kleene Three-Valued Logic Foundation
The tri-state evaluation system is built on Kleene Strong Three-Valued Logic (K3), which provides deterministic rules for combining partial information.
Canonical Kleene K3 Truth Tables:
AND (∧):
T ∧ T = T, T ∧ F = F, T ∧ R = R
F ∧ T = F, F ∧ F = F, F ∧ R = F
R ∧ T = R, R ∧ F = F, R ∧ R = R
OR (∨):
T ∨ T = T, T ∨ F = T, T ∨ R = T
F ∨ T = T, F ∨ F = F, F ∨ R = R
R ∨ T = T, R ∨ F = R, R ∨ R = R
Table format:
AND | T | F | R |
|---|---|---|---|
T | T | F | R |
F | F | F | F |
R | R | F | R |
OR | T | F | R |
|---|---|---|---|
T | T | T | T |
F | T | F | R |
R | T | R | R |
Key properties:
Deterministic: Same inputs always produce same outputs
Composable: Results can be combined predictably
Partial evaluation: Missing information doesn't break the system
Why Kleene logic? When some parameters are missing (R), we can still make progress:
T ∧ R = R(depends on the missing value)F ∧ R = F(definitely false regardless)T ∨ R = T(definitely true regardless)F ∨ R = R(depends on the missing value)
This enables smart partial evaluation where we only request the minimum context needed.
Caveat Result Aggregation
When multiple caveats exist (e.g., schema-level + tuple-level), results are combined using Kleene logic:
// Example: Effective caveat = business_hours AND ip_allowlist
// If business_hours = TRUE and ip_allowlist = REQUIRES_CONTEXT
// Result = TRUE AND R = R (still need IP context)
func CombineCaveats(results []CaveatEvalResult) CaveatEvalResult {
// INTERSECTION logic: ALL must be TRUE
for _, r := range results {
if r.IsFalse() {
return CaveatResultFalse() // Short-circuit on FALSE
}
}
// Collect all missing parameters
allMissing := make(map[string]bool)
for _, r := range results {
if r.RequiresContext() {
for _, param := range r.MissingParams {
allMissing[param] = true
}
}
}
if len(allMissing) > 0 {
return CaveatResultRequires(mapToSlice(allMissing))
}
return CaveatResultTrue()
}Comparison: Post 4 vs. Post 5
Aspect | Post 4 (ReBAC v2) | Post 5 (ABAC v1) |
|---|---|---|
Scope | Static permissions | Context-aware permissions |
Permission Model | Tuples + relations | Tuples + relations + caveats |
Evaluation | Deterministic (true/false) | Tri-state (true/false/requires_context) |
Context | Not used | Required for caveat evaluation |
Time-based Access | Not possible | Supported via caveats |
Attribute Matching | Not possible | Foundation for Post 6 |
Typical Use Cases | Hierarchies, inheritance | Business hours, IP allowlist, expiration |
Migration Guide: Adding Caveats to Existing Systems
Step 1: Identify conditional logic in application code
// Before: Application-layer time checks
if time.Now().Hour() >= 9 && time.Now().Hour() < 17 {
// Allow access
}
// After: Caveat-based authorization
// Move logic to schema, provide context at check timeStep 2: Define caveats in schema
// Start with simple, commonly-used conditions
schema.AddCaveat(&CaveatDefinition{
Name: "business_hours",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "current_hour", Type: CAV_T_INT},
},
Expression: /* ... */,
})
Step 3: Gradual rollout strategy
// Phase 1: Add caveats to new tuples only
// Phase 2: Migrate existing tuples with caveats
// Phase 3: Remove application-layer checks
// Use feature flags to control rollout
if enableCaveats {
// Use caveat-based check
} else {
// Fall back to old logic
}
Step 4: Monitor and validate
Compare caveat results with existing application logic
Log discrepancies for investigation
Gradually increase caveat adoption percentage
Remove application-layer checks once confident
Common migration patterns:
Time-based access: Business hours, expiration dates, maintenance windows
Location-based access: IP allowlists, geo-fencing, office networks
Attribute-based access: Department matching, clearance levels, user properties
Request-based access: HTTP methods, API endpoints, rate limiting
Takeaways
Caveats enable context-aware permissions: Instead of storing time-based roles or checking conditions in application code, attach caveats to tuples and evaluate them at check time.
Tri-state evaluation handles missing context: CAVEAT_REQUIRES_CONTEXT signals that the caller must provide more information. This is safer than guessing or defaulting to deny.
Caveats are composable: A single tuple can have multiple caveats (AND-ed together). A single caveat can be reused across many tuples. This keeps authorization logic DRY and maintainable.
What We've Built
We now have ABAC v1 with Caveats:
Direct permissions (Post 1):
OP_DIRECTRole-based permissions (Post 2):
SubjectSetreferencesBoolean combinations (Post 3):
OP_UNION,OP_INTERSECTION,OP_EXCLUSION,OP_COMPUTED_SUBJECTSCross-object inheritance (Post 4):
OP_EDGEContext-aware conditions (new in Post 5):
CaveatDefinition+ContextualizedCaveat
The authorization check now evaluates both structural permissions (tuples + relations) and contextual conditions (caveats), enabling time-based, attribute-based, and dynamic access control.
Limitations (What's Still Missing)
Wildcard subjects with namespace-based parameters: "All users can access if department = HR"
User-defined functions & rich expression library: Custom functions like
contains(),startsWith(),regex_match()for more expressive caveatsDeterminism guarantees: Ensuring two evaluators always agree
Up next (Post 6): Organization-Scoped Conditions We'll add wildcard subjects and namespaced parameters so caveats can match attributes between users and resources. This enables "any user can view HR docs if department = HR."
