Open Policy Agent¶
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.rolesandinput.resource.totalare what the route givesauthz.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¶
# 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
}
"""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)