# python-pdp-sdk
Policy decision point (PDP) sdk client for Python

## Generating Models for the ows-pdp client

We use `openapitools/openapi-generator` to generate models for
interacting with relevant ows-pdp endpoints. To re-generate models
using qa-ows-pdp.theorchard.io, run:

🚨 You will likely want to delete the `python_pdp_sdk/connectors/ows_pdp/models` dir before 
re-generating models! `make generate_models` will not delete old, no longer used models, it will simply add new models and replace existing models. 
Ensure that you still have a python_pdp_sdk/connectors/ows_pdp/models/__init__.py file, however! 🚨

```sh
make generate_models
```
You can review the full list of files that were autogenerated in `python_pdp_sdk/connectors/.openapi-generator/FILES`.

If the `make generate_models` command generates models which should be excluded (e.g. the model exposes RAP functionality), you can:
* Discard all changes: `git checkout -- .` and remove all new files as well as any existing files you don't want.
* Add the file path or wildcard to ignore to `.openapi-generator-ignore`
* Re-run `make generate_models`

** Keep in mind this tooling does not remove files that are already committed. You have to do that manually. **

## Usage

### `ResourceGetter`
`ResourceGetter` is an interface that provides a callable for the `AuthorizationBackend` to fetch relevant attributes about your application's resource. You can implement your own `ResourceGetter` with a callable taking `*args` and `**kwargs` and returning `dict[str, Any]`.

If you already have the relevant attributes, you can use this library's `ForwardKwargsGetter`.

### `PdpAuthorizationBackend`

The `PdpAuthorizationBackend` uses Permissions Platform conventions for checking an identity's authorization to perform an `action` on a `resource_type` with a `resource_id`. A `PdpAuthorizationBackend` object can be instantiated using something like:

```python
from owsclient import OwsClient

from python_pdp_sdk import OwsPdpClient, PdpAuthorizationBackend


ows_client = OwsClient(
    environment='qa',
    service_name='your-service',
    request_context_getter=my_request_context_getter,
)
ows_pdp_client = OwsPdpClient(ows_client=ows_client)
pdp_authorization_backend = PdpAuthorizationBackend(ows_pdp_client)
```

#### Exception Handling

The SDK distinguishes between **authentication errors** (missing/invalid/expired tokens) and **authorization errors** (insufficient permissions):

- **`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 an unknown error has occurred.

```python
from python_pdp_sdk import UnauthenticatedException, UnauthorizedException

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

### `.is_authorized`

To check if a given resource is authorized, and assuming you've implemented a `ResourceGetter`:

```python
result = pdp_authorization_backend.is_authorized(
    "view",  # action
    123,  # resource_id
    "audience",  # resource_type
    YourResourceGetter(),  # ResourceGetter
    *args,  # args will be passed to YourResourceGetter
    **kwargs,  # kwargs will be passed to YourResourceGetter
)
print(f"User is authorized to view audience 123: {result}")
```

Suppose you already have all the attributes of the resource and want to utilize the `ForwardKwargsGetter`:

```python
result = pdp_authorization_backend.is_authorized(
    "view",  # action
    123,  # resource_id
    "audience",  # resource_type
    ForwardKwargsGetter(),  # ResourceGetter
    tenant={
        "tenant_uuid": "7c8b382c-fc37-4179-9115-2165b1a93bed", 
        "tenant_type": "company_brand",
    },
    some="other relevant attribute",
)
print(f"User is authorized to view audience 123: {result}")
```

Suppose you don't have the `tenant_uuid`, you've got Vendors and their Vendor Ids. You can ask for an exchange of the tenant id to the uuid:

```python
result = pdp_authorization_backend.is_authorized(
    "view",  # action
    123,  # resource_id
    "audience",  # resource_type
    ForwardKwargsGetter(),  # ResourceGetter
    tenant={
        "tenant_type": "account",
    },
    id_to_uuid_exchange_tenant={
        "tenant_type": "account",
        "tenant_id": 7123,  # Accepts int or string
    }
    some="other relevant attribute",
)
print(f"User is authorized to view audience 123: {result}")
```

