Send PrAT and GrAT expiry emails to inherited members
What does this MR do and why?
Send PrAT and GrAT expiry emails to inherited members
Sends notification emails about expiring project and group access tokens to inherited members of the project or group, in addition to direct owners and maintainers of the project or group.
Behind a feature flag initially while we test the setting for specific groups and projects. Also requires the group settings to enable sending to inherited members.
References
Please include cross links to any resources that are relevant to this MR. This will give reviewers and future readers helpful context to give an efficient review of the changes introduced.
Part 4 of 4 MRs for this feature
- Instance-wide setting
- Group setting
- Convert to cascading namespace settings, default off
- This MR, with feature implementation and documentation
MR acceptance checklist
Please evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Screenshots or screen recordings
Screenshots are required for UI changes, and strongly recommended for all other merge requests.
| Before | After |
|---|---|
Database Query Changes
Original query on master
SELECT "users"."id",
"users"."email",
"users"."encrypted_password",
"users"."reset_password_token",
"users"."reset_password_sent_at",
"users"."remember_created_at",
"users"."sign_in_count",
"users"."current_sign_in_at",
"users"."last_sign_in_at",
"users"."current_sign_in_ip",
"users"."last_sign_in_ip",
"users"."created_at",
"users"."updated_at",
"users"."name",
"users"."admin",
"users"."projects_limit",
"users"."failed_attempts",
"users"."locked_at",
"users"."username",
"users"."can_create_group",
"users"."can_create_team",
"users"."state",
"users"."color_scheme_id",
"users"."password_expires_at",
"users"."created_by_id",
"users"."last_credential_check_at",
"users"."avatar",
"users"."confirmation_token",
"users"."confirmed_at",
"users"."confirmation_sent_at",
"users"."unconfirmed_email",
"users"."hide_no_ssh_key",
"users"."admin_email_unsubscribed_at",
"users"."notification_email",
"users"."hide_no_password",
"users"."password_automatically_set",
"users"."encrypted_otp_secret",
"users"."encrypted_otp_secret_iv",
"users"."encrypted_otp_secret_salt",
"users"."otp_required_for_login",
"users"."otp_backup_codes",
"users"."public_email",
"users"."dashboard",
"users"."project_view",
"users"."consumed_timestep",
"users"."layout",
"users"."hide_project_limit",
"users"."note",
"users"."unlock_token",
"users"."otp_grace_period_started_at",
"users"."external",
"users"."incoming_email_token",
"users"."auditor",
"users"."require_two_factor_authentication_from_group",
"users"."two_factor_grace_period",
"users"."last_activity_on",
"users"."notified_of_own_activity",
"users"."preferred_language",
"users"."theme_id",
"users"."accepted_term_id",
"users"."feed_token",
"users"."private_profile",
"users"."roadmap_layout",
"users"."include_private_contributions",
"users"."commit_email",
"users"."group_view",
"users"."managing_group_id",
"users"."first_name",
"users"."last_name",
"users"."static_object_token",
"users"."role",
"users"."user_type",
"users"."static_object_token_encrypted",
"users"."otp_secret_expires_at",
"users"."onboarding_in_progress",
"users"."color_mode_id"
FROM "users"
INNER JOIN "members" ON "users"."id" = "members"."user_id"
WHERE "members"."type" = 'GroupMember'
AND "members"."source_id" = 33
AND "members"."source_type" = 'Namespace'
AND "members"."requested_at" IS NULL
AND "members"."access_level" = 50
Group owners query
/* GroupMembersFinder query */
SELECT "members"."id",
"members"."access_level",
"members"."source_id",
"members"."source_type",
"members"."user_id",
"members"."notification_level",
"members"."type",
"members"."created_at",
"members"."updated_at",
"members"."created_by_id",
"members"."invite_email",
"members"."invite_token",
"members"."invite_accepted_at",
"members"."requested_at",
"members"."expires_at",
"members"."ldap",
"members"."override",
"members"."state",
"members"."invite_email_success",
"members"."member_namespace_id",
"members"."member_role_id",
"members"."expiry_notified_at",
"members"."request_accepted_at"
FROM
(SELECT DISTINCT ON (user_id,
invite_email) *
FROM "members"
WHERE "members"."type" = 'GroupMember'
AND "members"."source_type" = 'Namespace'
AND "members"."requested_at" IS NULL
AND "members"."source_id" IN
(SELECT "namespaces"."id"
FROM (
(SELECT "namespaces"."id",
"namespaces"."name",
"namespaces"."path",
"namespaces"."owner_id",
"namespaces"."created_at",
"namespaces"."updated_at",
"namespaces"."type",
"namespaces"."description",
"namespaces"."avatar",
"namespaces"."membership_lock",
"namespaces"."share_with_group_lock",
"namespaces"."visibility_level",
"namespaces"."request_access_enabled",
"namespaces"."ldap_sync_status",
"namespaces"."ldap_sync_error",
"namespaces"."ldap_sync_last_update_at",
"namespaces"."ldap_sync_last_successful_update_at",
"namespaces"."ldap_sync_last_sync_at",
"namespaces"."description_html",
"namespaces"."lfs_enabled",
"namespaces"."parent_id",
"namespaces"."shared_runners_minutes_limit",
"namespaces"."repository_size_limit",
"namespaces"."require_two_factor_authentication",
"namespaces"."two_factor_grace_period",
"namespaces"."cached_markdown_version",
"namespaces"."project_creation_level",
"namespaces"."runners_token",
"namespaces"."file_template_project_id",
"namespaces"."saml_discovery_token",
"namespaces"."runners_token_encrypted",
"namespaces"."custom_project_templates_group_id",
"namespaces"."auto_devops_enabled",
"namespaces"."extra_shared_runners_minutes_limit",
"namespaces"."last_ci_minutes_notification_at",
"namespaces"."last_ci_minutes_usage_notification_level",
"namespaces"."subgroup_creation_level",
"namespaces"."max_pages_size",
"namespaces"."max_artifacts_size",
"namespaces"."mentions_disabled",
"namespaces"."default_branch_protection",
"namespaces"."max_personal_access_token_lifetime",
"namespaces"."push_rule_id",
"namespaces"."shared_runners_enabled",
"namespaces"."allow_descendants_override_disabled_shared_runners",
"namespaces"."traversal_ids",
"namespaces"."organization_id"
FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND "namespaces"."id" = 33)) namespaces
WHERE "namespaces"."type" = 'Group')
ORDER BY user_id,
invite_email,
CASE
WHEN source_id = 33
AND source_type = 'Namespace' THEN access_level + 1
ELSE access_level
END DESC, member_role_id ASC, expires_at DESC, created_at ASC) members
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE "members"."type" = 'GroupMember'
AND (("members"."user_id" IS NULL
AND "members"."invite_token" IS NOT NULL)
OR "users"."state" = 'active')
AND "members"."requested_at" IS NULL
AND (members.access_level > 5)
AND "members"."access_level" IN (50,
60)
AND "members"."invite_token" IS NULL
AND (members.access_level > 5)
Project members query
/* MembersFinder query */
SELECT "members"."id", "members"."access_level", "members"."source_id", "members"."source_type", "members"."user_id", "members"."notification_level", "members"."type", "members"."created_at", "members"."updated_at", "members"."created_by_id", "members"."invite_email", "members"."invite_token", "members"."invite_accepted_at", "members"."requested_at", "members"."expires_at", "members"."ldap", "members"."override", "members"."state", "members"."invite_email_success", "members"."member_namespace_id", "members"."member_role_id", "members"."expiry_notified_at", "members"."request_accepted_at" FROM (SELECT DISTINCT ON (user_id, invite_email) member_union.id,COALESCE(project_authorizations.access_level, member_union.access_level) access_level,member_union.source_id,member_union.source_type,member_union.user_id,member_union.notification_level,member_union.type,member_union.created_at,member_union.updated_at,member_union.created_by_id,member_union.invite_email,member_union.invite_token,member_union.invite_accepted_at,member_union.requested_at,member_union.expires_at,member_union.ldap,member_union.override,member_union.state,member_union.invite_email_success,member_union.member_namespace_id,member_union.member_role_id,member_union.expiry_notified_at,member_union.request_accepted_at
FROM ((SELECT "members"."id", "members"."access_level", "members"."source_id", "members"."source_type", "members"."user_id", "members"."notification_level", "members"."type", "members"."created_at", "members"."updated_at", "members"."created_by_id", "members"."invite_email", "members"."invite_token", "members"."invite_accepted_at", "members"."requested_at", "members"."expires_at", "members"."ldap", "members"."override", "members"."state", "members"."invite_email_success", "members"."member_namespace_id", "members"."member_role_id", "members"."expiry_notified_at", "members"."request_accepted_at" FROM (SELECT DISTINCT ON (user_id, invite_email) * FROM "members" WHERE "members"."type" = 'GroupMember' AND "members"."source_type" = 'Namespace' AND "members"."requested_at" IS NULL AND "members"."source_id" IN (SELECT "namespaces"."id" FROM ((SELECT "namespaces"."id", "namespaces"."name", "namespaces"."path", "namespaces"."owner_id", "namespaces"."created_at", "namespaces"."updated_at", "namespaces"."type", "namespaces"."description", "namespaces"."avatar", "namespaces"."membership_lock", "namespaces"."share_with_group_lock", "namespaces"."visibility_level", "namespaces"."request_access_enabled", "namespaces"."ldap_sync_status", "namespaces"."ldap_sync_error", "namespaces"."ldap_sync_last_update_at", "namespaces"."ldap_sync_last_successful_update_at", "namespaces"."ldap_sync_last_sync_at", "namespaces"."description_html", "namespaces"."lfs_enabled", "namespaces"."parent_id", "namespaces"."shared_runners_minutes_limit", "namespaces"."repository_size_limit", "namespaces"."require_two_factor_authentication", "namespaces"."two_factor_grace_period", "namespaces"."cached_markdown_version", "namespaces"."project_creation_level", "namespaces"."runners_token", "namespaces"."file_template_project_id", "namespaces"."saml_discovery_token", "namespaces"."runners_token_encrypted", "namespaces"."custom_project_templates_group_id", "namespaces"."auto_devops_enabled", "namespaces"."extra_shared_runners_minutes_limit", "namespaces"."last_ci_minutes_notification_at", "namespaces"."last_ci_minutes_usage_notification_level", "namespaces"."subgroup_creation_level", "namespaces"."max_pages_size", "namespaces"."max_artifacts_size", "namespaces"."mentions_disabled", "namespaces"."default_branch_protection", "namespaces"."max_personal_access_token_lifetime", "namespaces"."push_rule_id", "namespaces"."shared_runners_enabled", "namespaces"."allow_descendants_override_disabled_shared_runners", "namespaces"."traversal_ids", "namespaces"."organization_id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND "namespaces"."id" = 33)) namespaces WHERE "namespaces"."type" = 'Group') ORDER BY user_id, invite_email,
CASE WHEN source_id = 33 and source_type = 'Namespace'
THEN access_level + 1 ELSE access_level END DESC,
member_role_id ASC, expires_at DESC, created_at ASC
) members WHERE "members"."type" = 'GroupMember' AND "members"."invite_token" IS NULL AND (members.access_level > 5))
UNION ALL
(SELECT "members"."id", "members"."access_level", "members"."source_id", "members"."source_type", "members"."user_id", "members"."notification_level", "members"."type", "members"."created_at", "members"."updated_at", "members"."created_by_id", "members"."invite_email", "members"."invite_token", "members"."invite_accepted_at", "members"."requested_at", "members"."expires_at", "members"."ldap", "members"."override", "members"."state", "members"."invite_email_success", "members"."member_namespace_id", "members"."member_role_id", "members"."expiry_notified_at", "members"."request_accepted_at" FROM "members" WHERE "members"."type" = 'ProjectMember' AND "members"."member_namespace_id" = 34 AND "members"."requested_at" IS NULL AND "members"."invite_token" IS NULL)) AS member_union
LEFT JOIN project_authorizations on project_authorizations.user_id = member_union.user_id
AND
project_authorizations.project_id = 7
ORDER BY user_id,
invite_email,
CASE
WHEN type = 'ProjectMember' THEN 1
WHEN type = 'GroupMember' THEN 2
ELSE 3
END
) AS members LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id" WHERE (("members"."user_id" IS NULL AND "members"."invite_token" IS NOT NULL) OR "users"."state" = 'active') AND "members"."requested_at" IS NULL AND (members.access_level > 5) AND "members"."access_level" IN (50, 40)
How to set up and validate locally
- Enable the feature flag for the group or instance-wide:
Feature.enable(:pat_expiry_inherited_members_notification, :instance) - Check the instance and group settings to ensure they are not disabled from a previous MR
- Create or visit a group with an Ultimate license
- Create a sub-group: test-subgroup
- Create a project in the sub-group: test-project
- Add another member to the top-level group: alice
- Create a project access token on test-project expiring in 5 days
- Create a group access token on test-group expiring in 5 days
- Run the
PersonalAccessTokens::ExpiringWorkereither via the Rails console (PersonalAccessTokens::ExpiringWorker.new.perform) or via selecting Enqueue now in the Sidekiq page:https://gdk.test:3443/admin/sidekiq/cron/personal_access_tokens_expiring_worker - Check letter-opener to ensure the emails are sent to user alice , who is not a direct member of test-subgroup or test-project
Validate settings:
Group setting:
- After creating the group hierarchy above, go to settings for test-subgroup :
https://gdk.test:3443/groups/test-group/test-subgroup/-/edit - Under Expiry notification emails about group and project access tokens within this group should be sent to: , select All direct and inherited members of the group or project
- Hit "Save changes"
- Create a new group access token expiring in 7 days or less
- Enqueue the cron job
- Check letter-opener to see that alice did not receive an email
Parent group setting:
- Change the setting back to All direct and inherited members of the group or project for test-subgroup
- Change the setting to Only direct members of the group or project for the parent group, test-group
- Create a new group access token expiring in 7 days or less
- Enqueue the cron job
- Check letter-opener to see that alice did not receive an email about the new token
Instance setting:
- Change the setting back to All direct and inherited members of the group or project for test-group
- Go to Admin
➡️ Settings➡️ Preferences - Expand the Email section
- Under Expiry notification emails about group and project access tokens should be sent to: , select Only direct members of the group or project
- Hit "Save changes"
- Create a new group access token on group test-subgroup
- Enqueue the cron job
- Check letter-opener to see that alice did not receive an email about the new token
Related to #463016 (closed)