Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): Add support for tenant-aware storage #40

Merged
merged 11 commits into from
Sep 30, 2024
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"type" : "library",
"require" : {
"php" : "^8.2",
"laravel/framework": "^11.0"
"laravel/framework": "^11.0",
"league/flysystem-path-prefixing": "^3.0"
},
"require-dev" : {
"phpunit/phpunit" : "^11.0.1",
Expand Down
4 changes: 4 additions & 0 deletions resources/config/sprout.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@

'listen_for_routing' => true,

'services' => [
'storage' => true,
],

];
26 changes: 26 additions & 0 deletions src/Contracts/TenantHasResources.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Sprout\Contracts;

/**
* Tenant Has Resources Contract
*
* This contract marks an implementation of {@see \Sprout\Contracts\Tenant} as
* having their own tenant-specific resources.
*/
interface TenantHasResources
{
/**
* Get the resource key used to identify the tenants resources
*
* @return string
*/
public function getTenantResourceKey(): string;

/**
* Gets the name of the resource key used to identify the tenants resources
*
* @return string
*/
public function getTenantResourceKeyName(): string;
}
48 changes: 48 additions & 0 deletions src/Database/Eloquent/Concerns/HasTenantResources.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);

namespace Sprout\Database\Eloquent\Concerns;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Sprout\Contracts\TenantHasResources;

/**
* @phpstan-require-implements \Sprout\Contracts\Tenant
* @phpstan-require-implements \Sprout\Contracts\TenantHasResources
* @phpstan-require-extends \Illuminate\Database\Eloquent\Model
*/
trait HasTenantResources
{
public static function bootHasTenantResources(): void
{
static::creating(static function (Model&TenantHasResources $model) {
if ($model->getAttribute($model->getTenantResourceKeyName()) === null) {
$model->setAttribute(
$model->getTenantResourceKeyName(),
Str::uuid()
);
}
});
}

/**
* Get the resource key used to identify the tenants resources
*
* @return string
*/
public function getTenantResourceKey(): string
{
return (string)$this->getAttribute($this->getTenantResourceKeyName());
}

/**
* Gets the name of the resource key used to identify the tenants resources
*
* @return string
*/
public function getTenantResourceKeyName(): string
{
return 'resource_key';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ private function passesInitialChecks(Model $model, Tenancy $tenancy, BelongsToMa

// If we hit here then there's no tenant, and the model isn't
// marked as tenant being optional, so we throw an exception
throw TenantMissing::make($model::class, $tenancy->getName());
throw TenantMissing::make($tenancy->getName());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ private function passesInitialChecks(Model $model, Tenancy $tenancy, BelongsTo $

// If we hit here then there's no tenant, and the model isn't
// marked as tenant being optional, so we throw an exception
throw TenantMissing::make($model::class, $tenancy->getName());
throw TenantMissing::make($tenancy->getName());
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Database/Eloquent/Scopes/BelongsToManyTenantsScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function apply(Builder $builder, Model $model): void
}

// We should throw an exception because the tenant is missing
throw TenantMissing::make($model::class, $tenancy->getName());
throw TenantMissing::make($tenancy->getName());
}

// Finally, add the clause so that all queries are scoped to the
Expand Down
2 changes: 1 addition & 1 deletion src/Database/Eloquent/Scopes/BelongsToTenantScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function apply(Builder $builder, Model $model): void
}

// We should throw an exception because the tenant is missing
throw TenantMissing::make($model::class, $tenancy->getName());
throw TenantMissing::make($tenancy->getName());
}

// Finally, add the clause so that all queries are scoped to the
Expand Down
6 changes: 2 additions & 4 deletions src/Exceptions/TenantMissing.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@

