# Auth Scan: ows-product-workflow

Generated by: `python-service-scan-authz-baseline` skill on 2026-07-01

## Summary

| Posture | Count |
|---|---|
| 🟢 PP enforced (complete) | 0 |
| 🟡 PP + permissive fallback | 0 |
| 🟡 PP + required fallback | 0 |
| 🟡 PP + legacy fallback (FastAPI) | 0 |
| 🟡 Legacy only — needs PP (FastAPI) | 0 |
| 🟡 Grass only (required) | 0 |
| 🟡 Grass only (permissive) | 0 |
| 🟡 Access rules (enforced) | 0 |
| 🔴 Access rules (disabled) | 0 |
| 🔴 Access rules (default deny / unmatched) | 0 |
| 🔴 No auth | 17 |
| 🔴 No resource-level auth (FastAPI) | 0 |
| ⚪ Needs human review | 1 |

## Auth infrastructure

*Flask:*
- `verify_access`: not set (no `rules_file` argument at all) · `access_log_only`: not set · `rules_file`: none found (`access_rules.yml` does not exist in this repo) · `exclude_paths`: `[]` (the call at `product_workflow/api.py:27` — `flask_request.setup(app, config.ENVIRONMENT, uwsgi_cache_enabled=uwsgiRunning, add_request_context=True)` — passes no `exclude_paths`)
- > ⚠️ Interpretation: there is **no access-rules middleware in this service at all** — not disabled, never configured. Every route is reachable by any caller unless a handler adds its own check. Note: `flask_logger.setup(..., exclude_paths=[config.HEALTH_CHECK])` at `api.py:17-20` only excludes `/hello/` from **logging**, not from auth — it has no bearing on this scan.

Other legacy mechanisms checked and **not found in use**:
- No `access_rules.yml` anywhere in the repo.
- No `verify_grass_access` / `verify_grass_headers` call sites. `product_workflow/validation/json_schema.py:156` defines a `reject_grass_headers` decorator (rejects requests carrying grass headers with a 400) but it is **never applied to any handler** — dead code, not an auth control.
- No `verify_rules_access`.
- No `python-pdp-sdk` — not in `pyproject.toml`, no `is_authorized*` / `AuthorizationBackend` call sites anywhere.

## Endpoint Auth Posture

| Posture | Endpoint | Handler | File | Evidence | Confidence |
|---|---|---|---|---|---|
| 🔴 No auth | GET /hello/ | `health` | handlers.py:36 | No auth infra active anywhere in service (standard health check — no PP migration needed, see note below) | High |
| 🔴 No auth | GET /release-approval/<int:release_approval_id>/rejections | `get_rejection_notes` | handlers.py:45 | Only header/schema validation (`json_schema.validate_request_headers`); no identity/access check | High |
| 🔴 No auth | PUT /release-approval/rejections | `update_rejections` | handlers.py:53 | Only header/body schema validation; no identity/access check | High |
| 🔴 No auth | GET /product/<int:product_id>/release-approval | `get_last_release_approval_for_product` | handlers.py:63 | Only header validation; no identity/access check | High |
| 🔴 No auth | POST /product/<int:product_id>/release-approval | `create_release_approval_for_product` | handlers.py:73 | Only header/body validation; no identity/access check | High |
| ⚪ Needs human review | POST /product/<int:product_id>/submission | `submit_product_by_id` | handlers.py:84 | Conditional partial identity check — see Detailed Findings | High |
| 🔴 No auth | GET /product/<int:product_id>/correction | `get_last_release_correction_for_product` | handlers.py:103 | Only header validation; no identity/access check | High |
| 🔴 No auth | GET /correction/<int:release_correction_id> | `get_correction` | handlers.py:110 | Only header validation; no identity/access check | High |
| 🔴 No auth | POST /product/<int:product_id>/correction | `create_correction_for_product` | handlers.py:118 | Only header/body validation; no identity/access check | High |
| 🔴 No auth | POST /correction/<int:release_correction_id>/details | `upsert_correction_details` | handlers.py:131 | Only header/body validation; no identity/access check | High |
| 🔴 No auth | PUT /correction/<int:release_correction_id> | `update_release_correction` | handlers.py:142 | Only header/body validation; no identity/access check | High |
| 🔴 No auth | DELETE /correction/<int:release_correction_id> | `delete_correction_for_product` | handlers.py:156 | Only header validation; no identity/access check | High |
| 🔴 No auth | POST /product/<int:product_id>/approve | `approve_product` | handlers.py:163 | No decorators at all — not even header/body schema validation | High |
| 🔴 No auth | GET /product/<int:product_id>/lyrics | `get_track_lyrics_for_product` | handlers.py:186 | No decorators at all | High |
| 🔴 No auth | GET /product/<int:product_id>/lyrics/<int:track_id> | `get_lyrics_for_track` | handlers.py:201 | No decorators at all | High |
| 🔴 No auth | POST /product/<int:product_id>/unsubmit | `unsubmit_product_by_id` | handlers.py:217 | Only header validation; no identity/access check | High |
| 🔴 No auth | POST /product/<int:product_id>/process_metadata_queue | `process_metadata` | handlers.py:224 | No decorators at all; the underlying `metadata_queue.process()` is currently a no-op | High |
| 🔴 No auth | POST /product/<int:product_id>/metadata_queue | `create_metadata_update` | handlers.py:232 | Requires `Orchard-Identity-Id` header to be *present* (handlers.py:244), but never checks that identity against any role/ownership rule | High |

