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
2 changes: 2 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
SSL_ENABLED: false
SESSION_DRIVER: redis
PHP_VERSION: 8.3
OTEL_SDK_DISABLED: true
OTEL_SERVICE_ENABLED: false
services:
mysql:
image: mysql:8.0
Expand Down
58 changes: 58 additions & 0 deletions app/Audit/AuditContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\Repositories\IUserRepository;
use Illuminate\Support\Facades\Auth;
use OAuth2\IResourceServerContext;

class AuditContext
{
public function __construct(
Expand All @@ -28,4 +33,57 @@ public function __construct(
public ?string $userAgent = null,
) {
}

/**
* Get the currently authenticated user from either OAuth2 or UI context
*
* @return \Auth\User|null
*/
public static function getCurrentUser()
{
$resourceContext = app(IResourceServerContext::class);
$clientId = $resourceContext->getCurrentClientId();
$userId = $resourceContext->getCurrentUserId();

if (!empty($clientId) && $userId) {
// OAuth2 context: user authenticated via API
return app(IUserRepository::class)->getById($userId);
}

// UI context: user logged in at IDP
return Auth::user();
}

/**
* Create an AuditContext from the current request
* Handles both OAuth2 and UI authentication contexts
*/
public static function fromCurrentRequest(): ?self
{
try {
$user = self::getCurrentUser();

if (!$user) {
return null;
}

$req = request();

return new self(
userId: $user->getId(),
userEmail: $user->getEmail(),
userFirstName: $user->getFirstName(),
userLastName: $user->getLastName(),
route: $req?->path(),
httpMethod: $req?->method(),
clientIp: $req?->ip(),
userAgent: $req?->userAgent(),
);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning('Failed to build audit context from request', [
'error' => $e->getMessage()
]);
return null;
}
}
}
29 changes: 13 additions & 16 deletions app/Audit/AuditEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use OAuth2\IResourceServerContext;
use OAuth2\Models\IClient;
use Services\OAuth2\ResourceServerContext;

/**
* Class AuditEventListener
Expand Down Expand Up @@ -99,26 +97,23 @@ private function getAuditStrategy($em): ?IAuditStrategy

private function buildAuditContext(): AuditContext
{
if (app()->runningInConsole()) {
if (app()->bound('audit.context')) {
$context = app('audit.context');
if ($context instanceof AuditContext) {
return $context;
}
}
}

/***
* here we have 2 cases
* 1. we are connecting to the IDP using an external APi ( under oauth2 ) so the
* 1. we are connecting to the IDP using an external API ( under oauth2 ) so the
* resource context have a client id and have a user id
* 2. we are logged at idp and using the UI ( $user = Auth::user() )
***/

$resource_server_context = app(IResourceServerContext::class);
$oauth2_current_client_id = $resource_server_context->getCurrentClientId();

if(!empty($oauth2_current_client_id)) {
$userId = $resource_server_context->getCurrentUserId();
// here $userId can be null bc
// $resource_server_context->getApplicationType() == IClient::ApplicationType_Service
$user = $userId ? app(IUserRepository::class)->getById($userId) : null;
}
else{
// 2. we are at IDP UI
$user = Auth::user();
}
$user = AuditContext::getCurrentUser();

$defaultUiContext = [
'app' => null,
Expand Down Expand Up @@ -157,4 +152,6 @@ private function buildAuditContext(): AuditContext
rawRoute: $rawRoute
);
}


}
44 changes: 44 additions & 0 deletions app/Listeners/CleanupJobAuditContextListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
namespace App\Listeners;

/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Support\Facades\Log;

/**
* Cleans up audit context after job processing to prevent context leakage between jobs
*/
class CleanupJobAuditContextListener
{
public function handle(JobProcessed $event): void
{
if (!config('opentelemetry.enabled', false)) {
return;
}

try {
if (app()->bound('audit.context')) {
app()->forgetInstance('audit.context');
Log::debug('CleanupJobAuditContextListener: audit context cleaned after job', [
'job' => get_class($event->job),
]);
}
} catch (\Exception $e) {
Log::warning('CleanupJobAuditContextListener failed', [
'error' => $e->getMessage(),
]);
}
}
}
84 changes: 84 additions & 0 deletions app/Listeners/RestoreJobAuditContextListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php
namespace App\Listeners;

/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Audit\AuditContext;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\Log;

