The MR is empty.
This MR contains the following updates:
| Package | Update | Change | MyDiffEnd |
|---|---|---|---|
| ruby-lsp-rspec (changelog) | patch |
0.1.28 -> 0.1.29
|
https://my.diffend.io/gems/ruby-lsp-rspec/0.1.28/0.1.29 |
MR created with the help of gitlab-org/frontend/renovate-gitlab-bot
v0.1.29
Full Changelog: https://github.com/st0012/ruby-lsp-rspec/compare/v0.1.28...v0.1.29
This MR has been generated by Renovate Bot.
@andrewn Sorry, I think I assumed it required more reviews since there were other reviewers assigned.
As part of the TLG-to-Organization onboarding flow, we want to begin auto-transferring Top-Level Groups (TLGs) to Organizations. Once in place, customers will be able to combine multiple TLGs into a single Organization via an onboarding experience.
We already have tooling to transfer groups from one Organization to another. However, there is currently no way to delete an Organization — which is required when merging TLGs: after transferring all groups out of a redundant Organization, that Organization needs to be removed.
Add the ability to soft-delete an Organization, reusing the same soft-delete pattern already used for groups.
Organizations are not namespace-hierarchical (no parent/ancestors), so the state machine
is simpler than Namespaces::Stateful. There is no ancestor_inherited state, no
archival, and no state preservation needed (cancel always returns to active).
| Value | Name | Description |
|---|---|---|
| 0 | active |
Default. Organization is fully operational. |
| 1 | deletion_scheduled |
Soft-deleted. Invisible to non-admins. Reversible. |
| 2 | deletion_in_progress |
Hard-deletion worker is running (reserved for future). |
| Event | From | To | Notes |
|---|---|---|---|
schedule_deletion |
active |
deletion_scheduled |
Soft-delete (issue scope) |
start_deletion |
active, deletion_scheduled
|
deletion_in_progress |
Future: hard-delete worker |
cancel_deletion |
deletion_scheduled, deletion_in_progress
|
active |
Restore |
reschedule_deletion |
deletion_in_progress |
deletion_scheduled |
Future: retry on failure |
active ──schedule_deletion──> deletion_scheduled ──start_deletion──> deletion_in_progress
^ | |
└──────── cancel_deletion ─────────┘ |
^ |
└──────────────────────── cancel_deletion / reschedule_deletion ─────────────┘
state to organizations
New file: db/migrate/<ts>_add_state_to_organizations.rb
class AddStateToOrganizations < Gitlab::Database::Migration[2.3]
def change
add_column :organizations, :state, :smallint, null: false, default: 0
add_index :organizations, :state
end
end
state_metadata to organization_details
Transition context (who triggered deletion, when) belongs in organization_details,
which is the natural home for extended org metadata — parallel to namespace_details
storing state_metadata for namespaces.
New file: db/migrate/<ts>_add_state_metadata_to_organization_details.rb
class AddStateMetadataToOrganizationDetails < Gitlab::Database::Migration[2.3]
def change
add_column :organization_details, :state_metadata, :jsonb, null: false, default: {}
end
end
Both changes are additive with non-null defaults — no backfill needed.
deletion_scheduled_at index for background job queries
The future hard-delete worker needs to efficiently query orgs past their adjourned period.
A functional index on the JSONB field in organization_details supports that:
add_index :organization_details,
"(state_metadata->>'deletion_scheduled_at')",
name: 'idx_org_details_deletion_scheduled_at',
using: :btree
This can be added as a post_migrate alongside the background worker (future scope).
Organizations::Stateful Concern
New directory: app/models/concerns/organizations/stateful/
Model the concern parallel to Namespaces::Stateful, but stripped of concepts that don't
apply to organizations (ancestor inheritance, archival, transfers, state preservation).
New file: app/models/concerns/organizations/stateful.rb
# frozen_string_literal: true
module Organizations
module Stateful
extend ActiveSupport::Concern
included do
include TransitionContext
include TransitionCallbacks
include TransitionLogging
attribute :state, :integer, limit: 2, default: 0
enum :state, {
active: 0,
deletion_scheduled: 1,
deletion_in_progress: 2
}, instance_methods: false
state_machine :state, initial: :active do
before_transition :update_state_metadata
before_transition on: :schedule_deletion, do: :ensure_transition_user
before_transition on: :schedule_deletion, do: :set_deletion_schedule_data
before_transition on: :cancel_deletion, do: :clear_deletion_schedule_data
event :schedule_deletion do
transition active: :deletion_scheduled
end
event :start_deletion do
transition %i[active deletion_scheduled] => :deletion_in_progress
end
event :cancel_deletion do
transition %i[deletion_scheduled deletion_in_progress] => :active
end
event :reschedule_deletion do
transition deletion_in_progress: :deletion_scheduled
end
after_transition :log_transition
after_failure :update_state_metadata_on_failure
after_failure :log_transition_failure
end
end
end
end
New file: app/models/concerns/organizations/stateful/transition_context.rb
module Organizations
module Stateful
module TransitionContext
private
def transition_args(transition) = transition.args.first || {}
def transition_user(transition) = transition_args(transition)[:transition_user]
end
end
end
New file: app/models/concerns/organizations/stateful/transition_callbacks.rb
All metadata is written to organization_detail.state_metadata, parallel to how
Namespaces::Stateful writes to namespace_details.state_metadata.
module Organizations
module Stateful
module TransitionCallbacks
private
def update_state_metadata(transition, error: nil)
organization_detail.state_metadata.merge!(
last_updated_at: Time.current.as_json,
last_error: error,
last_changed_by_user_id: transition_user(transition)&.id
)
end
def set_deletion_schedule_data(transition)
organization_detail.state_metadata.merge!(
deletion_scheduled_at: Time.current.as_json,
deletion_scheduled_by_user_id: transition_user(transition).id
)
end
def clear_deletion_schedule_data(_transition)
organization_detail.state_metadata.except!(
'deletion_scheduled_at',
'deletion_scheduled_by_user_id'
)
end
def update_state_metadata_on_failure(transition)
error_msg = "Cannot transition from #{transition.from_name} to #{transition.to_name}"
update_state_metadata(transition, error: error_msg)
organization_detail.save!
end
end
end
end
New file: app/models/concerns/organizations/stateful/transition_logging.rb
module Organizations
module Stateful
module TransitionLogging
private
def log_transition(transition)
Gitlab::AppLogger.info(
message: 'Organization state transition',
organization_id: id,
from: transition.from_name,
to: transition.to_name,
event: transition.event
)
end
def log_transition_failure(transition)
Gitlab::AppLogger.error(
message: 'Organization state transition failed',
organization_id: id,
from: transition.from_name,
to: transition.to_name,
event: transition.event,
errors: errors.full_messages
)
end
end
end
end
OrganizationDetail model
File: app/models/organizations/organization_detail.rb
Add store_accessor for the state_metadata JSONB column so callers can read typed
sub-keys without raw hash access, and add a delegation so the parent Organization can
expose deletion_scheduled_at directly.
store_accessor :state_metadata, :deletion_scheduled_at, :deletion_scheduled_by_user_id
Organization model
File: app/models/organizations/organization.rb
include Organizations::Stateful
enum, but explicitly document key ones)
# These come from the enum definition in Organizations::Stateful:
# scope :active — where(state: 0)
# scope :deletion_scheduled — where(state: 1)
# scope :deletion_in_progress — where(state: 2)
def empty?
groups.none? && projects.none?
end
empty? is intentionally extensible — as org-level entities (Artifact Registry, etc.) are
introduced, they add themselves to this check or the service validates them separately.
Organizations::DeleteService
New file: app/services/organizations/delete_service.rb
# frozen_string_literal: true
module Organizations
class DeleteService < BaseService
def initialize(organization, current_user:)
@organization = organization
@current_user = current_user
end
def execute
return error(_('Insufficient permissions')) unless authorized?
return error(_('Cannot delete the default organization')) if organization.default?
return error(_('Organization must be empty before it can be deleted')) unless organization.empty?
unless organization.schedule_deletion!(transition_user: current_user)
return error(organization.errors.full_messages.join(', '))
end
# TODO: emit audit event (Phase 8)
ServiceResponse.success(payload: { organization: organization })
end
private
attr_reader :organization, :current_user
def authorized?
Ability.allowed?(current_user, :delete_organization, organization)
end
def error(message)
ServiceResponse.error(message: message, payload: { organization: nil })
end
end
end
Organizations::DestroyService + OrganizationDestroyWorker
For eventual hard-deletion (out of scope for this issue), follow the same pattern as
Groups::DestroyService + GroupDestroyWorker: an idempotent worker that calls
start_deletion!, destroys all resources, then hard-deletes the record.
File: app/policies/organizations/organization_policy.rb
desc "Organization is the default"
condition(:default_organization, scope: :subject, score: 0) { @subject.default? }
rule { (admin | organization_owner) & ~default_organization }.enable :delete_organization
File: app/finders/organizations/organizations_finder.rb
Soft-deleted organizations (state deletion_scheduled or deletion_in_progress) must be
invisible to non-admins. Add a by_active_state filter step.
def filter_organizations(organizations)
organizations
.then { |o| by_user_access(o) }
.then { |o| by_active_state(o) }
.then { |o| by_search(o) }
end
private
def by_active_state(organizations)
# Admins can opt in to see deleted orgs via params[:include_deleted]
return organizations if current_user&.can_admin_all_resources? && params[:include_deleted]
organizations.active
end
OrganizationDelete
New file: app/graphql/mutations/organizations/delete.rb
# frozen_string_literal: true
module Mutations
module Organizations
class Delete < Base
graphql_name 'OrganizationDelete'
authorize :delete_organization
argument :id,
Types::GlobalIDType[::Organizations::Organization],
required: true,
description: 'ID of the organization to soft-delete.'
field :organization,
Types::Organizations::OrganizationType,
null: true,
description: 'The soft-deleted organization.'
def resolve(id:)
organization = authorized_find!(id: id)
result = ::Organizations::DeleteService.new(
organization,
current_user: current_user
).execute
{ organization: result.payload[:organization], errors: result.errors }
end
end
end
end
state on OrganizationType (admin-only)
File: app/graphql/types/organizations/organization_type.rb
field :state,
GraphQL::Types::String,
null: false,
description: 'State of the organization.',
authorize: :admin_organization
field :deletion_scheduled_at,
Types::TimeType,
null: true,
description: 'Timestamp when deletion was scheduled. Visible to admins only.',
authorize: :admin_organization
def deletion_scheduled_at
ts = object.organization_detail.state_metadata['deletion_scheduled_at']
Time.zone.parse(ts) if ts
end
File: app/graphql/types/mutation_type.rb
mount_mutation Mutations::Organizations::Delete
Wrap behind delete_organization flag for safe rollout.
spec/models/organizations/organization_spec.rb)
active scope excludes deletion_scheduled / deletion_in_progress orgsdeletion_scheduled scope returns only state=1 orgsschedule_deletion! transitions active → deletion_scheduled, populates state_metadata
cancel_deletion! transitions deletion_scheduled → active, clears deletion metadataempty? returns false when org has groups; false when org has projects; true when emptyschedule_deletion! from deletion_scheduled state (invalid transition)spec/models/concerns/organizations/stateful_spec.rb)
set_deletion_schedule_data stores deletion_scheduled_at and deletion_scheduled_by_user_id
clear_deletion_schedule_data removes those keysensure_transition_user prevents schedule_deletion without a transition_user
update_state_metadata_on_failure logs error into state_metadata
spec/services/organizations/delete_service_spec.rb)
schedule_deletion! fails (e.g., invalid transition)deletion_scheduled on successServiceResponse with organizationspec/policies/organizations/organization_policy_spec.rb)
delete_organization enabled for org ownersdelete_organization enabled for instance adminsdelete_organization disabled for regular membersdelete_organization disabled for the default organizationspec/finders/organizations/organizations_finder_spec.rb)
deletion_scheduled orgs excluded for regular usersdeletion_scheduled orgs excluded for admins by defaultdeletion_scheduled orgs included when include_deleted: true for adminsspec/requests/api/graphql/mutations/organizations/delete_spec.rb)
deletion_scheduled state)state field reflects deletion_scheduled after mutationEmit an organization_deletion_scheduled audit event in DeleteService#execute:
::Gitlab::Audit::Auditor.audit({
name: 'organization_deletion_scheduled',
author: current_user,
scope: organization,
target: organization,
message: "Scheduled organization '#{organization.name}' for deletion"
})
| File | Type | Notes |
|---|---|---|
db/migrate/<ts>_add_state_to_organizations.rb |
New |
state smallint |
db/migrate/<ts>_add_state_metadata_to_organization_details.rb |
New |
state_metadata jsonb |
app/models/concerns/organizations/stateful.rb |
New | State machine concern |
app/models/concerns/organizations/stateful/transition_context.rb |
New | |
app/models/concerns/organizations/stateful/transition_callbacks.rb |
New | |
app/models/concerns/organizations/stateful/transition_logging.rb |
New | |
app/models/organizations/organization.rb |
Modify | Include Organizations::Stateful, add empty?
|
app/services/organizations/delete_service.rb |
New | |
app/policies/organizations/organization_policy.rb |
Modify | Add delete_organization ability |
app/finders/organizations/organizations_finder.rb |
Modify | Add by_active_state filter |
app/graphql/mutations/organizations/delete.rb |
New | |
app/graphql/types/organizations/organization_type.rb |
Modify | Expose state, deletion_scheduled_at
|
app/graphql/types/mutation_type.rb |
Modify | Mount mutation |
spec/models/concerns/organizations/stateful_spec.rb |
New | |
spec/models/organizations/organization_spec.rb |
Modify | |
spec/services/organizations/delete_service_spec.rb |
New | |
spec/policies/organizations/organization_policy_spec.rb |
Modify | |
spec/finders/organizations/organizations_finder_spec.rb |
Modify | |
spec/requests/api/graphql/mutations/organizations/delete_spec.rb |
New |
Namespaces::Stateful
| Aspect | Namespaces::Stateful |
Organizations::Stateful |
|---|---|---|
| Ancestor hierarchy | Yes — ancestor_inherited state, validate_ancestors_state
|
No — orgs have no parent |
| Archival | Yes — archived state, archive/unarchive events |
No (not in scope) |
| State preservation | Yes — StatePreservation module, state_metadata['preserved_states']
|
No — cancel always returns to active
|
| Transfer | Yes — transfer_in_progress, start/complete/cancel_transfer
|
No |
| Metadata storage |
namespace_details.state_metadata (jsonb, separate table) |
organization_details.state_metadata (jsonb, same separate-table pattern) |
| Deletion metadata |
deletion_scheduled_at, deletion_scheduled_by_user_id in metadata |
Same pattern |
empty? extensibility: Should this be a configurable registry (so future org-level
entities self-register) or kept as explicit method calls? For now, explicit is simpler.
Adjourned period: Should soft-deleted orgs be automatically hard-deleted after N days
(like groups use deletion_adjourned_period)? Not in scope but should be a follow-up issue.
include_deleted as GraphQL argument: Expose includeDeleted: Boolean on the
organizations query (admin-only)? Useful for admin tooling. Propose as follow-up.
REST API: DELETE /api/v4/organizations/:id for API parity. Out of scope.
OrganizationRestore mutation: Issue says no UI required, but an admin-only mutation
wrapping cancel_deletion! would be useful. Propose as follow-up.
@alexpooley @smaglangit With the help of Claude Code, I tried to implement what you both suggested at #572153 (comment 2800085821), but I'm not sure if we really want to prevent anonymous users from reading organizations? I'm crossposting gitlab-com/content-sites/handbook!18951 (diffs, comment 3169101390) since we are also discussing it there. Let me know what you think!
@mandrewsgl @smaglangit FYI I used this issue to learn how I could leverage Claude Code as much as possible, you can see the end-result at !228030.
- Public Organizations can be seen by logged-in users, but not anonymous users. They can contain public and private Groups and Projects.Good timing, as I worked on gitlab-org/gitlab#572153 => gitlab-org/gitlab!228030 where it was advised that anonymous users cannot see any organizations.
That means we should probably update the table to replace "Everyone" with "Logged-in users".
Good suggestion — switched to Arel in the latest push. The condition is now built as:
org_users_table = ::Organizations::OrganizationUser.arel_table
orgs_table = ::Organizations::Organization.arel_table
member_condition = org_users_table[:user_id].eq(current_user.id)
public_condition = orgs_table[:visibility_level].eq(::Organizations::Organization::PUBLIC)
organizations
.left_outer_joins(:organization_users)
.where(member_condition.or(public_condition))
.distinct
Good point on consistency — changed to organizations.none in the latest push.
Rémy Coutable (d6f08476) at 19 Mar 15:26
Move organization authorization from GraphQL types to finder
@timofurrer The MR seems idle so you might want to take over if this is of interest, or close it for now?
@hfyngvason I think this requires your input.
@grantyoung
Looks good to me, thanks!
Enables monthly automated updates for Ansible Python dependencies requirements.txt in the Gitlab Environment Toolkit project.
Agreed — the Danger bot flagged this too. I'll add the ~database and ~"database::review pending" labels and assign the suggested DB reviewer (@eugielimpin) so the query plan and index coverage get a proper look.
Good call — I audited all resolvers and mutations that return Types::Organizations::OrganizationType:
Resolvers::Organizations::OrganizationResolver (singular lookup): uses authorized_find! with authorize :read_organization ✓Resolvers::Organizations::OrganizationsResolver (this MR): authorization delegated to OrganizationsFinder#by_user_access ✓Resolvers::Users::OrganizationsResolver: uses authorizes_object! + authorize :read_user_organizations + user-scoped UserOrganizationsFinder ✓Mutations::Organizations::Base: returns the mutated object after a write operation; authorization is enforced by the mutation itself ✓All existing entry points are accounted for. The rubocop disable is safe as-is, and the comment on the disable explains the rationale so future contributors know what to look for.