# Auth Scan: ows-project-manager

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

## Summary

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

**Total endpoints: 38**

## Auth infrastructure

*Flask:*
- `flask_request.setup()` (`project_manager/api.py:38`) is called with **no `rules_file`, no `verify_access`, no `access_log_only`, and no `exclude_paths`** — only `uwsgi_cache_enabled` and `add_request_context=True`.
- `verify_access`: `N/A (no rules_file)`, `access_log_only`: `N/A`, `rules_file`: `None`, `exclude_paths`: `[]`
- > ⚠️ **Interpretation**: No `access_rules.yml` middleware is active anywhere in this service. There is **no middleware-level authorization** — every endpoint's posture is determined entirely by what its own handler does. (The `exclude_paths` at `api.py:28` belongs to `flask_logger.setup` and governs **logging only**, not access enforcement; `/hello` and `/hello_db` are therefore not "excluded" in the auth sense.)
- **PP is already wired**: `PdpAuthorizationBackend(OwsPdpClient(ows_client))` is instantiated at module load in `api.py:54-60`. The `AuthorizationBackend wired` prerequisite is **✅ complete**. Endpoint-level `is_authorized` calls still need to be added to the non-transfer handlers.
- A reusable PP helper layer already exists in `project_manager/util/authorization.py` (`pdp_authorize_resource`, `pdp_authorize_project_transfer`, `authorize_transfer_job` decorator) — the `/transfer/*` family uses it today.
- > 🚫 **Do NOT reassign the existing `authorization_backend` singleton to `MigrationAuthorizationBackend`.** PP is already *enforcing* on the 9 `/transfer/*` endpoints: `util/authorization.py:48` calls `authorization_backend.is_authorized(...)` and rejects with 403 when it returns `False`. `MigrationAuthorizationBackend` **always returns `True`** (it only emits the `pp_auth.rollout.would_deny` metric), so swapping the shared `api.py:60` singleton — as the generic Flask Phase 2 template shows — would silently disable enforcement on those already-migrated endpoints. Instead, add a **separate** instance (e.g. `shadow_authorization_backend = MigrationAuthorizationBackend(inner_backend=authorization_backend, ...)`) and have only the new shadow helpers use it (return value ignored). The enforcing `authorization_backend` stays untouched so `/transfer/*` is unaffected. See the Phase 2 note below.

## Endpoint Auth Posture

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

