1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-08-06 16:54:05 +00:00

Merge branch 'master' into broadcasting-fixes

This commit is contained in:
lukinovec 2026-07-10 11:10:01 +02:00 committed by GitHub
commit a19d3b532f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 799 additions and 24 deletions

View file

@ -53,8 +53,8 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
{
/** @var TenantWithDatabase $tenant */
if (data_get($tenant->database()->getTemplateConnection(), 'url')) {
// The package works with individual parts of the database connection config, so DATABASE_URL is not supported.
// When DATABASE_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
// The package works with individual parts of the database connection config, so DB_URL is not supported.
// When DB_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
// which takes precedence over individual segments of the connection config. This issue can be hard to debug as it can be
// production-specific. Therefore, we throw an exception (that effectively blocks all tenant pages) to prevent incorrect DB use.
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');

View file

@ -255,4 +255,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
$sessionManager->getDrivers()['file']->setHandler($handler);
}
}
/** Get the central storage path from the bound singleton instance of this class. */
public static function getBoundCentralStoragePath(): string
{
return app(static::class)->originalStoragePath;
}
}

View file

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Log\LogManager;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* Use tenant-specific logging channels.
*
* Channels included in the $storagePathChannels property will be configured
* to write logs into the tenant's storage directory. The list includes
* Laravel's 'single' and 'daily' channels by default. To customize it,
* see the property's docblock.
*
* For the storage path channels to be scoped correctly:
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper,
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
* must be true), since the storage path suffix is what separates filesystem-based logs
*
* For logging channels that are not filesystem-based, see the $channelOverrides logic.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
class LogChannelBootstrapper implements TenancyBootstrapper
{
protected array $defaultConfig = [];
protected array $configuredChannels = [];
/**
* Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily').
*
* Channels included here will be configured to use tenant-specific storage paths
* created using storage_path() in the tenant context. Overrides in the $channelOverrides
* property take precedence over $storagePathChannels when a channel is included in both.
*
* Requires FilesystemTenancyBootstrapper to run before this bootstrapper,
* and storage path suffixing to be enabled.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
public static array $storagePathChannels = ['single', 'daily'];
/**
* Custom channel configuration overrides.
*
* Channels included here will be configured using the provided override.
* The overrides take precedence over the $storagePathChannels behavior
* when both approaches are used for the same channel.
*
* You can either map tenant attributes to channel config keys using an array,
* or provide a closure that returns the full channel config array.
*
* Examples:
* - Array mapping: ['slack' => ['url' => 'webhookUrl']]
* - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored)
* - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])]
* - this manually merges ['url' => $tenant->slackUrl] into the channel's config
* - null is not ignored, the closure controls the override fully
*
* So the channel overrides can be arrays and closures that return arrays.
*/
public static array $channelOverrides = [];
public function __construct(
protected Repository $config,
protected LogManager $logManager,
) {}
public function bootstrap(Tenant $tenant): void
{
$this->defaultConfig = $this->config->get('logging.channels');
$this->configuredChannels = $this->getChannels();
try {
$this->configureChannels($this->configuredChannels, $tenant);
$this->forgetChannels($this->configuredChannels);
} catch (\Throwable $exception) {
// If an exception is thrown while updating the logging config, the logging config
// could be left in a corrupt state, so we revert to the original config to
// to avoid logging the exception in a tenant channel or a broken channel.
$this->revert();
// We re-throw the exception after having reverted the logging config to central.
throw $exception;
}
}
public function revert(): void
{
$this->config->set('logging.channels', $this->defaultConfig);
$this->forgetChannels($this->configuredChannels);
}
/**
* Channels to configure and forget from the log manager so they can be
* re-resolved with the new, tenant-specific config on the next use.
*
* Includes:
* - all channels in the $storagePathChannels array
* - all channels that have custom overrides in the $channelOverrides property
* - any 'stack' channel that includes one of the above as a member
*
* Stack channels are included because once a stack has been used, it keeps logging
* to wherever its members pointed to at that moment. So a stack used in the central
* context would keep writing to the central logs, even after tenancy is initialized
* and its member channels are configured for the tenant.
* Forgetting the stack forces it to be re-resolved with its members' updated (tenant)
* config.
*
* Importantly, stacks are only inspected one level deep - they are not traversed recursively.
*/
protected function getChannels(): array
{
$configuredChannels = array_unique([
...static::$storagePathChannels,
...array_keys(static::$channelOverrides),
]);
$stackChannels = [];
foreach ($this->config->get('logging.channels') as $channel => $config) {
// Include stack channels that have at least one configured channel as a member
if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) {
$stackChannels[] = $channel;
}
}
return array_filter(
array_unique([...$configuredChannels, ...$stackChannels]),
fn (string $channel): bool => $this->config->has("logging.channels.{$channel}")
);
}
/**
* Configure channels for the tenant context.
*
* This handles both $storagePathChannels and $channelOverrides.
*/
protected function configureChannels(array $channels, Tenant $tenant): void
{
foreach ($channels as $channel) {
if (isset(static::$channelOverrides[$channel])) {
$this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
} elseif (in_array($channel, static::$storagePathChannels)) {
// Set storage path channels to use a tenant-specific directory.
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
$originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
$centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
// The tenant log will inherit the segment that follows the storage path from the central channel path config.
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log).
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath)));
}
}
}
/**
* Update channel configurations per $channelOverrides.
*
* For overrides set in array format, update individual keys of the channel.
* - This ignores cases where the value of the respective tenant attribute is null.
* For overrides set as closures, replace the entire channel with the returned config override.
* - This does not ignore cases where parts of the config may be null - the closure fully controls the override.
*/
protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void
{
if (is_array($override)) {
// Map tenant attributes to channel config keys.
foreach ($override as $configKey => $tenantAttributeName) {
/** @var Tenant&Model $tenant */
$tenantAttribute = data_get($tenant, $tenantAttributeName);
// If the tenant attribute is null, the override is ignored
// and the channel config key's value remains unchanged.
if ($tenantAttribute !== null) {
$this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute);
}
}
} elseif ($override instanceof Closure) {
$channelConfigKey = "logging.channels.{$channel}";
$result = $override($tenant, $this->config->get($channelConfigKey));
if (! is_array($result)) {
throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array.");
}
$this->config->set($channelConfigKey, $result);
}
}
/**
* Forget all passed channels from the log manager so that they can be
* re-resolved with the updated config on the next logging attempt.
*/
protected function forgetChannels(array $channels): void
{
foreach ($channels as $channel) {
$this->logManager->forgetChannel($channel);
}
}
}

