# Auth Scan: ows-product

Generated by: `python-service-scan-authz-baseline` skill on 2026-06-09

Framework: **Flask** (`Flask==3.1.3`, uWSGI). Package: `product`. Auth stack: `owsrequest` (grass
headers + `access_rules.yml`), `jwtauth`, and `python-pdp-sdk==6.1.0` (PP backend instantiated but
not yet invoked by any handler).

## Summary

| Posture | Count |
|---|---|
| 🟢 PP enforced (complete) | 0 |
| 🟡 PP + permissive fallback | 0 |
| 🟡 PP + required fallback | 0 |
| 🟡 Grass only (required) | 5 |
| 🟡 Grass only (permissive) | 17 |
| 🟡 Access rules (enforced) | 0 |
| 🔴 Access rules (disabled) | 12 |
| 🔴 Access rules (default deny / unmatched) | 0 |
| 🔴 No auth | 23 |
| ⚪ Needs human review | 3 |
| 🟢 No auth (excluded) | 2 |

> **Headline**: PP is wired at the app level (`PdpAuthorizationBackend` instantiated in
> `product/api.py`) and `product/auth.py` already contains ready-to-use helpers
> (`assert_authorization`, `is_authorized_for_tenant`, `assert_authorization_for_tenant`,
> `get_tenant`) — but **zero endpoints call them**. Every endpoint's effective runtime auth is
> legacy grass / access-rules / nothing. Access-rules middleware is **globally disabled**
> (`verify_access=False`), so the 12 "Access rules (disabled)" endpoints currently have **no
> enforcement at all** despite having rules defined.

## Auth infrastructure

*Flask (`product/api.py` → `flask_request.setup`):*
- `verify_access`: **`False`** (hardcoded), `access_log_only`: `config.ONLY_LOG_ACCESS_ERRORS` (`= True`, hardcoded in `config.py:98`), `rules_file`: `product/access_rules.yml`, `exclude_paths`: `config.EXCLUDE_PATH` = `["/hello/", "/public/localization/languages", "/lookup/product/ownership/"]`
- > ⚠️ **Interpretation**: `verify_access=False` means the access-rules middleware is **not
>   enforcing in any environment** — `access_rules.yml` is dormant. The inline comment confirms
>   intent: *"disabling verify_access until we are ready to flip access_log_only=False."* Any
>   endpoint whose only protection was an access rule is therefore effectively unprotected today.

*PP / `python-pdp-sdk`:*
- `PdpAuthorizationBackend(OwsPdpClient(ows_client))` instantiated at module load in `product/api.py` → **AuthorizationBackend wired: ✅ complete**.
- `product/auth.py` provides: `get_tenant(product_id, subaccount_id)` (resolves vendor/subaccount UUID from `vw_product` joins), `is_authorized_for_tenant(...)`, `assert_authorization_for_tenant(...)` (returns oto `Response`; 200/401/403), and `assert_authorization(product_id, vendor_id, subaccount_id, resource_type, action)` (PP-first + `verify_grass_access` fallback). **None are called from `handlers.py`.**
- > ⚠️ **Tenant-resolution gap**: `get_tenant()` resolves tenants **only by `product_id`
>   (release_id)**. Endpoints keyed by UPC, ISRC, track `tuid`, `language_id`, or raw
>   `account_id` have no tenant resolver yet and need one before PP enforcement.

*Resource/action map*: maintained in Notion — [Content microservices: endpoints → resource / action / PP authorization](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242) (resource_type / action / pp_authorization_method / proposed_roles per endpoint; filter `microservice = ows-product`) — **prerequisite ✅**.

## Endpoint Auth Posture

> **Column order is fixed — do not reorder.**

| Posture | Endpoint | Handler | File | Evidence | Confidence |
|---|---|---|---|---|---|
| 🟢 No auth (excluded) | GET /hello/ | health | handlers.py:46 | In `EXCLUDE_PATH`; health check | High |
| 🟢 No auth (excluded) | GET /public/localization/languages | get_public_all_itunes_languages | handlers.py:364 | In `EXCLUDE_PATH` | High |
| 🔴 Access rules (disabled) | HEAD /\<account_type\>/\<int:account_id\>/product_code/\<product_code\> | product_code_exists | handlers.py:87 | Rule `/vendor/<int>/product_code/<*>` HEAD (vendor only); `verify_access=False`; no handler auth | High |
| 🔴 Access rules (disabled) | GET /product/\<account_type\>/\<account_uuid\>/product_code/\<product_code\> | get_product_id_by_product_code_and_account | handlers.py:138 | Rule `/product/<str>/<str>/product_code/<*>` GET; `verify_access=False`; no handler auth | High |
| 🔴 Access rules (disabled) | GET /language/\<int:language_id\> | get_itunes_language_by_id | handlers.py:425 | Rule `/language/<int>` GET; disabled; no handler auth | High |
| 🔴 Access rules (disabled) | GET /localization/product/\<int:product_id\> | get_product_localization | handlers.py:376 | Rule `/localization/product/<int>` GET; disabled; no handler auth | High |
| 🔴 Access rules (disabled) | POST /localization/product/\<int:product_id\>/language/\<int:language_id\> | create_product_localizations | handlers.py:395 | Rule POST; disabled; no handler auth | High |
| 🔴 Access rules (disabled) | DELETE /localization/product/\<int:product_id\>/language/\<int:language_id\> | delete_product_localizations | handlers.py:439 | Rule DELETE; disabled; no handler auth | High |
| 🔴 Access rules (disabled) | GET /product/upc/\<int:upc\> | get_product_by_upc | handlers.py:198 | Rule `/product/upc/<int>` GET; disabled (see also grass row) | High |
| 🔴 Access rules (disabled) | GET /product/\<int:product_id\> | get_product_by_product_id | handlers.py:225 | Rule `/product/<int>` GET; disabled (see also grass row) | High |
| 🔴 Access rules (disabled) | POST /product/\<int:product_id\>/copy/\<context_type\> | copy_product_flexible | handlers.py:281 | Rule `/product/<int>/copy/digital|physical` POST; disabled (see also grass row) | High |
| 🔴 Access rules (disabled) | DELETE /product/\<int:product_id\> | delete_product_by_product_id | handlers.py:307 | Rule `/product/<int>` DELETE; disabled (see also review row) | High |
| 🔴 Access rules (disabled) | GET /product/\<int:product_id\>/document | get_product_document | handlers.py:772 | Rule `/product/<int>/document` GET; disabled. Handler rejects grass (internal-only); no positive authz | High |
| 🔴 Access rules (disabled) | POST /products/documents | get_products_documents | handlers.py:802 | Rule `/products/documents` POST; disabled. Handler rejects grass (internal-only); no positive authz | High |
| 🟡 Grass only (required) | GET /product/upc/\<int:upc\> | get_product_by_upc | handlers.py:214 | `verify_grass_access(vendor=…, subaccount=…)` (default required) | High |
| 🟡 Grass only (required) | GET /product/\<int:product_id\> | get_product_by_product_id | handlers.py:245 | `verify_grass_access(vendor=…, subaccount=…)` (default required) | High |
| 🟡 Grass only (required) | GET /vendor/\<int:vendor_id\>/display_upc/\<display_upc\> | get_upc_from_vendor_display_upc | handlers.py:584 | `verify_grass_access(request, vendor=vendor_id)` | High |
| 🟡 Grass only (required) | HEAD /vendor/\<int:vendor_id\>/display_upc/\<display_upc\>/available | check_display_upc_availability_for_vendor | handlers.py:628 | `verify_grass_access(request, vendor_id=vendor_id, subaccount=None)` ⚠️ passes `vendor_id=` not `vendor=` | Medium |
| 🟡 Grass only (required) | HEAD /subaccount/\<int:subaccount_id\>/display_upc/\<display_upc\>/available | check_display_upc_availability_for_subaccount | handlers.py:663 | `verify_grass_access(request, subaccount_id=subaccount_id, subaccount=None)` ⚠️ passes `subaccount_id=` not `subaccount=` | Medium |
| 🟡 Grass only (permissive) | POST /product/\<int:product_id\>/copy | copy_product | handlers.py:256 | `_product_ownership_check` returns True when no grass headers | High |
| 🟡 Grass only (permissive) | POST /product/\<int:product_id\>/copy/\<context_type\> | copy_product_flexible | handlers.py:281 | `_product_ownership_check` permissive (also Access-rules row) | High |
| 🟡 Grass only (permissive) | POST /product/\<int:product_id\>/subgenre | create_release_subgenre | handlers.py:831 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | PUT /product/\<int:product_id\>/subgenre | update_release_subgenre | handlers.py:843 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | GET /product/\<int:product_id\>/subgenre | get_release_subgenre | handlers.py:854 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | DELETE /product/\<int:product_id\>/subgenre | delete_release_subgenre | handlers.py:864 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | POST /product/\<int:product_id\>/artist | create_release_artist | handlers.py:874 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | PUT /product/\<int:product_id\>/artist | update_release_artist | handlers.py:886 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | GET /product/\<int:product_id\>/artist | get_release_artist | handlers.py:897 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | DELETE /product/\<int:product_id\>/artist | delete_release_artist | handlers.py:907 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | GET /product/\<int:product_id\>/phonetic-translations[/\<language_id\>] | get_phonetic_translations | handlers.py:1014 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | POST /product/\<int:product_id\>/phonetic-translations | create_phonetic_translations | handlers.py:1023 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | PUT /product/\<int:product_id\>/phonetic-translations | update_phonetic_translations | handlers.py:1041 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | DELETE /product/\<int:product_id\>/phonetic-translations | delete_phonetic_translations | handlers.py:1059 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | PUT /product/\<int:product_id\>/territory_dates | update_territory_dates | handlers.py:1125 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | PUT /product/\<int:product_id\>/asset-version | update_asset_version | handlers.py:1160 | `_product_ownership_check` permissive | High |
| 🟡 Grass only (permissive) | POST /\<account_type\>/\<int:account_id\>/upcs | check_products_ownership_by_upcs | handlers.py:927 | `verify_grass_access(request, required=False, …)` explicit `required=False` | High |
| 🔴 No auth | HEAD /upc/\<int:upc\> | upc_exists | handlers.py:52 | No handler auth; catch-all only (disabled) | High |
| 🔴 No auth | HEAD /upc/available/\<int:upc\> | upc_avaialble_for_use | handlers.py:58 | No handler auth | High |
| 🔴 No auth | GET /upc/\<int:upc\>/is_orchard_upc | is_orchard_upc | handlers.py:64 | No handler auth | High |
| 🔴 No auth | GET /\<account_type\>/\<account_uuid\>/product_code/available | product_codes_available_for_use | handlers.py:112 | Only 400 validation; no ownership check | High |
| 🔴 No auth | HEAD /\<account_type\>/\<int:account_id\>/product/\<int:product_id\> | check_product_ownership | handlers.py:167 | Ownership-lookup endpoint; no JWT/grass gate | High |
| 🔴 No auth | POST /upc/available | upcs_available_for_use | handlers.py:190 | No handler auth | High |
| 🔴 No auth | GET /localization/languages | get_all_itunes_languages | handlers.py:354 | No handler auth | High |
| 🔴 No auth | DELETE /localization/track/\<comma_separated_int:tuids\> | delete_track_localization_with_tuids | handlers.py:457 | No handler auth | High |
| 🔴 No auth | DELETE /localization/track/\<int:tuid\>/language/\<int:language_id\> | delete_track_localization_with_language | handlers.py:470 | No handler auth | High |
| 🔴 No auth | GET /localization/track/\<comma_separated_int:tuids\> | get_track_localization | handlers.py:486 | No handler auth | High |
| 🔴 No auth | POST /localization/track | bulk_get_track_localization | handlers.py:501 | No handler auth | High |
| 🔴 No auth | PUT /localization/track/\<int:tuid\>/language/\<int:language_id\> | update_track_localization | handlers.py:524 | No handler auth | High |
| 🔴 No auth | PUT /localization/tracks | update_multiple_tracks_localization | handlers.py:549 | No handler auth | High |
| 🔴 No auth | POST /upc/placeholder | generate_placeholder_upc | handlers.py:564 | No handler auth | High |
| 🔴 No auth | GET /products | get_upcs_by_product_ids | handlers.py:735 | No handler auth | High |
| 🔴 No auth | POST /bulk-upc | get_products_by_upcs | handlers.py:749 | No handler auth | High |
| 🔴 No auth | POST /product-id-by-upc-dataloader | get_product_id_by_upc_dataloader | handlers.py:760 | No handler auth | High |
| 🔴 No auth | GET /sound-recording/\<isrc\>/products | get_products_by_isrc | handlers.py:987 | Rejects grass (OA-only); no positive authz | High |
| 🔴 No auth | GET /release-artist/\<int:release_artist_id\>/product | get_products_by_release_artist_id | handlers.py:1089 | Rejects grass (internal-only); no positive authz | High |
| 🔴 No auth | POST /upc/provision | provision_upc | handlers.py:1108 | No handler auth | High |
| 🔴 No auth | GET /product/\<int:product_id\>/asset-version | get_asset_version | handlers.py:1148 | No ownership check (unlike PUT sibling) | High |
| 🔴 No auth | GET /log | say_hi | handlers.py:1221 | Debug/log endpoint; no auth | High |
| 🔴 No auth | GET /hfa/eligible-tracks | get_hfa_eligible_tracks | handlers.py:1236 | No handler auth; internal HFA job | High |
| ⚪ Needs human review | GET /\<account_type\>/\<int:account_id\>/products | get_products | handlers.py:677 | Reads `jwt_identity_id` in conditional (`is_jwt_identity_authorized`) + `verify_grass_headers` + `verify_grass_ownership` | Medium |
| ⚪ Needs human review | DELETE /product/\<int:product_id\> | delete_product_by_product_id | handlers.py:307 | Feature-flag-gated (`CCM_CHECK_DELETE_PRODUCT_ACCESS`): grass + profile-access checks when on, **no auth when off** | Medium |
| ⚪ Needs human review | POST /lookup/product/ownership/ | lookup_product_ownership | handlers.py:1188 | In `EXCLUDE_PATH` (access-rules bypass); reads `jwt_identity_id` (401 if absent) | Medium |

