Problem/Motivation

Role revocation in SubscriptionEntity::updateUserRoles() and preDelete() resolves which roles to remove by calling getPlan() at revocation time. If that lookup fails, no roles are removed and the user retains paid access indefinitely. The code acknowledges this: updateUserRoles() contains the comment "There's a bug here. If a plan is deleted, then an expiring subscription will fail to remove the corresponding user roles."

Lookup failure is not exotic. It happens when the plan entity was deleted locally (including by plan sync removing plans that no longer exist upstream), when the remote plan was renamed so plan_id no longer matches, or when #3616776: SubscriptionEntity::getPlan() ignores connector_plugin_id, allowing cross-connector plan_id collisions's cross-connector collision resolves to a different plan (which removes the wrong roles, equally bad). The same live-lookup pattern exists in subscription_manager_user_presave(), which rebuilds required roles from each active subscription's plan.

Net effect: access granted by a paid subscription can outlive the subscription. For sites selling access, this is revenue-relevant broken access control. (Filed in the public queue per policy, as the module has no stable release.)

Steps to reproduce

  1. Create a plan granting a role; subscribe a user (role granted).
  2. Delete the plan entity (or change its plan_id).
  3. Let the subscription expire via cron, or set status to 0 and save.
  4. updateUserRoles() logs "Could not find local plan matching remote plan id" and returns; the user keeps the role permanently.

Proposed resolution

  • Add a granted_roles multi-value string base field to the subscription entity. Write it whenever roles are granted (in addRoles()), recording exactly what this subscription conferred.
  • Revoke from the snapshot, not from getPlan(): updateUserRoles() (deactivation path) and preDelete() remove the snapshotted roles. Keep the plan lookup only as a fallback for pre-existing rows with an empty snapshot.
  • Update subscription_manager_user_presave() to compute required roles as the union of snapshots of the user's other active subscriptions, with plan-lookup fallback, so its re-add logic can't resurrect roles from a wrong plan match.
  • Update hook: install the field and backfill snapshots from current plan lookups where they still resolve.

Remaining tasks

Patch; kernel tests for the reproduce case above, for deletion with a missing plan, and for the presave union logic; change record for the new field.

User interface changes

None.

API changes

None; internal role-handling behavior only.

Data model changes

New granted_roles base field on the subscription entity, installed via update hook.

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

colan’s picture

Status: Active » Needs review

MR opened, implementing the proposed resolution:

  • granted_roles multi-value string base field on the subscription entity. preSave() snapshots the plan's roles whenever an active subscription is saved while its plan still resolves — grant time is the moment the lookup is known-good. On a plan change it first revokes from the old row's snapshot, then clears it so the new plan's roles are recorded.
  • Revocation reads the snapshot: updateUserRoles() (deactivation) and preDelete() now revoke via a new getRevocableRoleIds() helper — the snapshot, with the live plan lookup kept only as a fallback for rows saved before the field existed.
  • The user-presave union likewise unions the snapshots of the user's other active subscriptions (same fallback), so a wrong cross-connector plan match can no longer resurrect foreign roles.
  • Update hook 10014 installs the field and backfills via a reusable helper. Only ACTIVE subscriptions are backfilled: re-saving an active row just re-grants roles the owner already holds (idempotent), while re-saving an inactive one would re-run its revocation path, so inactive rows keep the plan-lookup fallback they always had.
  • Kernel tests (GrantedRolesSnapshotTest): the issue's reproduce case (deactivation after plan deletion now revokes), deletion with a missing plan, the presave union (deactivating one of two subscriptions removes only its role, and a plain user save resurrects nothing), and the backfill. The page-attachments query-budget expectation grows from 2 to 3 statements — the multi-value field's dedicated-table fetch joins the same logical load.

The "There's a bug here" comment this issue quotes is gone with the bug. Change record for the new field to follow before commit.

  • colan committed 63d924b1 on 1.0.x
    Issue #3616929: Record granted roles on the subscription and revoke from...
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.