Skip to content

feat(access-control): add group subscription identifier to metadata#4697

Merged
dkoo merged 11 commits into
trunkfrom
feat/group-subscription-improvements
May 13, 2026
Merged

feat(access-control): add group subscription identifier to metadata#4697
dkoo merged 11 commits into
trunkfrom
feat/group-subscription-improvements

Conversation

@dkoo
Copy link
Copy Markdown
Contributor

@dkoo dkoo commented May 2, 2026

All Submissions:

Changes proposed in this Pull Request:

Surfaces the user's content-access group identity (group subscription names and matching institution names) to two destinations, as outlined in NPPD-1451:

  1. ESPs / integrations, via a new NP_Content_Access_Group contact metadata field that joins the existing NP_Content_Access and NP_Content_Access_Source fields synced by Reader_Activation\Sync\Contact_Metadata\Content_Gate.
  2. Google Analytics 4, via a new group custom event parameter on the gtag config payload. Group and Institution names are anonymized in this parameter to avoid potentially leaking PII due to the fallback names for unnamed groups including the subscription owner's full name (e.g., gtag("config", ..., {"group":"Group 123, Institution 456", ...})). Defaults to none when there are no matching groups. Only added when Content_Gate::is_newspack_feature_enabled() is true.

The new value reflects active group subscriptions the user owns or is a member of, plus institutions whose rules the user matches via any means (verified email domain, IP range with cache-bypass cookie or current visitor, reader_data). Names are deduplicated and sorted naturally.

This PR also includes a few smaller changes that came up while wiring up the new field:

  • Adds a $strict parameter to Access_Rules::has_active_subscription() so callers can distinguish "user owns the subscription" from "user is a member of someone's group subscription" — used to choose between product names and 'group' for NP_Content_Access_Source.
  • Tightens Institution::user_matches_institution()'s "uncached" detection so the IP check only fires for the current visitor (not arbitrary $user_id values passed from background metadata syncs).
  • Refactors Content_Gate metadata source/group label collection into a shared collect_labels helper.
  • Migrates Teams_For_Memberships::handle_esp_sync_contact() off the deprecated Sync_Metadata::filter_enabled_fields() to Integration::filter_enabled_outgoing_fields().

Closes NPPD-1451.

How to test the changes in this Pull Request:

ESP metadata (NP_Content_Access_Group)

  1. With an ESP connected (e.g., Mailchimp), enable the new Content Access Group field in Reader Activation → ESP → outgoing fields.
  2. Create a published content gate with custom access enabled, requiring an active subscription for product P.
  3. Create a group subscription for product P with a name (e.g., "ACME Corp"). Add a reader as a member.
  4. Trigger a metadata sync for that reader (e.g., re-save their account or log them in/out). Confirm in your ESP that the contact has:
    • NP_Content_Access = Yes
    • NP_Content_Access_Source = group
    • NP_Content_Access_Group = ACME Corp
  5. Repeat with the reader as the owner of the group subscription instead of a member. Confirm NP_Content_Access_Source = <product name> and NP_Content_Access_Group still includes ACME Corp.
  6. Add a published Institution whose email_domain matches the reader's verified email. Add the institution to the gate's access rules. Re-sync and confirm both the group subscription and institution names appear (sorted, comma-separated) in NP_Content_Access_Group.

GA4 group parameter

  1. With Google Site Kit installed and the Newspack content-gating feature enabled (define('NEWSPACK_CONTENT_GATES', true)), view any page logged in as the reader from above.
  2. In the page source, locate the gtag("config", ...) call. Confirm the config object includes "group":"<group/institution names>".
  3. Log out and refresh. Confirm "group":"none".
  4. Add the reader to additional group subscriptions and matching institutions. Confirm all names appear comma-separated and sorted naturally.
  5. Cancel one of the group subscriptions and refresh the page. Confirm its name is excluded.

Regression checks

  • Confirm existing Content Access metadata flows still work (NP_Content_Access, NP_Content_Access_Source).
  • Confirm Institution IP-based access still works for both logged-in users and anonymous users with the wp_nocache_ip cache-bypass cookie set.
  • Confirm all unit tests pass: n test-php.

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully ran tests with your changes locally?

