Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("account_v2", "0006_organization_restrict_llm_adapter_creation"),
]

operations = [
migrations.AddField(
model_name="organization",
name="restrict_connector_creation",
field=models.BooleanField(
db_comment=(
"Controlled mode: when True, only organization admins may "
"create connectors in this org. Default False preserves "
"open creation."
),
default=False,
),
),
]
7 changes: 7 additions & 0 deletions backend/account_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ class Organization(models.Model):
"LLM adapters in this org. Default False preserves open creation."
),
)
restrict_connector_creation = models.BooleanField(
default=False,
db_comment=(
"Controlled mode: when True, only organization admins may create "
"connectors in this org. Default False preserves open creation."
),
)

class Meta:
verbose_name = "Organization"
Expand Down
35 changes: 35 additions & 0 deletions backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
from permissions.resource_share_views import ResourceShareManagementMixin
from plugins import get_plugin
from rest_framework import status, viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.filtering import FilterHelper
from utils.user_context import UserContext

from backend.constants import RequestKey
from connector_v2.constants import ConnectorInstanceKey as CIKey
Expand Down Expand Up @@ -44,6 +47,27 @@ def get_permissions(self) -> list[Any]:

return [IsOwnerOrSharedUserOrSharedToOrg()]

@staticmethod
def _enforce_connector_creation_restriction(request: Any) -> None:
"""Controlled mode (UN-3585): only org admins may create connectors
when the org has enabled the restriction. Service accounts (platform
API-key sessions) bypass; the default (flag off) keeps creation open
for everyone. Applies to all connector types (all connect to external
systems).
"""
if getattr(request.user, "is_service_account", False):
return
organization = UserContext.get_organization()
if (
organization
and organization.restrict_connector_creation
and not OrganizationMemberService.is_user_organization_admin(request.user)
):
raise PermissionDenied(
"Connector creation is restricted to organization admins. "
"Please contact your organization admin."
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def get_queryset(self) -> QuerySet | None:
queryset = ConnectorInstance.objects.for_user(self.request.user)

Expand Down Expand Up @@ -161,18 +185,29 @@ def perform_create(self, serializer: ConnectorInstanceSerializer) -> None:
except Exception as exc:
logger.error(f"Error while obtaining ConnectorAuth: {exc}")
raise OAuthTimeOut
# Explicitly bind the connector to the request-scoped organization.
# Defense-in-depth: DefaultOrganizationMixin already declares
# `organization` as editable=False (so DRF drops any client-supplied
# value) and its save() backfills the org from UserContext when unset.
# Binding here makes that explicit at the callsite and keeps the row's
# org consistent with the org the controlled-mode check evaluated.
serializer.save(
connector_id=connector_id,
connector_metadata=connector_metadata,
created_by=self.request.user,
modified_by=self.request.user,
organization=UserContext.get_organization(),
) # type: ignore

# Clean up OAuth cache after successful create
self._cleanup_oauth_cache(connector_id)

def create(self, request: Any) -> Response:
# Overriding default exception behavior
# Fail fast on the admin restriction before validating the payload —
# the check depends only on the request/org, not on validated data, so
# a denied caller shouldn't get input-validation feedback first.
self._enforce_connector_creation_restriction(request)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
Expand Down
51 changes: 35 additions & 16 deletions backend/tenant_account_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,22 @@ def reset_password(request: Request) -> Response:
)


# Boolean org-level controlled-mode flags exposed by ``organization_settings``.
# Each maps a request/response key to the ``Organization`` field it updates.
ORGANIZATION_SETTING_FLAGS = (
"restrict_llm_adapter_creation",
"restrict_connector_creation",
)


@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated, IsOrganizationAdmin])
def organization_settings(request: Request) -> Response:
"""Read or update org-level settings. Admin-only.

Currently exposes the ``restrict_llm_adapter_creation`` controlled-mode
flag. GET returns the current value; PATCH updates it.
Exposes the controlled-mode flags in ``ORGANIZATION_SETTING_FLAGS``
(``restrict_llm_adapter_creation``, ``restrict_connector_creation``). GET
returns the current values; PATCH updates any subset of them.
"""
organization = UserContext.get_organization()
if not organization:
Expand All @@ -71,27 +80,37 @@ def organization_settings(request: Request) -> Response:
)