class RestoreJobAuditContextListener
{
private const CONTAINER_BINDING_KEY = 'audit.context';
private const PAYLOAD_DATA_KEY = 'data';
private const PAYLOAD_CONTEXT_KEY = 'auditContext';
private const LOG_CONTEXT_KEY = 'event_name';
private const LOG_CONTEXT_VALUE = 'job.processing';

public function handle(JobProcessing $event): void
{
if (!$this->isOpenTelemetryEnabled()) {
return;
}

try {
$context = $this->extractContextFromPayload($event->job->payload());

if ($context !== null) {
app()->instance(self::CONTAINER_BINDING_KEY, $context);
}
} catch (\Exception $e) {
Log::warning('Failed to restore audit context from queue job', [
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
'exception_message' => $e->getMessage(),
'exception_class' => get_class($e),
]);
}
}

private function isOpenTelemetryEnabled(): bool
{
return config('opentelemetry.enabled', false);
}

private function extractContextFromPayload(array $payload): ?AuditContext
{
if (!isset($payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY])) {
return null;
}

try {
$context = unserialize(
$payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY],
['allowed_classes' => [AuditContext::class]]
);

if (!$context instanceof AuditContext) {
Log::warning('Invalid audit context type in job payload', [
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
'actual_type' => gettype($context),
]);
return null;
}

return $context;
} catch (\Exception $e) {
Log::warning('Failed to unserialize audit context from job payload', [
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
'exception_message' => $e->getMessage(),
]);
return null;
}
}
}
26 changes: 26 additions & 0 deletions app/Providers/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use App\Events\UserPasswordResetRequestCreated;
use App\Events\UserPasswordResetSuccessful;
use App\Events\UserSpamStateUpdated;
use App\Audit\AuditContext;
use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository;
use App\Mail\UserLockedEmail;
use App\Mail\UserPasswordResetMail;
Expand All @@ -30,12 +31,16 @@
use Illuminate\Database\Events\MigrationsStarted;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Models\OAuth2\Client;
use OAuth2\Repositories\IClientRepository;
use OAuth2\IResourceServerContext;

/**
* Class EventServiceProvider
Expand All @@ -57,6 +62,12 @@ final class EventServiceProvider extends ServiceProvider
'Illuminate\Auth\Events\Login' => [
'App\Listeners\OnUserLogin',
],
\Illuminate\Queue\Events\JobProcessing::class => [
'App\Listeners\RestoreJobAuditContextListener',
],
\Illuminate\Queue\Events\JobProcessed::class => [
'App\Listeners\CleanupJobAuditContextListener',
],
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
// ... other providers
'SocialiteProviders\\Facebook\\FacebookExtendSocialite@handle',
Expand All @@ -77,6 +88,21 @@ public function boot()
{
parent::boot();

if (config('opentelemetry.enabled', false)) {
Queue::createPayloadUsing(function ($connection, $queue, $payload) {
try {
$context = AuditContext::fromCurrentRequest();

if ($context) {
$payload['data']['auditContext'] = serialize($context);
}
} catch (\Exception $e) {
Log::warning('Failed to attach audit context to job', ['error' => $e->getMessage()]);
}
return $payload;
});
}

Event::listen(UserEmailVerified::class, function($event)
{
$service = App::make(IUserService::class);
Expand Down
74 changes: 74 additions & 0 deletions tests/Jobs/TestAuditContextJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Tests\Jobs;

/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class TestAuditContextJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public string $testData;
public ?int $timeout = null;
public ?int $tries = null;

public function __construct(string $testData)
{
$this->testData = $testData;
}

public function handle(): void
{
if (app()->bound('audit.context')) {
$context = app('audit.context');
Log::info('TestAuditContextJob processed', [
'user_id' => $context->userId,
'user_email' => $context->userEmail,
]);
}
}

public function payload(): array
{
return [
'data' => [
'testData' => $this->testData,
],
'displayName' => self::class,
'job' => 'Illuminate\\Queue\\CallQueuedHandler@call',
'maxTries' => $this->tries,
'timeout' => $this->timeout,
'timeoutAt' => null,
];
}

public function timeout(int $seconds): self
{
$this->timeout = $seconds;
return $this;
}

public function tries(int $tries): self
{
$this->tries = $tries;
return $this;
}
}
Loading
Loading