---
name: endpoint-resource-action-pp-authorization-table
description: Generate a CSV that maps a Flask, FastAPI, or GraphQL service's endpoints (microservice + endpoint) to resource_type, resource_schema, action, pp_authorization_method, plus rollout metadata (jira_ticket, proposed_roles, status), in preparation for adding Permissions Platform (PP) authorization checks.
---

# Endpoint → Resource / Action / PP Authorization Table

## Overview

For a given Flask, FastAPI, or GraphQL service, generate a CSV file with one row per endpoint. Each row captures the columns needed to plan PP authorization-check rollout for that service:

- `microservice`
- `endpoint`
- `resource_type`
- `resource_schema`
- `action`
- `pp_authorization_method`
- `jira_ticket`
- `proposed_roles`
- `status`

## Quick start (service owners)

If you own a microservice that needs PP authorization checks, here's what this skill does for you:

1. **Run the skill on your service repo.** It walks every Flask / FastAPI route (or GraphQL operation) and produces a CSV — no cerbos knowledge required to read it.
2. **You get `<service>_endpoint_resource_action_table.csv`** — one row per endpoint with the inferred `(resource_type, action)`, suggested `proposed_roles`, and other PP metadata. The skill cross-references existing ows-pdp cerbos policies, so its recommendations match what's already deployed for peer services.
3. **You get `<service>_diff_notes.md`** when a prior pass exists — what changed and which rows were ambiguous enough that the skill flagged them for human review.
4. **The team doing the rollout also gets a combined `_cerbos_proposals.md`** — when your service needs cerbos rules that don't exist yet (new resource types, new actions, new derived roles), the proposals show up there grouped by resource. PP team works from this artifact to file ows-pdp PRs.
5. **Hand the proposals to the PP team.** Slack `#permissions-platform-public` for ad-hoc questions; the [PDP User Guide](https://www.notion.so/PDP-User-Guide-7dba0f5b0b1f459d8817f7fba18b059e) for cerbos schema reference and PR conventions.

The skill aims to get you ~75% of the way to a correct mapping. The remaining 25% is **the conversation about user personas** — which user populations call each endpoint (Content App employees? Workstation customers? Fansifter consumers?) and which derived roles cover them. See [Personas](#personas-and-role-naming-conventions) below.

## When to use

Use this skill when:

- Onboarding a new microservice to PP access checks.
- Cataloging an existing service's endpoints and their resource/action mappings.
- Producing the spreadsheet that downstream PP authorization PRs will be built from.

## Output format

| microservice | endpoint        | resource_type | resource_schema       | action | pp_authorization_method | jira_ticket | proposed_roles                | status      |
|--------------|-----------------|---------------|-----------------------|--------|-------------------------|-------------|-------------------------------|-------------|
| ows-account  | GET /accounts   | account       | tenant_owned_resource | view   | is_authorized_many      | PP-1524     | account_admin, contract_admin | not_decided |
| ows-account  | POST /account   | account       | tenant_owned_resource | create | is_authorized           | PP-1524     | account_admin                 | decided     |
| ows-account  | PUT /account    | account       | tenant_owned_resource | update | is_authorized           | PP-1524     |                               | not_decided |
| ows-account  | DELETE /account | account       | tenant_owned_resource | delete | is_authorized           | PP-1524     | account_admin                 | decided     |

These rows assume standard REST verb semantics (`GET`=view, `POST`=create, `PUT`=update, `DELETE`=delete). When a service diverges (e.g. dataloader endpoints that use `POST` for batch reads), set `action` from the handler's actual operation, not the HTTP verb — see the reference CSVs.

`proposed_roles` is comma-separated; quote the cell in CSV when it contains commas (standard CSV escape). `status` is `decided` once the service owner confirms the row's mapping; otherwise `not_decided` or empty.

Write the CSV to a file the user specifies (or default to `<service>_endpoint_resource_action_table.csv` in the repo root). Sort rows by `endpoint` path for readability.

## Reference data

Ground-truth scrapes from every PP-onboarded service (and the cerbos policies that govern them) live in `references/`, organized by surface area:

- `references/pp_cerbos/` — three CSVs derived from `theorchard/ows-pdp/cerbos/policies/`. The authoritative source for which `(resource_type, action, role)` triples are actually defined in policy.
    - `cerbos_resource_actions.csv` — one row per (resource × action × role); also captures `resource_schema`, `effect`, `has_condition`, `rule_name`, and the source file:line of the rule
    - `cerbos_derived_roles.csv` — one row per named derived role (e.g. `account_admin`, `content_nr_contribution_can_view`) with parent roles and which file defines it
    - `cerbos_principal_policies.csv` — one row per (machine_principal × resource × action) for the machine policies in `policies/machines/**`
- `references/pp_endpoints/` — one CSV per Python microservice that uses PP. Each row is one (endpoint × resource × action) gating decision found in production code.
- `references/pp_graphql/` — one CSV per TypeScript GraphQL service. Covers both resolver-based gating (direct SDK calls) and directive-based gating (`@ppCanPerform` / `@ppAllowedTenants`).
- `references/pp_frontend/` — one CSV per frontend repo. Covers all three FE patterns: per-component `useCanPerformAction`, the pre-`python-pdp-sdk` declarative `RESOURCE_TYPE_ACTIONS` table, and the `useCrudPermissions` wrapper.

When mapping a new service, scan `pp_cerbos/cerbos_resource_actions.csv` first to see which `(resource_type, action)` pairs are already defined, then scan the per-service CSV for the closest peer service and reuse its conventions. **Reuse before proposing new** — most rows for a mature service should be exact reuses. When a real gap exists (resource_type or action genuinely missing from cerbos), follow the [Decision policy](#decision-policy-reuse-propose-or-wildcard) to propose a new entry via `_cerbos_proposals.md` rather than silently inventing a value the runtime will deny.

## Column definitions

### microservice

The name of the microservice that hosts the endpoint, e.g. `ows-account`, `ows-product`, `ows-track`. Use the canonical service name (the GitHub repo slug / Docker image name) — typically `ows-*` for OWS services. Every row in a single CSV file shares the same `microservice` value, but the column is required so that per-service CSVs can be combined into a cross-service "mega" database (e.g. one Notion database covering every service onboarded to PP) and the source service stays identifiable.

### endpoint

The HTTP method + URI path bound to a service handler (Flask/FastAPI), or — for GraphQL — the operation form `query <opName>` / `mutation <opName>` / `subscription <opName>`, or a field-level directive target `TypeName.fieldName`.

**Flask** — the endpoint is the `methods=` + path passed to `add_url_rule` (or `@app.route(...)`):

```python
account_api = Blueprint('account_api', __name__)

class AccountsDataloader(ListView):
    """View for accounts dataloader."""

    model_class = Account
    list_entry_schema = AccountDetailSchema()

    def post(self):
        """Get accounts by ids.

        NOTE: This endpoint uses a POST method to allow for a larger list of query args.
        """
        ...

account_api.add_url_rule(
    '/account/dataloader',
    methods=['POST'],
    view_func=AccountsDataloader.as_view('accounts_dataloader')
)
```

Endpoint value: `POST /account/dataloader`.

**FastAPI** — the HTTP method is the decorator name (`@router.get`, `@router.post`, `@router.put`, `@router.delete`, `@router.patch`); the path is the decorator's first argument. Path parameters use the `{name}` syntax:

```python
from fastapi import APIRouter, Depends

router = APIRouter(tags=["Bulk Sessions"])

@router.post(
    "/bulk-session",
    operation_id="create_bulk_session",
    response_model=GetBulkSessionResponse,
)
async def create(
    request: CreateBulkSessionRequest,
    identity_uuid: UUID4 = Depends(identity_uuid_from_scope),
) -> GetBulkSessionResponse:
    ...
```

Endpoint value: `POST /bulk-session`. FastAPI services typically split routers into per-resource files under `<service>/api/routers/` and register them on the app with `app.include_router(...)` — walk every router file to enumerate endpoints. The `operation_id` is a stable identifier worth keeping as a side note when an endpoint maps to a frontend GraphQL operation of the same name.

**GraphQL (TypeScript, `@theorchard/backend-ts-pdp-sdk`)** — services adopt PP one of two ways. Both produce rows in the same CSV; the value of `endpoint` differs:

1. **Resolver-based** — the resolver calls `pdpAuthorizationBackend.isAuthorized(...)` / `isAuthorizedMany(...)` / `getAuthorizedTenants(...)` / `isAuthorizedManyResourcesAndActions(...)` directly. Walk `src/resolvers/*.ts` (and any `src/connectors/*.ts` that wrap data fetchers) for these call sites, then trace each one back to the operation it gates. Endpoint values are `query <opName>` for root queries (e.g. `query accounts`), `mutation <opName>` for root mutations, or `<TypeName>.<fieldName>` when the call is in a field resolver (e.g. `Vendor.sonyFinancialMetadata`).

2. **Directive-based** — the schema declares a directive in `directives.graphql` and applies it on field definitions throughout `src/schema/*.graphql`:

    ```graphql
    directive @ppCanPerform(resource: String!, action: String!) on FIELD_DEFINITION
    directive @ppAllowedTenants(resource: String!, action: String!) on FIELD_DEFINITION

    type NrContribution {
      id: ID!
      evidence: String @ppCanPerform(resource: "contribution", action: "view:nr")
      isrc: String     @ppCanPerform(resource: "contribution", action: "view:basic")
    }

    extend type Query {
      nrContributionById(id: ID!): NrContribution
        @ppCanPerform(resource: "contribution", action: "view:basic")
    }
    ```

    Endpoint values:
    - `Query.nrContributionById` → `query nrContributionById`
    - Field-level directive on `NrContribution.evidence` → `NrContribution.evidence`

    The `resource:` arg supplies `resource_type`; the `action:` arg supplies `action`. See [pp_authorization_method](#pp_authorization_method) below for the directive → method mapping.

A single service can mix both styles. When extracting ground truth, scan both the resolver/connector files AND every `*.graphql` file. graphql-neighbouring-rights is the canonical directive-heavy example; graphql-account uses resolver-based gating.

### resource_type

The entity type users are authorized to access. Values seen in the wild:

- `account`, `subaccount`
- `identity`
- `audience`
- `bank_info`, `tax_info`
- `accounting_period`, `abacus_state`, `abacus_schedule` (account360 / royalties stack)
- `review_queue` (ows-product-review)
- `digital_audio` (ows-product-staging)
- `contribution`, `sound_recording` (NR ownership stack)

This list isn't exhaustive — match the resource_type a service already uses for the same entity rather than inventing a new one. When in doubt, grep the target service's existing PP call sites (or the consolidated ground-truth CSV when one exists for the rollout) for prior usage before picking a value.

### resource_schema

Describes the entity that owns the resource. Granting access to that entity grants access to the resource. `ows-pdp` defines these schemas.

| Schema                             | Purpose                                                                         |
|------------------------------------|---------------------------------------------------------------------------------|
| tenant_owned_resource              | A resource that is only attached to a tenant.                                   |
| identity_owned_resource            | A resource that is only attached to an identity.                                |
| identity_and_tenant_owned_resource | A resource that can be attached to a tenant or an identity.                     |
| shared_resource                    | A resource that can be shared with a tenant or specific identity_id.            |
| `none` / empty                     | A resource that is not tied to a specific tenant (e.g., account or subaccount). |

> **Encoding "no schema"**: in CSV, leave the cell empty or write the literal `none` (the existing reference CSVs use lowercase `none`). In Notion, leave the SELECT property unset rather than creating a `None` option — a value-less cell is unambiguous.

### action

The operation a user or service wants to perform on the resource. Common values:

- `view`
- `update`
- `create`
- `delete`

Some endpoints use more specific PDP actions (e.g., `view_pending_payment`, `review`, `impersonate`, `view_account_info`, `manage_employee`, `list_employees`).

**Colon-namespaced variants** are also common when one resource_type has multiple authorization tiers and the standard CRUD verb isn't specific enough — use the form `<verb>:<scope>` to keep the underlying verb obvious:

- `view:basic`, `view:nr`, `view:accounting` (graphql-neighbouring-rights — different field tiers on `contribution`)
- `update:country_id`, `update:service_tier`, `update:internal_staff` (ows-account — per-field PATCH endpoints on `account`)

When in doubt, reuse an action a peer endpoint already passes to PP rather than inventing a new one; otherwise pick the closest CRUD verb (or its `verb:scope` form).

### pp_authorization_method

The PDP authorization method needed to enable PP auth checks for the endpoint:

| Method                                  | Description                                                                                                    |
|-----------------------------------------|----------------------------------------------------------------------------------------------------------------|
| is_authorized                           | The endpoint authorizes 1 resource at a time.                                                                  |
| is_authorized_many                      | The endpoint authorizes more than 1 resource of the **same type and action** (e.g., dataloader endpoints).     |
| is_authorized_many_resources_and_actions | The endpoint authorizes a batch with **mixed resource types and/or actions** in a single call.                |
| get_authorized_tenants                  | The endpoint needs a list of tenants to add to a WHERE clause or filter before looking up resources.           |
| can_perform                             | A batch **identity-level** check (no specific resource_id) — answers "can the authenticated user perform `action` on `resource_type`?" Used by frontend permission gates via the `Identity.canPerform` GraphQL resolver. See [Frontend permission gates](#frontend-permission-gates-identitycanperform). |

**GraphQL directive → method mapping** (TypeScript services using `@theorchard/backend-ts-pdp-sdk`):

| Directive             | pp_authorization_method  | Notes                                                                                          |
|-----------------------|--------------------------|------------------------------------------------------------------------------------------------|
| `@ppCanPerform`       | `is_authorized`          | Runs an allowed-tenants check and raises `GraphQLError` if 0 tenants. Caches in `fieldPermissions`. |
| `@ppAllowedTenants`   | `get_authorized_tenants` | Same allowed-tenants fetch but doesn't raise — resolver code uses the cached tenant set to filter. |

### jira_ticket

The Jira issue authorizing the row's mapping. Usually the SPIKE ticket driving the rollout (e.g. `PP-1524`) or a per-service subtask. May be empty if the row hasn't been ticketed yet. Accepts either a bare key (`PP-1524`) or a full URL (`https://theorchard.atlassian.net/browse/PP-1524`).

### proposed_roles

The PP **derived roles** that should be allowed for `action` on `resource_type`. Derived roles are defined in cerbos policies under [`ows-pdp/cerbos/policies/derived_roles/*.{yml,yaml}`](https://github.com/theorchard/ows-pdp/tree/master/cerbos/policies/derived_roles).

**Leave this column blank.** The skill emits `proposed_roles` as empty for every CSV row — the **product owner** (CCM, Content Review, or whichever team owns the endpoint's user populations) populates it during review. Multi-valued: comma-separated, quoted in CSV when the cell contains commas. Not the legacy OA roles (`@requires_role`, `OARoles.has_any_of`); the value must always be a cerbos derived-role name.

**Why blank instead of auto-populating?** Persona breakdown is endpoint-specific in ways the skill can't infer. Even within a single `(resource_type, action)` like `digital_audio.view`:

- Some endpoints are **employees-only** (internal Content Creation & Management / Content Review flows)
- Some are **employees + clients** (the standard multi-persona case)
- Within clients, there are sub-product distinctions: a customer with **Catalog** access may not have **Insights**, and vice versa — so the same `(resource, action)` might gate to different role sets per endpoint depending on which client features are required

Auto-populating with cerbos's full role list (or even a filtered "content_*/workstation_* only" subset) gives a misleading "recommendation" that the product owner has to undo. Better to leave it blank and surface the question explicitly.

**What the skill DOES surface as context** (in the diff notes / `_cerbos_proposals.md`, NOT in the CSV cell):

- The list of cerbos rules and their derived roles for the `(resource, action)` — so the reviewer knows what's already defined
- A per-endpoint "personas to consider" prompt: "Is this endpoint employee-only, employee + client, or sub-segmented (Catalog vs Insights)?"
- For bucket-3/4 rows where no cerbos rule exists yet: a placeholder note in `_cerbos_proposals.md` saying which role *families* are likely candidates (e.g. "this endpoint is in a content microservice; expect `content_*` and possibly `workstation_*` roles once product-owner confirms personas")

The Notion combined database's `proposed_roles` MULTI_SELECT can keep its option list defined (so reviewers see a dropdown) — but no row should have values pre-set.

See [Personas](#personas-and-role-naming-conventions) for the role-prefix → persona map (referenced for *context*, not for auto-populating).

### status

Tracks whether the row's mapping has been confirmed by the service owner.

| Value         | Meaning                                                                       |
|---------------|-------------------------------------------------------------------------------|
| `decided`     | The service owner has confirmed the resource_type, action, roles, and method. |
| `not_decided` | Default for newly-mapped rows; awaiting service-owner review.                 |
| (empty)       | Same as `not_decided` — encoded as a blank cell in CSV.                       |

## Personas and role-naming conventions

A single endpoint can authorize **multiple user populations** simultaneously — each with its own derived-role set. Cerbos handles this with one resource policy that has multiple rules. Example from [`policies/content/digital_audio.yml`](https://github.com/theorchard/ows-pdp/tree/master/cerbos/policies/content/digital_audio.yml):

```yaml
- name: bulk_create                                          # internal CCM employees
  actions: ["bulk_create"]
  derivedRoles: ["content_ccm_digital_audio_can_bulk_create"]
- name: client_bulk_create                                   # external workstation customers
  actions: ["bulk_create"]
  derivedRoles: ["workstation_catalog", "workstation_admin"]
  condition:
    match:
      expr: V.has_bulk_uploader_tool_2025
```

One PP call (`is_authorized(digital_audio, bulk_create)`), two rules, three derived roles, two completely separate user populations. The endpoint's `proposed_roles` cell in the CSV lists **all three** — multi-valued.

Roles in cerbos follow a prefix convention that maps to a persona:

| Prefix          | Persona                                  | App / audience                                                |
|-----------------|------------------------------------------|---------------------------------------------------------------|
| `content_*`     | Content App internal employee            | frontend-content; CCM ops, NR review, etc.                    |
| `content_nr_*`  | NR-specific Content employee subset      | NR ownership flows in frontend-content                        |
| `workstation_*` | External customer (label / artist team)  | frontend-workstation                                          |
| `fansifter_*`   | Fansifter consumer (ad / email / SMS)    | Fansifter app                                                 |
| `account_*`     | a360 internal user                       | account / contract / royalties admin flows                    |
| `any_tenant_*`  | Cross-tenant variant of any of the above | Same role but scope is "all tenants" instead of "this tenant" |

When emitting `proposed_roles`, list **every persona's roles that should authorize the endpoint**. Most CRUD endpoints serve more than one persona; if a service is internal-only or external-only, say so in the diff notes so PP team can confirm.

Don't know which persona owns an endpoint? Check the frontend that calls it (frontend-content vs frontend-workstation vs the various Fansifter UIs) or grep for the route in `theorchard/frontend-*` repos. The [PDP User Guide](https://www.notion.so/PDP-User-Guide-7dba0f5b0b1f459d8817f7fba18b059e) and `#permissions-platform-public` are the fallbacks.

## How to map a new service

1. **Inventory endpoints.** Walk the service's route registrations and collect every method + path pair:
    - **Flask** — every `add_url_rule(...)` call (or `@app.route(...)` / `@blueprint.route(...)` decorator) across all blueprints.
    - **FastAPI** — every `@router.<method>(...)` decorator across each file in `<service>/api/routers/` (or wherever `APIRouter`s are defined). Cross-check with `app.include_router(...)` to make sure no router is missed.
    - **GraphQL (TypeScript)** — scan `src/schema/*.graphql` for every operation (queries, mutations, subscriptions) AND every field-level `@ppCanPerform` / `@ppAllowedTenants` directive application; also scan `src/resolvers/*.ts` (and `src/connectors/*.ts`) for direct calls to `isAuthorized` / `isAuthorizedMany` / `getAuthorizedTenants` / `isAuthorizedManyResourcesAndActions`. A single service may use both styles.
2. **For each endpoint, infer the columns:**
   - `microservice` — the service hosting the endpoint (e.g. `ows-product`, `ows-track`). Same value for every row in the file.
   - `resource_type` — from the handler's model class, response schema, or handler name (Flask/FastAPI), or from the `resource:` arg of the directive / first arg of the SDK call (GraphQL).
   - `resource_schema` — from the resource's PDP registration (look it up in `ows-pdp`).
   - `action` — from the HTTP verb and the handler's operation; reuse existing PDP actions where possible. For GraphQL, take it directly from the `action:` arg of the directive / SDK call.
   - `pp_authorization_method`:
     - dataloader / batch endpoints with one resource type → `is_authorized_many`
     - dataloader / batch endpoints with mixed resource types or actions → `is_authorized_many_resources_and_actions`
     - list endpoints that filter by tenant → `get_authorized_tenants`
     - single-resource endpoints → `is_authorized`
     - GraphQL `@ppCanPerform` → `is_authorized`; `@ppAllowedTenants` → `get_authorized_tenants`
   - `jira_ticket` — set to the ticket driving this rollout (e.g. `PP-1524` for the current bulk content-microservices effort); leave empty if unknown.
   - `proposed_roles` — pick from the cerbos derived roles in [`ows-pdp/cerbos/policies/derived_roles/`](https://github.com/theorchard/ows-pdp/tree/master/cerbos/policies/derived_roles), matching the resource family (a360 services use `a360.yml` roles, fansifter services use `fansifter.yml` roles, NR services use `nr_ownership.yml` roles, etc.). Use existing OA gating (`@requires_role`, `OARoles.has_any_of`) and the closest reference CSV row as starting hints — but the emitted value must be the **cerbos derived-role name**, not the OA role.
   - `status` — default to `not_decided` (or empty); the service owner flips it to `decided` after review.
3. **Cross-check against the reference CSVs** in `references/` for endpoints with the same shape (dataloader, list-with-filter, single-id).
4. **Emit the CSV** with all nine columns, one row per endpoint, sorted by path.

## Decision policy: reuse, propose, or wildcard

When mapping an endpoint, the inferred `(resource_type, action)` falls into one of these buckets. Pick one before emitting the CSV row.

### 1. Exact reuse (the goal)

If `(resource_type, action)` already exists in `cerbos_resource_actions.csv`, use it as-is. Set `proposed_roles` to the **union of derived roles** cerbos already lists for that pair (across all matching rules / personas — see [Personas](#personas-and-role-naming-conventions)). No proposal generated. Most rows for a mature service should land here.

### 2. Reuse with substitution

If the literal `(resource_type, action)` doesn't exist but a semantically-equivalent pair does (e.g. an "approve" endpoint maps to existing `digital_audio.review`), use the existing pair and note the substitution in the diff notes. Don't generate a proposal — the cerbos policy is already correct, just unfamiliar to the handler's vocabulary.

### 3. Propose a new action on an existing resource

When the resource_type exists in cerbos but the verb doesn't (e.g. `digital_audio.update` is missing), emit the row with the new verb and add an entry to `_cerbos_proposals.md` under "New actions on existing resource types". Include suggested rule YAML stubs — one per persona — so PP team can paste-review.

### 4. Propose a new resource type

When the resource_type itself isn't in cerbos at all (e.g. `project`, `language`, `upc`), emit the row with the new name and add an entry under "New resource types" with a suggested resource policy YAML stub (schema, imported derived-role sets, initial rules). Flag the personas as TBD when ambiguous — the service owner reviews.

### 5. Per-persona wildcard / `manage` collapse

When proposing new actions for the same persona, count how many CRUD verbs (`create`, `update`, `delete`) that persona's role set covers on the same resource. **If the count is ≥3 for a single persona, propose a `manage` action or `*` wildcard rule scoped to that persona — not for the whole resource.** Different personas may need different scopes (e.g. Content can do everything; Workstation can only create + update under a feature flag). The proposals doc lists per-verb and wildcard alternatives side-by-side; PP team picks one at review.

### 6. Machine principal policies (m2m endpoints)

If the endpoint is gated by `only_for_identity('lambda-...')` / `@only_for_identity` or otherwise marked as machine-only, propose a `principalPolicy` entry under `cerbos/policies/machines/` — these endpoints don't get derived roles. Add to `_cerbos_proposals.md` under "Machine principal policies" with the principal name and (resource, action) tuple.

### Decision tree summary

```
match in cerbos_resource_actions.csv?
├── yes (exact)                                              → reuse as-is
├── yes (semantic match, different verb)                     → reuse with substitution + diff note
└── no
    ├── machine-only endpoint (only_for_identity, etc.)      → propose principalPolicy entry
    ├── resource exists, action missing
    │   ├── 1–2 CRUD verbs missing for this persona          → propose per-verb action(s)
    │   └── ≥3 CRUD verbs missing for the same persona       → propose `manage` / `*` wildcard for that persona
    └── resource doesn't exist                               → propose new resource type (with rule stubs)
```

In every case, emit the CSV row using the proposed `(resource_type, action)`. The cell describes what PP **will** allow once the policy lands; `_cerbos_proposals.md` is the bridge.

## Output: combined cerbos proposals (`_cerbos_proposals.md`)

When the skill runs across multiple services in one rollout, it emits a single combined `_cerbos_proposals.md` at the top of the output directory (alongside the per-service CSVs). Combined — not per-service — because:

- Service owners can scan one file to see what's blocking their rollout.
- PP team sees overlaps at a glance (e.g. "5 services need `digital_audio.update`, propose once").
- Personas can be cross-cut — filter for `content_*` proposals to see Content team's queue, `workstation_*` for external customers, etc.
- Sortable by resource type so cross-app usage of the same resource is visible (e.g. how many services touch `digital_audio`).

### File schema

```markdown
# Cerbos policy proposals — <rollout name, e.g. PP-1524 take2>

Aggregated proposals across N services: <list>.

## Summary
| Resource | Status (new / extend) | New actions | Wildcard candidate? | Affected services |

## New resource types
### <resource_name>
- **Affected services**: <list with row count per service>
- **Suggested schema**: tenant_owned_resource | identity_owned_resource | identity_and_tenant_owned_resource | shared_resource | none
- **Suggested resource policy YAML** (paste-ready stub):
  ```yaml
  apiVersion: api.cerbos.dev/v1
  resourcePolicy:
    resource: <name>
    schemas: { resourceSchema: { ref: cerbos:///<schema>.json } }
    importDerivedRoles: [ <TBD — which persona role-set?> ]
    rules:
      - name: view
        actions: ["view"]
        derivedRoles: [<TBD>]
      - ...
  ```
- **Open questions**: which personas own this entity, whether m2m endpoints exist, etc.

## New actions on existing resource types
### <resource_name>
**Existing rules**: list verbs already in cerbos for this resource
**Missing actions**: <verbs>

#### Affected services
| Service | Endpoints | Verbs needed | Persona(s) |

#### Suggested rules (one per persona)

```yaml
# Internal persona
- name: <verb-or-manage>
  actions: ["<verb>"]                  # or ["create","update","delete"], or ["*"]
  derivedRoles: [<persona-roles>]
  effect: EFFECT_ALLOW

# External persona (if applicable)
- name: client_<verb-or-manage>
  actions: [...]
  derivedRoles: [<persona-roles>]
  condition: { match: { expr: V.<feature_flag> } }
  effect: EFFECT_ALLOW
```

#### Wildcard candidate?
- **Per-persona breakdown**: e.g. "Content persona: 3 CRUD verbs → wildcard candidate. Workstation persona: 2 CRUD verbs → no."
- If yes for any persona, the YAML above includes the wildcard alternative.

#### Open questions
- Feature-flag conditions (does `update` get the same `has_bulk_uploader_tool_2025` gate as `bulk_create`?)
- Scope differences between personas (e.g. customer can only delete their own pre-finalized uploads)
- Whether per-field `update:<field>` variants are needed for sensitive columns

## Wildcard / manage rules summary
| Resource | Persona | Verbs collapsed | Suggested rule name |

## Machine principal policies (m2m endpoints)
- **Principal**: lambda-<name> / fargate-<name>
- **Resource × actions**: ...
- **Affected services / endpoints**: ...

## How to act on this

1. **Service owners** — review the rows that name your service. Confirm personas (which user populations actually call these endpoints), raise open questions in `#permissions-platform-public`.
2. **PP team** — file ows-pdp PRs. Group new actions on the same resource into one PR per resource. The [PDP User Guide](https://www.notion.so/PDP-User-Guide-7dba0f5b0b1f459d8817f7fba18b059e) covers PR conventions.
3. **After PP-rules land**, re-run this skill — the affected rows in per-service CSVs validate clean against the updated `cerbos_resource_actions.csv`.

> **Resources**: [PDP User Guide](https://www.notion.so/PDP-User-Guide-7dba0f5b0b1f459d8817f7fba18b059e) · `#permissions-platform-public`
```

### When NOT to write the proposals doc

If every row in every per-service CSV exact-reuses an existing cerbos pair (Decision Policy bucket #1), skip the file — it'd be empty. The diff notes for each service will say so.

## Frontend permission gates (`Identity.canPerform`)

Some frontends use `Identity.canPerform` to decide whether to render a UI component (e.g. show or hide an "Edit" button). These are **not** HTTP endpoints — they're FE call sites that hit PP via a single GraphQL resolver — but the `(resource_type, action)` pairs they pass are still PP ground truth worth collecting.

**Resolver — implemented once, in `graphql-user`:**

```ts
// graphql-user/src/resolvers/identity.ts
async canPerform({ id }, { resourceTypeActions }, { dataSources, identityId }) {
    if (id !== identityId) {
        throw new GraphQLError('Identity.canPerform is only available for the authenticated user.');
    }
    return await dataSources.owsPdp.checkAuthenticatedUserResourceTypeActions(resourceTypeActions);
}
```

The resolver takes a batch of `{resourceType, action}` pairs and returns one allow/deny per pair, scoped to the authenticated identity. The underlying ows-pdp call is `checkAuthenticatedUserResourceTypeActions` — for the purposes of this skill, encode it as `pp_authorization_method = can_perform`.

**Frontend call sites** — use a `useCanPerformAction(identityId, action, resourceType)` hook. Two examples:

```ts
// frontend-royalties/src/components/account-detail/payment-detail-whitelabel.tsx
const { data: canEditBankInfo } = useCanPerformAction(
    identity.id,
    PERMISSIONS_ACTIONS.EDIT,           // "edit"
    PERMISSIONS_RESOURCE_TYPES.BANK_INFO // "bank_info"
);
```

```ts
// frontend-content/modules/neighbouringRights/src/utils/identityPermissions.ts
const viewPermission   = useCanPerformAction(identityId, 'view:*', resourceType);
const createPermission = useCanPerformAction(identityId, 'create', resourceType);
const editPermission   = useCanPerformAction(identityId, 'edit',   resourceType);
const deletePermission = useCanPerformAction(identityId, 'delete', resourceType);
```

**Action values seen in FE gates:** `view`, `view:*` (wildcard variant), `view:basic`, `view:nr`, `create`, `edit`, `update`, `delete`, plus any colon-namespaced variants the resource family uses (see [action](#action)).

### How to collect

Emit FE permission-gate rows to a **sibling CSV** named `<frontend>_frontend_pp_call_sites.csv` (one per frontend repo), using the same nine columns as the endpoint CSV but with these values:

| Column                  | Value                                                                                                                                                                              |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `microservice`          | The frontend repo slug (e.g. `frontend-royalties`, `frontend-content`). The "microservice" name is the consumer of the check, not graphql-user.                                    |
| `endpoint`              | `<relative_source_path>:<line>` of the `useCanPerformAction(...)` call site (e.g. `src/components/account-detail/payment-detail-whitelabel.tsx:75`). FE gates have no HTTP method/path. |
| `resource_type`         | The 3rd argument to `useCanPerformAction` (resolved if it's a constant — e.g. `PERMISSIONS_RESOURCE_TYPES.BANK_INFO` → `bank_info`).                                                |
| `resource_schema`       | Same lookup as backend rows — the resource's PDP registration in `ows-pdp`.                                                                                                        |
| `action`                | The 2nd argument to `useCanPerformAction` (resolved if it's a constant).                                                                                                           |
| `pp_authorization_method` | `can_perform`                                                                                                                                                                    |
| `jira_ticket` / `proposed_roles` / `status` | Same semantics as endpoint rows.                                                                                                                                  |

When you can't statically resolve an argument (it's a function parameter, e.g. `useCanPerformAction(id, 'view:*', resourceType)` where `resourceType` is a hook arg), emit the row with the literal symbol name and add a follow-up note — the calling components will reveal the concrete values.

### Where to look

- **Resolver (just the one):** `graphql-user/src/resolvers/identity.ts` — confirms the `can_perform` plumbing and shows the GraphQL operation name (`Identity.canPerform`).
- **Hook call sites (per FE repo):** grep for `useCanPerformAction(` across `frontend-*` repos. Each match is one row.
- **Constants modules:** many repos centralize the strings in `src/constants` (`PERMISSIONS_ACTIONS`, `PERMISSIONS_RESOURCE_TYPES`); resolve those before emitting rows. Some FE repos also wrap the hook in a higher-level helper (e.g. `useCrudPermissions` in frontend-content) — when that's the case, the helper will instantiate one hook per CRUD action and the wrapper itself is the most useful single row.

## Optional: publish the table to a Notion page

If the user asks to publish the tables to a Notion page (e.g. the SPIKE/planning page that owns the rollout), use the Notion MCP. Setup: see [README.md](README.md).

**Target shape: one combined database covering every onboarded service, plus a `# Follow-up` section with per-service review notes under `## <service>` subheadings.** Reviewers want a single sortable/filterable table with a `microservice` column to slice by, not N per-service tables nested in toggles. Don't use toggle headings — they add a click for every read.

Workflow:

1. **Fetch the target page** (`mcp__Notion__notion-fetch`) to confirm access and inspect existing structure. If a combined database is already on the page, append rows to it (skip to step 4 with the existing `data_source_id`) and add this service's notes under `## <service>` in the existing `# Follow-up` section. Otherwise, build it from scratch starting at step 2.
2. **Create the combined database** (`mcp__Notion__notion-create-database`) with the page as parent. Schema must be the **union of values across every CSV being published** — walk every CSV first to collect the full set of `microservice`, `resource_type`, `action`, etc. values before building the `CREATE TABLE`. Schema:
    - `endpoint` — `TITLE`
    - `microservice` — `SELECT` (one option per onboarded service, e.g. `ows-product`, `ows-track`, `ows-assets`)
    - `resource_type` — `SELECT` (values vary by service — see [column definitions](#resource_type) above)
    - `resource_schema` — `SELECT` (values from the [resource_schema](#resource_schema) table above)
    - `action` — `SELECT` (`view`, `update`, `create`, `delete`, plus any service-specific verbs that appear in the CSVs like `review`, `submit`)
    - `pp_authorization_method` — `SELECT` (`is_authorized`, `is_authorized_many`, `is_authorized_many_resources_and_actions`, `get_authorized_tenants`, `can_perform`)
    - `jira_ticket` — `RICH_TEXT`. To make the cell render as a clickable "PP-XXXX" link in Notion, set the value as a markdown link: `[PP-1524](https://theorchard.atlassian.net/browse/PP-1524)`. Notion's RICH_TEXT properties evaluate inline markdown, so the cell shows just `PP-1524` but clicking it opens Jira. (Plain `PP-1524` and full URLs also work but aren't clickable / not as compact.)
    - `proposed_roles` — `MULTI_SELECT` (one option per cerbos derived role, e.g. `account_admin`, `contract_admin`, `fansifter_can_view_fan_data`)
    - `status` — `SELECT` (`decided`, `not_decided`); leave the property unset for "not yet reviewed"

    Pick distinct colors per option for at-a-glance scanning (e.g. `view`:gray, `update`:yellow, `create`:green, `delete`:red; `decided`:green, `not_decided`:yellow).

    If the database already exists and you're adding a new service, use `mcp__Notion__notion-update-data-source` with `ALTER COLUMN` to extend the `microservice` / `resource_type` / `action` SELECT options before populating new rows.
3. **Reorder the default view's columns.** `notion-create-database` returns a default table view whose columns are ordered roughly alphabetically — not scannable. Call `mcp__Notion__notion-update-view` with this `configure` directive so the view matches the CSV order:

    ```
    SHOW "microservice", "endpoint", "resource_type", "resource_schema", "action", "pp_authorization_method", "jira_ticket", "proposed_roles", "status"
    ```

    Get the view ID from the `<view url="view://...">` block in the database fetch result (re-fetch the database if the create response didn't surface it).
4. **Populate rows** (`mcp__Notion__notion-create-pages`) using the returned `data_source_id` as the parent. The tool caps each call at 100 pages — split the combined row set into batches of ≤100. For rows with no PP mapping (health checks, debug endpoints), set only `endpoint` (title) and `microservice` (so the row still groups correctly) and leave the other properties unset. Likewise, when `resource_schema` is `none` in the CSV, leave the property unset rather than creating a `None` SELECT option.
5. **Restructure the page** so the combined database is the lead block. Call `mcp__Notion__notion-update-page` with `replace_content`. The page body should look like:

    ```
    <intro paragraph>
    <empty-block/>
    <database url="..." inline="true" data-source-url="...">title</database>
    <empty-block/>
    # Follow-up: rows worth a second look during PP rollout
    ## <service-1>
    - bullet
    - bullet
    ## <service-2>
    - bullet
    ```

    If the page previously had per-service databases that you want to clear out as part of this restructure, set `allow_deleting_content: true` on the call — Notion will move them to trash (recoverable for ~30 days). Otherwise leave it false to fail-safe if something would be deleted.

    **Two gotchas observed during PP-1524:**
    - Always put `<empty-block/>` between the database tag and the next heading. Without a separator, Notion can reorder a `## subheading` to sit *before* the `# Follow-up` heading that came after it. The empty block keeps the order stable.
    - `replace_content` may render the database tag as `inline="false"` (link card) even when you write `inline="true"` — the existing root-level reference (the database's natural parent is this page) takes precedence. Follow up with a targeted `update_content` swap that flips `inline="false"` → `inline="true"` on the exact tag. The second pass is reliably honored.

    Re-fetch the page after each update to confirm structure.
6. **Per-service review notes go under `## <service>` subheadings inside `# Follow-up`** — a bulleted list flagging anything ambiguous about a row's `action` / `pp_authorization_method` mapping that the rollout owner should confirm (e.g. OA-only endpoints, global-pool resources, internal job feeds, sub-resources without their own PDP registration, callbacks/internal endpoints left without a PP mapping). Plain `## <service>` headings, not toggles.

If the Notion MCP tools aren't available in the session, stop at the CSV and print a paste-ready table; don't try to fall back to `WebFetch` (it can't write to Notion).

## Where to get help

For any question this skill doesn't answer:

- **[PDP User Guide (Notion)](https://www.notion.so/PDP-User-Guide-7dba0f5b0b1f459d8817f7fba18b059e)** — cerbos schema reference, derived-role naming, principal-policy patterns, ows-pdp PR conventions. First stop for "how do I model X in cerbos?"
- **`#permissions-platform-public`** (Slack) — staffed by the PP team. For ambiguous resource/action mappings, persona uncertainty, or to confirm a proposed rule before opening an ows-pdp PR.

Service owners using this skill: hand the per-service CSV, the diff notes, and any rows you flagged for review to PP team via Slack. The combined `_cerbos_proposals.md` is the single artifact PP team works from to file ows-pdp PRs.
