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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Changelog

All notable changes to `utopia-php/audit` are documented in this file.

## 2.4.0

### ClickHouse adapter — actor terminology

The ClickHouse adapter now stores its principal columns under "actor" terminology:
`actorId`, `actorType`, `actorInternalId`. The shared SQL base, the Database adapter,
and the public `Audit` API are unchanged — Database-backed audit logs continue to use
`userId`.

This is a non-breaking change for callers of the public API. `Audit::log($userId, ...)`,
`Audit::getLogsByUser(...)`, `Audit::countLogsByUser(...)`, and the equivalent
`*ByUserAndEvents` methods all keep their original signatures. The ClickHouse adapter
translates the legacy `userId` array key and `Query::equal('userId', ...)` filter
internally to the renamed `actorId` column.
Comment on lines +5 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Version number contradicts PR title and description

The CHANGELOG header says 2.4.0 and explicitly states "This is a non-breaking change for callers of the public API," but the PR title uses the feat! conventional-commit prefix, says "BC break," and states the next tag should be 3.0.0. Additionally, the PR description's renames table lists Adapter::getByActor(), Audit::getLogsByActor(), etc. — but none of those method renames appear in the code; Adapter.php and Audit.php still expose getByUser, countByUser, log(?string $userId, ...). A consumer reading the PR description will look for getByActor() and find it absent. Before merging, the CHANGELOG version and the PR description must agree: either the ClickHouse-only schema rename is a non-breaking 2.x release (matching the code), or the public PHP API methods still need to be renamed to make this a true 3.0.0 BC break.


#### Added

- `Log::getActorId()`, `Log::getActorType()`, `Log::getActorInternalId()` getters for
ClickHouse-backed log reads.
- `Log` instances returned by the ClickHouse adapter expose both `actorId` / `actorType`
/ `actorInternalId` (canonical) and `userId` / `userType` / `userInternalId` (legacy
mirror) attribute keys so existing code paths continue to work.

#### ClickHouse schema changes

- Column `userId` → `actorId`
- Column `userType` → `actorType`
- Column `userInternalId` → `actorInternalId`
- Index `idx_userId_event` → `idx_actorId_event`
- Index `_key_user_type` → `_key_actor_type`
- Index `_key_user_internal_id` → `_key_actor_internal_id`
- Index `_key_user_internal_and_event` → `_key_actor_internal_and_event`

#### Migration

ClickHouse audit tables will be recreated by `setup()` with the new column names.
Existing ClickHouse audit data is not preserved automatically — this is acceptable
because the activity-events surface backed by this schema is not yet in public use.
If preservation is needed, run `ALTER TABLE ... RENAME COLUMN` for each renamed
column before redeploying.
Comment on lines +40 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Recreated by setup()" is factually incorrect

setup() issues CREATE TABLE IF NOT EXISTS, so an existing table is never touched — it is not dropped and recreated. For any operator upgrading the library against a live ClickHouse instance, the table retains the old userId/userType/userInternalId columns, and every query the new code emits (which references actorId) will fail immediately. The phrase "will be recreated by setup()" may mislead operators into believing no manual action is required before redeploying. The sentence should clarify that setup() only creates the table for fresh installations, and that existing installations require the ALTER TABLE ... RENAME COLUMN steps listed below before redeploying.


No migration is required for Database-backed audit logs. The Database adapter
continues to write and read `userId` columns and indexes unchanged.

## 2.3.2 and earlier