| Posture | Endpoint | Handler | File | Evidence | Confidence |
|---|---|---|---|---|---|
| 🟢 PP enforced | GET /transfer/jobs | list_transfer_jobs | handlers.py:769 | `@authorize_transfer_job(action='view', job_scoped=False)` → PP, no fallback | High |
| 🟢 PP enforced | POST /transfer/job | create_transfer_job | handlers.py:776 | `@authorize_transfer_job(action='create', defer_pdp=True)` + explicit `pdp_authorize_project_transfer` at :797 | High |
| 🟢 PP enforced | GET /transfer/job/\<id\> | get_transfer_job | handlers.py:813 | `@authorize_transfer_job(action='view')` | High |
| 🟢 PP enforced | DELETE /transfer/job/\<id\> | delete_transfer_job | handlers.py:840 | `@authorize_transfer_job(action='create')` | High |
| 🟢 PP enforced | GET /transfer/job/\<id\>/products | get_transfer_job_products | handlers.py:847 | `@authorize_transfer_job(action='view')` | High |
| 🟢 PP enforced | PATCH /transfer/job/\<id\>/products | set_destination_artists | handlers.py:854 | `@authorize_transfer_job(action='create')` | High |
| 🟢 PP enforced | POST /transfer/batch/execute | execute_transfer_batch | handlers.py:873 | `@authorize_transfer_job(action='execute_batch', job_scoped=False)` | High |
| 🟢 PP enforced | POST /transfer/job/\<id\>/execute-content-transfer | execute_content_transfer | handlers.py:895 | `@authorize_transfer_job(action='execute_batch', job_scoped=False)` | High |
| 🟢 PP enforced | GET /transfer/job/\<id\>/attachments | get_transfer_job_attachments | handlers.py:902 | `@authorize_transfer_job(action='execute_batch', job_scoped=False)` | High |
| ⚪ Needs human review | PATCH /transfer/job/\<id\> | update_transfer_job | handlers.py:820 | Reads `context.jwt_identity_id` in auth conditional; rejects missing identity (401). **No PP call** despite siblings being PP-enforced | High |
| ⚪ Needs human review | DELETE /project/\<id\>/hard-delete | hard_delete_project | handlers.py:347 | `@only_for_identity(...)` decorator — JWT-identity allowlist (single lambda UUID), bypasses PP resource/action flow | High |
| ⚪ Needs human review | GET /project (by param) | get_project_by_parameter | handlers.py:222 | Custom `orchard_user_id.startswith('oa:')` gate (403 if non-OA); permissive when header absent | Medium |
| 🟡 Grass only (required) | HEAD /ownership/\<account_type\>/\<account_id\>/project/\<project_id\> | check_project_ownership | handlers.py:486 | `verify_grass_access(request, vendor=..., subaccount=...)` (default required) + DB ownership check | High |
| 🟡 Grass only (permissive) | POST /project/\<id\>/mkt_priority | set_mkt_priority | handlers.py:645 | `verify_grass_headers(request, required=False)` — no headers ⇒ pass; + OA-prefix gate | High |
| 🟡 Grass only (permissive) | GET /project/\<id\>/mkt_priority | get_mkt_priority | handlers.py:677 | `verify_grass_headers(request)` — header-only, default permissive | High |
| 🟡 Grass only (permissive) | POST /project/mkt_priority/dataloader | dataload_mkt_priority | handlers.py:692 | `verify_grass_headers(request)` — header-only, default permissive | High |
| 🟡 Grass only (permissive) | DELETE /project/\<id\>/mkt_priority/\<projection_id\> | delete_mkt_priority | handlers.py:719 | `verify_grass_headers(request, required=False)` + OA-prefix gate | High |
| 🟡 Grass only (permissive) | DELETE /project/\<id\>/mkt_priority | delete_mkt_priority_for_project | handlers.py:744 | `verify_grass_headers(request, required=False)` + OA-prefix gate | High |
| 🔴 No auth | POST /project | post_project | handlers.py:157 | Reads Grass headers for optional scoping; no verify, no PP — implicit allow (mutation) | High |
| 🔴 No auth | PUT /project/\<id\> | update_project | handlers.py:190 | Reads Grass headers, passes to logic; no verify, no PP — implicit allow (mutation) | High |
| 🔴 No auth | DELETE /project/\<id\> | delete_project | handlers.py:325 | `access_check()` (header parse only, passes w/o headers); no PP — implicit allow (mutation) | High |
| 🔴 No auth | POST /project/dataloader | dataload_projects | handlers.py:277 | `access_check()` header parse only; no PP — implicit allow | High |
| 🔴 No auth | GET /project/\<id\> | get_project | handlers.py:250 | `access_check()` header parse only; no PP — implicit allow | High |
| 🔴 No auth | GET /report/\<id\> | get_report_metadata | handlers.py:545 | `access_check()` header parse only; no PP — implicit allow | High |
| 🔴 No auth | POST /report/\<id\> | post_report_generation | handlers.py:526 | `access_check()` in logic layer (`label_copy_export.py:26`); no PP — implicit allow | High |
| 🔴 No auth | GET /project/\<id\>/products | get_products_for_project | handlers.py:367 | Reads Grass headers for optional scoping; no verify, no PP — implicit allow | High |
| 🔴 No auth | GET /project/\<id\>/product/\<product_id\> | get_product_for_project | handlers.py:393 | Reads Grass headers for optional scoping; no verify, no PP — implicit allow | High |
| 🔴 No auth | GET /projects | get_projects | handlers.py:427 | Reads Grass headers + OA branching for scoping; no verify, no PP — implicit allow | High |
| 🔴 No auth | GET /project/available | get_project_codes_available_for_use | handlers.py:309 | No auth of any kind; reads JSON body only | High |
| 🔴 No auth | GET /project/\<id\>/document | get_project_document | handlers.py:573 | Internal-only (rejects requests bearing Grass headers); no PP | High |
| 🔴 No auth | GET /product/genres | get_product_genres | handlers.py:56 | Header validation only; no auth (public reference data) | High |
| 🔴 No auth | GET /public/product/genres | get_public_product_genres | handlers.py:69 | Public reference data; no auth | High |
| 🔴 No auth | GET /product/genres/\<id\>/subgenres | get_product_subgenres | handlers.py:111 | Header validation only; no auth (public reference data) | High |
| 🔴 No auth | GET /public/product/genres/\<id\>/subgenres | get_public_product_subgenres | handlers.py:125 | Public reference data; no auth | High |
| 🔴 No auth | GET /product/types | get_product_types | handlers.py:141 | Header validation only; no auth (public reference data) | High |
| 🔴 No auth | GET /hello | health | handlers.py:40 | Health check — non-sensitive, no migration needed | High |
| 🔴 No auth | GET /hello_db | health_db | handlers.py:46 | DB health check — non-sensitive, no migration needed | High |

