# Auth Scan: ows-product-digital

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

## Summary

> Counts are **posture-row** counts, not unique endpoints. Most endpoints have **layered auth**
> (middleware access-rules **and** a handler-level grass call), so they appear in more than one row
> per the multi-posture rule. 35 routes total: 32 in `handlers.py`, 3 in `unsafe_handlers.py`.

| Posture | Count |
|---|---|
| 🟢 No auth (excluded) | 2 |
| 🟢 PP enforced (complete) | 0 |
| 🟡 PP + permissive fallback | 1 |
| 🟡 PP + required fallback | 0 |
| 🟡 Grass only (required) | 0 |
| 🟡 Grass only (permissive) | 26 |
| 🟡 Access rules (enforced, conditional on `ONLY_LOG_ACCESS_ERRORS`) | 33 |
| 🔴 Access rules (disabled) | 0 |
| 🔴 Access rules (default deny / unmatched) | 0 |
| 🔴 No auth | 0 |
| ⚪ Needs human review | 4 |

## Auth infrastructure

*Flask:*
- `flask_request.setup(app, config.ENVIRONMENT, ..., verify_access=True, rules_file='product_digital/access_rules.yml', access_log_only=config.ONLY_LOG_ACCESS_ERRORS, exclude_paths=config.EXCLUDE_PATH)` — `api.py:40`.
- `verify_access`: `True`, `access_log_only`: **config var** `ONLY_LOG_ACCESS_ERRORS` (`config.py:97` → `True` in **prod**, `False` in qa/dev), `rules_file`: `product_digital/access_rules.yml`, `exclude_paths`: `['/hello/', '/public/meta-language']` (`config.py:99`).
- **PP backend wired ✅** — `PdpAuthorizationBackend(OwsPdpClient(ows_client))` instantiated at module level in `api.py:71`. `OwsClient` configured at `api.py:70`. Endpoint-level `is_authorized` calls still need to be added per handler (only `/meta-language` has one today, observability-only).
- > ⚠️ **Interpretation**: Access-rules enforcement is **inverted by environment**. In **qa/dev** the rules in `access_rules.yml` are **enforced** (rejection on no match). In **prod** `access_log_only=True`, so the rules are **log-only** — they do not block. In production the *effective* gate for most endpoints is the handler-level grass check (`verify_grass_headers`, permissive by default) plus, where present, `verify_grass_ownership`. Endpoints with **no** handler-level grass call (`copy`, `simple_submit`, `delete`) have **no effective auth in prod**.

## Endpoint Auth Posture

> **Column order is fixed — do not reorder.** Endpoints with both middleware access-rules and a
> handler-level grass call emit two rows (multi-posture rule). `verify_grass_headers` is header-only
> and **permissive by default** (no `required=True` anywhere in this service); `verify_grass_ownership`
> (noted in Evidence) adds a vendor/subaccount ownership check but is still legacy, not PP.

