Advanced Architectures for Application Authorization
This section puts a spotlight on various aspects of authorization for real-world applications, and how to address them in Rego.
Dynamic Dispatch for Multiple Applications
As your application, or your application's adoption of OPA for authorization grows, a rigorous structuring of your policy corpus becomes more important.
- authz
|- authz.rego
- filter
|- filter.rego
- app
| |- {appIdentifier}
| | |- authz
| | | |- authz.rego
| | |- filter
| | | |- filter.rego
| | |- ui
| | | |- ui.rego
- libraries
|- libName (e.g. jwt)
| |- jwt
| |- glob
| |- ...
- mandatory
|- mandatory.rego
In our Getting Started example, we have used the input.resource
information to determine the proper authorization policy to route to:
package authz
import rego.v1
default decision := false
decision if {
app_id := app_from_resource(input.resource)
data.app[app_id].authz.allow
}
context["reason"] := "unauthorized" if not decision
app_from_resource(resource) := "store-service" if {
resource.type == "endpoint"
path := split(resource.id, "/")
path[1] == "documents"
count(path) in {2, 3}
}
app_from_resource(resource) := "publish-service" if {
resource.type == "endpoint"
path := split(resource.id, "/")
path[1] == "documents"
path[3] == "published"
count(path) == 4
}
However, this can be duplicated effort: other parts of your application, like its API gateway, already have determined the same thing: Where a request goes to based on its path and HTTP request method.
This information should be injected into the policy decision request payload, and sent along for dispatching into the proper policy.
In scenarios where each application makes a call to OPA itself, the application requesting a policy decision should include its ID right away:
This is also the case when OPA is used as a sidecar to the application services.
Entrypoint Policy with App ID
package authz
import rego.v1
default decision := false
decision if data.app[input.context.appID].authz.allow
context["reason"] := "unauthorized" if not decision
Evaluate
For illustration, we'll use the "store-service" policy from Getting Started:
package app["store-service"].authz
import rego.v1
default allow := false
allow if {
subject_matches(input.subject, {"alice", "bob"})
action_matches(input.action)
}
subject_matches(subject, allowed) if {
subject.type == "user"
subject.id in allowed
}
action_matches(action) if action.name in {"POST", "GET", "PUT", "DELETE"} # CRUD
package tests
import rego.v1
result := output if {
output := data.authz with input.context.appID as "store-service"
with input.action as {"name": "GET"}
with input.subject as {"type": "user", "id": "alice"}
}
Mixing Allows and Denies – Conflict Resolution
For some requirements, both "allow" and "deny" rules can be used to create expressive policies. While in general it's possible to transform one into the other by negation, a fine-grained authorization system gives your policy authors a choice in the matter.
Let's consider an "approval service" that allows users to approve documents if the subject has appropriate permissions. As an exception to that rule, no subject is allowed to approve their own documents.
In effect, we want to achieve that:
POST /documents/1/approve
Content-Type: application/json
{
"input": {
"context": {
"appID": "approval-service",
"data": {
"submitted_by": "bob"
}
},
"subject": {
"id": "alice"
}
}
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"result": {
"decision": true
}
}
and
POST /documents/1/approve
Content-Type: application/json
{
"input": {
"context": {
"appID": "approval-service",
"data": {
"submitted_by": "eve"
}
},
"subject": {
"id": "eve"
}
}
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"result": {
"decision": false,
"context": {
"reason": "self-approval is prohibited"
}
}
}
These are example rules for the general requirement and the exception:
package app["approval-service"].authz
import rego.v1
allowed_subjects := {"alice", "bob", "eve"}
default allow := false
allow if input.subject.id in allowed_subjects
deny contains "self-approval is prohibited" if input.subject.id == input.context.data.submitted_by
The entrypoint policy is responsible for resolving the "conflict": for the second request, both data.app["approval-service"].authz.allow
and data.app["approval-service"].authz.deny
match.
package authz
import rego.v1
default decision := false
decision if {
data.app[input.context.appID].authz.allow # there must be at least one rule that allows the operation
count(data.app[input.context.appID].authz.deny) == 0 # AND there must be no rules that deny the operation
}
context["reason"] := concat(",", deny) if {
deny := data.app[input.context.appID].authz.deny
count(deny) > 0
}
Evaluate
package tests
import rego.v1
result := output if {
output := data.authz with input.context.appID as "approval-service"
with input.subject as {"type": "user", "id": "alice"}
with input.context.data.submitted_by as "bob"
}
package tests
import rego.v1
result := output if {
output := data.authz with input.context.appID as "approval-service"
with input.subject as {"type": "user", "id": "eve"}
with input.context.data.submitted_by as "eve"
}
Mandatory Globally Enforced Policies
This is for policies you want to enforce globally. For example:
Multi-factor authentication (MFA, e.g., username/password and authenticator app) is required for all API requests sent to the "production" deployment.
You can think of these as "sine qua non" requirements. Nothing may be permitted if these are not met.
In our policy structure, a rule like this lives in data.mandatory
(a Rego file in mandatory/
):
package mandatory.mfa # TODO: mandatory/mfa/mfa.rego?
import rego.v1
default allow := false
allow if input.context.data.mfa_active
package authz
import rego.v1
default decision := false
decision if {
data.app[input.context.appID].authz.allow # there must be at least one rule that allows the operation
every rule in data.mandatory {
rule.allow
}
}
Evaluate
package tests
import rego.v1
result := output if {
output := data.authz with input.context.appID as "store-service"
with input.action as {"name": "GET"}
with input.subject as {"type": "user", "id": "alice"}
with input.context.data.mfa_active as true
}
Fallback Decisions
It may be the case that some request come without an application ID. To serve those with a fallback decision, add another rule body to the entrypoint rule:
package authz
import rego.v1
default decision := false
decision if data.app[input.context.appID].authz.allow
decision if {
not "appID" in object.keys(input.context)
data.authz["default"].decision
}
package authz["default"]
import rego.v1
decision if { # for example, allow all health requests
input.resource.type == "endpoint"
input.resource.id == "/health"
}
Evaluate
package tests
import rego.v1
result := output if {
output := data.authz with input.resource as {"type": "endpoint", "id": "/health"}
with input.context as {}
}
Shared Reusable Libraries
Refactor your helper functions into reusable libraries, and put them into libraries/{libName}
.
For example, JWT-related methods would go into libraries/jwt/claims.rego
,
package libraries.jwt
import rego.v1
# METADATA
# description: |
# Decodes the passed JWT token and returns the selected claim(s).
# NOTE: Does not verify the token!
claims(token, key) := val if {
[_, claims, _] := io.jwt.decode(token)
val := claims[key]
}
Evaluate
package tests
import rego.v1
import data.libraries.jwt
# regal ignore:line-length
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWxpY2lhIFNtaXRoc29uaWFuIiwicm9sZXMiOlsicmVhZGVyIiwid3JpdGVyIl0sInVzZXJuYW1lIjoiYWxpY2UifQ.md2KPJFH9OgBq-N0RonGdf5doGYRO_1miN8ugTSeTYc"
roles := jwt.claims(token, "roles")
name := jwt.claims(token, "name")
username := jwt.claims(token, "username")