dkoo and others added 5 commits May 1, 2026 11:33
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_Group

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dkoo dkoo self-assigned this May 2, 2026
@dkoo dkoo added the [Status] Needs Review The issue or pull request needs to be reviewed label May 2, 2026
@dkoo dkoo marked this pull request as ready for review May 2, 2026 00:09
@dkoo dkoo requested a review from a team as a code owner May 2, 2026 00:09
Copilot AI review requested due to automatic review settings May 2, 2026 00:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a “content-access group identity” surface (group subscription + institution names) for both Reader Activation contact metadata and GA4 (Site Kit), alongside some related access-control correctness/refactor work.

Changes:

  • Add new Reader Activation outgoing field Content_Access_Group and refactor label collection for content-gate metadata.
  • Add GA4 custom parameter group (via Site Kit gtag config) derived from active group subscriptions + matching institutions.
  • Extend access/subscription and institution matching logic (strict subscription ownership option; tighten institution IP “uncached” detection) and update related integrations/tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/unit-tests/plugins/google-site-kit.php New unit tests validating GA4 group parameter behavior (none/owned/member/institution/sorting/dedup).
tests/unit-tests/content-gate/group-subscriptions.php Adds coverage for filtering stale/non-group subscription IDs from membership meta.
tests/unit-tests/content-gate/class-institution.php Adjusts IP-range institution test to align with new “uncached/current user” logic.
tests/unit-tests/content-gate/class-content-gate-metadata.php Expands test suite for Content_Access_Source semantics and new Content_Access_Group labels.
includes/reader-activation/sync/contact-metadata/class-content-gate.php Adds Content_Access_Group, refactors label gathering, updates source labeling logic.
includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription.php Filters missing/non-group subscriptions when resolving membership meta.
includes/plugins/google-site-kit/class-googlesitekit.php Adds GA4 group parameter and helper to compute user group/institution names.
includes/plugins/class-teams-for-memberships.php Switches ESP enabled-field filtering to integration API.
includes/content-gate/class-institution.php Tightens IP “uncached” detection to only run for current visitor (or cache-bypass cookie).
includes/content-gate/class-access-rules.php Adds $strict to has_active_subscription() and extends filter args accordingly.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/reader-activation/sync/contact-metadata/class-content-gate.php Outdated
Comment thread includes/reader-activation/sync/contact-metadata/class-content-gate.php Outdated
Comment thread includes/plugins/google-site-kit/class-googlesitekit.php
dkoo and others added 4 commits May 4, 2026 12:10
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lpers

- Restore Institution::user_matches_institution() IP-cache tightening lost in
  trunk merge: only treat the request as uncached when the visitor is the user
  being evaluated (or the cache-bypass cookie is set). Prevents the requestor's
  IP from leaking to non-current users during background metadata sync.
- Guard get_group_labels() institution branch against non-array $value to avoid
  a PHP 8 TypeError when an institution rule has empty/scalar value.
- Decouple Group_Subscription::get_group_subscriptions_for_user() from
  is_group_subscription()'s My-Account/Memberships side effect. Data-layer
  callers must always see the canonical group-enabled state.
