Skip field authorization for work items
What does this MR do and why?
To optimise the queries used for listing work items, we can remove the type authorisation from the fields group.workItems and project.workItems by extending the authorisation already performed by the finder.
We need to handle two cases that are currently not authorised by the finder:
- Redact group-level work items when the licensed feature
epicsis not available - Redact project-level epics when the feature flag
project_work_item_epicsis disabled
To achieve this, I implemented IssueTypesFilter to move the logic from IssuesFinder#by_issue_types and add the redaction for unavailable types.
These changes are added behind the feature flag authorize_issue_types_in_finder so it can be tested safely.
List of changes
- Add
skip_type_authorization: [:read_work_item]toWorkItemsResolver- this can be moved to the specific fields once we remove the FF - Move authorization from
LinkedWorkItemTypetoLinkedItemsResolver- required because this type also usesread_work_item, which is now skipped in all nested fields - Add
Issues::IssueTypesFilter- handles types redaction - Add
skip_type_authorization: [:read_label]inTypes::WorkItems::Widgets:: LabelsType- unnecessary check becauseread_labelis available if the user can read the work item - Add
skip_type_authorization: [:read_milestone]in inTypes::WorkItems::Widgets:: MilestoneType- same as above - Skip
read_namespaceandread_projectfor theprojectandnamespacefields inWorkItemType- same as above
References
Skip type authorisation for workItems fields (#542421)
Database changes
When the WorkItemsFinder does not receive the parameter issue_types, meaning we want to find all types, we might have to filter out unavailable work item types:
Case 1: with group param
group, user = Group.first, User.first
params = { group_id: group, include_descendants: true, exclude_projects: false }
WorkItems::WorkItemsFinder.new(user, params).execute
When the epics feature is not available
Excludes all namespace-level work items and project-level work items with type Epic:
previous query
SELECT "issues".* FROM "issues" INNER JOIN "projects" ON "projects"."id" = "issues"."project_id" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM UNNEST(
COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query))
) AS namespaces(id)
) ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
new query
SELECT "issues".* FROM "issues" INNER JOIN "projects" ON "projects"."id" = "issues"."project_id" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM UNNEST(
COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query))
) AS namespaces(id)
) AND "issues"."work_item_type_id" != 8 ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
| previous query plan | new query plan |
|---|---|
| console.postgres.ai | console.postgres.ai |
When project-level epics are disabled
Include all namespace-level work items and exclude project-level work items with type Epic:
previous query
WITH "namespace_ids" AS MATERIALIZED (SELECT "namespaces"."id" FROM ((SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}')))
UNION ALL
(SELECT "projects"."project_namespace_id" FROM "projects" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM UNNEST(
COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query))
) AS namespaces(id)
))) namespaces) SELECT "issues".* FROM "issues" WHERE (issues.namespace_id IN (SELECT id FROM namespace_ids)) ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
new query
WITH "namespace_ids" AS MATERIALIZED (SELECT "namespaces"."id" FROM ((SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}')))
UNION ALL
(SELECT "projects"."project_namespace_id" FROM "projects" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM UNNEST(
COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query))
) AS namespaces(id)
))) namespaces) SELECT "issues".* FROM "issues" WHERE (issues.namespace_id IN (SELECT id FROM namespace_ids)) AND "issues"."id" NOT IN (WITH "namespace_ids" AS MATERIALIZED (SELECT "namespaces"."id" FROM ((SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}')))
UNION ALL
(SELECT "projects"."project_namespace_id" FROM "projects" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM UNNEST(
COALESCE(
(SELECT ids FROM (SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids FROM "namespace_descendants" WHERE "namespace_descendants"."outdated_at" IS NULL AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids FROM (SELECT ARRAY_AGG("namespaces"."id") AS ids FROM (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query))
) AS namespaces(id)
))) namespaces) SELECT "issues"."id" FROM "issues" WHERE (issues.namespace_id IN (SELECT id FROM namespace_ids)) AND "issues"."work_item_type_id" = 8 AND "issues"."project_id" IS NOT NULL) ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
| previous query plan | new query plan |
|---|---|
| console.postgres.ai | console.postgres.ai |
Case 2: with project param
project, user = Project.first, User.first
params = { project_id: project }
WorkItems::WorkItemsFinder.new(user, params).execute
Exclude work items with type Epic when epics feature is not enabled or the project_work_item_epics feature flag is disabled. This query only changes if project-level epics exist, which is unlikely for the near future because the feature has not been released.
previous query
SELECT "issues".* FROM "issues" INNER JOIN "projects" ON "projects"."id" = "issues"."project_id" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."id" = 278964 ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
new query
SELECT "issues".* FROM "issues" INNER JOIN "projects" ON "projects"."id" = "issues"."project_id" LEFT JOIN project_features ON projects.id = project_features.project_id WHERE (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 1 AND (project_authorizations.project_id = projects.id)) OR projects.visibility_level IN (0,10,20)) AND ("project_features"."issues_access_level" > 0 OR "project_features"."issues_access_level" IS NULL) AND "projects"."id" = 278964 AND "issues"."work_item_type_id" != 8 ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101;
| previous query plan | new query plan |
|---|---|
| console.postgres.ai | console.postgres.ai/ |
Screenshots or screen recordings
No UI changes
How to set up and validate locally
- Enable feature flags
Feature.enable(:authorize_issue_types_in_finder)
Feature.enable(:project_work_item_epics)
- Create testing data
Click to expand
user = User.first
root_group = FactoryBot.create(:group, name: 'Root Group', path: 'root-group', creator: user)
root_project = FactoryBot.create(:project, name: 'Root Project', path: 'root-project', group: root_group, creator: user)
subgroup = FactoryBot.create(:group, name: 'Subgroup', path: 'subgroup', creator: user, parent: root_group)
subgroup_project = FactoryBot.create(:project, name: 'Subgroup Project', path: 'sub-project', group: subgroup, creator: user)
issue_type = WorkItems::Type.default_by_type(:issue)
epic_type = WorkItems::Type.default_by_type(:epic)
[issue_type, epic_type].each do |type|
WorkItem.create!(title: "Root Group #{type.name}", work_item_type: type, namespace: root_group, author: user)
WorkItem.create!(title: "Root Project #{type.name}", work_item_type: type, project: root_project, author: user)
WorkItem.create!(title: "Subroup #{type.name}", work_item_type: type, namespace: subgroup, author: user)
WorkItem.create!(title: "Sub Project #{type.name}", work_item_type: type, project: subgroup_project, author: user)
end
# We can also use the finder to verify the changes, e.g.:
params = { group_id: root_group, include_descendants: true, exclude_projects: false }
WorkItems::WorkItemsFinder.new(user, params).execute
- Visit
http://gdk.test:3000/-/graphql-explorerand try the following query with the different variables
GQL query
query getAllWorkItems($excludeProjects: Boolean = false, $includeDescendants: Boolean = true, $isGroup: Boolean = true, $fullPath: ID!, $types: [IssueType!]) {
group(fullPath: $fullPath) @include(if: $isGroup) {
id
name
workItems(
excludeProjects: $excludeProjects
includeDescendants: $includeDescendants
types: $types
) {
nodes {
title
}
}
}
project(fullPath: $fullPath) @skip(if: $isGroup) {
id
name
workItems(
types: $types
) {
nodes {
title
}
}
}
}
variables
# root group level - all work items including descendant groups and projects
# {"excludeProjects":false,"includeDescendants":true,"isGroup":true,"fullPath":"root-group"}
# {"excludeProjects":false,"includeDescendants":true,"isGroup":true,"fullPath":"root-group", "types": "EPIC"}
# root group level - only group items, include descendant groups
# {"excludeProjects":true,"includeDescendants":true,"isGroup":true,"fullPath":"root-group"}
# {"excludeProjects":true,"includeDescendants":true,"isGroup":true,"fullPath":"root-group", "types": "EPIC"}
# root group level - only group items, exclude descendant groups
# {"excludeProjects":true,"includeDescendants":false,"isGroup":true,"fullPath":"root-group"}
# {"excludeProjects":true,"includeDescendants":false,"isGroup":true,"fullPath":"root-group", "types": "EPIC"}
# project
# {"isGroup":false,"fullPath":"root-group/root-project"}
# {"isGroup":false,"fullPath":"root-group/root-project", "types": "EPIC"}
# sub project
# {"isGroup":false,"fullPath":"root-group/subgroup/sub-project"}
# {"isGroup":false,"fullPath":"root-group/subgroup/sub-project", "types": "EPIC"}
- Verify that namespace-level work items are not returned when
epicsfeature is not available (can be replicated by removingepicsfromee/app/models/gitlab_subscriptions/features.rb) - Verify that project-level epics are not returned when
epicsfeature is not available - Verify that project-level epics are not returned when
project_work_item_epicsfeature flag is disabled
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.