Access Policies
Access Policies provide fine-grained authorization control beyond SMART scopes. They allow you to define complex access rules using pattern matching and JavaScript-based policies.
Policy Evaluation Flow
Section titled “Policy Evaluation Flow”When a request comes in, the policy engine evaluates access in the following order:
- SMART Scopes Check (if enabled) - Verifies token scopes permit the operation
- Policy Matching - Finds policies that match the request context
- Policy Evaluation - Evaluates matched policies in priority order
- Final Decision - Returns the first definitive decision or applies the default
Request → SMART Scopes → Policy Matching → Policy Evaluation → Decision ↓ ↓ ↓ Deny? No match? Allow/Deny/Abstain ↓ ↓ ↓ STOP Default Decision Continue to next policyAccess Decisions
Section titled “Access Decisions”Each policy evaluation returns one of three possible decisions:
The policy explicitly grants access to the requested operation.
// QuickJS policy exampleif (ctx.user?.roles?.includes("admin")) { return allow();}Behavior: The request is permitted. However, a subsequent policy with higher priority may still deny access.
The policy explicitly rejects access with a reason.
// Deny access outside business hoursconst hour = new Date(ctx.environment.requestTime).getHours();if (hour < 8 || hour > 18) { return deny("Access is only permitted during business hours (8:00-18:00)");}Behavior: The request is immediately rejected. No further policies are evaluated. The denial reason is included in the error response.
Abstain
Section titled “Abstain”The policy cannot make a decision for this request and delegates to the next policy.
// This policy only handles Patient resourcesif (ctx.request.resourceType !== "Patient") { return abstain(); // Let another policy decide}
// Apply Patient-specific rulesif (isPatientUser() && ctx.request.resourceId === ctx.user.fhirId) { return allow();}return deny("You can only access your own Patient record");Behavior: The policy is skipped and the next matching policy is evaluated. This is useful for:
- Conditional policies - Policies that only apply to specific resource types, operations, or user roles
- Delegation - When one policy handles part of the logic and delegates the rest
- Fallback patterns - When the policy cannot determine access based on available context
Decision Priority
Section titled “Decision Priority”Policies are evaluated in priority order (lower number = higher priority). The evaluation follows these rules:
| Decision | Effect |
|---|---|
Deny | Immediate rejection - stops evaluation, request denied |
Allow | Marks approval - continues evaluation (may be overridden by later Deny) |
Abstain | No effect - continues to next policy |
After all policies are evaluated:
- If any policy returned
Allow→ Request is permitted - If no policy returned
Allow→ Default decision applies (denyby default)
Example: Multi-Policy Evaluation
Section titled “Example: Multi-Policy Evaluation”Priority 10: Admin Policy → Abstain (user is not admin)Priority 20: Department Policy → Allow (user is in cardiology dept)Priority 30: Audit Policy → Abstain (just logs, doesn't decide)Priority 40: Rate Limit Policy → Deny (too many requests)
Result: DENIED (first Deny wins, even though there was an Allow)Policy Engine Types
Section titled “Policy Engine Types”Allow Engine
Section titled “Allow Engine”Always grants access. Use for broad permissions.
{ "id": "allow-practitioners", "name": "Allow all practitioners", "engine": { "type": "allow" }, "matcher": { "roles": ["practitioner"] }}Deny Engine
Section titled “Deny Engine”Always denies access. Use for explicit blocks.
{ "id": "block-delete", "name": "Block all delete operations", "engine": { "type": "deny" }, "denyMessage": "Delete operations are not permitted", "matcher": { "operations": ["delete"] }}QuickJS Engine
Section titled “QuickJS Engine”JavaScript-based policies for complex logic.
{ "id": "patient-compartment", "name": "Patient compartment access", "engine": { "type": "quickjs", "script": "if (isPatientUser()) { return inPatientCompartment() ? allow() : deny('Access denied outside your patient compartment'); } return abstain();" }}QuickJS Helper Functions
Section titled “QuickJS Helper Functions”The following functions are available in QuickJS policy scripts:
| Function | Description |
|---|---|
allow() | Return an allow decision |
deny(reason) | Return a deny decision with the specified reason |
abstain() | Return an abstain decision (delegate to next policy) |
hasRole(role) | Check if user has a specific role |
hasAnyRole(...roles) | Check if user has any of the specified roles |
isPatientUser() | Check if user’s FHIR type is Patient |
isPractitionerUser() | Check if user’s FHIR type is Practitioner |
inPatientCompartment() | Check if request is within user’s patient compartment |
console.log/warn/error | Logging (mapped to server tracing) |
Policy Context
Section titled “Policy Context”QuickJS scripts have access to the full request context via the ctx object:
ctx = { user: { id: "user-123", name: "John Doe", email: "john@example.com", roles: ["practitioner", "admin"], fhirType: "Practitioner", fhirId: "Practitioner/456" }, client: { id: "client-abc", name: "My SMART App", trusted: false, clientType: "public" }, scopes: { raw: "patient/Patient.rs user/Observation.cruds", patientScopes: ["patient/Patient.rs"], userScopes: ["user/Observation.cruds"], systemScopes: [], hasWildcard: false }, request: { operation: "read", // create, read, update, delete, search, etc. resourceType: "Patient", resourceId: "123", method: "GET", path: "/Patient/123", queryParams: {} }, environment: { requestTime: "2024-01-15T10:30:00Z", sourceIp: "192.168.1.100", requestId: "req-xyz", patientContext: "Patient/123", encounterContext: null }}Configuration
Section titled “Configuration”Configure the policy engine in octofhir.toml:
[auth.policy]# Enable QuickJS scripting enginequickjs_enabled = true
# Default decision when no policy matches# Options: "deny" (secure default) or "allow"default_decision = "deny"
# Evaluate SMART scopes before policies# If false, policies handle all authorizationevaluate_scopes_first = true
[auth.policy.quickjs]# Runtime pool size (for parallel evaluation)pool_size = 4
# Memory limit per runtime (MB)memory_limit_mb = 8
# Stack size limit (KB)max_stack_size_kb = 256
# Script execution timeout (ms)timeout_ms = 100Best Practices
Section titled “Best Practices”1. Use Abstain for Conditional Logic
Section titled “1. Use Abstain for Conditional Logic”Don’t force every policy to make a decision. Use abstain() when the policy doesn’t apply:
// Good: Only handle what this policy is responsible forif (ctx.request.resourceType !== "Observation") { return abstain();}// ... Observation-specific logic
// Bad: Forcing a decision on everythingif (ctx.request.resourceType === "Observation") { // ... logic} else { return allow(); // Dangerous! Allows everything else}2. Order Policies by Specificity
Section titled “2. Order Policies by Specificity”- Lower priority numbers = evaluated first
- Put specific deny rules early (e.g., rate limiting, blocked users)
- Put general allow rules later
Priority 10: Block banned users (Deny)Priority 20: Rate limiting (Deny if exceeded)Priority 50: Role-based access (Allow/Abstain)Priority 100: Default patient compartment (Allow/Deny)3. Provide Clear Deny Messages
Section titled “3. Provide Clear Deny Messages”Help users understand why access was denied:
// Goodreturn deny("You can only access patients assigned to your care team");
// Badreturn deny("Access denied");4. Keep Scripts Simple
Section titled “4. Keep Scripts Simple”Complex logic should be split across multiple policies:
// Good: Single responsibility// Policy 1: Check department access// Policy 2: Check time-based restrictions// Policy 3: Check patient compartment
// Bad: Everything in one scriptif (hasRole("doctor") && hour >= 8 && hour <= 18 && ...) { ... }Troubleshooting
Section titled “Troubleshooting”Policy Not Being Evaluated
Section titled “Policy Not Being Evaluated”- Check if the policy is
active: true - Verify the
matcherconditions match your request - Check policy priority order
Unexpected Denials
Section titled “Unexpected Denials”- Enable debug logging:
RUST_LOG=octofhir_auth::policy=debug - Use
evaluate_with_audit()to see which policies were evaluated - Check if SMART scope check is denying before policies run
QuickJS Script Errors
Section titled “QuickJS Script Errors”- Check server logs for script execution errors
- Verify script syntax is valid JavaScript
- Ensure you’re returning a decision (
allow(),deny(), orabstain()) - Check timeout configuration if scripts are timing out