> **Endpoint discovery confidence**: Discovery via `@app.route` grep on `product/handlers.py`
> (all routes are registered there). No `add_url_rule`, `api.add_resource`, `MethodView`, or
> blueprint registrations exist outside `handlers.py` (the `@app.route` hits in `auth.py` /
> `json_schema.py` are docstring examples, not live routes). **High confidence** the inventory is
> complete. Multi-method routes (`/phonetic-translations` with/without `language_id`) collapsed to
> one row.

## Detailed Findings

Findings are grouped by posture because endpoints within each group share an identical migration
treatment. Per-endpoint resource_type/action come from the [PP authorization Notion table](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242).

### Group 1 — 🔴 No auth (23 endpoints)

- **Current posture**: No enforcement; any caller reaches any resource. Includes mutating
  endpoints (`POST /upc/provision`, `POST /upc/placeholder`, track-localization `PUT`/`DELETE`,
  `POST /upc/available`) and bulk read/lookup endpoints.
- **Auth call**: none.
- **PP status**: Not started.
- **Tenant-resolution caveat**: most of these are keyed by UPC / track tuid / language / ISRC, not
  `product_id`, so `get_tenant()` cannot resolve a tenant as-is. A new resolver (or
  `id_to_uuid_exchange_tenant`) is required before PP can be enforced. Several (`/log`,
  `/hfa/eligible-tracks`, `/products` lookup-style) are internal/machine traffic — confirm caller
  identity before choosing enforce vs. M2M-token strategy.

