Skip to main content

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")