Skip to content

Create add on eligible users GraphQL API

Josianne Hyson requested to merge jh/graphql_for_add_on_assignments into master

What does this MR do and why?

Implements the last part of the API for: https://gitlab.com/gitlab-org/gitlab/-/issues/421974+, which will be used to create the UI in: https://gitlab.com/gitlab-org/gitlab/-/issues/421967+

Create a GraphQL API that can fetch the users in a namespace that are eligible for the :code_suggestions add-on.

Query to fetch users

{
  namespace(fullPath: "<GROUP-FULL-PATH>") {
    addOnEligibleUsers(addOnType: CODE_SUGGESTIONS, search: "Test") {
      nodes {
        id
        username
        name
        publicEmail
        avatarUrl
        webUrl
        lastActivityOn
        lastLoginAt
        addOnAssignments(
          addOnPurchaseIds: ["gid://gitlab/GitlabSubscriptions::AddOnPurchase/<ADD-ON-PURCHASE-ID>"]
        ) {
          nodes {
            addOnPurchase {
              name
            }
          }
        }
      }
    }
  }
}

How to set up and validate locally

  1. Create a group and add a guest, developer, owner
  2. Enable the hamilton_seat_management FF
    • Feature.enable(:hamilton_seat_management)
  3. Create an add on purchase for that group
    • GitlabSubscriptions::AddOnPurchase.create!(add_on: GitlabSubscriptions::AddOn.first, expires_on: 1.year.from_now, namespace_id: <GROUP-ID>, quantity: 10, purchase_xid: 'A-12345')
  4. Visit localhost:3000/graphql-explorer, and add the query specified above

SQL Queries

Resolving the AddOnAssignments

This are generated by the batch loading in the UserAddOnAssignmentsResolver.

Query plans generated locally, where I have assignment data:

Load assignments:

Locally generated query plan: https://explain.depesz.com/s/ybts

SELECT
    "subscription_user_add_on_assignments".*
FROM
    "subscription_user_add_on_assignments"
    INNER JOIN "subscription_add_on_purchases" ON "subscription_add_on_purchases"."id" = "subscription_user_add_on_assignments"."add_on_purchase_id"
WHERE
    "subscription_user_add_on_assignments"."user_id" IN (9459, 9455, 9451, 9446, 9445, 9444, 9443, 9442, 9441, 9440, 9439, 9438, 9437, 9436, 9435, 9434, 9433, 9432, 9431, 9430, 9429, 9428, 9427, 9426, 9425, 9424, 9423, 9422, 9421, 9420, 9419, 9418, 9415, 9414, 9413, 9412, 9411, 9410, 9409, 9408, 9407, 9406, 9405, 9404, 9403, 9402, 9401, 9400, 9399, 9398, 9397, 9396, 9395, 9394, 9393, 9392, 9391, 9390, 9389, 9388, 9387, 9357, 9354, 9353, 9352, 9351, 9350, 9349, 9348, 9347, 9346, 9345, 9344, 9343, 9342, 9341, 9340, 9339, 9338, 9337, 9336, 9335, 9334, 9333, 9332, 9331, 9330, 9329, 9328, 9327, 9326, 9320, 9319, 9318, 9317, 9316, 9315, 9314, 9313, 9312)
    AND "subscription_add_on_purchases"."id" IN (1, 2, 3, 4, 5)
    AND (expires_on >= '2023-10-02')
Load purchases:

Locally generated query plan: https://explain.depesz.com/s/GQQZ

SELECT
    "subscription_add_on_purchases".*
FROM
    "subscription_add_on_purchases"
WHERE
    "subscription_add_on_purchases"."id" IN (3, 2)
Load add on:

Locally generated query plan: https://explain.depesz.com/s/SqjW

SELECT 
    "subscription_add_ons".* 
FROM 
    "subscription_add_ons" 
WHERE 
    "subscription_add_ons"."id" = 1 
Load assigned_user count:

Locally generated query plan: https://explain.depesz.com/s/13KW

SELECT
    "subscription_user_add_on_assignments".*
FROM
    "subscription_user_add_on_assignments"
