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
4 changes: 4 additions & 0 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
diff,
record_platform_event,
)
from platform_audit.resources import record_resource_event, resource_ref
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
Expand Down Expand Up @@ -319,6 +320,7 @@ def create(self, request: Any) -> Response:
raise DuplicateAdapterNameError(
name=serializer.validated_data.get(AdapterKeys.ADAPTER_NAME)
)
record_resource_event(request, Action.RESOURCE_CREATED, instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

Expand Down Expand Up @@ -396,6 +398,7 @@ def destroy(
)
raise DeleteAdapterInUseError(adapter_name=adapter_instance.adapter_name)

deleted = resource_ref(adapter_instance)
try:
super().perform_destroy(adapter_instance)
except ProtectedError:
Expand All @@ -405,6 +408,7 @@ def destroy(
)
# TODO: Provide details of adpter usage with exception object
raise DeleteAdapterInUseError(adapter_name=adapter_instance.adapter_name)
record_resource_event(request, Action.RESOURCE_DELETED, deleted)
return Response(status=status.HTTP_204_NO_CONTENT)

@action(detail=True, methods=["post"], url_path="share")
Expand Down
8 changes: 8 additions & 0 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
)
from permissions.resource_share_views import ResourceShareManagementMixin
from platform_api.openapi_schema import PlatformKeyAutoSchema
from platform_audit.actions import Action
from platform_audit.resources import record_resource_event, resource_ref
from plugins import get_plugin
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
from rest_framework import serializers, status, views, viewsets
Expand Down Expand Up @@ -389,6 +391,7 @@ def create(
# OWNER membership row (UN-2202 co-owners).
serializer.instance.grant_owner(request.user)
api_key = DeploymentHelper.create_api_key(serializer=serializer, request=request)
record_resource_event(request, Action.RESOURCE_CREATED, serializer.instance)
response_serializer = DeploymentResponseSerializer(
{"api_key": api_key.api_key, **serializer.data}
)
Expand All @@ -408,6 +411,11 @@ def create(
headers=headers,
)

def perform_destroy(self, instance: APIDeployment) -> None:
deleted = resource_ref(instance)
super().perform_destroy(instance)
record_resource_event(self.request, Action.RESOURCE_DELETED, deleted)

@action(detail=False, methods=["get"])
def by_prompt_studio_tool(self, request: Request) -> Response:
"""Get API deployments for a specific prompt studio tool."""
Expand Down
5 changes: 5 additions & 0 deletions backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from permissions.membership_views import OwnerManagementMixin
from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg
from permissions.resource_share_views import ResourceShareManagementMixin
from platform_audit.actions import Action
from platform_audit.resources import record_resource_event, resource_ref
from plugins import get_plugin
from rest_framework import status, viewsets
from rest_framework.decorators import action
Expand Down Expand Up @@ -245,6 +247,7 @@ def create(self, request: Any) -> Response:
# ``created_by`` is audit-only; the creator's access flows through an
# OWNER membership row (UN-2202 co-owners).
serializer.instance.grant_owner(request.user)
record_resource_event(request, Action.RESOURCE_CREATED, serializer.instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

Expand All @@ -262,6 +265,7 @@ def perform_destroy(self, instance: ConnectorInstance) -> None:
Raises:
DeleteConnectorInUseError: If the connector is being used in workflows
"""
deleted = resource_ref(instance)
try:
super().perform_destroy(instance)
except ProtectedError:
Expand All @@ -270,3 +274,4 @@ def perform_destroy(self, instance: ConnectorInstance) -> None:
f" named {instance.connector_name}"
)
raise DeleteConnectorInUseError(connector_name=instance.connector_name)
record_resource_event(self.request, Action.RESOURCE_DELETED, deleted)
42 changes: 33 additions & 9 deletions backend/permissions/membership_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,34 @@ def validate_user_id(self, value: int) -> int:
raise serializers.ValidationError("User is already an owner.")
return value

def save(self, **kwargs) -> None:
def save(self, **kwargs) -> bool:
"""Promote a viewer or create the owner row; whether anything changed.

A concurrent request may have made the user an owner since validation.
"""
resource = self.context["resource"]
# Reverse manager auto-scopes to this resource; promote a viewer or
# create the owner row.
resource.memberships.update_or_create(
user_id=self.validated_data["user_id"],
defaults={"role": ResourceRole.OWNER},
)
user_id = self.validated_data["user_id"]
with transaction.atomic():
# Reverse manager auto-scopes to this resource. Lock the user's row
# by key (a concurrent share/ may add, remove or recreate it);
# create one only when there is none, then lock what exists.
for _ in range(3):
row = (
resource.memberships.select_for_update()
.filter(user_id=user_id)
.first()
)
if row is not None:
if row.role == ResourceRole.OWNER:
return False
resource.memberships.filter(pk=row.pk).update(role=ResourceRole.OWNER)
return True
_, created = resource.memberships.get_or_create(
user_id=user_id, defaults={"role": ResourceRole.OWNER}
)
if created:
return True
raise serializers.ValidationError("Could not add the co-owner; please retry.")


class RemoveOwnerSerializer(serializers.Serializer):
Expand All @@ -65,7 +85,8 @@ def validate(self, attrs: dict) -> dict:
)
return attrs

def save(self, **kwargs) -> None:
def save(self, **kwargs) -> bool:
"""Remove the OWNER row; whether there was one to remove."""
resource = self.context["resource"]
user = self.context["user_to_remove"]
with transaction.atomic():
Expand Down Expand Up @@ -93,4 +114,7 @@ def save(self, **kwargs) -> None:
raise serializers.ValidationError(
"Cannot remove the last owner. Add another owner first."
)
resource.memberships.filter(user=user, role=ResourceRole.OWNER).delete()
deleted, _ = resource.memberships.filter(
user=user, role=ResourceRole.OWNER
).delete()
return deleted > 0
24 changes: 22 additions & 2 deletions backend/permissions/membership_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from account_v2.models import User
from django.shortcuts import get_object_or_404
from platform_audit.actions import Action
from platform_audit.resources import record_resource_event
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
Expand Down Expand Up @@ -40,8 +42,12 @@ def add_co_owner(self, request: Request, pk: str | None = None) -> Response:
data=request.data, context={"request": request, "resource": resource}
)
serializer.is_valid(raise_exception=True)
serializer.save()
changed = serializer.save()
added_user = User.objects.get(id=serializer.validated_data["user_id"])
if changed:
_record_co_owner(
request, Action.RESOURCE_CO_OWNER_ADDED, resource, added_user
)
self._notify_owner_added(resource, added_user, request.user)
return Response(
{"id": str(resource.pk), "co_owners": self._owner_refs(resource)},
Expand All @@ -63,7 +69,10 @@ def remove_co_owner(
},
)
serializer.is_valid(raise_exception=True)
serializer.save()
if serializer.save():
_record_co_owner(
request, Action.RESOURCE_CO_OWNER_REMOVED, resource, user_to_remove
)
self.on_owner_removed(resource, user_to_remove)
self._notify_owner_removed(resource, user_to_remove, request.user)
return Response(status=status.HTTP_204_NO_CONTENT)
Expand Down Expand Up @@ -139,3 +148,14 @@ def _notify_owner_removed(self, resource: Any, user: User, actor: Any) -> None:
)
except Exception as e:
logger.exception("Failed to send co-owner removed notification: %s", e)


def _record_co_owner(request: Request, action: str, resource: Any, user: User) -> None:
"""Searchable by the co-owner's e-mail, like a group membership change."""
record_resource_event(
request,
action,
resource,
summary=user.email,
metadata={"user_id": user.pk, "email": user.email},
)
18 changes: 18 additions & 0 deletions backend/permissions/resource_share_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ def share(self, request: Request, pk: str | None = None) -> Response:
if "shared_groups" in desired
else set()
)
org_before = (
bool(getattr(resource, "shared_to_org", False))
if "shared_to_org" in desired
else None
)
ShareAuthorizationService.authorize_and_commit(
actor=request.user, resource=resource, desired=desired
)
Expand All @@ -223,6 +228,19 @@ def share(self, request: Request, pk: str | None = None) -> Response:
if "shared_groups" in desired
else groups_before
)
from platform_audit.resources import record_sharing_change