See git history.
80 changes: 67 additions & 13 deletions src/Audit/Adapter/ClickHouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,18 @@ public function getAttributes(): array
{
$parentAttributes = parent::getAttributes();

foreach ($parentAttributes as &$attribute) {
if (($attribute['$id'] ?? null) === 'userId') {
$attribute['$id'] = 'actorId';
break;
}
}
unset($attribute);

return [
...$parentAttributes,
[
'$id' => 'userType',
'$id' => 'actorType',
'type' => Database::VAR_STRING,
'size' => Database::LENGTH_KEY,
'required' => true,
Expand All @@ -350,7 +358,7 @@ public function getAttributes(): array
'filters' => [],
],
[
'$id' => 'userInternalId',
'$id' => 'actorInternalId',
'type' => Database::VAR_STRING,
'size' => Database::LENGTH_KEY,
'required' => false,
Expand Down Expand Up @@ -477,13 +485,21 @@ public function getIndexes(): array
{
$parentIndexes = parent::getIndexes();

// New indexes to add
foreach ($parentIndexes as &$index) {
if (($index['$id'] ?? null) === 'idx_userId_event') {
$index['$id'] = 'idx_actorId_event';
$index['attributes'] = ['actorId', 'event'];
break;
}
}
unset($index);

return [
...$parentIndexes,
[
'$id' => '_key_user_internal_and_event',
'$id' => '_key_actor_internal_and_event',
'type' => Database::INDEX_KEY,
'attributes' => ['userInternalId', 'event'],
'attributes' => ['actorInternalId', 'event'],
'lengths' => [],
'orders' => [],
],
Expand All @@ -502,16 +518,16 @@ public function getIndexes(): array
'orders' => [],
],
[
'$id' => '_key_user_internal_id',
'$id' => '_key_actor_internal_id',
'type' => Database::INDEX_KEY,
'attributes' => ['userInternalId'],
'attributes' => ['actorInternalId'],
'lengths' => [],
'orders' => [],
],
[
'$id' => '_key_user_type',
'$id' => '_key_actor_type',
'type' => Database::INDEX_KEY,
'attributes' => ['userType'],
'attributes' => ['actorType'],
'lengths' => [],
'orders' => [],
],
Expand Down Expand Up @@ -770,6 +786,22 @@ private function getColumnNames(): array
* @return bool True if valid
* @throws Exception If attribute name is invalid
*/
/**
* Translate legacy user* attribute names to actor* column names.
*
* @param string $attribute
* @return string
*/
private function translateAttribute(string $attribute): string
{
return match ($attribute) {
'userId' => 'actorId',
'userType' => 'actorType',
'userInternalId' => 'actorInternalId',
default => $attribute,
};
}

private function validateAttributeName(string $attributeName): bool
{
// Special case: 'id' is always valid
Expand Down Expand Up @@ -1110,6 +1142,7 @@ private function parseQueries(array $queries): array
$method = $query->getMethod();
$attribute = $query->getAttribute();
/** @var string $attribute */
$attribute = $this->translateAttribute($attribute);
$values = $query->getValues();

// Reject empty values for filter methods that take values — mirrors
Expand Down Expand Up @@ -1651,9 +1684,24 @@ public function createBatch(array $logs): bool
$rows = [];

foreach ($logs as $log) {
foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) {
if (\array_key_exists($legacy, $log) && !\array_key_exists($current, $log)) {
$log[$current] = $log[$legacy];
}
unset($log[$legacy]);
}

/** @var array<string, mixed> $logData */
$logData = $log['data'] ?? [];

foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) {
if (\array_key_exists($legacy, $logData) && !\array_key_exists($current, $logData)) {
$logData[$current] = $logData[$legacy];
}
unset($logData[$legacy]);
}
$log['data'] = $logData;

// Separate data for non-schema attributes
$nonSchemaData = $logData;
$resourceValue = $log['resource'] ?? null;
Expand Down Expand Up @@ -1805,6 +1853,12 @@ private function parseJsonResults(string $result): array
unset($document['id']);
}

foreach (['actorId' => 'userId', 'actorType' => 'userType', 'actorInternalId' => 'userInternalId'] as $current => $legacy) {
if (\array_key_exists($current, $document) && !\array_key_exists($legacy, $document)) {
$document[$legacy] = $document[$current];
}
}

$documents[] = new Log($document);
}

Expand Down Expand Up @@ -1904,7 +1958,7 @@ public function getByUser(
bool $ascending = false,
): array {
$queries = [
Query::equal('userId', $userId),
Query::equal('actorId', $userId),
];

if ($after !== null && $before !== null) {
Expand Down Expand Up @@ -1934,7 +1988,7 @@ public function countByUser(
?int $max = null,
): int {
$queries = [
Query::equal('userId', $userId),
Query::equal('actorId', $userId),
];

if ($after !== null && $before !== null) {
Expand Down Expand Up @@ -2021,7 +2075,7 @@ public function getByUserAndEvents(
bool $ascending = false,
): array {
$queries = [
Query::equal('userId', $userId),
Query::equal('actorId', $userId),
Query::contains('event', $events),
];

Expand Down Expand Up @@ -2053,7 +2107,7 @@ public function countByUserAndEvents(
?int $max = null,
): int {
$queries = [
Query::equal('userId', $userId),
Query::equal('actorId', $userId),
Query::contains('event', $events),
];

Expand Down
33 changes: 33 additions & 0 deletions src/Audit/Log.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ public function getUserId(): ?string
return is_string($userId) ? $userId : null;
}

/**
* Get the actor ID associated with this log entry.
*
* @return string|null
*/
public function getActorId(): ?string
{
$actorId = $this->getAttribute('actorId');
return is_string($actorId) ? $actorId : null;
}

/**
* Get the actor type associated with this log entry.
*
* @return string|null
*/
public function getActorType(): ?string
{
$actorType = $this->getAttribute('actorType');
return is_string($actorType) ? $actorType : null;
}

/**
* Get the actor internal ID associated with this log entry.
*
* @return string|null
*/
public function getActorInternalId(): ?string
{
$actorInternalId = $this->getAttribute('actorInternalId');
return is_string($actorInternalId) ? $actorInternalId : null;
}

/**
* Get the event name.
*
Expand Down
44 changes: 22 additions & 22 deletions tests/Audit/Adapter/ClickHouseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ protected function initializeAudit(): void
protected function getRequiredAttributes(): array
{
return [
'userType' => 'member',
'actorType' => 'member',
'resourceType' => 'document',
'resourceId' => 'res-1',
'projectId' => 'proj-1',
Expand Down Expand Up @@ -414,7 +414,7 @@ public function testBatchOperationsWithSpecialCharacters(): void
// Test batch with special characters in data
$batchEvents = [
[
'userId' => 'user`with`backticks',
'actorId' => 'actor`with`backticks',
'event' => 'create',
'resource' => 'doc/"quotes"',
'userAgent' => "User'Agent\"With'Quotes",
Expand All @@ -429,7 +429,7 @@ public function testBatchOperationsWithSpecialCharacters(): void
$this->assertTrue($result);

// Verify retrieval
$logs = $this->audit->getLogsByUser('user`with`backticks');
$logs = $this->audit->getLogsByUser('actor`with`backticks');
$this->assertGreaterThan(0, count($logs));
}

Expand All @@ -449,9 +449,9 @@ public function testClickHouseAdapterAttributes(): void

// Verify all expected attributes exist
$expectedAttributes = [
'userType',
'userId',
'userInternalId',
'actorType',
'actorId',
'actorInternalId',
'resourceParent',
'resourceType',
'resourceId',
Expand Down Expand Up @@ -491,11 +491,11 @@ public function testClickHouseAdapterIndexes(): void

// Verify all ClickHouse-specific indexes exist
$expectedClickHouseIndexes = [
'_key_user_internal_and_event',
'_key_actor_internal_and_event',
'_key_project_internal_id',
'_key_team_internal_id',
'_key_user_internal_id',
'_key_user_type',
'_key_actor_internal_id',
'_key_actor_type',
'_key_country',
'_key_hostname'
];
Expand All @@ -505,7 +505,7 @@ public function testClickHouseAdapterIndexes(): void
}

// Verify parent indexes are also included (with parent naming convention)
$parentExpectedIndexes = ['idx_event', 'idx_userId_event', 'idx_resource_event', 'idx_time_desc'];
$parentExpectedIndexes = ['idx_event', 'idx_actorId_event', 'idx_resource_event', 'idx_time_desc'];
foreach ($parentExpectedIndexes as $expected) {
$this->assertContains($expected, $indexIds, "Parent index '{$expected}' not found in ClickHouse adapter");
}
Expand All @@ -516,7 +516,7 @@ public function testClickHouseAdapterIndexes(): void
*/
public function testParseResourceComplexPath(): void
{
$userId = 'parseUser';
$actorId = 'parseActor';
$userAgent = 'UnitTestAgent/1.0';
$ip = '127.0.0.1';

Expand All @@ -531,7 +531,7 @@ public function testParseResourceComplexPath(): void
unset($required['resourceType'], $required['resourceId'], $required['resourceParent']);
$dataWithAttributes = array_merge($data, $required);

$log = $this->audit->log($userId, 'create', $resource, $userAgent, $ip, $dataWithAttributes);
$log = $this->audit->log($actorId, 'create', $resource, $userAgent, $ip, $dataWithAttributes);

$this->assertInstanceOf(\Utopia\Audit\Log::class, $log);

Expand Down Expand Up @@ -675,7 +675,7 @@ public function testCountByUserWithMaxBound(): void

public function testNotEqualQuery(): void
{
// Fixture: 3x event=update/delete for userId, plus 1x event=insert for null user
// Fixture: 3x event=update/delete for actor, plus 1x event=insert for null actor
$logs = $this->audit->find([
Query::notEqual('event', 'update'),
]);
Expand Down Expand Up @@ -735,17 +735,17 @@ public function testNotBetweenQuery(): void

public function testIsNullAndIsNotNullQueries(): void
{
$nullUser = $this->audit->find([
Query::isNull('userId'),
$nullActor = $this->audit->find([
Query::isNull('actorId'),
]);
// Only the insert log has null userId
$this->assertCount(1, $nullUser);
$this->assertEquals('insert', $nullUser[0]->getEvent());
// Only the insert log has null actorId
$this->assertCount(1, $nullActor);
$this->assertEquals('insert', $nullActor[0]->getEvent());

$notNullUser = $this->audit->find([
Query::isNotNull('userId'),
$notNullActor = $this->audit->find([
Query::isNotNull('actorId'),
]);
$this->assertCount(3, $notNullUser);
$this->assertCount(3, $notNullActor);
}

public function testStartsWithAndEndsWithQueries(): void
Expand Down Expand Up @@ -801,7 +801,7 @@ public function testSelectProjectsRequestedColumns(): void
{
$logs = $this->audit->find([
Query::select(['event', 'resource']),
Query::equal('userId', 'userId'),
Query::equal('actorId', 'userId'),
Query::limit(1),
]);

Expand Down
Loading