Skip to main content

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-apps returns 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:

ScopePath prefixAuthPurposeDTO
Public/v2/catalog-appsUnauthenticatedCatalog browsing for public-facing UI pagesCatalogApp
Partner/v2/partners/{partner_id}/catalog-appsAuthenticatedApproval management with partner-specific contextCatalogAppApproval

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

RelationshipBounded?Always needed?Static?Decision
CatalogAppApproval -> CatalogAppYes (always 1)Yes (identity of the resource)YesEmbed as full CatalogApp DTO --- reuses the existing public shape, keeps catalog data clearly separate from partner context
CatalogAppApproval -> Global approval statusYes (always 1)Yes (core feature)Yes (status field)Include as global_approval_status on the wrapper
CatalogAppApproval -> Client usage countYes (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 listNo (10--100+ clients)No (only in detail drawer)No (changes with overrides)Separate endpoint
ClientApprovalEntry -> Client identityYes (always 1)Yes (row display)YesEmbed as ClientRef

Global Approval Status: Three-State Model

ValueMeaningCascade to non-overridden clients
"approved"Explicitly approved globallyClients become approved
"not_approved"Explicitly un-approved globallyClients become not approved
nullNo global decision (initial state)No cascade --- clients manage own status

State transitions:

  • null -> approved (via POST .../approvals)
  • null -> not_approved (via POST .../approvals/remove)
  • approved -> not_approved (via POST .../approvals/remove)
  • not_approved -> approved (via POST .../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."

ResponsibilityLayerTrigger
Cascade to existing clientsNestJS APIGlobal approval changes
Inherit for newly-detected appsPython ensure_appAgent 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"
}
FieldTypeNullableDescription
idstring (uuid)NoStable unique identifier for the client
namestringNoClient 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
}
FieldTypeNullableDescription
catalog_appCatalogAppNoThe shared catalog app with vendor and categories embedded
global_approval_statusstringYes"approved", "not_approved", or null (no global decision yet)
clients_usingintegerNoNumber of the partner's clients that have this app in their account
clients_totalintegerNoTotal number of the partner's clients with SaaS usage reporting (collection_mode = 'saas_usage' and status = 'active')

DB source:

DTO FieldDB Column
catalog_appFull CatalogApp DTO joined via catalog_applications (same as the public endpoint)
global_approval_statusLEFT 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_usingaggregate: COUNT of apps rows for this catalog_application_id across partner's clients
clients_totalaggregate: 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
}
FieldTypeNullableDescription
clientClientRefNoClient identity
is_approvedbooleanNoCurrent approval status for this client-app pair
is_overridebooleanNotrue if individually overridden, false if inherited from the global setting

DB source:

DTO FieldDB Column
clientclients.id, clients.name
is_approvedapps.is_approved (for the matching catalog_application_id + client_id)
is_overrideapps.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
}
FieldTypeNullableDescription
globally_approved_countintegerNoCatalog apps with global_approval_status = "approved" for this partner
client_used_app_countintegerNoDistinct catalog apps used by at least one of the partner's clients
app_countintegerNoTotal catalog apps
vendor_countintegerNoTotal 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"
}
]
}
FieldTypeDescription
succeededstring (uuid)[]IDs successfully processed (idempotent --- already-in-desired-state included)
failedobject[]IDs that failed with error details
failed[].idstring (uuid)The ID that failed
failed[].errorstringReason 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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID

Query Parameters:

ParameterTypeDefaultDescription
qstring-Search against canonical_name (case-insensitive, partial match)
vendor_idsstring-Comma-separated vendor UUIDs
category_idsstring-Comma-separated category UUIDs
global_approval_statusstring-Filter: approved or not_approved. Omit to show all.
has_clientsboolean-true: only apps used by at least one client. false: only apps with zero clients. Omit: all.
sort_bystringcanonical_nameSort field: canonical_name, vendor, clients_using
sort_orderstringascSort direction: asc, desc
page_sizeinteger50Items per page. Max: 200
cursorstring-Pagination cursor

Response:

FieldTypeDescription
dataCatalogAppApproval[]Array of catalog app objects with approval data
total_countintegerTotal matching the current filters
next_cursorstring | nullCursor 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 caseEndpoint
Public catalog browsing (unauthenticated)GET /v2/catalog-apps/{catalog_app_id}
App detail drawer with global approval settingGET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}
Deep link or page refresh on app drawerGET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}

Path Parameters:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID
catalog_app_idstring (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:

ParameterTypeDescription
partner_idstring (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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID

Request Body:

FieldTypeRequiredDescription
catalog_app_idsstring (uuid)[]YesCatalog 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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID

Request Body:

FieldTypeRequiredDescription
catalog_app_idsstring (uuid)[]YesCatalog 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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID
catalog_app_idstring (uuid)Catalog app ID

Query Parameters:

ParameterTypeDefaultDescription
is_approvedboolean-Filter by approval status
is_overrideboolean-Filter by override status
qstring-Search by client name
page_sizeinteger50Items per page. Max: 200
cursorstring-Pagination cursor

Response:

FieldTypeDescription
dataClientApprovalEntry[]Array of per-client approval entries
total_countintegerTotal clients matching filters
next_cursorstring | nullCursor 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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID
catalog_app_idstring (uuid)Catalog app ID

Request Body:

FieldTypeRequiredDescription
client_idsstring (uuid)[]YesClient 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:

ParameterTypeDescription
partner_idstring (uuid)Partner organization ID
catalog_app_idstring (uuid)Catalog app ID

Request Body:

FieldTypeRequiredDescription
client_idsstring (uuid)[]YesClient 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

EndpointShapeEstimated JoinsStatus
GET /partners/{partner_id}/catalog-appsList~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/summarySummary1 (approval table aggregate) + catalog-level countsOK
GET /partners/{partner_id}/catalog-apps/{id}/approvals/clientsList1 (clients table)OK

Guideline References

DecisionGuideline Section
Partner-scoped path /v2/partners/{partner_id}/catalog-appsMirrors /v2/clients/{client_id}/... pattern; keeps /v2/catalog-apps public
Embedding vendor and categoriesEmbedding Rule --- bounded, always needed, static
Per-client list as separate endpointEmbedding Rule --- unbounded, not always needed
ClientRef namingDTO Naming: When to use a Ref --- subset of full Client
Cursor-based paginationPagination is always cursor-based
vendor_ids, category_ids plural paramsFilter Parameters: Plural IDs
partner_id, catalog_app_id path paramsPath parameters --- resource-specific names
POST for bulk approve/removeBulk state transitions and Bulk removal
CRUD summary (no period)CRUD summary
id in DTOsResource IDs
No PUTHTTP Methods

Checklist

  • Create Partner Catalog Apps Overview domain overview page
  • Define ClientRef in domain overview
  • Define CatalogAppApproval in domain overview
  • Define ClientApprovalEntry in domain overview
  • Define GlobalApprovalsSummary in domain overview
  • Define BulkApprovalResult in domain overview
  • Document GET /v2/partners/{partner_id}/catalog-apps endpoint page
  • Document GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id} endpoint page
  • Document GET /v2/partners/{partner_id}/catalog-apps/summary endpoint page
  • Document POST /v2/partners/{partner_id}/catalog-apps/approvals endpoint page
  • Document POST /v2/partners/{partner_id}/catalog-apps/approvals/remove endpoint page
  • Document GET /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients endpoint page
  • Document POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients endpoint page
  • Document POST /v2/partners/{partner_id}/catalog-apps/{catalog_app_id}/approvals/clients/remove endpoint page
  • Update Apps Overview with per-client override semantics for is_approved
  • Add sidebar entries for new partner-catalog-apps pages