Prevent SAs from creating top-level groups
Summary
Restricts free-tier service accounts from creating top-level groups as a policy-level defense-in-depth safeguard.
Currently, service accounts are created with external: true, which implicitly sets can_create_group = false. However, an admin can override this via PUT /api/v4/users/:id. This change adds explicit policy-level prevention that cannot be bypassed by updating user attributes.
What does this MR do?
Introduces a shared policy concern ServiceAccountGroupCreationRestriction that prevents service accounts from creating top-level groups unless they belong to a paid subscription tier.
Behavior
"Provisioning group" = root ancestor of the provisioned_by_group_id or provisioned_by_project_id entity. Always a group that sits on the top of the hierarchy.
| Environment | Condition | Can create group? |
|---|---|---|
| SaaS | No provisioning group (instance-level SA) | Yes = these SAs are controlled by GL, backward compatibility |
| SaaS | Provisioning group with no subscription | No |
| SaaS | Provisioning group with expired subscription | No |
| SaaS | Provisioning group with free plan | No |
| SaaS | Provisioning group with trial subscription | Yes |
| SaaS | Provisioning group with paid subscription (Premium/Ultimate) | Yes |
| Self-managed | No license | No |
| Self-managed | Licensed feature | Yes |
Why both GlobalPolicy and OrganizationPolicy?
Group creation can happen through two code paths. GlobalPolicy governs the standard create_group ability, while OrganizationPolicy governs group creation scoped to an organization. Both must enforce the restriction.
Why update_column(:can_create_group, true) in tests?
Service accounts are created with external: true, which forces can_create_group = false via a before_save callback. The tests use update_column to bypass this and set can_create_group = true at the database level, so that the base policy condition passes and we can verify that the new policy logic correctly prevents group creation independently.
How to test manually
There are many cases.
For smoke testing, these are convenient:
Project-provisioned SA
- Start instance as SaaS
- Create free group (new one, do not attach sub to it)
- Create project in it
- Create SA in this project. You won't see this option in UX, so one approach may be to create SA in paid project via UX and then update
provisioned_by_project_idfiled with the Free project's ID. Or just run something via console:
::Users::AuthorizedCreateService.new(
User.find_by(admin: true),
{
organization_id: Organizations::Organization.default_organization.id,
name: "SA for #{project.name}",
username: "service_account_#{project.id}_#{SecureRandom.hex(4)}",
email: "sa_#{SecureRandom.hex(4)}@service.account.gitlab.com",
user_type: :service_account,
provisioned_by_project_id: project.id,
skip_confirmation: true,
confirmed_at: Time.current,
external: true
}
).execute
- Ensure to run
sa.update_column(:can_create_group, true)so we actually test the new mechanism - Check
Ability.allowed?(sa, :create_group) - On this branch, it will be
falsefor a Free SA, onmaster- still true - Repeat the same with SA created in a paid project. Both on
masterand on this branch the ability will be allowed.
Subgroup provisioned SA
Similar as above - but the SA should be within subgroup.
You should use provisioned_by_group_id field for SA manipulation.
Repeat for paid/free top group, and then on master and this branch.
On this branch, we start returning false for Ability.allowed?(sa, :create_group)` for a Free subgroup hierarchy.
Self-managed
You need to de-attach your license which may be annoying.
Feel free to test as you see fit.
Related issues
Closes #540774
Part of &20439