## Detailed Findings

### GET /hello/

- **Current posture**: 🔴 No auth (standard health check — excluded from Recommended Migration Order below; no resource to authorize)
- **Handler**: `health` in `handlers.py:36`
- **Auth call**: none
- **PP status**: Not started

### GET /release-approval/<int:release_approval_id>/rejections

- **Current posture**: 🔴 No auth
- **Handler**: `get_rejection_notes` in `handlers.py:45`
- **Auth call**: `json_schema.validate_request_headers` only validates header *shape* against the RAML spec — it is not an identity or ownership check
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm a request with no `Authorization` header currently returns 200 with rejection notes for an arbitrary `release_approval_id`.
- Confirm cross-tenant access: an identity from tenant A can currently read rejection notes for a `release_approval_id` belonging to tenant B.
- Capture the current response shape for a valid `release_approval_id` and for a non-existent one.

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend` wrapping `PdpAuthorizationBackend`; side-effect-only `is_authorized()` (always allows, emits `pp_auth.rollout.would_deny`).
- **Behavior change**: None — no existing auth to preserve; this measures rollout readiness only.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Currently zero enforcement, so any caller lacking a JWT will be newly rejected — must confirm all callers via Datadog APM first.

> Template C means no fallback — see *Caller analysis and rollout safety* below.

---

### PUT /release-approval/rejections

- **Current posture**: 🔴 No auth
- **Handler**: `update_rejections` in `handlers.py:53`
- **Auth call**: none beyond header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `update` (`is_authorized_many` — batch of `rejections`)

#### Phase 1 — Baseline integration tests
- Confirm the endpoint currently accepts an unauthenticated batch update spanning rejection notes for multiple, unrelated `release_approval_id`s / products.
- Capture behavior when the batch mixes rejections belonging to different tenants in one call — this determines whether `is_authorized_many` or `is_authorized_many_resources_and_actions` is correct in Phase 3.

#### Phase 2 — Shadow
- Same pattern as above; add side-effect-only `is_authorized()` per rejection in the batch.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized_many` (`digital_audio`, `update`)
- **Caller analysis required**: Yes
- **Risk**: Mutating + batched — highest-risk row in this scan if any caller lacks a JWT.

---

### GET /product/<int:product_id>/release-approval

- **Current posture**: 🔴 No auth
- **Handler**: `get_last_release_approval_for_product` in `handlers.py:63`
- **Auth call**: none beyond header schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated read access for an arbitrary `product_id`.
- Confirm behavior for a `product_id` with no release-approval record yet.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Read-only, but currently exposes product release status to any caller.

---

### POST /product/<int:product_id>/release-approval

