Target: gitlab-org/gitlab @ HEAD 8e5a8f9 (sparse: app/graphql only on disk; services/models/policies pulled from git). Surface: 329 CE mutation files under app/graphql/mutations/. EE not cloned.
Every candidate that surfaced was defended either at the GraphQL layer (authorized_find! + class authorize) or at the service layer (per-object can?/Ability.allowed?).
The GraphQL authorize is a TWO-LAYER contract, not one. authorized_find! enforces the class authorize :ability on the PRIMARY object only. SECONDARY objects (resolved inline from another id arg) are routinely passed UN-authorized to a Service, and the Service does the per-object can?. To call a finding, you MUST read the service - a raw .find/object_from_id/id_in at the mutation layer is NOT a bug if the downstream service filters. Auditing only app/graphql yields false positives.
Bulk mutations deliberately have NO class authorize (ci/runner/bulk_delete+bulk_pause, packages/bulk_destroy, work_items/bulk_move+bulk_update, members/*/bulk_update). Pattern = resolve all ids -> service partitions with Ability.allowed? per object -> acts on authorized subset only (filter-and-skip) OR aborts batch on first unauthorized. Verified secure:
- Ci::Runners::BulkPause/BulkDeleteRunnersService: .partition { Ability.allowed?(user, :update_runner/:delete_runner, runner) }, destructive op scoped to authorized ids.
- Packages::MarkPackagesForDestructionService#can_destroy_packages?: can?(user, :destroy_package, package) per batch; aborts with :unauthorized if any fails (mutation raises on it).
- Members::*::BulkUpdate: only_direct_members + InvalidMembersError rejects user_ids outside the authorized source.
Cross-object "move/transfer" destination authz lives in the model, not the mutation. issues/move.rb resolves target_project via UNauthorized resolve_project, but WorkItems::DataSync::MoveService -> verify_can_move_work_item -> Issue#can_move? requires user.can?(:admin_issue, to_namespace) on the DESTINATION plus :admin_issue on source. The mutation-layer gap is fully compensated. (This is the single most important place a real GitLab IDOR could hide - the destination check was present and correct.)
loads: argument auto-authorizes with the loaded type's read_* ability. set_milestone (loads MilestoneType -> read_milestone), reviewer_rereview (loads UserType). When a sibling uses raw model_id instead of loads: (e.g. set_labels), the label authz is deferred to UpdateService - cosmetic asymmetry, not a bug.
skip_*_auth bypass params are safe ONLY when not exposed as GraphQL arguments. Environments::Create/UpdateService have skip_agent_auth, but the mutation does not declare it as an argument, so attacker can't set it. Cluster-agent secondary object IS authorized via Clusters::Agents::Authorizations::UserAccess::Finder (unauthorized_cluster_agent?). Worth checking on any future mutation: does a service-bypass kwarg leak into the GraphQL argument list?
Ability.allowed? and inline raise_resource_not_available_error! are auth mechanisms my first grep missed. achievements/update_user_achievement_priorities looked auth-less (no authorize/authorized_find!) but does Ability.allowed?(user, :update_owned_user_achievement, ua) per object inline. Grep for the full set: authorize :|authorized_find!|can\?\(|authorize!|Ability\.allowed\?|raise_resource_not_available.
link/relation mutations: work_items/linked_items/add resolves linked targets via raw .map(&:find) (zero GraphQL authz) but RelatedWorkItemLinks::CreateService#linkable_issuables -> can_admin_work_item_link? -> can?(user, :admin_work_item_link, target) filters per-target. The IssuableLinks::CreateService base is the load-bearing guard for ALL link mutations (issues/MRs/epics/work_items).
Set-difference per subdirectory (who calls authorized_find! vs who skips) surfaces the candidates fast; but the DECISION requires pulling the service from git (git show HEAD:app/services/...) since the sparse checkout hides them. Disclosure note for operator: GitLab is heavily hunted - all the above patterns are old/core and almost certainly already audited. No fresh peripheral gap found this pass.