Attribute-based authorization for ASP.NET Core using Open Policy Agent (OPA). This package provides an [OpaAuthorize] attribute for controllers and methods to enable policy-based authorization decisions.
$ dotnet add package OpenPolicyAgent.Opa.AuthorizationA .NET NuGet package that provides attribute-based authorization for ASP.NET Core using Open Policy Agent (OPA).
[OpaAuthorize] attribute on controllers and methodsInstall the package via NuGet:
dotnet add package OpenPolicyAgent.Opa.Authorization
In your Program.cs or Startup.cs:
using OpenPolicyAgent.Opa.Authorization;
var builder = WebApplication.CreateBuilder(args);
// Add authentication (required)
builder.Services.AddAuthentication(/* your authentication configuration */);
// Add OPA authorization
builder.Services.AddOpaAuthorization(options =>
{
options.OpaUrl = "http://localhost:8181";
options.DefaultPolicyPath = "authz";
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Apply the [OpaAuthorize] attribute to your controllers or actions:
using Microsoft.AspNetCore.Mvc;
using OpenPolicyAgent.Opa.Authorization;
[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
// Uses the default policy path configured in options
[OpaAuthorize]
[HttpGet]
public IActionResult GetAll()
{
return Ok(new[] { "document1", "document2" });
}
// Uses a custom policy path for this specific action (legacy behavior)
[OpaAuthorize(PolicyPath = "authz/documents")]
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
return Ok($"document{id}");
}
// Specifies multiple policies (new behavior)
// Access is granted if the OPA policy allows it based on any of these policy names
[OpaAuthorize("policy.read", "policy.admin")]
[HttpPost]
public IActionResult Create([FromBody] object document)
{
return Created("", document);
}
}
Create a Rego policy file (e.g., policy.rego) using modern Rego syntax:
package authz
import rego.v1
# Default deny - always deny by default for security
default allow := false
# Allow if the request matches any of the required policies
allow if {
some policy in input.policies
policy == "policy.read"
input.action.operation == "GET"
}
allow if {
some policy in input.policies
policy == "policy.admin"
has_role("admin")
}
# Helper function to check if user has a specific role
# Checks both groups (recommended) and claims (backward compatible)
has_role(role) if {
some group in input.context.identity.groups
group == role
}
has_role(role) if {
some claim in input.context.identity.claims
claim.type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
claim.value == role
}
Note: This example uses modern Rego syntax with:
import rego.v1 for future-proof policiesif keyword for clearer rule definitions:= for explicit assignmentsome for explicit iterationFor more information, see the OPA Policy Language documentation and Rego Cheat Sheet.
Start an OPA server with your policy:
opa run --server --addr localhost:8181 policy.rego
builder.Services.AddOpaAuthorization(options =>
{
// OPA server URL (default: http://localhost:8181)
options.OpaUrl = "http://localhost:8181";
// Default policy path to evaluate (optional)
// This should be the package path (e.g., "authz") not the rule path (e.g., "authz/allow")
options.DefaultPolicyPath = "authz";
// Preferred language key for access denial reasons (default: "en")
options.ReasonKey = "en";
// Allow unauthenticated requests (default: false)
options.AllowUnauthenticated = false;
// Include authorization token in OPA input (default: false)
// When enabled, the Authorization header value is included in input.subject.token
options.IncludeAuthorizationToken = false;
// Request timeout for OPA calls (default: 30 seconds)
options.RequestTimeout = TimeSpan.FromSeconds(30);
// Require HTTPS for OPA URL (default: false)
// When enabled, non-HTTPS URLs will cause validation errors
options.RequireHttps = false;
// Control which headers are sent to OPA (default: true)
options.IncludeHeaders = true;
// Customize which headers to exclude (default includes: Authorization, Cookie, X-API-Key, X-Auth-Token)
options.ExcludedHeaders.Add("Custom-Sensitive-Header");
// Or clear and start fresh:
// options.ExcludedHeaders.Clear();
// Customize which claim types are treated as groups (default includes standard role claim types)
// These claims will be extracted and included in input.context.identity.groups in OPA policies
options.GroupClaimTypes.Add("custom-group-claim");
// Or replace the defaults entirely:
// options.GroupClaimTypes = new HashSet<string> { "custom-role", "custom-group" };
// Disable OPA authorization entirely (default: false)
// When enabled, no calls are made to the OPA server and all authorization attempts are logged locally
// Useful for development and debugging
options.DisableAuthorization = false;
});
You can also configure the OPA URL via environment variable:
export OPA_URL=http://opa-server:8181
During development, you may want to disable OPA authorization entirely to simplify testing and debugging. When disabled, no calls are made to the OPA server, and all authorization attempts are logged locally with information about what would have been sent to OPA.
builder.Services.AddOpaAuthorization(options =>
{
options.DisableAuthorization = true; // Disable OPA entirely
options.DefaultPolicyPath = "authz"; // Other options can still be set
});
When DisableAuthorization is enabled:
Information level with:
Example log output:
[Information] OPA Authorization is DISABLED. All authorization requests will be logged and allowed.
[Information] OPA Authorization is DISABLED. Resource: /api/documents/123, Action: GET, Decision: Disabled - No authorization performed
Use cases:
Important: This option should never be enabled in production environments. Always ensure DisableAuthorization is set to false (or omitted, as it defaults to false) in production configurations.
Add OPA connectivity health checks to your application:
builder.Services.AddHealthChecks()
.AddOpaHealthCheck(
name: "opa",
tags: new[] { "ready", "opa" });
// In your pipeline
app.MapHealthChecks("/health/ready");
The health check verifies that the OPA server is reachable and responding.
Inject additional context data into OPA evaluation:
public class CustomContextDataProvider : IOpaContextDataProvider
{
public object GetContextData(HttpContext context)
{
return new
{
tenant_id = context.Request.Headers["X-Tenant-Id"].ToString(),
request_time = DateTime.UtcNow
};
}
}
// Register the provider
builder.Services.AddOpaContextDataProvider<CustomContextDataProvider>();
This data will be available under input.context.data in your OPA policy.
The package sends the following input to OPA (inspired by Trino's OPA integration, adapted for .NET/ASP.NET Core):
{
"context": {
"identity": {
"user": "<user identity name>",
"claims": [/* array of user claims with type, value, valueType, issuer */],
"groups": [/* array of role values extracted from claims */],
"token": "<authorization header value, if IncludeAuthorizationToken is enabled>"
},
"requestId": "<unique request identifier (trace ID)>",
"softwareStack": {
"framework": "aspnetcore",
"runtimeVersion": "<.NET runtime version>"
},
"http": {
"host": "<request host>",
"ip": "<remote IP address>",
"port": <remote port>
},
"data": {/* custom context data, if provider registered */},
"metadata": "<extra information from attribute, if provided>"
},
"action": {
"operation": "<HTTP method>",
"resource": {
"endpoint": {
"path": "<request path>",
"type": "endpoint"
}
},
"protocol": "<HTTP protocol>",
"headers": {/* request headers */}
},
"policies": [/* array of policy names from attribute */]
}
Note:
token field in context.identity is only included when IncludeAuthorizationToken is set to true in the options and an Authorization header is present.metadata field is only included when using ExtaInformation property in [OpaAuthorize].headers field in action respects the IncludeHeaders and ExcludedHeaders configuration. By default, sensitive headers like Authorization, Cookie, X-API-Key, and X-Auth-Token are excluded.The package expects the following response from OPA:
{
"allow": true,
"reason": "Access granted" // or {"en": "Access granted", "es": "Acceso concedido"}
}
Important:
allow rule that returns a boolean valuereason field is optional but recommended for providing denial explanationsresult (e.g., {"result": {"allow": true, "reason": "..."}})result objectdecision_log, debug_info) are ignored but won't cause errorsSee the samples directory for complete working examples, including:
samples/SampleWebApi/policies/debug_policy.rego for an example that logs complete call information for troubleshootingThe sample includes a comprehensive debug policy (debug_policy.rego) that logs all evaluation details:
package authz.debug
import rego.v1
# Returns comprehensive decision log with all input data
decision_log := {
"timestamp": time.now_ns(),
"subject": {
"id": input.subject.id,
"claims_count": count(input.subject.claims),
},
"resource": {
"id": input.resource.id,
"type": input.resource.type,
},
"action": {
"name": input.action.name,
},
"evaluation": {
"matched_rules": matched_rules,
"user_roles": user_roles,
"is_authenticated": is_authenticated,
"is_admin": is_admin,
},
}
Note: The decision_log does not include allow and reason to avoid circular references. These are separate top-level fields in the policy response.
To use the debug policy:
392: 1. Configure your endpoint to use the debug policy path:
393: csharp 394: [OpaAuthorize(PolicyPath = "authz/debug")] 395: [HttpGet] 396: public IActionResult GetDocument() { ... } 397:
Query the decision log alongside your allow decision:
curl -X POST http://localhost:8181/v1/data/authz/debug \
-H 'Content-Type: application/json' \
-d @input.json
Enable OPA decision logging for automatic audit trails:
opa run --server --addr localhost:8181 \
--set decision_logs.console=true \
policy.rego
Use print() statements during development:
allow if {
print("Checking user:", input.subject.id)
print("User roles:", user_roles)
has_role("admin")
}
Test policies with sample inputs:
opa eval -d policy.rego -i input.json 'data.authz.allow'
Enable verbose logging in this package:
{
"Logging": {
"LogLevel": {
"OpenPolicyAgent.Opa.Authorization": "Debug"
}
}
}
By default, sensitive headers are excluded from being sent to OPA:
AuthorizationCookieX-API-KeyX-Auth-TokenYou can customize this list via options.ExcludedHeaders or disable header inclusion entirely with options.IncludeHeaders = false.
For production environments, consider enabling HTTPS enforcement:
options.RequireHttps = true;
This ensures that the OPA URL uses HTTPS, preventing credentials or sensitive data from being transmitted over unencrypted connections.
When IncludeAuthorizationToken is enabled, be aware that the authorization token will be sent to OPA. Ensure:
The package uses structured logging at different levels:
Trace: Detailed OPA evaluation informationDebug: OPA input/output (may contain sensitive data - disable in production)Information: Authorization decisionsWarning: Configuration or connectivity issuesError: Failures and exceptionsImportant: Debug and Trace level logging may expose sensitive information. Configure log levels appropriately for your environment.
Symptom: Authorization always fails with HTTP connection errors.
Solutions:
curl http://localhost:8181/healthbuilder.Services.AddHealthChecks().AddOpaHealthCheck()Symptom: "Error evaluating OPA policy" in logs.
Solutions:
curl http://localhost:8181/v1/policiescurl -X POST http://localhost:8181/v1/data/authz \
-H 'Content-Type: application/json' \
-d '{"input": {...}}'
Symptom: "Timeout communicating with OPA server" errors.
Solutions:
options.RequestTimeout = TimeSpan.FromSeconds(60)Symptom: Users are denied access when they should be allowed.
Solutions:
AllowUnauthenticated should be enabled{"allow": true} (not {"result": true})Symptom: "Could not convert bool result to type OpenPolicyAgent.Opa.Authorization.OpaResponse" error in logs.
Cause: This occurs when the policy path points to a specific rule (e.g., authz/allow) instead of the package (e.g., authz).
Solution:
Change your policy path from authz/allow to authz:
options.DefaultPolicyPath = "authz"; // Correct - queries the package
// NOT: options.DefaultPolicyPath = "authz/allow"; // Wrong - queries only the rule
Your OPA policy package should contain allow and optionally reason fields:
package authz
default allow := false
allow if {
# your rules
}
reason["en"] := "Access denied" if {
not allow
}
When querying /v1/data/authz, OPA returns:
{"result": {"allow": true, "reason": {"en": "..."}}}
But when querying /v1/data/authz/allow, OPA returns:
{"result": true} // Just the boolean value
The library expects the full object structure with allow and reason fields, not just a boolean.
Symptom: Headers are missing in the OPA policy input.
Solutions:
IncludeHeaders is set to true (default)ExcludedHeaders listThe package does not include built-in caching of OPA decisions. For high-traffic applications, consider:
This package is built on top of:
MIT
Contributions are welcome! Please feel free to submit a Pull Request.