Skip to content

Commit 13cff1e

Browse files
feat: Add listener for inject audit context in jobs (#101)
* feat: Add listener for inyect audit context in jobs * feat: add cleanupjob for jobFailed and JobException. Refactr for constants * chore: change log entry * chore: removed log inline ns --------- Co-authored-by: smarcet <smarcet@gmail.com>
1 parent 5b2608b commit 13cff1e

11 files changed

Lines changed: 912 additions & 19 deletions

.github/workflows/push.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
SSL_ENABLED: false
3232
SESSION_DRIVER: redis
3333
PHP_VERSION: 8.3
34+
OTEL_SDK_DISABLED: true
35+
OTEL_SERVICE_ENABLED: false
3436
services:
3537
mysql:
3638
image: mysql:8.0

app/Audit/AuditContext.php

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
<?php
2-
namespace App\Audit;
1+
<?php namespace App\Audit;
32
/**
4-
* Copyright 2025 OpenStack Foundation
3+
* Copyright 2026 OpenStack Foundation
54
* Licensed under the Apache License, Version 2.0 (the "License");
65
* you may not use this file except in compliance with the License.
76
* You may obtain a copy of the License at
@@ -12,8 +11,19 @@
1211
* See the License for the specific language governing permissions and
1312
* limitations under the License.
1413
**/
14+
15+
use Auth\Repositories\IUserRepository;
16+
use Illuminate\Support\Facades\Auth;
17+
use OAuth2\IResourceServerContext;
18+
use Illuminate\Support\Facades\Log;
1519
class AuditContext
1620
{
21+
22+
public const CONTAINER_KEY = 'audit.context';
23+
24+
25+
public const UI_CONTEXT_CONTAINER_KEY = 'ui.context';
26+
1727
public function __construct(
1828
public ?int $userId = null,
1929
public ?string $userEmail = null,
@@ -28,4 +38,57 @@ public function __construct(
2838
public ?string $userAgent = null,
2939
) {
3040
}
41+
42+
/**
43+
* Get the currently authenticated user from either OAuth2 or UI context
44+
*
45+
* @return \Auth\User|null
46+
*/
47+
public static function getCurrentUser()
48+
{
49+
$resourceContext = app(IResourceServerContext::class);
50+
$clientId = $resourceContext->getCurrentClientId();
51+
$userId = $resourceContext->getCurrentUserId();
52+
53+
if (!empty($clientId) && $userId) {
54+
// OAuth2 context: user authenticated via API
55+
return app(IUserRepository::class)->getById($userId);
56+
}
57+
58+
// UI context: user logged in at IDP
59+
return Auth::user();
60+
}
61+
62+
/**
63+
* Create an AuditContext from the current request
64+
* Handles both OAuth2 and UI authentication contexts
65+
*/
66+
public static function fromCurrentRequest(): ?self
67+
{
68+
try {
69+
$user = self::getCurrentUser();
70+
71+
if (!$user) {
72+
return null;
73+
}
74+
75+
$req = request();
76+
77+
return new self(
78+
userId: $user->getId(),
79+
userEmail: $user->getEmail(),
80+
userFirstName: $user->getFirstName(),
81+
userLastName: $user->getLastName(),
82+
route: $req?->path(),
83+
httpMethod: $req?->method(),
84+
clientIp: $req?->ip(),
85+
userAgent: $req?->userAgent(),
86+
);
87+
} catch (\Exception $e) {
88+
Log::warning('AuditContext::fromCurrentRequest Failed to build audit context from request', [
89+
'error' => $e->getMessage()
90+
]);
91+
return null;
92+
}
93+
}
3194
}

app/Audit/AuditEventListener.php

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@
2424
use Illuminate\Support\Facades\Route;
2525
use Illuminate\Http\Request;
2626
use OAuth2\IResourceServerContext;
27-
use OAuth2\Models\IClient;
28-
use Services\OAuth2\ResourceServerContext;
2927

3028
/**
3129
* Class AuditEventListener
@@ -99,26 +97,23 @@ private function getAuditStrategy($em): ?IAuditStrategy
9997

10098
private function buildAuditContext(): AuditContext
10199
{
100+
if (app()->runningInConsole()) {
101+
if (app()->bound(AuditContext::CONTAINER_KEY)) {
102+
$context = app(AuditContext::CONTAINER_KEY);
103+
if ($context instanceof AuditContext) {
104+
return $context;
105+
}
106+
}
107+
}
108+
102109
/***
103110
* here we have 2 cases
104-
* 1. we are connecting to the IDP using an external APi ( under oauth2 ) so the
111+
* 1. we are connecting to the IDP using an external API ( under oauth2 ) so the
105112
* resource context have a client id and have a user id
106113
* 2. we are logged at idp and using the UI ( $user = Auth::user() )
107114
***/
108115

109-
$resource_server_context = app(IResourceServerContext::class);
110-
$oauth2_current_client_id = $resource_server_context->getCurrentClientId();
111-
112-
if(!empty($oauth2_current_client_id)) {
113-
$userId = $resource_server_context->getCurrentUserId();
114-
// here $userId can be null bc
115-
// $resource_server_context->getApplicationType() == IClient::ApplicationType_Service
116-
$user = $userId ? app(IUserRepository::class)->getById($userId) : null;
117-
}
118-
else{
119-
// 2. we are at IDP UI
120-
$user = Auth::user();
121-
}
116+
$user = AuditContext::getCurrentUser();
122117

123118
$defaultUiContext = [
124119
'app' => null,
@@ -157,4 +152,6 @@ private function buildAuditContext(): AuditContext
157152
rawRoute: $rawRoute
158153
);
159154
}
155+
156+
160157
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
namespace App\Listeners;
3+
4+
/**
5+
* Copyright 2026 OpenStack Foundation
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
**/
16+
17+
use App\Audit\AuditContext;
18+
use Illuminate\Queue\Events\JobProcessed;
19+
use Illuminate\Queue\Events\JobFailed;
20+
use Illuminate\Queue\Events\JobExceptionOccurred;
21+
use Illuminate\Support\Facades\Log;
22+
23+
/**
24+
* Cleans up audit context after job processing to prevent context leakage between jobs
25+
*/
26+
class CleanupJobAuditContextListener
27+
{
28+
public function handleJobProcessed(JobProcessed $event): void
29+
{
30+
$this->cleanup(get_class($event->job));
31+
}
32+
33+
public function handleJobFailed(JobFailed $event): void
34+
{
35+
$this->cleanup(get_class($event->job));
36+
}
37+
38+
public function handleJobExceptionOccurred(JobExceptionOccurred $event): void
39+
{
40+
$this->cleanup(get_class($event->job));
41+
}
42+
43+
private function cleanup(string $jobClass): void
44+
{
45+
if (!config('opentelemetry.enabled', false)) {
46+
return;
47+
}
48+
49+
try {
50+
if (app()->bound(AuditContext::CONTAINER_KEY)) {
51+
app()->forgetInstance(AuditContext::CONTAINER_KEY);
52+
Log::debug('CleanupJobAuditContextListener::cleanup audit context cleaned after job', [
53+
'job' => $jobClass,
54+
]);
55+
}
56+
} catch (\Exception $e) {
57+
Log::warning('CleanupJobAuditContextListener::cleanup failed', [
58+
'error' => $e->getMessage(),
59+
]);
60+
}
61+
}
62+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
namespace App\Listeners;
3+
4+
/**
5+
* Copyright 2026 OpenStack Foundation
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
**/
16+
17+
use App\Audit\AuditContext;
18+
use Illuminate\Queue\Events\JobProcessing;
19+
use Illuminate\Support\Facades\Log;
20+
21+
class RestoreJobAuditContextListener
22+
{
23+
private const PAYLOAD_DATA_KEY = 'data';
24+
private const PAYLOAD_CONTEXT_KEY = 'auditContext';
25+
private const LOG_CONTEXT_KEY = 'event_name';
26+
private const LOG_CONTEXT_VALUE = 'job.processing';
27+
28+
public function handle(JobProcessing $event): void
29+
{
30+
if (!$this->isOpenTelemetryEnabled()) {
31+
return;
32+
}
33+
34+
try {
35+
$context = $this->extractContextFromPayload($event->job->payload());
36+
37+
if ($context !== null) {
38+
app()->instance(AuditContext::CONTAINER_KEY, $context);
39+
}
40+
} catch (\Exception $e) {
41+
Log::warning('RestoreJobAuditContextListener::handle Failed to restore audit context from queue job', [
42+
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
43+
'exception_message' => $e->getMessage(),
44+
'exception_class' => get_class($e),
45+
]);
46+
}
47+
}
48+
49+
private function isOpenTelemetryEnabled(): bool
50+
{
51+
return config('opentelemetry.enabled', false);
52+
}
53+
54+
private function extractContextFromPayload(array $payload): ?AuditContext
55+
{
56+
if (!isset($payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY])) {
57+
return null;
58+
}
59+
60+
try {
61+
$context = unserialize(
62+
$payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY],
63+
['allowed_classes' => [AuditContext::class]]
64+
);
65+
66+
if (!$context instanceof AuditContext) {
67+
Log::warning('RestoreJobAuditContextListener::extractContextFromPayload Invalid audit context type in job payload', [
68+
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
69+
'actual_type' => gettype($context),
70+
]);
71+
return null;
72+
}
73+
74+
return $context;
75+
} catch (\Exception $e) {
76+
Log::warning('RestoreJobAuditContextListener::extractContextFromPayload Failed to unserialize audit context from job payload', [
77+
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
78+
'exception_message' => $e->getMessage(),
79+
]);
80+
return null;
81+
}
82+
}
83+
}

