POC: Organization-scoped read-only mode

What does this MR do and why?

Implements the POC for Organization-scoped read-only mode, as tracked in #594327 (closed). Covers Steps 1, 2, and portions of Steps 3-5 from the implementation plan.

This replaces the namespace-scoped approach from !226399 (closed).

Step 1: Organization maintenance state machine

Adds a state column (smallint, default 0) to the organizations table and an Organizations::Maintenable concern:

  • start_maintenance! / complete_maintenance! — state transitions
  • maintenance? / active? — query methods

Step 2: Central controller enforcement via Current.organization

Adds EnforcesReadOnlyOrganization concern included in ApplicationController with a before_action that runs after set_current_organization. Uses Current.organization directly — no per-controller overrides needed.

Request type Org in maintenance Result
GET/HEAD Yes Allowed
POST/PATCH/PUT/DELETE No Allowed
POST/PATCH/PUT/DELETE (JSON) Yes 503 + Retry-After header
POST/PATCH/PUT/DELETE (HTML) Yes Redirect with flash alert

Step 3 (partial): API enforcement

Adds check_organization_read_only! in API::Helpers#set_current_organization. Covers all API endpoints that call set_current_organization (Projects, Groups, Snippets, Topics, etc.). Endpoints that don't call set_current_organization are a known gap for follow-up.

Step 5: GraphQL mutation enforcement

Adds disallow_mutations_for_organization_read_only in GraphqlController. Blocks mutations when Current.organization is in maintenance. Queries (reads) pass through. Skips the HTML-level enforce_read_only_organization since GraphQL has its own check.

Bonus: Git push enforcement

Adds check_organization_read_only! in Gitlab::GitAccess#check_db_accessibility!. Blocks git push when the project's organization is in maintenance. git pull is unaffected. Uses respond_to?(:organization) guard since Wiki containers don't implement organization.

Changes

Step 1 — State machine:

  • db/migrate/20260324230000_add_state_to_organizations.rb
  • app/models/concerns/organizations/maintenable.rb (new)
  • app/models/organizations/organization.rb
  • spec/models/concerns/organizations/maintenable_spec.rb (new)

Step 2 — Controller enforcement:

  • app/controllers/concerns/enforces_read_only_organization.rb (new)
  • app/controllers/application_controller.rb — include + before_action

Step 3 — API enforcement:

  • lib/api/helpers.rbcheck_organization_read_only!
  • spec/requests/api/organization_read_only_mode_spec.rb (new)

Step 5 — GraphQL enforcement:

  • app/controllers/graphql_controller.rb — mutation blocking + skip HTML enforcement
  • spec/controllers/graphql_controller_organization_read_only_spec.rb (new)

Git push enforcement:

  • lib/gitlab/git_access.rbcheck_organization_read_only!
  • spec/lib/gitlab/git_access_organization_read_only_spec.rb (new)

Other:

  • ee/spec/requests/search_controller_spec.rb:with_current_organization trait
  • ee/spec/requests/search_default_scope_spec.rb — same
  • ee/spec/features/search/zoekt/search_spec.rb — same

Known gaps (follow-up)

  • API endpoints that don't call set_current_organization (e.g. Issues API)
  • find_group!/find_project! level enforcement for broader API coverage
  • Admin UI/API toggle for maintenance mode
  • Background write paths (Sidekiq workers, cron jobs)
  • GET requests with side effects

How to validate locally

org = Organizations::Organization.find(id)
org.start_maintenance!
# POST to a group/project endpoint → blocked with 503
# GET a group/project page → works normally
org.complete_maintenance!
# Writes work again

MR acceptance checklist

Evaluate against the MR acceptance checklist.

Edited by Chen Zhang

Merge request reports

Loading