## Detailed Findings

> The 9 `/transfer/*` endpoints are already 🟢 PP enforced (`util/authorization.py`); they need no migration and are omitted from Detailed Findings. The `/hello*` health checks are non-sensitive and omitted from migration. Findings below cover the endpoints that need work, grouped by posture.
>
> ⚠️ **Phase 2 wiring (read once before starting):** every "Phase 2 — Shadow" step below adds a side-effect-only `is_authorized()` via a **new** `shadow_authorization_backend = MigrationAuthorizationBackend(inner_backend=authorization_backend, ...)`. Do **not** reassign the existing enforcing `authorization_backend` (`api.py:60`) — that singleton is what gates `/transfer/*`, and `MigrationAuthorizationBackend` always allows, so reusing the same name would undo that already-shipped enforcement. See *Auth infrastructure* above.

### POST /project · PUT /project/\<id\> · DELETE /project/\<id\> (mutations, 🔴 No auth)

- **Current posture**: 🔴 No auth — implicit allow
- **Handlers**: `post_project` (handlers.py:157), `update_project` (handlers.py:190), `delete_project` (handlers.py:325)
- **Auth call**: None enforcing. `post_project`/`update_project` read `Grass-Account-Type`/`Grass-Account-Id` and forward them to the logic layer for *optional* tenant scoping; `delete_project` calls `handler_util.access_check()` which only parses the headers (returns `{account_type:None, account_id:None}, 200` when absent). No authorization decision is made.
- **PP status**: Not started (PP backend wired; handler call missing)

#### Phase 1 — Baseline integration tests
- Confirm a request **with** matching Grass headers (owner vendor) currently succeeds (200).
- Confirm a request **without** Grass headers currently succeeds (implicit allow) — capture this as the baseline behavior to preserve in shadow / change in enforce.
- Confirm a request with a **non-owner** vendor's Grass headers — observe actual behavior (does the logic layer scope it out?).
- Confirm malformed `account_type` XOR `account_id` (only one set) → 403 via `access_check` (delete only).

#### Phase 2 — Shadow
- **Key API**: `MigrationAuthorizationBackend` wrapping the existing `PdpAuthorizationBackend`; add a side-effect-only `is_authorized()` (resource_type `project`, action `create`/`update`/`delete`) alongside the untouched header handling. Always allows; emits `pp_auth.rollout.would_deny`.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C (no fallback — current posture is implicit-allow, nothing safe to fall back to).
- **Key API**: `assert_authorization(resource_id=project_id, resource_type='project', action=...)`; reject with the service's `flaskify(response.create_error_response(..., status=403))` convention.
- **Caller analysis required**: Yes — these are the highest-blast-radius mutations.
- **Risk**: High. Any caller that cannot send a valid JWT will be rejected once enforced.

### POST /project/dataloader · GET /project/\<id\> · GET /report/\<id\> · POST /report/\<id\> (🔴 No auth via `access_check`)

- **Current posture**: 🔴 No auth — `access_check()` parses Grass headers but does not authorize; absent headers ⇒ implicit allow.
- **Handlers**: `dataload_projects` (277), `get_project` (250), `get_report_metadata` (545), `post_report_generation` (526, via `label_copy_export.py:26`).
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- With owner Grass headers → 200. Without headers → currently passes (implicit allow). Non-owner headers → observe scoping behavior. Dataloader: posting non-int ids → 400.

#### Phase 2 — Shadow
- **Key API**: side-effect-only `is_authorized()` (resource_type `project`, action `view`; `report` → `view`). Always allows; emits `pp_auth.rollout.would_deny`.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C.
- **Key API**: `assert_authorization(resource_id=project_id, resource_type='project'|'report', action='view')`.
- **Caller analysis required**: Yes.
- **Risk**: Medium–High (reads of tenant-scoped project/report data).

### GET /project/\<id\>/products · GET /project/\<id\>/product/\<product_id\> · GET /projects · GET /project/\<id\>/product/imprints (🔴 No auth, header-scoped reads)

- **Current posture**: 🔴 No auth — reads Grass headers for optional scoping; no verify, no PP.
- **Handlers**: handlers.py:367, :393, :427, :84.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Owner headers → 200 scoped list. No headers → observe (implicit allow / unscoped). Non-owner headers → observe scoping. `/projects`: non-OA with no valid `Grass-Account-Type` → currently 400.

#### Phase 2 — Shadow
- **Key API**: side-effect-only `is_authorized()` (resource_type `project`, action `view`). Always allows; emits `pp_auth.rollout.would_deny`.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C.
- **Key API**: `assert_authorization(resource_id=project_id, resource_type='project', action='view')`.
- **Caller analysis required**: Yes.
- **Risk**: Medium.

### GET /project/available · GET /project/\<id\>/document (🔴 No auth, internal)

- **Current posture**: 🔴 No auth. `/project/available` has no auth at all; `/project/<id>/document` is internal-only and explicitly *rejects* requests that carry Grass headers (returns fatal), but applies no positive authorization.
- **Handlers**: handlers.py:309, :573.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- `/document`: request with Grass headers → fatal/error (preserve). Request with no Grass headers → 200 (internal path). `/available`: any request → 200.

#### Phase 2 — Shadow
- **Key API**: side-effect-only `is_authorized()` (resource_type `project`, action `view`). Always allows.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C.
- **Key API**: `assert_authorization(...)`. For `/document` (service-to-service) confirm callers can send an M2M JWT before enforcing.
- **Caller analysis required**: Yes (M2M callers).
- **Risk**: Medium.

### GET /product/genres · /public/product/genres · /product/genres/\<id\>/subgenres · /public/* · /product/types (🔴 No auth, public reference data)

- **Current posture**: 🔴 No auth. These return static reference data (genre/subgenre/type lookups); the `/public/*` variants set `Cache-Control: max-age=3600`.
- **PP status**: Not started — **likely intentionally open**; confirm with product owner whether any check is desired at all.

#### Phase 1 — Baseline integration tests
- Any request → 200 with the reference list. No tenant scoping expected.

#### Phase 2 / Phase 3
- **Recommendation**: Likely **no migration** (public reference data). If a check is desired, Template C with a coarse action. Treat as LOW priority / possibly out-of-scope.

### POST/GET/DELETE /project/\<id\>/mkt_priority(+dataloader) (🟡 Grass only (permissive))

- **Current posture**: 🟡 Grass only (permissive) — `verify_grass_headers(...)` is header-shape validation only (no ownership); `required=False` (or default) means **requests without Grass headers are implicitly authorized**. Mutating routes add an `orchard_user_id.startswith('oa:')` gate for OA users only.
- **Handlers**: handlers.py:645, :677, :692, :719, :744.
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- With valid Grass headers → 200. Without Grass headers → currently passes (permissive). OA user (`oa:` prefix) on mutating routes → 200; non-OA orchard user → 403 fatal. Dataloader: non-int ids → 400.

#### Phase 2 — Shadow
- **Key API**: side-effect-only `is_authorized()` (resource_type `project`, action `view`/`update`). Always allows; emits `pp_auth.rollout.would_deny`. Legacy `verify_grass_headers` stays in force.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: C (current grass check is permissive — no real enforcement to fall back to).
- **Key API**: `assert_authorization(resource_id=project_id, resource_type='project', action='view'|'update')`.
- **Caller analysis required**: Yes — implicit-allow today means unknown callers may rely on header-less access.
- **Risk**: Medium-High (implicit-allow + mutating deletes).

> Template C means no fallback — strict PP enforcement only; any request failing the PP check is rejected with 403.

### HEAD /ownership/\<account_type\>/\<account_id\>/project/\<project_id\> (🟡 Grass only (required))

- **Current posture**: 🟡 Grass only (required) with ownership. `verify_grass_access(request, vendor=..., subaccount=...)` (default `required=True`) validates the caller's Grass headers AND vendor/subaccount ownership of the project, after a DB ownership lookup.
- **Handler**: `check_project_ownership` (handlers.py:486).
- **PP status**: Not started.

#### Phase 1 — Baseline integration tests
- Owner's Grass headers (vendor or subaccount) → 200. Non-owner → 403/4xx from `verify_grass_access`. No Grass headers → denied (required). Invalid `account_type` → 400.

#### Phase 2 — Shadow
- **Key API**: side-effect-only `is_authorized()` (resource_type `project`, action `view`). Always allows; emits `pp_auth.rollout.would_deny`. `verify_grass_access` stays in force.
- **Behavior change**: None.

#### Phase 3 — Enforce
- **Template**: A (Grass required + ownership) — PP first, fall back to `verify_grass_headers` + ownership check.
- **Key API**: `assert_authorization(resource_id=project_id, resource_type='project', action='view')`.
- **Caller analysis required**: Yes (but already rejects unauthenticated traffic — lower urgency).
- **Risk**: Low-Medium (already enforced; this hardens it).

### ⚪ Needs human review

- **PATCH /transfer/job/\<id\>** (`update_transfer_job`, handlers.py:820): reads `context.jwt_identity_id` directly and rejects only when missing (401). All sibling `/transfer/job` routes are PP-enforced via `@authorize_transfer_job`, but this one is **not** — it is a partial identity check that bypasses PP's resource/action flow. Intent ambiguous (S2S-only by docstring). **Recommend** wiring `@authorize_transfer_job(action='create')` or equivalent.
- **DELETE /project/\<id\>/hard-delete** (`hard_delete_project`, handlers.py:347): `@only_for_identity(authorization.HARD_DELETE_PROJECT_AUTHORIZED_IDENTITIES)` — a hardcoded JWT-identity allowlist (single bulk-ingest lambda UUID) in `auth.py`. Functions as a coarse M2M gate; decide whether to model it as a PP principal policy instead of a hardcoded UUID list.
- **GET /project (by param)** (`get_project_by_parameter`, handlers.py:222): custom `orchard_user_id.startswith('oa:')` gate (403 for non-OA when the header is present) but permissive when the header is absent. Determine the intended caller set before classifying.

---

## 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` without the `migration` extra in `pyproject.toml`; add the extra for Phase 2.
- [x] `OwsClient` configured — `setup_ows_client()` in `api.py:44`.
- [x] **Flask**: `AuthorizationBackend` wired — `PdpAuthorizationBackend` instantiated in `api.py:54-60`.
- [ ] Cerbos resource/action policies exist for `resource_type='project'` / `'report'` (the `project_transfer` policies already exist). Run `endpoint-resource-action-pp-authorization-table` to map the remaining endpoints — see the [endpoint → resource/action resource table](https://app.notion.com/p/121453445b6f46eaa8fdc02dc8789ad5?v=a4f766c448634863b267c6b37443c242) (Notion).
- [ ] All upstream callers identified and capable of sending a JWT (Datadog APM, per Step 1).
- [ ] A `project`-resource `assert_authorization`/helper exists. Today only `pdp_authorize_project_transfer` (resource_type `project_transfer`) is implemented; add a `project`/`report` equivalent in `util/authorization.py`.

## Recommended Migration Order

| Priority | Endpoint | Posture | Template | Notes |
|---|---|---|---|---|
| HIGH | POST /project | 🔴 No auth | C | Mutating create; implicit-allow today |
| HIGH | PUT /project/\<id\> | 🔴 No auth | C | Mutating update; implicit-allow today |
| HIGH | DELETE /project/\<id\> | 🔴 No auth | C | Mutating delete via `access_check` (header parse only) |
| HIGH | POST /report/\<id\> | 🔴 No auth | C | Triggers metadata generation; `access_check` only |
| HIGH | POST /project/dataloader | 🔴 No auth | C | Bulk project read; `access_check` only |
| HIGH | GET /project/\<id\> | 🔴 No auth | C | Tenant-scoped read; `access_check` only |
| HIGH | GET /report/\<id\> | 🔴 No auth | C | Tenant-scoped read; `access_check` only |
| HIGH | GET /projects | 🔴 No auth | C | Tenant-scoped list; header-scoped only |
| HIGH | GET /project/\<id\>/products | 🔴 No auth | C | Tenant-scoped read; header-scoped only |
| HIGH | GET /project/\<id\>/product/\<product_id\> | 🔴 No auth | C | Tenant-scoped read; header-scoped only |
| HIGH | GET /project/\<id\>/product/imprints | 🔴 No auth | C | Tenant-scoped read; `access_check` only |
| HIGH | DELETE /project/\<id\>/mkt_priority | 🟡 Grass (permissive) | C | Mutating delete; implicit-allow without headers |
| HIGH | DELETE /project/\<id\>/mkt_priority/\<projection_id\> | 🟡 Grass (permissive) | C | Mutating delete; implicit-allow without headers |
| HIGH | POST /project/\<id\>/mkt_priority | 🟡 Grass (permissive) | C | Mutating set; implicit-allow without headers |
| HIGH | GET /project/\<id\>/mkt_priority | 🟡 Grass (permissive) | C | Implicit-allow without headers |
| HIGH | POST /project/mkt_priority/dataloader | 🟡 Grass (permissive) | C | Bulk read; implicit-allow without headers |
| MEDIUM | GET /project/\<id\>/document | 🔴 No auth | C | Internal/S2S read; confirm M2M JWT before enforcing |
| MEDIUM | GET /project/available | 🔴 No auth | C | No auth; confirm intended caller set |
| MEDIUM | HEAD /ownership/.../project/\<project_id\> | 🟡 Grass (required) | A | Already enforces ownership; hardens with PP-first + grass fallback |
| LOW | GET /product/genres | 🔴 No auth | C | Public reference data; likely no migration |
| LOW | GET /public/product/genres | 🔴 No auth | C | Public reference data; likely no migration |
| LOW | GET /product/genres/\<id\>/subgenres | 🔴 No auth | C | Public reference data; likely no migration |
| LOW | GET /public/product/genres/\<id\>/subgenres | 🔴 No auth | C | Public reference data; likely no migration |
| LOW | GET /product/types | 🔴 No auth | C | Public reference data; likely no migration |
| LOW | PATCH /transfer/job/\<id\> | ⚪ Needs human review | — | jwt_identity-only check; wire `@authorize_transfer_job` |
| LOW | DELETE /project/\<id\>/hard-delete | ⚪ Needs human review | — | Hardcoded identity allowlist; model as PP principal policy |
| LOW | GET /project (by param) | ⚪ Needs human review | — | OA-prefix gate; clarify intended callers |
| COMPLETE | /transfer/jobs, /transfer/job(+/\<id\>, /products, /attachments, /execute-content-transfer), /transfer/batch/execute | 🟢 PP enforced | — | Already migrated (PORT-69) |

> **Health checks** (`GET /hello`, `GET /hello_db`) are non-sensitive and intentionally unauthenticated — no migration.

## Endpoint discovery confidence

- **Methods used**: ripgrep for `@app.route` (all 38 routes registered via the Flask `@app.route` decorator on `project_manager/api.py:app`); cross-checked for `add_url_rule`, `api.add_resource`, `MethodView`, `as_view`, and FastAPI `@router/@app.get|post|...` — **none found**.
- **Auth scans**: `flask_request.setup`, `verify_grass_access`, `verify_grass_headers`, `access_rules.yml`, `is_authorized*`, `PdpAuthorizationBackend`, `assert_authorization`, `jwt_identity`, `only_for_identity`, `abort(40x)`, `access_check`.
- **Confidence**: High. All routes live in a single `handlers.py`; no blueprints, no dynamic registration. The two non-`@app.route` matches in `auth.py` are docstring examples, not real routes.
- **Possible gaps**: Logic-layer authorization (e.g. `project_manager.py:350` "check whether vendor is authorized for subaccount") may add scoping not visible at the handler layer — worth a per-endpoint logic-layer read during Phase 1 baselining.