- **Current posture**: 🔴 No auth
- **Handler**: `create_release_approval_for_product` in `handlers.py:73`
- **Auth call**: none beyond header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `create` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated creation of a release-approval record for an arbitrary `product_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `create`)
- **Caller analysis required**: Yes
- **Risk**: Mutating; creates a workflow record with no gate today.

---

### POST /product/<int:product_id>/submission

- **Current posture**: ⚪ Needs human review
- **Handler**: `submit_product_by_id` in `handlers.py:84`
- **Auth call**: **Inconsistent, partial identity check.** If the request body includes `account_id` **and** `account_type`, the handler performs **no check at all** (falls straight through to `product_submission.submit_digital_product`). Only when *both* are missing does it call `handler_util.is_identity_authorized(submit_data.get('identity_id'))`, which checks the given `identity_id` against a hardcoded allowlist (`AUTHORIZED_IDENTITIES` in `product_workflow/constants/authorization.py`, currently one lambda UUID: `BULK_SESSION_INGEST_SUBMIT_PRODUCT_LAMBDA_IDENTITY_UUID`). This is a client-supplied `identity_id` (from the JSON body, not a JWT claim) checked against a static allowlist — not a PP-compatible resource/action decision, and the majority code path (`account_id`+`account_type` present) has no check whatsoever.
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `submit` (new action — see `_cerbos_proposals.md` from the prior `endpoint-resource-action-pp-authorization-table` run)

> ⚠️ This endpoint needs a design decision before it can be scaffolded like the others: is the
> `account_id`/`account_type` path meant for authenticated human/service callers (who should go
> through PP) and the `identity_id`-allowlist path meant only for the one known lambda (which should
> become a PP machine principal policy instead of a body-supplied ID check)? Recommend resolving this
> with the service owner before writing Phase 1 tests, since the two paths likely need different
> PP treatments (human identity vs. machine principal).

---

### GET /product/<int:product_id>/correction

- **Current posture**: 🔴 No auth
- **Handler**: `get_last_release_correction_for_product` in `handlers.py:103`
- **Auth call**: none beyond header schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated read for an arbitrary `product_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Read-only exposure of correction status.

---

### GET /correction/<int:release_correction_id>

- **Current posture**: 🔴 No auth
- **Handler**: `get_correction` in `handlers.py:110`
- **Auth call**: none beyond header schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated read for an arbitrary `release_correction_id`, including one belonging to a different tenant than the caller.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Read-only, but keyed directly by `release_correction_id` with no product/tenant scoping in the URL — good candidate to double check ownership resolution logic (`get_tenant`) during Phase 3 design.

---

### POST /product/<int:product_id>/correction

- **Current posture**: 🔴 No auth
- **Handler**: `create_correction_for_product` in `handlers.py:118`
- **Auth call**: none beyond header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `create` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated creation of a correction record for an arbitrary `product_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `create`)
- **Caller analysis required**: Yes
- **Risk**: Mutating; creates a correction with no gate today.

---

### POST /correction/<int:release_correction_id>/details

- **Current posture**: 🔴 No auth
- **Handler**: `upsert_correction_details` in `handlers.py:131`
- **Auth call**: none beyond header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `update` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated upsert of correction details for an arbitrary `release_correction_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `update`)
- **Caller analysis required**: Yes
- **Risk**: Mutating; no product/tenant scoping in the URL itself, same ownership-resolution note as `GET /correction/<id>`.

---

### PUT /correction/<int:release_correction_id>

- **Current posture**: 🔴 No auth
- **Handler**: `update_release_correction` in `handlers.py:142`
- **Auth call**: none beyond header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `update` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated update for an arbitrary `release_correction_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `update`)
- **Caller analysis required**: Yes
- **Risk**: Mutating.

---

### DELETE /correction/<int:release_correction_id>

- **Current posture**: 🔴 No auth
- **Handler**: `delete_correction_for_product` in `handlers.py:156`
- **Auth call**: none beyond header schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `delete` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated deletion of an arbitrary `release_correction_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `delete`)
- **Caller analysis required**: Yes
- **Risk**: Destructive and currently ungated — highest-severity row alongside the batch rejection update.

---

### POST /product/<int:product_id>/approve

- **Current posture**: 🔴 No auth
- **Handler**: `approve_product` in `handlers.py:163`
- **Auth call**: none — no decorators at all, not even header/body schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `review` (reuse existing `content_can_review_digital_audio` rule — see `_cerbos_proposals.md`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated approval for an arbitrary `product_id`, and that it applies correction-lyrics values as a side effect.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `review`)
- **Caller analysis required**: Yes
- **Risk**: This finalizes a release approval with no gate at all today — one of the two highest-severity rows in this scan.

---

### GET /product/<int:product_id>/lyrics

- **Current posture**: 🔴 No auth
- **Handler**: `get_track_lyrics_for_product` in `handlers.py:186`
- **Auth call**: none — no decorators at all
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated read for an arbitrary `product_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Read-only.

---

### GET /product/<int:product_id>/lyrics/<int:track_id>

- **Current posture**: 🔴 No auth
- **Handler**: `get_lyrics_for_track` in `handlers.py:201`
- **Auth call**: none — no decorators at all
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `view` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated read for an arbitrary `product_id` / `track_id` pair.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `view`)
- **Caller analysis required**: Yes
- **Risk**: Read-only.

---

### POST /product/<int:product_id>/unsubmit

- **Current posture**: 🔴 No auth
- **Handler**: `unsubmit_product_by_id` in `handlers.py:217`
- **Auth call**: none beyond header schema validation
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `unsubmit` (new action — see `_cerbos_proposals.md`)

#### Phase 1 — Baseline integration tests
- Confirm current unauthenticated deletion of the latest release-approval-queue record for an arbitrary `product_id`.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `unsubmit`)
- **Caller analysis required**: Yes
- **Risk**: Mutating/destructive (deletes the latest approval record); no gate today.

---

### POST /product/<int:product_id>/process_metadata_queue

- **Current posture**: 🔴 No auth
- **Handler**: `process_metadata` in `handlers.py:224`
- **Auth call**: none — no decorators at all
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `process` (new action — see `_cerbos_proposals.md`); confirm this endpoint is still needed first
- **Note**: `product_workflow/logic/metadata_queue.py:process()` is currently a no-op (`# Disabled by CCM-1650`, body is `pass`) and ignores `product_id` entirely. Migrating auth here has limited value unless the service owner confirms the endpoint is still live/wired to something downstream.

#### Phase 1 — Baseline integration tests
- Confirm the endpoint currently returns 200 for any caller regardless of `product_id` (expected, given the no-op body).

#### Phase 2 — Shadow
- Low priority given the no-op state — recommend deferring until the service owner confirms this endpoint should stay.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `process`)
- **Caller analysis required**: Yes (if kept)
- **Risk**: Low in practice today (no-op), but the endpoint is still reachable and unauthenticated.

---

### POST /product/<int:product_id>/metadata_queue

- **Current posture**: 🔴 No auth
- **Handler**: `create_metadata_update` in `handlers.py:232`
- **Auth call**: Requires the `Orchard-Identity-Id` header to be **present** (`handlers.py:241-246`) or returns a 400; the value itself is never validated against any role, ownership, or allowlist — presence-only, not an authorization decision.
- **PP status**: Not started
- **Proposed PP mapping**: `digital_audio` / `create` (`is_authorized`)

#### Phase 1 — Baseline integration tests
- Confirm current behavior: any non-empty `Orchard-Identity-Id` header value is accepted regardless of whether that identity has any relationship to `product_id`'s tenant.
- Confirm the 400 response when the header is absent.

#### Phase 2 — Shadow
- Same pattern as above.

#### Phase 3 — Enforce
- **Template**: C
- **Key API**: `PdpAuthorizationBackend.is_authorized` (`digital_audio`, `create`)
- **Caller analysis required**: Yes
- **Risk**: Mutating; the existing header-presence check gives a false sense of enforcement — worth calling out to the service owner that it is not an authorization control.

---

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

> **This service has no legacy fallback available** (no grass headers, no access rules) for any
> endpoint except the reviewed-separately `POST /product/<int:product_id>/submission`. Every other
> endpoint uses **Template C** — enforcement will be strict PP-or-403 with nothing to fall back to,
> which raises the stakes on Step 1 (caller identification) relative to services that have a grass
> fallback to lean on during migration.

## Prerequisites checklist

- [ ] `python-pdp-sdk[migration] == 6.2.0` in service dependencies (the `migration` extra provides `MigrationAuthorizationBackend`) — **not currently a dependency**
- [ ] `OwsClient` configured (see `python-microservice-add-owsclient` skill) — **not currently configured**
- [ ] `AuthorizationBackend` wired (see `python-microservice-add-authorization-backend` skill) — **not currently wired**
- [ ] Cerbos resource/action policies exist — partially: `digital_audio` / `view` and `review` already exist and can be reused; `create`, `update`, `delete`, `submit`, `unsubmit`, `process` are proposed but not yet defined (see `_cerbos_proposals.md` from the prior `endpoint-resource-action-pp-authorization-table` run in this repo)
- [ ] All upstream callers identified and capable of sending a JWT — **not yet done; no Datadog caller analysis performed as part of this scan**

## Recommended Migration Order

Endpoint discovery confidence: **High.** All routes are registered via `@app.route(...)` in the
single `product_workflow/handlers.py` module — no blueprints, `add_url_rule`, `MethodView`, or
`flask_restful` resources found anywhere else in the codebase (confirmed by grepping for those
patterns outside `handlers.py`, which returned only a docstring example, not a real registration).

| Priority | Endpoint | Posture | Template | Notes |
|---|---|---|---|---|
| HIGH | DELETE /correction/<int:release_correction_id> | 🔴 No auth | C | Destructive; no gate today |
| HIGH | POST /product/<int:product_id>/approve | 🔴 No auth | C | Finalizes a release approval; no gate today |
| HIGH | PUT /release-approval/rejections | 🔴 No auth | C | Mutating, batched across possibly multiple products/tenants |
| HIGH | POST /product/<int:product_id>/unsubmit | 🔴 No auth | C | Deletes the latest approval record; no gate today |
| HIGH | POST /product/<int:product_id>/release-approval | 🔴 No auth | C | Creates a workflow record; no gate today |
| HIGH | POST /product/<int:product_id>/correction | 🔴 No auth | C | Creates a correction record; no gate today |
| HIGH | POST /correction/<int:release_correction_id>/details | 🔴 No auth | C | Mutating; no product/tenant scoping in URL |
| HIGH | PUT /correction/<int:release_correction_id> | 🔴 No auth | C | Mutating; no gate today |
| HIGH | POST /product/<int:product_id>/metadata_queue | 🔴 No auth | C | Mutating; header-presence check gives false sense of enforcement |
| HIGH | GET /release-approval/<int:release_approval_id>/rejections | 🔴 No auth | C | Read-only but no gate today |
| HIGH | GET /product/<int:product_id>/release-approval | 🔴 No auth | C | Read-only but no gate today |
| HIGH | GET /product/<int:product_id>/correction | 🔴 No auth | C | Read-only but no gate today |
| HIGH | GET /correction/<int:release_correction_id> | 🔴 No auth | C | Read-only but no gate today; no product/tenant scoping in URL |
| HIGH | GET /product/<int:product_id>/lyrics | 🔴 No auth | C | Read-only but no gate today |
| HIGH | GET /product/<int:product_id>/lyrics/<int:track_id> | 🔴 No auth | C | Read-only but no gate today |
| HIGH | POST /product/<int:product_id>/process_metadata_queue | 🔴 No auth | C | Currently a no-op — confirm still needed before migrating |
| LOW | POST /product/<int:product_id>/submission | ⚪ Needs human review | TBD | Resolve the inconsistent identity-check design (human vs. machine caller paths) before scaffolding — see Detailed Findings |

`GET /hello/` (health check) is omitted from this table — it has no resource to authorize and
needs no PP migration.

## References

- [`references/auth-system-quick-reference.md`](references/auth-system-quick-reference.md) — grass headers, access_rules, PP+grass fallback pattern
- [`references/flask-migration-templates.md`](references/flask-migration-templates.md) — Phase 2 shadow wiring (`MigrationAuthorizationBackend` + `request_tags`); Phase 3 enforce Templates A, B, C
- [`references/caller-analysis-and-rollout.md`](references/caller-analysis-and-rollout.md) — Datadog APM rollout steps; caller classification
- [`references/important-notes.md`](references/important-notes.md) — pitfalls and cross-cutting reminders
