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

  1. Create two plans with the same plan_id value but different connector_plugin_id values and different roles.
  2. Create a subscription referencing the second connector.
  3. 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_id in the getPlan() 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 from plan_id and user_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.

Command icon 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

colan created an issue. See original summary.

colan’s picture

Priority: Normal » Major
colan’s picture

colan’s picture

Status: Active » Needs review

MR opened, implementing the proposed resolution:

  • SubscriptionEntity::getPlan() includes connector_plugin_id in 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 @todo to remove it in 2.0.0, the first release allowed to drop pre-1.0 data support.
  • The audit found two more real instances: userIsSubscribedToPlan() now scopes its subscription query by the plan's connector, and the subscription list builder's plan link goes through getPlan() instead of its own plan_id-only lookup. The drush plan listing's plan_id reference only formats output rows, so it needed nothing.
  • Kernel tests (CrossConnectorPlanTest) construct the collision from the summary — two plans sharing plan_id on 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.

  • colan committed 797ccdf5 on 1.0.x
    Issue #3616776: Scope plan lookups by the subscription's connector...
colan’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.