Summary
Adds project-scoped self-service binding of provider connections, so section heads can bind/unbind platform-approved provider connections on their own projects without holding the global integration.edit permission.
Builds on EPIC P (#92) which shipped the provider connection admin surface + the project_provider_connections join table.
Why
Today integration.edit is a single binary global permission. Every holder sees every provider connection across every project, edits any, deletes any. With the polyrepo + tenancy model EPIC L + N built (section heads owning project subtrees), this is too blunt. EPIC Q ships the minimum useful split: connections stay platform-managed, bindings become project-scoped.
Locked design decisions (§1–§6)
Q1 — connections global, bindings scoped (Option C)
Q2 — new integration.bind permission, integration.edit unchanged
Q3 — bind-only for scoped admins, no create/update/delete
Q4 — hard block prod for scoped binders (Option A); per-connection prod
opt-in parked as Q-follow-up #1
Q5 — 3 stable codes + environment_id_required for the binder branch
Q6 — new provider_connection_binder seed role; not added to developer
Q7 — unbind skips self_service_bindable but enforces scope + non-prod
Q8 — extend shared GET with ?for_binding=true (4-way branching)
Q9 — per-project/per-env Provider Connections card on ProjectEnv only
Q10 — project-anchored URLs alongside admin URLs
Q11 — new GET /projects/:id/provider-connection-bindings with server join
Q12 — card placement: ProjectEnv only (no /projects/:id overview page)
Q13 — show project-wide bindings with badge + disabled Unbind
Q14 — empty-state copy split: pure-bind users see no link; admins see
"Manage at /admin/provider-connections"
Q15 — capability helper (NOT generic hasPermission override);
explicit endpoint selection by permission
Q16 — 3 new prometheus counters (created/deleted/denied)
Q17 — 5-slice PR breakdown
Load-bearing gate-order correction (§3): project/env coverage check runs BEFORE connection load to prevent enumeration leak.
Scoped DELETE: binding.project_id == URL projectID validated; mismatch returns binding_not_found (not out_of_scope_binding, which would leak existence).
Picker excludes connections that are project-wide-bound to the project (transparency about inherited bindings).
Default deny: self_service_bindable BOOLEAN NOT NULL DEFAULT false.
Schema (migration 0031)
ALTER TABLE provider_connections
ADD COLUMN self_service_bindable BOOLEAN NOT NULL DEFAULT false;
No new tables. No FK changes. Existing connections stay invisible to scoped binders until platform explicitly opts each in.
Sub-issues
Q-follow-ups (NOT v1 blockers)
Stable error codes added
connection_not_self_service_bindable 403
prod_binding_not_allowed_for_scope 403
out_of_scope_binding 403
environment_id_required 400
Existing codes reused: connection_disabled (409), connection_not_found (404), binding_exists (409), binding_not_found (404), environment_not_in_project (400).
Audit events
binding.create — successful bind via either path; metadata carries actor_permission_used so platform can grep scoped vs admin
binding.delete — successful unbind; includes self_service_bindable when connection row still loadable, omits when not
binding.denied_out_of_scope — step-2 failure on scoped path; does NOT include provider_connection_id (gate-order protection)
Observability
Three prometheus counters (low-cardinality labels only — never actor_id/project_id/connection_id/environment_id):
provider_connection_bindings_created_total{permission_used, env_kind}
provider_connection_bindings_deleted_total{permission_used, env_kind}
provider_connection_bindings_denied_total{reason}
reason fixed set: out_of_scope, prod_blocked, not_self_service_bindable, connection_disabled, connection_not_found, env_not_in_project, binding_exists, binding_not_found.
Test plan (cross-slice)
Beyond the per-PR test scope:
Dependencies
Definition of done
- Q1–Q5 merged
- All 4 follow-ups filed with intentional priority labels
- EPIC Q skills round-trip merged
- Operator guide live on docs site
Summary
Adds project-scoped self-service binding of provider connections, so section heads can bind/unbind platform-approved provider connections on their own projects without holding the global
integration.editpermission.Builds on EPIC P (#92) which shipped the provider connection admin surface + the
project_provider_connectionsjoin table.Why
Today
integration.editis a single binary global permission. Every holder sees every provider connection across every project, edits any, deletes any. With the polyrepo + tenancy model EPIC L + N built (section heads owning project subtrees), this is too blunt. EPIC Q ships the minimum useful split: connections stay platform-managed, bindings become project-scoped.Locked design decisions (§1–§6)
Load-bearing gate-order correction (§3): project/env coverage check runs BEFORE connection load to prevent enumeration leak.
Scoped DELETE:
binding.project_id == URL projectIDvalidated; mismatch returnsbinding_not_found(notout_of_scope_binding, which would leak existence).Picker excludes connections that are project-wide-bound to the project (transparency about inherited bindings).
Default deny:
self_service_bindable BOOLEAN NOT NULL DEFAULT false.Schema (migration 0031)
No new tables. No FK changes. Existing connections stay invisible to scoped binders until platform explicitly opts each in.
Sub-issues
Q-follow-ups (NOT v1 blockers)
self_service_bindable_prod)/bindings)team_adminaggregate role bundlingintegration.bind+user_role.editStable error codes added
Existing codes reused:
connection_disabled(409),connection_not_found(404),binding_exists(409),binding_not_found(404),environment_not_in_project(400).Audit events
binding.create— successful bind via either path; metadata carriesactor_permission_usedso platform can grep scoped vs adminbinding.delete— successful unbind; includesself_service_bindablewhen connection row still loadable, omits when notbinding.denied_out_of_scope— step-2 failure on scoped path; does NOT includeprovider_connection_id(gate-order protection)Observability
Three prometheus counters (low-cardinality labels only — never
actor_id/project_id/connection_id/environment_id):reasonfixed set:out_of_scope,prod_blocked,not_self_service_bindable,connection_disabled,connection_not_found,env_not_in_project,binding_exists,binding_not_found.Test plan (cross-slice)
Beyond the per-PR test scope:
Dependencies
Definition of done