- Extract Group_Subscription::get_group_names_for_user() and
  Institution::get_matching_names_for_user() helpers, each with per-request
  static memoization. Use html_entity_decode( ENT_QUOTES | ENT_HTML5 ) so
  names with non-basic entities (&mdash;, &#8217;, etc.) decode cleanly for
  ESP/GA4 transport. Use WooCommerce_Connection::get_active_subscriptions_for_user()
  for the owned path so status + gifting filters live in one place.
- Update Content_Gate metadata get_group_labels() and GoogleSiteKit::get_user_group_names()
  to delegate to the shared helpers.
- GA4 helper now early-returns when ! Reader_Activation::is_user_reader( $user ),
  matching the framing of the surrounding is_reader / is_subscriber params.
- Replace brittle one-shot filter ordering in test_subscription_filter_source
  with an unconditional filter + unregistered product so the strict per-product
  lookup naturally falls through.
- Add regression tests: malformed institution rule values (data provider) and
  cross-user IP leak prevention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dkoo
Copy link
Copy Markdown
Contributor Author

dkoo commented May 11, 2026

Addressed all blockers and most suggestions from the code review in 7426ab3.

Blockers

  • B1: Restored the Institution::user_matches_institution() IP-cache tightening that was lost in the trunk merge. The IP check now only fires when $user_id === get_current_user_id() (or the cache-bypass cookie is set), so the requestor's IP can no longer leak to non-current users during background metadata sync. B2 is resolved by B1.
  • B3: Added an is_array() guard in get_group_labels()'s institution branch (now folded into the new helper). A malformed institution rule (empty / null / scalar value) no longer fatals — confirmed by a new data-provider regression test.
  • B4: Group_Subscription::get_group_subscriptions_for_user() now checks Group_Subscription_Settings::get_subscription_settings($sub)['enabled'] directly instead of going through is_group_subscription(), so the My-Account/Memberships UI-visibility suppression no longer bleeds into the data layer.

Suggestions

  • S2 + S3 + S4 + S7 + S10: Extracted two reusable helpers — Group_Subscription::get_group_names_for_user( $user_id, ?array $product_filter = null ) and Institution::get_matching_names_for_user( $user_id, ?array $institution_filter = null ). Both memoize per request (S4), read get_subscription_settings() once (S3), decode names with html_entity_decode( ENT_QUOTES | ENT_HTML5 ) so &mdash; / &#8217; / etc. survive ESP/GA4 transport (S7), and use WooCommerce_Connection::get_active_subscriptions_for_user() for the owned path so status + gifting filters are centralized (S10). Both Content_Gate::get_group_labels() and GoogleSiteKit::get_user_group_names() delegate to these.
  • S6: Dropped the class_exists() guards in the GA4 helper (the classes ship in the same plugin).
  • S8: GA4 helper now early-returns when ! Reader_Activation::is_user_reader( $user ), consistent with the surrounding is_reader / is_subscriber params.
  • S9: Replaced the brittle one-shot filter ordering in test_subscription_filter_source with an unconditional __return_true filter + unregistered product, so the strict per-product lookup naturally falls through regardless of how many times the filter fires.
  • S5: Added regression tests for malformed institution rule values (data provider covering empty string, empty array, null, scalar int, scalar string) and a cross-user IP leak prevention test.

All 1136 tests pass.

Deferred

  • S1 (string label change: 'Subscription''subscription', 'group' reused for group-membership subscription access vs institution): kept the new taxonomy because it's clearer, but happy to discuss release notes / segmentation guidance separately.
  • Nits N1–N6: open to addressing in this PR if you'd like, otherwise good follow-up candidates.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread tests/unit-tests/plugins/google-site-kit.php
Comment thread tests/unit-tests/content-gate/class-content-gate-metadata.php
Copy link
Copy Markdown
Member

@adekbadek adekbadek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — one blocker: get_user_group_names() adds uncached per-pageview DB work to the GA4 config path on content-gate sites; should be fixed before the feature ships (details + suggested fix inline). The rest of the PR is in good shape — the cross-user IP-attribution risk is closed with a regression test, the shared name helpers are clean, PHP suites are green in CI; a few suggestions and nits inline.

Comment thread includes/content-gate/class-access-rules.php
Comment thread includes/plugins/google-site-kit/class-googlesitekit.php Outdated
Comment thread includes/plugins/google-site-kit/class-googlesitekit.php Outdated
Comment thread tests/unit-tests/plugins/google-site-kit.php
Comment thread includes/reader-activation/sync/contact-metadata/class-content-gate.php Outdated
@github-actions github-actions Bot added the [Status] Needs Changes or Feedback The issue or pull request needs action from the original creator label May 12, 2026
T10 — Restore Memberships parity at the My-Account UI layer
- Gate Group_Subscription_MyAccount::inject_member_group_subscriptions() on
  ! Memberships::is_active() so the data-layer decoupling done in 7426ab3
  doesn't re-expose member group subs in the WC subscriptions list on
  Memberships sites. Mirrors the suppression that previously lived inside
  is_group_subscription(), but at the UI layer where it belongs.

T7 — $strict filter contract caveat
- Document on Access_Rules::has_active_subscription() that $strict only
  constrains the built-in ownership / group-membership checks. The
  newspack_access_rules_has_active_subscription filter is always applied;
  filter authors must opt into the 4th $strict arg and respect it, or
  callers relying on $strict (e.g. Content_Gate source labels) may
  misclassify filter-granted access as local ownership.

T8 / T9 — Helper cache lifecycle + ID accessors
- Move Group_Subscription and Institution per-request name caches to
  class-level statics so they can be reset.
- Add public reset_cache() / reset_matching_cache() methods.
- Hook reset on subscription status changes and group-member user-meta
  add/update/delete events so long-running CLI workers don't serve stale
  data across jobs.
- Refactor each helper to a shared get_settings_map_for_user() /
  get_matching_map_for_user() that returns [id => decoded_name]; the
  existing get_*_names_for_user() functions and the new get_*_ids_for_user()
  accessors project from it. Settings are read once per candidate sub.
- Institution cache key now includes get_current_user_id() so the IP-rule
  branch's current-user dependency doesn't produce stale hits.
- Docblock note on the gifting-filter asymmetry between owned and member
  subscription paths.

T11 — Anonymize GA4 group param
- GoogleSiteKit::get_user_group_labels() now emits "Group {sub_id}" /
  "Institution {inst_id}" identifiers instead of display names. The
  unnamed-group fallback synthesizes a name from the owner's billing full
  name, so sending names to GA4 would leak PII for every member of an
  unnamed group. ESP path keeps human-readable names — only GA4 is anonymized.

T14 — reader_data source label
- get_source_labels() returns [ 'reader_data' ] for the reader_data rule
  slug. Previously a reader_data-only access rule yielded Content_Access=Yes
  but empty Content_Access_Source.

T12 — Use get_post_field() for institution title transport
- Replace get_the_title( $inst_id ) so the value going to GA4/ESP isn't
  run through the_title filters (texturization or third-party hooks that
  can introduce entities or markup).

T13 — Document cookie-branch IP cross-attribution caveat
- One-line caveat on IP_Access_Rule::is_cookie_set() disjunct: it still
  treats any non-current $user_id as uncached when the current visitor
  carries the cache-bypass cookie. Narrow pre-existing edge case.

T18 — Direct $resolver(...) invocation
- Drop call_user_func() in Content_Gate::collect_labels(); PHP 7+ invokes
  array callables directly. (Reverts the Copilot autofix in d350ee4.)

T5 / T6 — Test isolation
- Track Institution::create() post IDs in test classes that use them and
  delete them in tear_down(). Institution::create() inserts real posts
  not tracked by $this->factory, so they were leaking into later tests.
- Reset Group_Subscription / Institution caches in set_up and tear_down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dkoo dkoo requested a review from adekbadek May 12, 2026 17:05
Copy link
Copy Markdown
Member

@adekbadek adekbadek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-2 changes (7426ab3d + 15488f71a) cover the earlier feedback — see resolved threads. The GA4 path is fine to land as-is for the reasons in the blocker thread.

CI is currently red on lint-php and build-distributable, but both fail with the same composer auth error cloning github.com/joshtronic/php-loremipsum.git — infrastructure, not the diff. test-php, test-js, lint-js-scss all pass; a re-run should clear it.

Optional: the one-line docblock on get_user_group_labels() from the blocker thread.

@github-actions github-actions Bot added [Status] Approved The pull request has been reviewed and is ready to merge and removed [Status] Needs Review The issue or pull request needs to be reviewed [Status] Needs Changes or Feedback The issue or pull request needs action from the original creator labels May 13, 2026
@dkoo dkoo merged commit 8d71495 into trunk May 13, 2026
9 checks passed
@dkoo dkoo deleted the feat/group-subscription-improvements branch May 13, 2026 15:25
@github-actions
Copy link
Copy Markdown

Hey @dkoo, good job getting this PR merged! 🎉

Now, the needs-changelog label has been added to it.

Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label.

If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label.

Thank you! ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Approved The pull request has been reviewed and is ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants