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,65 @@
# Generated by Django 4.2.30 on 2026-10-14 23:16

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("account_v2", "0007_organization_restrict_connector_creation"),
]

# Django 4.2 drops a column's database default after backfilling it. Keep
# one, so an older image still running during a rollout (or after an
# image-only rollback) can insert organizations without these columns.
operations = [
migrations.AddField(
Comment thread
ritwik-g marked this conversation as resolved.
model_name="organization",
name="restrict_api_key_management",
field=models.BooleanField(
db_comment="Capability: when True, only organization admins may create and rotate platform and global deployment keys. Default True is today's admin-only behaviour.",
default=True,
),
),
migrations.AddField(
model_name="organization",
name="restrict_member_management",
field=models.BooleanField(
db_comment="Capability: when True, only organization admins may invite members and change roles. Default True is today's admin-only behaviour.",
default=True,
),
),
migrations.AddField(
model_name="organization",
name="restrict_other_adapter_creation",
field=models.BooleanField(
db_comment="Capability: when True, only organization admins may create non-LLM adapters. Default False preserves open creation.",
default=False,
),
),
migrations.AddField(
model_name="organization",
name="restrict_platform_defaults",
field=models.BooleanField(
db_comment="Capability: when True, only organization admins may change organization-wide platform settings. Default True is today's behaviour.",
default=True,
),
),
migrations.AddField(
model_name="organization",
name="restrict_resource_deletion",
field=models.BooleanField(
db_comment="Capability: when True, only organization admins may delete workflows, API deployments and pipelines. Default False keeps today's owner-or-admin rule.",
default=False,
),
),
migrations.RunSQL(
Comment thread
ritwik-g marked this conversation as resolved.
sql=[
"ALTER TABLE organization ALTER COLUMN restrict_other_adapter_creation SET DEFAULT false",
"ALTER TABLE organization ALTER COLUMN restrict_api_key_management SET DEFAULT true",
"ALTER TABLE organization ALTER COLUMN restrict_member_management SET DEFAULT true",
"ALTER TABLE organization ALTER COLUMN restrict_platform_defaults SET DEFAULT true",
"ALTER TABLE organization ALTER COLUMN restrict_resource_deletion SET DEFAULT false",
],
reverse_sql=migrations.RunSQL.noop,
),
]
40 changes: 40 additions & 0 deletions backend/account_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,46 @@ class Organization(models.Model):
"connectors in this org. Default False preserves open creation."
),
)
# Capability permissions (UN-4399): True means admins only. Defaults keep
# today's behaviour for each capability.
restrict_other_adapter_creation = models.BooleanField(
default=False,
db_comment=(
"Capability: when True, only organization admins may create non-LLM "
"adapters. Default False preserves open creation."
),
)
restrict_api_key_management = models.BooleanField(
default=True,
db_comment=(
"Capability: when True, only organization admins may create and "
"rotate platform and global deployment keys. Default True is "
"today's admin-only behaviour."
),
)
restrict_member_management = models.BooleanField(
default=True,
db_comment=(
"Capability: when True, only organization admins may invite members "
"and change roles. Default True is today's admin-only behaviour."
),
)
restrict_platform_defaults = models.BooleanField(
Comment thread
ritwik-g marked this conversation as resolved.
default=True,
db_comment=(
"Capability: when True, only organization admins may change "
"organization-wide platform settings. Default True is today's "
"behaviour."
),
)
restrict_resource_deletion = models.BooleanField(
default=False,
db_comment=(
"Capability: when True, only organization admins may delete "
"workflows, API deployments and pipelines. Default False keeps "
"today's owner-or-admin rule."
),
)

class Meta:
verbose_name = "Organization"
Expand Down
35 changes: 35 additions & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
get_deprecation_metadata,
is_adapter_deprecated,
)
from adapter_processor_v2.exceptions import InValidAdapterId
from adapter_processor_v2.image_output_gating import validate_image_output_allowed
from backend.constants import FieldLengthConstants as FLC
from backend.serializers import AuditSerializer
Expand Down Expand Up @@ -111,8 +112,42 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
raise ValidationError(
{AdapterKeys.ADAPTER_ID: get_deprecation_message(adapter_id)}
)
self._reject_type_mismatch(attrs)
return attrs

