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 definitions
  • jwt-public.pem - JWT public key for token validation
  • config.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

  1. If any forbid policy matches → Deny
  2. Else if any permit policy matches → Allow
  3. 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:

  1. Cedar is enabled but policies are too restrictive
  2. Policy file not found or invalid syntax
  3. Token claims don't match policy conditions
  4. 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 = true temporarily to debug (development only)
  • Add logging to see which policies are evaluated

500 Internal Server Error

Symptom: Requests return 500 errors

Possible Causes:

  1. Policy file syntax errors (invalid Cedar)
  2. Policy evaluation errors (missing attributes)
  3. 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 = false to 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_path points at the file you are editing

Future: Automatic file watching and hot-reload will be implemented soon.

Performance Tips

  1. Enable caching: Reduces latency by 90% (10-50ms → 1-5ms)
  2. Use simple policies: Complex conditions increase evaluation time
  3. Cache warm-up: First requests may be slower as cache populates
  4. Monitor cache hit rate: Aim for >80% hit rate in production
  5. Optimize policy order: Put most common permits first
  6. 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-ip when 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

  1. Always use fail-closed in production: fail_open = false
  2. Use PASETO by default: Secure by design, no algorithm confusion attacks
  3. Principle of least privilege: Only grant necessary permissions
  4. Audit policies regularly: Review and update policies quarterly
  5. Use forbid for sensitive operations: Explicit denials are safer than implicit
  6. Secure any policy reload route: If you expose a route that calls CedarAuthz::reload_policies(), restrict it to admin-only access
  7. Secure policy files: Restrict file permissions (automatic hot-reload in progress)
  8. Test policy changes: Validate in staging before production deployment
  9. Monitor authorization decisions: Track allow/deny rates and investigate anomalies
  10. 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

  1. Add more policies: Extend the example with your use cases
  2. Integrate with database: Load resource attributes from DB
  3. Implement policy management API: CRUD operations for policies
  4. Add policy testing: Unit tests for Cedar policies
  5. Monitor policy decisions: Track allow/deny metrics
  6. Implement policy versioning: Deploy policies with rollback capability

References

Previous
OAuth/OIDC