WHERE
    "subscription_user_add_on_assignments"."add_on_purchase_id" IN (3, 2)
Load namespaces for authorisation:

Locally generated query plan: https://explain.depesz.com/s/Mb2d

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"."emails_disabled",
    "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"."id" IN (22, 510)

Rewriting the BilledUsersFinder query

Context for this query here: !129203 (comment 1559493910)

Query plan on gitlab-org:

  1. First run: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22786/commands/73487
  2. Second run: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22786/commands/73488
Find all eligible users
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"
FROM
    "users"
WHERE
    "users"."id" IN ( WITH "our_group_namespaces" AS (
            SELECT
                "namespaces"."id",
                namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS last_traversal_id
            FROM
                "namespaces"
            WHERE
                "namespaces"."type" = 'Group'
                AND (traversal_ids @> ('{9970}'))),
            "base_ancestors_cte" AS (
                SELECT
                    "namespaces"."traversal_ids"
                FROM
                    "namespaces"
                WHERE
                    "namespaces"."type" = 'Group'
                    AND ("namespaces"."id" IN (
                            SELECT
                                "group_group_links"."shared_with_group_id"
                            FROM
                                group_group_links
                            WHERE
                                "group_group_links"."shared_group_id" IN (
                                    SELECT
                                        id
                                    FROM
                                        our_group_namespaces)
                                UNION
                                SELECT
                                    "project_group_links"."group_id"
                                FROM
                                    project_group_links
                                WHERE
                                    "project_group_links"."project_id" IN (
                                        SELECT
                                            id
                                        FROM
                                            projects
                                        WHERE
                                            "projects"."namespace_id" IN (
                                                SELECT
                                                    last_traversal_id
                                                FROM
                                                    "our_group_namespaces"))))),
                                "our_namespace_bans" AS (
                                    SELECT
                                        "namespace_bans"."user_id"
                                    FROM
                                        "namespace_bans"
                                    WHERE
                                        "namespace_bans"."namespace_id" = 9970
)
                                    SELECT
                                        "members"."user_id"
                                    FROM ((
                                            SELECT
                                                "members"."user_id"
                                            FROM
                                                "members"
                                            LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
                                    WHERE
                                        "members"."type" = 'ProjectMember'
                                        AND "members"."source_type" = 'Project'
                                        AND ("users"."state" IN ('active'))
                                        AND "users"."user_type" IN (0, 4, 5)
                                        AND "members"."state" = 0
                                        AND "members"."requested_at" IS NULL
                                        AND "members"."invite_token" IS NULL
                                        AND (members.access_level > 5)
                                        AND "members"."user_id" NOT IN (
                                            SELECT
                                                "user_id"
                                            FROM
                                                "our_namespace_bans")
                                            AND "members"."source_id" IN (
                                                SELECT
                                                    "projects"."id"
                                                FROM
                                                    "projects"
                                                WHERE
                                                    "projects"."namespace_id" IN (
                                                        SELECT
                                                            "id"
                                                        FROM
                                                            "our_group_namespaces")))
                                            UNION (
                                                SELECT
                                                    "members"."user_id"
                                                FROM
                                                    "members"
                                                LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
                                        WHERE
                                            "members"."type" = 'GroupMember'
                                            AND "members"."source_type" = 'Namespace'
                                            AND ("users"."state" IN ('active'))
                                            AND "users"."user_type" IN (0, 4, 5)
                                            AND "members"."state" = 0
                                            AND "members"."requested_at" IS NULL
                                            AND "members"."invite_token" IS NULL
                                            AND (members.access_level > 5)
                                            AND "members"."user_id" NOT IN (
                                                SELECT
                                                    "user_id"
                                                FROM
                                                    "our_namespace_bans")
                                                AND ("members"."source_id" IN (
                                                        SELECT
                                                            unnest("base_ancestors_cte"."traversal_ids")
                                                        FROM
                                                            base_ancestors_cte
                                                        UNION
                                                        SELECT
                                                            id
                                                        FROM
                                                            our_group_namespaces)))) members)
                                ORDER BY
                                    "users"."id" DESC
                                LIMIT 101

MR acceptance checklist

This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Josianne Hyson

Merge request reports