diff --git a/reviewboard/accounts/templatetags/accounts.py b/reviewboard/accounts/templatetags/accounts.py
index e042069f0d15252a793bab18c00ce63933d88703..cb61823657da764e6481c6fc2ee2ee82bf358ec5 100644
--- a/reviewboard/accounts/templatetags/accounts.py
+++ b/reviewboard/accounts/templatetags/accounts.py
@@ -78,6 +78,7 @@ def js_user_session_info(context):
         avatar_html = {}
 
         info.update({
+            'allowSelfShipIt': siteconfig.get('reviews_allow_self_shipit'),
             'fullName': user.get_full_name() or username,
             'readOnly': is_site_read_only_for(user),
             'username': username,
diff --git a/reviewboard/accounts/tests/test_template_tags.py b/reviewboard/accounts/tests/test_template_tags.py
index b090496b4b633c9ce291b8eb28fa277f0f049632..27006040db506f55f63897709091fb6619e1752c 100644
--- a/reviewboard/accounts/tests/test_template_tags.py
+++ b/reviewboard/accounts/tests/test_template_tags.py
@@ -53,6 +53,7 @@ class JSUserSessionInfoTests(TestCase):
         self.assertJSONEqual(
             self._render_tag(self.user),
             {
+                'allowSelfShipIt': False,
                 'archivedReviewRequestsURL':
                     '/api/users/test/archived-review-requests/',
                 'authenticated': True,
@@ -118,6 +119,7 @@ class JSUserSessionInfoTests(TestCase):
         self.assertJSONEqual(
             self._render_tag(self.user, local_site=local_site),
             {
+                'allowSelfShipIt': False,
                 'archivedReviewRequestsURL':
                     '/s/local-site-1/api/users/test/archived-review-requests/',
                 'authenticated': True,
@@ -176,6 +178,7 @@ class JSUserSessionInfoTests(TestCase):
             self.assertJSONEqual(
                 self._render_tag(self.user),
                 {
+                    'allowSelfShipIt': False,
                     'archivedReviewRequestsURL':
                         '/api/users/test/archived-review-requests/',
                     'authenticated': True,
@@ -229,6 +232,7 @@ class JSUserSessionInfoTests(TestCase):
             self.assertJSONEqual(
                 self._render_tag(self.user),
                 {
+                    'allowSelfShipIt': False,
                     'archivedReviewRequestsURL':
                         '/api/users/test/archived-review-requests/',
                     'authenticated': True,
diff --git a/reviewboard/actions/base.py b/reviewboard/actions/base.py
index 42404a3b759350f4c0f4b8b23f7480a157b71235..0edf8134d4dddb1a99e323c9626deccd8fc0939d 100644
--- a/reviewboard/actions/base.py
+++ b/reviewboard/actions/base.py
@@ -362,7 +362,7 @@ class BaseAction:
         *,
         request: HttpRequest,
         context: Context,
-    ) -> dict:
+    ) -> dict[str, Any]:
         """Return extra template context for the action.
 
         Subclasses can override this to provide additional context needed by
diff --git a/reviewboard/actions/templatetags/actions.py b/reviewboard/actions/templatetags/actions.py
index e5971f533bb638a742d928c8c83d44c395668508..89a623574b2fac70a0942f226946f59a3a23260c 100644
--- a/reviewboard/actions/templatetags/actions.py
+++ b/reviewboard/actions/templatetags/actions.py
@@ -4,9 +4,11 @@ Version Added:
     6.0
 """
 
+from __future__ import annotations
+
 import json
 import logging
-from typing import Iterable, List
+from typing import Iterable
 
 from django.template import Context, Library
 from django.utils.safestring import SafeText, mark_safe
@@ -39,7 +41,7 @@ def actions_html(
     request = context['request']
     actions: Iterable[BaseAction] = \
         actions_registry.get_for_attachment(attachment)
-    rendered: List[str] = []
+    rendered: list[str] = []
 
     for action in actions:
         try:
@@ -70,7 +72,7 @@ def child_actions_html(
     """
     request = context['request']
     actions: Iterable[BaseAction] = context['children']
-    rendered: List[str] = []
+    rendered: list[str] = []
 
     for child in actions:
         try:
@@ -101,7 +103,7 @@ def actions_js(
     """
     request = context['request']
     actions: Iterable[BaseAction] = actions_registry
-    rendered: List[str] = []
+    rendered: list[str] = []
 
     for action in actions:
         try:
diff --git a/reviewboard/admin/forms/review_settings.py b/reviewboard/admin/forms/review_settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb8dd21bad168581d66f2e9fd3c6f5eb05dcdf9e
--- /dev/null
+++ b/reviewboard/admin/forms/review_settings.py
@@ -0,0 +1,27 @@
+"""Administration form for review workflow."""
+
+from __future__ import annotations
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+from djblets.siteconfig.forms import SiteSettingsForm
+
+
+class ReviewSettingsForm(SiteSettingsForm):
+    """Review workflow settings for Review Board."""
+
+    css_bundle_names = ['djblets-forms']
+    js_bundle_names = ['djblets-forms']
+
+    reviews_allow_self_shipit = forms.BooleanField(
+        label=_('Allow users to mark "Ship It!" on their own review requests'),
+        help_text=_(
+            'If selected, users will be allowed to mark their own review '
+            'requests as "Ship It!".'),
+        required=False,
+    )
+
+    class Meta:
+        """Metadata for the form."""
+
+        title = _('Review Workflow Settings')
diff --git a/reviewboard/admin/siteconfig.py b/reviewboard/admin/siteconfig.py
index b758e04085b632c94da7b12a2b0e4b05de313201..2c1052281d3130d08b906d0289ae86d2f4c2787c 100644
--- a/reviewboard/admin/siteconfig.py
+++ b/reviewboard/admin/siteconfig.py
@@ -102,6 +102,14 @@ defaults.update(log_siteconfig.defaults)
 defaults.update(recaptcha_siteconfig.defaults)
 defaults.update(avatar_services.get_siteconfig_defaults())
 defaults.update({
+    # General server settings
+    'company': '',
+    'default_ui_theme': 'default',
+    'send_support_usage_stats': True,
+    'site_domain_method': 'http',
+    'site_read_only': False,
+
+    # Authentication settings
     'auth_ldap_anon_bind_uid': '',
     'auth_ldap_anon_bind_passwd': '',
     'auth_ldap_email_domain': '',
@@ -117,10 +125,15 @@ defaults.update({
     'auth_x509_username_field': 'SSL_CLIENT_S_DN_CN',
     'auth_x509_username_regex': '',
     'auth_x509_autocreate_users': False,
-    'code_safety_checkers': {},
-    'company': '',
-    'default_ui_theme': 'default',
+    'client_web_login': True,
+    'sso_auto_login_backend': '',
+
+    # Reviews settings
     'default_use_rich_text': True,
+    'reviews_allow_self_shipit': False,
+
+    # Diff Viewer settings
+    'code_safety_checkers': {},
     'diffviewer_context_num_lines': 5,
     'diffviewer_include_space_patterns': [],
     'diffviewer_max_binary_size': 10_485_760,
@@ -131,37 +144,37 @@ defaults.update({
     'diffviewer_syntax_highlighting_threshold': 20_000,
     'diffviewer_custom_pygments_lexers': {'.less': 'LessCss'},
     'diffviewer_show_trailing_whitespace': True,
+
+    # E-mail settings
     'mail_send_review_mail': False,
     'mail_send_new_user_mail': False,
     'mail_send_password_changed_mail': False,
     'mail_enable_autogenerated_header': True,
     'mail_from_spoofing': EmailMessage.FROM_SPOOFING_SMART,
-    'search_enable': False,
-    'send_support_usage_stats': True,
-    'site_domain_method': 'http',
-    'site_read_only': False,
-    'sso_auto_login_backend': '',
-    'client_web_login': True,
 
     # The number of days in which client API tokens should expire
     # after creation.
     'client_token_expiration': 365,
 
+    # Privacy settings
     'privacy_enable_user_consent': False,
     'privacy_info_html': None,
     'privacy_policy_url': None,
     'terms_of_service_url': None,
 
+    # Search settings.
+    'search_enable': False,
     'search_results_per_page': 20,
     'search_backend_id': WhooshBackend.search_backend_id,
     'search_backend_settings': {},
     'search_on_the_fly_indexing': False,
 
     # Overwrite this.
-    'site_media_url': settings.SITE_ROOT + "media/",
+    'site_media_url': f'{settings.SITE_ROOT}media/',
 })
 
 defaults.update({
+    # Amazon S3 file storage
     'aws_access_key_id': '',
     'aws_calling_format': 2,
     'aws_default_acl': 'public-read',
@@ -172,8 +185,12 @@ defaults.update({
     'aws_s3_bucket_name': '',
     'aws_s3_secure_urls': False,
     'aws_secret_access_key': '',
+
+    # CouchDB file storage
     'couchdb_default_server': '',
     'couchdb_storage_options': {},
+
+    # OpenStack Swift file storage
     'swift_auth_url': '',
     'swift_auth_version': '1',
     'swift_container_name': '',
diff --git a/reviewboard/admin/urls.py b/reviewboard/admin/urls.py
index 15f9b9a28bc348dcdfb4a1e7cb5af04aeb303816..d4714563e1c14bfd39fd139aaba95e5dddb3f875 100644
--- a/reviewboard/admin/urls.py
+++ b/reviewboard/admin/urls.py
@@ -35,6 +35,7 @@ from reviewboard.admin.forms.email_settings import EMailSettingsForm
 from reviewboard.admin.forms.general_settings import GeneralSettingsForm
 from reviewboard.admin.forms.logging_settings import LoggingSettingsForm
 from reviewboard.admin.forms.privacy_settings import PrivacySettingsForm
+from reviewboard.admin.forms.review_settings import ReviewSettingsForm
 from reviewboard.admin.forms.search_settings import SearchSettingsForm
 from reviewboard.admin.forms.storage_settings import StorageSettingsForm
 from reviewboard.admin.forms.support_settings import SupportSettingsForm
@@ -85,6 +86,13 @@ urlpatterns = [
              },
              name='settings-email'),
 
+        path('reviews/',
+             views.site_settings,
+             kwargs={
+                'form_class': ReviewSettingsForm,
+             },
+             name='settings-reviews'),
+
         path('diffs/',
              views.site_settings,
              kwargs={
diff --git a/reviewboard/reviews/actions.py b/reviewboard/reviews/actions.py
index c4657ab3186c7b7c1242db0e717e73957ece42d4..3f5a64089c6c878a73ab6b389249a7d4fc96e405 100644
--- a/reviewboard/reviews/actions.py
+++ b/reviewboard/reviews/actions.py
@@ -7,6 +7,7 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Union
 from django.http import HttpRequest
 from django.template import Context
 from django.utils.translation import gettext_lazy as _
+from djblets.siteconfig.models import SiteConfiguration
 
 from reviewboard.actions import (AttachmentPoint,
                                  BaseAction,
@@ -396,6 +397,34 @@ class ShipItAction(BaseAction):
     js_view_class = 'RB.ShipItActionView'
     template_name = 'actions/detailed_menuitem_action.html'
 
+    def should_render(
+        self,
+        *,
+        context: Context,
+    ) -> bool:
+        """Return whether this action should render.
+
+        Args:
+            context (django.template.Context):
+                The current rendering context.
+
+        Returns:
+            bool:
+            ``True`` if the action should render.
+        """
+        request = context['request']
+        user = request.user
+        review_request = context.get('review_request')
+        siteconfig = SiteConfiguration.objects.get_current()
+
+        return (
+            super().should_render(context=context) and
+            not is_site_read_only_for(user) and
+            review_request is not None and
+            (user.pk != review_request.submitter_id or
+             siteconfig.get('reviews_allow_self_shipit'))
+        )
+
 
 class LegacyAddGeneralCommentAction(BaseAction):
     """The action for adding a general comment.
diff --git a/reviewboard/reviews/models/review.py b/reviewboard/reviews/models/review.py
index 71d07d5dd8561cf87e342ad7ca2d82fccafa40a1..7ecfcac390d9765e77fcb1cd6b6f8699d37ad713 100644
--- a/reviewboard/reviews/models/review.py
+++ b/reviewboard/reviews/models/review.py
@@ -3,16 +3,16 @@
 from __future__ import annotations
 
 import logging
-from typing import ClassVar, Optional, Tuple
+from typing import ClassVar, Optional, TYPE_CHECKING
 
 from django.contrib.auth.models import User
 from django.db import models
 from django.db.models import Q
-from django.http import HttpRequest
 from django.utils import timezone
 from django.utils.functional import cached_property
 from django.utils.translation import gettext, gettext_lazy as _
 from djblets.db.fields import CounterField, JSONField
+from djblets.siteconfig.models import SiteConfiguration
 
 from reviewboard.diffviewer.models import DiffSet
 from reviewboard.reviews.errors import PublishError, RevokeShipItError
@@ -30,6 +30,9 @@ from reviewboard.reviews.signals import (reply_publishing, reply_published,
                                          review_ship_it_revoking,
                                          review_ship_it_revoked)
 
+if TYPE_CHECKING:
+    from django.http import HttpRequest
+
 
 logger = logging.getLogger(__name__)
 
@@ -363,7 +366,7 @@ class Review(models.Model):
     def can_publish(
         self,
         review_request_will_publish: bool = False,
-    ) -> Tuple[bool, Optional[str]]:
+    ) -> tuple[bool, Optional[str]]:
         """Check if this review can be published.
 
         Args:
@@ -417,6 +420,14 @@ class Review(models.Model):
                         'a comment on a file attachment which has not yet '
                         'been published.')
 
+        siteconfig = SiteConfiguration.objects.get_current()
+
+        if (self.ship_it and
+            self.user_id == self.review_request.submitter_id and
+            not siteconfig.get('reviews_allow_self_shipit')):
+            return False, gettext(
+                'You cannot mark "Ship It!" on your own review request')
+
         return True, None
 
     def publish(
diff --git a/reviewboard/static/rb/js/common/models/userSessionModel.ts b/reviewboard/static/rb/js/common/models/userSessionModel.ts
index 58075eaf84d5649c783efa817343daf0578e5314..854b3e127f42953066c18b1f9aaf68540621e116 100644
--- a/reviewboard/static/rb/js/common/models/userSessionModel.ts
+++ b/reviewboard/static/rb/js/common/models/userSessionModel.ts
@@ -243,17 +243,37 @@ class StoredItems extends BaseResource<StoredItemsAttrs> {
 
 /** Attributes for the UserSession model. */
 interface UserSessionAttrs extends ModelAttributes {
+    /**
+     * Whether to allow users to mark "Ship It" on their own review requests.
+     */
+    allowSelfShipIt: boolean;
+
     /** The URL for the archived review requests API resource. */
     archivedReviewRequestsURL: string;
 
     /** Whether the user is currently authenticated. */
     authenticated: boolean;
 
+    /** HTML to use for avatars at different sizes. */
+    avatarHTML: Record<number, string>;
+
+    /** URLs for avatars at various sizes and pixel densities. */
+    avatarURLs: Record<number, Record<string, string>>;
+
+    /** Whether to open an issue by default */
+    commentsOpenAnIssue: boolean;
+
+    /** Whether to use rich text by default. */
+    defaultUseRichText: boolean;
+
     /**
      * Whether the user wants to see diffs with excess whitespace highlighted.
      */
     diffsShowExtraWhitespace: boolean;
 
+    /** Whether to enable desktop notifications. */
+    enableDesktopNotifications: boolean;
+
     /** The user's full name. */
     fullName: string;
 
@@ -338,9 +358,15 @@ export class UserSession extends BaseModel<UserSessionAttrs> {
     }
 
     defaults: UserSessionAttrs = {
+        allowSelfShipIt: false,
         archivedReviewRequestsURL: null,
         authenticated: false,
+        avatarHTML: {},
+        avatarURLs: {},
+        commentsOpenAnIssue: true,
+        defaultUseRichText: false,
         diffsShowExtraWhitespace: false,
+        enableDesktopNotifications: false,
         fullName: null,
         loginURL: null,
         mutedReviewRequestsURL: null,
diff --git a/reviewboard/static/rb/js/reviews/views/reviewDialogView.ts b/reviewboard/static/rb/js/reviews/views/reviewDialogView.ts
index 188c395187bdabd3ee336a314f1652f15d582193..5893a022063267fcca812e370bc78e95f2e57aa5 100644
--- a/reviewboard/static/rb/js/reviews/views/reviewDialogView.ts
+++ b/reviewboard/static/rb/js/reviews/views/reviewDialogView.ts
@@ -1068,10 +1068,12 @@ export class ReviewDialogView extends BaseView<
     static className = 'review';
 
     static template = _.template(dedent`
-        <div class="edit-field">
-         <input id="id_shipit" type="checkbox">
-         <label for="id_shipit"><%- shipItText %></label>
-        </div>
+        <% if (allowShipIt) { %>
+         <div class="edit-field">
+          <input id="id_shipit" type="checkbox">
+          <label for="id_shipit"><%- shipItText %></label>
+         </div>
+        <% } %>
         <div class="review-dialog-hooks-container"></div>
         <div class="edit-field body-top"></div>
         <ol id="review-dialog-body-top-comments" class="review-comments"></ol>
@@ -1343,9 +1345,18 @@ export class ReviewDialogView extends BaseView<
      * the server will begin loading and rendering.
      */
     protected onInitialRender() {
+        const userSession = UserSession.instance;
+
+        const reviewRequest = this.model.get('parentObject');
+        const allowShipIt = (
+            userSession.get('allowSelfShipIt') ||
+            (userSession.get('username') !==
+             reviewRequest.get('links')['submitter']['title']));
+
         this.$el.html(this.template({
             addFooterText: _`Add footer`,
             addHeaderText: _`Add header`,
+            allowShipIt: allowShipIt,
             markdownDocsURL: MANUAL_URL + 'users/markdown/',
             markdownText: _`Markdown Reference`,
             shipItText: _`Ship It`,
@@ -1371,7 +1382,7 @@ export class ReviewDialogView extends BaseView<
             .prependTo(this.$el);
 
         this.listenTo(this.#tipsView, 'hide', () => {
-            UserSession.instance.set('showReviewDialogTips', false);
+            userSession.set('showReviewDialogTips', false);
             this.#updateTipsVisibility(false);
         });
 
@@ -1384,11 +1395,11 @@ export class ReviewDialogView extends BaseView<
                 e.preventDefault();
 
                 this.#updateTipsVisibility(true);
-                UserSession.instance.set('showReviewDialogTips', true);
+                userSession.set('showReviewDialogTips', true);
             });
 
         this.#updateTipsVisibility(
-            UserSession.instance.get('showReviewDialogTips'));
+            userSession.get('showReviewDialogTips'));
 
         RB.ReviewDialogHook.each(hook => {
             const HookView = hook.get('viewType');
diff --git a/reviewboard/templates/admin/sidebar.html b/reviewboard/templates/admin/sidebar.html
index c1862c6591e27625072ee8bfdda0f98a06454e05..6de551c3d527e9f53c6a909b55a6028a51f1d08e 100644
--- a/reviewboard/templates/admin/sidebar.html
+++ b/reviewboard/templates/admin/sidebar.html
@@ -18,6 +18,7 @@
 {% admin_subnav "settings-authentication" _("Authentication") %}
 {% admin_subnav "settings-avatars" _("Avatars") %}
 {% admin_subnav "settings-email" _("E-Mail") %}
+{% admin_subnav "settings-reviews" _("Review Workflow") %}
 {% admin_subnav "settings-diffs" _("Diff Viewer") %}
 {% admin_subnav "settings-logging" _("Logging") %}
 {% admin_subnav "settings-ssh" _("SSH")  %}
diff --git a/reviewboard/webapi/tests/test_review.py b/reviewboard/webapi/tests/test_review.py
index 77a8b706ee28f5e2e57534f863ca34352adca98b..586560f41c43b924c9b420d884bd83352ade2ae8 100644
--- a/reviewboard/webapi/tests/test_review.py
+++ b/reviewboard/webapi/tests/test_review.py
@@ -1,4 +1,9 @@
+"""Unit tests for the ReviewResource API."""
+
+from __future__ import annotations
+
 from datetime import datetime, timedelta, timezone
+from typing import TYPE_CHECKING
 
 from django.core import mail
 from django.utils import timezone as django_timezone
@@ -11,7 +16,7 @@ from kgb import SpyAgency, spy_on
 
 from reviewboard.reviews.models import Review, ReviewRequest
 from reviewboard.reviews.signals import review_ship_it_revoking
-from reviewboard.webapi.errors import REVOKE_SHIP_IT_ERROR
+from reviewboard.webapi.errors import PUBLISH_ERROR, REVOKE_SHIP_IT_ERROR
 from reviewboard.webapi.resources import resources
 from reviewboard.webapi.tests.base import BaseWebAPITestCase
 from reviewboard.webapi.tests.mimetypes import (review_list_mimetype,
@@ -24,6 +29,9 @@ from reviewboard.webapi.tests.mixins_review import (ReviewItemMixin,
 from reviewboard.webapi.tests.urls import (get_review_item_url,
                                            get_review_list_url)
 
+if TYPE_CHECKING:
+    from djblets.util.typing import JSONDict
+
 
 class ResourceListTests(ReviewListMixin, ReviewRequestChildListMixin,
                         BaseWebAPITestCase, metaclass=BasicTestsMetaclass):
@@ -409,8 +417,7 @@ class ResourceItemTests(SpyAgency, ReviewItemMixin,
         revoke_timestamp = review_timestamp + timedelta(hours=1)
 
         with spy_on(django_timezone.now, call_fake=lambda: creation_timestamp):
-            review_request = self.create_review_request(publish=True,
-                                                        submitter=self.user)
+            review_request = self.create_review_request(publish=True)
 
         with spy_on(django_timezone.now, call_fake=lambda: review_timestamp):
             review = self.create_review(review_request,
@@ -443,7 +450,7 @@ class ResourceItemTests(SpyAgency, ReviewItemMixin,
         self.assertEqual(review.timestamp, review_timestamp)
 
     @add_fixtures(['test_site'])
-    def test_put_publish(self):
+    def test_put_publish(self) -> None:
         """Testing the PUT review-requests/<id>/reviews/<id>/?public=1 API"""
         body_top = "My Body Top"
         body_bottom = ""
@@ -481,3 +488,64 @@ class ResourceItemTests(SpyAgency, ReviewItemMixin,
             review_request.submitter.username,
             self.user.username,
         ])
+
+    @add_fixtures(['test_site'])
+    def test_put_publish_self_shipit_disallowed(self) -> None:
+        """Testing the PUT review-requests/<id>/reviews/<id>/?public=1 API
+        with a ship-it on a user's own review request when not allowed
+        """
+        body_top = "My Body Top"
+        body_bottom = ""
+        ship_it = True
+
+        review_request = self.create_review_request(
+            publish=True, submitter=self.user)
+        review = self.create_review(review_request, user=self.user)
+        settings: JSONDict = {
+            'reviews_allow_self_shipit': False,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.api_put(
+                get_review_item_url(review_request, review.pk),
+                {
+                    'public': True,
+                    'ship_it': ship_it,
+                    'body_top': body_top,
+                    'body_bottom': body_bottom,
+                },
+                expected_status=500)
+
+            assert rsp is not None
+            self.assertEqual(rsp['stat'], 'fail')
+            self.assertEqual(rsp['err']['code'], PUBLISH_ERROR.code)
+
+    @add_fixtures(['test_site'])
+    def test_put_publish_self_shipit_allowed(self) -> None:
+        """Testing the PUT review-requests/<id>/reviews/<id>/?public=1 API
+        with a ship-it on a user's own review request when allowed
+        """
+        body_top = "My Body Top"
+        body_bottom = ""
+        ship_it = True
+
+        review_request = self.create_review_request(
+            publish=True, submitter=self.user)
+        review = self.create_review(review_request, user=self.user)
+        settings: JSONDict = {
+            'reviews_allow_self_shipit': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.api_put(
+                get_review_item_url(review_request, review.pk),
+                {
+                    'public': True,
+                    'ship_it': ship_it,
+                    'body_top': body_top,
+                    'body_bottom': body_bottom,
+                },
+                expected_mimetype=review_item_mimetype)
+
+            assert rsp is not None
+            self.assertEqual(rsp['stat'], 'ok')
