---
name: python-service-scan-authz-baseline
description: Scan a Python microservice repository for existing authentication/authorization patterns (access_rules, grass headers, python-owsrequest) and generate a prioritized migration plan for adding Permissions Platform (PP) authorization checks.
---

# Scan Service Auth → Recommend PP Checks

## Overview

Many Python microservices use legacy authorization systems — `access_rules.yml`,
`verify_grass_access`, or no enforcement at all. This skill scans the target repo, classifies
each endpoint's current auth posture, and produces a concrete, phased migration plan to add
Permissions Platform (PP) authorization checks using `python-pdp-sdk`.

The output follows the two-phase approach established in PP-1518 (`ows-product`):

- **Phase 1**: Write integration tests that capture current access behavior (baseline).
- **Phase 2 — Shadow** *(ship first; additive, zero behavior change)*: wire `MigrationAuthorizationBackend`
  (`python-pdp-sdk[migration]==6.6.0`) and add a side-effect-only `is_authorized()` call alongside the
  untouched legacy auth. It always allows and emits `pp_auth.rollout.would_deny` on a would-deny.
- **Phase 3 — Enforce** *(follow-up PR, after the metric is clean)*: swap in `PdpAuthorizationBackend`
  and restructure the handler to PP-first + legacy fallback.

> **Related skills** — use these alongside this one:
> - [`python-microservice-add-authorization-backend`](.github/skills/python-microservice-add-authorization-backend/SKILL.md) — wiring `PdpAuthorizationBackend` into a Flask or FastAPI service.
> - [`python-microservice-add-owsclient`](.github/skills/python-microservice-add-owsclient/SKILL.md) — prerequisite for the above; sets up `OwsClient`.
> - [`endpoint-resource-action-pp-authorization-table`](.github/skills/endpoint-resource-action-pp-authorization-table/SKILL.md) — generates the CSV mapping endpoints to `resource_type` / `action` / `proposed_roles`.

---

## When to Use

- A service is being onboarded to PP and you want to understand what auth it already has before touching any code.
- You need a prioritized list of endpoints that need PP checks, ordered by migration risk.
- You want pseudocode / concrete helpers scaffolded for a specific service before opening a PR.

---

## Steps

### Step 1 — Identify the service name and root

Determine the service name (e.g. `ows-product`, `ows-grass`, `ows-track`) from the repo root.
Look for `setup.cfg`, `pyproject.toml`, or the top-level Python package directory.

**Detect the web framework** — check `pyproject.toml` dependencies and the top-level package:

| Signal | Framework |
|---|---|
| `fastapi` in dependencies; routes use `@router.get/post/put/patch/delete`; `Depends(...)` for auth | **FastAPI** |
| `flask` in dependencies; routes use `@app.route` / `@bp.route`; `flask_request.setup` present | **Flask** |

> **FastAPI services skip Steps 2–5** and go directly to **Step 2b** below.

### Step 2b — FastAPI auth scan (FastAPI services only)

For FastAPI services, resource-level authorization is handled entirely inside each route handler
(or a logic-layer function it calls). There is no middleware-level access-rules enforcement.

