Skip to content

Open Policy Agent

Open Policy Agent User to Your app: sam: refund order 2. Your app to Open Policy Agent: shop.allow, total 250? Open Policy Agent to Your app: false: over 100. Your app to User: 403 Forbidden. User to Your app: max: refund order 2. Your app to Open Policy Agent: shop.allow, a manager? Open Policy Agent to Your app: true. Your app to User: 200, refunded. The policy lives in a service of its own, asked on each request. User Your app Open Policy Agent sam: refund order 2 shop.allow, total 250? false: over 100 403 Forbidden max: refund order 2 shop.allow, a manager? true 200, refunded The policy lives in a service of its own, asked on each request
What it's for: a Rego policy in a service of its own decides, over the network, who reads each order and who refunds it.

A shop whose Open Policy Agent policy decides who reads each order and who refunds it, asked over the network: everybody reads the catalogue, customers their own orders and the support staff every order. Support refunds up to 100 euros, and a manager above that. While OPA is down, every request gets a 503.

Users log in with a fixed token each, which stands in for your authentication: ana-token and leo-token (customers), sam-token (support) and max-token (support and manager).

Run it

From a clone of the repository, OPA in Docker with the policy, and the app:

docker run -p 8181:8181 -v "$PWD/examples/authz:/policies:ro" openpolicyagent/opa:1.21.4 \
    run --server --addr 0.0.0.0:8181 /policies/shop.rego
make deps                 # .venv, with every extra
source .venv/bin/activate
uvicorn examples.authz.opa_service:app

OPA_URL and OPA_TOKEN (environment variables or a .env file) point it to another OPA.

Try it

Everybody reads the catalogue, a token or not:

$ curl http://127.0.0.1:8000/products
[{"id":1,"name":"Olive oil","price":9.5}]

Ana reads her orders, not Leo's; Sam, in support, reads every order:

$ curl http://127.0.0.1:8000/orders/1 -H "Authorization: Bearer ana-token"
{"id":1,"customer":"ana","total":40.0,"refunded":false}

$ curl -i http://127.0.0.1:8000/orders/3 -H "Authorization: Bearer ana-token"
HTTP/1.1 404 Not Found
content-type: application/json

{"detail":"No such order"}

$ curl http://127.0.0.1:8000/orders/3 -H "Authorization: Bearer sam-token"
{"id":3,"customer":"leo","total":15.0,"refunded":false}

The policy reads the order's total: Sam doesn't refund 250 euros, Max, a manager, does. Ana doesn't refund her own:

$ curl -i -X POST http://127.0.0.1:8000/orders/2/refund -H "Authorization: Bearer sam-token"
HTTP/1.1 403 Forbidden
content-type: application/json

{"detail":"Refunds are for the support staff, and a manager above 100 euros"}

$ curl -X POST http://127.0.0.1:8000/orders/2/refund -H "Authorization: Bearer max-token"
{"id":2,"customer":"ana","total":250.0,"refunded":true}

$ curl -i -X POST http://127.0.0.1:8000/orders/1/refund -H "Authorization: Bearer ana-token"
HTTP/1.1 403 Forbidden
content-type: application/json

{"detail":"Refunds are for the support staff, and a manager above 100 euros"}

Stop OPA, and nothing is let through:

$ curl -i http://127.0.0.1:8000/products
HTTP/1.1 503 Service Unavailable
content-type: application/json

{"detail":"Authorization unavailable"}

What to look at

  • The policy is elsewhere: the app sends who asks (the user with its roles), the order and the action, and OPA answers. Changing who refunds what is a change to shop.rego, which OPA loads, not a deploy of the app.
  • Attributes in the input: input.subject.roles and input.resource.total are what the route gives authz.enforce, the user the authentication loaded and the order it read.
  • default allow := false: without it, the rule would be undefined for what no body matches, which the enforcer refuses with a warning.
  • Fails closed: OPA down is a 503, never a request let through, and after 5 failures the circuit breaker refuses at once instead of waiting for OPA on every request.
  • 404 for what you may not read: an order a user may not read doesn't exist for them.

The code

examples/authz/shop.rego
# Who may do what in the shop: the rule `allow` of `package shop`, which the app queries as
# shop.allow with the input {"subject": ..., "resource": ..., "action": ..., "context": ...}.
package shop

# Nothing is allowed unless a rule below says so: the rule is never undefined.
default allow := false

# Everybody reads the catalogue, signed in or not.
allow if {
    input.action == "GET"
    input.resource == "/products"
}

