diff --git a/Gemfile b/Gemfile
index 87d970ababb2e3dd95b891f450071eddf3b164d9..f0b33c5dd0e775d28955a77b4c6a6e442c11affb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -566,6 +566,7 @@ gem 'lockbox', '~> 1.1.1'
 gem 'valid_email', '~> 0.1'
 
 # JSON
+gem 'jsonb_accessor', '~> 1.3.10'
 gem 'json', '~> 2.6.3'
 gem 'json_schemer', '~> 0.2.18'
 gem 'oj', '~> 3.13.21'
diff --git a/Gemfile.checksum b/Gemfile.checksum
index db8b20f6e49398ef0d5cf91e107ebda561b1980a..ea6d7716c1e3fd23211797d08234d39fa674fc4b 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -316,6 +316,8 @@
 {"name":"json","version":"2.6.3","platform":"ruby","checksum":"86aaea16adf346a2b22743d88f8dcceeb1038843989ab93cda44b5176c845459"},
 {"name":"json-jwt","version":"1.15.3","platform":"ruby","checksum":"66db4f14e538a774c15502a5b5b26b1f3e7585481bbb96df490aa74b5c2d6110"},
 {"name":"json_schemer","version":"0.2.18","platform":"ruby","checksum":"3362c21efbefdd12ce994e541a1e7fdb86fd267a6541dd8715e8a580fe3b6be6"},
+{"name":"jsonb_accessor","version":"1.3.10","platform":"java","checksum":"6630ac69dac46457b03e1352178ed3e2d7ba2d8edb99f2e9b64a0e60cda9ed26"},
+{"name":"jsonb_accessor","version":"1.3.10","platform":"ruby","checksum":"670f80a257ae39e3be9233c6a8ef3b03517e06687affe510dfe61237454c58e0"},
 {"name":"jsonpath","version":"1.1.2","platform":"ruby","checksum":"6804124c244d04418218acb85b15c7caa79c592d7d6970195300428458946d3a"},
 {"name":"jwt","version":"2.5.0","platform":"ruby","checksum":"b835fe55287572e1f65128d6c12d3ff7402bb4652c4565bf3ecdcb974db7954d"},
 {"name":"kaminari","version":"1.2.2","platform":"ruby","checksum":"c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 6a704144a5575991beaa317c0821646c2a9d1673..3a10ac441fb117f62dae5dd6b1042b9ee090aecc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -863,6 +863,10 @@ GEM
       hana (~> 1.3)
       regexp_parser (~> 2.0)
       uri_template (~> 0.7)
+    jsonb_accessor (1.3.10)
+      activerecord (>= 5.0)
+      activesupport (>= 5.0)
+      pg (>= 0.18.1)
     jsonpath (1.1.2)
       multi_json
     jwt (2.5.0)
@@ -1822,6 +1826,7 @@ DEPENDENCIES
   js_regex (~> 3.8)
   json (~> 2.6.3)
   json_schemer (~> 0.2.18)
+  jsonb_accessor (~> 1.3.10)
   jwt (~> 2.5)
   kaminari (~> 1.2.2)
   kas-grpc (~> 0.2.0)
diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb
index ce89f57a73bf9a6e5354657972a1dce13f4d89e4..7c3ba4bf50cb30f2cec0dfd9bf8e9182ea4b3578 100644
--- a/app/models/organizations/organization.rb
+++ b/app/models/organizations/organization.rb
@@ -8,6 +8,8 @@ class Organization < ApplicationRecord
 
     before_destroy :check_if_default_organization
 
+    has_one :settings, class_name: "OrganizationSetting"
+
     validates :name,
       presence: true,
       length: { maximum: 255 }
diff --git a/app/models/organizations/organization_setting.rb b/app/models/organizations/organization_setting.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5a5ace0cc061af30de1eea92235f8b261e6a4170
--- /dev/null
+++ b/app/models/organizations/organization_setting.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Organizations
+  class OrganizationSetting < ApplicationRecord
+    belongs_to :organization
+
+    jsonb_accessor :settings,
+      restricted_visibility_levels: [:integer, { array: true }]
+
+    validates_each :restricted_visibility_levels do |record, attr, value|
+      value&.each do |level|
+        unless Gitlab::VisibilityLevel.options.value?(level)
+          record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level))
+        end
+      end
+    end
+  end
+end
diff --git a/db/docs/organization_settings.yml b/db/docs/organization_settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..669aafc9ed7bf1a83ca492a0bfe470abef4d6bae
--- /dev/null
+++ b/db/docs/organization_settings.yml
@@ -0,0 +1,10 @@
+---
+table_name: organization_settings
+classes:
+- Organizations::OrganizationSetting
+feature_categories:
+- cell
+description: Settings related to Organizations
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123380
+milestone: '16.2'
+gitlab_schema: gitlab_main
diff --git a/db/migrate/20230607124754_create_organization_settings.rb b/db/migrate/20230607124754_create_organization_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..15d3fa3159fbd9e06416f9dd0502b1b6ccc8fa56
--- /dev/null
+++ b/db/migrate/20230607124754_create_organization_settings.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CreateOrganizationSettings < Gitlab::Database::Migration[2.1]
+  enable_lock_retries!
+
+  def change
+    create_table :organization_settings, id: false do |t|
+      t.references :organization, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
+      t.timestamps_with_timezone null: false
+      t.jsonb :settings, default: {}, null: false
+    end
+  end
+end
diff --git a/db/schema_migrations/20230607124754 b/db/schema_migrations/20230607124754
new file mode 100644
index 0000000000000000000000000000000000000000..2c49744ec26ac7a2e0502105b7ea9a99d4ba0756
--- /dev/null
+++ b/db/schema_migrations/20230607124754
@@ -0,0 +1 @@
+3aca70a09ce278454f38740817bba4e88501b8e68d719b9ef3f922cb09b7c7d3
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index ebd2c9a0424b4f952bb09d788a69c59d8d63de8c..600e2bfb47539363f787615d87689485696ddde5 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -19235,6 +19235,13 @@ CREATE SEQUENCE operations_user_lists_id_seq
 
 ALTER SEQUENCE operations_user_lists_id_seq OWNED BY operations_user_lists.id;
 
