Skip to content

Commit 3bd244e

Browse files
committed
feat: add Doctrine logging generic classes
1 parent f13ba45 commit 3bd244e

19 files changed

+1344
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Audit;
4+
5+
/**
6+
* Copyright 2025 OpenStack Foundation
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
**/
17+
18+
abstract class AbstractAuditLogFormatter implements IAuditLogFormatter
19+
{
20+
protected AuditContext $ctx;
21+
22+
final public function setContext(AuditContext $ctx): void
23+
{
24+
$this->ctx = $ctx;
25+
}
26+
27+
protected function getUserInfo(): string
28+
{
29+
if (!$this->ctx) {
30+
return 'Unknown (unknown)';
31+
}
32+
33+
$user_name = 'Unknown';
34+
if ($this->ctx->userFirstName || $this->ctx->userLastName) {
35+
$user_name = trim(sprintf("%s %s", $this->ctx->userFirstName ?? '', $this->ctx->userLastName ?? '')) ?: 'Unknown';
36+
} elseif ($this->ctx->userEmail) {
37+
$user_name = $this->ctx->userEmail;
38+
}
39+
40+
$user_id = $this->ctx->userId ?? 'unknown';
41+
return sprintf("%s (%s)", $user_name, $user_id);
42+
}
43+
44+
abstract public function format($subject, array $change_set): ?string;
45+
}

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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 Doctrine\ORM\Event\OnFlushEventArgs;
18+
use Illuminate\Support\Facades\App;
19+
use Illuminate\Support\Facades\Log;
20+
use Illuminate\Support\Facades\Route;
21+
use Illuminate\Http\Request;
22+
/**
23+
* Class AuditEventListener
24+
* @package App\Audit
25+
*/
26+
class AuditEventListener
27+
{
28+
private const ROUTE_METHOD_SEPARATOR = '|';
29+
30+
public function onFlush(OnFlushEventArgs $eventArgs): void
31+
{
32+
if (app()->environment('testing')) {
33+
return;
34+
}
35+
$em = $eventArgs->getObjectManager();
36+
$uow = $em->getUnitOfWork();
37+
// Strategy selection based on environment configuration
38+
$strategy = $this->getAuditStrategy($em);
39+
if (!$strategy) {
40+
return; // No audit strategy enabled
41+
}
42+
43+
$ctx = $this->buildAuditContext();
44+
45+
try {
46+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
47+
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_CREATION, $ctx);
48+
}
49+
50+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
51+
$strategy->audit($entity, $uow->getEntityChangeSet($entity), IAuditStrategy::EVENT_ENTITY_UPDATE, $ctx);
52+
}
53+
54+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
55+
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
56+
}
57+
58+
foreach ($uow->getScheduledCollectionUpdates() as $col) {
59+
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
60+
}
61+
} catch (\Exception $e) {
62+
Log::error('Audit event listener failed', [
63+
'error' => $e->getMessage(),
64+
'strategy_class' => get_class($strategy),
65+
'trace' => $e->getTraceAsString(),
66+
]);
67+
}
68+
}
69+
70+
/**
71+
* Get the appropriate audit strategy based on environment configuration
72+
*/
73+
private function getAuditStrategy($em): ?IAuditStrategy
74+
{
75+
// Check if OTLP audit is enabled
76+
if (config('opentelemetry.enabled', false)) {
77+
try {
78+
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogOtlpStrategy");
79+
return App::make(AuditLogOtlpStrategy::class);
80+
} catch (\Exception $e) {
81+
Log::warning('Failed to create OTLP audit strategy, falling back to database', [
82+
'error' => $e->getMessage()
83+
]);
84+
}
85+
}
86+
87+
// Use database strategy (either as default or fallback)
88+
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogStrategy");
89+
return new AuditLogStrategy($em);
90+
}
91+
92+
private function buildAuditContext(): AuditContext
93+
{
94+
$resourceCtx = app(\models\oauth2\IResourceServerContext::class);
95+
$userExternalId = $resourceCtx->getCurrentUserId();
96+
$member = null;
97+
if ($userExternalId) {
98+
$memberRepo = app(\models\main\IMemberRepository::class);
99+
$member = $memberRepo->findOneBy(["user_external_id" => $userExternalId]);
100+
}
101+
102+
//$ui = app()->bound('ui.context') ? app('ui.context') : [];
103+
104+
$req = request();
105+
$rawRoute = null;
106+
// does not resolve the route when app is running in console mode
107+
if ($req instanceof Request && !app()->runningInConsole()) {
108+
try {
109+
$route = Route::getRoutes()->match($req);
110+
$method = $route->methods[0] ?? 'UNKNOWN';
111+
$rawRoute = $method . self::ROUTE_METHOD_SEPARATOR . $route->uri;
112+
} catch (\Exception $e) {
113+
Log::warning($e);
114+
}
115+
}
116+
117+
return new AuditContext(
118+
userId: $member?->getId(),
119+
userEmail: $member?->getEmail(),
120+
userFirstName: $member?->getFirstName(),
121+
userLastName: $member?->getLastName(),
122+
uiApp: $ui['app'] ?? null,
123+
uiFlow: $ui['flow'] ?? null,
124+
route: $req?->path(),
125+
httpMethod: $req?->method(),
126+
clientIp: $req?->ip(),
127+
userAgent: $req?->userAgent(),
128+
rawRoute: $rawRoute
129+
);
130+
}
131+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php namespace App\Audit;
2+
/**
3+
* Copyright 2025 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
use App\Audit\ConcreteFormatters\ChildEntityFormatters\ChildEntityFormatterFactory;
15+
use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter;
16+
use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter;
17+
use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter;
18+
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
19+
use App\Audit\Interfaces\IAuditStrategy;
20+
use Doctrine\ORM\PersistentCollection;
21+
use Illuminate\Support\Facades\Log;
22+
use Doctrine\ORM\Mapping\ClassMetadata;
23+
class AuditLogFormatterFactory implements IAuditLogFormatterFactory
24+
{
25+
26+
private array $config;
27+
28+
public function __construct()
29+
{
30+
// cache the config so we don't hit config() repeatedly
31+
$this->config = config('audit_log', []);
32+
}
33+
34+
public function make(AuditContext $ctx, $subject, $eventType): ?IAuditLogFormatter
35+
{
36+
$formatter = null;
37+
switch ($eventType) {
38+
case IAuditStrategy::EVENT_COLLECTION_UPDATE:
39+
$child_entity_formatter = null;
40+
41+
if ($subject instanceof PersistentCollection) {
42+
$targetEntity = null;
43+
Log::debug
44+
(
45+
sprintf
46+
(
47+
"AuditLogFormatterFactory::make subject is a PersistentCollection isInitialized %b ?",
48+
$subject->isInitialized()
49+
)
50+
);
51+
if (method_exists($subject, 'getTypeClass')) {
52+
$type = $subject->getTypeClass();
53+
// Your log shows this is ClassMetadata
54+
if ($type instanceof ClassMetadata) {
55+
// Doctrine supports either getName() or public $name
56+
$targetEntity = method_exists($type, 'getName') ? $type->getName() : ($type->name ?? null);
57+
} elseif (is_string($type)) {
58+
$targetEntity = $type;
59+
}
60+
Log::debug("AuditLogFormatterFactory::make getTypeClass targetEntity {$targetEntity}");
61+
}
62+
elseif (method_exists($subject, 'getMapping')) {
63+
$mapping = $subject->getMapping();
64+
$targetEntity = $mapping['targetEntity'] ?? null;
65+
Log::debug("AuditLogFormatterFactory::make getMapping targetEntity {$targetEntity}");
66+
} else {
67+
// last-resort: read private association metadata (still no hydration)
68+
$ref = new \ReflectionObject($subject);
69+
foreach (['association', 'mapping', 'associationMapping'] as $propName) {
70+
if ($ref->hasProperty($propName)) {
71+
$prop = $ref->getProperty($propName);
72+
$prop->setAccessible(true);
73+
$mapping = $prop->getValue($subject);
74+
$targetEntity = $mapping['targetEntity'] ?? null;
75+
if ($targetEntity) break;
76+
}
77+
}
78+
}
79+
80+
if ($targetEntity) {
81+
// IMPORTANT: build formatter WITHOUT touching collection items
82+
$child_entity_formatter = ChildEntityFormatterFactory::build($targetEntity);
83+
}
84+
Log::debug
85+
(
86+
sprintf
87+
(
88+
"AuditLogFormatterFactory::make subject is a PersistentCollection isInitialized %b ? ( final )",
89+
$subject->isInitialized()
90+
)
91+
);
92+
} elseif (is_array($subject)) {
93+
$child_entity = $subject[0] ?? null;
94+
$child_entity_formatter = $child_entity ? ChildEntityFormatterFactory::build($child_entity) : null;
95+
} elseif (is_object($subject) && method_exists($subject, 'getSnapshot')) {
96+
$snap = $subject->getSnapshot(); // only once
97+
$child_entity = $snap[0] ?? null;
98+
$child_entity_formatter = $child_entity ? ChildEntityFormatterFactory::build($child_entity) : null;
99+
}
100+
101+
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
102+
break;
103+
case IAuditStrategy::EVENT_ENTITY_CREATION:
104+
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
105+
if(is_null($formatter)) {
106+
$formatter = new EntityCreationAuditLogFormatter();
107+
}
108+
break;
109+
case IAuditStrategy::EVENT_ENTITY_DELETION:
110+
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
111+
if(is_null($formatter)) {
112+
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
113+
$formatter = new EntityDeletionAuditLogFormatter($child_entity_formatter);
114+
}
115+
break;
116+
case IAuditStrategy::EVENT_ENTITY_UPDATE:
117+
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
118+
if(is_null($formatter)) {
119+
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
120+
$formatter = new EntityUpdateAuditLogFormatter($child_entity_formatter);
121+
}
122+
break;
123+
}
124+
if ($formatter === null) return null;
125+
$formatter->setContext($ctx);
126+
return $formatter;
127+
}
128+
129+
private function getFormatterByContext(object $subject, string $event_type, AuditContext $ctx): ?IAuditLogFormatter
130+
{
131+
$class = get_class($subject);
132+
$entity_config = $this->config['entities'][$class] ?? null;
133+
134+
if (!$entity_config) {
135+
return null;
136+
}
137+
138+
if (isset($entity_config['strategies'])) {
139+
foreach ($entity_config['strategies'] as $strategy) {
140+
if (!$this->matchesStrategy($strategy, $ctx)) {
141+
continue;
142+
}
143+
144+
$formatter_class = $strategy['formatter'] ?? null;
145+
return $formatter_class ? new $formatter_class($event_type) : null;
146+
}
147+
}
148+
149+
if (isset($entity_config['strategy'])) {
150+
$strategy_class = $entity_config['strategy'];
151+
return new $strategy_class($event_type);
152+
}
153+
154+
return null;
155+
}
156+
157+
private function matchesStrategy(array $strategy, AuditContext $ctx): bool
158+
{
159+
if (isset($strategy['route']) && !$this->routeMatches($strategy['route'], $ctx->rawRoute)) {
160+
return false;
161+
}
162+
163+
return true;
164+
}
165+
166+
private function routeMatches(string $route, string $actual_route): bool
167+
{
168+
return strcmp($actual_route, $route) === 0;
169+
}
170+
}

0 commit comments

Comments
 (0)