Skip to main content

Application Authorization

Standardizing application authorization logic for applications improves security posture, creates a more consistent user experience and saves engineering time. This section contains guides for IAM Engineers and Architects which demonstrate how to standardize authorization logic across their applications using either Enterprise OPA or Open Policy Agent (OPA).

Readers will get the best value out of these guides if they have at least an introductory knowledge of OPA and Rego basics (see Recommended Reading).

Common Model

While authorization models are varied, the basic building blocks of access control decisions commonly refer to subject, action, and resource.

Subject

The actor of an access control decision: a human operating a browser, or a program using an API (e.g. "alice", "bob").

Resource

The object that is acted on (e.g. "picture", "profile", "bank account").

Action

The act of the access control decision: how a resource is referred to (e.g. "read", "delete", "update").

This model is proven to adapt well to many different use cases. We recommend that you use a similar model in your own applications.

API Authorization: Your First Model

Service Interaction

We recommend starting with API-based authorization for applications. With this architecture, the calling application requests authorization decisions from OPA for API calls made to the application.

In the examples that follow, we use HTTP requests to show messages. However, the same ideas are easily translated to a range of different RPC methods and protocols.

Now that you understand the flow of requests across your application's service lifecycle, you need to zoom in on the call from the application to OPA for decision requests and responses.

An example HTTP request to OPA would look like:

POST /v1/data/{path:.+}
Content-Type: application/json
{
"input": {
// Decision Request Input
}
}

And a sample response from OPA would look like:

HTTP/1.1 200 OK
Content-Type: application/json
{
"result": {
// Decision Response Output
}
}

These show three architectural decisions to be made:

  1. Decision Request Input Schema
  2. Decision Response Output Schema
  3. Policy Structure and Paths

Note that (3.) isn't annotated in the examples above: It's the /v1/data/{path:+} of the decision request POST request. It corresponds to the package stanzas in your Rego policies.

Decision Request Input Schema

The decision request must provide data required to make authorization decisions. This means both the required data to classify the request and run the correct policy, as well as any data that might be needed by that policy to make decisions.

We recommend the following input schema to start with:

Resource contains information about the requested application resource.

ParameterTypeValueDescription
input.resource.typeStringendpointA constant describing the type of resource being accessed
input.resource.idStringEndpoint servlet pathE.g. /documents/1

Action contains information about the request: in our architecture, these are protocol-terms of HTTP.

ParameterTypeValueDescription
input.action.nameStringGET, POST, PUT, PATCH, HEAD, OPTIONS, TRACE, or DELETEHTTP request method
input.action.protocolStringHTTP protocol for request, e.g. HTTP 1.1
input.action.headersMap[String, Any]HTTP headers of requestNot guaranteed to be present

Context contains additional information about the request. This can be useful for enforcing business rules related to the connection rather than the operation.

ParameterTypeValueDescription
input.context.typeStringhttpA constant describing the type of contextual information provided
input.context.hostStringHTTP remote host of request
input.context.ipStringHTTP remote IP of request
input.context.portStringHTTP remote port for request
input.context.dataMap[String, Any]Optional supplemental data
input.context.appID StringApplication API routing IDSee Advanced Architecture

Modeling the subject can be more tricky but we recommend the following to begin with:

ParameterTypeValueDescription
input.subject.typeStringuserA constant describing the kind of subject being provided
input.subject.idStringUser ID or usernameID representing the subject being authorized

For example, here is a complete request following the above model showing the data that would be sent to OPA for a policy decision:

POST /v1/data/{path:.+}
Content-Type: application/json
{
"input": {
"resource": {
"type": "endpoint",
"id": "/documents/1"
},
"action": {
"name": "GET",
"protocol": "HTTP 1.1",
"headers": {
":authority": "example-app",
":method": "POST",
":path": "/pets/dogs",
"accept": "*/*",
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWxpY2lhIFNtaXRoc29uaWFuIiwicm9sZXMiOlsicmVhZGVyIiwid3JpdGVyIl0sInVzZXJuYW1lIjoiYWxpY2UifQ.md2KPJFH9OgBq-N0RonGdf5doGYRO_1miN8ugTSeTYc",
"content-length": "0",
"user-agent": "curl/7.68.0-DEV",
"x-ext-auth-allow": "yes",
"x-forwarded-proto": "http",
"x-request-id": "1455bbb0-0623-4810-a2c6-df73ffd8863a"
}
},
"subject": {
"type": "user",
"id": "bob"
},
"context": {
"type": "http",
"host": "example-app"
}
}
}