final class TenantMissing extends SproutException
{
public static function make(string $model, ?string $tenancy): self
public static function make(string $tenancy): self
{
return new self(
'Model [' . $model . '] requires a tenant, and the tenancy'
. ($tenancy ? ' [' . $tenancy . '] ' : ' ')
. 'does not have one'
'There is no current tenant for tenancy [' . $tenancy . ']'
);
}
}
51 changes: 51 additions & 0 deletions src/Listeners/CleanupLaravelServices.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);

namespace Sprout\Listeners;

use Illuminate\Filesystem\FilesystemManager;
use Sprout\Events\CurrentTenantChanged;

final class CleanupLaravelServices
{
/**
* @var \Illuminate\Filesystem\FilesystemManager
*/
private FilesystemManager $filesystemManager;

public function __construct(FilesystemManager $filesystemManager)
{
$this->filesystemManager = $filesystemManager;
}

/**
* @template TenantClass of \Sprout\Contracts\Tenant
*
* @param \Sprout\Events\CurrentTenantChanged<TenantClass> $event
*
* @return void
*/
public function handle(CurrentTenantChanged $event): void
{
$this->purgeFilesystemDisks();
}

private function purgeFilesystemDisks(): void
{
// If we're not overriding the storage service we can exit early
if (config('sprout.services.storage', false) === false) {
return;
}

/** @var array<string, array<string, mixed>> $diskConfig */
$diskConfig = config('filesystems.disks', []);

// If any of the disks have the 'sprout' driver we need to purge them,
// if they exist, so we don't end up leaking tenant information
foreach ($diskConfig as $disk => $config) {
if (($config['driver'] ?? null) === 'sprout') {
$this->filesystemManager->forgetDisk($disk);
}
}
}
}
18 changes: 18 additions & 0 deletions src/SproutServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@
namespace Sprout;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Foundation\Application;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
use RuntimeException;
use Sprout\Contracts\TenantHasResources;
use Sprout\Events\CurrentTenantChanged;
use Sprout\Http\Middleware\TenantRoutes;
use Sprout\Http\RouterMethods;
use Sprout\Listeners\CleanupLaravelServices;
use Sprout\Listeners\IdentifyTenantOnRouting;
use Sprout\Listeners\PerformIdentityResolverSetup;
use Sprout\Listeners\SetCurrentTenantContext;
use Sprout\Listeners\SetCurrentTenantForJob;
use Sprout\Managers\IdentityResolverManager;
use Sprout\Managers\ProviderManager;
use Sprout\Managers\TenancyManager;
use Sprout\Support\StorageHelper;

class SproutServiceProvider extends ServiceProvider
{
Expand Down Expand Up @@ -86,6 +92,7 @@ public function boot(): void
{
$this->publishConfig();
$this->registerEventListeners();
$this->registerServiceOverrides();
}

private function publishConfig(): void
Expand All @@ -105,6 +112,17 @@ private function registerEventListeners(): void

$events->listen(CurrentTenantChanged::class, SetCurrentTenantContext::class);
$events->listen(CurrentTenantChanged::class, PerformIdentityResolverSetup::class);
$events->listen(CurrentTenantChanged::class, CleanupLaravelServices::class);
$events->listen(JobProcessing::class, SetCurrentTenantForJob::class);
}

private function registerServiceOverrides(): void
{
// If we're providing a tenanted override for Laravels filesystem/storage
// service, we'll do that here
if ($this->sprout->config('services.storage', false)) {
$filesystemManager = $this->app->make(FilesystemManager::class);
$filesystemManager->extend('sprout', StorageHelper::creator($this->sprout, $filesystemManager));
}
}
}
98 changes: 98 additions & 0 deletions src/Support/StorageHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);

namespace Sprout\Support;

use Closure;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Foundation\Application;
use RuntimeException;
use Sprout\Contracts\TenantHasResources;
use Sprout\Exceptions\TenantMissing;
use Sprout\Sprout;

