Skip to content

Commit b039221

Browse files
committed
feat: add Doctrine logging generic classes, add listener and config
1 parent 9ee4d37 commit b039221

28 files changed

+2363
-5
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace App\Audit;
4+
5+
use App\Audit\Utils\DateFormatter;
6+
7+
/**
8+
* Copyright 2025 OpenStack Foundation
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
* http://www.apache.org/licenses/LICENSE-2.0
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
**/
19+
20+
abstract class AbstractAuditLogFormatter implements IAuditLogFormatter
21+
{
22+
protected ?AuditContext $ctx = null;
23+
protected string $event_type;
24+
25+
public function __construct(string $event_type)
26+
{
27+
$this->event_type = $event_type;
28+
}
29+
30+
final public function setContext(AuditContext $ctx): void
31+
{
32+
$this->ctx = $ctx;
33+
}
34+
35+
protected function getUserInfo(): string
36+
{
37+
if (app()->runningInConsole()) {
38+
return 'Worker Job';
39+
}
40+
if (!$this->ctx) {
41+
return 'Unknown (unknown)';
42+
}
43+
44+
$user_name = 'Unknown';
45+
if ($this->ctx->userFirstName || $this->ctx->userLastName) {
46+
$user_name = trim(sprintf("%s %s", $this->ctx->userFirstName ?? '', $this->ctx->userLastName ?? '')) ?: 'Unknown';
47+
} elseif ($this->ctx->userEmail) {
48+
$user_name = $this->ctx->userEmail;
49+
}
50+
51+
$user_id = $this->ctx->userId ?? 'unknown';
52+
return sprintf("%s (%s)", $user_name, $user_id);
53+
}
54+
55+
protected function formatAuditDate($date, string $format = 'Y-m-d H:i:s'): string
56+
{
57+
return DateFormatter::format($date, $format);
58+
}
59+
60+
protected function getIgnoredFields(): array
61+
{
62+
return [
63+
'last_created',
64+
'last_updated',
65+
'last_edited',
66+
'created_by',
67+
'updated_by',
68+
'created_at',
69+
'updated_at',
70+
];
71+
}
72+
73+
protected function formatChangeValue($value): string
74+
{
75+
if (is_bool($value)) {
76+
return $value ? 'true' : 'false';
77+
}
78+
if (is_null($value)) {
79+
return 'null';
80+
}
81+
if ($value instanceof \DateTimeInterface) {
82+
return $value->format('Y-m-d H:i:s');
83+
}
84+
if ($value instanceof \Doctrine\Common\Collections\Collection) {
85+
$count = $value->count();
86+
return sprintf('Collection[%d items]', $count);
87+
}
88+
if (is_object($value)) {
89+
$className = get_class($value);
90+
return sprintf('%s', $className);
91+
}
92+
if (is_array($value)) {
93+
return sprintf('Array[%d items]', count($value));
94+
}
95+
return (string) $value;
96+
}
97+
98+
99+
protected function buildChangeDetails(array $change_set): string
100+
{
101+
$changed_fields = [];
102+
$ignored_fields = $this->getIgnoredFields();
103+
104+
foreach ($change_set as $prop_name => $change_values) {
105+
if (in_array($prop_name, $ignored_fields)) {
106+
continue;
107+
}
108+
109+
$old_value = $change_values[0] ?? null;
110+
$new_value = $change_values[1] ?? null;
111+
112+
$formatted_change = $this->formatFieldChange($prop_name, $old_value, $new_value);
113+
if ($formatted_change !== null) {
114+
$changed_fields[] = $formatted_change;
115+
}
116+
}
117+
118+
if (empty($changed_fields)) {
119+
return 'properties without changes registered';
120+
}
121+
122+
$fields_summary = count($changed_fields) . ' field(s) modified: ';
123+
return $fields_summary . implode(' | ', $changed_fields);
124+
}
125+
126+
protected function formatFieldChange(string $prop_name, $old_value, $new_value): ?string
127+
{
128+
$old_display = $this->formatChangeValue($old_value);
129+
$new_display = $this->formatChangeValue($new_value);
130+
131+
return sprintf("Property \"%s\" has changed from \"%s\" to \"%s\"", $prop_name, $old_display, $new_display);
132+
}
133+
134+
abstract public function format(mixed $subject, array $change_set): ?string;
135+
}