Decision Response Output Schema

We recommend the following output schema to start with:

ParameterTypeValueDescription
decisionBooleantrue or falseThe policy decision: true indicates the request should be allowed to proceed
context.reasonAnyInformation backing the decisionE.g. "quota exceeded" (String) or {"authn": "token expired"} (Map[String,String])

On the wire, an example looks like this:

HTTP/1.1 200 OK
Content-Type: application/json
{
"result": {
"decision": false,
"context": {
"reason": "insufficient access"
}
}
}
note

For security reasons, the contextual information returned by OPA may not be what your application is returning to the user: In some situations, it would be prudent to return "not found" when the reason is "unauthorized", such as not to give away the existence of the requested resource.

Also, localization of error messages would typically happen outside of OPA.

Policy Structure and Paths

Let the application's API design determine the structure of your policies. Each service that contributes to your to-be-authorized API is assigned an identifier, and that is used to construct the policies for those service.

package app["{appIdentifier}"].authz

import rego.v1

default allow := false # TODO: add other definitions

These separate packages are evaluated by a top-level policy. That policy is also responsible for building the Decision Response Output Schema.

package authz

import rego.v1

default decision := false

decision if {
app_id := app_from_resource(input.resource)
data.app[app_id].allow # may use `input.resource`, `input.action` etc
}

context["reason"] := "unauthorized" if not decision

# implementation of app_from_resource

This policy is queried via POST /v1/data/authz with the appropriate input (see Decision Request Input Schema).

Complete Example

In our example application, a document service, two services contribute to the API:

  1. "store-service" takes care of create, read, update, delete (CRUD) operations
  2. "publish-service" allows changing the visibility of a document

Subjects of our example include "Alice" and "Bob". Alice has full access to "store-service", and can retrieve if a document has been published. Only Bob is permitted to change the visibility of documents. Bob has full access to "store-service". No other subjects exist in the example system.

The policies for the two services are:

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 app["publish-service"].authz

import rego.v1

default allow := false

allow if {
subject_matches(input.subject, {"alice", "bob"})
action_matches(input.action)
allowed(input.subject.id, input.action.name)
}

subject_matches(subject, allowed) if {
subject.type == "user"
subject.id in allowed
}

action_matches(action) if action.name in {"GET", "POST"} # POST updates published status

allowed("bob", _) # bob has full access

allowed("alice", "GET")

The top-level policy, with the previously-missing app_from_resource, looks like this;

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, "/")
count(path) in {2, 3}
path[1] == "documents"
}

app_from_resource(resource) := "publish-service" if {
resource.type == "endpoint"
path := split(resource.id, "/")
count(path) == 4
path[1] == "documents"
path[3] == "published"
}

Evaluations

package authz_test

import rego.v1

test_alice_can_create_documents if {
data.authz.decision with input.subject as {"type": "user", "id": "alice"}
with input.resource as {"type": "endpoint", "id": "/documents"}
with input.action as {"name": "POST"}
}

test_alice_can_read_documents if {
data.authz.decision with input.subject as {"type": "user", "id": "alice"}
with input.resource as {"type": "endpoint", "id": "/documents/3321"}
with input.action as {"name": "GET"}
}

test_alice_can_retrieve_if_doc_is_published if {
data.authz.decision with input.subject as {"type": "user", "id": "alice"}
with input.resource as {"type": "endpoint", "id": "/documents/3321/published"}
with input.action as {"name": "GET"}
}

test_alice_can_not_change_published if {
not data.authz.decision with input.subject as {"type": "user", "id": "alice"}
with input.resource as {"type": "endpoint", "id": "/documents/3321/published"}
with input.action as {"name": "POST"}
}

test_bob_can_change_published if {
data.authz.decision with input.subject as {"type": "user", "id": "bob"}
with input.resource as {"type": "endpoint", "id": "/documents/3321/published"}
with input.action as {"name": "POST"}
}

Architecture Guides