Loading content...
In most applications, authorization logic is scattered throughout the codebase:
# Authorization buried in business logic
def delete_document(user, document):
if user.role != 'admin' and document.owner_id != user.id:
raise PermissionDenied()
# ... delete logic
This pattern seems simple but creates serious problems at scale:
Policy-Based Authorization addresses these problems by externalizing authorization logic from application code into declarative policies. Policies are treated as data—versioned, tested, audited, and deployed independently of application code. This separation of concerns transforms authorization from scattered code into a manageable, governable system.
By the end of this page, you will understand the principles of policy-based authorization, know how to design effective policies for complex requirements, implement policy lifecycle management (versioning, testing, deployment), establish governance practices for policy management at scale, and operate policy systems in production with confidence.
Well-designed policies are readable, maintainable, testable, and performant. The following principles guide effective policy design.
Principle 1: Single Responsibility
Each policy should address one concern. Don't combine unrelated authorization logic:
# Bad: Multiple concerns in one policy
package mixed_concerns
allow if {
user_can_read_documents
billing_is_current
device_is_trusted
}
# Good: Separate policies for separate concerns
package documents
allow if { user_can_read_documents }
package billing.gate
allow if { billing_is_current }
package device.trust
allow if { device_is_trusted }
Separate policies can be combined at the evaluation layer, making each individually testable and auditable.
Principle 2: Explicit Deny, Explicit Permit
Make authorization decisions explicit rather than relying on implicit defaults:
# Bad: Implicit permit if no deny
package implicit_permit
deny if { some_bad_condition }
# (What happens if deny is false? It's unclear.)
# Good: Explicit decisions
package explicit_decisions
default decision := "deny" # Explicit default
decision := "permit" if {
# Explicit conditions for permit
has_valid_role
resource_not_restricted
}
decision := "deny" if {
# Explicit conditions for deny
account_suspended
}
Explicit defaults prevent accidental permissions when policies fail to match.
Principle 3: Least Privilege by Default
Design policies around minimal necessary access, not maximal possible access:
# Bad: Permissive default with exceptions
default allow := true
deny if { is_sensitive_resource }
# Good: Restrictive default with explicit grants
default allow := false
allow if { has_explicit_permission }
Principle 4: Policy Composability
Design policies that can be combined and reused:
package authorization.base
# Reusable building blocks
is_authenticated if { input.subject.authenticated == true }
is_internal_network if {
net.cidr_contains("10.0.0.0/8", input.environment.ip)
}
is_business_hours if {
hour := time.clock(time.now_ns())[0]
hour >= 9
hour < 17
}
# Composed in specific policies
package authorization.documents
import data.authorization.base
allow if {
base.is_authenticated
base.is_internal_network
# document-specific rules
}
Treat policies like code: version control them, review changes, test before deployment, and apply continuous integration practices. If you wouldn't push code without review and tests, don't push policies without them either.
Authorization requirements follow recurring patterns. Recognizing these patterns helps design consistent, well-structured policies.
| Pattern | Description | Example |
|---|---|---|
| Role Check | Permit based on subject's role | Admins can access admin dashboard |
| Ownership | Permit if subject owns resource | Users can edit their own posts |
| Relationship | Permit based on relationship between entities | Managers can view their team's data |
| Attribute Match | Permit if attributes align | Users can access resources in their department |
| Time Window | Permit during specific times | Reports accessible during business hours |
| Context Gate | Permit based on environmental context | High-risk actions only from trusted networks |
| Delegation | Permit via delegated authority | Alice can act on behalf of Bob |
| Hierarchical | Permit based on hierarchy position | Parent org admins can access child resources |
Pattern: Ownership with Delegation
package documents
# Direct ownership
allow if {
input.action.name == "edit"
input.resource.owner_id == input.subject.id
}
# Delegated access
allow if {
input.action.name == "edit"
delegation := data.delegations[_]
delegation.delegator == input.resource.owner_id
delegation.delegate == input.subject.id
delegation.permission == "edit"
time.now_ns() < delegation.expires_at
}
Pattern: Hierarchical Organization Access
package organizations
# User can access their own org
allow if {
input.subject.org_id == input.resource.org_id
}
# User can access child orgs
allow if {
is_ancestor_org(input.subject.org_id, input.resource.org_id)
}
# Recursive ancestor check
is_ancestor_org(ancestor, descendant) if {
parent := data.org_hierarchy[descendant]
parent == ancestor
}
is_ancestor_org(ancestor, descendant) if {
parent := data.org_hierarchy[descendant]
is_ancestor_org(ancestor, parent)
}
Pattern: Risk-Adaptive Access
package risk_adaptive
# Low-risk requests: normal access
allow if {
input.environment.risk_score < 0.3
has_basic_permission
}
# Medium-risk: require additional verification
allow if {
input.environment.risk_score >= 0.3
input.environment.risk_score < 0.7
has_basic_permission
input.subject.mfa_verified == true
}
# High-risk: deny or require step-up
deny if {
input.environment.risk_score >= 0.7
}
# Response can include obligations
response := {
"decision": allow,
"obligations": obligations
}
obligations["log_sensitive_access"] if {
input.resource.classification == "sensitive"
}
obligations["require_mfa"] if {
input.environment.risk_score >= 0.3
input.subject.mfa_verified == false
}
Build an internal pattern library documenting your organization's standard authorization patterns. This accelerates policy development, ensures consistency, and reduces review burden. New policies can reference established patterns rather than reinventing approaches.
Authorization policies must be tested rigorously. A bug in authorization logic can expose sensitive data or block legitimate users. Testing strategies must verify both permits and denies.
Testing Dimensions:
Unit Testing Policies (OPA Example):
package documents_test
import data.documents
# Test: Owners can edit their documents
test_owner_can_edit {
documents.allow with input as {
"subject": {"id": "user-123"},
"action": {"name": "edit"},
"resource": {"owner_id": "user-123", "type": "document"}
}
}
# Test: Non-owners cannot edit
test_non_owner_cannot_edit {
not documents.allow with input as {
"subject": {"id": "user-456"},
"action": {"name": "edit"},
"resource": {"owner_id": "user-123", "type": "document"}
}
}
# Test: Admins can edit any document
test_admin_can_edit_any {
documents.allow with input as {
"subject": {"id": "user-789", "role": "admin"},
"action": {"name": "edit"},
"resource": {"owner_id": "user-123", "type": "document"}
}
}
# Test: Deleted documents cannot be accessed
test_deleted_document_denied {
not documents.allow with input as {
"subject": {"id": "user-123"},
"action": {"name": "read"},
"resource": {"owner_id": "user-123", "type": "document", "deleted": true}
}
}
Run tests: opa test . -v
Table-Driven Testing:
package authorization_test
import data.authorization
# Define test cases as data
test_cases := [
{
"name": "owner_read_own_doc",
"input": {"subject": {"id": "u1"}, "action": {"name": "read"}, "resource": {"owner": "u1"}},
"expected": true
},
{
"name": "non_owner_read_private",
"input": {"subject": {"id": "u2"}, "action": {"name": "read"}, "resource": {"owner": "u1", "private": true}},
"expected": false
},
{
"name": "admin_read_any",
"input": {"subject": {"id": "u3", "role": "admin"}, "action": {"name": "read"}, "resource": {"owner": "u1"}},
"expected": true
}
]
# Run all test cases
test_all_cases {
case := test_cases[_]
result := authorization.allow with input as case.input
result == case.expected
}
Integration Testing:
Test policies end-to-end with your actual PEP and PDP infrastructure:
import pytest
from app.auth import check_authorization
@pytest.mark.parametrize("user_id,resource_id,action,expected", [
("alice", "doc-owned-by-alice", "edit", True),
("bob", "doc-owned-by-alice", "edit", False),
("admin", "doc-owned-by-alice", "edit", True),
("alice", "doc-owned-by-alice", "delete", True),
("alice", "doc-owned-by-bob", "delete", False),
])
def test_document_authorization(user_id, resource_id, action, expected):
result = check_authorization(
subject={"id": user_id, "role": get_user_role(user_id)},
resource=get_resource(resource_id),
action={"name": action}
)
assert result.permitted == expected
A common mistake is only testing that allowed actions succeed. You must also verify that forbidden actions are denied. An authorization system that allows everything passes all permit tests but fails at security. Always include negative test cases.
Policies are configuration that changes over time. Managing these changes requires versioning, controlled deployment, and rollback capabilities.
Policy Version Control:
policies/
├── authorization/
│ ├── base.rego
│ ├── documents.rego
│ ├── users.rego
│ └── tests/
│ ├── documents_test.rego
│ └── users_test.rego
├── data/
│ ├── role_permissions.json
│ └── resource_classifications.json
└── bundles/
└── production/
└── manifest.yaml
Store policies in version control (Git) alongside tests and supporting data. Treat policy changes like code changes: require reviews, run CI, and track history.
Policy Bundles:
Policy engines like OPA use bundles—versioned, signed packages of policies and data:
# manifest.yaml
roots:
- authorization
- data
recursive: true
revision: "v1.2.3"
signing:
key: "policy-signing-key"
Bundles provide:
Policy CI/CD Pipeline:
# .github/workflows/policy-ci.yaml
name: Policy CI/CD
on:
push:
paths:
- 'policies/**'
pull_request:
paths:
- 'policies/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2
- name: Run policy tests
run: opa test policies/ -v
- name: Check policy formatting
run: opa fmt --diff policies/
- name: Static analysis
run: opa check policies/ --strict
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Build bundle
run: opa build -b policies/ -o bundle.tar.gz
- name: Sign bundle
run: opa sign bundle.tar.gz --key ${{ secrets.SIGNING_KEY }}
- name: Deploy to policy server
run: |
aws s3 cp bundle.tar.gz s3://policy-bundles/production/
Deployment Strategies:
Immediate (Big Bang):
Gradual Rollout:
Shadow Mode:
Canary with Dry-Run:
package authorization
default allow := false
default allow_canary := false
# Production policy
allow if {
# current logic
}
# Canary policy (new logic being tested)
allow_canary if {
# new logic
}
# Response includes both for comparison
response := {
"production_decision": allow,
"canary_decision": allow_canary,
"mismatch": allow != allow_canary
}
Always have a tested rollback procedure. Before deploying new policies, verify that you can revert to the previous version in under 5 minutes. Authorization failures are high-severity incidents—fast rollback is essential.
As policy count grows, governance becomes critical. Without governance, organizations end up with inconsistent, conflicting, and unmaintainable policies.
Governance Challenges:
Governance Framework:
1. Policy Registry
Maintain a central inventory of all policies:
# policy-registry.yaml
policies:
- id: pol-doc-ownership
name: Document Ownership
description: Users can manage documents they own
owner: platform-team
scope: [documents-service]
classification: security-critical
path: policies/documents/ownership.rego
created: 2024-01-01
last_review: 2024-06-15
- id: pol-admin-override
name: Admin Override
description: Admins can access any resource
owner: security-team
scope: [all-services]
classification: security-critical
path: policies/base/admin.rego
created: 2023-06-01
last_review: 2024-06-01
2. Ownership Model
Every policy has an owner responsible for:
3. Review Requirements
Policy changes require review based on classification:
4. Policy Lifecycle States:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ DRAFT │─────▶│ REVIEW │─────▶│ ACTIVE │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌─────────────┐ │
│ DEPRECATED │◀─────────────┘
└──────┬──────┘
│
┌──────▼──────┐
│ ARCHIVED │
└─────────────┘
5. Periodic Review Cadence:
Reviews verify:
Governance requires observability. Track which policies are evaluated (and how often), which policies are never triggered (candidates for removal), and which policies cause the most denials (potential usability issues). Use this data to inform governance decisions.
Before deploying policies, analyze their behavior to prevent unintended consequences. Policy analysis tools help verify correctness, identify conflicts, and simulate impact.
Static Analysis:
Identify issues without running policies:
# OPA static analysis
opa check policies/ --strict
# Common issues detected:
# - Unused variables
# - Type errors
# - Unreachable rules
# - Shadowed variables
Formal Verification:
AWS Cedar supports formal verification—mathematical proof that policies satisfy certain properties:
// Cedar policy for verification
permit (
principal is User,
action == Action::"read",
resource is Document
) when {
resource.owner == principal
};
// Verification query: "Can any user read documents they don't own?"
// Formal verifier proves this is impossible given the policy.
Impact Simulation:
Before deploying, simulate policy changes against historical requests:
# Policy impact analyzer
def analyze_policy_impact(new_policies, historical_requests):
results = {
"total_requests": len(historical_requests),
"decision_changes": [],
"new_permits": 0,
"new_denies": 0
}
for request in historical_requests:
old_decision = evaluate_with(current_policies, request)
new_decision = evaluate_with(new_policies, request)
if old_decision != new_decision:
results["decision_changes"].append({
"request": request,
"old": old_decision,
"new": new_decision
})
if new_decision == "permit":
results["new_permits"] += 1
else:
results["new_denies"] += 1
return results
# Example output:
# {
# "total_requests": 1000000,
# "decision_changes": [...],
# "new_permits": 150,
# "new_denies": 3
# }
Impact Analysis Questions:
Conflict Detection:
Identify policies that may conflict:
# Find policy conflicts
def detect_conflicts(policies):
conflicts = []
for p1, p2 in combinations(policies, 2):
# Find inputs that satisfy conditions of both
overlapping_inputs = find_overlap(p1.conditions, p2.conditions)
if overlapping_inputs and p1.decision != p2.decision:
conflicts.append({
"policy1": p1.id,
"policy2": p2.id,
"example_input": overlapping_inputs[0],
"p1_decision": p1.decision,
"p2_decision": p2.decision,
"resolution": "depends on combining algorithm"
})
return conflicts
What-If Analysis:
Answer questions about policy behavior:
# OPA REPL for exploration
$ opa run policies/
> data.authorization.allow with input as {"subject": {"role": "admin"}, "action": {"name": "delete"}}
true
> data.authorization.allow with input as {"subject": {"role": "viewer"}, "action": {"name": "delete"}}
false
# Trace evaluation path
> trace data.authorization.allow with input as {...}
Analysis tools are helpful but not infallible. Always combine static analysis with dynamic testing. Edge cases missed by analysis may appear in production. Use defense in depth: analysis → testing → shadow mode → gradual rollout.
Running policy-based authorization in production requires comprehensive monitoring. You need visibility into decision outcomes, performance, errors, and anomalies.
Key Metrics:
| Metric | Description | Alert Threshold Example |
|---|---|---|
| Decision Rate | Requests evaluated per second | Capacity warning at 80% |
| Permit/Deny Ratio | Proportion of permits to denies | Sudden shift > 10% |
| Evaluation Latency | Time to reach decision (p50, p99) | p99 > 10ms |
| Error Rate | Failed evaluations (missing attrs, errors) | 0.1% |
| Cache Hit Rate | Attribute/decision cache effectiveness | < 80% |
| Policy Sync Lag | Time since last policy update | 5 minutes |
Decision Logging:
{
"timestamp": "2024-01-08T14:30:00Z",
"request_id": "req-123456",
"subject": {
"id": "user-alice",
"role": "doctor",
"department": "cardiology"
},
"action": {
"name": "read"
},
"resource": {
"id": "record-789",
"type": "patient_record",
"patient_id": "patient-789"
},
"environment": {
"ip": "10.0.1.50",
"network": "hospital_internal"
},
"decision": "permit",
"policies_evaluated": ["medical.records/allow"],
"evaluation_time_ms": 2.3
}
Privacy Considerations:
Dashboards:
Build dashboards showing:
Real-Time Operations:
Security Monitoring:
Capacity Planning:
Alerting:
# Prometheus alerting rules
alerts:
- alert: HighAuthorizationLatency
expr: histogram_quantile(0.99, rate(authz_decision_duration_seconds_bucket[5m])) > 0.01
for: 5m
labels:
severity: warning
annotations:
summary: "Authorization latency p99 > 10ms"
- alert: AuthorizationErrorRate
expr: rate(authz_decision_errors_total[5m]) / rate(authz_decisions_total[5m]) > 0.001
for: 2m
labels:
severity: critical
annotations:
summary: "Authorization error rate > 0.1%"
Include structured deny reasons in responses (not exposed to users, but logged). This transforms debugging from 'why was this denied?' into 'denied because policy X condition Y failed'. Dramatically reduces troubleshooting time.
We've explored the full lifecycle of policy-based authorization—from design principles through operational monitoring. Let's consolidate the key insights:
What's Next:
We've covered individual authorization patterns in depth. The final page examines Permission Inheritance—how permissions flow through hierarchies of organizations, resources, and entities. Understanding inheritance is critical for multi-tenant systems, document hierarchy applications, and enterprise software with complex organizational structures.
You now understand policy-based authorization end-to-end—design, testing, deployment, governance, and operations. You can implement policy systems that are maintainable, auditable, and operationally sound at scale.