record_sharing_change(
request,
resource,
users=(users_before, users_after) if "shared_users" in desired else None,
groups=(groups_before, groups_after) if "shared_groups" in desired else None,
shared_to_org=(
(org_before, bool(resource.shared_to_org))
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
if org_before is not None
else None
),
)
notify_resource_group_share_changed(
resource=resource,
added=groups_after - groups_before,
Expand Down
5 changes: 5 additions & 0 deletions backend/pipeline_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
is_activation_only_patch,
)
from permissions.resource_share_views import ResourceShareManagementMixin
from platform_audit.actions import Action
from platform_audit.resources import record_resource_event, resource_ref
from plugins import get_plugin
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
Expand Down Expand Up @@ -179,11 +181,14 @@ def create(self, request: Request) -> Response:
raise DuplicateData(
f"{PipelineErrors.PIPELINE_EXISTS}, {PipelineErrors.DUPLICATE_API}"
)
record_resource_event(request, Action.RESOURCE_CREATED, pipeline_instance)
return Response(data=serializer.data, status=status.HTTP_201_CREATED)

def perform_destroy(self, instance: Pipeline) -> None:
pipeline_to_remove = str(instance.pk)
deleted = resource_ref(instance)
super().perform_destroy(instance)
record_resource_event(self.request, Action.RESOURCE_DELETED, deleted)
return SchedulerHelper.remove_job(pipeline_to_remove)