def _reject_type_mismatch(self, attrs: dict[str, Any]) -> None:
"""Reject an ``adapter_type`` that is not the type of ``adapter_id``.

The create capability is chosen from ``adapter_type`` (UN-4399), so a
mislabelled pair would let a member past "Create LLM adapters" or
"Create other adapters". On update either field may be absent, so each
falls back to the stored one. An id the registry does not know is left
to the existing paths, which already fail it when it is used.
"""
instance = self.instance
adapter_id = attrs.get(AdapterKeys.ADAPTER_ID) or getattr(
instance, "adapter_id", None
)
declared = attrs.get(AdapterKeys.ADAPTER_TYPE) or getattr(
instance, "adapter_type", None
)
if not adapter_id or not declared:
return
try:
actual = AdapterProcessor.get_adapter_data_with_key(
adapter_id, AdapterKeys.ADAPTER_TYPE
)
except InValidAdapterId:
return
if actual and actual != declared:
raise ValidationError(
{
AdapterKeys.ADAPTER_TYPE: (
f"Adapter '{adapter_id}' has type {actual}, not {declared}."
)
}
)

def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]:
if data.get(AdapterKeys.ADAPTER_METADATA, None):
# Reject image output mode on deployments without the cloud
Expand Down
38 changes: 17 additions & 21 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.versioning import URLPathVersioning
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from tenant_account_v2.capabilities import Capability, enforce_capability
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tool_instance_v2.models import ToolInstance
from utils.filtering import FilterHelper
Expand Down Expand Up @@ -227,26 +227,17 @@ def get_serializer_class(
return AdapterInstanceSerializer

@staticmethod
def _enforce_llm_creation_restriction(request: Any, adapter_type: str) -> None:
"""Controlled mode (UN-3584): only org admins may create LLM adapters
when the org has enabled the restriction. Service accounts (platform
API-key sessions) and non-LLM adapter types bypass; the default
(flag off) keeps creation open for everyone.
def _enforce_adapter_creation_capability(request: Any, adapter_type: str) -> None:
Comment thread
ritwik-g marked this conversation as resolved.
"""Capability permissions (UN-3584, UN-4399): LLM adapters fall under
"Create LLM adapters", every other type under "Create other adapters".
Service accounts bypass; the defaults keep creation open for everyone.
"""
if adapter_type != AdapterKeys.LLM:
return
if getattr(request.user, "is_service_account", False):
return
organization = UserContext.get_organization()
if (
organization
and organization.restrict_llm_adapter_creation
and not OrganizationMemberService.is_user_organization_admin(request.user)
):
raise PermissionDenied(
"LLM adapter creation is restricted to organization admins. "
"Please contact your organization admin."
)
enforce_capability(
request,
Capability.CREATE_LLM_ADAPTERS
if adapter_type == AdapterKeys.LLM
else Capability.CREATE_OTHER_ADAPTERS,
)

def create(self, request: Any) -> Response:
serializer = self.get_serializer(data=request.data)
Expand All @@ -262,7 +253,7 @@ def create(self, request: Any) -> Response:
# which also covers update/partial_update.
serializer.is_valid(raise_exception=True)
adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE)
self._enforce_llm_creation_restriction(request, adapter_type)
self._enforce_adapter_creation_capability(request, adapter_type)
Comment thread
ritwik-g marked this conversation as resolved.

try:
if adapter_type == AdapterKeys.X2TEXT and use_platform_unstract_key:
Expand Down Expand Up @@ -532,6 +523,11 @@ def update(

# Get the adapter instance for update
adapter = self.get_object()
# Changing an adapter's type amounts to creating one of the new type,
# so it needs the same capability as creating it would.
new_type = request.data.get(AdapterKeys.ADAPTER_TYPE)
Comment thread
ritwik-g marked this conversation as resolved.
if new_type and new_type != adapter.adapter_type:
self._enforce_adapter_creation_capability(request, new_type)

if use_platform_unstract_key:
logger.error("Processing adapter with platform key")
Expand Down
8 changes: 7 additions & 1 deletion backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from tenant_account_v2.capabilities import Capability, RequiresCapability
from tool_instance_v2.models import ToolInstance
from utils.enums import CeleryTaskState
from utils.hubspot_notify import notify_hubspot_event
Expand Down Expand Up @@ -291,8 +292,13 @@ def get_permissions(self) -> list[Any]:
self.request, flag="is_active"
):
return [IsOwnerOrSharedUserOrSharedToOrg()]
if self.action == "destroy":
# Ownership as before, narrowed by the org's deletion capability.
return [
IsOwner(),
RequiresCapability(Capability.DELETE_WORKFLOWS_AND_DEPLOYMENTS),
]
if self.action in [
"destroy",
"partial_update",
"update",
"add_co_owner",
Expand Down
2 changes: 2 additions & 0 deletions backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,8 @@ def filter(self, record):
"ENUM_NAME_OVERRIDES": {
"ErrorType": "api_v2.openapi_schema.ERROR_TYPES",
"ApiKeyPermission": "platform_api.models.ApiKeyPermission.choices",
"CapabilityKey": "tenant_account_v2.capabilities.CAPABILITY_KEY_VALUES",
"CapabilitySetting": "tenant_account_v2.capabilities.CAPABILITY_SETTING_VALUES",
},
# Group descriptions generated clients show in their help; without this
# the spec has no root `tags` array for the text to live in.
Expand Down
16 changes: 2 additions & 14 deletions backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@
from plugins import get_plugin
from rest_framework import status, viewsets
from rest_framework.decorators import action
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 tenant_account_v2.capabilities import Capability, enforce_capability
from utils.filtering import FilterHelper
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
Expand Down Expand Up @@ -75,18 +74,7 @@ def _enforce_connector_creation_restriction(request: Any) -> None:
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."
)
enforce_capability(request, Capability.CREATE_CONNECTORS)

def get_queryset(self) -> QuerySet | None:
# Avoid per-row queries for owner/co-owner + creator fields in list views
Expand Down
8 changes: 7 additions & 1 deletion backend/pipeline_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
from scheduler.helper import SchedulerHelper
from tenant_account_v2.capabilities import Capability, RequiresCapability
from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination

Expand Down Expand Up @@ -74,8 +75,13 @@ def get_permissions(self) -> list[Any]:
self.request, flag="active", ignore=("pipeline_id",)
):
return [IsOwnerOrSharedUserOrSharedToOrg()]
if self.action == "destroy":
# Ownership as before, narrowed by the org's deletion capability.
return [
IsOwner(),
RequiresCapability(Capability.DELETE_WORKFLOWS_AND_DEPLOYMENTS),
]
if self.action in [
"destroy",
"partial_update",
"update",
"add_co_owner",
Expand Down
5 changes: 2 additions & 3 deletions backend/platform_audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ Expected emitters outside this PR:

| Task | Action | Notes |
|---|---|---|
| PB-T04 (UN-4399) | `capability.changed` | target `capability`, id = capability key, `changes` before/after audience |
| PB-T05 (UN-4360) | `key.expiry_set` | target per key type; cloud review keys may register their own `review_key.*` actions |
| PB-T08 (UN-4318) | `execution.aborted` | target `execution`; actor is the owner or admin who aborted |

Expand All @@ -221,8 +220,8 @@ Expected emitters outside this PR:
| `key.changed` | Key changed | keys | any other key field |
| `key.expiry_set` | Key expiry set | keys | PB-T05 |
| `default_profile.changed` | Default changed | defaults | `adapter/default_triad/`; per member, before→after adapter names per slot |
| `platform_setting.changed` | Setting changed | settings | `organization/settings` PATCH and `notifications/settings/` PATCH, one event per changed setting |
| `capability.changed` | Capability changed | capabilities | PB-T04 |
| `platform_setting.changed` | Setting changed | settings | `notifications/settings/` PATCH |
| `capability.changed` | Capability changed | capabilities | `organization/capabilities/` PATCH and `organization/settings` PATCH (its two flags are the storage of two capabilities), one event per changed capability; target `capability`, id = capability key, name = label, `changes` `{"before": {"setting": …}, "after": {"setting": …}}` with values `admins_only`/`any_member` |
| `execution.aborted` | Execution aborted | executions | PB-T08 |

Target types: `member`, `invitation`, `group`, `platform_api_key`,
Expand Down
13 changes: 6 additions & 7 deletions backend/platform_audit/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,24 +684,23 @@ def test_unreadable_previous_defaults_record_the_current_ones(self) -> None:

class PlatformSettingEventTests(AuditTestBase):
def test_organization_setting_changed(self) -> None:
"""Its flags are the storage of two capabilities (UN-4399), so a change
is recorded once, as the capability changing."""
response = self.call(
organization_settings,
"patch",
data={"restrict_llm_adapter_creation": True},
)
self.assertEqual(response.status_code, 200)
event = self.only_event(action="platform_setting.changed")
event = self.only_event(action="capability.changed")
self.assert_admin_actor(event)
self.assertEqual(
(event.target_type, event.target_id),
("platform_setting", "restrict_llm_adapter_creation"),
(event.target_type, event.target_id, event.target_name),
("capability", "create_llm_adapters", "Create LLM adapters"),
)
self.assertEqual(
event.changes,
{
"before": {"restrict_llm_adapter_creation": False},
"after": {"restrict_llm_adapter_creation": True},
},
{"before": {"setting": "any_member"}, "after": {"setting": "admins_only"}},
)
# Re-sending the same value is not a change.
self.call(
Expand Down
Loading
Loading