#### Phase 1 — Baseline integration tests
- Capture current behavior: requests **with no auth headers succeed today** — assert `200`/`2xx` to lock the baseline.
- Cover one mutating endpoint (`POST /upc/provision`) and one read (`GET /hfa/eligible-tracks`).
- Assert that malformed input still returns the existing `400`s (don't conflate validation with authz).

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend` wrapping `PdpAuthorizationBackend`; side-effect-only `is_authorized()` (always allows, emits `pp_auth.rollout.would_deny`).
- **Behavior change**: None.
- Blocked on tenant resolution for non-product-keyed endpoints — wire shadow only where a tenant can be resolved.

#### Phase 3 — Enforce
- **Template**: C (no fallback — strict PP).
- **Key API**: `is_authorized_for_tenant` / `assert_authorization_for_tenant`.
- **Caller analysis required**: Yes — highest blast radius; many callers may be unauthenticated/internal today.
- **Risk**: High. These are the top migration priority but also the most likely to break internal callers; do caller analysis first.

### Group 2 — 🔴 Access rules (disabled) (12 endpoints)

- **Current posture**: An `access_rules.yml` rule is defined, but `verify_access=False` disables the
  middleware globally — so these run with **no active enforcement**. Six have no handler-level check
  at all (localization GET/POST/DELETE, `GET /language/<int>`, product_code lookups); the other six
  also carry a grass or review row (see those groups).
- **Auth call**: access-rules rule only (dormant).
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Confirm requests pass today **regardless of profile headers** (rules are off) — assert `2xx`.
- For the product-keyed ones (`/localization/product/<id>`, `/product/<id>`, `/product/<id>/document`), use a product owned by the test tenant.

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend`; side-effect `is_authorized()` only.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C (per skill rule: *Access rules disabled → always Template C*; there is nothing to fall back to at the middleware level).
- **Key API**: `assert_authorization_for_tenant` (product-keyed) — resolve tenant via `get_tenant(product_id)`.
- **Caller analysis required**: Yes.
- **Risk**: Medium–High. The `LabelProfile:[catalog]` intent in `access_rules.yml` is the de-facto target authorization; encode the equivalent in Cerbos policy.

### Group 3 — 🟡 Grass only (permissive) (17 endpoints)

- **Current posture**: `_product_ownership_check` (or `verify_grass_access(required=False)`) — when a
  request carries **no grass headers, it is implicitly authorized**. Includes mutating product
  sub-resource endpoints: `subgenre`, `artist`, `phonetic-translations` (POST/PUT/DELETE),
  `territory_dates` PUT, `asset-version` PUT, `copy`, and `POST /<account_type>/<id>/upcs`.
- **Auth call**: `flask_request.get_grass_headers` + conditional `product_logic.check_product_ownership`, or `verify_grass_access(required=False)`.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Assert: **request with no grass headers → passes today** (implicit allow); request with grass headers for a non-owned product → `403`.
- Cover one mutating (`POST /product/<id>/subgenre`) and one read (`GET /product/<id>/artist`).

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend`; side-effect `is_authorized()` only — leaves `_product_ownership_check` in force.
- **Behavior change**: None — confirms how many header-less (would-deny) callers exist before tightening.

#### Phase 3 — Enforce
- **Template**: C (permissive grass → Template C; the no-header implicit-allow must be removed).
- **Key API**: `assert_authorization_for_tenant` via `get_tenant(product_id)` — all are product-keyed, so the existing helper works directly.
- **Caller analysis required**: Yes — implicit-allow callers are exactly the ones a strict PP check will start rejecting.
- **Risk**: High for mutating endpoints. These are the implicit-allow risk surface; prioritize after Group 1.

### Group 4 — 🟡 Grass only (required) (5 endpoints)

- **Current posture**: `verify_grass_access(...)` default `required=True` — requests without matching
  grass headers are already rejected. Two endpoints (`get_product_by_upc`, `get_product_by_product_id`)
  verify vendor/subaccount ownership.
- **Auth call**: `flask_request.verify_grass_access(request, vendor=…, subaccount=…)`.
- **PP status**: Not started.
- ⚠️ `check_display_upc_availability_for_vendor`/`_for_subaccount` pass `vendor_id=`/`subaccount_id=`
  kwargs (not `vendor=`/`subaccount=`) to `verify_grass_access` — verify these actually bind the
  account (possible silent no-op) during migration.

#### Phase 1 — Baseline integration tests
- Assert: no grass headers → `403` today; valid owner headers → `200`; non-owner → `403`.

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend`; side-effect `is_authorized()` only.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: A (grass required + ownership) — keep grass as the fallback under PP. `product/auth.py`’s existing `assert_authorization(product_id=…, vendor_id=…, subaccount_id=…)` already implements this exact PP-first + `verify_grass_access` fallback; wire it into the handler.
- **Key API**: `assert_authorization` (existing helper) for product-keyed; UPC/display-UPC endpoints need a UPC→product tenant resolver first.
- **Caller analysis required**: Yes (lower risk — already rejects header-less traffic).
- **Risk**: Low–Medium.

### Group 5 — ⚪ Needs human review (3 endpoints)

- **`GET /<account_type>/<int:account_id>/products`** (`get_products`): reads `g.request_context.jwt_identity_id` directly and calls `handler_util.is_jwt_identity_authorized` (allow-list in `header.AUTHORIZED_IDENTITIES`), **plus** `verify_grass_headers` + `verify_grass_ownership`. The raw-identity allow-list bypasses PP's resource/action flow — confirm intent before migrating. Maps to `is_authorized_many` (per the [PP authorization Notion table](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242)).
- **`DELETE /product/<int:product_id>`** (`delete_product_by_product_id`): behavior depends on the `CCM_CHECK_DELETE_PRODUCT_ACCESS` feature flag — grass-headers-required + `check_profile_access_to_product` when **on**, **no auth** when **off**. Resolve the flag state before classifying.
- **`POST /lookup/product/ownership/`** (`lookup_product_ownership`): in `EXCLUDE_PATH` (access-rules bypassed) but the handler requires a JWT identity (`401` if absent). Partial identity gate, no resource check.

#### Phase 1 — Baseline integration tests
- Pin current behavior for each branch: for `get_products`, test an allow-listed identity vs. a non-listed one vs. grass-ownership paths; for the DELETE, test both feature-flag states; for the lookup, test missing-JWT → `401`.

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend`; side-effect `is_authorized()` only.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: investigate manually before selecting (likely C, or A for the grass-ownership branch of `get_products`).
- **Key API**: TBD after review — replace the raw `jwt_identity_id`/allow-list checks with PP resource/action authorization.
- **Caller analysis required**: Yes.
- **Risk**: Medium — ambiguous intent; do not auto-migrate.

> **Excluded paths** (`GET /hello/`, `GET /public/localization/languages`) require no PP migration and are omitted from findings and migration order.

---

## Caller analysis and rollout safety

> ⚠️ **Adding an enforcing PP check to a live endpoint will reject requests from any
> caller that cannot send a valid JWT.** Always follow this three-step rollout:

### Step 1 — Identify callers (Datadog APM, required before any code change)

1. Open [Datadog APM](https://sonymusic-pde.datadoghq.com/apm/home) → find this service.
2. Navigate to each endpoint resource and open the **Dependencies** tab.
3. Set the time window to **1 month** to capture infrequent callers.
4. Classify each caller using **only** the types in this table (do not add rows for caller types not listed here):

| Caller Type | JWT Support | Rollout Approach |
|---|---|---|
| **SPA / Suite Application (frontend)** | ✅ Yes if authenticated via Auth0; ❌ No if unauthenticated session | Confirm JWT presence in the shadow-phase `would_deny` metric before enforcing; unauthenticated SPAs must log in first |
| **Lambda** | ✅ Yes if M2M JWT provisioned; ❌ No if not yet provisioned | File a ticket to provision a dedicated M2M JWT before enforcing |

### Step 2 — Deploy in shadow mode first (`MigrationAuthorizationBackend` wired)

Wire `MigrationAuthorizationBackend` (Phase 2 in the migration templates) and add the side-effect
`is_authorized()` call. The wrapper always allows traffic and emits the Datadog metric
`pp_auth.rollout.would_deny` whenever the real PP decision *would* have denied. Monitor it over
1–2 weeks. Each increment identifies a caller that *would* have been rejected. The metric is tagged
with `environment`, `service_name`, `action`, `resource_type`, `reason`
(`pp_denied` | `unauthenticated` | `exception`), plus the `extra_tags_getter` tags
(`method`, `endpoint`, `has_authorization_header`, and `profile_type` on Flask). Break down by
`reason`, `endpoint`, and `has_authorization_header` to identify which callers lack a valid JWT.
Resolve every would-deny source before enabling enforcement.

> **Tracing individual denials**: the metric gives you counts, not per-request detail. To
> investigate a specific denial, use Datadog APM → find the service → filter by the endpoint in
> question and look for requests that correlate with a metric spike.

### Step 3 — Enforce (follow-up PR)

We can move to the `enforce` step when we're confident that enabling PP will not result in legitimate traffic being denied.

#### PP Enforce Readiness Criteria

1. PP [resource policies](https://app.notion.com/p/Writing-Cerbos-resource-policies-dbe5cc1d2ffd4ea6aa70bea59e772e6e) and [derived roles](https://app.notion.com/p/Derived-roles-tenants-tenant-hierarchy-13e84204dbdf48f99ce8cf06209d4836) 
   are defined for the application and downstream services. (`/endpoint-resource-action-pp-authorization-table` skill)
2. Human identities in requests from JWT-enabled applications have derived roles attached in PP. Configured using SettingsV2 or pdp-backfill.
3. Machine identities have dedicated M2M tokens and Principal policies defined in PP.
4. Traffic that does not meet the PP criteria can be authorized by the fallback method, if available.
5. All other traffic is rejected.

The `pp_auth.rollout.would_deny` metric is how you verify the criteria above took effect: each
increment is a request PP *would* deny. You're ready to enforce when the only remaining increments
are traffic you intend to reject (criterion 5) — i.e. every legitimate caller (criteria 1–4) already
passes the PP check or is covered by the fallback.

Then ship the Phase 3 enforce change: swap `MigrationAuthorizationBackend` → `PdpAuthorizationBackend`
and restructure the handler to PP-first + legacy fallback (Templates A/B/C for Flask, FA/FB/FC for
FastAPI). This is a code change, not an env-var flip.

## Prerequisites checklist

- [ ] `python-pdp-sdk[migration] == 6.2.0` in service dependencies — currently `python-pdp-sdk==6.1.0` (no `[migration]` extra); **bump to 6.2.0 + add `[migration]`** for `MigrationAuthorizationBackend`.
- [x] `OwsClient` configured (`setup_ows_client()` in `product/api.py`).
- [x] **Flask**: `AuthorizationBackend` wired (`PdpAuthorizationBackend` instantiated in `product/api.py`). *Endpoint-level `is_authorized` calls still need to be added to each handler — none exist today.*
- [x] Cerbos resource/action policies mapping started (see the [PP authorization Notion table](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242)) — confirm policies are actually deployed in Cerbos.
- [ ] All upstream callers identified and capable of sending a JWT (per-endpoint Datadog APM analysis).
- [ ] Tenant resolver for non-`product_id`-keyed endpoints (UPC / track / ISRC / language / account) — `get_tenant()` currently resolves by `product_id` only.

## Recommended Migration Order

| Priority | Endpoint | Posture | Template | Notes |
|---|---|---|---|---|
| HIGH | POST /upc/provision | 🔴 No auth | C | Mutating; no enforcement; confirm caller (internal?) |
| HIGH | POST /upc/placeholder | 🔴 No auth | C | Mutating; no enforcement |
| HIGH | POST /upc/available | 🔴 No auth | C | No enforcement |
| HIGH | PUT /localization/track/\<tuid\>/language/\<lang\> | 🔴 No auth | C | Mutating; needs track→tenant resolver |
| HIGH | PUT /localization/tracks | 🔴 No auth | C | Mutating; needs tenant resolver |
| HIGH | DELETE /localization/track/* | 🔴 No auth | C | Mutating; needs tenant resolver |
| HIGH | GET /product/\<id\>/asset-version | 🔴 No auth | C | Read; PUT sibling has a check, GET doesn’t |
| HIGH | GET /products, POST /bulk-upc, POST /product-id-by-upc-dataloader | 🔴 No auth | C | Bulk lookups; confirm callers |
| HIGH | HEAD/GET /upc/*, /\<account_type\>/.../product_code/available, HEAD .../product/\<id\> | 🔴 No auth | C | Existence/ownership lookups; no gate |
| HIGH | GET /sound-recording/\<isrc\>/products, GET /release-artist/\<id\>/product, GET /log, GET /hfa/eligible-tracks | 🔴 No auth | C | Internal-only by convention; confirm + M2M where needed |
| HIGH | GET /language/\<id\>, GET/POST/DELETE /localization/product/\<id\>[/language/\<lang\>] | 🔴 Access rules (disabled) | C | Rules defined but `verify_access=False`; product-keyed → `get_tenant` works |
| HIGH | GET /product/\<account_type\>/\<uuid\>/product_code/\<code\>, HEAD /vendor/\<id\>/product_code/\<code\> | 🔴 Access rules (disabled) | C | Rules dormant; no handler gate |
| HIGH | GET /product/\<id\>/document, POST /products/documents | 🔴 Access rules (disabled) | C | Internal-only (reject grass); rules dormant |
| HIGH | POST/PUT/DELETE /product/\<id\>/subgenre | 🟡 Grass only (permissive) | C | Mutating; implicit-allow when header-less; product-keyed |
| HIGH | POST/PUT/DELETE /product/\<id\>/artist | 🟡 Grass only (permissive) | C | Mutating; implicit-allow |
| HIGH | POST/PUT/DELETE /product/\<id\>/phonetic-translations | 🟡 Grass only (permissive) | C | Mutating; implicit-allow |
| HIGH | PUT /product/\<id\>/territory_dates | 🟡 Grass only (permissive) | C | Mutating; implicit-allow |
| HIGH | PUT /product/\<id\>/asset-version | 🟡 Grass only (permissive) | C | Mutating; implicit-allow |
| HIGH | POST /product/\<id\>/copy, POST /product/\<id\>/copy/\<context\> | 🟡 Grass only (permissive) | C | Mutating; implicit-allow (copy/\<context\> also has dormant rule) |
| HIGH | POST /\<account_type\>/\<id\>/upcs | 🟡 Grass only (permissive) | C | `required=False`; implicit-allow |
| MEDIUM | GET /product/\<id\>/subgenre, GET /product/\<id\>/artist, GET /product/\<id\>/phonetic-translations | 🟡 Grass only (permissive) | C | Reads; lower urgency than mutating siblings |
| MEDIUM | GET /product/\<id\> | 🟡 Grass only (required) | A | Existing `assert_authorization` wires directly; also dormant rule |
| MEDIUM | GET /product/upc/\<upc\> | 🟡 Grass only (required) | A | Needs UPC→product tenant resolver |
| MEDIUM | GET /vendor/\<id\>/display_upc/\<upc\> | 🟡 Grass only (required) | A | Already rejects header-less |
| MEDIUM | HEAD /vendor/\<id\>/display_upc/\<upc\>/available, HEAD /subaccount/\<id\>/display_upc/\<upc\>/available | 🟡 Grass only (required) | A | ⚠️ verify `vendor_id=`/`subaccount_id=` kwarg actually binds |
| LOW | GET /\<account_type\>/\<id\>/products | ⚪ Needs human review | A (TBD) | Raw `jwt_identity_id` allow-list + grass ownership; resolve intent |
| LOW | DELETE /product/\<id\> | ⚪ Needs human review | C/A (TBD) | Feature-flag-gated auth; resolve flag state |
| LOW | POST /lookup/product/ownership/ | ⚪ Needs human review | C (TBD) | Excluded from access-rules; JWT-presence-only gate |

**Tier rationale**: every mutating endpoint with no/implicit-allow auth is HIGH (largest risk of
unauthorized writes); grass-required and read-only permissive endpoints are MEDIUM (already partially
protected); the three ambiguous endpoints are LOW pending manual review. No 🟢 PP-enforced endpoints
exist yet — this service is at the **start** of the migration despite the backend being wired.