| Posture | Endpoint | Handler | File | Evidence | Confidence |
|---|---|---|---|---|---|
| 🟢 No auth (excluded) | GET /hello/ | health | handlers.py:43 | In `exclude_paths` | High |
| 🟢 No auth (excluded) | GET /public/meta-language | get_public_meta_languages | handlers.py:49 | In `exclude_paths` | High |
| 🟡 Access rules (enforced, conditional) | GET /meta-language | get_meta_languages | handlers.py:59 | access_rules.yml explicit rule (`/meta-language` GET) | High |
| 🟡 PP + permissive fallback | GET /meta-language | get_meta_languages | handlers.py:66 | `is_authorized(action="view", resource_type="language")` — observability-only, all errors swallowed; legacy fallback = access rules | High |
| 🟡 Access rules (enforced, conditional) | POST /product/audio | create_product | handlers.py:117 | access_rules.yml explicit (`/product/audio` POST) | High |
| 🟡 Grass only (permissive) | POST /product/audio | create_product | handlers.py:127 | `verify_grass_headers(request)` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | POST /product/<int:product_id>/copy | copy_product | handlers.py:151 | access_rules.yml catch-all `<*>` | High |
| ⚪ Needs human review | POST /product/<int:product_id>/copy | copy_product | handlers.py:152 | `@json_schema.reject_grass_headers` — rejects grass headers; no grass/PP gate | Medium |
| 🟡 Access rules (enforced, conditional) | PUT /product/audio/<int:product_id> | update_product | handlers.py:167 | access_rules.yml explicit (`/product/audio/<int>` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/audio/<int:product_id> | update_product | handlers.py:175 | `verify_grass_headers` + `verify_grass_ownership` (ownership) | High |
| 🟡 Access rules (enforced, conditional) | POST /product/audio/<int:product_id>/submit | simple_submit_product | handlers.py:222 | access_rules.yml catch-all `<*>`; **no handler-level auth** | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/audio/<int:product_id>/status | submit_product | handlers.py:229 | access_rules.yml (`/product/audio/<int>/<*>` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/audio/<int:product_id>/status | submit_product | handlers.py:236 | `verify_grass_headers` + conditional `verify_grass_ownership` | High |
| ⚪ Needs human review | PUT /product/audio/<int:product_id>/status | submit_product | handlers.py:244 | `handler_util.is_identity_authorized(identity_id)` — identity allowlist bypasses PP resource/action flow | Medium |
| 🟡 Access rules (enforced, conditional) | GET /product/audio/<int:product_id> | get_product | handlers.py:271 | access_rules.yml explicit (`/product/audio/<int>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/audio/<int:product_id> | get_product | handlers.py:279 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | POST /product/audio/dataloader | dataload_products | handlers.py:296 | access_rules.yml explicit (`/product/audio/dataloader` POST) | High |
| 🟡 Grass only (permissive) | POST /product/audio/dataloader | dataload_products | handlers.py:317 | `verify_grass_headers` + per-id `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | POST /product/audio/validate/dataloader | dataload_product_audio_validation | handlers.py:335 | access_rules.yml explicit (`/product/audio/validate/dataloader` POST) | High |
| 🟡 Grass only (permissive) | POST /product/audio/validate/dataloader | dataload_product_audio_validation | handlers.py:344 | `verify_grass_headers` + per-id `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | GET /product/audio/<int:product_id>/validate | get_product_audio_validate | handlers.py:403 | access_rules.yml (`/product/audio/<int>/<*>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/audio/<int:product_id>/validate | get_product_audio_validate | handlers.py:415 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | GET /product/<int:product_id>/scheduling-and-pricing | get_scheduling_and_pricing | handlers.py:470 | access_rules.yml (`/product/<int>/<*>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/<int:product_id>/scheduling-and-pricing | get_scheduling_and_pricing | handlers.py:481 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | PUT /release-approval/rejections | update_rejections | handlers.py:496 | access_rules.yml explicit (`/release-approval/rejections` PUT) | High |
| 🟡 Grass only (permissive) | PUT /release-approval/rejections | update_rejections | handlers.py:505 | `verify_grass_headers` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | GET /product/<int:product_id>/tracks/grats | get_grats_for_product | handlers.py:512 | access_rules.yml (`/product/<int>/<*>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/<int:product_id>/tracks/grats | get_grats_for_product | handlers.py:519 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | POST /product/<int:product_id>/correction | create_product_correction | handlers.py:532 | access_rules.yml explicit (`/product/<int>/correction` POST) | High |
| 🟡 Grass only (permissive) | POST /product/<int:product_id>/correction | create_product_correction | handlers.py:543 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | DELETE /product/<int:product_id>/correction/<int:release_correction_id> | delete_release_correction | handlers.py:559 | access_rules.yml explicit (`/product/<int>/correction/<int>` DELETE) | High |
| 🟡 Grass only (permissive) | DELETE /product/<int:product_id>/correction/<int:release_correction_id> | delete_release_correction | handlers.py:571 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | POST /product/<int:product_id>/correction/<int:release_correction_id>/details | create_correction_details | handlers.py:588 | access_rules.yml (`/product/<int>/correction/<*>` POST) | High |
| 🟡 Grass only (permissive) | POST /product/<int:product_id>/correction/<int:release_correction_id>/details | create_correction_details | handlers.py:602 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | GET /product/<int:product_id>/mkt_priority | get_mkt_priority | handlers.py:623 | access_rules.yml (`/product/<int>/<*>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/<int:product_id>/mkt_priority | get_mkt_priority | handlers.py:630 | `verify_grass_headers` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | POST /product/mkt_priority/dataloader | dataload_mkt_priority | handlers.py:638 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | POST /product/mkt_priority/dataloader | dataload_mkt_priority | handlers.py:658 | `verify_grass_headers` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | POST /product/<int:product_id>/mkt_priority | set_mkt_priority | handlers.py:666 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | POST /product/<int:product_id>/mkt_priority | set_mkt_priority | handlers.py:675 | `verify_grass_headers(required=False)` — no ownership; OA-only enforced in logic | High |
| 🟡 Access rules (enforced, conditional) | DELETE /product/<int:product_id>/mkt_priority/<int:country_id> | delete_mkt_priority | handlers.py:697 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | DELETE /product/<int:product_id>/mkt_priority/<int:country_id> | delete_mkt_priority | handlers.py:706 | `verify_grass_headers(required=False)` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/audio/<int:product_id>/unsubmit | unsubmit_product | handlers.py:721 | access_rules.yml (`/product/audio/<int>/<*>` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/audio/<int:product_id>/unsubmit | unsubmit_product | handlers.py:728 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/audio/<int:product_id>/reject | reject_product_from_productreview | handlers.py:747 | access_rules.yml (`/product/audio/<int>/<*>` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/audio/<int:product_id>/reject | reject_product_from_productreview | handlers.py:757 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/audio/<int:product_id>/approve | approve_product_from_productreview | handlers.py:773 | access_rules.yml (`/product/audio/<int>/<*>` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/audio/<int:product_id>/approve | approve_product_from_productreview | handlers.py:786 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/<int:product_id>/not_for_distribution | update_not_for_distribution | handlers.py:802 | access_rules.yml explicit (`/product/<int>/not_for_distribution` PUT) | High |
| 🟡 Grass only (permissive) | PUT /product/<int:product_id>/not_for_distribution | update_not_for_distribution | handlers.py:809 | `verify_grass_headers`; rejects both-grass-headers (m2m), no ownership | High |
| 🟡 Access rules (enforced, conditional) | PUT /product/<int:product_id>/approve | approve | handlers.py:829 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | PUT /product/<int:product_id>/approve | approve | handlers.py:836 | `verify_grass_headers`; OA-only enforced in handler logic, no ownership | High |
| 🟡 Access rules (enforced, conditional) | DELETE /product/<int:product_id> | delete | handlers.py:866 | access_rules.yml catch-all `<*>`; **no handler-level auth** | High |
| 🟡 Access rules (enforced, conditional) | DELETE /product/<int:product_id>/mkt_priority | delete_mkt_priority_in_bulk | handlers.py:885 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | DELETE /product/<int:product_id>/mkt_priority | delete_mkt_priority_in_bulk | handlers.py:894 | `verify_grass_headers(required=False)` — no ownership | High |
| 🟡 Access rules (enforced, conditional) | GET /product/<int:product_id>/spatial | get_product_spatial | handlers.py:908 | access_rules.yml (`/product/<int>/<*>` GET) | High |
| 🟡 Grass only (permissive) | GET /product/<int:product_id>/spatial | get_product_spatial | handlers.py:919 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | DELETE /products/<int:product_id>/spatial | delete_product_spatial | handlers.py:931 | access_rules.yml catch-all `<*>` (note plural `/products/` — no explicit `/product/` rule matches) | High |
| 🟡 Grass only (permissive) | DELETE /products/<int:product_id>/spatial | delete_product_spatial | handlers.py:942 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | POST /product/<int:product_id>/spatial | create_product_spatial | handlers.py:954 | access_rules.yml catch-all `<*>` | High |
| 🟡 Grass only (permissive) | POST /product/<int:product_id>/spatial | create_product_spatial | handlers.py:967 | `verify_grass_headers` + `verify_grass_ownership` | High |
| 🟡 Access rules (enforced, conditional) | DELETE /product/upc/<upc> | UNSAFE_delete_product_by_upc | unsafe_handlers.py:10 | access_rules.yml catch-all `<*>` | High |
| ⚪ Needs human review | DELETE /product/upc/<upc> | UNSAFE_delete_product_by_upc | unsafe_handlers.py:11 | `@only_for_identity(config.DELETE_PRODUCT_IDENTITY)` — JWT identity allowlist | Medium |
| 🟡 Access rules (enforced, conditional) | PUT /product/<int:product_id>/display_upc | UNSAFE_update_display_upc | unsafe_handlers.py:30 | access_rules.yml catch-all `<*>` | High |
| ⚪ Needs human review | PUT /product/<int:product_id>/display_upc | UNSAFE_update_display_upc | unsafe_handlers.py:31 | `@only_for_identity([UPDATE_DISPLAY_UPC_GRPS_IDENTITY, UPDATE_DISPLAY_UPC_DDEX_IDENTITY])` | Medium |
| 🟡 Access rules (enforced, conditional) | DELETE /product/<int:product_id>/soft_delete | UNSAFE_soft_delete | unsafe_handlers.py:50 | access_rules.yml catch-all `<*>` | High |
| ⚪ Needs human review | DELETE /product/<int:product_id>/soft_delete | UNSAFE_soft_delete | unsafe_handlers.py:51 | `@only_for_identity(config.PRODUCT_SOFT_DELETE_IDENTITY)` | Medium |

**Endpoint discovery confidence**: High. Discovery via `@app.route` grep across `product_digital/` (32 routes in `handlers.py`, 3 in `unsafe_handlers.py`; `@app.route` matches in `auth.py`/`json_schema.py` are docstring examples, not real routes). No `add_url_rule`, `MethodView`, `flask_restful add_resource`, or blueprint registration patterns present. Health/error handlers excluded from migration scope.

## Detailed Findings

> Findings are grouped by **archetype** because 26 endpoints share an identical grass-permissive
> posture. The prod-effective behavior (access rules log-only in prod) is the cross-cutting risk —
> see Auth infrastructure. Templates cited by letter (A/B/C) — see
> `references/flask-migration-templates.md`.

### Archetype 1 — Grass-permissive + `verify_grass_ownership` (read/write, ownership-gated)

Endpoints: `update_product` (PUT /product/audio/<id>), `get_product`, `dataload_products`,
`dataload_product_audio_validation`, `get_product_audio_validate`, `get_scheduling_and_pricing`,
`get_grats_for_product`, `create_product_correction`, `delete_release_correction`,
`create_correction_details`, `unsubmit_product`, `reject_product_from_productreview`,
`approve_product_from_productreview`, `get_product_spatial`, `delete_product_spatial`,
`create_product_spatial`.

- **Current posture**: 🟡 Grass only (permissive) + access rules (enforced, conditional)
- **Auth call**: `verify_grass_headers(request)` (permissive — no-header requests pass) followed by `verify_grass_ownership(request, check_product_ownership, product_id=...)`. Ownership is checked when grass headers are present; service/OA callers without grass headers bypass.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Vendor with matching grass headers + owns product → 200.
- Vendor with grass headers for a product they do **not** own → expect current denial (403/4xx via ownership).
- Request with **no** grass headers (service-to-service / OA) → observe current pass-through (200).
- Run each rejection case twice: under enforcement (qa) and log-only (prod) — record both outcomes since `access_log_only` is environment-dependent.

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend` wrapping `PdpAuthorizationBackend`; side-effect-only `is_authorized()` (always allows, emits `pp_auth.rollout.would_deny`).
- **Behavior change**: None — grass ownership stays in force; measures rollout readiness.

#### Phase 3 — Enforce
- **Template**: C (grass entry is permissive — no required gate to fall back to).
- **Key API**: `PdpAuthorizationBackend` (swapped in for the shadow wrapper) + `is_authorized` per handler with the resource_type/action from the resource-action table.
- **Caller analysis required**: Yes — service-to-service callers without JWTs will be denied.
- **Risk**: Medium. Ownership already restricts vendor callers; main risk is unauthenticated m2m/OA traffic.

### Archetype 2 — Grass-permissive, NO ownership (mutating + bulk)

Endpoints: `create_product` (POST /product/audio), `update_rejections`, `get_mkt_priority`,
`dataload_mkt_priority`, `set_mkt_priority` (`required=False`), `delete_mkt_priority` (`required=False`),
`delete_mkt_priority_in_bulk` (`required=False`), `update_not_for_distribution`, `approve`.

- **Current posture**: 🟡 Grass only (permissive) + access rules (enforced, conditional)
- **Auth call**: `verify_grass_headers(request[, required=False])` only — header-only, no ownership. Some endpoints add ad-hoc logic gates (OA-only via `OA_USER_PREFIX`, reject both-grass-headers) but no resource ownership check.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Caller with grass headers → 200.
- Caller with **no** grass headers → observe pass-through (these mutate without ownership today).
- For OA-gated endpoints (`set_mkt_priority`, `approve`, `not_for_distribution`): non-OA user → current 403; OA user → 200.
- Dual outcome (qa enforced vs prod log-only) for any access-rules-driven rejection.

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

#### Phase 3 — Enforce
- **Template**: C (no fallback — strict PP; any request failing the PP check is rejected 403).
- **Key API**: `PdpAuthorizationBackend` + `is_authorized` per handler.
- **Caller analysis required**: Yes.
- **Risk**: **High** — these mutate product/pricing/approval state with only a permissive header check. Highest-value targets for PP enforcement.

### Archetype 3 — No handler-level auth (access rules only; log-only in prod)

Endpoints: `copy_product` (POST /product/<id>/copy — `reject_grass_headers`), `simple_submit_product`
(POST /product/audio/<id>/submit), `delete` (DELETE /product/<id>).

- **Current posture**: 🟡 Access rules (enforced, conditional) — **no grass, no PP**.
- **Auth call**: None at handler level. `copy_product` actively rejects grass headers. In **prod** (`access_log_only=True`) these have **no effective enforcement**.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Any authenticated caller → observe current 200 (no resource check).
- Confirm prod behavior: request that access_rules.yml would deny still passes in prod (log-only) → 200; in qa → 403.

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

#### Phase 3 — Enforce
- **Template**: C (no-fallback strict PP enforcement). Do **not** add a grass fallback — there is none today.
- **Key API**: `PdpAuthorizationBackend` + `is_authorized`.
- **Caller analysis required**: Yes — especially `DELETE /product/<id>` (destructive, unauthenticated-passable in prod).
- **Risk**: **High** — `delete` and `simple_submit` mutate/destroy with no effective prod auth.

### Archetype 4 — `/meta-language` (PP observability already wired)

- **Current posture**: 🟡 PP + permissive fallback (observability-only) + access rules (enforced, conditional).
- **Auth call**: `api.authorization_backend.is_authorized(action="view", resource_id=0, resource_type="language", resource_getter=ForwardKwargsGetter())` at `handlers.py:66`. Result is **discarded**; `UnauthenticatedException` and all other exceptions are swallowed/logged (`handlers.py:72-78`). Legacy fallback = access-rules middleware.
- **PP status**: In progress (shadow-equivalent, hand-rolled).

#### Phase 1 — Baseline integration tests
- Authorized profile (per access_rules.yml `/meta-language` rule) → 200.
- Confirm PP failure currently does **not** block (errors swallowed) → 200 regardless of PP decision.

#### Phase 2 — Shadow
- Migrate the hand-rolled try/except to the standard `MigrationAuthorizationBackend` wrapper so the would-deny metric (`pp_auth.rollout.would_deny`) is emitted consistently with other endpoints.

#### Phase 3 — Enforce
- **Template**: C — replace the swallow-all try/except with enforced `is_authorized` (raise 403 on `False`).
- **Key API**: `PdpAuthorizationBackend.is_authorized`.
- **Caller analysis required**: Yes.
- **Risk**: Low-medium — read-only metadata; resource policy `language`/`view` already referenced.

### Archetype 5 — `⚪ Needs human review` (identity allowlists)

Endpoints: `submit_product` (PUT /product/audio/<id>/status — `handler_util.is_identity_authorized`),
`UNSAFE_delete_product_by_upc`, `UNSAFE_update_display_upc`, `UNSAFE_soft_delete`
(all three `@only_for_identity(...)`).

- **Current posture**: ⚪ Needs human review (plus grass/access-rules rows as applicable).
- **Auth call**: `submit_product` calls `is_identity_authorized(identity_id)` (checks `authorization.AUTHORIZED_IDENTITIES` allowlist; `handler_util.py:112`) to *skip* grass ownership for trusted identities. The UNSAFE handlers use `@only_for_identity` (`auth.py:16`) — a JWT `jwt_identity_id` allowlist returning 403 on mismatch.
- **PP status**: Needs human decision before migration.
- **Why human review**: These are partial **identity** checks that bypass PP's resource/action authorization flow. Per CLAUDE.md, UNSAFE-prefixed endpoints must remain restricted to specific identities — confirm whether PP should *replace* or *layer alongside* the identity allowlist. Do not auto-migrate.

#### Phase 1 — Baseline integration tests
- Allowlisted identity → 200; non-allowlisted identity → 403.
- For `submit_product`: allowlisted identity bypasses ownership (200 without grass ownership); non-allowlisted falls through to grass ownership.

#### Phase 2 / 3
- Defer until product/security owner decides the target model for identity-restricted endpoints. If migrated, likely Template A/B (identity → principal policy in PP) — requires explicit M2M principal definitions.

---

## 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 (the `migration` extra provides `MigrationAuthorizationBackend`) — currently `python-pdp-sdk = "^6.0.0"` with no `migration` extra; add the extra and pin.
- [x] `OwsClient` configured — `api.py:70`.
- [x] **Flask**: `AuthorizationBackend` wired — `PdpAuthorizationBackend` instantiated at `api.py:71`. *(Endpoint-level `is_authorized` calls still need to be added per handler — only `/meta-language` has one.)*
- [ ] Cerbos resource/action policies exist (run `endpoint-resource-action-pp-authorization-table` if not) — a draft `ows_product_digital_endpoint_resource_action_table.csv` exists in repo root; verify policies are deployed. Resource/action mapping: [endpoint resource-action table (Notion)](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242).
- [ ] All upstream callers identified and capable of sending a JWT (Datadog APM, per endpoint).
- [ ] Decision recorded for `⚪ Needs human review` endpoints (UNSAFE + `submit_product` identity allowlist) before migration.

## Recommended Migration Order

| Priority | Endpoint | Posture | Template | Notes |
|---|---|---|---|---|
| HIGH | DELETE /product/<int:product_id> | 🔴 No handler auth (access rules log-only in prod) | C | Destructive; no effective prod enforcement |
| HIGH | POST /product/audio/<int:product_id>/submit | 🔴 No handler auth | C | State mutation; no grass/PP |
| HIGH | POST /product/<int:product_id>/copy | 🔴 No handler auth (rejects grass) | C | Creates product copy; access rules log-only in prod |
| HIGH | POST /product/audio | 🟡 Grass permissive, no ownership | C | Creates product; mutating |
| HIGH | PUT /release-approval/rejections | 🟡 Grass permissive, no ownership | C | Mutates approval state |
| HIGH | PUT /product/<int:product_id>/not_for_distribution | 🟡 Grass permissive, no ownership | C | Mutating; m2m blocked but no ownership |
| HIGH | PUT /product/<int:product_id>/approve | 🟡 Grass permissive, no ownership | C | Approval; OA-only logic gate only |
| HIGH | POST /product/<int:product_id>/mkt_priority | 🟡 Grass permissive, no ownership | C | Mutates pricing priority |
| HIGH | DELETE /product/<int:product_id>/mkt_priority/<int:country_id> | 🟡 Grass permissive, no ownership | C | Mutating |
| HIGH | DELETE /product/<int:product_id>/mkt_priority | 🟡 Grass permissive, no ownership | C | Bulk delete priorities |
| HIGH | POST /product/mkt_priority/dataloader | 🟡 Grass permissive, no ownership | C | Bulk read, no ownership |
| HIGH | GET /product/<int:product_id>/mkt_priority | 🟡 Grass permissive, no ownership | C | Read, no ownership |
| MEDIUM | PUT /product/audio/<int:product_id> | 🟡 Grass permissive + ownership | C | Ownership-gated update |
| MEDIUM | GET /product/audio/<int:product_id> | 🟡 Grass permissive + ownership | C | Ownership-gated read |
| MEDIUM | POST /product/audio/dataloader | 🟡 Grass permissive + ownership | C | Per-id ownership |
| MEDIUM | POST /product/audio/validate/dataloader | 🟡 Grass permissive + ownership | C | Per-id ownership |
| MEDIUM | GET /product/audio/<int:product_id>/validate | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | GET /product/<int:product_id>/scheduling-and-pricing | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | GET /product/<int:product_id>/tracks/grats | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | POST /product/<int:product_id>/correction | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | DELETE /product/<int:product_id>/correction/<int:release_correction_id> | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | POST /product/<int:product_id>/correction/<int:release_correction_id>/details | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | PUT /product/audio/<int:product_id>/unsubmit | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | PUT /product/audio/<int:product_id>/reject | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | PUT /product/audio/<int:product_id>/approve | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | GET /product/<int:product_id>/spatial | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | DELETE /products/<int:product_id>/spatial | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | POST /product/<int:product_id>/spatial | 🟡 Grass permissive + ownership | C | Ownership-gated |
| MEDIUM | PUT /product/audio/<int:product_id>/status | ⚪ Needs human review + grass | C (after review) | `is_identity_authorized` allowlist bypass |
| LOW | PUT /product/<int:product_id>/display_upc | ⚪ Needs human review | A/B | `@only_for_identity`; keep restricted (UNSAFE) |
| LOW | DELETE /product/upc/<upc> | ⚪ Needs human review | A/B | `@only_for_identity`; keep restricted (UNSAFE) |
| LOW | DELETE /product/<int:product_id>/soft_delete | ⚪ Needs human review | A/B | `@only_for_identity`; keep restricted (UNSAFE) |
| LOW | GET /meta-language | 🟡 PP + permissive fallback (observability) | C | Already shadow-equivalent; finish enforce |

> **Note on prod log-only**: Because `access_log_only=True` in production, the access-rules layer
> does **not** block in prod. Every grass-permissive and no-handler-auth endpoint relies solely on
> the permissive grass check in prod — which is why mutating endpoints are ranked HIGH regardless of
> their access-rules coverage. Template C is correct for all of them (no required gate to fall back
> to). The 2 excluded paths (`/hello/`, `/public/meta-language`) need no migration.
