feat(access-control): add group subscription identifier to metadata#4697
Conversation
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>
There was a problem hiding this comment.
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_Groupand 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.
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 (—, ’, 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>
|
Addressed all blockers and most suggestions from the code review in 7426ab3. Blockers
Suggestions
All 1136 tests pass. Deferred
|
adekbadek
left a comment
There was a problem hiding this comment.
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.
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>
adekbadek
left a comment
There was a problem hiding this comment.
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.
|
Hey @dkoo, good job getting this PR merged! 🎉 Now, the 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! ❤️ |
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:
NP_Content_Access_Groupcontact metadata field that joins the existingNP_Content_AccessandNP_Content_Access_Sourcefields synced byReader_Activation\Sync\Contact_Metadata\Content_Gate.groupcustom 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 tononewhen there are no matching groups. Only added whenContent_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:
$strictparameter toAccess_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'forNP_Content_Access_Source.Institution::user_matches_institution()'s "uncached" detection so the IP check only fires for the current visitor (not arbitrary$user_idvalues passed from background metadata syncs).Content_Gatemetadata source/group label collection into a sharedcollect_labelshelper.Teams_For_Memberships::handle_esp_sync_contact()off the deprecatedSync_Metadata::filter_enabled_fields()toIntegration::filter_enabled_outgoing_fields().Closes NPPD-1451.
How to test the changes in this Pull Request:
ESP metadata (
NP_Content_Access_Group)Content Access Groupfield in Reader Activation → ESP → outgoing fields.NP_Content_Access=YesNP_Content_Access_Source=groupNP_Content_Access_Group=ACME CorpNP_Content_Access_Source=<product name>andNP_Content_Access_Groupstill includesACME Corp.email_domainmatches 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) inNP_Content_Access_Group.GA4
groupparameterdefine('NEWSPACK_CONTENT_GATES', true)), view any page logged in as the reader from above.gtag("config", ...)call. Confirm the config object includes"group":"<group/institution names>"."group":"none".Regression checks
Content Accessmetadata flows still work (NP_Content_Access,NP_Content_Access_Source).wp_nocache_ipcache-bypass cookie set.n test-php.Other information: