Skip to content
Open
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: 2 additions & 2 deletions frontend/src/client/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5483,7 +5483,7 @@ export const $AuditSettingsUpdate = {
],
title: "Audit Webhook Custom Payload",
description:
"Custom JSON payload merged into streamed audit event payloads. Custom keys override default audit event keys.",
"Custom JSON fields merged into streamed audit event payloads. Canonical audit event fields take precedence; conflicting custom keys are ignored.",
},
audit_webhook_payload_attribute: {
anyOf: [
Expand Down Expand Up @@ -18733,7 +18733,7 @@ export const $PlatformAuditSettingsUpdate = {
],
title: "Audit Webhook Custom Payload",
description:
"Custom JSON payload merged into streamed audit event payloads. Custom keys override default audit event keys.",
"Custom JSON fields merged into streamed audit event payloads. Canonical audit event fields take precedence; conflicting custom keys are ignored.",
},
audit_webhook_payload_attribute: {
anyOf: [
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1372,7 +1372,7 @@ export type AuditSettingsUpdate = {
[key: string]: string
} | null
/**
* Custom JSON payload merged into streamed audit event payloads. Custom keys override default audit event keys.
* Custom JSON fields merged into streamed audit event payloads. Canonical audit event fields take precedence; conflicting custom keys are ignored.
*/
audit_webhook_custom_payload?: {
[key: string]: unknown
Expand Down Expand Up @@ -5653,7 +5653,7 @@ export type PlatformAuditSettingsUpdate = {
[key: string]: string
} | null
/**
* Custom JSON payload merged into streamed audit event payloads. Custom keys override default audit event keys.
* Custom JSON fields merged into streamed audit event payloads. Canonical audit event fields take precedence; conflicting custom keys are ignored.
*/
audit_webhook_custom_payload?: {
[key: string]: unknown
Expand Down
2 changes: 2 additions & 0 deletions packages/tracecat-ee/tracecat_ee/admin/settings/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from tracecat.audit.logger import audit_log
from tracecat.audit.service import clear_audit_setting_cache
from tracecat.auth.secrets import get_db_encryption_key
from tracecat.auth.types import PlatformRole
from tracecat.db.models import PlatformSetting
Expand Down Expand Up @@ -146,6 +147,7 @@ async def update_audit_settings(
for key, value in params.model_dump(exclude_unset=True).items():
await self._upsert_setting(key, value)
await self.session.commit()
clear_audit_setting_cache()
return await self.get_audit_settings()

async def get_registry_settings(self) -> PlatformRegistrySettingsRead:
Expand Down
4 changes: 2 additions & 2 deletions packages/tracecat-ee/tracecat_ee/rbac/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ async def update_role(
await self._set_role_scopes(role.id, scope_ids)

await self.session.commit()
await self.session.refresh(role, ["scopes"])
await self.session.refresh(role, ["updated_at", "scopes"])
return role

@require_scope("org:rbac:delete")
Expand Down Expand Up @@ -397,7 +397,7 @@ async def update_group(
group.description = description

await self.session.commit()
await self.session.refresh(group, ["members"])
await self.session.refresh(group, ["updated_at", "members"])
return group

@require_scope("org:rbac:delete")
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/test_admin_settings_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import annotations

import uuid
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock

import orjson
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from tracecat_ee.admin.settings import service as admin_settings_service_module
from tracecat_ee.admin.settings.schemas import PlatformAuditSettingsUpdate
from tracecat_ee.admin.settings.service import AdminSettingsService

Expand Down Expand Up @@ -52,8 +53,15 @@ async def test_platform_audit_settings_default_to_disconnected(
async def test_platform_audit_settings_encrypt_sensitive_values(
session: AsyncSession,
platform_role: PlatformRole,
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = AdminSettingsService(session, platform_role)
clear_audit_cache = MagicMock()
monkeypatch.setattr(
admin_settings_service_module,
"clear_audit_setting_cache",
clear_audit_cache,
)
custom_headers = {"Authorization": "Bearer secret"}
custom_payload = {"source": "tracecat-platform"}

Expand All @@ -72,6 +80,7 @@ async def test_platform_audit_settings_encrypt_sensitive_values(
assert settings.audit_webhook_custom_payload == custom_payload
assert settings.audit_webhook_payload_attribute == "event"
assert settings.audit_webhook_verify_ssl is False
clear_audit_cache.assert_called_once_with()

rows = (await session.execute(select(PlatformSetting))).scalars().all()
settings_by_key = {setting.key: setting for setting in rows}
Expand Down
50 changes: 45 additions & 5 deletions tests/unit/test_audit_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ def audit_service(role: Role) -> AuditService:
@pytest.fixture(autouse=True)
def clear_audit_setting_cache() -> Iterator[None]:
"""Isolate the module-level audit-setting TTL cache between tests."""
audit_service_module._get_audit_setting_cached.cache_clear()
audit_service_module.clear_audit_setting_cache()
yield
audit_service_module._get_audit_setting_cached.cache_clear()
audit_service_module.clear_audit_setting_cache()


class _AuditedService(BaseService):
Expand Down Expand Up @@ -881,7 +881,7 @@ async def test_audit_setting_cache_clear_restores_fresh_reads(
assert await audit_service._get_webhook_url() == "https://first.example.com/audit"
assert await audit_service._get_webhook_url() == "https://first.example.com/audit"

audit_service_module._get_audit_setting_cached.cache_clear()
audit_service_module.clear_audit_setting_cache()

assert await audit_service._get_webhook_url() == "https://second.example.com/audit"
assert fetch.await_count == 2
Expand All @@ -900,7 +900,14 @@ async def test_post_event_uses_custom_payload_headers_and_verify_ssl(
monkeypatch.setattr(
audit_service,
"_get_custom_payload",
AsyncMock(return_value={"resource_type": "organization", "custom": "yes"}),
AsyncMock(
return_value={
"resource_type": "organization",
"resource_id": "not-a-uuid",
"status": "INVALID_STATUS",
"custom": "yes",
}
),
)
monkeypatch.setattr(
audit_service,
Expand Down Expand Up @@ -947,7 +954,9 @@ async def test_post_event_uses_custom_payload_headers_and_verify_ssl(
assert args[0] == webhook_url
assert kwargs["headers"] == {"X-Custom-Header": "custom-value"}
assert kwargs["json"]["custom"] == "yes"
assert kwargs["json"]["resource_type"] == "organization"
assert kwargs["json"]["resource_type"] == "workflow"
assert kwargs["json"]["resource_id"] == str(event.resource_id)
assert kwargs["json"]["status"] == "SUCCESS"
assert kwargs["json"]["actor_label"] == "user@example.com"


Expand Down Expand Up @@ -1640,6 +1649,37 @@ async def mock_create_event(*args, **kwargs):
assert resource_ids == [invitation_id, invitation_id]


@pytest.mark.anyio
async def test_audit_log_extracts_id_from_keyword_object(role: Role) -> None:
resource_id = uuid.uuid4()

class MockService:
def __init__(self):
self.session = AsyncMock()

@audit_log(resource_type="organization_secret", action="update")
async def update_secret(self, *, secret: SimpleNamespace) -> None:
return None

service = MockService()
token = ctx_role.set(role)
create_event_calls: list[dict[str, object]] = []

async def mock_create_event(*args, **kwargs):
create_event_calls.append(kwargs)

try:
with patch.object(AuditService, "create_event", side_effect=mock_create_event):
await service.update_secret(secret=SimpleNamespace(id=resource_id))
finally:
ctx_role.reset(token)

assert [call["resource_id"] for call in create_event_calls] == [
resource_id,
resource_id,
]


@pytest.mark.anyio
async def test_audit_log_inner_function_failure_logs_failure_event(role: Role):
"""Test that when inner function raises exception, failure event is logged."""
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/test_control_plane_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,11 @@ async def _schedule(mp: pytest.MonkeyPatch, role: Role, action: str):
schedule_id = ScheduleUUID.new_uuid4()
service = WorkflowSchedulesService(cast(Any, _session()), role=role)
if action == "create":
mp.setattr(
service,
"_require_published_workflow",
AsyncMock(),
)
mp.setattr(
service,
"_create_schedule_impl",
Expand Down Expand Up @@ -475,6 +480,7 @@ async def _case_upsert(mp: pytest.MonkeyPatch, role: Role):
async def _case_update(mp: pytest.MonkeyPatch, role: Role):
trigger_id = uuid.uuid4()
service = CaseTriggersService(cast(Any, _session()), role=role)
cast(AsyncMock, service.session.scalar).return_value = trigger_id
mp.setattr(service, "require_entitlement", AsyncMock())
mp.setattr(
service,
Expand All @@ -485,7 +491,11 @@ async def _case_update(mp: pytest.MonkeyPatch, role: Role):
WorkflowUUID.new_uuid4(), CaseTriggerUpdate(status="online")
)
return _pair(
"case_trigger", "update", None, trigger_id, {"changed_fields": ["status"]}
"case_trigger",
"update",
trigger_id,
trigger_id,
{"changed_fields": ["status"]},
)


Expand Down
10 changes: 10 additions & 0 deletions tests/unit/test_organization_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any
from unittest.mock import MagicMock

import orjson
import pytest
Expand All @@ -11,6 +12,7 @@
from tracecat.contexts import ctx_role
from tracecat.db.models import OrganizationDomain
from tracecat.organization.domains import normalize_domain
from tracecat.settings import service as settings_service_module
from tracecat.settings.constants import SENSITIVE_SETTINGS_KEYS
from tracecat.settings.router import (
check_other_auth_enabled,
Expand Down Expand Up @@ -229,15 +231,23 @@ async def test_update_git_settings(
@pytest.mark.anyio
async def test_update_audit_settings(
settings_service_with_defaults: SettingsService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ensure audit webhook updates persist."""
service = settings_service_with_defaults
clear_audit_cache = MagicMock()
monkeypatch.setattr(
settings_service_module,
"clear_audit_setting_cache",
clear_audit_cache,
)
await service.update_audit_settings(
AuditSettingsUpdate(audit_webhook_url="https://example.com/audit")
)
settings = await service.list_org_settings(keys={"audit_webhook_url"})
settings_dict = {setting.key: service.get_value(setting) for setting in settings}
assert settings_dict["audit_webhook_url"] == "https://example.com/audit"
clear_audit_cache.assert_called_once_with()


@pytest.mark.anyio
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_rbac_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ async def test_update_role(
)
assert updated.name == "Updated Name"
assert updated.description == "New description"
assert updated.updated_at >= updated.created_at

async def test_delete_role(
self,
Expand Down Expand Up @@ -296,6 +297,25 @@ async def test_create_group(
assert group.organization_id == org.id
assert group.created_by == role.user_id

async def test_update_group(
self,
session: AsyncSession,
role: Role,
):
"""Updated groups expose server-generated timestamps without lazy IO."""
service = RBACService(session, role=role)
group = await service.create_group(name="Original Group")

updated = await service.update_group(
group.id,
name="Updated Group",
description="New description",
)

assert updated.name == "Updated Group"
assert updated.description == "New description"
assert updated.updated_at >= updated.created_at

async def test_add_member_to_group(
self,
session: AsyncSession,
Expand Down
42 changes: 41 additions & 1 deletion tests/unit/test_workflow_schedules_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from tracecat.audit.enums import AuditEventStatus
from tracecat.audit.service import AuditService
from tracecat.auth.types import Role
from tracecat.authz.scopes import EDITOR_SCOPES
from tracecat.db.models import Schedule, Workflow
from tracecat.exceptions import ScopeDeniedError
from tracecat.exceptions import ScopeDeniedError, TracecatNotFoundError
from tracecat.identifiers import WorkspaceID
from tracecat.identifiers.workflow import WorkflowUUID
from tracecat.workflow.schedules import bridge
Expand Down Expand Up @@ -81,6 +84,43 @@ async def _lock_workflow(workflow_id: WorkflowUUID) -> None:
assert locked_workflow_ids == [WorkflowUUID.new(workflow.id)]


@pytest.mark.anyio
async def test_create_schedule_unpublished_workflow_emits_failure(
session: AsyncSession, svc_role, monkeypatch
):
workflow = Workflow(
title="Unpublished Schedule",
description="Test workflow",
status="offline",
workspace_id=svc_role.workspace_id,
)
session.add(workflow)
await session.commit()

create_event = AsyncMock()
monkeypatch.setattr(AuditService, "create_event", create_event)
service = WorkflowSchedulesService(session, role=svc_role)

with pytest.raises(
TracecatNotFoundError,
match="Workflow must be saved before creating a schedule",
):
await service.create_schedule(
ScheduleCreate(
workflow_id=WorkflowUUID.new(workflow.id),
every=timedelta(hours=1),
inputs={},
status="offline",
timeout=0,
)
)

assert [call.kwargs["status"] for call in create_event.await_args_list] == [
AuditEventStatus.ATTEMPT,
AuditEventStatus.FAILURE,
]


@pytest.mark.anyio
async def test_update_schedule_updates_existing_schedule(
session: AsyncSession, svc_role, monkeypatch
Expand Down
6 changes: 6 additions & 0 deletions tracecat/audit/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ def _extract_resource_id(
2. The first positional argument with an ``attr`` attribute.
3. The bound function argument named ``attr``.
4. The keyword argument named ``attr``.
5. The first keyword argument object with an ``attr`` attribute.

Args:
args: Positional arguments passed to the decorated function.
Expand Down Expand Up @@ -377,6 +378,11 @@ def _extract_resource_id(
raw = bound_arguments.get(attr)
if raw is None and attr in kwargs:
raw = kwargs.get(attr)
if raw is None:
for argument in kwargs.values():
if hasattr(argument, attr):
raw = getattr(argument, attr)
break

if raw is None:
return None
Expand Down
Loading
Loading