From 0aeede2d11dbc61af427d2c51a3e6d9cd3aa063f Mon Sep 17 00:00:00 2001
From: Abdul Wadood <awadood@gitlab.com>
Date: Mon, 23 Sep 2024 08:37:19 +0000
Subject: [PATCH] Add a REST API to create an organization

This change introduces a new POST API for organization creation. The
functionality is controlled by a feature flag named
'allow_organization_creation'. The change also modifies workhorse to
enable file uploads on the '/api/v4/organizations' endpoint.
---
 app/helpers/application_settings_helper.rb    |   1 +
 app/models/application_setting.rb             |   2 +
 .../application_setting_implementation.rb     |   1 +
 .../application_setting_rate_limits.json      |   5 +
 .../_organizations_api_limits.html.haml       |  20 ++
 .../application_settings/network.html.haml    |   2 +
 .../rate_limit_on_organizations_api.md        |  36 ++++
 doc/api/organizations.md                      |  63 +++++++
 doc/security/rate_limits.md                   |   1 +
 lib/api/api.rb                                |   1 +
 .../entities/organizations/organization.rb    |  23 +++
 lib/api/helpers.rb                            |   2 +-
 lib/api/organizations.rb                      |  47 +++++
 lib/gitlab/application_rate_limiter.rb        |   1 +
 locale/gitlab.pot                             |   9 +
 spec/features/admin/admin_settings_spec.rb    |  14 ++
 .../application_settings_helper_spec.rb       |   1 +
 .../organizations/organization_spec.rb        |  27 +++
 spec/models/application_setting_spec.rb       |   2 +
 spec/requests/api/organizations_spec.rb       | 175 ++++++++++++++++++
 .../network.html.haml_spec.rb                 |  12 +-
 .../_support/lint_last_known_acceptable.txt   |   4 +-
 workhorse/cmd/gitlab-workhorse/upload_test.go |   2 +
 workhorse/internal/upstream/routes.go         |   6 +
 24 files changed, 452 insertions(+), 5 deletions(-)
 create mode 100644 app/views/admin/application_settings/_organizations_api_limits.html.haml
 create mode 100644 doc/administration/settings/rate_limit_on_organizations_api.md
 create mode 100644 doc/api/organizations.md
 create mode 100644 lib/api/entities/organizations/organization.rb
 create mode 100644 lib/api/organizations.rb
 create mode 100644 spec/lib/api/entities/organizations/organization_spec.rb
 create mode 100644 spec/requests/api/organizations_spec.rb

diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 9c6f757a2ce74..1ebff2a375f64 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -515,6 +515,7 @@ def visible_attributes
       :project_api_limit,
       :project_invited_groups_api_limit,
       :projects_api_limit,
+      :create_organization_api_limit,
       :user_contributed_projects_api_limit,
       :user_projects_api_limit,
       :user_starred_projects_api_limit,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index afe5ffdc3edc2..d19ea0f41ea70 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -599,6 +599,7 @@ def self.kroki_formats_attributes
       :max_terraform_state_size_bytes,
       :members_delete_limit,
       :notes_create_limit,
+      :create_organization_api_limit,
       :package_registry_cleanup_policies_worker_capacity,
       :packages_cleanup_package_file_worker_capacity,
       :pages_extra_deployments_default_expiry_seconds,
@@ -630,6 +631,7 @@ def self.kroki_formats_attributes
     group_shared_groups_api_limit: [:integer, { default: 60 }],
     groups_api_limit: [:integer, { default: 200 }],
     members_delete_limit: [:integer, { default: 60 }],
+    create_organization_api_limit: [:integer, { default: 10 }],
     project_api_limit: [:integer, { default: 400 }],
     project_invited_groups_api_limit: [:integer, { default: 60 }],
     projects_api_limit: [:integer, { default: 2000 }],
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index d66e47476d090..342e76f06d44d 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -285,6 +285,7 @@ def defaults # rubocop:disable Metrics/AbcSize
         group_projects_api_limit: 600,
         group_shared_groups_api_limit: 60,
         groups_api_limit: 200,
+        create_organization_api_limit: 10,
         project_api_limit: 400,
         project_invited_groups_api_limit: 60,
         projects_api_limit: 2000,
