Proposal: Global App Approval Management
Status
Approved --- all open questions resolved, ready for implementation
Overview
This feature introduces partner-level approval management for catalog apps. Today, approval (is_approved) exists only on the client-scoped apps table --- each client's approval is independent. This feature adds a partner-level global approval that cascades as the default to all clients, with per-client overrides that are preserved ("sticky") when the global setting changes.
The feature touches two existing domains:
- Catalog Apps --- shared catalog, remains public/unauthenticated at
/v2/catalog-apps. A new partner-scoped view at/v2/partners/{partner_id}/catalog-appsreturns the same catalog enriched with approval status and client usage. - Apps --- client-scoped app records with existing
is_approved. The override concept extends this field with "inherited vs. overridden" semantics.
The existing /v2/catalog-apps endpoints remain public and unchanged. The existing client-scoped approval endpoints (POST /v2/clients/{client_id}/apps/approvals and /remove) continue to function as-is and now create per-client overrides.
Public vs. Partner-Scoped Catalog App Endpoints
The catalog app domain now has two parallel sets of endpoints serving different audiences:
| Scope | Path prefix | Auth | Purpose | DTO |
|---|---|---|---|---|
| Public | /v2/catalog-apps | Unauthenticated | Catalog browsing for public-facing UI pages | CatalogApp |
| Partner | /v2/partners/{partner_id}/catalog-apps | Authenticated | Approval management with partner-specific context | CatalogAppApproval |
The partner-scoped endpoints return the same underlying catalog data (embedded as the existing CatalogApp DTO) enriched with partner-specific fields: global_approval_status, clients_using, and clients_total. The catalog itself is shared --- all partners see the same catalog apps. The partner scope adds the approval and client usage context on top.
Embedding Decisions
| Relationship | Bounded? | Always needed? | Static? | Decision |
|---|---|---|---|---|
| CatalogAppApproval -> CatalogApp | Yes (always 1) | Yes (identity of the resource) | Yes | Embed as full CatalogApp DTO --- reuses the existing public shape, keeps catalog data clearly separate from partner context |
| CatalogAppApproval -> Global approval status | Yes (always 1) | Yes (core feature) | Yes (status field) | Include as global_approval_status on the wrapper |
| CatalogAppApproval -> Client usage count | Yes (always 1 ratio) | Yes (table column) | No (computed aggregate) | Include as computed fields clients_using/clients_total on the wrapper --- needed for display despite being computed |
| CatalogAppApproval -> Per-client approval list | No (10--100+ clients) | No (only in detail drawer) | No (changes with overrides) | Separate endpoint |
| ClientApprovalEntry -> Client identity | Yes (always 1) | Yes (row display) | Yes | Embed as ClientRef |
Global Approval Status: Three-State Model
| Value | Meaning | Cascade to non-overridden clients |
|---|---|---|
"approved" | Explicitly approved globally | Clients become approved |
"not_approved" | Explicitly un-approved globally | Clients become not approved |
null | No global decision (initial state) | No cascade --- clients manage own status |
State transitions:
null->approved(viaPOST .../approvals)null->not_approved(viaPOST .../approvals/remove)approved->not_approved(viaPOST .../approvals/remove)not_approved->approved(viaPOST .../approvals)- No UI path back to
null--- it is the initial state only
Data Model
Schema changes
partner_catalog_app_approvals (new table)
+-- id (PK)
+-- partner_id (FK)
+-- catalog_application_id (FK)
+-- status ("approved" | "not_approved")
+-- created_at
+-- updated_at
apps (existing table, add column)
+-- is_approval_override (boolean, default false)
partner_catalog_app_approvals stores the global partner-level approval status per catalog app. The existing apps.is_approved column continues to hold the effective client-level approval state. The new apps.is_approval_override column marks whether a client's approval was individually set (true) or inherited from the global setting (false).
Cascade behavior
When a global approval changes, a synchronous cascade updates all non-overridden clients within the same transaction:
UPDATE apps SET is_approved = ?
WHERE catalog_application_id = ANY(?)
AND client_id IN (SELECT id FROM clients WHERE partner_id = ?)
AND is_approval_override = false
Worst case (50 apps x 200 clients = 10,000 rows) is sub-second with proper indexing on (catalog_application_id, client_id, is_approval_override). The global table write and the cascade are wrapped in one transaction for atomicity. No background jobs or eventual consistency needed.
History side effects
Global cascade changes do not generate app_history rows --- this avoids creating thousands of rows for a single global action. The partner_catalog_app_approvals row itself (timestamps + actor) serves as the audit record for global changes.
Direct per-client overrides via the existing endpoints (POST /v2/clients/{client_id}/apps/approvals and /remove) continue to create approval_status_changed history events as they do today. These endpoints additionally set is_approval_override = true.
New client inheritance
App records are created by the Python ingestion/agent-api layer (ensure_app). When ensure_app inserts a new apps row for a client, it checks partner_catalog_app_approvals and sets is_approved accordingly with is_approval_override = false. No speculative app records are created --- the apps table retains its semantic of "this client has been observed using this app."
| Responsibility | Layer | Trigger |
|---|---|---|
| Cascade to existing clients | NestJS API | Global approval changes |
| Inherit for newly-detected apps | Python ensure_app | Agent reports a new app for a client |
DTOs
ClientRef
Lightweight client reference for embedding inside approval entries. A strict subset of the full Client DTO --- consumers call GET /clients/{client_id} for partner info, enrollment token, and timestamps.
{
"id": "aa7cf840-9ca9-46a3-9778-9015d6580d50",
"name": "Acme Corporation"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string (uuid) | No | Stable unique identifier for the client |
name | string | No | Client display name |
DB source: clients.id, clients.name
Used in: Embedded inside ClientApprovalEntry.client
CatalogAppApproval
Partner-scoped wrapper around the existing CatalogApp DTO. Embeds the full shared catalog app and adds partner-specific context: global approval status and client usage metrics. Used by both the list and detail partner-scoped endpoints.
This follows the same pattern as the client-scoped App DTO, which wraps CatalogApp with client-specific fields (is_approved, is_favorite, license_count). Here, the wrapper adds partner-specific fields instead.
{
"catalog_app": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"canonical_name": "Microsoft Teams",
"description": "A comprehensive collaboration platform that combines video conferencing, chat messaging, file sharing, and team collaboration tools.",
"display_colour": "#6264A7",
"logo": "https://cdn.example.com/logos/teams.png",
"type": "desktop",
"vendor": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Microsoft",
"url": "https://www.microsoft.com"
},
"categories": [
{ "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "name": "Communication" },
{ "id": "d4e5f6a7-b8c9-0123-defa-234567890123", "name": "Productivity" },
{ "id": "e5f6a7b8-c9d0-1234-efab-345678901234", "name": "Meetings" }
],
"created_at": "2025-06-15T10:00:00.000Z",
"updated_at": "2026-01-20T14:30:00.000Z"
},
"global_approval_status": "not_approved",
"clients_using": 4,
"clients_total": 5
}
| Field | Type | Nullable | Description |
|---|---|---|---|
catalog_app | CatalogApp | No | The shared catalog app with vendor and categories embedded |
global_approval_status | string | Yes | "approved", "not_approved", or null (no global decision yet) |
clients_using | integer | No | Number of the partner's clients that have this app in their account |
clients_total | integer | No | Total number of the partner's clients with SaaS usage reporting (collection_mode = 'saas_usage' and status = 'active') |
DB source:
| DTO Field | DB Column |
|---|---|
catalog_app | Full CatalogApp DTO joined via catalog_applications (same as the public endpoint) |
global_approval_status | LEFT JOIN partner_catalog_app_approvals WHERE partner_id = path param AND catalog_application_id = catalog app id; "approved" or "not_approved" if row exists, null otherwise |
clients_using | aggregate: COUNT of apps rows for this catalog_application_id across partner's clients |
clients_total | aggregate: COUNT of partner's clients WHERE collection_mode = 'saas_usage' AND status = 'active' |
Used in: List Partner Catalog Apps, Get Partner Catalog App
ClientApprovalEntry
Per-client approval status for a specific catalog app. Returned by the per-client breakdown endpoint in the app detail drawer.
{
"client": {
"id": "aa7cf840-9ca9-46a3-9778-9015d6580d50",
"name": "Acme Corporation"
},
"is_approved": true,
"is_override": true
}
| Field | Type | Nullable | Description |
|---|---|---|---|
client | ClientRef | No | Client identity |
is_approved | boolean | No | Current approval status for this client-app pair |
is_override | boolean | No | true if individually overridden, false if inherited from the global setting |
DB source:
| DTO Field | DB Column |
|---|---|
client | clients.id, clients.name |
is_approved | apps.is_approved (for the matching catalog_application_id + client_id) |
is_override | apps.is_approval_override |
Used in: List Client Approvals for a Catalog App
GlobalApprovalsSummary
CRUD summary for the four metric cards. No date parameters, no period object --- reflects current state.
{
"globally_approved_count": 50,
"client_used_app_count": 127,
"app_count": 250,
"vendor_count": 41
}
| Field | Type | Nullable | Description |
|---|---|---|---|
globally_approved_count | integer | No | Catalog apps with global_approval_status = "approved" for this partner |
client_used_app_count | integer | No | Distinct catalog apps used by at least one of the partner's clients |
app_count | integer | No | Total catalog apps |
vendor_count | integer | No | Total vendors in the catalog |
Used in: Partner Catalog Apps Summary endpoint
BulkApprovalResult
Standard bulk operation result envelope for all approval mutation endpoints.
{
"succeeded": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"e5f6a7b8-c9d0-1234-efab-567890123456"
],
"failed": [
{
"id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
"error": "Catalog app not found"
}
]
}
| Field | Type | Description |
|---|---|---|
succeeded | string (uuid)[] | IDs successfully processed (idempotent --- already-in-desired-state included) |
failed | object[] | IDs that failed with error details |
failed[].id | string (uuid) | The ID that failed |
failed[].error | string | Reason for the failure |
Used in: All POST approval/removal endpoints
Endpoints
GET /v2/partners/{partner_id}/catalog-apps --- List Partner Catalog Apps
Returns a paginated list of all catalog apps enriched with the partner's global approval status and client usage. Powers the "App Catalog" table.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | - | Search against canonical_name (case-insensitive, partial match) |
vendor_ids | string | - | Comma-separated vendor UUIDs |
category_ids | string | - | Comma-separated category UUIDs |
global_approval_status | string | - | Filter: approved or not_approved. Omit to show all. |
has_clients | boolean | - | true: only apps used by at least one client. false: only apps with zero clients. Omit: all. |
sort_by | string | canonical_name | Sort field: canonical_name, vendor, clients_using |
sort_order | string | asc | Sort direction: asc, desc |
page_size | integer | 50 | Items per page. Max: 200 |
cursor | string | - | Pagination cursor |
Response:
| Field | Type | Description |
|---|---|---|
data | CatalogAppApproval[] | Array of catalog app objects with approval data |
total_count | integer | Total matching the current filters |
next_cursor | string | null | Cursor for next page. null on last page. |
GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id} --- Get Partner Catalog App
Returns the full catalog app with the partner's global approval status and client usage. Powers the app detail drawer. This is the partner-scoped counterpart to the public GET /v2/catalog-apps/{catalog_app_id}.
When to use which detail endpoint:
| Use case | Endpoint |
|---|---|
| Public catalog browsing (unauthenticated) | GET /v2/catalog-apps/{catalog_app_id} |
| App detail drawer with global approval setting | GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id} |
| Deep link or page refresh on app drawer | GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id} |
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
catalog_app_id | string (uuid) | Catalog app ID |
Response: CatalogAppApproval object.
GET /v2/partners/{partner_id}/catalog-apps/summary --- Partner Catalog Apps Summary
Returns aggregate counts for the four metric cards.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
Query Parameters: None
Response: GlobalApprovalsSummary object.
POST /v2/partners/{partner_id}/catalog-apps/approvals --- Bulk Add to Global Approved List
Add one or more catalog apps to the partner's global approved list. Sets global_approval_status to "approved". Idempotent. All clients without per-client overrides are updated to approved.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
catalog_app_ids | string (uuid)[] | Yes | Catalog app IDs to approve globally |
Response: 200 OK --- BulkApprovalResult
Side effects: Within the same transaction, cascades is_approved = true to all apps rows where is_approval_override = false for the affected catalog apps across the partner's clients. No app_history events are created for cascaded changes.
POST /v2/partners/{partner_id}/catalog-apps/approvals/remove --- Bulk Remove from Global Approved List
Remove one or more catalog apps from the partner's global approved list. Sets global_approval_status to "not_approved". Idempotent. All clients without per-client overrides revert to not approved.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
catalog_app_ids | string (uuid)[] | Yes | Catalog app IDs to remove from global approved list |
Response: 200 OK --- BulkApprovalResult
Side effects: Within the same transaction, cascades is_approved = false to all apps rows where is_approval_override = false for the affected catalog apps across the partner's clients. No app_history events are created for cascaded changes.
GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients --- List Client Approvals
Returns a paginated list of the partner's clients with their approval status for a specific catalog app. Powers the "Clients Using This App" section in the app detail drawer.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
catalog_app_id | string (uuid) | Catalog app ID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
is_approved | boolean | - | Filter by approval status |
is_override | boolean | - | Filter by override status |
q | string | - | Search by client name |
page_size | integer | 50 | Items per page. Max: 200 |
cursor | string | - | Pagination cursor |
Response:
| Field | Type | Description |
|---|---|---|
data | ClientApprovalEntry[] | Array of per-client approval entries |
total_count | integer | Total clients matching filters |
next_cursor | string | null | Cursor for next page |
POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients --- Bulk Override Client to Approved
Set per-client approval override to approved for one or more clients. Creates a sticky override that persists across future global approval changes. Idempotent.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
catalog_app_id | string (uuid) | Catalog app ID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
client_ids | string (uuid)[] | Yes | Client IDs to override as approved |
Response: 200 OK --- BulkApprovalResult
Side effects: Sets apps.is_approved = true and apps.is_approval_override = true for each client. Creates approval_status_changed history events per client.
POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients/remove --- Bulk Override Client to Not Approved
Set per-client approval override to not approved for one or more clients. Creates a sticky override that persists across future global approval changes. Idempotent.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
partner_id | string (uuid) | Partner organization ID |
catalog_app_id | string (uuid) | Catalog app ID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
client_ids | string (uuid)[] | Yes | Client IDs to override as not approved |
Response: 200 OK --- BulkApprovalResult
Side effects: Sets apps.is_approved = false and apps.is_approval_override = true for each client. Creates approval_status_changed history events per client.
Query Cost Check
| Endpoint | Shape | Estimated Joins | Status |
|---|---|---|---|
GET /partners/{partner_id}/catalog-apps | List | ~4 (vendor + categories junction + categories + approval table + client count subquery) | AT THRESHOLD --- all fields serve direct table columns. Client count uses a correlated subquery, not a join. Acceptable. |
GET /partners/{partner_id}/catalog-apps/{catalog_app_id} | Detail | ~4 (same as list, single row by PK) | OK --- detail endpoints tolerate up to 6 joins. |
GET /partners/{partner_id}/catalog-apps/summary | Summary | 1 (approval table aggregate) + catalog-level counts | OK |
GET /partners/{partner_id}/catalog-apps/{id}/approvals/clients | List | 1 (clients table) | OK |
Guideline References
| Decision | Guideline Section |
|---|---|
Partner-scoped path /v2/partners/{partner_id}/catalog-apps | Mirrors /v2/clients/{client_id}/... pattern; keeps /v2/catalog-apps public |
| Embedding vendor and categories | Embedding Rule --- bounded, always needed, static |
| Per-client list as separate endpoint | Embedding Rule --- unbounded, not always needed |
ClientRef naming | DTO Naming: When to use a Ref --- subset of full Client |
| Cursor-based pagination | Pagination is always cursor-based |
vendor_ids, category_ids plural params | Filter Parameters: Plural IDs |
partner_id, catalog_app_id path params | Path parameters --- resource-specific names |
POST for bulk approve/remove | Bulk state transitions and Bulk removal |
CRUD summary (no period) | CRUD summary |
id in DTOs | Resource IDs |
No PUT | HTTP Methods |
Checklist
- Create Partner Catalog Apps Overview domain overview page
- Define
ClientRefin domain overview - Define
CatalogAppApprovalin domain overview - Define
ClientApprovalEntryin domain overview - Define
GlobalApprovalsSummaryin domain overview - Define
BulkApprovalResultin domain overview - Document
GET /v2/partners/{partner_id}/catalog-appsendpoint page - Document
GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}endpoint page - Document
GET /v2/partners/{partner_id}/catalog-apps/summaryendpoint page - Document
POST /v2/partners/{partner_id}/catalog-apps/approvalsendpoint page - Document
POST /v2/partners/{partner_id}/catalog-apps/approvals/removeendpoint page - Document
GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clientsendpoint page - Document
POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clientsendpoint page - Document
POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients/removeendpoint page - Update Apps Overview with per-client override semantics for
is_approved - Add sidebar entries for new partner-catalog-apps pages