---
name: python-microservice-add-authorization-backend
description: Guide for onboarding Python microservices to use AuthorizationBackend from python-pdp-sdk
---

# Onboarding a Python Microservice to Use AuthorizationBackend

## Overview

This skill document explains how to integrate the `AuthorizationBackend` from the `python-pdp-sdk` library (version 6.6.0 or later) into a Python microservice. The `AuthorizationBackend` provides a standardized way to check authorization for actions on resources using the Permissions Platform (PDP).

## What is AuthorizationBackend?

The `python-pdp-sdk` package provides authorization capabilities through the `PdpAuthorizationBackend` class, which implements the `AuthorizationBackend` interface. This backend:

- Checks if an identity is authorized to perform an action on a resource
- Uses the Policy Decision Point (PDP) service for authorization decisions
- Supports checking multiple resources at once
- Provides methods to get authorized tenants for a resource type
- Distinguishes between authentication errors (401) and authorization errors (403)

### Key Methods

- **`is_authorized`**: Check if a user is authorized to perform an action on a single resource
- **`is_authorized_many`**: Check authorization for multiple resources of the same type with the same action
- **`is_authorized_many_resources_and_actions`**: Check authorization for multiple resources with different types and/or actions
- **`get_authorized_tenants`**: Get the list of tenants where the user is authorized to perform an action on a resource type

## Prerequisites

- The service must already have `OwsClient` configured (see `python-microservice-add-owsclient` skill)
- Access to the `python-pdp-sdk` package (version 6.6.0 or later)
- Configuration management setup (environment-based)

> **Note on package managers**: The examples below show representative package manager syntax, but always check the project's existing dependency files (`requirements.txt` / `requirements-dev.txt` for pip, `pyproject.toml` with `[tool.poetry.*]` for Poetry, `pyproject.toml` with `[project.dependencies]` for uv) and use the same package manager and format already in use.

---

# Onboarding: FastAPI Microservices

> **Prerequisite**: `OwsClient` must already be wired up in `datasources.py`. If it isn't, follow the `python-microservice-add-owsclient` skill first, then return here.

## Implementation Steps

### Step 1: Update Dependencies

Add the `python-pdp-sdk` package using your project's package manager. Ensure you use version 6.6.0 or later.

**Poetry (`pyproject.toml`):**

```toml
[tool.poetry.dependencies]
python-pdp-sdk = "^6.6.0"
```

**pip (`requirements.txt`):**

```text
python-pdp-sdk>=6.6.0
```

**uv (`pyproject.toml`):**

```toml
[project]
dependencies = [
    "python-pdp-sdk>=6.6.0",
]
```

Then install with the appropriate command:

```bash
# Poetry
poetry add python-pdp-sdk && poetry lock

# pip
pip install -r requirements.txt

# uv
uv sync
```

**For mypy users**, add `python_pdp_sdk.*` to the list of modules to ignore:

```toml
[[tool.mypy.overrides]]
module = [
    # ... other modules ...
    "python_pdp_sdk.*"
]
ignore_missing_imports = true
```

### Step 2: Import Required Dependencies

In the file where `OwsClient` is already set up (typically `datasources.py`), add the following imports:

```python
from python_pdp_sdk import (
    AuthorizationBackend,
    OwsPdpClient,
    PdpAuthorizationBackend,
)
```

These imports provide:
- `AuthorizationBackend`: The protocol/interface type for authorization backends
- `PdpAuthorizationBackend`: The concrete implementation that uses the PDP service
- `OwsPdpClient`: The client that communicates with the ows-pdp service

### Step 3: Define the Datasource Key

Define a module-level constant for the datasource key:

```python
AUTHORIZATION_BACKEND_KEY = "AUTHORIZATION_BACKEND"
```

This key is used to store and retrieve the authorization backend instance from the datasources dictionary.

### Step 4: Create the Setup Function

Add a setup function that creates the authorization backend. This function should be called from your lifespan context manager:

```python
def setup_authorization_backend(
    ows_client: OwsClient,
) -> AuthorizationBackend:
    """Set up the authorization backend for the application."""
    ows_pdp_client = OwsPdpClient(ows_client)
    return PdpAuthorizationBackend(ows_pdp_client)
```

### Step 5: Initialize the Backend in the Lifespan Context Manager

In the existing `datasources_lifespan` function, add the authorization backend initialisation **after** the existing `ows_client` setup — do not re-add the `OwsClient` initialisation:

```python
    # ows_client is already initialised above this point — do not add it again.
    # Add only these lines:
    authorization_backend = setup_authorization_backend(ows_client)
    logger.info("[lifespan] Initialized authorization backend")
    DATA_SOURCES[AUTHORIZATION_BACKEND_KEY] = authorization_backend
```

> **Note**: No special cleanup is needed for the authorization backend.

### Step 6: Create a Dependency Injection Function

Create a getter function that can be used as a FastAPI dependency:

```python
def get_authorization_backend():
    """Dependency injector method for the authorization backend."""
    return DATA_SOURCES[AUTHORIZATION_BACKEND_KEY]
```

This function retrieves the authorization backend instance from the datasources dictionary.

### Step 7: Connect to FastAPI App Lifespan

Ensure your FastAPI app uses the datasources lifespan (this should already be configured if you've set up OwsClient):

```python
from your_service.api.datasources import datasources_lifespan

app = FastAPI(
    # ... other parameters ...
    lifespan=datasources_lifespan,
)
```

## Usage in Route Handlers

### Basic Authorization Check

```python
from fastapi import Depends, APIRouter, HTTPException
from python_pdp_sdk import (
    AuthorizationBackend,
    ForwardKwargsGetter,
    UnauthenticatedException,
    UnauthorizedException,
)
from your_service.api.datasources import get_authorization_backend

router = APIRouter()

@router.get("/audiences/{audience_id}")
async def get_audience(
    audience_id: int,
    authorization_backend: AuthorizationBackend = Depends(get_authorization_backend),
):
    """Example endpoint that checks authorization before returning data."""
    try:
        is_authorized = authorization_backend.is_authorized(
            "view",  # action
            audience_id,  # resource_id
            "audience",  # resource_type
            ForwardKwargsGetter(),  # ResourceGetter
            tenant={
                "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
                "tenant_type": "company_brand",
            },
        )

        if not is_authorized:
            raise HTTPException(status_code=403, detail="Not authorized to view this audience")

        # Proceed with fetching and returning the audience data
        # ...

    except UnauthenticatedException:
        raise HTTPException(status_code=401, detail="Authentication required")
    except UnauthorizedException:
        raise HTTPException(status_code=403, detail="Not authorized")
```

### Using raise_when_unauthorized

You can simplify the code by using the `raise_when_unauthorized` parameter:

```python
@router.get("/audiences/{audience_id}")
async def get_audience(
    audience_id: int,
    authorization_backend: AuthorizationBackend = Depends(get_authorization_backend),
):
    """Example endpoint that checks authorization with automatic exception raising."""
    try:
        authorization_backend.is_authorized(
            "view",
            audience_id,
            "audience",
            ForwardKwargsGetter(),
            raise_when_unauthorized=True,  # Will raise UnauthorizedException if denied
            tenant={
                "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
                "tenant_type": "company_brand",
            },
        )

        # If we get here, the user is authorized
        # Proceed with fetching and returning the audience data
        # ...

    except UnauthenticatedException:
        raise HTTPException(status_code=401, detail="Authentication required")
    except UnauthorizedException:
        raise HTTPException(status_code=403, detail="Not authorized")
```

### Checking Multiple Resources

```python
from python_pdp_sdk import ResourceWithAttributes

@router.post("/audiences/batch")
async def get_audiences_batch(
    audience_ids: list[str],
    authorization_backend: AuthorizationBackend = Depends(get_authorization_backend),
):
    """Example endpoint that checks authorization for multiple audiences."""

    # Build the resources with attributes
    resources_with_attributes = [
        ResourceWithAttributes(
            resource_id=audience_id,
            attributes={
                "tenant": {
                    "tenant_type": "audience",
                    "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a"
                }
            },
        )
        for audience_id in audience_ids
    ]

    try:
        # Get authorization results for all resources
        authorization_results = authorization_backend.is_authorized_many(
            action="view",
            resource_type="audience",
            resources_with_attributes=resources_with_attributes,
        )

        # Filter to only include authorized resources
        authorized_audience_ids = [
            audience_id
            for audience_id, is_authorized in zip(audience_ids, authorization_results)
            if is_authorized
        ]

        # Fetch and return only authorized audiences
        # ...

    except UnauthenticatedException:
        raise HTTPException(status_code=401, detail="Authentication required")
```

## Usage in Logic Layer

You can also use the authorization backend in your business logic layer:
>
```python
from python_pdp_sdk import AuthorizationBackend, ForwardKwargsGetter, UnauthorizedException

def check_audience_access(
    authorization_backend: AuthorizationBackend,
    audience_id: int,
    action: str,
    tenant_uuid: str,
    tenant_type: str,
) -> bool:
    """Business logic to check audience access."""
    return authorization_backend.is_authorized(
        action,
        audience_id,
        "audience",
        ForwardKwargsGetter(),
        tenant={
            "tenant_uuid": tenant_uuid,
            "tenant_type": tenant_type,
        },
    )
```

Then use it in your route handler:

```python
@router.get("/audiences/{audience_id}")
async def get_audience(
    audience_id: int,
    authorization_backend: AuthorizationBackend = Depends(get_authorization_backend),
):
    """Route handler that uses business logic."""
    is_authorized = check_audience_access(
        authorization_backend,
        audience_id,
        "view",
        "7c8b382c-fc37-4179-9115-2165b1a93bed",
        "company_brand",
    )

    if not is_authorized:
        raise HTTPException(status_code=403, detail="Not authorized")

    # Proceed with fetching and returning the audience data
    # ...
```

## Testing

In FastAPI, the authorization backend is injected as a dependency, so tests use `app.dependency_overrides` rather than `patch`.

**1. Add a `mock_authorization_backend` fixture to `tests/conftest.py`:**

```python
import pytest
from unittest.mock import MagicMock
from python_pdp_sdk import AuthorizationBackend

@pytest.fixture
def mock_authorization_backend() -> AuthorizationBackend:
    """Return a mocked authorization backend."""
    return MagicMock(spec=AuthorizationBackend)
```

**2. Wire it into the existing `app` fixture via `dependency_overrides`** — do not create a new `app` fixture, add to the one that already exists alongside other datasource overrides:

```python
from your_service.api.datasources import get_authorization_backend

@pytest.fixture
def app(
    mock_authorization_backend: AuthorizationBackend,
    # ... other mock fixtures ...
) -> Generator[FastAPI, None, None]:
    _app.dependency_overrides[get_authorization_backend] = lambda: mock_authorization_backend
    # ... other overrides ...
    yield _app
    _app.dependency_overrides = {}
```

**3. Use it in tests** via the `test_client` fixture — the override is already in place.

Cover three scenarios so the exception handlers in the route (see [Basic Authorization Check](#basic-authorization-check)) are exercised:

- **Authorized** — `is_authorized` returns `True` → `200`
- **Unauthenticated** — `is_authorized` raises `UnauthenticatedException` → `401`
- **Unauthorized** — *only* when the route uses `raise_when_unauthorized=True`, `is_authorized` raises `UnauthorizedException` → `403`

> `UnauthorizedException` is *not* raised by `is_authorized` in the basic-mode example — that path returns `False` and the handler turns it into a `403` via `HTTPException`. Only add the `UnauthorizedException` test when the route opts into `raise_when_unauthorized=True`.

```python
from unittest.mock import ANY
from python_pdp_sdk import UnauthenticatedException, UnauthorizedException


def test_endpoint_with_authorization(
    test_client: TestClient,
    mock_authorization_backend: AuthorizationBackend,
) -> None:
    """Authorized: backend returns True → endpoint returns 200."""
    mock_authorization_backend.is_authorized.return_value = True

    response = test_client.get("/audiences/123")

    assert response.status_code == 200
    mock_authorization_backend.is_authorized.assert_called_once_with(
        "view",
        123,
        "audience",
        ANY,
        tenant={
            "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
            "tenant_type": "company_brand",
        },
    )


@pytest.mark.parametrize(
    ("side_effect", "expected_status"),
    [
        (UnauthenticatedException(), 401),
        (UnauthorizedException(), 403),
    ],
    ids=["unauthenticated", "unauthorized"],
)
def test_endpoint_returns_error_when_not_permitted(
    test_client: TestClient,
    mock_authorization_backend: AuthorizationBackend,
    side_effect: Exception,
    expected_status: int,
) -> None:
    """Only include the unauthorized (403) case for routes using raise_when_unauthorized=True."""
    mock_authorization_backend.is_authorized.side_effect = side_effect

    response = test_client.get("/audiences/123")

    assert response.status_code == expected_status
    mock_authorization_backend.is_authorized.assert_called_once_with(
        "view",
        123,
        "audience",
        ANY,
        tenant={
            "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
            "tenant_type": "company_brand",
        },
    )
```

## FastAPI Summary

To onboard `AuthorizationBackend` as a datasource in a FastAPI microservice:

1. ✅ Add `python-pdp-sdk` to dependencies (version 6.6.0+)
2. ✅ Add `python_pdp_sdk.*` to mypy ignore list (if using mypy)
3. ✅ Import `AuthorizationBackend`, `PdpAuthorizationBackend`, and `OwsPdpClient`
4. ✅ Define `AUTHORIZATION_BACKEND_KEY` constant
5. ✅ Create `setup_authorization_backend()` function
6. ✅ Initialize the backend in lifespan context manager (after OwsClient)
7. ✅ Create `get_authorization_backend()` dependency injector
8. ✅ Use `Depends(get_authorization_backend)` in route handlers and logic
9. ✅ Handle `UnauthenticatedException` and `UnauthorizedException` appropriately
10. ✅ Add `mock_authorization_backend` fixture and wire it into the `app` fixture via `dependency_overrides`

---

# Onboarding: Flask Microservices

Flask microservices use the same `AuthorizationBackend` but instantiate it as a module-level global, similar to how `OwsClient` is used in Flask.

## Flask Implementation Steps

> **Prerequisite**: `OwsClient` must already be wired up as a module-level global in `api.py`. If it isn't, follow the `python-microservice-add-owsclient` skill first, then return here.

### Step 1: Update Dependencies

Add `python-pdp-sdk` using your project's package manager:

**Poetry (`pyproject.toml`):**
```toml
[tool.poetry.dependencies]
python-pdp-sdk = "^6.6.0"
```

**pip (`requirements.txt`):**
```text
python-pdp-sdk>=6.6.0
```

**uv (`pyproject.toml`):**
```toml
[project]
dependencies = [
    "python-pdp-sdk>=6.6.0",
]
```

> **Note**: No `owsrequest` version bump is needed. `owsclient` uses `httpx` internally and has no dependency on `owsrequest`.

### Step 2: Import Required Dependencies

In `api.py`, add the `python-pdp-sdk` imports alongside the existing `owsclient` imports:

```python
from python_pdp_sdk import (
    AuthorizationBackend,
    OwsPdpClient,
    PdpAuthorizationBackend,
)
```

### Step 3: Create the Setup Function and Global

After the existing `ows_client = setup_ows_client()` line, add:

```python
def setup_authorization_backend(ows_client: OwsClient) -> AuthorizationBackend:
    """Setup Authorization Backend."""
    ows_pdp_client = OwsPdpClient(ows_client)
    return PdpAuthorizationBackend(ows_pdp_client)


authorization_backend = setup_authorization_backend(ows_client)
```

## Flask Usage in Models / Logic Layer

In Flask, import the global `authorization_backend` directly into modules that need it:

```python
from your_service.api import authorization_backend
from python_pdp_sdk import ForwardKwargsGetter, UnauthorizedException

def check_audience_access(audience_id: int, action: str) -> bool:
    """Check if the current user can access an audience."""
    try:
        return authorization_backend.is_authorized(
            action,
            audience_id,
            "audience",
            ForwardKwargsGetter(),
            tenant={
                "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
                "tenant_type": "company_brand",
            },
        )
    except UnauthorizedException:
        return False
```

## Flask Usage in Route Handlers

```python
from flask import jsonify, abort
from your_service.api import app, authorization_backend
from python_pdp_sdk import ForwardKwargsGetter, UnauthenticatedException, UnauthorizedException

@app.route('/audiences/<int:audience_id>', methods=['GET'])
def get_audience(audience_id: int):
    """Example handler that checks authorization."""
    try:
        is_authorized = authorization_backend.is_authorized(
            "view",
            audience_id,
            "audience",
            ForwardKwargsGetter(),
            tenant={
                "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
                "tenant_type": "company_brand",
            },
        )

        if not is_authorized:
            abort(403, description="Not authorized to view this audience")

        # Proceed with fetching and returning the audience data
        # ...

    except UnauthenticatedException:
        abort(401, description="Authentication required")
    except UnauthorizedException:
        abort(403, description="Not authorized")
```

Or delegate to a logic/model module (preferred pattern — keep handlers thin):

```python
from flask import jsonify, abort
from your_service.api import app
from your_service.models import audience_model

@app.route('/audiences/<int:audience_id>', methods=['GET'])
def get_audience(audience_id: int):
    """Example handler that delegates to model layer."""
    try:
        audience_data = audience_model.get_audience_if_authorized(audience_id)
        return jsonify(audience_data)
    except UnauthenticatedException:
        abort(401, description="Authentication required")
    except UnauthorizedException:
        abort(403, description="Not authorized")
```

## Flask Testing

Add a `mock_authorization_backend` fixture to `tests/conftest.py`. In Flask, the authorization backend is a module-level global, so the fixture must patch it directly. Ship this fixture in the **same PR** as the authorization backend setup — it has no independent value and belongs alongside the code it supports:

```python
import pytest
from unittest.mock import MagicMock, patch
from python_pdp_sdk import AuthorizationBackend

@pytest.fixture
def mock_authorization_backend() -> AuthorizationBackend:
    """Return a mocked authorization backend, patched onto the api module global."""
    mock = MagicMock(spec=AuthorizationBackend)
    with patch("your_service.api.authorization_backend", mock):
        yield mock
```

The fixture is then available to all test files via conftest. Cover three scenarios so the exception handlers in the route (see [Flask Usage in Route Handlers](#flask-usage-in-route-handlers)) are exercised:

- **Authorized** — `is_authorized` returns `True` → `200`
- **Unauthenticated** — `is_authorized` raises `UnauthenticatedException` → `401`
- **Unauthorized** — *only* when the route uses `raise_when_unauthorized=True`, `is_authorized` raises `UnauthorizedException` → `403`

> `UnauthorizedException` is *not* raised by `is_authorized` in the basic-mode example — that path returns `False` and the handler turns it into a `403` via `abort(403, ...)`. Only add the `UnauthorizedException` test when the route opts into `raise_when_unauthorized=True`.

```python
from python_pdp_sdk import UnauthenticatedException, UnauthorizedException


def test_get_audience_authorized(
    client,
    mock_authorization_backend: AuthorizationBackend,
) -> None:
    """Authorized: backend returns True → endpoint returns 200."""
    mock_authorization_backend.is_authorized.return_value = True

    response = client.get("/audiences/123")

    assert response.status_code == 200


def test_get_audience_returns_401_when_unauthenticated(
    client,
    mock_authorization_backend: AuthorizationBackend,
) -> None:
    """Backend raises UnauthenticatedException → endpoint returns 401."""
    mock_authorization_backend.is_authorized.side_effect = UnauthenticatedException()

    response = client.get("/audiences/123")

    assert response.status_code == 401


def test_get_audience_returns_403_when_unauthorized(
    client,
    mock_authorization_backend: AuthorizationBackend,
) -> None:
    """Only for routes using raise_when_unauthorized=True:
    backend raises UnauthorizedException → endpoint returns 403.
    """
    mock_authorization_backend.is_authorized.side_effect = UnauthorizedException()

    response = client.get("/audiences/123")

    assert response.status_code == 403
```

## Flask Summary

To onboard `AuthorizationBackend` in a Flask microservice:

1. ✅ Run `python-microservice-add-owsclient` skill first (if `OwsClient` not already wired up)
2. ✅ Add `python-pdp-sdk` to dependencies
3. ✅ Import `AuthorizationBackend`, `PdpAuthorizationBackend`, and `OwsPdpClient`
4. ✅ Create `setup_authorization_backend()` function and `authorization_backend` global (after `ows_client`)
5. ✅ Import `authorization_backend` in models/handlers and call its methods synchronously
6. ✅ Handle `UnauthenticatedException` and `UnauthorizedException` appropriately
7. ✅ Add `mock_authorization_backend` fixture to `tests/conftest.py` (using `patch`, in the same PR as the backend setup)

---

# Common Reference

## Exception Handling

The SDK provides two main exception types:

- **`UnauthenticatedException`**: Raised when the service returns a 401 status (invalid, missing, or expired Authorization header). This exception **always propagates**, regardless of the `raise_when_unauthorized` flag.
- **`UnauthorizedException`**: Raised when authorization is denied (only when `raise_when_unauthorized=True`) or when an unknown error occurs.

```python
from python_pdp_sdk import UnauthenticatedException, UnauthorizedException

try:
    result = authorization_backend.is_authorized(...)
except UnauthenticatedException:
    # User lacks a valid Authorization header
    # Return 401
    ...
except UnauthorizedException:
    # User lacks permission
    # Return 403
    ...
```

## ResourceGetter

The `ResourceGetter` is an interface that provides a callable for the `AuthorizationBackend` to fetch relevant attributes about your application's resource.

### ForwardKwargsGetter

If you already have the relevant attributes, use the built-in `ForwardKwargsGetter`:

```python
from python_pdp_sdk import ForwardKwargsGetter

result = authorization_backend.is_authorized(
    "view",
    123,
    "audience",
    ForwardKwargsGetter(),
    tenant={
        "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed",
        "tenant_type": "company_brand",
    },
)
```

### Custom ResourceGetter

You can implement your own `ResourceGetter` with a callable that takes `*args` and `**kwargs` and returns `dict[str, Any]`:

```python
class MyResourceGetter:
    def __call__(self, *args, **kwargs) -> dict[str, Any]:
        # Fetch resource attributes from your database or other source
        resource_id = kwargs.get("resource_id")
        # ... fetch from database ...
        return {
            "tenant": {
                "tenant_uuid": "...",
                "tenant_type": "...",
            },
            # ... other attributes ...
        }


result = authorization_backend.is_authorized(
    "view",
    123,
    "audience",
    MyResourceGetter(),
    resource_id=123,
)
```

#### Safely merging extra tenant attributes

If your getter (or any helper that builds the `tenant` dict) accepts caller-supplied `tenant_attributes`, **never let them override the canonical `tenant_type` / `tenant_uuid` keys**. The unsafe pattern below lets a caller pass `tenant_attributes={"tenant_uuid": "<other-tenant>"}` and authorize against a tenant they don't own — a tenant-spoofing risk, especially when `tenant_attributes` flows from a request payload.

❌ **Unsafe — caller can override canonical keys:**

```python
# tenant_uuid / tenant_type set first, then spread overwrites them
return {
    "tenant": {
        "tenant_type": tenant_type,
        "tenant_uuid": str(tenant_uuid),
        **(tenant_attributes or {}),  # ← can clobber the two keys above
    },
}
```

✅ **Safe — spread first, then set canonical keys last so they always win:**

```python
return {
    "tenant": {
        **(tenant_attributes or {}),
        "tenant_type": tenant_type,
        "tenant_uuid": str(tenant_uuid),
    },
}
```

✅ **Also safe — strip reserved keys from the incoming attributes:**

```python
RESERVED_TENANT_KEYS = {"tenant_type", "tenant_uuid"}

safe_attributes = {
    k: v for k, v in (tenant_attributes or {}).items()
    if k not in RESERVED_TENANT_KEYS
}
return {
    "tenant": {
        "tenant_type": tenant_type,
        "tenant_uuid": str(tenant_uuid),
        **safe_attributes,
    },
}
```

Apply the same rule anywhere you build a `tenant` dict from a mix of trusted arguments and untrusted/optional attributes — route helpers, logic-layer wrappers, and custom `ResourceGetter` implementations alike.

#### Test that reserved keys cannot be overridden

Add a unit test that passes `tenant_attributes` containing reserved keys (`tenant_type`, `tenant_uuid`) with sentinel "wrong" values, then asserts the authorization backend was called with the canonical values from the explicit arguments — not the override attempts. Sketch:

```python
tenants = [
    Tenant(
        tenant_type="subaccount",
        tenant_uuid=tenant_uuid,
        tenant_attributes={
            "tenant_hierarchy": [1, 2, 3],
            "tenant_type": "not-subaccount-this-is-naughty-client-behavior",
            "tenant_uuid": "00000000-0000-0000-0000-000000000000",
        },
    ),
]
# ... call the helper ...
authorization_backend.is_authorized_many.assert_called_once_with(
    # ...
    resources_with_attributes=[
        ResourceWithAttributes(
            # ...
            attributes={
                "tenant": {
                    "tenant_hierarchy": [1, 2, 3],
                    "tenant_type": "subaccount",            # canonical wins
                    "tenant_uuid": str(tenant_uuid),        # canonical wins
                }
            },
        ),
    ],
)
```

The assertion is the load-bearing part: it pins the behavior so a future refactor that reintroduces the unsafe spread order will fail the test.

## ID to UUID Exchange

If you have tenant IDs but not UUIDs, you can request an exchange:

```python
result = authorization_backend.is_authorized(
    "view",
    123,
    "audience",
    ForwardKwargsGetter(),
    tenant={
        "tenant_type": "account",
    },
    id_to_uuid_exchange_tenant={
        "tenant_type": "account",
        "tenant_id": 7123,  # Accepts int or string
    },
)
```

**Supported Tenant Types:**
- `account` - Yes
- `subaccount` - Yes
- `label_participant` - No (LPs are all on UUIDs)
- `company_brand` - Yes
- `parent_company` - Yes
- `collaborator` - No (reach out if you need this)

## Authorization Methods

### is_authorized

Check if a user is authorized to perform an action on a single resource:

```python
def is_authorized(
    action: str,                          # e.g., "view", "edit", "delete"
    resource_id: int | str,               # The ID of the resource
    resource_type: str,                   # e.g., "audience", "account"
    getter: ResourceGetter,               # Callable to fetch resource attributes
    *args,                                # Passed to getter
    raise_when_unauthorized: bool = False,  # Raise UnauthorizedException if denied
    **kwargs,                             # Passed to getter
) -> bool: ...
```

### is_authorized_many

Check authorization for multiple resources of the same type with the same action:

```python
from python_pdp_sdk import ResourceWithAttributes

resources_with_attributes = [
    ResourceWithAttributes(
        resource_id="123",
        attributes={"tenant": {...}},
    ),
    ResourceWithAttributes(
        resource_id="456",
        attributes={"tenant": {...}},
    ),
]

def is_authorized_many(
    action: str,
    resource_type: str,
    resources_with_attributes: list[ResourceWithAttributes],
    raise_when_unauthorized: bool = False,
) -> list[bool]: ...
```

**Behavior with `raise_when_unauthorized`:**

| raise_when_unauthorized | Result from PDP         | Behavior in SDK  |
| --------                | -------                 | ------- |
| False (default)         | All ALLOWs              | List of True booleans |
| False (default)         | Some DENYs some ALLOWs  | List of True and False booleans |
| False (default)         | Exception               | List of all False booleans |
| True                    | All ALLOWs              | List of True booleans |
| True                    | Some DENYs some ALLOWs  | Unauthorized Exception |
| True                    | Exception               | Unauthorized Exception |

### is_authorized_many_resources_and_actions

Check authorization for multiple resources with different types and/or actions:

```python
from python_pdp_sdk import ResourceAction
from dataclasses import asdict

resource_actions = [
    ResourceAction(
        resource_id="123",
        attributes={"tenant": {...}},
        action="view",
        resource_type="account",
    ),
    ResourceAction(
        resource_id="123",
        attributes={"tenant": {...}},
        action="delete",
        resource_type="account",
    ),
]

def is_authorized_many_resources_and_actions(
    resource_actions: list[ResourceAction],
    raise_when_unauthorized: bool = False,
) -> list[bool]: ...

# Combine results with original requests
zipped_response = [
    {"item": asdict(r), "is_authorized": is_authorized}
    for r, is_authorized in zip(resource_actions, results)
]
```

**Important:** Don't reuse the same `resource_id` for multiple `ResourceAction` objects with the same `resource_type` and `action`.

### get_authorized_tenants

Get the list of tenants where the user is authorized to perform an action on a resource type:

```python
def get_authorized_tenants(
    action: str,         # e.g., "edit"
    resource_type: str,  # e.g., "audience"
) -> list[AuthorizedTenant]: ...
```

Example usage:
```python
result = authorization_backend.get_authorized_tenants("edit", "audience")
# Convert to dictionaries
tenants = [at.to_dict() for at in result]
print(f"User is authorized to edit audience for tenants {tenants}")
```

The result includes both `tenant_uuids` and `tenant_ids`. Use `to_dict()` to get the value for `tenant_id` instead of the complex `TenantId` type instance.

## Configuration Requirements

Ensure your config module has the following properties:

```python
# In your_service/config.py
ENVIRONMENT = os.getenv("Environment", "dev")  # dev, qa, prod
SERVICE_NAME = "your-service-name"  # e.g., "ows-product-staging"
```

These are typically already configured if you've set up `OwsClient`.