final class StorageHelper
{
public static function creator(Sprout $sprout, FilesystemManager $manager): Closure
{
return static function (Application $app, array $config) use ($sprout, $manager): ?Filesystem {
if ($sprout->config('services.storage', false) === false) {
return null;
}

$tenancy = $sprout->tenancies()->get($config['tenancy'] ?? null);

// If there's no tenant, error out
if (! $tenancy->check()) {
throw TenantMissing::make($tenancy->getName());
}

$tenant = $tenancy->tenant();

// If the tenant isn't configured for resources, also error out
if (! ($tenant instanceof TenantHasResources)) {
// TODO: Better exception
throw new RuntimeException('Current tenant isn\t configured for resources');
}

$tenantConfig = self::getTenantStorageConfig($manager, $tenant, $config);

// Create a scoped driver for the new path
return $manager->createScopedDriver($tenantConfig);
};
}

/**
* @param \Illuminate\Filesystem\FilesystemManager $manager
* @param \Sprout\Contracts\TenantHasResources $tenant
* @param array<string, mixed> $config
*
* @return array<string, mixed>
*/
private static function getTenantStorageConfig(FilesystemManager $manager, TenantHasResources $tenant, array $config): array
{
/** @var string $pathPrefix */
$pathPrefix = $config['path'] ?? '{tenant}';

// Create the empty tenant config
$tenantConfig = [];

// Build up the path prefix with the tenant resource key
$tenantConfig['prefix'] = self::createTenantedPrefix($tenant, $pathPrefix);

// Set the disk config on the newly created tenant config, so that the
// filesystem manager uses this, rather gets it straight from the config
$tenantConfig['disk'] = self::getDiskConfig($config);

return $tenantConfig;
}

private static function createTenantedPrefix(TenantHasResources $tenant, string $pathPrefix): string
{
return str_replace('{tenant}', $tenant->getTenantResourceKey(), $pathPrefix);
}

/**
* @param array<string, mixed> $config
*
* @return array<string, mixed>
*/
private static function getDiskConfig(array $config): array
{
if (is_array($config['disk'])) {
$diskConfig = $config['disk'];
} else {
/** @var string $diskName */
$diskName = $config['disk'] ?? config('filesystems.default');
$diskConfig = config('filesystems.disks.' . $diskName);
}

/** @var array<string, mixed> $diskConfig */

// This is where we'd do anything like load config overrides for
// the tenant, like say they have their own S3 setup, etc.

return $diskConfig;
}
}
10 changes: 2 additions & 8 deletions tests/Database/Eloquent/BelongsToManyTenantsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,7 @@ public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenCr
{
$this->expectException(TenantMissing::class);
$this->expectExceptionMessage(
'Model ['
. TenantChildren::class
. '] requires a tenant, and the tenancy'
. ' [tenants] does not have one'
'There is no current tenant for tenancy [tenants]'
);

TenantChildren::factory()->create();
Expand Down Expand Up @@ -169,10 +166,7 @@ public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenHy

$this->expectException(TenantMissing::class);
$this->expectExceptionMessage(
'Model ['
. TenantChildren::class
. '] requires a tenant, and the tenancy'
. ' [tenants] does not have one'
'There is no current tenant for tenancy [tenants]'
);

TenantChildren::query()->find($child->getKey());
Expand Down
10 changes: 2 additions & 8 deletions tests/Database/Eloquent/BelongsToTenantTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,7 @@ public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenCr
{
$this->expectException(TenantMissing::class);
$this->expectExceptionMessage(
'Model ['
. TenantChild::class
. '] requires a tenant, and the tenancy'
. ' [tenants] does not have one'
'There is no current tenant for tenancy [tenants]'
);

TenantChild::factory()->create();
Expand Down Expand Up @@ -201,10 +198,7 @@ public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenHy

$this->expectException(TenantMissing::class);
$this->expectExceptionMessage(
'Model ['
. TenantChild::class
. '] requires a tenant, and the tenancy'
. ' [tenants] does not have one'
'There is no current tenant for tenancy [tenants]'
);

TenantChild::query()->find($child->getKey());
Expand Down
Loading