Skip to content

0030. Extend full access to shop connections via a sibling consuming-system connection

Date: 2026-09-21 Status: Accepted

Context

ADR-0007 splits a PlatformSupport user's reach inside a reachable tenant in two: connections to a system they operate are fully accessible, every other connection of the same tenant is status-only. ADR-0008 fixes which systems can ever have support members at all — SystemDriver::canHaveSupportUsers() returns false for every shop adapter (WooCommerce, PrestaShop, Shopify, Shopware) and true for the internal consuming platforms (rapidmail, User Platform). A shop adapter therefore structurally never has support members of its own: nobody is ever a member of a System row whose driver is a shop adapter, so "connections to a system they operate" can never match one.

Combined, the two decisions left every shop connection permanently status-only for every PlatformSupport user, in every tenant, regardless of which internal platform they support — including the Bulk Sync action, which SystemConnectionPolicy gates on SystemConnection::fullyAccessibleBy(). A support user for rapidmail, looking at the tenant's WooCommerce connection through which their own rapidmail connection is actually being synced, could see its status but never trigger a resync on it — the one action the role exists to grant. ADR-0007's own-connections/sibling-connections split was written with two internal-platform connections in mind; it did not anticipate that one side of that split (shop adapters) can never be reached by the "operate a system" route at all, no matter how directly the support relationship runs through it.

Decision

SystemConnection::scopeFullyAccessibleBy() now matches a connection when either:

  1. its own system is one the user directly supports (the existing ADR-0007 rule), or
  2. (new) its system is a shop adapter (System::scopeCannotHaveSupportUsers()) and it belongs to a tenant that also has a sibling connection whose system the user directly supports (System::scopeSupportedBy(User $user)).

Both branches consult the same underlying test for "does this user support this system", now extracted once as System::scopeSupportedBy(User $user) — a canHaveSupportUsers() system with a matching system_user row — shared by both scopeFullyAccessibleBy()'s own-connection branch and its new sibling-cascade branch. System::scopeCannotHaveSupportUsers() is the inverse of the existing scopeCanHaveSupportUsers() (ADR-0008), added so the cascade branch can state "this is a shop adapter" without restating the driver list. Both scopes read a single private supportEligibleDriverValues() helper on System. Tenant::scopeAccessibleBy() is unaffected in behaviour and now calls System::scopeSupportedBy() instead of restating the same check inline.

The cascade is one hop, and only ever lands on a shop adapter: a connection to a second internal/consuming-platform system is never made fully accessible this way, even when the user supports a third, different internal system in the same tenant. Two shop connections in the same tenant (unusual, but not prevented elsewhere) both become accessible once either is reached through a supported sibling, since the test is "does the tenant contain a supported system", not "is this specific shop paired with the supported one".

The cascade is deliberately scoped to SystemConnection actions only — never to SyncRule management. A first version of this decision let the cascade flow straight through SystemConnection::fullyAccessibleBy() into SyncRule::scopeManageableBy(), SyncRuleForm::counterpartOptions(), and App\Rules\ManageableSyncRulePair, since all three read that same scope. In manual testing this proved wrong: it let a User-Platform support user create and manage a WooCommerce ↔ rapidmail sync rule, even though the rapidmail connection itself stays status-only (read-only) to them per ADR-0007 — a connection that is read-only must keep every sync rule touching it read-only too, not just the connection record itself. SystemConnection::scopeOperatedBy(User $user) is the fix: it is exactly the pre-ADR-0030 definition of full access (own system directly supported, no cascade), and SyncRule::scopeManageableBy(), SyncRuleForm::counterpartOptions(), and ManageableSyncRulePair all read it instead of scopeFullyAccessibleBy(). Only SystemConnectionPolicy and the Filament connection resource (view/resync/update/delete on the connection itself, and the full_access/base_url/external_user_id display split) read the cascading scopeFullyAccessibleBy().

Consequences

A PlatformSupport user gains full access (including Bulk Sync) to a shop connection reached through a sibling connection to a system they operate, but this does not extend to managing sync rules that shop participates in unless the other side of the rule is also one they directly operate — SyncRule::manageableBy() still requires ADR-0012's "at least one directly-operated side", now read via operatedBy() rather than the cascading fullyAccessibleBy(). A rule between a cascade-reached shop and a system the user does not operate stays visible but unmanageable, the same as before this ADR.

Two shop connections belonging to different tenants remain fully isolated from each other; the cascade never crosses a tenant boundary (ADR-0010 still governs rule pairing). A PlatformSupport user who supports no system in a tenant still reaches nothing in it, cascade or not — the cascade only widens what is fully accessible inside an already-reachable tenant, never which tenants are reachable.