Skip to content

Cedar

Cedar User to Your app: eva: GET /documents/2. Your app to Cedar policies: eva read Document 2? Your app: a forbid beats a permit. Cedar policies to Your app: deny: confidential. Your app to User: 404, as if missing. User to Your app: ben: GET /documents/2. Your app to User: 200, Budget 2027. Attributes decide, and a forbid beats every permit. User Your app Cedar policies eva: GET /documents/2 eva read Document 2? a forbid beats a permit deny: confidential 404, as if missing ben: GET /documents/2 200, Budget 2027 Attributes decide, and a forbid beats every permit
What it's for: policies on attributes (a document's team and owner, who's a contractor) where a forbid beats every permit, validated against a schema.

A team's shared documents, whose Cedar policies decide who reads, edits and deletes each one by its attributes: its team, its owner, whether it's confidential, and who's asking. The members of a document's team read it, its owner edits it, admins delete documents, and contractors never see a confidential one, whatever else allows it.

Users log in with a fixed token each, which stands in for your authentication: ana-token (admin, editorial), ben-token (editorial), eva-token (a contractor in editorial) and dan-token (sales).

Run it

From a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
uvicorn examples.authz.cedar_policies:app

Try it

Ben reads both documents of his team, and none of sales. Eva, in the same team, doesn't get the confidential budget:

$ curl http://127.0.0.1:8000/documents -H "Authorization: Bearer ben-token"
[{"id":1,"title":"Style guide","owner":"ana","team":"editorial","confidential":false},{"id":2,"title":"Budget 2027","owner":"ben","team":"editorial","confidential":true}]

$ curl http://127.0.0.1:8000/documents -H "Authorization: Bearer eva-token"
[{"id":1,"title":"Style guide","owner":"ana","team":"editorial","confidential":false}]

$ curl -i http://127.0.0.1:8000/documents/2 -H "Authorization: Bearer eva-token"
HTTP/1.1 404 Not Found
content-type: application/json

{"detail":"No such document"}

Only its owner edits a document, admins included:

$ curl -i -X PUT http://127.0.0.1:8000/documents/2 -H "Authorization: Bearer ana-token" -H "Content-Type: application/json" -d '{"title": "Budget 2028"}'
HTTP/1.1 403 Forbidden
content-type: application/json

{"detail":"Only its owner edits a document"}

$ curl -X PUT http://127.0.0.1:8000/documents/2 -H "Authorization: Bearer ben-token" -H "Content-Type: application/json" -d '{"title": "Budget 2027, second draft"}'
{"id":2,"title":"Budget 2027, second draft","owner":"ben","team":"editorial","confidential":true}

Deleting is for admins, asked as a named action, (user, documents, delete):

$ curl -i -X DELETE http://127.0.0.1:8000/documents/3 -H "Authorization: Bearer dan-token"
HTTP/1.1 403 Forbidden
content-type: application/json

{"detail":"Forbidden"}

$ curl -i -X DELETE http://127.0.0.1:8000/documents/3 -H "Authorization: Bearer ana-token"
HTTP/1.1 204 No Content
content-type: application/json

Without a token, a 401 before the policy is asked:

$ curl -i http://127.0.0.1:8000/documents
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json

{"detail":"Not authenticated"}

What to look at

  • Attributes, not roles alone: the user's team and whether it's a contractor, the document's team, owner and confidentiality. The user is the entity the authentication gives; each document, the entity the route builds from the row it read.
  • A forbid beats every permit: Eva is in the budget's team, which permits reading it, and the forbid on confidential documents for contractors wins.
  • Validated at start: the policies are checked against schema.cedarschema when the enforcer is made, so a misspelled attribute fails at start, not as a wrong answer.
  • 404 for what you may not read: a document a user may not read doesn't exist for them.

The code

examples/authz/policies.cedar
// Everybody with an account lists the documents; each one is checked as it's read.
permit (principal, action == Action::"GET", resource == Resource::"/documents");

// The members of a document's team read it.
permit (principal, action == Action::"read", resource)
when { principal.team == resource.team };

// Its owner reads and edits it.
permit (principal, action in [Action::"read", Action::"edit"], resource)
when { resource.owner == principal };

// Admins delete documents.
permit (
  principal in Role::"admin",
  action == Action::"delete",
  resource == Resource::"documents"
);

// Contractors never see a confidential document, whatever else allows it.
forbid (principal, action, resource)
when { principal.contractor && resource is Document && resource.confidential };
examples/authz/schema.cedarschema
entity Role;
entity User in [Role] { team: String, contractor: Bool };
entity Document { owner: User, team: String, confidential: Bool };
entity Resource { id: String };

