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
36 changes: 36 additions & 0 deletions ProcessMaker/Auth/PassportTokenGuardFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace ProcessMaker\Auth;

use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Guards\TokenGuard;
use Laravel\Passport\PassportUserProvider;
use League\OAuth2\Server\ResourceServer;

/**
* Build Passport's TokenGuard from the given application container.
*
* Laravel Passport's default guard factory closes over the service provider's
* root application. Under Octane that is the worker app, not the per-request
* sandbox — so the guard keeps the landlord Encrypter after SwitchTenant
* swaps APP_KEY. Resolving from the current app fixes cookie API 401s.
*/
class PassportTokenGuardFactory
{
public function make(Application $app, array $config): TokenGuard
{
return tap(new TokenGuard(
$app->make(ResourceServer::class),
new PassportUserProvider(Auth::createUserProvider($config['provider']), $config['provider']),
$app->make(ClientRepository::class),
$app->make('encrypter'),
$app->make('request')
), function (TokenGuard $guard) use ($app): void {
$app->refresh('request', $guard, 'setRequest');
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@

class TenantAwareBroadcastManager extends BroadcastManager
{
private int $tenantId;

public function __construct($app, int $tenantId)
{
parent::__construct($app);
$this->tenantId = $tenantId;
}

public function createPusherDriver($config)
/**
* Create an instance of the driver.
*
* @param array $config
* @return \Illuminate\Contracts\Broadcasting\Broadcaster
*/
protected function createPusherDriver(array $config)
{
return new TenantAwarePusherBroadcaster($this->pusher($config), $this->tenantId);
return new TenantAwarePusherBroadcaster($this->pusher($config), $config['jsonp'] ?? false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,99 @@
namespace ProcessMaker\Multitenancy\Broadcasting;

use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
use Pusher\Pusher;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class TenantAwarePusherBroadcaster extends PusherBroadcaster
{
private int $tenantId;
/**
* Authenticate the incoming request for a given channel.
*
* Channel callbacks are registered without a tenant prefix (once per Octane
* worker). Incoming Echo channels are prefixed, so strip the current
* tenant's prefix before matching. Pusher still signs the original name.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*
* @throws AccessDeniedHttpException
*/
public function auth($request)
{
$channelName = $this->normalizeChannelName($request->channel_name);
$channelName = $this->unprefixTenantChannel($channelName);

if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
!$this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}

public function __construct(Pusher $pusher, int $tenantId)
return parent::verifyUserCanAccessChannel(
$request, $channelName
);
}

/**
* @param array $channels
* @return array
*/
protected function formatChannels(array $channels)
{
parent::__construct($pusher);
$this->tenantId = $tenantId;
return array_map(function ($channel) {
return $this->prefixTenantChannel((string) $channel);
}, $channels);
}

public function channel($channel, $callback, $options = [])
private function currentTenantId(): ?int
{
$channel = "tenant_{$this->tenantId}.{$channel}";
$tenant = app()->bound('currentTenant') ? app('currentTenant') : null;

return parent::channel($channel, $callback, $options);
return $tenant?->id ? (int) $tenant->id : null;
}

protected function formatChannels(array $channels)
private function tenantPrefix(): ?string
{
$channels = array_map(function ($channel) {
$channel = (string) $channel;
if ($this->tenantId) {
// Check if channel starts with "private-"
if (str_starts_with($channel, 'private-')) {
return "private-tenant_{$this->tenantId}." . substr($channel, 8); // Remove "private-" prefix and add tenant before the rest
$tenantId = $this->currentTenantId();

return $tenantId ? "tenant_{$tenantId}." : null;
}

private function prefixTenantChannel(string $channel): string
{
$prefix = $this->tenantPrefix();
if ($prefix === null) {
return $channel;
}

foreach (['private-encrypted-', 'private-', 'presence-'] as $guardPrefix) {
if (str_starts_with($channel, $guardPrefix)) {
$name = substr($channel, strlen($guardPrefix));
if (str_starts_with($name, $prefix)) {
return $channel;
}

return "tenant_{$this->tenantId}.{$channel}";
return $guardPrefix . $prefix . $name;
}
}

if (str_starts_with($channel, $prefix)) {
return $channel;
}, $channels);
}

return $prefix . $channel;
}

private function unprefixTenantChannel(string $channelName): string
{
$prefix = $this->tenantPrefix();
if ($prefix === null) {
return $channelName;
}

if (!str_starts_with($channelName, $prefix)) {
throw new AccessDeniedHttpException;
}

return $channels;
return substr($channelName, strlen($prefix));
}
}
38 changes: 29 additions & 9 deletions ProcessMaker/Multitenancy/SwitchTenant.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace ProcessMaker\Multitenancy;

use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Support\Arr;
use Illuminate\Support\Env;
use Monolog\Handler\RotatingFileHandler;
use Laravel\Passport\ApiTokenCookieFactory;
use Laravel\Passport\ClientRepository;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\ResourceServer;
use ProcessMaker\Application;
use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager;
use Spatie\Multitenancy\Concerns\UsesMultitenancyConfig;
use Spatie\Multitenancy\Contracts\IsTenant;
use Spatie\Multitenancy\Tasks\SwitchTenantTask;
Expand Down Expand Up @@ -40,11 +41,6 @@ public function makeCurrent(IsTenant $tenant): void
request()->headers->set('host', $tenant->domain);

$this->overrideConfigs($app, $tenant);

// Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id.
$app->extend(BroadcastManager::class, function ($manager, $app) use ($tenant) {
return new TenantAwareBroadcastManager($app, $tenant->id);
});
}

/**
Expand All @@ -65,7 +61,31 @@ public function forgetCurrent(): void

// app key / encrypter
$this->setConfig('app.key', $this->landlordConfig('app.key'));
$this->flushTenantSensitiveSingletons($app);
}

/**
* Drop container instances that captured the previous tenant's APP_KEY or oauth keys.
*
* Passport's ResourceServer/AuthorizationServer and Encrypter are singletons.
* Under Octane they can outlive a tenant switch and keep signing or verifying
* laravel_token cookies with the wrong key (API 401s).
*/
private function flushTenantSensitiveSingletons(Application $app): void
{
$app->forgetInstance('encrypter');
$app->forgetInstance(ResourceServer::class);
$app->forgetInstance(AuthorizationServer::class);
$app->forgetInstance(ClientRepository::class);
$app->forgetInstance(ApiTokenCookieFactory::class);

if ($app->resolved('auth.driver')) {
$app->forgetInstance('auth.driver');
}

if ($app->resolved('auth')) {
$app->make('auth')->forgetGuards();
}
}

private function landlordConfig($key)
Expand Down Expand Up @@ -130,7 +150,7 @@ private function overrideConfigs(Application $app, IsTenant $tenant)
// app key / encrypter
$landlordEncrypter = $app->make('encrypter');
$this->setConfig('app.key', $landlordEncrypter->decryptString($tenant->config['app.key']));
$app->forgetInstance('encrypter');
$this->flushTenantSensitiveSingletons($app);

// Logging
$this->setConfig('logging.channels.daily.path', storage_path('logs/processmaker.log'));
Expand Down
19 changes: 19 additions & 0 deletions ProcessMaker/Providers/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Passport;
use ProcessMaker\Auth\PassportTokenGuardFactory;
use ProcessMaker\Events\TenantResolved;
use ProcessMaker\Models\AnonymousUser;
use ProcessMaker\Models\Media;
Expand Down Expand Up @@ -64,6 +65,8 @@ public function boot()

Passport::authorizationView('auth.oauth2.authorize');

$this->registerPassportGuard();

Gate::before(function ($user) {
if ($user->is_administrator) {
return true;
Expand Down Expand Up @@ -113,4 +116,20 @@ public function register()
$this->defineGates();
});
}

/**
* Replace Passport's guard so TokenGuard is resolved from the current app.
*
* Passport binds the guard with the service provider's root container.
* Octane clones a sandbox per request and SwitchTenant swaps APP_KEY on
* that sandbox; the root worker still holds the landlord Encrypter.
*/
private function registerPassportGuard(): void
{
Auth::resolved(function ($auth): void {
$auth->extend('passport', function ($app, $name, array $config) {
return $app->make(PassportTokenGuardFactory::class)->make($app, $config);
});
});
}
}
28 changes: 27 additions & 1 deletion ProcessMaker/Providers/BroadcastServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

namespace ProcessMaker\Providers;

use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager;

class BroadcastServiceProvider extends ServiceProvider
{
Expand All @@ -14,7 +18,29 @@ class BroadcastServiceProvider extends ServiceProvider
*/
public function boot()
{
Broadcast::routes(['middleware'=>['web', 'auth:anon']]);
if (config('app.multitenancy')) {
$this->useTenantAwareBroadcastManager();
}

Broadcast::routes(['middleware' => ['web', 'auth:anon']]);
require base_path('routes/channels.php');
}

/**
* Replace Laravel's deferred BroadcastManager after it has registered, so
* channel callbacks stay on one driver instance for the life of the Octane
* worker. Tenant prefixes are applied at auth/broadcast time instead.
*/
private function useTenantAwareBroadcastManager(): void
{
$this->app->make(BroadcastManager::class);

$manager = new TenantAwareBroadcastManager($this->app);
$this->app->instance(BroadcastManager::class, $manager);
$this->app->instance(BroadcastingFactory::class, $manager);
$this->app->forgetInstance(BroadcasterContract::class);

Broadcast::clearResolvedInstance(BroadcastManager::class);
Broadcast::clearResolvedInstance(BroadcastingFactory::class);
}
}
Loading
Loading