Add granular token authorization to work item mutations

What does this MR do and why?

Adds granular (fine-grained) personal access token authorization to 34 GraphQL mutations in the work items area (work items, saved views, work item types/settings, custom fields, lifecycles, requirements, test cases, and iterations), as part of removing mutation: entries from config/authz/graphql/authorization_todo.txt.

For each mutation this MR:

  • adds an authorize_granular_token directive with the appropriate permission and boundary,
  • creates or reuses raw permission definitions and assignable permissions,
  • adds the shared-example authorization request spec per declared boundary type,
  • removes the mutation's line from the authorization todo file,
  • regenerates the fine-grained token reference docs (GraphQL and REST).

Part of the granular PAT GraphQL coverage effort. Implementation guide: GraphQL implementation guide.

Mutation → permission → boundary

Mutation Permission Boundary
WorkItemAddLinkedItems update_work_item (reused) project + group (id → resource_parent)
WorkItemRemoveLinkedItems update_work_item (reused) project + group (id → resource_parent)
workItemsHierarchyReorder update_work_item (reused) project + group (id → resource_parent)
WorkItemBulkMove update_work_item (reused) + create_work_item on the target (additional scope) project + group (source_full_path, target_full_path)
WorkItemBulkUpdate update_work_item (reused) project + group (full_path)
WorkItemsCsvExport / WorkItemExport (alias) read_work_item (reused) project (project_path)
WorkItemsCsvImport create_work_item (reused) project (project_path)
WorkItemSubscribe subscribe_work_item (new) project + group (id → resource_parent)
WorkItemUserPreferenceUpdate update_work_item_user_preference (raw existed; folded into update_work_item) project + group (namespace_path)
WorkItemSavedViewCreate create_work_item_saved_view (new raw; own assignable; aliases create_saved_view) project + group (namespace_path)
WorkItemSavedViewUpdate update_work_item_saved_view (new raw; own assignable; aliases update_saved_view) project + group (id → resource_parent)
WorkItemSavedViewDelete delete_work_item_saved_view (new raw; own assignable; aliases delete_saved_view) project + group (id → resource_parent)
WorkItemSavedViewReorder / Subscribe / Unsubscribe reorder_work_item_saved_view / subscribe_work_item_saved_view / unsubscribe_work_item_saved_view (new raws; bundled in the subscribe_work_item_saved_view assignable; alias the generic saved view abilities) project + group (id → resource_parent)
WorkItemTypeCreate create_work_item_type (raw existed; folded into create_work_item) group (full_path; omitted full_path falls back to the organization and is rejected in ready? for fine-grained tokens)
WorkItemTypeUpdate update_work_item_type (raw existed; folded into update_work_item); passing archive also requires archive_work_item_type (new raw; standalone assignable) via an explicit Authz::Tokens::AuthorizeGranularScopesService check group (full_path; omitted full_path falls back to the organization and is rejected in ready? for fine-grained tokens)
WorkItemAvailabilityToggle update_work_item_type_visibility (raw existed; folded into update_work_item) project + group (full_path)
WorkItemSettingsUpdate update_work_item_setting (raw existed; folded into update_work_item) group (full_path; omitted full_path falls back to the organization and is rejected in ready? for fine-grained tokens)
CustomFieldCreate create_custom_field (new raw; folded into create_work_item) group (group_path)
CustomFieldUpdate update_custom_field (new raw; folded into update_work_item) group (id → namespace)
CustomFieldArchive / CustomFieldUnarchive archive_custom_field / unarchive_custom_field (new raws; standalone archive_custom_field assignable) group (id → namespace)
LifecycleCreate create_work_item_lifecycle (new raw; folded into create_work_item) group (namespace_path)
LifecycleUpdate / LifecycleAttachWorkItemType update_work_item_lifecycle (new raw; folded into update_work_item) group (namespace_path)
LifecycleDelete delete_work_item_lifecycle (new raw; folded into delete_work_item) group (namespace_path)
CreateRequirement create_requirement (new raw; folded into create_work_item) project (project_path)
UpdateRequirement update_requirement (new raw; folded into update_work_item) project (project_path)
ExportRequirements read_requirement (new raw; folded into read_work_item) project (project_path)
CreateTestCase create_test_case (new raw; folded into create_work_item) project (project_path)
iterationCreate / CreateIteration (alias) create_iteration (new raw; folded into create_work_item) group only (group_path)

Skipped

  • WorkItemHierarchyAddChildrenItems stays in authorization_todo.txt: its required children_ids argument uses loads:, and loading the children triggers WorkItemType's type-level granular directive. A token holding only the mutation's permission is denied while loading arguments, before the mutation-level check runs, so the mutation cannot currently be authorized (or spec-tested) with the granular framework. The merged precedent for this situation is removing loads: (see IssueSetEpic), which is a behavioral change beyond this MR's scope.

Judgment calls

  • Iterations are group-only: Iteration belongs to a group and the create service does not support projects, so iterationCreate declares only a group boundary even though it accepts a project_path argument (a code comment records this).
  • Consolidation into the work_item bundles: nested work item resources (work item types and settings, custom fields, lifecycles, requirements, test cases, and iterations) are folded into the create/update/delete/read_work_item assignables by verb, matching the pattern used for issue boards, epics, milestones, and iteration cadences. The exceptions are saved views (own assignables, see above), the archive verbs (own assignables), and subscribe_work_item (standalone, matching subscribe_merge_request).
  • WorkItemSavedViews::SavedView#resource_parent returns the Project for a project namespace, the Group for a group, and nil otherwise. SavedViewPolicy delegates to it, so the RBAC subject and the granular boundary come from one method.
  • update_work_item_type_visibility does not get its own user-facing permission; it is folded into the update_work_item assignable alongside update_work_item_type.
  • Omitted fullPath falls back to the organization: WorkItemTypeCreate, WorkItemTypeUpdate, and WorkItemSettingsUpdate accept an omitted fullPath that falls back to the current organization. That fallback has no project or group boundary, so requests authenticated with a fine-grained token and no fullPath are now rejected in ready? with an explicit error instead of an opaque denial.

How was this validated?

  • bundle exec rake gitlab:permissions:validate passes (permissions, boundaries, test coverage, docs freshness).
  • bundle exec rake gitlab:permissions:graphql:compile_docs and gitlab:permissions:routes:compile_docs regenerated docs committed.
  • Authorization request specs run in CI (shared example authorizing granular token permissions for GraphQL, one per declared boundary type).

🤖 Generated with Claude Code

Edited by Alex Buijs

Merge request reports

Loading
Loading