**Scan `main.py` for `JWTAuthenticationMiddleware` configuration.** Record:
- `exclude_paths` — paths that bypass JWT authentication entirely (analogous to Flask's `exclude_paths`). Typically `/hello/$` (health check). Ignore `/redoc`, `/docs`, `/openapi.json` — these are FastAPI auto-generated UI paths and need no migration.
- `enabled` — if bound to a config variable, note that JWT enforcement may be disabled in some environments.

Endpoints matching `exclude_paths` appear in the posture table as `🟢 No auth (excluded)` and are **omitted from Detailed Findings** — they require no PP migration.

**JWT is always required for all other endpoints.** The `JWTAuthenticationMiddleware` rejects
requests with no valid JWT before the handler runs. Any handler using
`Depends(identity_uuid_from_scope)` or `Depends(profiles_from_scope)` requires a valid JWT.
"No resource-level auth" means authenticated but no ownership check — unlike Flask's
`required=False` implicit-allow.

Scan for the following patterns and record each call site (file path, function, line number):

**PP authorization (target state):**
```
is_authorized_for_tenant
is_authorized_for_tenants
assert_authorization        # logic-layer wrapper calling is_authorized_for_tenant first
```

**Legacy authorization (pre-PP, needs migration):**
```
assert_access               # logic-layer wrapper; only calls check_vendor_access_for_profiles
check_vendor_access_for_profiles   # direct call; no PP check
```

**Key distinctions:**
- `assert_authorization` = PP first (`is_authorized_for_tenant`), then legacy fallback → **PP + legacy fallback**
- `assert_access` = legacy only, no PP → **Legacy only (needs PP migration)**. Migrate by swapping the call to `assert_authorization` (no handler rewrite).
- Inline `get_tenant` + `is_authorized_for_tenant` + `check_vendor_access_for_profiles` in handler → **PP + legacy fallback (inline)**
- `Depends(identity_uuid_from_scope)` only, no resource check in handler or called logic → **No resource-level auth**

### Step 2 — Scan for `flask_request.setup` call *(Flask only)*

Search for `flask_request.setup` (from `owsrequest`). Record:

| Field | Where to find it |
|---|---|
| `verify_access` | keyword argument; default `False` |
| `access_log_only` | keyword argument; `True` = log-only, no rejection |
| `rules_file` | path to `access_rules.yml` |
| `exclude_paths` | paths that bypass rule enforcement |

When `verify_access=False` or `access_log_only=True`, access-rules enforcement is **disabled**
even if `rules_file` points to a real file.

> ⚠️ **`verify_rules_access_standalone` exception**: If `verify_access=False` in
> `flask_request.setup` but **every endpoint handler** calls `verify_rules_access_standalone()`
> directly, the service IS effectively enforcing access rules — it has moved the check from
> middleware to handler ("standalone mode"). This is required for PP / `pdp-sdk` integration
> because middleware-level access-rule enforcement would deny requests before the handler can
> call `pdp-sdk`. See [PDP SDK Integration Recipes — Enable Standalone Access Rule Checks](https://app.notion.so/PDP-SDK-Integration-Recipes-1c297177520f807790f3c98c36866fa9#1c297177520f808cbb0bdde13bbb33d1).
> Classify these endpoints as `🟡 Access rules (enforced)` (standalone), **not**
> `🔴 Access rules (disabled)`.

If `flask_request.setup()` is called **without a `rules_file` argument**, access-rules middleware
is not active. Set `exclude_paths` to `[]` in the auth infrastructure section. The `exclude_paths`
list in `flask_request.setup()` is authoritative — trust it exactly. Do **not** add extra paths
to `exclude_paths` based on endpoint names (e.g. a `/hello/` endpoint that is not in the config
list is not excluded, even if it looks like a health check).

Grep: `flask_request.setup`, `verify_access`, `access_log_only`

### Step 3 — Parse `access_rules.yml` *(Flask only)*

If a `rules_file` was found (or any `**/access_rules.yml`), read and extract each rule:
`path`, `methods`, `profiles` (ProfileType → roles). Note paths with **no matching rule** —
they are denied by default if `verify_access` were turned on.

**Catch-all rules**: A rule with `path: <*>` and `methods: ['*']` covers any unmatched path.
Endpoints covered only by the catch-all are still `Access rules (enforced)` (note "via catch-all"),
not `No auth`. Only endpoints with no explicit rule AND no catch-all are truly unmatched.

### Step 4 — Scan for grass auth calls *(Flask only)*

- **`verify_grass_access`** — validates headers AND vendor/subaccount ownership.
- **`verify_grass_headers`** — header-only check; no ownership.

Posture mapping (defaults differ between the two functions):
- `verify_grass_access(...)` — default is **required**; blocks requests without matching grass headers
  - No `required` arg, or `required=True` → `🟡 Grass only (required)`
  - `required=False` explicitly → `🟡 Grass only (permissive)`
- `verify_grass_headers(...)` — header-only check, **no ownership check**; default is **permissive**
  - ⚠️ `verify_grass_headers` is **NOT** the same as `verify_grass_access`. It does not verify vendor/subaccount ownership and does **not** block requests by default.
  - No `required` arg, or `required=False` → `🟡 Grass only (permissive)` — requests without headers are **implicitly authorized**
  - `required=True` explicitly → `🟡 Grass only (required)`

For each call: enclosing function, file, `vendor`/`subaccount` args, `required` flag, return value checked.

Grep: `verify_grass_access`, `verify_grass_headers`, `Grass-Account-Type`, `Grass-Account-Id`

### Step 5 — Scan for `verify_rules_access` and `verify_rules_access_standalone` calls *(Flask only)*

Grep for both `verify_rules_access` and `verify_rules_access_standalone`. For each call: enclosing handler, any override profiles/roles.

**Standalone mode detection**: If `verify_access=False` in `flask_request.setup` but every endpoint
calls `verify_rules_access_standalone()`, the service is using *standalone mode* — access rules are
enforced per-handler rather than in middleware. This pattern is required before adding `pdp-sdk` calls,
because middleware-level enforcement would deny requests before the handler can reach `pdp-sdk`.
Record which endpoints use standalone vs. none, and flag any endpoints that are missing the
`verify_rules_access_standalone` call (they would be unprotected in standalone mode).

### Step 6 — Scan for existing PP auth (`python-pdp-sdk`)

Grep for evidence that PP auth is already in place:

```
python_pdp_sdk
is_authorized
get_authorized_tenants
AuthorizationBackend
PdpAuthorizationBackend
assert_authorization
is_authorized_for_tenant
```

If `PdpAuthorizationBackend` or `AuthorizationBackend` is already imported and instantiated
(even at module level in `handlers.py` or `api.py`), mark the "AuthorizationBackend wired"
prerequisite as **✅ complete**. Do not flag it as needing confirmation — instantiation IS
wiring. Note separately in Phase 2 that endpoint-level `is_authorized` calls still need to
be added to each handler.

> ⚠️ **Existing singleton — do not override**: If the service already has a named
> `auth_backend` singleton (e.g. `auth_backend = PdpAuthorizationBackend(...)`), the migration
> plan must **not** replace or rename it. Instead, instruct the team to create a **second**
> singleton for the shadow phase, for example:
> ```python
> migration_auth_backend = MigrationAuthorizationBackend(PdpAuthorizationBackend(...))
> ```
> Call the new singleton `migration_auth_backend` (or similar) and use it only for the
> side-effect `is_authorized()` calls in Phase 2. The existing `auth_backend` singleton
> and all existing PP checks remain untouched throughout the migration.

### Step 6b — Scan for other legacy auth helpers

Flag for human review rather than auto-classifying:

```
assert_access
check_.*access
has_.*access
authorize
abort(403)
abort(401)
Forbidden
Unauthorized
```

Also scan for **decorators** containing `auth`, `access`, `permission`, or `identity`. Mark as `⚪ Needs human review`.

Also flag any endpoint that directly reads `jwt_identity_id` (or any raw JWT identity field) in an auth conditional, or calls a helper whose name contains `jwt_identity` (e.g. `is_jwt_identity_authorized`). These are partial identity checks that bypass PP's resource/action authorization flow — their intent is ambiguous and they require human review even if a PP backend is wired up at the app level.

### Step 7 — Enumerate endpoints

Grep for all route registration patterns:

```
@app.route / @bp.route / @blueprint.route / @<var>.route   # Flask
add_url_rule / MethodView / as_view / flask_restful.*add_resource
@router.get|post|put|delete|patch   # FastAPI
@app.get|post|put|delete|patch      # FastAPI
```

Map `add_url_rule` / `api.add_resource` registrations to their handler class/function explicitly.

> **Coverage note**: Include an "Endpoint discovery confidence" note listing discovery methods used and flagging any patterns that may have been missed.

### Step 8 — Map endpoints and their auth posture

**Multi-posture rule**: When an endpoint has *both* middleware access-rules AND a handler-level grass call, emit **two rows** in the posture table — one per protection layer. This is not optional: a single merged row is wrong. Example:

```
| 🟡 Access rules (enforced) | PUT /catalog/<int:catalog_id> | update_catalog | handlers.py:22 | Covered by access_rules.yml explicit rule | High |
| 🟡 Grass only (required)   | PUT /catalog/<int:catalog_id> | update_catalog | handlers.py:22 | verify_grass_access(vendor=..., required=True) | High |
```

| Posture | Criteria |
|---|---|
| 🟢 **PP enforced** | Calls `is_authorized*` / `assert_authorization`. No permissive legacy fallback. |
| 🟡 **PP + permissive fallback** | PP first; fallback to `verify_grass_access(required=False)` — no-auth-header requests still pass. |
| 🟡 **PP + required fallback** | PP first; fallback uses `required=True` or similarly strict check. |
| 🟡 **PP + legacy fallback (FastAPI)** | `is_authorized_for_tenant` first, then `check_vendor_access_for_profiles`. JWT always required; legacy callers with valid profiles bypass PP. |
| 🟡 **Legacy only — needs PP (FastAPI)** | `assert_access` or `check_vendor_access_for_profiles` only; no `is_authorized_for_tenant`. Migration: swap `assert_access` → `assert_authorization`. |
| 🟡 **Grass only (required)** | `verify_grass_access(required=True)`; no PP. Requests without grass headers denied. |
| 🟡 **Grass only (permissive)** | `verify_grass_access(required=False)`; requests **without grass headers pass**. |
| 🟡 **Access rules (enforced)** | Covered by `access_rules.yml` (explicit or catch-all rule) with `verify_access=True` and `access_log_only=False`. |
| 🔴 **Access rules (disabled)** | `access_rules.yml` present but `verify_access=False` or `access_log_only=True`. |
| 🔴 **Access rules (default deny / unmatched)** | `verify_access=True`, no explicit rule, no catch-all. Denied by default but no PP check. |
| 🔴 **No auth** | No access-rule coverage, no grass check, no PP check. Implicitly allows all requests. |
| 🔴 **No resource-level auth (FastAPI)** | JWT required (middleware) but no resource-level ownership or PP check. Any authenticated user reaches any resource. |
| ⚪ **Needs human review** | Legacy auth helper or decorator detected; posture cannot be determined automatically. |

When `access_log_only` is set via a **config variable** (not hardcoded `True`), use `🟡 Access rules (enforced)` with a conditional note — e.g. `Access rules (enforced, conditional on ONLY_LOG_ACCESS_ERRORS)`. The rules ARE defined and DO enforce in production; only use `🔴 Access rules (disabled)` when `access_log_only=True` is hardcoded. An endpoint with conditional enforcement and a handler-level grass call still gets two rows — one for the conditional middleware posture, one for the grass posture.

### Step 9 — Write the auth scan report

Write the report to `AUTH.md` in the **root of the target service repository**.

> **Response format**: Your entire response must be the report itself — starting directly with
> the `# Auth Scan: <service-name>` heading. Do **not** output planning notes or analysis text
> before the heading.
>
> **Output length**: Detailed Findings entries should be concise — bullet-point findings plus
> 3–5 line Phase 1/2/3 summaries. Do **not** inline template code; cite by letter only
> (A/B/C for Flask — see `references/flask-migration-templates.md`;
> FA/FB/FC for FastAPI — see `references/fastapi-migration-templates.md`).
>
> **Excluded paths**: Appear in posture table as `🟢 No auth (excluded)`. Omit from Detailed Findings and migration order.
>
> **Caller analysis section**: Read `references/caller-analysis-and-rollout.md` and include its full content as the `## Caller analysis and rollout safety` section. Use only the caller types defined in that reference (SPAs and Lambdas) — do not add rows for other caller types (e.g. no "3rd-party webhook", "OWS service", "OA/OrchAdmin", or "Workstation").

Report structure:

```markdown
# Auth Scan: <service-name>

Generated by: `python-service-scan-authz-baseline` skill on <YYYY-MM-DD>

## Summary

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

## Auth infrastructure

*Flask:*
- `verify_access`: `<True|False>`, `access_log_only`: `<True|False|config var>`, `rules_file`: `<path>`, `exclude_paths`: `<list>`
- > ⚠️ Interpretation: <one sentence on runtime enforcement state>

*FastAPI:*
- **`JWTAuthenticationMiddleware`**: `exclude_paths`: `<list>` — paths that bypass JWT (typically `/hello/$`; ignore `/redoc`, `/docs`, `/openapi.json`).
- `access_rules.yml` / `flask_request.setup`: N/A.
- Resource-level auth is per-handler via `assert_authorization` (PP) or `assert_access` (legacy).

## Endpoint Auth Posture

> **Column order is fixed — do not reorder.** Posture first provides a visual scan of the auth
> status without reading each full row.

| Posture | Endpoint | Handler | File | Evidence | Confidence |
|---|---|---|---|---|---|

## Detailed Findings

### <METHOD /path>

- **Current posture**: <posture>
- **Handler**: `<fn>` in `<file>:<line>`
- **Auth call**: <what it calls and with what args>
- **PP status**: <Not started / In progress / Complete>

#### Phase 1 — Baseline integration tests

For **FastAPI** services: read `tests/integration/conftest.py` to identify existing persona
fixtures. Write test scaffolding using **actual `@pytest.mark.parametrize` syntax** (not prose
descriptions) with the persona fixture names as string parameters — see
`references/fastapi-migration-templates.md` for the exact pattern. The base personas (`client`,
`subaccount_client`, `no_access_client`) should already exist in `conftest.py`. For every
endpoint being migrated to PP, always include this ⚠️ prerequisite note **here in Phase 1**:
"Add `<PP_role_name>_client` and `<PP_role_name>_subaccount_client` fixtures to
`conftest.py` before writing the Phase 3 (enforce) test cases."

When `access_log_only` is controlled by a config variable (environment-dependent enforcement),
each test case that expects rejection under enforcement must show **dual outcomes**:
- Log-only (e.g. staging): request passes — expect `200`
- Enforced (e.g. production): request blocked — expect `403`

<3–5 bullet test cases — observe actual behavior, do not assume>

> ⚠️ **Prerequisite (FastAPI, PP-role test cases)**: add `<PP_role_name>_client` and
> `<PP_role_name>_subaccount_client` fixtures to `conftest.py` before writing the Phase 3 (enforce)
> test cases.

Emit the ⚠️ prerequisite block above **verbatim** in the Phase 1 section of every FastAPI endpoint
being migrated to PP — it is a required part of the report, not optional commentary.

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

#### Phase 3 — Enforce
- **Template**: <letter>
- **Key API**: <`PdpAuthorizationBackend` (swapped in for the shadow wrapper) / `is_authorized` / `assert_authorization` — name the specific PP method>
- **Caller analysis required**: Yes/No
- **Risk**: <summary>

> **Template C means no fallback.** If Template C is cited, do not describe a grass fallback in
> the implementation notes. Template C = strict PP enforcement only — any request that fails the
> PP check is rejected with 403.

> See *Caller analysis and rollout safety* below.

---

## Caller analysis and rollout safety

<content from references/caller-analysis-and-rollout.md>

## Prerequisites checklist

- [ ] `python-pdp-sdk[migration] == 6.6.0` in service dependencies (the `migration` extra provides `MigrationAuthorizationBackend`)
- [ ] `OwsClient` configured (see `python-microservice-add-owsclient` skill)
- [ ] **Flask**: `AuthorizationBackend` wired (see `python-microservice-add-authorization-backend` skill)
- [ ] **FastAPI**: `PdpAuthorizationBackend` wired; `is_authorized_for_tenant` / `get_tenant` helpers in `<service>/api/auth.py`
- [ ] Cerbos resource/action policies exist (run `endpoint-resource-action-pp-authorization-table` if not)
- [ ] All upstream callers identified and capable of sending a JWT
- [ ] **FastAPI**: logic-layer `assert_authorization` exists for each resource type being migrated
```

### Step 10 — Prioritize migration order

Append `## Recommended Migration Order` to the report. Priority order (most urgent first):

1. 🔴 **No auth** / **No resource-level auth (FastAPI)** — sensitive endpoints (creates, updates, deletes)
2. 🔴 **Access rules (disabled)** — rules file exists but not enforced
3. 🟡 **Grass only (permissive)** — implicit-allow risk; caller analysis first
4. 🟡 **Legacy only — needs PP (FastAPI)** — JWT required but no PP; swap `assert_access` → `assert_authorization`
5. 🟡 **Grass only (required)** — already rejects unauthenticated; lower urgency
6. 🟡 **Access rules (enforced)** — already protected; migrate after higher-risk
7. 🔴 **Access rules (default deny / unmatched)** — blocked by default; add explicit PP check
8. ⚪ **Needs human review** — investigate manually
9. 🟡 **PP + permissive fallback** / **PP + legacy fallback (FastAPI)** — in progress; remove fallback when callers migrated
10. 🟢 **PP enforced** — complete

Priority tiers: items 1–4 → HIGH, items 5–7 → MEDIUM, items 8–10 → LOW (🟢 = COMPLETE).

| Priority | Endpoint | Posture | Template | Notes |
|---|---|---|---|---|
| HIGH | POST /foo | 🔴 No auth | Template C | Mutating; no enforcement at all |

Template selection by posture:

| Current posture | Template |
|---|---|
| Grass (required) + ownership | A |
| Grass (required), no ownership | B |
| Grass (permissive) / No auth / Access rules disabled | C |
| Access rules (enforced) | No handler fallback; middleware stays; add PP log-only inside handler |
| Legacy only — needs PP (FastAPI): `assert_access` | FA |
| PP + legacy fallback inline (FastAPI): direct `check_vendor_access_for_profiles` | FB |
| No resource-level auth (FastAPI) | FC |

> **Access rules (disabled) → always Template C.** When `verify_access=False`, no enforcement
> layer is active at the middleware level. Even if other endpoints in the same service use grass
> auth, an endpoint with no handler-level grass call has nothing to fall back to — use Template C
> (no-fallback, strict PP enforcement). Do not use Template A for these endpoints.

---

## 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/fastapi-migration-templates.md`](references/fastapi-migration-templates.md) — Phase 2 shadow wiring (`CurrentRequestMiddleware` + `request_tags` + `MigrationAuthorizationBackend`); Phase 3 enforce Templates FA, FB, FC; integration test personas
- [`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