action read, edit appliesTo { principal: [User], resource: [Document] };
action GET, delete appliesTo { principal: [User], resource: [Resource] };
examples/authz/cedar_policies.py
"""A team's shared documents, whose Cedar policies decide who reads, edits and deletes each one by
its attributes: its team, its owner, whether it's confidential, and who's asking.

    uvicorn examples.authz.cedar_policies:app

The members of a document's team read it, its owner edits it, admins delete documents, and
contractors never see a confidential one, whatever else allows it: a `forbid` beats every
`permit`. The policies are in policies.cedar, validated at start against schema.cedarschema.
The user (with its team and roles) is the entity the authentication gives; each document, the
entity the route builds from the row it read.
"""

from pathlib import Path
from typing import Annotated, Any

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field

from fastapi_alcazaba.authz import FastAPIAuthz
from fastapi_alcazaba.authz.cedar import CedarEnforcer

HERE = Path(__file__).parent


class Document(BaseModel):
    id: int
    title: str = Field(max_length=200)
    owner: str
    team: str
    confidential: bool = False


class Edit(BaseModel):
    title: str = Field(max_length=200)


class User(BaseModel):
    name: str
    team: str
    contractor: bool = False
    roles: list[str] = []


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

    def __init__(self) -> None:
        self.documents = {
            1: Document(id=1, title="Style guide", owner="ana", team="editorial"),
            2: Document(
                id=2, title="Budget 2027", owner="ben", team="editorial", confidential=True
            ),
            3: Document(id=3, title="Sales targets", owner="dan", team="sales"),
        }


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", team="editorial", roles=["admin"]),
    "ben-token": User(name="ben", team="editorial"),
    "eva-token": User(name="eva", team="editorial", contractor=True),
    "dan-token": User(name="dan", team="sales"),
}


def user_entity(user: User) -> dict[str, Any]:
    """The user as Cedar sees it: its attributes, and its roles as parents."""
    return {
        "uid": {"type": "User", "id": user.name},
        "attrs": {"team": user.team, "contractor": user.contractor},
        "parents": [{"type": "Role", "id": role} for role in user.roles],
    }


def document_entity(document: Document) -> dict[str, Any]:
    owner = {"__entity": {"type": "User", "id": document.owner}}
    attrs = {"owner": owner, "team": document.team, "confidential": document.confidential}
    return {"uid": {"type": "Document", "id": str(document.id)}, "attrs": attrs, "parents": []}


async def current_user(
    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
) -> dict[str, Any] | None:
    """Who makes the request, as a Cedar entity; 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_entity(user)


enforcer = CedarEnforcer(
    (HERE / "policies.cedar").read_text(),
    entities=[{"uid": {"type": "Role", "id": "admin"}, "attrs": {}, "parents": []}],
    schema=(HERE / "schema.cedarschema").read_text(),  # the policies are checked against it
)
authz = FastAPIAuthz(enforcer, subject=current_user)  # a 401 without a token
Member = Annotated[dict[str, Any], Depends(authz)]  # (user, path, method): GET /documents


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 document 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 = FastAPI()


async def readable(user: dict[str, Any], document_id: int) -> Document:
    """The document, if the user may read it; a 404 otherwise, as if it didn't exist."""
    document = db.documents.get(document_id)
    if document is None or not await authz.enforce(user, document_entity(document), "read"):
        raise HTTPException(404, "No such document")
    return document


@app.get("/documents")
async def list_documents(user: Member) -> list[Document]:
    """Those the user may read: the policy decides for each one."""
    documents = db.documents.values()
    return [d for d in documents if await authz.enforce(user, document_entity(d), "read")]


@app.get("/documents/{document_id}")
async def read_document(document_id: int, user: SignedIn) -> Document:
    return await readable(user, document_id)


@app.put("/documents/{document_id}")
async def edit_document(document_id: int, edit: Edit, user: SignedIn) -> Document:
    document = await readable(user, document_id)
    if not await authz.enforce(user, document_entity(document), "edit"):
        raise HTTPException(403, "Only its owner edits a document")
    document.title = edit.title
    return document


@app.delete(
    "/documents/{document_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(authz.require("documents", "delete"))],
)
async def delete_document(document_id: int) -> None:
    """A named action, not a path: (user, documents, delete)."""
    if db.documents.pop(document_id, None) is None:
        raise HTTPException(404, "No such document")


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