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
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
69 changes: 66 additions & 3 deletions app/Audit/AuditContext.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<?php
namespace App\Audit;
<?php namespace App\Audit;
/**
* Copyright 2025 OpenStack Foundation
* 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
Expand All @@ -12,8 +11,19 @@
* 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;
use Illuminate\Support\Facades\Log;
class AuditContext
{

public const CONTAINER_KEY = 'audit.context';


public const UI_CONTEXT_CONTAINER_KEY = 'ui.context';

public function __construct(
public ?int $userId = null,
public ?string $userEmail = null,
Expand All @@ -28,4 +38,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) {
Log::warning('AuditContext::fromCurrentRequest 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(AuditContext::CONTAINER_KEY)) {
$context = app(AuditContext::CONTAINER_KEY);
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
);
}


}
62 changes: 62 additions & 0 deletions app/Listeners/CleanupJobAuditContextListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?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\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobExceptionOccurred;
use Illuminate\Support\Facades\Log;

/**
* Cleans up audit context after job processing to prevent context leakage between jobs
*/
class CleanupJobAuditContextListener
{
public function handleJobProcessed(JobProcessed $event): void
{
$this->cleanup(get_class($event->job));
}

public function handleJobFailed(JobFailed $event): void
{
$this->cleanup(get_class($event->job));
}

public function handleJobExceptionOccurred(JobExceptionOccurred $event): void
{
$this->cleanup(get_class($event->job));
}

private function cleanup(string $jobClass): void
{
if (!config('opentelemetry.enabled', false)) {
return;
}

try {
if (app()->bound(AuditContext::CONTAINER_KEY)) {
app()->forgetInstance(AuditContext::CONTAINER_KEY);
Log::debug('CleanupJobAuditContextListener::cleanup audit context cleaned after job', [
'job' => $jobClass,
]);
}
} catch (\Exception $e) {
Log::warning('CleanupJobAuditContextListener::cleanup failed', [
'error' => $e->getMessage(),
]);
}
}
}
83 changes: 83 additions & 0 deletions app/Listeners/RestoreJobAuditContextListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?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 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(AuditContext::CONTAINER_KEY, $context);
}
} catch (\Exception $e) {
Log::warning('RestoreJobAuditContextListener::handle 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('RestoreJobAuditContextListener::extractContextFromPayload 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('RestoreJobAuditContextListener::extractContextFromPayload Failed to unserialize audit context from job payload', [
self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE,
'exception_message' => $e->getMessage(),
]);
return null;
}
}
}
32 changes: 32 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,18 @@ 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@handleJobProcessed',
],
\Illuminate\Queue\Events\JobFailed::class => [
'App\Listeners\CleanupJobAuditContextListener@handleJobFailed',
],
\Illuminate\Queue\Events\JobExceptionOccurred::class => [
'App\Listeners\CleanupJobAuditContextListener@handleJobExceptionOccurred',
],
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
// ... other providers
'SocialiteProviders\\Facebook\\FacebookExtendSocialite@handle',
Expand All @@ -77,6 +94,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('EventServiceProvider::boot 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
Loading
Loading