# Flask Migration Templates

Use these when implementing PP checks in Flask services. PP migration spans **Phase 1 (Baseline)**, **Phase 2 (Shadow)** and **Phase 3 (Enforce)** — 

- **Phase 1 - Baseline integration tests**: see `SKILL.md`
- **Phase 2 — Shadow** *(ship first; additive, zero behavior change)*: leave the existing legacy
  auth (grass / access-rules) **untouched** and bolt on a side-effect-only `is_authorized()` call.
  The `MigrationAuthorizationBackend` from `python-pdp-sdk[migration]==6.6.0` always returns allow
  and emits the `pp_auth.rollout.would_deny` Datadog metric whenever the real PP decision *would*
  have denied. This measures rollout readiness without disrupting traffic.
- **Phase 3 — Enforce** *(follow-up PR, after metrics are clean)*: restructure the handler to
  PP-first + legacy fallback (Templates A / B / C), and swap `MigrationAuthorizationBackend` for the
  real `PdpAuthorizationBackend`. The report cites the Phase 3 template by letter.

> **Why additive shadow rather than a config-driven backend swap?** If the handler is written in
> its final PP-first shape and short-circuits on `is_authorized() == True`, the always-allow
> migration backend makes it return early **before** the legacy grass check runs — silently
> dropping real enforcement during the shadow phase. Keeping legacy auth untouched and adding a
> side-effect-only PP call avoids that enforcement gap. See
> `_tasks/migration-authorization-backend-shadow-approach.md`.

**Before writing any Phase 3 code — detect the service's error response convention:**

```bash
grep -rn "401\|403\|Unauthorized\|Forbidden\|abort(" <service>/handlers.py | head -20
```

| Pattern found | Use in scaffolded code |
|---|---|
| `flaskify(response.create_error_response(..., status=401))` | Same — oto convention |
| `abort(401)` / `abort(403)` | Same — Flask abort |
| `raise Unauthorized(...)` / `raise Forbidden(...)` | Same — werkzeug |
| Custom exception (e.g. `raise ServiceError(status=401)`) | Same — match custom class |

---

# Phase 2 — Shadow

## `context.py` — `request_tags()`

```python
# 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.

    Flask's `request` is a thread-local proxy, so it resolves to the active request
    whenever `request_tags()` is called during request handling.
    """
    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()}",
    ]
```

## `config.py` addition

```python
SERVICE_NAME = '<service-name>'          # e.g. 'ows-product'
# `environment` is assumed to already exist in config.

# No DD_API_KEY / DD_APP_KEY config needed: MigrationAuthorizationBackend fetches the key from
# `<environment>/datadog/DD_API_KEY` automatically (or pass dd_api_key=... explicitly).
```

## Startup wiring in `api.py` *(once at startup)*

```python
import config
from owsclient import OwsClient

from python_pdp_sdk import (
    MigrationAuthorizationBackend, 
    OwsPdpClient,
    PdpAuthorizationBackend
)

from <service>.context import request_tags

ows_client = OwsClient(
    environment=config.environment,
    service_name=config.SERVICE_NAME,
    request_context_getter=my_request_context_getter,
)
ows_pdp_client = OwsPdpClient(ows_client=ows_client)
pdp_backend = PdpAuthorizationBackend(ows_pdp_client)

# Always-allow wrapper that emits pp_auth.rollout.would_deny on a would-deny.
# Fetches DD_API_KEY from `<env>/datadog/DD_API_KEY`; pass dd_api_key=... to override.
authorization_backend = MigrationAuthorizationBackend(
    inner_backend=pdp_backend,
    service_name=config.SERVICE_NAME,
    environment=config.environment,
    extra_tags_getter=request_tags,
)
```

## Handler shadow call *(same for all postures)*

Leave the existing legacy auth **exactly as it is** and add the side-effect-only PP call. The return
value is intentionally ignored — the backend always allows, so traffic is unaffected; the call
exists only to emit `pp_auth.rollout.would_deny` when the real PP decision would have denied.