app/Providers/EventServiceProvider.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use App\Events\UserPasswordResetRequestCreated;
2121
use App\Events\UserPasswordResetSuccessful;
2222
use App\Events\UserSpamStateUpdated;
23+
use App\Audit\AuditContext;
2324
use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository;
2425
use App\Mail\UserLockedEmail;
2526
use App\Mail\UserPasswordResetMail;
@@ -30,12 +31,16 @@
3031
use Illuminate\Database\Events\MigrationsStarted;
3132
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
3233
use Illuminate\Support\Facades\App;
34+
use Illuminate\Support\Facades\Auth;
3335
use Illuminate\Support\Facades\Config;
3436
use Illuminate\Support\Facades\DB;
3537
use Illuminate\Support\Facades\Event;
38+
use Illuminate\Support\Facades\Log;
3639
use Illuminate\Support\Facades\Mail;
40+
use Illuminate\Support\Facades\Queue;
3741
use Models\OAuth2\Client;
3842
use OAuth2\Repositories\IClientRepository;
43+
use OAuth2\IResourceServerContext;
3944

4045
/**
4146
* Class EventServiceProvider
@@ -57,6 +62,18 @@ final class EventServiceProvider extends ServiceProvider
5762
'Illuminate\Auth\Events\Login' => [
5863
'App\Listeners\OnUserLogin',
5964
],
65+
\Illuminate\Queue\Events\JobProcessing::class => [
66+
'App\Listeners\RestoreJobAuditContextListener',
67+
],
68+
\Illuminate\Queue\Events\JobProcessed::class => [
69+
'App\Listeners\CleanupJobAuditContextListener@handleJobProcessed',
70+
],
71+
\Illuminate\Queue\Events\JobFailed::class => [
72+
'App\Listeners\CleanupJobAuditContextListener@handleJobFailed',
73+
],
74+
\Illuminate\Queue\Events\JobExceptionOccurred::class => [
75+
'App\Listeners\CleanupJobAuditContextListener@handleJobExceptionOccurred',
76+
],
6077
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
6178
// ... other providers
6279
'SocialiteProviders\\Facebook\\FacebookExtendSocialite@handle',
@@ -77,6 +94,21 @@ public function boot()
7794
{
7895
parent::boot();
7996

97+
if (config('opentelemetry.enabled', false)) {
98+
Queue::createPayloadUsing(function ($connection, $queue, $payload) {
99+
try {
100+
$context = AuditContext::fromCurrentRequest();
101+
102+
if ($context) {
103+
$payload['data']['auditContext'] = serialize($context);
104+
}
105+
} catch (\Exception $e) {
106+
Log::warning('EventServiceProvider::boot Failed to attach audit context to job', ['error' => $e->getMessage()]);
107+
}
108+
return $payload;
109+
});
110+
}
111+
80112
Event::listen(UserEmailVerified::class, function($event)
81113
{
82114
$service = App::make(IUserService::class);

0 commit comments

Comments
 (0)