# Customers read their own orders, and the support staff every order.
allow if {
    input.action == "read"
    input.resource.customer == input.subject.name
}

allow if {
    input.action == "read"
    "support" in input.subject.roles
}

# The support staff refund up to 100 euros; above that, a manager does.
allow if {
    input.action == "refund"
    "support" in input.subject.roles
    input.resource.total <= 100
}

allow if {
    input.action == "refund"
    "manager" in input.subject.roles
}
examples/authz/opa_service.py
"""A shop whose orders and refunds Open Policy Agent decides, asked over the network: the policy
lives in its own service, shared by every app of the company.

    docker run -p 8181:8181 -v "$PWD/examples/authz:/policies:ro" openpolicyagent/opa:1.21.4 \\
        run --server --addr 0.0.0.0:8181 /policies/shop.rego
    uvicorn examples.authz.opa_service:app

Everybody reads the catalogue, customers their own orders and the support staff every order.
Support refunds up to 100 euros, and a manager above that. The policy is in shop.rego: the app
sends who asks (with the user's roles), the order and the action, and OPA answers. When OPA
doesn't answer, the requests get a 503: nothing is let through.
"""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, Any

import uvicorn
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

from fastapi_alcazaba.authz import FastAPIAuthz
from fastapi_alcazaba.authz.opa import OPAEnforcer


class Settings(BaseSettings):
    """The configuration, from environment variables or a .env file."""

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    opa_url: str = "http://localhost:8181"
    opa_token: SecretStr | None = None  # when OPA runs with --authentication=token


class Product(BaseModel):
    id: int
    name: str
    price: float


class Order(BaseModel):
    id: int
    customer: str
    total: float
    refunded: bool = False


class User(BaseModel):
    name: str
    roles: list[str] = []


class Database:
    """Stands in for the database."""

    def __init__(self) -> None:
        self.products = [Product(id=1, name="Olive oil", price=9.5)]
        self.orders = {
            1: Order(id=1, customer="ana", total=40),
            2: Order(id=2, customer="ana", total=250),
            3: Order(id=3, customer="leo", total=15),
        }


settings = Settings()
db = Database()
bearer = HTTPBearer(auto_error=False)
# Stands in for the app's authentication (JWTs, sessions, API keys): a token per user.
USERS = {
    "ana-token": User(name="ana"),
    "leo-token": User(name="leo"),
    "sam-token": User(name="sam", roles=["support"]),
    "max-token": User(name="max", roles=["support", "manager"]),
}


async def current_user(
    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
) -> dict[str, Any] | None:
    """Who makes the request, with what the policy reads of them; None without a token."""
    if credentials is None:
        return None
    user = USERS.get(credentials.credentials)
    if user is None:
        raise HTTPException(401, "Invalid token", headers={"WWW-Authenticate": "Bearer"})
    return user.model_dump()


enforcer = OPAEnforcer(settings.opa_url, rule="shop.allow", token=settings.opa_token)
authz = FastAPIAuthz(enforcer, subject=current_user, anonymous="*anonymous")


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    yield
    await enforcer.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/products", dependencies=[Depends(authz)])
async def list_products() -> list[Product]:
    """(subject, path, method): the policy opens it to everybody, anonymous visitors too."""
    return db.products


async def signed_in(
    user: Annotated[dict[str, Any] | None, Depends(current_user)],
) -> dict[str, Any]:
    """The user, for routes that ask the policy about the order they read."""
    if user is None:
        raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
    return user


SignedIn = Annotated[dict[str, Any], Depends(signed_in)]


@app.get("/orders/{order_id}")
async def read_order(order_id: int, user: SignedIn) -> Order:
    """The order, as OPA reads it: a 404 for those who may not, as if it didn't exist."""
    order = db.orders.get(order_id)
    if order is None or not await authz.enforce(user, order.model_dump(), "read"):
        raise HTTPException(404, "No such order")
    return order


@app.post("/orders/{order_id}/refund")
async def refund_order(order_id: int, user: SignedIn) -> Order:
    """The policy reads the order's total: support refunds small orders, managers any."""
    order = db.orders.get(order_id)
    if order is None or not await authz.enforce(user, order.model_dump(), "read"):
        raise HTTPException(404, "No such order")
    if not await authz.enforce(user, order.model_dump(), "refund"):
        raise HTTPException(403, "Refunds are for the support staff, and a manager above 100 euros")
    order.refunded = True
    return order


if __name__ == "__main__":
    uvicorn.run("opa_service:app", reload=True)