Problem/Motivation
SubscriptionEntity::getPlan() loads the plan with loadByProperties(['plan_id' => $this->getPlanId()]) and returns the first result. The plan_id field holds the connector's remote identifier, and remote identifiers from different billing services live in different namespaces: nothing prevents two connectors from each having a plan whose remote ID is, say, pro-monthly.
When that happens, getPlan() can return the other connector's plan. Because plan lookups feed role grants (subscription_manager_user_presave()) and the membership portal display, a collision silently grants the wrong roles or displays the wrong plan. The subscription entity already knows its connector; the lookup just doesn't use it.
Steps to reproduce
- Create two plans with the same
plan_idvalue but differentconnector_plugin_idvalues and different roles. - Create a subscription referencing the second connector.
- Call
getPlan()on it; depending on entity IDs, the first connector's plan is returned and its roles are granted.
Proposed resolution
- Include
connector_plugin_idin thegetPlan()lookup, falling back to the current plan_id-only lookup when the subscription predates the field being populated (or when the stored connector has been uninstalled), to avoid breaking existing data. - Audit other plan_id-only lookups for the same pattern (e.g.
userIsSubscribedToPlan()builds its query fromplan_idanduser_id).
Remaining tasks
Patch, kernel test constructing the collision above and asserting the correct plan is returned, decide whether the fallback deserves a deprecation notice.
User interface changes
None.
API changes
None; getPlan() keeps its signature and return type.
Data model changes
None.
Issue fork subscription_manager-3616776
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
colanComment #3
colanComment #5
colanMR opened, implementing the proposed resolution:
SubscriptionEntity::getPlan()includesconnector_plugin_idin the lookup, with the plan_id-only fallback kept for legacy rows (predating a populated connector field, or whose connector was uninstalled while the plan was re-imported under another). On the "decide whether the fallback deserves a deprecation notice" question: no runtime deprecation — such rows cannot be backfilled (the original connector is unknowable), so the fallback must live for all of 1.x; it carries a@todoto remove it in 2.0.0, the first release allowed to drop pre-1.0 data support.userIsSubscribedToPlan()now scopes its subscription query by the plan's connector, and the subscription list builder's plan link goes throughgetPlan()instead of its own plan_id-only lookup. The drush plan listing'splan_idreference only formats output rows, so it needed nothing.CrossConnectorPlanTest) construct the collision from the summary — two plans sharingplan_idon different connectors with different roles, with the colliding plan deliberately holding the lower entity id: the subscription's own connector's plan is returned and only its role granted (RED before the fix), the legacy fallback still resolves, and the subscribed-to-plan check distinguishes the colliding plans.Comment #7
colan