Skip to main content
Version: main 🚧

PDP Coverage Audit — UI BFF /api/* Routes

Status: Living audit, last refreshed 2026-05-27. Re-run the grep snippets in the How this audit was produced section whenever you change ui/src/lib/api-middleware.ts::resolveLegacyWithAuthRbacPolicy or add a new /api/* route.

Audience: Engineers reviewing whether a UI BFF endpoint is properly PDP-gated, and security architects checking that we never silently regressed to a Mongo-only "is admin?" decision.

TL;DR

  1. Every production /api/* route in the UI BFF reaches a Policy Decision Point (PDP). There is no endpoint today that authorizes purely from MongoDB.
  2. The PDP is OpenFGA for everything that goes through requireRbacPermission(), with a Keycloak Authorization Services fallback for routes whose target relation isn't yet modelled in OpenFGA. See JWT and OpenFGA for the dual-PDP rationale.
  3. Three patterns coexist today, in decreasing order of how cleanly they map to user intent:
    • Resource-scoped PDP (requireResourcePermission(..., { type, id, action })) — best.
    • Capability-scoped PDP (requireRbacPermission(..., resource, scope) or requireAdminSurfaceManage/etc.) — good.
    • Compatibility withAuth(...) that resolves route prefixes to explicit capabilities via ui/src/lib/api-middleware.ts. Tracked for continued cleanup by 2026-05-27-fine-grained-rbac-for-withauth-routes.
  4. A handful of routes still have an if (user.role === 'admin') { skip PDP } short-circuit. These are not Mongo-based — session.role === 'admin' comes from the JWT (BOOTSTRAP_ADMIN_EMAILS or OIDC_REQUIRED_ADMIN_GROUP) — but they are a parallel decision path with no OpenFGA audit trail. Listed in Category 1.

If you are here because something denied unexpectedly, jump straight to How to read an audit_event_id row.


How the BFF gate works

Every UI API route ends up at one of three call sites, all defined in ui/src/lib/api-middleware.ts:

// Pattern 1 — resource-scoped (preferred)
const { session } = await getAuthFromBearerOrSession(request);
await requireResourcePermission(session, { type: "system_config", id: "platform_settings", action: "read" });

// Pattern 2 — capability-scoped
const { session } = await getAuthFromBearerOrSession(request);
await requireRbacPermission(session, "admin_ui", "admin");

// Pattern 3 — legacy umbrella (still PDP, but via a prefix-derived policy)
return withAuth(request, async (req, user, session) => { ... });

withAuth is not "is the user signed in?" — it resolves the URL to a { resource, scope } pair and then runs the same requireRbacPermission() everyone else does. The mapping (post the 2026-05-27 platform-config carve-out) is:

URL prefixPDP capability the BFF enforces
/api/users/debugadmin_ui#view
/api/admin/platform-config (GET, exception)system_config#read
/api/admin/* (other GET)admin_ui#view
/api/admin/* (non-GET)admin_ui#manage
/api/users/meself_profile#read/write
/api/users/searchuser_directory#read
/api/auth/my-roles, /api/auth/roleself_profile#read
/api/auth/slack-link, /api/auth/webex-linkself_profile#write
/api/settingsuser_settings#read/write
/api/nps, /api/feedbackfeedback#submit
/api/chat, /api/a2a, /api/dynamic-agents/models, /api/dynamic-agents/availablechat_supervisor#invoke
/api/filesuser_files#read/write
/api/aiai_assist#invoke
/api/credentialscredential_vault#use plus concrete secret_ref checks
/api/task-configs (GET / non-GET)dynamic_agent#view / manage
/api/workflow-runs (GET / non-GET)dynamic_agent#view / invoke
/api/catalog-api-keysskill#configure
/api/skills/seedadmin_ui#admin
/api/skills/tokenskill#invoke
/api/skills / /api/skill-templates (everything else)skill#view / delete / configure
fallback (GET / non-GET)admin_ui#view / manage

The OpenFGA relation each pair maps to is computed by organizationRelationFor() in the same file:

resourcescopeOpenFGA relation on organization:caipe
admin_uiview / audit.viewcan_audit
admin_uiany othercan_manage
self_profileread / writecan_read_self / can_manage_self
user_directoryanycan_search_directory
chat_supervisoranycan_chat
feedbackanycan_submit_feedback
user_settingsanycan_manage_self
user_filesanycan_use_files
ai_assistanycan_use_ai_assist
credential_vaultanycan_use_credentials
anyview / read / query / invokecan_use
anyaudit.viewcan_audit
anyanything elsecan_manage

So withAuth on GET /api/users/me becomes an OpenFGA check for user:<sub> can_read_self organization:caipe, while PATCH /api/users/me checks can_manage_self. Those relations are derived from organization membership/admin in the model, so existing signed-in members keep baseline self-service access after upgrade.


Route-by-route audit

The audit reaches every route.ts under ui/src/app/api/ (excluding __tests__/). Routes are grouped by what gates them, not by URL.

Category 1 — Legacy admin bypass (no PDP on the bypass branch)

These routes contain a branch of the form if (user.role === 'admin') { skip PDP } else { ... }. session.role === 'admin' is set by the NextAuth JWT callback in ui/src/lib/auth-config.ts from one of:

  • A match against BOOTSTRAP_ADMIN_EMAILS, or
  • Membership in OIDC_REQUIRED_ADMIN_GROUP (default empty / unused).

The bypass is not Mongo-derived, but it is invisible to OpenFGA and produces no audit_event_id row. In the steady state we want every authorization decision to flow through PDP so the audit trail is complete.

RouteFileBypass siteNotes
/api/workflow-configsui/src/app/api/workflow-configs/route.tslines 117, 217, 265Skips requireResourcePermission({ type: "task", … }) for admins on GET, PUT, DELETE.
/api/skills/configsui/src/app/api/skills/configs/route.tsline 367Admin reads skip requireResourcePermission({ type: "skill", id, action: "read" }). Writes/deletes always PDP.
/api/admin/teams/[id]/kb-assignments (GLOBAL_PSEUDO_TEAM branch)ui/src/app/api/admin/teams/[id]/kb-assignments/route.tslines 151, 204, 300The global pseudo-team branch only checks user.role === 'admin' — no PDP at all on that branch.
/api/admin/openfga/baseline-profile (isAdminUser helper)ui/src/app/api/admin/openfga/baseline-profile/route.tsline 167Reads users.role === 'admin' from MongoDB, but only as input data to bulk OpenFGA reconciliation. The route handler itself is gated by withOpenFgaViewAuth / withOpenFgaAdminAuth. Safe; included here for completeness.

Severity: Medium. Bootstrap admins are already empowered through PDP anyway (they hold organization:caipe#can_manage), so the bypass doesn't actually grant additional access. The cost is the missing audit trail and a code path that future contributors might extend without realizing they're skipping the canonical PDP.

Remediation: Replace each bypass with a requireResourcePermission(..., { ..., action: "admin" }) call against the resource the route mutates (or requireRbacPermission(session, "admin_ui", "admin") where no resource exists). The GLOBAL_PSEUDO_TEAM branch in kb-assignments should route through PDP unconditionally.

Category 2 — withAuth-only routes (PDP via the legacy umbrella)

These routes look ungated at first glance — the only auth line in the file is return withAuth(request, async (req, user, session) => { ... }) — but they ARE PDP-gated. withAuth runs the URL through resolveLegacyWithAuthRbacPolicy() and then calls requireRbacPermission(), which hits OpenFGA. The capability they end up enforcing is in the table at the top of this doc. The long tail that used to fall through to a generic supervisor capability is now split into purpose-specific route capabilities.

RouteFileEffective PDP capability
/api/users/me (GET / PATCH)ui/src/app/api/users/me/route.tsself_profile#read/write (self-scoped Mongo query is keyed on the caller's verified email — no IDOR surface)
/api/users/me/insights, /api/users/me/favorites, /api/users/me/agent-*ui/src/app/api/users/me/...self_profile#read (self-scoped)
/api/users/searchui/src/app/api/users/search/route.tsuser_directory#read — returns public profile fields only (email, name, avatar_url)
/api/chat/conversations/[id]/messagesui/src/app/api/chat/conversations/[id]/messages/route.tschat_supervisor#invoke at the BFF + an inline supervisor invoke check on PATCH until that route is fully migrated
/api/chat/conversations/[id]/shareui/src/app/api/chat/conversations/[id]/share/route.tschat_supervisor#invoke at the BFF + requireConversationResourcePermission(session, user.email, conversation, "share") inline
/api/settings/*ui/src/app/api/settings/...user_settings#read/write
/api/nps/*, /api/feedbackui/src/app/api/{nps,feedback}/...feedback#submit
/api/files/list, /api/files/contentui/src/app/api/files/...user_files#read/write (session-scoped upload tree)
/api/ai/review, /api/ai/assistui/src/app/api/ai/...ai_assist#invoke
/api/credentials/{retrieve,audit,health}ui/src/app/api/credentials/...credential_vault#use at the BFF; per-credential secret_ref checks enforced inside the credential services in ui/src/lib/credentials/
/api/credentials/connections/*, /api/credentials/oauth/*, /api/credentials/inject/*ui/src/app/api/credentials/...Same pattern — credential-vault route gate plus resource-scoped checks inside service
/api/integrations/slack/.../access-check, /api/integrations/webex/.../access-checkui/src/app/api/integrations/...Concrete slack_channel#read / webex_space#read before delegated access checks
/api/auth/role, /api/auth/my-roles, /api/auth/slack-link, /api/auth/webex-linkui/src/app/api/auth/...self_profile#read/write; /api/auth/role also checks organization:caipe#can_manage to elevate to admin
/api/dynamic-agents/modelsui/src/app/api/dynamic-agents/models/route.tschat_supervisor#invoke
/api/dynamic-agents/availableui/src/app/api/dynamic-agents/available/route.tschat_supervisor#invoke at the BFF + filterResourcesByPermission(session, agents, { type: "agent", action: "use" }) inline
/api/a2a/[[...path]]ui/src/app/api/a2a/...chat_supervisor#invoke; A2A path runs its own JWKS validation downstream
/api/versionui/src/app/api/version/route.tsPublic read-only build metadata; no withAuth wrapper

Severity: Reduced. Every route IS PDP-gated, and the high-traffic basic surfaces now emit capability names that match the surface being used. Any newly added withAuth route that does not fit the table should be given an explicit capability mapping before merge.

Remediation: Continue shrinking this category by replacing wrapper-level gates with inline resource checks where concrete resource ids are available. New withAuth routes should add an explicit mapping instead of relying on the deprecated fallback.

Category 3 — Properly resource-scoped PDP

These routes call a fine-grained require*Permission helper directly. The PDP decision matches user intent, the audit trail is precise, and revocation is granular.

RouteFilePDP capability
/api/admin/teamsui/src/app/api/admin/teams/route.tsrequireAdminSurfaceManage(session, "teams")
/api/admin/teams/[id]ui/src/app/api/admin/teams/[id]/route.ts`requireRbacPermission(session, "team", "view"
/api/admin/teams/[id]/openfga/reconcileui/src/app/api/admin/teams/[id]/openfga/reconcile/route.tsrequireTeamMembershipManagementPermission(session, user.email, { slug })
/api/admin/users/[id]/roleui/src/app/api/admin/users/[id]/role/route.tsrequireRbacPermission(session, "admin_ui", "admin")
/api/admin/statsui/src/app/api/admin/stats/route.tsrequireAdminSurfaceManage(session, "stats")
/api/admin/openfga/baseline-profileui/src/app/api/admin/openfga/baseline-profile/route.tswithOpenFgaViewAuth / withOpenFgaAdminAuth
/api/admin/credentials/secrets/*, /api/admin/credentials/oauth-connectors/*ui/src/app/api/admin/credentials/...Resource-scoped via the credential services
/api/admin/platform-configui/src/app/api/admin/platform-config/route.tsrequireResourcePermission(session, { type: "system_config", id: "platform_settings", action: "read" }) on GET; admin_ui#admin + system_config#admin on PATCH
/api/admin/teams/[id]/kb-assignments (non-global branch)ui/src/app/api/admin/teams/[id]/kb-assignments/route.tsrequireRbacPermission(session, "admin_ui", "admin") OR canonical team-admin via findUserRoleInTeam (per 2026-05-26-canonical-team-membership)
/api/rag/[...path]ui/src/app/api/rag/[...path]/route.tsrequireRbacPermission for the parent capability + requireResourcePermission per datasource. Per-resource calls pass bypassForOrgAdmin: true (PR 1, 2026-05-27 fine-grained KB ReBAC plan) so org admins (user:<sub> can_manage organization:<key>) are always allowed on KB / Search / Data Sources / Graph / MCP Tools; set RAG_ADMIN_BYPASS_DISABLED=true to revert to pure per-resource checks.
/api/rag/kb/[...path]ui/src/app/api/rag/kb/[...path]/route.tsSame as /api/rag/[...path] — uses bypassForOrgAdmin: true for the per-KB gate (PR 1, 2026-05-27 fine-grained KB ReBAC plan).
/api/rbac/kb-tab-gatesui/src/app/api/rbac/kb-tab-gates/route.tsPR 2 of the 2026-05-27 fine-grained KB ReBAC plan. Returns {search, data_sources, graph, mcp_tools, has_any_kb, kb_count} so the Knowledge sidebar can render disabled-with-tooltip tabs. Org admins short-circuit to every tab true with kb_count=-1; non-admins get a count from the /v1/datasources list filtered by knowledge_base:<id>#can_read.
/api/rag/kbs/[id]/sharingui/src/app/api/rag/kbs/[id]/sharing/route.tsPR 3 of the 2026-05-27 fine-grained KB ReBAC plan. GET returns the current shared-team slugs; PUT reconciles the slug list through reconcileKnowledgeBaseRelationships (writes new team grants, deletes ones that were unchecked). Gated by knowledge_base:<id>#can_manage with bypassForOrgAdmin: true.
PUT /v1/mcp/custom-tools/<tool_id> (proxied via /api/rag/[...path])ui/src/app/api/rag/[...path]/route.tsPR 4 of the 2026-05-27 fine-grained KB ReBAC plan. After the RAG server returns 2xx the proxy reconciles mcp_tool:<tool_id> OpenFGA tuples (owner subject + owner team slug). The owner team slug, if present in the request body, is gated by team:<slug>#can_use.
GET /v1/mcp/custom-tools (proxied via /api/rag/[...path])ui/src/app/api/rag/[...path]/route.tsPR 4 of the 2026-05-27 fine-grained KB ReBAC plan. Response is filtered to tools the caller has mcp_tool:<id>#can_read on. Org admins bypass via PR 1's super-grant.
/api/rag/toolsui/src/app/api/rag/tools/route.tsrequireRbacPermission(session, "rag", "tool.*")
/api/skill-hubs/*, /api/skill-hubs/[id]/refreshui/src/app/api/skill-hubs/...requireBaselineAdminSurfaceRead(session, "skills") / requireAdminSurfaceManage(session, "skills")
/api/dynamic-agents/teamsui/src/app/api/dynamic-agents/teams/route.tsrequireResourcePermission(session, { type: "organization", id: caipeOrgKey(), action: "manage" })
/api/chat/conversations/[id]/share (PATCH/DELETE)ui/src/app/api/chat/conversations/[id]/share/route.ts`requireConversationResourcePermission(session, user.email, conversation, "share"

This is the shape every route should reach over time.

Category 4 — Hybrid Mongo + PDP (intentional)

A few admin routes still consult the teams collection or canonical team_membership_sources reader inline. These are not legacy holdouts; they're intentional, scoped to team-membership questions that the 2026-05-26-canonical-team-membership refactor moved to a canonical Mongo store. The reader is findUserRoleInTeam in ui/src/lib/rbac/team-membership-store.ts, and it's used alongside a fall-through to organization-level PDP:

const canAdmin = await requireRbacPermission(session, "admin_ui", "admin").then(() => true, () => false);
const role = await findUserRoleInTeam(team.slug, { user_email: normalizeEmail(email) });
if (!canAdmin && role !== "admin") {
throw new ApiError("You do not have permission …", 403);
}

This is the supported pattern until team-membership tuples themselves live in OpenFGA. Don't refactor these without coordinating with the canonical-team-membership migration.


How to read an audit_event_id row

Every requireRbacPermission() and requireResourcePermission() call writes an audit row via logAuthzDecision() in ui/src/lib/rbac/audit.ts. Rows live in MongoDB rbac_audit_events and the structured Next.js log.

A typical allow row looks like:

{
"subject_hash": "sha256:9accc8a00ffe…",
"capability": "system_config:platform_settings#read",
"outcome": "allow",
"reason_code": "OK",
"pdp": "openfga",
"email": "viewer@example.com",
"tenantId": "default",
"audit_event_id": "ae_2026-05-27T06:26:14.082Z_…"
}

Useful filters when troubleshooting:

  • Route-specific capabilities such as self_profile#read, chat_supervisor#invoke, and feedback#submit identify the surface that performed the check. If an audit row is too generic, add a route-specific entry to resolveLegacyWithAuthRbacPolicy().
  • pdp: openfga vs pdp: keycloakkeycloak only appears as a fallback when OpenFGA returned DENY_NO_CAPABILITY and the route still wants to consult Keycloak Authorization Services. If you see a route consistently using keycloak, its target relation is missing from the OpenFGA model.
  • reason_code: DENY_PDP_UNAVAILABLE means OpenFGA returned an error. The user gets a 503, not a 403. Treat it like an SLO incident, not a policy bug.

How this audit was produced

To re-run from a clean clone:

# 1. List every production route file that touches a role/admin pattern.
rg -l 'requireAdmin|isAdmin\(|userRole|getUserRole|session\.role\s*===\s*.admin|role:\s*.admin' \
ui/src/app/api --type ts -g '!**/__tests__/**' -g '!**/*.test.ts'

# 2. List routes whose only auth line is withAuth (Category 2 candidates).
for f in $(rg -l '' ui/src/app/api --type ts -g 'route.ts' -g '!**/__tests__/**'); do
if ! rg -q 'requireRbacPermission|requireResourcePermission|requireAdmin|withOpenFga|requireAdminSurface' "$f"; then
echo " $f"
fi
done

# 3. For each candidate, confirm whether withAuth or getAuthFromBearerOrSession is present.
# If neither, the route is genuinely ungated and that IS a bug.

When you add a new /api/* route:

  1. Pick the smallest require*Permission helper that matches user intent. Resource-scoped is best.
  2. If the route falls back to withAuth, add an explicit entry in resolveLegacyWithAuthRbacPolicy rather than relying on the admin UI fallback.
  3. Add a route test that mocks checkOpenFgaTuple and asserts the relation that was requested. The pattern is in ui/src/lib/__tests__/api-middleware.test.ts under withAuth › legacy RBAC policy resolution.
  4. Append a row to the right table in this audit doc.