@action(detail=True, methods=["get"], url_path="users")
Expand Down
27 changes: 26 additions & 1 deletion backend/platform_audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,23 @@ Expected emitters outside this PR:
| `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 |
| `resource.created` | Resource created | resources | create of an adapter, connector, workflow, API deployment, pipeline or Prompt Studio project |
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
| `resource.deleted` | Resource deleted | resources | delete of the same; a refused delete records nothing |
| `resource.sharing_changed` | Sharing changed | resources | `<resource>/<id>/share/` POST; `changes` holds only the axes that changed (`shared_users` e-mails, `shared_groups` names, `shared_to_org`) |
| `resource.co_owner_added` | Co-owner added | resources | `<resource>/<id>/owners/` POST; co-owner e-mail in summary |
| `resource.co_owner_removed` | Co-owner removed | resources | `<resource>/<id>/owners/<user_id>/` DELETE |

The `resource.*` target type is the resource's kind: `adapter_instance`,
`connector_instance`, `workflow`, `api_deployment`, `pipeline`, `custom_tool`
(a Prompt Studio project), and on cloud `agentic_project` and `lookup`, whose
sharing is recorded through the same endpoints. The summary is the adapter type
(`LLM`, `EMBEDDING`, ...) or pipeline type (`ETL`, `TASK`) where there is one.

Target types: `member`, `invitation`, `group`, `platform_api_key`,
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
`global_api_deployment_key`, `platform_key`, `default_profile`,
`platform_setting`, `capability`, `execution`.
`platform_setting`, `capability`, `execution`, and the resource kinds
`adapter_instance`, `connector_instance`, `workflow`, `api_deployment`,
`pipeline`, `custom_tool`, `agentic_project`, `lookup`.

Per-deployment API keys (`api_v2`) are not recorded.

Expand Down Expand Up @@ -257,6 +270,14 @@ made any other way are not recorded: identity-provider or SCIM changes, cloud
staff and admin routes, Django admin, invitation acceptance, and cloud-only
features (until cloud code emits them).

`resource.created` and `resource.deleted` come from the OSS viewsets only.
These cloud paths create or delete the same kinds of resource without an
event: Prompt Studio clone (`prompt-studio/clone/`, also mounted on-prem), the
Agentic Studio connector views that create and delete adapters, and
frictionless onboarding (the default adapters and the sample project). A
project cloned and later deleted through the OSS viewset therefore shows a
`resource.deleted` with no matching `resource.created`.

Side effects that change a member's default adapter are not recorded. Among
them: creating an adapter when the member has no default of that type makes it
the default; revoking a share, or removing a co-owner who loses all access,
Expand All @@ -268,6 +289,10 @@ active a moment before; two admins toggling keys at the same instant can make
that list miss or name the wrong key, and if reading that list fails, the
deactivated keys are not recorded.

Editing a resource (renaming it, changing its settings) is not recorded. Nor is
anything a resource delete removes with it: deleting a workflow also deletes its
pipelines and API deployments, with no event of their own.

Each invitation is recorded as the identity provider accepts it, so the ones
sent before a later one fails are kept. The role recorded is the one the
request asked for; an identity provider that coerces a missing or unknown role
Expand Down
12 changes: 12 additions & 0 deletions backend/platform_audit/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ActionCategory(StrEnum):
SETTINGS = "settings"
CAPABILITIES = "capabilities"
EXECUTIONS = "executions"
RESOURCES = "resources"


class TargetType(StrEnum):
Expand Down Expand Up @@ -73,6 +74,12 @@ class Action(StrEnum):
PLATFORM_SETTING_CHANGED = "platform_setting.changed"
CAPABILITY_CHANGED = "capability.changed"
EXECUTION_ABORTED = "execution.aborted"
# Target: the resource, typed by its SHAREABLE_RESOURCES kind.
RESOURCE_CREATED = "resource.created"
RESOURCE_DELETED = "resource.deleted"
RESOURCE_SHARING_CHANGED = "resource.sharing_changed"
RESOURCE_CO_OWNER_ADDED = "resource.co_owner_added"
RESOURCE_CO_OWNER_REMOVED = "resource.co_owner_removed"


# The width of PlatformAuditEvent.action; a longer key could never be stored.
Expand Down Expand Up @@ -133,5 +140,10 @@ def all_actions() -> list[AuditAction]:
(Action.PLATFORM_SETTING_CHANGED, "Setting changed", ActionCategory.SETTINGS),
(Action.CAPABILITY_CHANGED, "Capability changed", ActionCategory.CAPABILITIES),
(Action.EXECUTION_ABORTED, "Execution aborted", ActionCategory.EXECUTIONS),
(Action.RESOURCE_CREATED, "Resource created", ActionCategory.RESOURCES),
(Action.RESOURCE_DELETED, "Resource deleted", ActionCategory.RESOURCES),
(Action.RESOURCE_SHARING_CHANGED, "Sharing changed", ActionCategory.RESOURCES),
(Action.RESOURCE_CO_OWNER_ADDED, "Co-owner added", ActionCategory.RESOURCES),
(Action.RESOURCE_CO_OWNER_REMOVED, "Co-owner removed", ActionCategory.RESOURCES),
):
register_action(_key, _label, _category)
Loading
Loading