Middleware & Auth
Cedar Authorization
New to acton-service?
Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See the Glossary for technical term definitions.
acton-service integrates AWS Cedar for declarative, policy-based authorization. Define who can do what with which resources using human-readable policy files.
What You'll Learn
- Policy-based access control with admin vs user roles
- Resource ownership patterns (users can only access their own documents)
- Custom path normalization for alphanumeric IDs
- Layered security with token authentication (PASETO/JWT) + Cedar authorization
- Optional Redis caching for sub-5ms policy decisions
Quick Start
cargo run --manifest-path=acton-service/Cargo.toml --example cedar-authz --features cedar-authz,cache
The example automatically creates configuration files in ~/.config/acton-service/cedar-authz-example/:
policies.cedar- Cedar policy definitionsjwt-public.pem- JWT public key for token validationconfig.toml- Service configuration
Server starts on http://localhost:8080
Optional: Enable Policy Decision Caching
For faster policy decisions (1-5ms instead of 10-50ms), start Redis:
docker run -d -p 6379:6379 redis:latest
Without Redis, policy evaluation is still perfectly usable at 10-50ms latency.
Testing Authorization
Step 1: Verify Health Endpoints (No Auth Required)
# Health check - should return 200 OK
curl http://localhost:8080/health
# Readiness check - should return 200 OK
curl http://localhost:8080/ready
Step 2: Test Without Authentication (Should Fail)
# Try to access documents without a token - should return 401 Unauthorized
curl http://localhost:8080/api/v1/documents
Step 3: Generate Test JWT Tokens
Install PyJWT for token generation:
# Create virtual environment and install PyJWT
uv venv .venv
source .venv/bin/activate
uv pip install pyjwt cryptography
Generate tokens with Python:
import jwt
from datetime import datetime, timedelta, UTC
# Read the JWT private key (included in examples/)
with open("acton-service/examples/jwt-private.pem", "r") as f:
private_key = f.read()
# Generate USER token (regular user)
user_payload = {
"sub": "user:123",
"username": "alice",
"email": "alice@example.com",
"roles": ["user"], # Regular user role
"perms": ["read:documents", "write:documents"],
"exp": int((datetime.now(UTC) + timedelta(hours=1)).timestamp()),
"iat": int(datetime.now(UTC).timestamp()),
"jti": "test-user-token"
}
user_token = jwt.encode(user_payload, private_key, algorithm="RS256")
print("USER TOKEN:")
print(user_token)
print()
# Generate ADMIN token (admin user)
admin_payload = {
"sub": "user:456",
"username": "bob",
"email": "bob@example.com",
"roles": ["user", "admin"], # Admin role
"perms": ["read:documents", "write:documents", "admin:all"],
"exp": int((datetime.now(UTC) + timedelta(hours=1)).timestamp()),
"iat": int(datetime.now(UTC).timestamp()),
"jti": "test-admin-token"
}
admin_token = jwt.encode(admin_payload, private_key, algorithm="RS256")
print("ADMIN TOKEN:")
print(admin_token)
Save the tokens for testing:
export USER_TOKEN="<paste-user-token-here>"
export ADMIN_TOKEN="<paste-admin-token-here>"
Step 4: Test Cedar Authorization Policies
Test 1: User can list documents ✅
curl -H "Authorization: Bearer $USER_TOKEN" \
http://localhost:8080/api/v1/documents
# Expected: 200 OK with documents array
# [{"id":"doc1","owner_id":"user123","title":"My Document",...},...]
Test 2: User CANNOT access admin endpoint ❌
curl -H "Authorization: Bearer $USER_TOKEN" \
http://localhost:8080/api/v1/admin/users
# Expected: 403 Forbidden
# {"error":"Access denied by policy","code":"FORBIDDEN","status":403}
Test 3: Admin CAN access admin endpoint ✅
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:8080/api/v1/admin/users
# Expected: 200 OK with users array
# [{"id":"user123","username":"alice","roles":["user"]},...]
Test 4: User can create documents ✅
curl -X POST \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"doc-new","owner_id":"user123","title":"New Document","content":"Test"}' \
http://localhost:8080/api/v1/documents
# Expected: 200 OK with created document
# {"id":"doc-new","owner_id":"user123",...}
Test 5: Get specific document (Ownership check)
curl -H "Authorization: Bearer $USER_TOKEN" \
http://localhost:8080/api/v1/documents/user123/doc1
# Expected: 200 OK if user:123 matches the user_id in path
Test 6: Update document (Owner only)
curl -X PUT \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"doc1","owner_id":"user123","title":"Updated","content":"New"}' \
http://localhost:8080/api/v1/documents/user123/doc1
# Expected: 200 OK if user owns the document
Test 7: Delete document (Owner or admin)
curl -X DELETE \
-H "Authorization: Bearer $USER_TOKEN" \
http://localhost:8080/api/v1/documents/user123/doc1
# Expected: 200 OK if user owns the document
Cedar Policy Explanation
The example policies demonstrate common authorization patterns:
1. Admin Override
permit(principal, action, resource)
when { principal.roles.contains("admin") };
Admins bypass all restrictions and can perform any action on any resource.
2. Resource Listing
permit(
principal,
action == Action::"GET /api/v1/documents",
resource
);
Any authenticated user can list documents. No ownership check required for browsing.
3. Ownership-based Access
permit(
principal,
action in [Action::"GET /api/v1/documents/{user_id}/{doc_id}", ...],
resource
)
when { principal.sub == resource.owner_id };
Users can only access documents they own. The owner_id attribute from the resource must match the principal's sub claim.
4. Forbid with Unless (Restrictive)
forbid(
principal,
action == Action::"GET /api/v1/admin/users",
resource
)
unless { principal.roles.contains("admin") };
Explicitly deny admin endpoints to non-admin users. More restrictive than permit-only policies.
How It Works
Request Flow
Client Request
↓
Token Authentication (validates PASETO/JWT token, extracts claims)
↓
Cedar Authorization (evaluates policies)
↓
Your Handler
Cedar Evaluation Model
Cedar evaluates each request using four components:
Principal (who)
- Extracted from token claims:
sub,roles,perms,username,email - Represents the authenticated user or service making the request
Action (what)
- HTTP method + normalized path
- Examples:
GET /api/v1/documents/{user_id}/{doc_id},POST /api/v1/documents
Resource (which)
- Path parameters or request body attributes
- Examples:
owner_id,document_id,user_id
Context (when/where)
- Request metadata:
ip_address,timestamp,user_agent - Environmental factors for conditional policies
Decision Logic
- If any
forbidpolicy matches → Deny - Else if any
permitpolicy matches → Allow - Otherwise → Deny (default deny)
Caching (Optional)
Redis caching reduces policy evaluation latency from 10-50ms to 1-5ms:
- Cache key: Hash of principal, action, resource, context
- Default TTL: 5 minutes (configurable)
- Automatic invalidation on policy reload
- Significant performance improvement for high-traffic endpoints
Configuration Options
[cedar]
enabled = true # Enable/disable Cedar authorization
policy_path = "path/to/policies.cedar" # Path to policy file
hot_reload = false # [IN PROGRESS] Automatic policy file watching
hot_reload_interval_secs = 60 # [IN PROGRESS] Check interval for hot-reload
cache_enabled = true # Enable policy decision caching
cache_ttl_secs = 300 # Cache TTL in seconds
fail_open = false # true = allow on errors, false = deny on errors
Note: Automatic hot-reload is currently in progress. To reload policies without restarting the service, call CedarAuthz::reload_policies() from a route you expose yourself — see Reloading Policies at Runtime.
Reloading Policies at Runtime
acton-service does not ship a built-in reload endpoint. It exposes the CedarAuthz::reload_policies() method, which re-reads and re-parses the policy file at policy_path and atomically swaps in the new policy set. Wire it to a route you own, so you control the path, the auth requirements, and the response shape:
use std::sync::Arc;
use acton_service::prelude::*;
use acton_service::middleware::CedarAuthz;
use axum::{Extension, extract::State, routing::post, Router};
async fn reload_policies(
State(cedar): State<Arc<CedarAuthz>>,
Extension(claims): Extension<Claims>,
) -> Result<&'static str, Error> {
// Protect the route: only admins may reload policies.
if !claims.has_role("admin") {
return Err(Error::Forbidden("admin role required".to_string()));
}
cedar.reload_policies().await?;
Ok("policies reloaded")
}
let cedar = CedarAuthz::builder(config.cedar.clone()).build().await?;
let cedar = Arc::new(cedar);
let routes = Router::new()
.route("/admin/reload-policies", post(reload_policies))
.with_state(cedar.clone());
let service = ServiceBuilder::new()
.with_config(config)
.with_routes(routes)
.with_cedar((*cedar).clone())
.build();
Because the route is yours, you can equally protect it with a Cedar policy instead of the inline role check, restrict it to an internal listener, or omit it entirely and reload by restarting the service.
Fail-Open vs Fail-Closed
Fail-Closed (Recommended for Production)
fail_open = false
- Deny requests if policy evaluation fails
- More secure - prevents accidental access during errors
- May cause downtime if policies are misconfigured
- Always use in production environments
Fail-Open (Development Only)
fail_open = true
- Allow requests if policy evaluation fails
- Less secure - grants access during errors
- Useful for debugging policy issues
- Never use in production
Custom Path Normalization
acton-service supports customizable path normalization to handle various ID formats:
A path normalizer is a plain fn(&str) -> String. It receives the request path and returns the normalized form used to build the Cedar resource. For most services, register it directly on the ServiceBuilder — Cedar is auto-configured from [cedar] in your config, and the policy file comes from policy_path:
use acton_service::prelude::*;
// Normalizes alphanumeric document IDs:
// /api/v1/documents/user123/doc1 -> /api/v1/documents/{user_id}/{doc_id}
fn normalize_document_paths(path: &str) -> String {
let pattern = regex::Regex::new(
r"^(/api/v[0-9]+/documents/)([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$"
).unwrap();
if let Some(caps) = pattern.captures(path) {
return format!("{}{{user_id}}/{{doc_id}}", &caps[1]);
}
path.to_string()
}
let service = ServiceBuilder::new()
.with_config(config)
.with_routes(routes)
.with_cedar_path_normalizer(normalize_document_paths)
.build();
If you need to construct the CedarAuthz instance yourself — for example to attach a policy cache — build it from your CedarConfig and hand it to the builder. Note that build() is async, since it reads the policy file from policy_path:
use acton_service::middleware::CedarAuthz;
let cedar = CedarAuthz::builder(config.cedar.clone())
.with_path_normalizer(normalize_document_paths)
.build()
.await?;
let service = ServiceBuilder::new()
.with_config(config)
.with_routes(routes)
.with_cedar(cedar)
.build();
Common Patterns:
- UUID IDs:
/api/v1/documents/550e8400-e29b-41d4-a716-446655440000→/api/v1/documents/{id} - Numeric IDs:
/api/v1/users/12345→/api/v1/users/{id} - Slug IDs:
/api/v1/posts/my-blog-post→/api/v1/posts/{slug}
Troubleshooting
403 Forbidden
Symptom: All requests return 403 Forbidden
Possible Causes:
- Cedar is enabled but policies are too restrictive
- Policy file not found or invalid syntax
- Token claims don't match policy conditions
- Default deny with no matching permit policies
Solutions:
- Check logs for Cedar evaluation details
- Verify policy file exists and is valid Cedar syntax
- Ensure token contains required claims (
roles,sub, etc.) - Set
fail_open = truetemporarily to debug (development only) - Add logging to see which policies are evaluated
500 Internal Server Error
Symptom: Requests return 500 errors
Possible Causes:
- Policy file syntax errors (invalid Cedar)
- Policy evaluation errors (missing attributes)
- Cache connection issues (if Redis enabled)
Solutions:
- Check logs for policy parsing errors
- Validate policy syntax with Cedar CLI tools
- Verify Redis is running (if cache enabled)
- Test with
cache_enabled = falseto isolate issue
Policy Not Reloading
Symptom: Policy changes don't take effect
Current Status: Automatic hot-reload is in progress. Policies must be reloaded manually.
Solutions:
- Restart the service to load updated policies
- Or expose your own admin-only route that calls
CedarAuthz::reload_policies()— see Reloading Policies at Runtime - Check file permissions on policy file (must be readable)
- Confirm
policy_pathpoints at the file you are editing
Future: Automatic file watching and hot-reload will be implemented soon.
Performance Tips
- Enable caching: Reduces latency by 90% (10-50ms → 1-5ms)
- Use simple policies: Complex conditions increase evaluation time
- Cache warm-up: First requests may be slower as cache populates
- Monitor cache hit rate: Aim for >80% hit rate in production
- Optimize policy order: Put most common permits first
- Use forbid sparingly: Permit-based policies are typically faster
Audit Integration
When the audit feature is enabled, the Cedar middleware automatically emits an AuthPermissionDenied audit event at Warning severity whenever a policy decision is Deny. Both the HTTP middleware and the gRPC tower service emit the event.
The event records:
- The authenticated subject (from JWT
sub) - Client user-agent and request-id (from headers for HTTP; gRPC metadata is carried in the same headers)
- Client IP (from
x-forwarded-for/x-real-ipwhen present)
No additional code is required — emission is on by default when audit_auth_events: true is set in the audit configuration (the default). See Audit Logging for storage and SIEM export.
Set audit_auth_events: false in your audit configuration if you want to suppress these events — for example, in environments where Cedar denies are expected and noisy and you'd rather route them through application logs instead of the audit chain.
Security Best Practices
- Always use fail-closed in production:
fail_open = false - Use PASETO by default: Secure by design, no algorithm confusion attacks
- Principle of least privilege: Only grant necessary permissions
- Audit policies regularly: Review and update policies quarterly
- Use forbid for sensitive operations: Explicit denials are safer than implicit
- Secure any policy reload route: If you expose a route that calls
CedarAuthz::reload_policies(), restrict it to admin-only access - Secure policy files: Restrict file permissions (automatic hot-reload in progress)
- Test policy changes: Validate in staging before production deployment
- Monitor authorization decisions: Track allow/deny rates and investigate anomalies
- Version control policies: Track policy changes in git for audit trail
Integration Patterns
Token Auth + Cedar Layered Security
Token authentication and Cedar authorization are automatically applied by ServiceBuilder when both are configured:
# config.toml
[token]
format = "paseto"
version = "v4"
purpose = "local"
key_path = "./keys/paseto.key"
[cedar]
enabled = true
policy_path = "policies.cedar"
Token authentication provides authentication (who you are), Cedar provides authorization (what you can do).
gRPC Support
With the grpc feature, the same configuration that protects HTTP routes protects gRPC services — no per-service wiring required. When [token] is configured, ServiceBuilder applies token authentication to all registered gRPC services (validating the authorization metadata and injecting Claims), and when [cedar] is enabled it authorizes every method as Action::"/package.Service/Method":
permit(
principal,
action == Action::"/hello.v1.HelloService/SayHello",
resource
) when { context.roles.contains("user") };
Health (grpc.health.v1.Health) and reflection services are exempt so infrastructure probes work without credentials, and public_paths prefixes in the token configuration are honored for intentionally public methods. Denials are returned as gRPC statuses (UNAUTHENTICATED, PERMISSION_DENIED), not transport errors.
For manual stack composition, both layers are available as HTTP-level tower layers that forward NamedService, so wrapped services register directly with GrpcServicesBuilder::add_service:
use acton_service::grpc::GrpcTokenAuthLayer;
use acton_service::middleware::cedar::{CedarAuthz, CedarAuthzLayer};
use tower::Layer;
let cedar = CedarAuthz::builder(cedar_config).build().await?;
let services = GrpcServicesBuilder::new()
.add_service(
// Auth outermost: it injects the Claims that Cedar authorizes.
GrpcTokenAuthLayer::new(paseto_auth).layer(
CedarAuthzLayer::new(cedar).layer(MyServiceServer::new(svc)),
),
)
.build(None);
See the runnable cedar-grpc example for an end-to-end demonstration with test tokens and grpcurl commands.
Next Steps
- Add more policies: Extend the example with your use cases
- Integrate with database: Load resource attributes from DB
- Implement policy management API: CRUD operations for policies
- Add policy testing: Unit tests for Cedar policies
- Monitor policy decisions: Track allow/deny metrics
- Implement policy versioning: Deploy policies with rollback capability
References
- Cedar Policy Language Documentation
- Cedar Rust Crate Documentation
- Token Authentication - Configure PASETO/JWT authentication
- Redis Caching - Enable policy decision caching