app/Audit/AuditContext.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
namespace App\Audit;
3+
/**
4+
* Copyright 2025 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
class AuditContext
16+
{
17+
public function __construct(
18+
public ?int $userId = null,
19+
public ?string $userEmail = null,
20+
public ?string $userFirstName = null,
21+
public ?string $userLastName = null,
22+
public ?string $uiApp = null,
23+
public ?string $uiFlow = null,
24+
public ?string $route = null,
25+
public ?string $rawRoute = null,
26+
public ?string $httpMethod = null,
27+
public ?string $clientIp = null,
28+
public ?string $userAgent = null,
29+
) {
30+
}
31+
}

app/Audit/AuditEventListener.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
namespace App\Audit;
3+
/**
4+
* Copyright 2025 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use App\Audit\Interfaces\IAuditStrategy;
17+
use App\Repositories\DoctrineUserRepository;
18+
use Auth\Repositories\IUserRepository;
19+
use Auth\User;
20+
use Doctrine\ORM\Event\OnFlushEventArgs;
21+
use Illuminate\Support\Facades\App;
22+
use Illuminate\Support\Facades\Log;
23+
use Illuminate\Support\Facades\Route;
24+
use Illuminate\Http\Request;
25+
use OAuth2\IResourceServerContext;
26+
/**
27+
* Class AuditEventListener
28+
* @package App\Audit
29+
*/
30+
class AuditEventListener
31+
{
32+
private const ROUTE_METHOD_SEPARATOR = '|';
33+
34+
public function onFlush(OnFlushEventArgs $eventArgs): void
35+
{
36+
if (app()->environment('testing')) {
37+
return;
38+
}
39+
$em = $eventArgs->getObjectManager();
40+
$uow = $em->getUnitOfWork();
41+
// Strategy selection based on environment configuration
42+
$strategy = $this->getAuditStrategy($em);
43+
if (!$strategy) {
44+
return; // No audit strategy enabled
45+
}
46+
47+
$ctx = $this->buildAuditContext();
48+
49+
try {
50+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
51+
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_CREATION, $ctx);
52+
}
53+
54+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
55+
$strategy->audit($entity, $uow->getEntityChangeSet($entity), IAuditStrategy::EVENT_ENTITY_UPDATE, $ctx);
56+
}
57+
58+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
59+
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
60+
}
61+
62+
foreach ($uow->getScheduledCollectionUpdates() as $col) {
63+
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
64+
}
65+
} catch (\Exception $e) {
66+
Log::error('Audit event listener failed', [
67+
'error' => $e->getMessage(),
68+
'strategy_class' => get_class($strategy),
69+
'trace' => $e->getTraceAsString(),
70+
]);
71+
}
72+
}
73+
74+
/**
75+
* Get the appropriate audit strategy based on environment configuration
76+
*/
77+
private function getAuditStrategy($em): ?IAuditStrategy
78+
{
79+
// Check if OTLP audit is enabled
80+
if (config('opentelemetry.enabled', false)) {
81+
try {
82+
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogOtlpStrategy");
83+
return App::make(AuditLogOtlpStrategy::class);
84+
} catch (\Exception $e) {
85+
Log::warning('Failed to create OTLP audit strategy, falling back to database', [
86+
'error' => $e->getMessage()
87+
]);
88+
}
89+
}
90+
91+
// Use database strategy (either as default or fallback)
92+
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogStrategy");
93+
return new AuditLogStrategy($em);
94+
}
95+
96+
private function buildAuditContext(): AuditContext
97+
{
98+
$userId = app(IResourceServerContext::class)->getCurrentUserId();
99+
100+
/**
101+
* @var User|null $user
102+
*/
103+
$user = $userId ? app(IUserRepository::class)->getById($userId) : null;
104+
105+
$defaultUiContext = [
106+
'app' => null,
107+
'flow' => null
108+
];
109+
$uiContext = [
110+
...$defaultUiContext,
111+
// ...app()->bound('ui.context') ? app('ui.context') : [],
112+
];
113+
114+
$req = request();
115+
$rawRoute = null;
116+
// does not resolve the route when app is running in console mode
117+
if ($req instanceof Request && !app()->runningInConsole()) {
118+
try {
119+
$route = Route::getRoutes()->match($req);
120+
$method = $route->methods[0] ?? 'UNKNOWN';
121+
$rawRoute = $method . self::ROUTE_METHOD_SEPARATOR . $route->uri;
122+
} catch (\Exception $e) {
123+
Log::warning($e);
124+
}
125+
}
126+
127+
return new AuditContext(
128+
userId: $user?->getId(),
129+
userEmail: $user?->getEmail(),
130+
userFirstName: $user?->getFirstName(),
131+
userLastName: $user?->getLastName(),
132+
uiApp: $uiContext['app'],
133+
uiFlow: $uiContext['flow'],
134+
route: $req?->path(),
135+
httpMethod: $req?->method(),
136+
clientIp: $req?->ip(),
137+
userAgent: $req?->userAgent(),
138+
rawRoute: $rawRoute
139+
);
140+
}
141+
}

0 commit comments

Comments
 (0)