+CREATE TABLE organization_settings (
+    organization_id bigint NOT NULL,
+    created_at timestamp with time zone NOT NULL,
+    updated_at timestamp with time zone NOT NULL,
+    settings jsonb DEFAULT '{}'::jsonb NOT NULL
+);
+
 CREATE TABLE organizations (
     id bigint NOT NULL,
     created_at timestamp with time zone NOT NULL,
@@ -27788,6 +27795,9 @@ ALTER TABLE ONLY operations_strategies_user_lists
 ALTER TABLE ONLY operations_user_lists
     ADD CONSTRAINT operations_user_lists_pkey PRIMARY KEY (id);
 
+ALTER TABLE ONLY organization_settings
+    ADD CONSTRAINT organization_settings_pkey PRIMARY KEY (organization_id);
+
 ALTER TABLE ONLY organizations
     ADD CONSTRAINT organizations_pkey PRIMARY KEY (id);
 
@@ -37516,6 +37526,9 @@ ALTER TABLE ONLY boards_epic_board_recent_visits
 ALTER TABLE ONLY ci_job_artifacts
     ADD CONSTRAINT fk_rails_c5137cb2c1_p FOREIGN KEY (partition_id, job_id) REFERENCES p_ci_builds(partition_id, id) ON UPDATE CASCADE ON DELETE CASCADE;
 
+ALTER TABLE ONLY organization_settings
+    ADD CONSTRAINT fk_rails_c56e4690c0 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY project_settings
     ADD CONSTRAINT fk_rails_c6df6e6328 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
 
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 4ec6d3ad4f573d711b07219d6c3b8e6c9399c8fe..5716b9fd012e14cd0a9c0de094e61734a6de44b7 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -244,6 +244,7 @@
     "GeoNodeStatus" => %w[status],
     "Operations::FeatureFlagScope" => %w[strategies],
     "Operations::FeatureFlags::Strategy" => %w[parameters],
+    "Organizations::OrganizationSetting" => %w[settings], # Custom validations
     "Packages::Composer::Metadatum" => %w[composer_json],
     "RawUsageData" => %w[payload], # Usage data payload changes often, we cannot use one schema
     "Releases::Evidence" => %w[summary],
diff --git a/spec/factories/organizations/organization_settings.rb b/spec/factories/organizations/organization_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ad4715ee653ff68b520c84f5597b6ba0b47935a5
--- /dev/null
+++ b/spec/factories/organizations/organization_settings.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :organization_setting, class: 'Organizations::OrganizationSetting' do
+    organization { association(:organization) }
+  end
+end
diff --git a/spec/models/organizations/organization_setting_spec.rb b/spec/models/organizations/organization_setting_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0dd379743f4a09d91826249cc288822154b9e295
--- /dev/null
+++ b/spec/models/organizations/organization_setting_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Organizations::OrganizationSetting, type: :model, feature_category: :cell do
+  let_it_be(:organization) { create(:organization) }
+
+  describe 'associations' do
+    it { is_expected.to belong_to :organization }
+  end
+
+  describe 'validations' do
+    context 'when setting restricted_visibility_levels' do
+      it 'is one or more of Gitlab::VisibilityLevel constants' do
+        setting = build(:organization_setting)
+
+        setting.restricted_visibility_levels = [123]
+
+        expect(setting.valid?).to be false
+        expect(setting.errors.full_messages).to include(
+          "Restricted visibility levels '123' is not a valid visibility level"
+        )
+
+        setting.restricted_visibility_levels = [Gitlab::VisibilityLevel::PUBLIC, Gitlab::VisibilityLevel::PRIVATE,
+          Gitlab::VisibilityLevel::INTERNAL]
+        expect(setting.valid?).to be true
+      end
+    end
+  end
+end