View file

@ -74,8 +74,8 @@ class DisallowSqliteAttach implements Feature
// @phpstan-ignore method.notFound
$pdo->setAuthorizer(static function (int $action): int {
return $action === 24 // SQLITE_ATTACH
? PDO\Sqlite::DENY
: PDO\Sqlite::OK;
? PDO\Sqlite::DENY // @phpstan-ignore classConstant.notFound
: PDO\Sqlite::OK; // @phpstan-ignore classConstant.notFound
});
}
}

View file

@ -22,13 +22,14 @@ trait TriggerSyncingEvents
public static function bootTriggerSyncingEvents(): void
{
static::saving(static function (self $pivot) {
// Try getting the central resource to see if it is available
// If it is not available, throw an exception to interrupt the saving process
// And prevent creating a pivot record without a central resource
// Try getting the central resource to see if it is available.
// If it is not, getCentralResourceAndTenant() throws (indirectly, via findCentralResource() -> getResourceClass()),
// interrupting the save, preventing the creation of a pivot record without a central resource.
$pivot->getCentralResourceAndTenant();
});
static::saved(static function (self $pivot) {
// Only attach when the pivot is created
static::created(static function (self $pivot) {
/**
* @var static&Pivot $pivot
* @var SyncMaster|null $centralResource
@ -55,6 +56,10 @@ trait TriggerSyncingEvents
});
}
/**
* @throws CentralResourceNotAvailableInPivotException Throws when the tenant is the pivot parent
* but the central resource class cannot be resolved (thrown indirectly via findCentralResource() -> getResourceClass())
*/
public function getCentralResourceAndTenant(): array
{
/** @var $this&Pivot $this */

View file

@ -30,7 +30,6 @@ if (! function_exists('tenant')) {
return app(Tenant::class);
}
// @phpstan-ignore-next-line nullsafe.neverNull
return app(Tenant::class)?->getAttribute($key);
}
}