diff --git a/app/validators/json_schemas/application_setting_rate_limits.json b/app/validators/json_schemas/application_setting_rate_limits.json
index 04d8deedb3786..24c27737cd6bc 100644
--- a/app/validators/json_schemas/application_setting_rate_limits.json
+++ b/app/validators/json_schemas/application_setting_rate_limits.json
@@ -19,6 +19,11 @@
       "minimum": 1,
       "description": "Maximum number of simultaneous import jobs for GitHub importer"
     },
+    "create_organization_api_limit": {
+      "type": "integer",
+      "minimum": 0,
+      "description": "Number of requests allowed to the POST /api/v4/organizations API."
+    },
     "group_api_limit": {
       "type": "integer",
       "minimum": 0,
diff --git a/app/views/admin/application_settings/_organizations_api_limits.html.haml b/app/views/admin/application_settings/_organizations_api_limits.html.haml
new file mode 100644
index 0000000000000..fbdd8999318d0
--- /dev/null
+++ b/app/views/admin/application_settings/_organizations_api_limits.html.haml
@@ -0,0 +1,20 @@
+= render ::Layouts::SettingsBlockComponent.new(_('Organizations API rate limits'),
+  id: 'js-organizations-api-limits-settings',
+  testid: 'organizations-api-limits-settings',
+  expanded: expanded_by_default?) do |c|
+  - c.with_description do
+    = _('Set the per-user rate limits for the requests to Organizations API.')
+    = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_organizations_api'), target: '_blank', rel: 'noopener noreferrer'
+  - c.with_body do
+    = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-organizations-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
+      = form_errors(@application_setting)
+
+      %fieldset
+        = _("Set to 0 to disable the limits.")
+
+      %fieldset
+        .form-group
+          = f.label :create_organization_api_limit, format(_('Maximum requests to the %{api_name} API per %{timeframe} per user'), api_name: 'POST /organizations', timeframe: 'minute'), class: 'label-bold'
+          = f.number_field :create_organization_api_limit, min: 0, class: 'form-control gl-form-input'
+
+      = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index dedeef6363ff0..30d4feff27274 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -133,6 +133,8 @@
 
 = render 'projects_api_limits'
 
+= render 'organizations_api_limits'
+
 = render 'members_api_limits'
 
 = render ::Layouts::SettingsBlockComponent.new(_('Import and export rate limits'),
diff --git a/doc/administration/settings/rate_limit_on_organizations_api.md b/doc/administration/settings/rate_limit_on_organizations_api.md
new file mode 100644
index 0000000000000..5531688378f18
--- /dev/null
+++ b/doc/administration/settings/rate_limit_on_organizations_api.md
@@ -0,0 +1,36 @@
+---
+stage: Data Stores
+group: Tenant Scale
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Rate limit on Organizations API
+
+DETAILS:
+**Tier:** Free, Premium, Ultimate
+**Offering:** GitLab.com, Self-managed
+**Status:** Experiment
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/470613) in GitLab 17.5 with a [flag](../feature_flags.md) named `allow_organization_creation`. Disabled by default. This feature is an [experiment](../../policy/experiment-beta-support.md).
+
+FLAG:
+The availability of this feature is controlled by a feature flag.
+For more information, see the history.
+
+Requests over the rate limit are logged into the `auth.log` file.
+
+For example, if you set a limit of 400 for `POST /organizations`, requests to the API endpoint that
+exceed a rate of 400 within one minute are blocked. Access to the endpoint is restored after one minute.
+
+You can configure the per minute rate limit per user for requests to the [POST /organizations API](../../api/organizations.md#create-organization). The default is 10.
+
+## Change the rate limit
+
+To change the rate limit:
+
+1. On the left sidebar, at the bottom, select **Admin**.
+1. Select **Settings > Network**.
+1. Expand **Organizations API rate limits**.
+1. Change the value of any rate limit. The rate limits are per minute per user.
+   To disable a rate limit, set the value to `0`.
+1. Select **Save changes**.
diff --git a/doc/api/organizations.md b/doc/api/organizations.md
new file mode 100644
index 0000000000000..68629c38c32af
--- /dev/null
+++ b/doc/api/organizations.md
@@ -0,0 +1,63 @@
+---
+stage: Data Stores
+group: Tenant Scale
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Organizations API
+
+DETAILS:
+**Tier:** Free, Premium, Ultimate
+**Offering:** GitLab.com, Self-managed
+**Status:** Experiment
+
+## Create organization
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/470613) in GitLab 17.5 with a [flag](../administration/feature_flags.md) named `allow_organization_creation`. Disabled by default. This feature is an [experiment](../policy/experiment-beta-support.md).
+
+FLAG:
+The availability of this feature is controlled by a feature flag.
+For more information, see the history.
+
+Creates a new organization.
+
+This endpoint is an [experiment](../policy/experiment-beta-support.md) and might be changed or removed without notice.
+
+```plaintext
+POST /organizations
+```
+
+Parameters:
+
+| Attribute     | Type   | Required | Description                           |
+|---------------|--------|----------|---------------------------------------|
+| `name`        | string | yes      | The name of the organization          |
+| `path`        | string | yes      | The path of the organization          |
+| `description` | string | no       | The description of the organization   |
+| `avatar`      | file   | no       | The avatar image for the organization |
+
+Example request:
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
+--form "name=New Organization" \
+--form "path=new-org" \
+--form "description=A new organization" \
+--form "avatar=@/path/to/avatar.png" \
+"https://gitlab.example.com/api/v4/organizations"
+```
+
+Example response:
+
+```json
+{
+  "id": 42,
+  "name": "New Organization",
+  "path": "new-org",
+  "description": "A new organization",
+  "created_at": "2024-09-18T02:35:15.371Z",
+  "updated_at": "2024-09-18T02:35:15.371Z",
+  "web_url": "https://gitlab.example.com/-/organizations/new-org",
+  "avatar_url": "https://gitlab.example.com/uploads/-/system/organizations/organization_detail/avatar/42/avatar.png"
+}
+```
diff --git a/doc/security/rate_limits.md b/doc/security/rate_limits.md
index 01f28ce93ce08..8c1bffee92dc1 100644
--- a/doc/security/rate_limits.md
+++ b/doc/security/rate_limits.md
@@ -53,6 +53,7 @@ You can set these rate limits in the **Admin** area of your instance:
 - [Incident management rate limits](../administration/settings/incident_management_rate_limits.md)
 - [Projects API rate limits](../administration/settings/rate_limit_on_projects_api.md)
 - [Groups API rate limits](../administration/settings/rate_limit_on_groups_api.md)
+- [Organizations API rate limits](../administration/settings/rate_limit_on_organizations_api.md)
 
 You can set these rate limits using the Rails console:
 
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 010f93cb143e1..03d9db45a10a7 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -305,6 +305,7 @@ def initialize(location_url)
         mount ::API::NpmProjectPackages
         mount ::API::NugetGroupPackages
         mount ::API::NugetProjectPackages
+        mount ::API::Organizations
         mount ::API::PackageFiles
         mount ::API::Pages
         mount ::API::PagesDomains
diff --git a/lib/api/entities/organizations/organization.rb b/lib/api/entities/organizations/organization.rb
new file mode 100644
index 0000000000000..f389bdfc76915
--- /dev/null
+++ b/lib/api/entities/organizations/organization.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    module Organizations
+      class Organization < Grape::Entity
+        expose :id, documentation: { type: 'integer', example: 1 }
+        expose :name, documentation: { type: 'string', example: 'GitLab' }
+        expose :path, documentation: { type: 'string', example: 'gitlab' }
+        expose :description, documentation: { type: 'string', example: 'My description' }
+        expose :created_at, documentation: { type: 'dateTime', example: '2022-02-24T20:22:30.097Z' }
+        expose :updated_at, documentation: { type: 'dateTime', example: '2022-02-24T20:22:30.097Z' }
+        expose :web_url, documentation: { type: "string", example: "https://example.com/-/organizations/gitlab" }
+        expose(:avatar_url, documentation: {
+          type: 'string',
+          example: 'https://example.com/uploads/-/system/organizations/organization_detail/avatar/1/avatar.png'
+        }) do |organization, _options|
+          organization.avatar_url(only_path: false)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index f72041dbd0060..709e0aac2e4eb 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -202,7 +202,7 @@ def check_pipeline_access(pipeline)
     end
 
     def find_organization!(id)
-      organization = Organizations::Organization.find_by_id(id)
+      organization = ::Organizations::Organization.find_by_id(id)
       check_organization_access(organization)
     end
 
diff --git a/lib/api/organizations.rb b/lib/api/organizations.rb
new file mode 100644
index 0000000000000..dbc15191e5f79
--- /dev/null
+++ b/lib/api/organizations.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module API
+  class Organizations < ::API::Base
+    feature_category :cell
+
+    before { authenticate! }
+
+    helpers do
+      def authorize_organization_creation!
+        authorize! :create_organization
+      end
+    end
+
+    resource :organizations do
+      desc 'Create an organization' do
+        detail 'This feature was introduced in GitLab 17.5. \
+                    This feature is currently in an experimental state. \
+                    This feature is behind the `allow_organization_creation` feature flag.'
+        success Entities::Organizations::Organization
+        tags %w[organizations]
+      end
+      params do
+        requires :name, type: String, desc: 'The name of the organization'
+        requires :path, type: String, desc: 'The path of the organization'
+        optional :description, type: String, desc: 'The description of the organization'
+        optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'The avatar image for the organization',
+          documentation: { type: 'file' }
+      end
+      post do
+        forbidden! unless Feature.enabled?(:allow_organization_creation, current_user)
+        check_rate_limit!(:create_organization_api, scope: current_user)
+        authorize_organization_creation!
+
+        response = ::Organizations::CreateService
+          .new(current_user: current_user, params: declared_params(include_missing: false))
+          .execute
+
+        if response.success?
+          present response[:organization], with: Entities::Organizations::Organization
+        else
+          render_api_error!(response.message, :bad_request)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb
index 8352f2b52d1d6..20fffe0e3ab99 100644
--- a/lib/gitlab/application_rate_limiter.rb
+++ b/lib/gitlab/application_rate_limiter.rb
@@ -34,6 +34,7 @@ def rate_limits # rubocop:disable Metrics/AbcSize
           group_projects_api: { threshold: -> { application_settings.group_projects_api_limit }, interval: 1.minute },
           groups_api: { threshold: -> { application_settings.groups_api_limit }, interval: 1.minute },
           project_api: { threshold: -> { application_settings.project_api_limit }, interval: 1.minute },
+          create_organization_api: { threshold: -> { application_settings.create_organization_api_limit }, interval: 1.minute },
           project_invited_groups_api: { threshold: -> { application_settings.project_invited_groups_api_limit }, interval: 1.minute },
           projects_api: { threshold: -> { application_settings.projects_api_limit }, interval: 10.minutes },
           user_contributed_projects_api: { threshold: -> { application_settings.user_contributed_projects_api_limit }, interval: 1.minute },
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f0af516ad771a..8901b107a1b42 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -33080,6 +33080,9 @@ msgstr ""
 msgid "Maximum requests to the %{api_name} API per %{timeframe} per IP address for unauthenticated requests"
 msgstr ""
 
+msgid "Maximum requests to the %{api_name} API per %{timeframe} per user"
+msgstr ""
+
 msgid "Maximum requests to the %{api_name} API per %{timeframe} per user for authenticated requests"
 msgstr ""
 
@@ -37988,6 +37991,9 @@ msgstr ""
 msgid "Organizations"
 msgstr ""
 
+msgid "Organizations API rate limits"
+msgstr ""
+
 msgid "Organization|%{linkStart}Organizations%{linkEnd} are a top-level container to hold your groups and projects."
 msgstr ""
 
@@ -51091,6 +51097,9 @@ msgstr ""
 msgid "Set the per-user rate limit for notes created by web or API requests."
 msgstr ""
 
+msgid "Set the per-user rate limits for the requests to Organizations API."
+msgstr ""
+
 msgid "Set this issue as blocked by %{target}."
 msgstr ""
 
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index da013289d1ca3..2ada78e36cccb 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -878,6 +878,20 @@
         end
       end
 
+      describe 'organizations API rate limits' do
+        let_it_be(:network_settings_section) { 'organizations-api-limits-settings' }
+
+        context 'for POST /organizations API requests' do
+          let(:rate_limit_field) do
+            format(_('Maximum requests to the %{api_name} API per %{timeframe} per user'), api_name: 'POST /organizations', timeframe: 'minute')
+          end
+
+          let(:application_setting_key) { :create_organization_api_limit }
+
+          it_behaves_like 'API rate limit setting'
+        end
+      end
+
       describe 'groups API rate limits' do
         let_it_be(:network_settings_section) { 'groups-api-limits-settings' }
 
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index e899bf5aaeeb7..d30d2125633e5 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -71,6 +71,7 @@
           group_shared_groups_api_limit
           group_invited_groups_api_limit
           project_invited_groups_api_limit
+          create_organization_api_limit
         ])
     end
 
diff --git a/spec/lib/api/entities/organizations/organization_spec.rb b/spec/lib/api/entities/organizations/organization_spec.rb
new file mode 100644
index 0000000000000..f0a38b7cd0a7a
--- /dev/null
+++ b/spec/lib/api/entities/organizations/organization_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Organizations::Organization, feature_category: :cell do
+  let(:avatar_url) { 'https://example.com/uploads/-/system/organizations/organization_detail/avatar/1/avatar.png' }
+  let(:organization) { build_stubbed(:organization) }
+
+  subject(:json) { described_class.new(organization).as_json }
+
+  before do
+    allow(organization).to receive(:avatar_url).with(only_path: false).and_return(avatar_url)
+  end
+
+  it 'exposes all the correct attributes' do
+    expect(json).to match_array(
+      id: organization.id,
+      name: organization.name,
+      path: organization.path,
+      description: organization.description,
+      created_at: organization.created_at,
+      updated_at: organization.updated_at,
+      web_url: organization.web_url,
+      avatar_url: avatar_url
+    )
+  end
+end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 4939021c83bc8..14497ee4a669e 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -41,6 +41,7 @@
     it { expect(setting.group_projects_api_limit).to eq(600) }
     it { expect(setting.group_shared_groups_api_limit).to eq(60) }
     it { expect(setting.groups_api_limit).to eq(200) }
+    it { expect(setting.create_organization_api_limit).to eq(10) }
     it { expect(setting.project_api_limit).to eq(400) }
     it { expect(setting.project_invited_groups_api_limit).to eq(60) }
     it { expect(setting.projects_api_limit).to eq(2000) }
@@ -237,6 +238,7 @@ def many_usernames(num = 100)
           package_registry_cleanup_policies_worker_capacity
           packages_cleanup_package_file_worker_capacity
           pipeline_limit_per_project_user_sha
+          create_organization_api_limit
           project_api_limit
           projects_api_limit
           projects_api_rate_limit_unauthenticated
diff --git a/spec/requests/api/organizations_spec.rb b/spec/requests/api/organizations_spec.rb
new file mode 100644
index 0000000000000..772521855223a
--- /dev/null
+++ b/spec/requests/api/organizations_spec.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Organizations, feature_category: :cell do
+  include WorkhorseHelpers
+
+  let(:user) { create(:user) }
+
+  shared_examples 'organization avatar upload' do
+    context 'when valid' do
+      let(:file_path) { 'spec/fixtures/banana_sample.gif' }
+
+      it 'returns avatar url in response' do
+        make_upload_request
+
+        organization_id = json_response['id']
+        avatar_url = "http://localhost/uploads/-/system/organizations/organization_detail/avatar/#{organization_id}/banana_sample.gif"
+        expect(json_response['avatar_url']).to eq(avatar_url)
+      end
+    end
+
+    context 'when invalid' do
+      shared_examples 'invalid file upload request' do
+        it 'returns 400', :aggregate_failures do
+          make_upload_request
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(response.message).to eq('Bad Request')
+          expect(json_response['message'].to_s).to match(/#{message}/)
+        end
+      end
+
+      context 'when file format is not supported' do
+        let(:file_path) { 'spec/fixtures/doc_sample.txt' }
+        let(:message) { 'file format is not supported. Please try one of the following supported formats: image/png' }
+
+        it_behaves_like 'invalid file upload request'
+      end
+
+      context 'when file is too large' do
+        let(:file_path) { 'spec/fixtures/big-image.png' }
+        let(:message)   { 'is too big' }
+
+        it_behaves_like 'invalid file upload request'
+      end
+    end
+  end
+
+  describe 'POST /organizations' do
+    let(:base_params) do
+      {
+        name: 'New Organization',
+        path: 'new-org',
+        description: 'A new organization'
+      }
+    end
+
+    let(:params) { base_params }
+
+    context 'when user is not authorized' do
+      it 'returns unauthorized' do
+        post api("/organizations"), params: params
+
+        expect(response).to have_gitlab_http_status(:unauthorized)
+      end
+    end
+
+    context 'when feature flag is disabled' do
+      before do
+        stub_feature_flags(allow_organization_creation: false)
+      end
+
+      it 'returns forbidden' do
+        post api("/organizations", user), params: params
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end
+    end
+
+    context 'when user is authorized' do
+      it_behaves_like 'organization avatar upload' do
+        def make_upload_request
+          params_with_file_upload = params.merge(avatar: fixture_file_upload(file_path))
+
+          workhorse_form_with_file(
+            api('/organizations', user),
+            method: :post,
+            file_key: :avatar,
+            params: params_with_file_upload
+          )
+        end
+      end
+
+      it_behaves_like 'rate limited endpoint', rate_limit_key: :create_organization_api do
+        let(:current_user) { user }
+
+        def request
+          post api("/organizations", user), params: params
+        end
+      end
+
+      shared_examples 'returns bad request' do
+        specify do
+          post api("/organizations", user), params: params
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+        end
+      end
+
+      it 'creates a new organization' do
+        post api("/organizations", user), params: params
+
+        expect(response).to have_gitlab_http_status(:success)
+        expect(json_response['name']).to eq('New Organization')
+        expect(json_response['path']).to eq('new-org')
+        expect(json_response['description']).to eq('A new organization')
+      end
+
+      context 'when optional params are missing' do
+        context 'with missing description' do
+          let(:params) { base_params.except(:description) }
+
+          it 'creates a new organization' do
+            post api("/organizations", user), params: params
+
+            expect(response).to have_gitlab_http_status(:success)
+            expect(json_response['name']).to eq('New Organization')
+            expect(json_response['path']).to eq('new-org')
+          end
+        end
+      end
+
+      context 'when required params are missing' do
+        context 'with missing name' do
+          let(:params) { base_params.except(:name) }
+
+          it_behaves_like 'returns bad request'
+        end
+
+        context 'with missing path' do
+          let(:params) { base_params.except(:path) }
+
+          it_behaves_like 'returns bad request'
+        end
+      end
+
+      context 'when organization creation fails' do
+        it 'returns an error message' do
+          message = _('Failed to create organization')
+          allow_next_instance_of(::Organizations::CreateService) do |service|
+            allow(service).to receive(:execute).and_return(ServiceResponse.error(message: Array(message)))
+          end
+
+          post api("/organizations", user), params: params
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(json_response['message']).to match_array(message)
+        end
+      end
+
+      context 'when organization creation is disable by admin' do
+        before do
+          stub_application_setting(can_create_organization: false)
+        end
+
+        it 'returns forbidden' do
+          post api("/organizations", user), params: params
+
+          expect(response).to have_gitlab_http_status(:forbidden)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/views/admin/application_settings/network.html.haml_spec.rb b/spec/views/admin/application_settings/network.html.haml_spec.rb
index 7ce94a24e15be..25995fb6929cc 100644
--- a/spec/views/admin/application_settings/network.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/network.html.haml_spec.rb
@@ -22,7 +22,7 @@
   end
 
   context 'for Projects API rate limits' do
-    it 'renders the `projects_api_rate_limit_unauthenticated` field' do
+    it 'renders the project rate limit fields' do
       render
 
       expect(rendered).to have_field('application_setting_projects_api_rate_limit_unauthenticated')
@@ -35,7 +35,7 @@
   end
 
   context 'for Groups API rate limits' do
-    it 'renders the `projects_api_rate_limit_unauthenticated` field' do
+    it 'renders the group rate limit fields' do
       render
 
       expect(rendered).to have_field('application_setting_groups_api_limit')
@@ -44,6 +44,14 @@
     end
   end
 
+  context 'for Organizations API rate limits' do
+    it 'renders the organization rate limit fields' do
+      render
+
+      expect(rendered).to have_field('application_setting_create_organization_api_limit')
+    end
+  end
+
   context 'for Members API rate limit' do
     it 'renders the `members_delete_limit` field' do
       render
diff --git a/workhorse/_support/lint_last_known_acceptable.txt b/workhorse/_support/lint_last_known_acceptable.txt
index d3f4a8c20921c..0f76c6a833fcd 100644
--- a/workhorse/_support/lint_last_known_acceptable.txt
+++ b/workhorse/_support/lint_last_known_acceptable.txt
@@ -171,8 +171,8 @@ internal/upload/destination/objectstore/uploader.go:5:2: G501: Blocklisted impor
 internal/upload/destination/objectstore/uploader.go:95:12: G401: Use of weak cryptographic primitive (gosec)
 internal/upload/exif/exif.go:103:10: G204: Subprocess launched with variable (gosec)
 internal/upstream/routes.go:170:74: `(*upstream).wsRoute` - `matchers` always receives `nil` (unparam)
-internal/upstream/routes.go:230: Function 'configureRoutes' is too long (333 > 60) (funlen)
-internal/upstream/routes.go:479: internal/upstream/routes.go:479: Line contains TODO/BUG/FIXME/NOTE/OPTIMIZE/HACK: "TODO: We should probably not return a HT..." (godox)
+internal/upstream/routes.go:230: Function 'configureRoutes' is too long (339 > 60) (funlen)
+internal/upstream/routes.go:485: internal/upstream/routes.go:485: Line contains TODO/BUG/FIXME/NOTE/OPTIMIZE/HACK: "TODO: We should probably not return a HT..." (godox)
 internal/upstream/upstream.go:116: internal/upstream/upstream.go:116: Line contains TODO/BUG/FIXME/NOTE/OPTIMIZE/HACK: "TODO: move to LabKit https://gitlab.com/..." (godox)
 internal/zipartifacts/metadata.go:118:54: G115: integer overflow conversion int -> uint32 (gosec)
 internal/zipartifacts/open_archive.go:78:28: response body must be closed (bodyclose)
diff --git a/workhorse/cmd/gitlab-workhorse/upload_test.go b/workhorse/cmd/gitlab-workhorse/upload_test.go
index 9dee6019136ac..c1fc2f6fb951e 100644
--- a/workhorse/cmd/gitlab-workhorse/upload_test.go
+++ b/workhorse/cmd/gitlab-workhorse/upload_test.go
@@ -149,6 +149,8 @@ func TestAcceleratedUpload(t *testing.T) {
 		{"POST", `/api/graphql`, false},
 		{"POST", `/api/v4/topics`, false},
 		{"PUT", `/api/v4/topics`, false},
+		{"POST", `/api/v4/organizations`, false},
+		{"PUT", `/api/v4/organizations/1`, false},
 		{"POST", `/api/v4/groups`, false},
 		{"PUT", `/api/v4/groups/5`, false},
 		{"PUT", `/api/v4/groups/group%2Fsubgroup`, false},
diff --git a/workhorse/internal/upstream/routes.go b/workhorse/internal/upstream/routes.go
index 74c4918e86a8d..d9b0de104b245 100644
--- a/workhorse/internal/upstream/routes.go
+++ b/workhorse/internal/upstream/routes.go
@@ -430,6 +430,12 @@ func configureRoutes(u *upstream) {
 		u.route("PUT",
 			newRoute(apiPattern+`v4/groups/[^/]+\z`, "api_groups", railsBackend), tempfileMultipartProxy),
 
+		// Organization Avatar
+		u.route("POST",
+			newRoute(apiPattern+`v4/organizations\z`, "api_organizations", railsBackend), tempfileMultipartProxy),
+		u.route("PUT",
+			newRoute(apiPattern+`v4/organizations/[0-9]+\z`, "api_organizations", railsBackend), tempfileMultipartProxy),
+
 		// User Avatar
 		u.route("PUT",
 			newRoute(apiPattern+`v4/user/avatar\z`, "api_user_avatar", railsBackend), tempfileMultipartProxy),
-- 
GitLab