*`id_to_uuid_exchange_tenant` Support*
|Tenant Type|Support|Comments|
|-----------|-------|--------|
|account |Yes||
|subaccount|Yes||
|label_participant|No|LPs are all on uuids.|
|company_brand|Yes||
|collaborator|No|Reach out if you need this. [See PP-590.](https://www.notion.so/Spike-PP-590-Work-Needed-for-Collaborator-s-Hierarchy-5764b748879d400681f3c3dadc76fb45)|

### `.get_authorized_tenants`

If you would like the list of tenants for which the requester is authorized to perform a certain 
action on a certain type of resource:

```python
result = pdp_authorization_backend.get_authorized_tenants(
    "edit", # action
    "audience", # resource_type
)
tenants = [at.to_dict() for at in result] # read note below
print(f"User is authorized to {action} resource type {resource} for tenants {tenants}")
```
_Note_: You will get back a list of tenants with `tenant_uuids` and `tenant_ids`.
The list comprehension ensures you get back just the _value_ for `tenant_id`, and not
an instance of the unfortunately complicated [TenantId type](https://github.com/theorchard/python-pdp-sdk/blob/e6d9a03b50f11ac4c9d7117f07f9c5a6dd85b000/python_pdp_sdk/connectors/ows_pdp/models/tenant_id.py#L30). We apologize on behalf
of openapi-generator, which struggles with union types.

💡 You can also get the value of the `tenant_id` with `TenantId.actual_instance`.

### `.is_authorized_many`

Dataloader endpoints can use the `is_authorized_many` SDK to check if a user is authorized to access 2 or more resources. This method does 
not support a "getter" callback parameter but has a `ResourceWithAttributes` type parameter for the caller to
pass resources and attributes. 


```python
from python_pdp_sdk import (
    ResourceWithAttributes,
)

# Assume the upstream client requested resources ['123', '456']
# The handler must create the following `ResourceWithAttributes` objects.
resources_with_attributes = [
    ResourceWithAttributes(
        resource_id="123",
        attributes={
            "tenant": {
                "tenant_type": "audience",
                "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a"
            }
        },
    ),
    ResourceWithAttributes(
        resource_id="456",
        attributes={
            "tenant": {
                "tenant_type": "audience",
                "tenant_uuid": "ec1fd7e2-9c95-4e09-a037-e924e0244283"
            }
        },
    ),
]

response = pdp_authorization_backend.is_authorized_many(
    action="view",
    resource_type="account",
    resources_with_attributes=resources_with_attributes,
)    

# The results are returned in request order. To combine the results:
zipped_response = [
    {"item": r, "is_authorized": is_authorized}
    for r, is_authorized in zip(resources_with_attributes, response)
]
print(f"response is: {zipped_response}")

# Purge or 'nullify' response items where is_authorized==False.
```

💥 When `raise_when_unauthorized` is `True` in the request parameters, an exception will be raised if 
there are _any_ `DENY`s returned from PDP. If an exception is raised during the request,
an `UnauthorizedException` will be raised from the SDK.

```python
response = pdp_authorization_backend.is_authorized_many(
    action="view",
    resource_type="account",
    resources_with_attributes=resources_with_attributes,
    raise_when_unauthorized=True
)
```

Alternatively, the default behavior (`raise_when_unauthorized` is `False`) is to return a list of the
authorization decisions. If an exception is raised, the default behavior is to return a 
`list of False booleans` the length of `resources_with_attributes`. The implication 
is that every individual authorization request is denied if there is an 
exception on the request itself.

| 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`

Suppose you have a list of resources and their actions you'd like to check. `is_authorized_many` doesn't support this use case because these resources are of different resource types AND/OR different actions. In this case, `python-pdp-sdk`'s `AuthorizationBackend` provides a more direct way of asking for authorization with `is_authorized_many_resources_and_actions`.

First, you'll need to build the list of `ResourceAction` objects to check. Be sure to include:

- action
- resource type
- resource_id - be sure that if you end up having multiple ResourceAction objects with the same `resource_type` and `action` in the list, that `resource_id` is unique.
- attributes - dictionary, how you'd normally expect the ResourceGetter to return a dictionary for the resource being authorized (see above).

__DON'T__ reuse the same `resource_id`  for multiple `ResourceAction` objects with the same `resource_type` and `action`:

```python
resource_actions = [
    ResourceAction(
        resource_id="0",
        attributes={
            "tenant": {
                "tenant_type": "account",
                "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a",
            }
        },
        action="view",
        resource_type="account",
    ),
    ResourceAction(
        resource_id="0",
        attributes={
            "tenant": {
                "tenant_type": "account",
                "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a",
            }
        },
        action="view",
        resource_type="account",
    ),
]
```

__DO__:

```python
# Naturally you'd build this dynamically, but this should illustrate the basics!
resource_actions = [
    ResourceAction(
        resource_id="123",
        attributes={
            "tenant": {
                "tenant_type": "account",
                "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a",
            }
        },
        action="view",
        resource_type="account",
    ),
    ResourceAction(
        resource_id="123",
        attributes={
            "tenant": {
                "tenant_type": "account",
                "tenant_uuid": "e055a30d-34de-450f-b1f2-1fa433ceb15a",
            }
        },
        action="delete",
        resource_type="account",
    ),
]
```

Pass the `list[ResourceAction]` to `is_authorized_many_resources_and_actions`:

```python
from dataclasses import asdict
...


response = backend.is_authorized_many_resources_and_actions(
    resource_actions,
)

# The results are returned in request order. To combine the results:
zipped_response = [
    {"item": asdict(r), "is_authorized": is_authorized}
    for r, is_authorized in zip(resource_actions, response)
]
print(f"response is: {zipped_response}")
```
The `raise_when_unauthorized` flag works the same in this method as it does in `.is_authorized_many`.

## Migration Tooling

During the PP auth migration rollout, teams may need to shadow real authorization decisions
without disrupting production traffic. `MigrationAuthorizationBackend` wraps any
`AuthorizationBackend` and **always returns `True`** to callers, while emitting a
`pp_auth.rollout.would_deny` Datadog metric whenever the inner backend would have denied.
This lets you measure rollout readiness before switching enforcement on.

> **Note:** `get_authorized_tenants` is delegated to the inner backend unchanged and is
> **not** covered by the always-allow guarantee — it may still raise.

### Install

```toml
# pyproject.toml — install with the migration extra
python-pdp-sdk = { extras = ["migration"], version = "^6.0.0" }
```

Services that are not yet migrating install the base package as before and never pull in
the `datadog` transitive dependency.

### Usage

#### Datadog credentials

Callers can optionally pass the `dd_api_key` constructor argument. If omitted, the
`MigrationAuthorizationBackend` class will attempt to fetch the key from AWS Secrets
Manager automatically.

It will be fetched from the path: `<environment>/datadog/DD_API_KEY`.

#### Flask

The `extra_tags_getter` collects request information used as `tags` for DataDog metric collection. For
Flask, we read values from the [request object](https://flask.palletsprojects.com/en/stable/api/#flask.Request).
Define a `request_tags()` function and pass it directly — Flask's `request` is a thread-local proxy, so it
resolves to the active request whenever `request_tags()` is called during request handling.

| Tag                        | Description                                         | Example                              |
|----------------------------|-----------------------------------------------------|--------------------------------------|
| `method`                   | HTTP method                                         | `POST`, `GET`                        |
| `endpoint`                 | Matched route rule                                  | `/account/:id:`                      |
| `has_authorization_header` | `true` if the request has an `Authorization` header | `true`, `false`                      |
| `profile_type`             | Orchard profile type                                | `ContentProfile`, `OrchAdminProfile` |

```python
# context.py
# PP TODO: Move `request_tags()` into `python-owsrequest`.

from flask import request


def request_tags() -> list[str]:
    """Datadog tags describing the active request, for use as extra_tags_getter."""
    return [
        f"method:{request.method}",
        f"endpoint:{request.url_rule or request.path}",
        f"has_authorization_header:{str(bool(request.headers.get('Authorization'))).lower()}",
        f"profile_type:{(request.headers.get('Orchard-Profile-Type') or 'none').lower()}",
    ]
```

```python
# authorization setup (called once at startup)
import config
from owsclient import OwsClient

from python_pdp_sdk import (
    MigrationAuthorizationBackend,
    OwsPdpClient,
    PdpAuthorizationBackend,
)

from myservice.context import request_tags

ows_client = OwsClient(
    environment="qa",
    service_name="ows-product",
    request_context_getter=my_request_context_getter,
)
ows_pdp_client = OwsPdpClient(ows_client=ows_client)
pdp_backend = PdpAuthorizationBackend(ows_pdp_client)

# MigrationAuthorizationBackend will fetch DD_API_KEY from `<env>/datadog/DD_API_KEY`.
authorization_backend = MigrationAuthorizationBackend(
    inner_backend=pdp_backend,
    service_name="ows-product",
    environment=config.environment,
    extra_tags_getter=request_tags,
)
```

Or to pass a user-defined DD API KEY:
```python
authorization_backend = MigrationAuthorizationBackend(
    # Other args
    dd_api_key=config.DD_API_KEY,
)
```


#### FastAPI

FastAPI has no global request proxy, so this pattern requires `RequestContextMiddleware` and
`CorrelationIdMiddleware` from `python-owscontext` in your middleware stack (standard for OWS
FastAPI services).

`CurrentRequestMiddleware` stores the live Starlette `Request` in a `ContextVar`, and the
accessors read it lazily. Keep the middleware, the accessors, and `request_tags()` together
as one unit, then pass `request_tags` directly as the `extra_tags_getter`.

`request_tags()` collects tags for Datadog metrics:

| Tag                        | Description                                         | Example         |
|----------------------------|-----------------------------------------------------|-----------------|
| `method`                   | HTTP method                                         | `POST`, `GET`   |
| `endpoint`                 | Matched route template                              | `/account/:id:` |
| `has_authorization_header` | `true` if the request has an `Authorization` header | `true`, `false` |


```python
# context.py
# PP TODO: Move this to python-owscontext
from contextvars import ContextVar

from owscontext import get_request_context
from starlette.requests import Request
from starlette.types import ASGIApp, Receive, Scope, Send

_current_request: ContextVar[Request | None] = ContextVar("current_request", default=None)


class CurrentRequestMiddleware:
    """Store the active Starlette Request in a ContextVar for request_tags()."""

    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        token = _current_request.set(Request(scope, receive))
        try:
            await self.app(scope, receive, send)
        finally:
            _current_request.reset(token)


def get_route_template() -> str | None:
    """Matched route template (e.g. /tracks/{track_id}); falls back to the raw path
    for unmatched routes, keeping metric tags low-cardinality.

    Read lazily: Starlette only populates scope["route"] during routing, after every
    middleware's setup runs — so this resolves at auth-check time, inside the endpoint.
    """
    request = _current_request.get()
    if request is None:
        return None
    route = request.scope.get("route")
    return route.path if route is not None else request.url.path


def request_tags() -> list[str]:
    """Datadog tags describing the active request, for use as extra_tags_getter."""
    request = _current_request.get()
    if request is None:
        return []
    context = get_request_context()
    has_auth_header = bool(context and context.authorization)
    return [
        f"method:{request.method}",
        f"endpoint:{get_route_template()}",
        f"has_authorization_header:{str(has_auth_header).lower()}",
    ]
```

```python
# main.py
from owscontext.context.asgi.middleware import CorrelationIdMiddleware, RequestContextMiddleware

from myservice.context import CurrentRequestMiddleware

app = FastAPI(...)
app.add_middleware(CurrentRequestMiddleware)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(CorrelationIdMiddleware)
```

```python
# authorization setup (called once at startup)
from myservice.context import request_tags

authorization_backend = MigrationAuthorizationBackend(
    inner_backend=pdp_backend,
    service_name="ows-product",
    environment=config.environment,
    extra_tags_getter=request_tags,
    # Optionally include `dd_api_key`
)
```

Each `pp_auth.rollout.would_deny` metric is tagged with:
`environment`, `service_name`, `action`, `resource_type`, and `reason`
(`pp_denied` | `unauthenticated` | `exception`), plus any tags from `extra_tags_getter`.

Omit `dd_api_key` (or don't install the `migration` extra) to disable
metrics entirely — the backend will still always return `True`.

Pass `metrics_enabled=False` to explicitly disable Datadog metrics regardless of
`dd_api_key` — this skips DD API key resolution (including any Secrets Manager
lookup) and `datadog.initialize` entirely. Metrics are enabled by default.

```python
authorization_backend = MigrationAuthorizationBackend(
    inner_backend=pdp_backend,
    service_name="ows-product",
    environment=config.environment,
    metrics_enabled=False,
)
```

#### `on_would_deny` callback

Pass `on_would_deny` to `is_authorized`, `is_authorized_many`, or
`is_authorized_many_resources_and_actions` to run your own side effect whenever the
inner backend would have denied — for example, to support a custom logging function.

🚨 Consider the logging volume before wiring `on_would_deny` to a logging function —
excessive logging can cause the PDE Datadog account to exceed its daily log limit. 🚨

```python
from flask import abort, g

from python_pdp_sdk import (
    ForwardKwargsGetter,
    MigrationAuthorizationBackend,
    WouldDenyMetadata,
)
from python_pdp_sdk.models import Tenant, TenantType

RESOURCE_TYPE = "product"

migration_backend = MigrationAuthorizationBackend(
    inner_backend=pdp_backend,
    service_name="ows-product",
    environment=config.environment,
    extra_tags_getter=request_tags,
    # Optionally include `dd_api_key`
)

def _log_would_deny_identity(metadata: WouldDenyMetadata) -> None:
    """Log the JWT identity PP would deny, to build the grant list."""
    identity_id = g.request_context.jwt_identity_id
    if not identity_id:
        return

    g.log.info(
        f"pp_would_deny identity={identity_id} action={metadata.action} "
        f"resource_type={metadata.resource_type} reason={metadata.reason}"
    )


@app.route("/products/<int:product_id>")
def get_product(product_id: int):
    action = "view"
    product = product_service.get_product(product_id)
    tenant = Tenant(tenant_type=TenantType.ACCOUNT, tenant_uuid=product.vendor_uuid)

    authorized = migration_backend.is_authorized(
        action,
        product_id,
        RESOURCE_TYPE,
        ForwardKwargsGetter(),
        tenant={"tenant_type": tenant.tenant_type, "tenant_uuid": tenant.tenant_uuid},
        on_would_deny=_log_would_deny_identity,
    )
    if not authorized:
        abort(403)

    return product.to_dict()
```