```python
# <service>/auth.py
from ddtrace import tracer
from python_pdp_sdk import ForwardKwargsGetter
from <service>.api import authorization_backend


@tracer.wrap()
def shadow_authorization(*, resource_id: int, resource_type: str, action: str) -> None:
    """Shadow the PP decision for metrics. Always allows; never blocks traffic."""
    tenant = get_tenant(resource_id)
    if not tenant:
        return  # can't resolve a tenant; nothing to shadow
    # Return value ignored on purpose — MigrationAuthorizationBackend always returns True
    # and emits pp_auth.rollout.would_deny when the real decision would deny.
    authorization_backend.is_authorized(
        action=action, resource_id=0, resource_type=resource_type,
        resource_getter=ForwardKwargsGetter(),
        tenant={"tenant_type": tenant.tenant_type, "tenant_uuid": str(tenant.tenant_uuid)},
    )
```

```python
# in the handler — existing legacy auth stays untouched; add the shadow call alongside it
auth.shadow_authorization(resource_id=resource_id, resource_type='product', action='read')
# ... existing verify_grass_access / access-rules / etc. continue to enforce as before ...
```

> **Tenant UUID sourcing**: Prefer local DB joins. If UUIDs aren't local, use
> `id_to_uuid_exchange_tenant` in `python-pdp-sdk` to resolve them server-side.

---

# Phase 3 — Enforce

Ship this only after the `pp_auth.rollout.would_deny` metric is clean for the endpoint (see
`references/caller-analysis-and-rollout.md`). Swap the startup wiring to the real backend and remove
the shadow call — `authorization_backend` is now the gating decision:

```python
# api.py — replace the MigrationAuthorizationBackend wrapper with the raw backend
authorization_backend = PdpAuthorizationBackend(ows_pdp_client)
```

Then restructure the handler into one of the templates below. All three share the same
`assert_authorization` signature.

## Template A — Grass (required) + ownership fallback

```python
# <service>/auth.py
import logging
from ddtrace import tracer
from flask import g, request
from owsrequest import flask_request
from oto import response
from python_pdp_sdk import ForwardKwargsGetter, UnauthenticatedException
from <service> import config
from <service>.api import authorization_backend
from <service>.constants import error

logger = logging.getLogger(__name__)


@tracer.wrap()
def assert_authorization(*, resource_id: int, resource_type: str, action: str):
    """PP first; fall back to grass headers + ownership check."""
    tenant = get_tenant(resource_id)
    pp_authorized = False
    if tenant:
        try:
            pp_authorized = authorization_backend.is_authorized(
                action=action, resource_id=0, resource_type=resource_type,
                resource_getter=ForwardKwargsGetter(),
                tenant={"tenant_type": tenant.tenant_type, "tenant_uuid": str(tenant.tenant_uuid)},
            )
        except UnauthenticatedException:
            return response.create_error_response(
                code=error.ERROR_CODE_AUTHORIZATION, message="JWT required", status=401)
    if pp_authorized:
        return None
    headers_ok = bool(flask_request.verify_grass_headers(request))
    ownership_ok = headers_ok and bool(
        flask_request.verify_grass_ownership(request, check_ownership, resource_id=resource_id))
    if not ownership_ok:
        return response.create_error_response(
            code=error.ERROR_CODE_AUTHORIZATION, message="Unauthorized", status=403)
    return None
```

## Template B — Grass (required), no ownership check *(replace the fallback block in Template A)*

```python
    if not flask_request.verify_grass_headers(request):
        return response.create_error_response(
            code=error.ERROR_CODE_AUTHORIZATION, message="Unauthorized", status=403)
    return None
```

## Template C — No fallback *(permissive grass / no auth / access rules disabled)*

```python
    return response.create_error_response(
        code=error.ERROR_CODE_AUTHORIZATION, message="Unauthorized", status=403)
```

## Handler wire-up *(same for all templates)*

```python
err = auth.assert_authorization(resource_id=resource_id, resource_type='product', action='read')
if err:
    return flaskify(err)
```

> **Tenant UUID sourcing**: Prefer local DB joins. If UUIDs aren't local, use
> `id_to_uuid_exchange_tenant` in `python-pdp-sdk` to resolve them server-side.