if request.method == "PATCH":
value = request.data.get("restrict_llm_adapter_creation")
if not isinstance(value, bool):
provided = {
flag: request.data[flag]
for flag in ORGANIZATION_SETTING_FLAGS
if flag in request.data
}
if not provided:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"message": "restrict_llm_adapter_creation must be a boolean"},
data={
"message": (
"Provide at least one of: "
f"{', '.join(ORGANIZATION_SETTING_FLAGS)}"
)
},
)
organization.restrict_llm_adapter_creation = value
# Validate everything before mutating so a bad value in a later flag
# doesn't leave the in-memory org partially updated.
for flag, value in provided.items():
if not isinstance(value, bool):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"message": f"{flag} must be a boolean"},
)
for flag, value in provided.items():
setattr(organization, flag, value)
organization.modified_by = request.user
Comment thread
greptile-apps[bot] marked this conversation as resolved.
organization.save(
update_fields=[
"restrict_llm_adapter_creation",
"modified_by",
"modified_at",
]
)
organization.save(update_fields=[*provided, "modified_by", "modified_at"])

return Response(
status=status.HTTP_200_OK,
data={
"restrict_llm_adapter_creation": (organization.restrict_llm_adapter_creation)
},
data={flag: getattr(organization, flag) for flag in ORGANIZATION_SETTING_FLAGS},
)


Expand Down
67 changes: 66 additions & 1 deletion frontend/src/components/settings/platform/PlatformSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ function PlatformSettings() {
// Controlled-mode flag: only admins can create LLM adapters when true.
const [restrictLlmCreation, setRestrictLlmCreation] = useState(false);
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
// Controlled-mode flag: only admins can create connectors when true.
const [restrictConnectorCreation, setRestrictConnectorCreation] =
useState(false);
const [isSavingConnectorRestriction, setIsSavingConnectorRestriction] =
useState(false);
const { sessionDetails } = useSessionStore();
const { setAlertDetails } = useAlertStore();
const axiosPrivate = useAxiosPrivate();
Expand Down Expand Up @@ -155,9 +160,12 @@ function PlatformSettings() {
setRestrictLlmCreation(
Boolean(res?.data?.restrict_llm_adapter_creation),
);
setRestrictConnectorCreation(
Boolean(res?.data?.restrict_connector_creation),
);
})
.catch((err) => {
console.warn("Failed to load LLM adapter creation setting", err);
console.warn("Failed to load organization settings", err);
});
}, [sessionDetails?.orgId, sessionDetails?.isAdmin]);

Expand Down Expand Up @@ -192,6 +200,37 @@ function PlatformSettings() {
});
};

const handleToggleConnectorRestriction = (checked) => {
const previous = restrictConnectorCreation;
setRestrictConnectorCreation(checked); // optimistic
setIsSavingConnectorRestriction(true);
axiosPrivate({
method: "PATCH",
url: `/api/v1/unstract/${sessionDetails?.orgId}/organization/settings`,
headers: {
"X-CSRFToken": sessionDetails?.csrfToken,
"Content-Type": "application/json",
},
data: { restrict_connector_creation: checked },
})
.then((res) => {
setRestrictConnectorCreation(
Boolean(res?.data?.restrict_connector_creation),
);
setAlertDetails({
type: "success",
content: "Connector creation setting updated.",
});
})
.catch((err) => {
setRestrictConnectorCreation(previous); // revert on failure
setAlertDetails(handleException(err, "Failed to update setting"));
})
.finally(() => {
setIsSavingConnectorRestriction(false);
});
};

const handleSaveInterval = () => {
if (
!batchIntervalMinutes ||
Expand Down Expand Up @@ -582,6 +621,32 @@ function PlatformSettings() {
</div>
</div>
)}
{isEnterpriseBuild && sessionDetails?.isAdmin && (
<div className="plt-set-section">
<Typography.Title level={5}>
Connector Creation
</Typography.Title>
<Typography.Text
type="secondary"
className="plt-set-section-subtitle"
>
Restrict creation of connectors to organization admins. When
enabled, non-admin users cannot create connectors.
</Typography.Text>
<div className="plt-set-inner-card">
<div className="plt-set-notif-field-row">
<Switch
checked={restrictConnectorCreation}
loading={isSavingConnectorRestriction}
onChange={handleToggleConnectorRestriction}
/>
<Typography.Text>
Only admins can create connectors
</Typography.Text>
</div>
</div>
</div>
)}
</div>
</IslandLayout>
</div>
Expand Down
Loading