The Problem Statement
Our model from Posts 1–7 handles flexible, contextual access. But real systems also need non-negotiable rules — mandatory policies that apply everywhere, regardless of individual tuple configuration. Without schema-level enforcement, we risk:
Policy violations: Tuples created without required security checks
Compliance failures: Missing mandatory caveats for regulated data
Security gaps: Legacy tuples that bypass new security requirements
Inconsistent enforcement: Some tuples have caveats, others don't
Real-world examples from production systems:
Looking at how major systems enforce mandatory policies reveals critical requirements:
Google Workspace: All external sharing requires admin approval (schema-level policy)
AWS S3: Bucket policies can enforce encryption for all objects (mandatory requirement)
GitHub Enterprise: Can require 2FA for all organization members (org-level policy)
Salesforce: Field-level security applies to all records (schema-level constraint)
Common patterns that require schema-level enforcement:
Compliance: "All access to HIPAA data must check business_hours caveat"
Security: "All external users must have ip_restriction caveat"
Audit: "All admin access must have mfa_verified caveat"
Data residency: "All EU data access must check gdpr_consent caveat"
What we can't enforce today:
"ALL users accessing classified documents must satisfy the security_clearance caveat—no exceptions."
Our current model allows:
❌ Creating tuples without required caveats
❌ Legacy tuples that bypass new security requirements
❌ Inconsistent caveat application across tuples
❌ No way to enforce mandatory policies at schema level
The core problem: We need to enforce mandatory caveats at the schema level, ensuring all tuples for a relation satisfy required policies, while gracefully handling legacy data.
Human Rule
"All users accessing patient records must obey the business_hours caveat—no exceptions, regardless of when the access tuple was created."
This single sentence captures the requirement: a schema-level policy that applies universally to all tuples, old and new, without requiring data migration.
Why Current Models Don't Work
Let's try to enforce "all viewers must access during business hours" using our existing tools from Posts 1-7.
What We Have So Far (Post 1-7 Recap)
From previous posts, we have:
From Post 5: Tuple-level caveats:
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "1", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"}, // ← Optional!
},
}From Post 3: Subject constraints (type restrictions):
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{Namespace: "user"}, // ← Only type restriction
},
}
What's missing: No way to enforce that ALL tuples must have a specific caveat!
Approach 1: Manual Caveat on Every Tuple (Error-Prone)
// Attempt: Add business_hours caveat to every tuple manually
// Tuple 1: ✅ Has caveat
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "1", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "alice"},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
},
}
// Tuple 2: ❌ Forgot caveat!
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "2", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "bob"},
// Missing business_hours caveat!
},
}
// Tuple 3: ✅ Has caveat
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "3", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "charlie"},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
},
}Problems:
❌ Human error: Easy to forget caveat when creating tuples
❌ No enforcement: System doesn't prevent missing caveats
❌ Inconsistent: Some tuples have caveat, others don't
❌ Audit nightmare: Must scan all tuples to verify compliance
Example failure:
// Bob can access document:2 at ANY time (no caveat!)
Check(document:2#viewer, user:bob, context={current_hour: 23})
// → TRUE (should be FALSE outside business hours!)Approach 2: Application-Layer Validation (Brittle)
// Attempt: Validate caveats in application code before creating tuples
func CreateViewerTuple(documentID, userID string) error {
tuple := &RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: documentID, Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: userID},
Caveat: &ContextualizedCaveat{CaveatName: "business_hours"}, // ← Hardcoded!
},
}
return tupleStore.Write(tuple)
}
Problems:
❌ Scattered logic: Policy enforcement in application code, not authorization system
❌ Bypass risk: Direct tuple writes bypass validation
❌ Version skew: Different app versions may have different policies
❌ No schema evolution: Can't update policy without redeploying all apps
Example failure:
// Legacy app version doesn't know about business_hours requirement
legacyApp.WriteTuple(document:4#viewer, user:dave) // ← No caveat!Approach 3: Composite Caveat (Doesn't Scale)
// Attempt: Create a composite caveat that includes business_hours
// Define composite caveat
&CaveatDefinition{
Name: "viewer_with_business_hours",
Expression: &BooleanExpr{
Op: BOOL_AND,
Children: []BooleanExpr{
// Original viewer caveat (if any)
&BooleanExpr{Op: BOOL_PREDICATE, Predicate: ...},
// Mandatory business_hours check
&BooleanExpr{Op: BOOL_PREDICATE, Predicate: ...},
},
},
}
// Use composite caveat on every tuple
RelationTuple{
Subject: &DirectSubject{
Caveat: &ContextualizedCaveat{CaveatName: "viewer_with_business_hours"},
},
}
Problems:
❌ Caveat explosion: Need separate composite for every combination
❌ Still manual: Must remember to use composite caveat
❌ Can't evolve: Adding new mandatory check requires new composite caveats
❌ No enforcement: Nothing prevents using wrong caveat
Example failure:
// Developer uses wrong caveat
RelationTuple{
Subject: &DirectSubject{
Caveat: &ContextualizedCaveat{CaveatName: "ip_restriction"}, // ← Wrong caveat!
},
}The Solution: RequiredCaveat in SubjectConstraints
We extend the SubjectConstraints model (introduced in Post 3) to support required caveats that apply to all subjects of a given type.
Part 1: RequiredCaveat Field
Already defined in our model (Post 3, now fully utilized):
type AllowedDirectSubject struct {
Namespace string
RequiredCaveat string // ← NEW: Mandatory caveat for all direct subjects
}
type AllowedSubjectSet struct {
Namespace string
Relation string
RequiredCaveat string // ← NEW: Mandatory caveat for all subject sets
}
type AllowedWildcardSubject struct {
Namespace string
RequiredCaveat string // ← NEW: Mandatory caveat for all wildcard subjects
}Semantic meaning:
If RequiredCaveat is set, ALL tuples with subjects of this type MUST satisfy the caveat during evaluation.
Key insight: The caveat is enforced at evaluation time, not write time. This allows:
✅ Graceful schema evolution: Old tuples still work
✅ No data migration: Don't need to rewrite existing tuples
✅ Consistent enforcement: Policy applies to all tuples, old and new
Part 2: Effective Caveat Combination
Rule: When evaluating a tuple, the effective caveat is the AND of:
Schema-level RequiredCaveat (from
SubjectConstraints)Tuple-level Caveat (from
Subject.Caveat)
Formula:
EffectiveCaveat = RequiredCaveat AND TupleCaveat
Truth table (tri-state logic):
RequiredCaveat | TupleCaveat | EffectiveCaveat | Meaning |
|---|---|---|---|
|
|
| No caveats (always TRUE) |
|
|
| Only schema caveat applies |
|
|
| Only tuple caveat applies |
|
|
| Both caveats must pass |
Example:
// Schema: All viewers must access during business hours
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{
Namespace: "user",
RequiredCaveat: "business_hours", // ← Schema-level requirement
},
},
}
// Tuple 1: No tuple-level caveat
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "1", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "alice"},
Caveat: nil, // ← No tuple caveat
},
}
// `EffectiveCaveat = business_hours` (from schema)
// Tuple 2: Has tuple-level caveat
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "2", Relation: "viewer"},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "bob"},
Caveat: &ContextualizedCaveat{CaveatName: "ip_restriction"},
},
}
// `EffectiveCaveat = business_hours AND ip_restriction` (both must pass)Evaluation:
// Check at 2 PM from allowed IP
Check(document:1#viewer, user:alice, context={current_hour: 14, ip: "10.0.0.1"})
// → Evaluate business_hours → TRUE ✅
// Check at 11 PM from allowed IP
Check(document:1#viewer, user:alice, context={current_hour: 23, ip: "10.0.0.1"})
// → Evaluate business_hours → FALSE ❌
// Check at 2 PM from disallowed IP
Check(document:2#viewer, user:bob, context={current_hour: 14, ip: "192.168.1.1"})
// → Evaluate (business_hours AND ip_restriction)
// → business_hours: TRUE, ip_restriction: FALSE
// → Result: FALSE ❌
Implementation Details
Algorithm: Computing Effective Caveat Expression
The effective caveat is a BooleanExpr, not a named caveat. This avoids the "__composite__" naming hack.
function GetEffectiveCaveatExpr(tuple, schema, context) -> BooleanExpr:
// Step 1: Get tuple-level caveat expression
tupleCaveat = tuple.Subject.GetCaveat()
tupleExpr = nil
if tupleCaveat != nil:
tupleExpr = ResolveCaveat(tupleCaveat.CaveatName).Expression
// Step 2: Get schema-level required caveat expression
relation = schema.GetRelation(tuple.Resource.Namespace, tuple.Resource.Relation)
requiredCaveatName = relation.GetRequiredCaveatForSubject(tuple.Subject)
schemaExpr = nil
if requiredCaveatName != "":
schemaExpr = ResolveCaveat(requiredCaveatName).Expression
// Step 3: Combine expressions
if schemaExpr == nil and tupleExpr == nil:
return nil # No caveats
if schemaExpr == nil:
return tupleExpr # Only tuple caveat
if tupleExpr == nil:
return schemaExpr # Only schema caveat
# Both present: AND them together
return &BooleanExpr{
Op: BOOL_AND,
Children: [schemaExpr, tupleExpr],
}
function EvaluateEffectiveCaveat(tuple, schema, context) -> CaveatEvalResult:
effectiveExpr = GetEffectiveCaveatExpr(tuple, schema, context)
if effectiveExpr == nil:
return CAV_TRUE # No caveats
return EvaluateBooleanExpr(effectiveExpr, context)Key implementation notes:
Direct BooleanExpr composition: No intermediate
"__composite__"caveat name; directly build AND expressionLookup by subject type: Schema matches subject type to find correct
RequiredCaveatnameEvaluation order: Schema expression is left child (evaluated first), tuple expression is right child
Caching: Effective expression can be cached per (schema_version, tuple_signature)
Real-World Example: HIPAA Compliance System
Let's build a complete example for a healthcare system that must comply with HIPAA regulations.
Requirements
Compliance Policy:
"All access to patient health records must:
Occur during business hours (9 AM - 5 PM) — MANDATORY for all users
Doctors must have valid medical license — tuple-specific
Nurses must be assigned to patient's department — tuple-specific"
Schema Definition
// Define mandatory business_hours caveat
businessHours := &CaveatDefinition{
Name: "business_hours",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "env.current_hour", Type: CAV_T_INT},
},
Expression: &BooleanExpr{
Op: BOOL_AND,
Children: []*BooleanExpr{
{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "env.current_hour"},
Op: CMP_GE,
Right: &LiteralExpr{Value: 9, Type: CAV_T_INT},
},
},
{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "env.current_hour"},
Op: CMP_LT,
Right: &LiteralExpr{Value: 17, Type: CAV_T_INT},
},
},
},
},
}
// Define doctor-specific caveat
validLicense := &CaveatDefinition{
Name: "valid_medical_license",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "user.license_expiry", Type: CAV_T_TIMESTAMP},
&ScalarParameter{Name: "env.now_utc", Type: CAV_T_TIMESTAMP},
},
Expression: &BooleanExpr{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "user.license_expiry"},
Op: CMP_GT,
Right: &IdentifierExpr{Name: "env.now_utc"},
},
},
}
// Define nurse-specific caveat
departmentMatch := &CaveatDefinition{
Name: "department_match",
Parameters: []CaveatParameter{
&ScalarParameter{Name: "user.department", Type: CAV_T_STRING},
&ScalarParameter{Name: "patient.department", Type: CAV_T_STRING},
},
Expression: &BooleanExpr{
Op: BOOL_PREDICATE,
Pred: &Predicate{
Left: &IdentifierExpr{Name: "user.department"},
Op: CMP_EQ,
Right: &IdentifierExpr{Name: "patient.department"},
},
},
}
// Schema with RequiredCaveat enforcement
patientRecordSchema := &NamespaceDefinition{
Name: "patient_record",
Relations: map[string]*NamespaceRelationDefinition{
"viewer": {
Name: "viewer",
SubjectsComputationExpression: &DirectExpression{},
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
// Doctors: business_hours is MANDATORY
&AllowedDirectSubject{
Namespace: "doctor",
RequiredCaveat: "business_hours", // ← Schema-level enforcement
},
// Nurses: business_hours is MANDATORY
&AllowedDirectSubject{
Namespace: "nurse",
RequiredCaveat: "business_hours", // ← Schema-level enforcement
},
},
},
},
},
}Tuple Creation
// Tuple 1: Doctor with license check (tuple-level caveat)
doctorTuple := &RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "patient_record",
ObjectID: "patient-12345",
Relation: "viewer",
},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "doctor", ObjectID: "dr-smith"},
Caveat: &ContextualizedCaveat{
CaveatName: "valid_medical_license",
Context: &CaveatContext{
Values: map[string]any{
"user.license_expiry": int64(1735689600), // 2025-01-01
},
},
},
},
}
// `EffectiveCaveat = business_hours AND valid_medical_license`
// Tuple 2: Nurse with department check (tuple-level caveat)
nurseTuple := &RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "patient_record",
ObjectID: "patient-12345",
Relation: "viewer",
},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "nurse", ObjectID: "nurse-jones"},
Caveat: &ContextualizedCaveat{
CaveatName: "department_match",
Context: &CaveatContext{
Values: map[string]any{
"patient.department": "Cardiology",
},
},
},
},
}
// `EffectiveCaveat = business_hours AND department_match`
// Tuple 3: Legacy doctor tuple (no tuple-level caveat)
legacyDoctorTuple := &RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "patient_record",
ObjectID: "patient-67890",
Relation: "viewer",
},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "doctor", ObjectID: "dr-brown"},
Caveat: nil, // ← No tuple caveat (legacy data)
},
}
// `EffectiveCaveat = business_hours` (schema enforces it!)Evaluation Examples
Scenario 1: Doctor during business hours with valid license
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 14) // 2 PM
ctx.SetValue("env.now_utc", int64(1704067200)) // 2024-01-01
Check(patient_record:patient-12345#viewer, doctor:dr-smith, ctx)
// → Evaluate (business_hours AND valid_medical_license)
// → business_hours: 14 >= 9 AND 14 < 17 → TRUE
// → valid_medical_license: 1735689600 > 1704067200 → TRUE
// → Result: TRUE ✅Scenario 2: Doctor outside business hours (HIPAA violation prevented!)
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 22) // 10 PM
ctx.SetValue("env.now_utc", int64(1704067200))
Check(patient_record:patient-12345#viewer, doctor:dr-smith, ctx)
// → Evaluate (business_hours AND valid_medical_license)
// → business_hours: 22 >= 9 AND 22 < 17 → FALSE ❌
// → Result: FALSE (short-circuit, don't check license)Scenario 3: Nurse with wrong department
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 10) // 10 AM
ctx.SetValue("user.department", "Neurology")
Check(patient_record:patient-12345#viewer, nurse:nurse-jones, ctx)
// → Evaluate (business_hours AND department_match)
// → business_hours: 10 >= 9 AND 10 < 17 → TRUE
// → department_match: "Neurology" == "Cardiology" → FALSE
// → Result: FALSE ❌Scenario 4: Legacy doctor tuple (schema caveat still enforced!)
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 23) // 11 PM
Check(patient_record:patient-67890#viewer, doctor:dr-brown, ctx)
// → Evaluate business_hours (from schema, even though tuple has no caveat!)
// → business_hours: 23 >= 9 AND 23 < 17 → FALSE ❌
// → Result: FALSE (legacy tuple protected by schema policy!)Key insight: Even legacy tuples created before the business_hours requirement are automatically protected by the schema-level policy. No data migration needed!
Design Decisions
Decision 1: Evaluation-Time vs Write-Time Enforcement
Question: Should we enforce RequiredCaveat when writing tuples or when evaluating them?
Answer: Evaluation-time enforcement — for graceful schema evolution.
Rationale:
Write-time enforcement (rejected):
// ❌ Reject tuple writes that don't have required caveat
func WriteTuple(tuple *RelationTuple) error {
requiredCaveat := schema.GetRequiredCaveat(tuple)
if requiredCaveat != "" && tuple.Subject.Caveat == nil {
return errors.New("missing required caveat")
}
return store.Write(tuple)
}Problems:
❌ Breaking change: Existing tuples become invalid when schema adds
RequiredCaveat❌ Data migration: Must rewrite all existing tuples to add caveat
❌ Downtime: Can't deploy schema change without migrating data first
❌ Rollback complexity: Can't easily revert schema change
Evaluation-time enforcement (chosen):
// ✅ Apply required caveat during evaluation
func Check(resource, subject, context) bool {
tuple := FindTuple(resource, subject)
effectiveCaveat := GetEffectiveCaveat(tuple, schema)
return EvaluateCaveat(effectiveCaveat, context)
}
Benefits:
✅ Non-breaking, zero-downtime deployment: Old tuples continue to work; schema change takes effect immediately without data migration; easy rollback
Trade-off: Tuples don't "know" about schema-level caveats, but this is acceptable because:
Schema is the source of truth for policy
Evaluation always uses latest schema
Audit logs capture effective caveat used
Decision 2: AND Semantics (Not OR)
Question: Should RequiredCaveat AND or OR with tuple caveat?
Answer: AND semantics — for additive security.
Rationale:
OR semantics (rejected):
EffectiveCaveat = RequiredCaveat OR TupleCaveat
Problem: Tuple caveat could bypass schema requirement!
Example failure:
// Schema requires business_hours
RequiredCaveat: "business_hours"
// Tuple has different caveat
TupleCaveat: "ip_restriction"
// With OR: business_hours OR ip_restriction
// → If ip_restriction passes, access granted even outside business hours!
// → Schema requirement bypassed! ❌AND semantics (chosen):
EffectiveCaveat = RequiredCaveat AND TupleCaveat
Benefits:
✅ Additive security: Schema requirement always enforced
✅ No bypass: Tuple caveat can only add restrictions, not remove them
✅ Compliance: Mandatory policies cannot be circumvented
✅ Intuitive: "Required" means "must pass"
Example:
// Schema requires business_hours
RequiredCaveat: "business_hours"
// Tuple has additional restriction
TupleCaveat: "ip_restriction"
// With AND: business_hours AND ip_restriction
// → Both must pass
// → Schema requirement always enforced ✅Decision 3: Subject Type Specificity
Question: Should RequiredCaveat apply to all subjects or specific subject types?
Answer: Per subject type — for fine-grained control.
Rationale:
Global requirement (rejected):
// ❌ All subjects must satisfy same caveat
&NamespaceRelationDefinition{
Name: "viewer",
RequiredCaveat: "business_hours", // Applies to ALL subject types
}Problems:
❌ Too coarse: Can't have different requirements for different subject types
❌ Inflexible: Doctors and nurses might need different checks
❌ Workaround needed: Would need separate relations for each subject type
Per-type requirement (chosen):
// ✅ Different requirements for different subject types
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{
Namespace: "doctor",
RequiredCaveat: "business_hours", // Doctors: business hours only
},
&AllowedDirectSubject{
Namespace: "admin",
RequiredCaveat: "mfa_verified", // Admins: MFA required
},
&AllowedDirectSubject{
Namespace: "system",
RequiredCaveat: "", // Systems: no restriction
},
},
}Benefits:
✅ Fine-grained: Different policies for different subject types
✅ Flexible: Can exempt certain subjects (e.g., system accounts)
✅ Realistic: Matches real-world compliance requirements
✅ Composable: Can combine with tuple-level caveats
Edge Cases and Safety
Edge Case 1: Unknown Required Caveat
Problem: What if schema references a caveat that doesn't exist?
Solution: Treat as FALSE (fail-safe).
// Schema requires non-existent caveat
RequiredCaveat: "unknown_caveat"
// Evaluation
effectiveCaveat := GetEffectiveCaveat(tuple, schema)
caveatDef := caveatRegistry.Get(effectiveCaveat.CaveatName)
if caveatDef == nil {
// Unknown caveat → deny access (fail-safe)
return FALSE
}Rationale:
✅ Fail-safe: Unknown policy → deny access
✅ Prevents bypass: Can't grant access with missing caveat
✅ Detectable: Monitoring can alert on unknown caveats
✅ Schema validation: Compilation should catch this earlier
Edge Case 2: Pre-Bound Context in RequiredCaveat
Problem: Should RequiredCaveat allow pre-binding context values?
Solution: Disallow pre-binding in RequiredCaveat — only caveat name is allowed.
// ✅ VALID: RequiredCaveat references caveat by name only
&AllowedDirectSubject{
Namespace: "user",
RequiredCaveat: "business_hours", // ← Name only, no pre-bound context
}
// ❌ INVALID: RequiredCaveat with pre-bound context
&AllowedDirectSubject{
Namespace: "user",
RequiredCaveat: &ContextualizedCaveat{ // ← Not allowed!
CaveatName: "business_hours",
Context: &CaveatContext{
Values: map[string]any{"env.current_hour": 9},
},
},
}Rationale:
✅ Security: Prevents tuple from overriding schema-level security parameters
✅ Simplicity: RequiredCaveat is just a string name, not a full ContextualizedCaveat
✅ Predictable: All parameters come from runtime context (caller-provided)
✅ No conflicts: Eliminates precedence ambiguity
Attack scenario prevented:
// If pre-binding were allowed:
// Schema: RequiredCaveat with env.current_hour=9 (always passes)
// Attacker tuple: TupleCaveat with env.current_hour=23
// If tuple wins → attacker can deny legitimate access
// If schema wins → tuple can't override, but then why allow pre-binding?Design decision: RequiredCaveat is a string (caveat name), not a ContextualizedCaveat. All context values must come from the runtime Check call.
Edge Case 3: Multiple Subject Types Match
Problem: What if a subject could match multiple AllowedSubjectType entries?
Solution: This is a schema validation error — each subject type should appear at most once.
// ❌ INVALID SCHEMA: doctor appears twice
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{
Namespace: "doctor",
RequiredCaveat: "business_hours",
},
&AllowedDirectSubject{
Namespace: "doctor", // ← Duplicate!
RequiredCaveat: "mfa_verified",
},
},
}Schema validation:
func ValidateSubjectConstraints(constraints *SubjectConstraints) error {
seen := make(map[string]bool)
for _, allowedType := range constraints.AllowedSubjectTypes {
key := allowedType.GetKey() // e.g., "direct:doctor" or "set:role#member"
if seen[key] {
return fmt.Errorf("duplicate subject type: %s", key)
}
seen[key] = true
}
return nil
}Rationale:
✅ Unambiguous: Each subject type has exactly one policy
✅ Fail-fast: Catch errors at schema compilation time
✅ Deterministic: No tie-breaking needed
Implementation Notes
Schema Validation and Deployment Safety
Critical: Schema changes with RequiredCaveat can cause production outages if not validated and deployed carefully.
1. Schema Compilation and Validation
Before deploying a schema, validate that all RequiredCaveat references exist:
func ValidateSchema(schema *NamespaceDefinition, caveatRegistry CaveatRegistry) error {
for _, relation := range schema.Relations {
if relation.SubjectConstraints == nil {
continue
}
for _, allowedType := range relation.SubjectConstraints.AllowedSubjectTypes {
requiredCaveat := allowedType.GetRequiredCaveat()
if requiredCaveat == "" {
continue // No required caveat
}
// Verify caveat exists
caveatDef := caveatRegistry.Get(requiredCaveat)
if caveatDef == nil {
return fmt.Errorf(
"unknown RequiredCaveat '%s' in %s#%s for subject type %s",
requiredCaveat,
schema.Name,
relation.Name,
allowedType.GetKey(),
)
}
}
}
return nil
}Rationale:
✅ Fail-fast: Catch typos at schema compile time, not runtime
✅ Prevents outages: Invalid schema rejected before deployment
✅ Clear errors: Pinpoint exact location of invalid reference
2. Staged Rollout: Observe → Enforce
Deploy new RequiredCaveat in two stages to avoid breaking production:
ALGORITHM: Staged RequiredCaveat Deployment
Stage 1: OBSERVE mode (1-2 weeks)
- Schema deployed with RequiredCaveat
- Evaluation logs violations but returns REQUIRES_CONTEXT (not FALSE)
- Metrics track "would-deny" rate per namespace/relation/caveat
- Teams notified to add missing context parameters
Stage 2: ENFORCE mode (after "would-deny" rate < 1%)
- Flip enforcement flag
- Actually deny access on caveat failure
- Monitor error rates for spikes
- Auto-rollback if error rate > thresholdImplementation:
type RequiredCaveatMode int32
const (
MODE_OBSERVE RequiredCaveatMode = iota // Log but don't enforce
MODE_ENFORCE // Actually deny access
)
func EvaluateWithMode(effectiveCaveat, context, mode) CaveatEvalResult {
result := EvaluateCaveat(effectiveCaveat, context)
if mode == MODE_OBSERVE && result == CAV_FALSE {
// Log violation but don't deny
logger.Warn("RequiredCaveat would deny access",
"caveat", effectiveCaveat.CaveatName,
"context", context.String(),
)
metrics.Increment("required_caveat.would_deny")
// Return REQUIRES_CONTEXT to signal missing params
return CAV_REQUIRES_CONTEXT
}
return result
}3. Canary Deployment with Auto-Rollback
Deploy schema changes to 1% of traffic first:
// Route 1% of traffic to canary schema
func GetSchema(namespace string) *NamespaceDefinition {
if canary.IsEnabled(namespace) && rand.Float64() < 0.01 {
return schemaRegistry.GetCanary(namespace)
}
return schemaRegistry.GetStable(namespace)
}
// Monitor canary metrics
if canaryMetrics.ErrorRate > 0.01 || canaryMetrics.LatencyP99 > 100*time.Millisecond {
schemaRegistry.RollbackCanary(namespace)
alert.Send("Schema canary failed - auto-rollback triggered")
}Context Parameter Discovery
Problem: When you add RequiredCaveat: "business_hours", callers must provide env.current_hour. How do they discover this?
Solution 1: Schema Introspection API (Describe)
Provide an API that returns required context parameters:
GET /v1/schema/{namespace}/{relation}/describe
Response:
{
"namespace": "patient_record",
"relation": "viewer",
"subjectTypes": [
{
"subjectType": "doctor",
"requiredCaveat": {
"name": "business_hours",
"parameters": [
{"name": "env.current_hour", "type": "int", "scope": "env"}
]
}
}
]
}Solution 2: REQUIRES_CONTEXT with Minimal Missing Set
If caller omits required parameters, Check returns missing parameter names (see Post 9):
// Caller submits Check without full context
result := Check(patient_record:123#viewer, doctor:alice, context={})
// Response: REQUIRES_CONTEXT with missing params
{
"decision": "REQUIRES_CONTEXT",
"missing": ["env.current_hour"],
"caveat": "business_hours"
}
// Caller fetches and retries
context.Set("env.current_hour", time.Now().Hour())
result = Check(patient_record:123#viewer, doctor:alice, context)Solution 3: SDK/Codegen for Type Safety
Generate typed client from schema:
// Generated from schema
type PatientRecordViewerContext struct {
CurrentHour int `json:"env.current_hour"` // Required
}
// Type-safe API
client.CheckPatientRecordViewer(
resourceID: "123",
subject: doctor:alice,
context: &PatientRecordViewerContext{
CurrentHour: time.Now().Hour(),
},
)
Deployment timeline for new RequiredCaveat:
Week 0: Publish schema via Describe API; update SDK
Week 1: Deploy in OBSERVE mode; monitor REQUIRES_CONTEXT rate
Week 2: Teams deploy code to provide required context
Week 3: Flip to ENFORCE mode once REQUIRES_CONTEXT rate < 1%Performance Considerations
Benchmark target: RequiredCaveat should add <5% latency overhead vs. tuple-only caveats.
1. Schema Caching
Cache compiled schema to avoid repeated lookups:
type CompiledRelation struct {
Relation *NamespaceRelationDefinition
RequiredCaveatMap map[string]string // subjectType → caveatName
}
// Build map once at schema load time
func CompileRelation(relation *NamespaceRelationDefinition) *CompiledRelation {
requiredCaveatMap := make(map[string]string)
for _, allowedType := range relation.SubjectConstraints.AllowedSubjectTypes {
key := allowedType.GetKey() // e.g., "direct:user"
requiredCaveatMap[key] = allowedType.GetRequiredCaveat()
}
return &CompiledRelation{
Relation: relation,
RequiredCaveatMap: requiredCaveatMap,
}
}
// Fast lookup during evaluation
compiled := schemaCache.GetRelation(resource.Namespace, resource.Relation)
for tuple := range tuples {
subjectKey := tuple.Subject.GetKey()
requiredCaveat := compiled.RequiredCaveatMap[subjectKey]
// ...
}2. Effective Caveat Caching
Cache effective caveat per (schema_version, tuple_signature):
type EffectiveCaveatCache struct {
cache map[string]*BooleanExpr // Key: "v42:user:alice[business_hours]"
}
func (c *EffectiveCaveatCache) Get(schemaVersion, tupleSignature string) *BooleanExpr {
key := fmt.Sprintf("%s:%s", schemaVersion, tupleSignature)
return c.cache[key]
}
// Invalidate on schema change
func (c *EffectiveCaveatCache) InvalidateSchema(schemaVersion string) {
for key := range c.cache {
if strings.HasPrefix(key, schemaVersion+":") {
delete(c.cache, key)
}
}
}3. Short-Circuit Evaluation
Evaluate RequiredCaveat first; if FALSE, skip TupleCaveat:
func EvaluateEffectiveCaveat(requiredCaveat, tupleCaveat, context) CaveatEvalResult {
// Evaluate schema caveat first
if requiredCaveat != nil {
schemaResult := EvaluateCaveat(requiredCaveat, context)
if schemaResult == CAV_FALSE {
return CAV_FALSE // Short-circuit
}
if tupleCaveat != nil {
tupleResult := EvaluateCaveat(tupleCaveat, context)
return CombineAND(schemaResult, tupleResult)
}
return schemaResult
}
if tupleCaveat != nil {
return EvaluateCaveat(tupleCaveat, context)
}
return CAV_TRUE
}Audit Trail and Observability
Critical: Audit logs must clearly indicate when RequiredCaveat denies access (compliance requirement).
Structured Audit Log:
type AuditLogEntry struct {
Timestamp time.Time
Decision string // "ALLOW", "DENY", "REQUIRES_CONTEXT"
Reason string // "required_caveat_failed", "tuple_caveat_failed"
CaveatName string // "business_hours"
CaveatSource string // "schema", "tuple", "both"
FailedExpr string // "env.current_hour >= 9 AND env.current_hour < 17"
ProvidedContext map[string]any // {"env.current_hour": 23}
MissingParams []string // ["user.license_expiry"]
SchemaVersion string // "v42"
Tuple string // "patient_record:123#viewer@doctor:alice"
}Metrics:
// Counter: RequiredCaveat failures
metrics.Increment("required_caveat.failed",
"namespace", "patient_record",
"relation", "viewer",
"caveat", "business_hours",
)
// Counter: Unknown caveat references
metrics.Increment("required_caveat.unknown",
"caveat", "typo_caveat",
)
// Histogram: Evaluation latency
metrics.Histogram("required_caveat.eval_latency_ms", latencyMs)Alerts:
# Unknown caveat (schema validation miss)
- alert: UnknownRequiredCaveat
expr: rate(required_caveat.unknown[5m]) > 0
severity: critical
# High REQUIRES_CONTEXT rate (callers missing params)
- alert: HighRequiresContextRate
expr: rate(required_caveat.requires_context[5m]) > 0.05
severity: warningTesting Strategies
1. Schema Validation Tests
Test that invalid schemas are rejected at compile time:
func TestSchemaValidation_UnknownRequiredCaveat(t *testing.T) {
schema := &NamespaceDefinition{
Name: "document",
Relations: map[string]*NamespaceRelationDefinition{
"viewer": {
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{
Namespace: "user",
RequiredCaveat: "typo_caveat", // ← Doesn't exist
},
},
},
},
},
}
err := ValidateSchema(schema, caveatRegistry)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown RequiredCaveat 'typo_caveat'")
}
func TestSchemaValidation_DuplicateSubjectType(t *testing.T) {
schema := &NamespaceDefinition{
Relations: map[string]*NamespaceRelationDefinition{
"viewer": {
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{Namespace: "user", RequiredCaveat: "business_hours"},
&AllowedDirectSubject{Namespace: "user", RequiredCaveat: "mfa_verified"}, // ← Duplicate!
},
},
},
},
}
err := ValidateSchema(schema, caveatRegistry)
assert.Error(t, err)
assert.Contains(t, err.Error(), "duplicate subject type")
}2. Schema Evolution Tests
Test that adding RequiredCaveat doesn't break existing tuples:
func TestSchemaEvolution_AddRequiredCaveat(t *testing.T) {
// Step 1: Create tuple without caveat
tuple := &RelationTuple{
Resource: document:1#viewer,
Subject: &DirectSubject{Object: user:alice, Caveat: nil},
}
store.Write(tuple)
// Step 2: Check passes (no required caveat yet)
result := Check(document:1#viewer, user:alice, ctx)
assert.True(t, result)
// Step 3: Deploy schema with RequiredCaveat
schema := &NamespaceDefinition{
Relations: map[string]*NamespaceRelationDefinition{
"viewer": {
SubjectConstraints: &SubjectConstraints{
AllowedSubjectTypes: []AllowedSubjectType{
&AllowedDirectSubject{
Namespace: "user",
RequiredCaveat: "business_hours",
},
},
},
},
},
}
schemaService.Deploy(schema)
// Step 4: Check now requires business_hours (no data migration!)
ctx.SetValue("env.current_hour", 14)
result = Check(document:1#viewer, user:alice, ctx)
assert.True(t, result) // Passes during business hours
ctx.SetValue("env.current_hour", 23)
result = Check(document:1#viewer, user:alice, ctx)
assert.False(t, result) // Fails outside business hours
}3. AND Semantics Tests
Test that schema and tuple caveats are both enforced:
func TestRequiredCaveat_ANDSemantics(t *testing.T) {
// Schema requires business_hours
// Tuple requires ip_restriction
// Test 1: Both pass
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 14)
ctx.SetValue("request.ip", "10.0.0.1")
result := Check(document:1#viewer, user:alice, ctx)
assert.True(t, result)
// Test 2: Schema caveat fails
ctx.SetValue("env.current_hour", 23) // Outside business hours
ctx.SetValue("request.ip", "10.0.0.1")
result = Check(document:1#viewer, user:alice, ctx)
assert.False(t, result)
// Test 3: Tuple caveat fails
ctx.SetValue("env.current_hour", 14)
ctx.SetValue("request.ip", "192.168.1.1") // Disallowed IP
result = Check(document:1#viewer, user:alice, ctx)
assert.False(t, result)
// Test 4: Both fail
ctx.SetValue("env.current_hour", 23)
ctx.SetValue("request.ip", "192.168.1.1")
result = Check(document:1#viewer, user:alice, ctx)
assert.False(t, result)
}4. Subject Type Specificity Tests
Test that different subject types have different requirements:
func TestRequiredCaveat_PerSubjectType(t *testing.T) {
// Schema: doctors require business_hours, admins require mfa_verified
// Test 1: Doctor during business hours (passes)
ctx := NewCaveatContext()
ctx.SetValue("env.current_hour", 14)
result := Check(patient_record:1#viewer, doctor:smith, ctx)
assert.True(t, result)
// Test 2: Doctor outside business hours (fails)
ctx.SetValue("env.current_hour", 23)
result = Check(patient_record:1#viewer, doctor:smith, ctx)
assert.False(t, result)
// Test 3: Admin without MFA (fails)
ctx := NewCaveatContext()
ctx.SetValue("user.mfa_verified", false)
result = Check(patient_record:1#viewer, admin:jones, ctx)
assert.False(t, result)
// Test 4: Admin with MFA (passes, no business hours requirement!)
ctx.SetValue("user.mfa_verified", true)
ctx.SetValue("env.current_hour", 23) // Outside business hours, but OK for admins
result = Check(patient_record:1#viewer, admin:jones, ctx)
assert.True(t, result)
}5. Property-Based Tests
Test invariants that must hold for all inputs:
// Property: Adding RequiredCaveat never increases access (monotonic security)
func TestProperty_MonotonicSecurity(t *testing.T) {
// For all tuples T, all contexts C:
// Check(T, C, schema_without_required) >= Check(T, C, schema_with_required)
// (TRUE >= REQUIRES_CONTEXT >= FALSE)
quick.Check(func(tuple *RelationTuple, context *CaveatContext) bool {
// Check without RequiredCaveat
resultWithout := CheckWithSchema(tuple, context, schemaWithoutRequired)
// Check with RequiredCaveat
resultWith := CheckWithSchema(tuple, context, schemaWithRequired)
// RequiredCaveat can only make access stricter
return resultWithout.Priority() >= resultWith.Priority()
}, nil)
}
// Property: RequiredCaveat is always enforced
func TestProperty_RequiredCaveatAlwaysEnforced(t *testing.T) {
// For all tuples T with RequiredCaveat R:
// If Check(T, C) == TRUE, then EvaluateCaveat(R, C) == TRUE
quick.Check(func(tuple *RelationTuple, context *CaveatContext) bool {
result := Check(tuple, context)
if result != TRUE {
return true // Property only applies when access granted
}
// If access granted, RequiredCaveat must have passed
requiredCaveat := schema.GetRequiredCaveat(tuple)
if requiredCaveat == "" {
return true // No required caveat
}
caveatResult := EvaluateCaveat(requiredCaveat, context)
return caveatResult == CAV_TRUE
}, nil)
}
// Property: Schema evolution is backward compatible
func TestProperty_SchemaEvolutionBackwardCompatible(t *testing.T) {
// For all tuples T created before schema change:
// If Check(T, C, old_schema) == TRUE, then Check(T, C, new_schema) != ERROR
// (May become FALSE or REQUIRES_CONTEXT, but never ERROR)
quick.Check(func(tuple *RelationTuple, context *CaveatContext) bool {
resultOld := CheckWithSchema(tuple, context, oldSchema)
if resultOld != TRUE {
return true
}
resultNew := CheckWithSchema(tuple, context, newSchemaWithRequired)
return resultNew != ERROR // Never breaks existing tuples
}, nil)
}Takeaways
Schema-level enforcement prevents policy violations —
RequiredCaveatensures all tuples satisfy mandatory policies, regardless of when they were created. Evaluation-time enforcement (not write-time) enables zero-downtime schema evolution without data migration.Production deployment requires operational rigor — Schema validation catches typos before deployment; staged rollout (observe → enforce) prevents breaking production; canary deployment with auto-rollback ensures safety. The gap between "correct design" and "safe to deploy" is significant.
Caller contract is explicit and discoverable — Schema introspection (Describe API), runtime discovery (REQUIRES_CONTEXT), and SDK/codegen make required context parameters discoverable at build-time and runtime. Minimal coupling: callers own their data fetching.
Invariants touched: Fail-safe defaults (unknown caveat → FALSE), monotonic security (RequiredCaveat never increases access), determinism (AND semantics, no precedence ambiguity).
Why it matters: Schema-level safety turns policy from a suggestion into a contract — the system itself refuses to violate compliance.
Next → Post 9: Determinism and Tie-Breaks
We now have powerful schema-level safety nets, but a critical question remains: Do two evaluators always agree?
In distributed systems, determinism is essential. When multiple authorization services evaluate the same Check request, they must produce identical results. But what happens when:
Multiple tuples could grant access with different caveats?
Some caveats return
REQUIRES_CONTEXTwhile others returnTRUE?Schema evolution changes the order of subject types?
Post 9 tackles the determinism problem: canonical signatures, ordering rules, and tie-breaking semantics that guarantee two evaluators always converge on the same answer.
Preview:
// Two paths to access:
// Path A: Direct tuple (REQUIRES_CONTEXT, missing 2 params)
// Path B: Via role (REQUIRES_CONTEXT, missing 1 param)
// Path C: Via group (TRUE, no missing params)
// Question: Which result do we return?
// Answer: Deterministic tie-break rules ensure consistent choice