From e6ed21d17c7a11f48d7f6fb5de45f4322437ec08 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Tue, 25 Aug 2026 10:46:05 +0200 Subject: [PATCH 01/11] Harden tenant hierarchy traversal --- ...1_000092_add_parent_tenant_foreign_key.php | 31 ++++++++++ .../Http/Controllers/Api/TenantController.php | 20 ++++++- packages/core/src/Models/Tenant.php | 53 ++++++++++++---- .../tests/Feature/TenantAuthorizationTest.php | 60 +++++++++++++++++++ 4 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000092_add_parent_tenant_foreign_key.php diff --git a/packages/core/database/migrations/0001_01_01_000092_add_parent_tenant_foreign_key.php b/packages/core/database/migrations/0001_01_01_000092_add_parent_tenant_foreign_key.php new file mode 100644 index 0000000..568427e --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000092_add_parent_tenant_foreign_key.php @@ -0,0 +1,31 @@ +foreign('parent_tenant_id') + ->references('id') + ->on('tenants') + ->restrictOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropForeign(['parent_tenant_id']); + }); + } +}; diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index a7dbc54..879bcfc 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -16,6 +16,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Validation\ValidationException; class TenantController extends Controller { @@ -80,7 +81,11 @@ public function show(Tenant $tenant) { Gate::authorize('view', $tenant); - return Response::jsonResource($tenant->load('plan')->append('tenant_usage_list')); + return Response::jsonResource($tenant->load('plan')->append([ + 'tenant_usage_list', + 'all_users_count', + 'all_sub_tenants_count', + ])); } /** @@ -94,6 +99,13 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) $parentTenant = array_key_exists('parent_tenant_id', $tenantData) ? Tenant::query()->find($tenantData['parent_tenant_id']) : $tenant->parentTenant; + + if (!$tenant->canHaveParent($parentTenant)) { + throw ValidationException::withMessages([ + 'parent_tenant_id' => 'A tenant cannot be assigned to itself or one of its descendants.', + ]); + } + $plan = array_key_exists('plan_id', $tenantData) ? Plan::query()->findOrFail($tenantData['plan_id']) : $tenant->plan; @@ -132,6 +144,12 @@ public function destroy(Tenant $tenant) { Gate::authorize('delete', $tenant); + if ($tenant->subTenants()->exists()) { + throw ValidationException::withMessages([ + 'tenant' => 'A tenant with child tenants cannot be deleted.', + ]); + } + $tenant->delete(); event(new ResourceDeleted($tenant, [])); diff --git a/packages/core/src/Models/Tenant.php b/packages/core/src/Models/Tenant.php index f2c1e21..7174226 100644 --- a/packages/core/src/Models/Tenant.php +++ b/packages/core/src/Models/Tenant.php @@ -46,9 +46,7 @@ class Tenant extends Model public $appends = [ 'users_count', - 'all_users_count', 'sub_tenants_count', - 'all_sub_tenants_count', ]; public function environments(): HasMany @@ -146,10 +144,9 @@ public function scopeChildrenOf(Builder $query, Tenant $tenant): Builder /** * Limit the query to tenants contained in the given tenant tree. * - * This currently resolves the tree in PHP because tenant trees are expected - * to be shallow in normal control-panel usage. The method keeps callers away - * from duplicating hierarchy traversal and can later be replaced internally - * by a recursive SQL implementation if needed. + * This resolves the tree in PHP using a batched breadth-first traversal with + * cycle protection. The method keeps callers away from duplicating hierarchy + * traversal and can later be replaced internally by recursive SQL if needed. */ public function scopeInTreeOf(Builder $query, Tenant $tenant, bool $includeSelf = true): Builder { @@ -160,19 +157,35 @@ public function scopeInTreeOf(Builder $query, Tenant $tenant, bool $includeSelf * Return all descendants of this tenant in breadth-first order. * * The returned collection contains children, grandchildren, and deeper - * descendants, but never the current tenant itself. + * descendants, but never the current tenant itself. Each tree level is + * loaded in one query and visited IDs protect against malformed cycles. * * @return Collection */ public function allSubTenants(): Collection { $all = new Collection(); - $queue = $this->subTenants()->get(); - - while ($queue->isNotEmpty()) { - $all = $all->merge($queue); - $queue = $queue->map->subTenants->flatten(); + $visited = [$this->id => true]; + $frontier = [$this->id]; + + while ($frontier !== []) { + $children = static::query() + ->whereIn('parent_tenant_id', $frontier) + ->orderBy('id') + ->get(); + $frontier = []; + + foreach ($children as $child) { + if (isset($visited[$child->id])) { + continue; + } + + $visited[$child->id] = true; + $all->push($child); + $frontier[] = $child->id; + } } + return $all; } @@ -210,6 +223,22 @@ public function parentTenant(): BelongsTo return $this->belongsTo(Tenant::class, 'parent_tenant_id', 'id'); } + /** + * Determine whether the given tenant can become this tenant's parent. + * + * A tenant cannot be assigned to itself or to one of its descendants, + * otherwise the adjacency-list tree would contain a cycle. + */ + public function canHaveParent(?Tenant $parentTenant): bool + { + if ($parentTenant === null || !$this->exists) { + return true; + } + + return $this->id !== $parentTenant->id + && !$this->isAncestorOf($parentTenant); + } + /** * Check whether this tenant is an ancestor of the given tenant. */ diff --git a/packages/core/tests/Feature/TenantAuthorizationTest.php b/packages/core/tests/Feature/TenantAuthorizationTest.php index d0e6bc0..33b087b 100644 --- a/packages/core/tests/Feature/TenantAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantAuthorizationTest.php @@ -62,6 +62,66 @@ public function test_tenant_tree_helpers_resolve_descendants_and_ancestors(): vo $this->assertTrue($rootTenant->isParentToTenant($grandchildTenant)); } + public function test_tenant_tree_helpers_terminate_when_data_contains_a_cycle(): void + { + $rootTenant = Tenant::query()->root()->firstOrFail(); + $childTenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $grandchildTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); + + DB::table('tenants') + ->where('id', $rootTenant->id) + ->update(['parent_tenant_id' => $grandchildTenant->id]); + + try { + $descendantIds = $rootTenant->fresh()->descendantIds(); + + $this->assertCount(2, $descendantIds); + $this->assertSame([$childTenant->id, $grandchildTenant->id], $descendantIds); + } finally { + DB::table('tenants') + ->where('id', $rootTenant->id) + ->update(['parent_tenant_id' => null]); + } + } + + public function test_tenant_cannot_be_reassigned_under_itself(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $tenant->id, [ + 'parent_tenant_id' => $tenant->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['parent_tenant_id']); + } + + public function test_tenant_cannot_be_reassigned_under_one_of_its_descendants(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $descendant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $tenant->id, [ + 'parent_tenant_id' => $descendant->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['parent_tenant_id']); + } + + public function test_tenant_with_children_cannot_be_deleted(): void + { + $tenant = Tenant::query()->root()->firstOrFail(); + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['tenant']); + } + public function test_tenant_tree_scopes_filter_root_children_and_tree(): void { $rootTenant = Tenant::query()->root()->firstOrFail(); From 13469a7390ffaa89b633c21a87563e61061a86da Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Tue, 25 Aug 2026 11:21:42 +0200 Subject: [PATCH 02/11] Add package-owned setting definitions --- ...001_01_01_000093_add_setting_ownership.php | 86 ++++++ .../database/seeders/SettingsTableSeeder.php | 2 +- .../seeders/Testing/DatabaseSeeder.php | 2 +- packages/core/src/Models/Setting.php | 18 ++ .../Services/Bootstrap/BootstrapService.php | 2 +- .../core/src/Services/Traits/HasSettings.php | 16 +- .../src/Support/PackageServiceProvider.php | 8 +- packages/core/src/Support/Setting.php | 266 ++++++++++++---- packages/core/src/Support/SettingRegistry.php | 292 ++++++++++++++++++ .../tests/Feature/SettingRegistryTest.php | 86 ++++++ .../src/Support/MarketplaceCredentials.php | 9 +- .../EnsurePackageUpdatesAreCompleteTest.php | 4 +- .../Feature/PackagesSyncPreMigrationTest.php | 6 +- .../tests/Feature/PendingCompletionTest.php | 2 +- ...001_01_02_000001_create_theme_settings.php | 2 + 15 files changed, 721 insertions(+), 80 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000093_add_setting_ownership.php create mode 100644 packages/core/src/Support/SettingRegistry.php create mode 100644 packages/core/tests/Feature/SettingRegistryTest.php diff --git a/packages/core/database/migrations/0001_01_01_000093_add_setting_ownership.php b/packages/core/database/migrations/0001_01_01_000093_add_setting_ownership.php new file mode 100644 index 0000000..71051af --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000093_add_setting_ownership.php @@ -0,0 +1,86 @@ +string('owner_package', 191)->nullable()->after('key'); + $table->string('definition_key', 26)->nullable()->after('owner_package'); + $table->index('owner_package'); + $table->index('definition_key'); + }); + + // Existing rows are grouped into stable definitions before ownership is backfilled. + // Rows whose original package cannot be determined remain explicitly unowned and must + // be adopted by a deliberate package migration before their definition can change. + DB::table('settings') + ->select('category', 'key') + ->distinct() + ->get() + ->each(function (object $path): void { + $definitionKey = DB::table('settings') + ->where('category', $path->category) + ->where('key', $path->key) + ->whereNotNull('definition_key') + ->value('definition_key') + ?? (string)Str::ulid(); + + DB::table('settings') + ->where('category', $path->category) + ->where('key', $path->key) + ->update(['definition_key' => $definitionKey]); + }); + + $knownOwners = [ + 'auditlog' => 'froxlor/core', + 'api' => 'froxlor/core', + 'core' => 'froxlor/core', + 'node' => 'froxlor/core', + 'appearance' => 'froxlor/ui', + ]; + + foreach ($knownOwners as $category => $owner) { + DB::table('settings') + ->where('category', $category) + ->whereNull('owner_package') + ->update(['owner_package' => $owner]); + } + + DB::table('settings') + ->where('category', 'packages') + ->whereNull('owner_package') + ->get(['id', 'key']) + ->each(function (object $setting): void { + $owner = match ($setting->key) { + 'marketplace_username', 'marketplace_token' => 'froxlor/packages', + default => null, + }; + + if ($owner === null && preg_match('/^(.+)\.(enabled|pending)$/', $setting->key, $matches)) { + $owner = $matches[1]; + } + + if ($owner !== null) { + DB::table('settings') + ->where('id', $setting->id) + ->update(['owner_package' => $owner]); + } + }); + } + + public function down(): void + { + Schema::table('settings', function (Blueprint $table): void { + $table->dropIndex('settings_owner_package_index'); + $table->dropIndex('settings_definition_key_index'); + $table->dropColumn(['owner_package', 'definition_key']); + }); + } +}; diff --git a/packages/core/database/seeders/SettingsTableSeeder.php b/packages/core/database/seeders/SettingsTableSeeder.php index 788561f..9287969 100644 --- a/packages/core/database/seeders/SettingsTableSeeder.php +++ b/packages/core/database/seeders/SettingsTableSeeder.php @@ -27,7 +27,7 @@ class SettingsTableSeeder extends Seeder public function run(): void { foreach ($this->settings as $setting) { - Setting::addFromArray($setting); + Setting::addFromArray($setting, source: 'froxlor/core'); } } } diff --git a/packages/core/database/seeders/Testing/DatabaseSeeder.php b/packages/core/database/seeders/Testing/DatabaseSeeder.php index e8e05a2..15881f8 100644 --- a/packages/core/database/seeders/Testing/DatabaseSeeder.php +++ b/packages/core/database/seeders/Testing/DatabaseSeeder.php @@ -16,7 +16,7 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - Setting::set('auditlog.severity', 7, 'integer', 5); + Setting::set('auditlog.severity', 7, 'integer', 5, 'froxlor/core'); $this->call([ PlansAndResourcesTableSeeder::class, diff --git a/packages/core/src/Models/Setting.php b/packages/core/src/Models/Setting.php index 7143213..2936cf6 100644 --- a/packages/core/src/Models/Setting.php +++ b/packages/core/src/Models/Setting.php @@ -6,12 +6,15 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Carbon; +use LogicException; /** * @property string $id * @property MorphTo $settingable * @property string $category * @property string $key + * @property string|null $owner_package + * @property string|null $definition_key * @property mixed $value * @property mixed $default_value * @property mixed $type @@ -32,6 +35,21 @@ class Setting extends Model 'properties' => 'array', ]; + protected static function booted(): void + { + static::creating(function (self $setting): void { + if (!$setting->owner_package || !$setting->definition_key) { + throw new LogicException('Settings must be created through Froxlor\\Core\\Support\\Setting with a package source.'); + } + }); + + static::updating(function (self $setting): void { + if ($setting->isDirty(['category', 'key', 'owner_package', 'definition_key'])) { + \Froxlor\Core\Support\SettingRegistry::assertDefinitionIsImmutable($setting); + } + }); + } + public function settingable(): MorphTo { return $this->morphTo(); diff --git a/packages/core/src/Services/Bootstrap/BootstrapService.php b/packages/core/src/Services/Bootstrap/BootstrapService.php index 21f3595..e7fd182 100644 --- a/packages/core/src/Services/Bootstrap/BootstrapService.php +++ b/packages/core/src/Services/Bootstrap/BootstrapService.php @@ -40,7 +40,7 @@ public function initRootTenant(string $email, string $firstName, string $lastNam $user->roles()->attach($superAdminRoleId); // mark application as initialized - Setting::add('core.initialized', true, type: 'boolean', properties: ['visible' => false]); + Setting::add('core.initialized', true, type: 'boolean', properties: ['visible' => false], source: 'froxlor/core'); return $user; } diff --git a/packages/core/src/Services/Traits/HasSettings.php b/packages/core/src/Services/Traits/HasSettings.php index 6d1a841..d1b79d6 100644 --- a/packages/core/src/Services/Traits/HasSettings.php +++ b/packages/core/src/Services/Traits/HasSettings.php @@ -4,9 +4,11 @@ use Exception; use Froxlor\Core\Models\Setting as SettingModel; +use Froxlor\Core\Support\ComposerPackage; use Froxlor\Core\Support\Setting; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; +use RuntimeException; trait HasSettings { @@ -82,7 +84,7 @@ public function getSetting(string $settings_path, mixed $default = null): mixed */ public function addSetting(string $settings_path, mixed $value, mixed $default = null, string $type = 'string', array $properties = []): void { - Setting::add($settings_path, $value, $default, $type, $properties, self::class, $this->id); + Setting::add($settings_path, $value, $default, $type, $properties, self::class, $this->id, self::settingPackage()); } /** @@ -95,7 +97,7 @@ public function addSetting(string $settings_path, mixed $value, mixed $default = */ public function setSetting(string $settings_path, mixed $value): mixed { - return Setting::setValueForModel($this, $settings_path, $value); + return Setting::setValueForModel($this, $settings_path, $value, source: self::settingPackage()); } /** @@ -111,7 +113,7 @@ public function setSetting(string $settings_path, mixed $value): mixed */ public static function addTypeSetting(string $settings_path, mixed $value, mixed $default = null, string $type = 'string', array $properties = []): void { - Setting::add($settings_path, $value, $default, $type, $properties, self::class); + Setting::add($settings_path, $value, $default, $type, $properties, self::class, source: self::settingPackage()); } /** @@ -124,7 +126,7 @@ public static function addTypeSetting(string $settings_path, mixed $value, mixed */ public static function setTypeSetting(string $settings_path, mixed $value): void { - Setting::setValueForType(self::class, $settings_path, $value); + Setting::setValueForType(self::class, $settings_path, $value, source: self::settingPackage()); } /** @@ -137,4 +139,10 @@ public static function getTypeSetting(string $settings_path, mixed $default = nu { return Setting::getValueForType(self::class, $settings_path, $default); } + + private static function settingPackage(): string + { + return ComposerPackage::forClass(self::class) + ?? throw new RuntimeException('Unable to resolve the composer package for ' . self::class); + } } diff --git a/packages/core/src/Support/PackageServiceProvider.php b/packages/core/src/Support/PackageServiceProvider.php index bea3d52..20c04b3 100644 --- a/packages/core/src/Support/PackageServiceProvider.php +++ b/packages/core/src/Support/PackageServiceProvider.php @@ -132,7 +132,7 @@ public function requireCompletion(string $reason, string $route, string $stage = 'reason' => $reason, 'route' => $route, 'stage' => $stage, - ]); + ], source: $this->packageName()); } /** @@ -147,7 +147,7 @@ public function completeCompletion(): void // The settings table's value column is NOT NULL, so an empty array is the "cleared" // sentinel rather than null. - Setting::set($this->pendingCompletionSettingPath(), []); + Setting::set($this->pendingCompletionSettingPath(), [], source: $this->packageName()); if ($pending === null) { return; @@ -185,7 +185,7 @@ public function enable(): void } $this->enabling(); - Setting::set($this->enabledSettingPath(), true, 'boolean', true); + Setting::set($this->enabledSettingPath(), true, 'boolean', true, $this->packageName()); $this->enabled(); Audit::info(sprintf('Package %s has been enabled.', $this->packageName())); @@ -198,7 +198,7 @@ public function disable(): void } $this->disabling(); - Setting::set($this->enabledSettingPath(), false, 'boolean', true); + Setting::set($this->enabledSettingPath(), false, 'boolean', true, $this->packageName()); $this->disabled(); Audit::info(sprintf('Package %s has been disabled.', $this->packageName())); diff --git a/packages/core/src/Support/Setting.php b/packages/core/src/Support/Setting.php index e8dc1f1..b72bd6b 100644 --- a/packages/core/src/Support/Setting.php +++ b/packages/core/src/Support/Setting.php @@ -4,9 +4,10 @@ use Exception; use Froxlor\Core\Models\Setting as SettingModel; -use Illuminate\Database\QueryException; use Froxlor\Core\Services\Traits\HasSettings; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\QueryException; +use LogicException; class Setting { @@ -15,11 +16,7 @@ public static function get(string $path, mixed $default = null): mixed $s = self::parsePath($path); try { - $setting = SettingModel::query() - ->select('value', 'type') - ->where('category', $s['category']) - ->where('key', $s['key']) - ->first(); + $setting = self::findForScope($s, null, null, ['value', 'type']); } catch (QueryException) { // Settings are read during provider boot (e.g. PackageServiceProvider::isEnabled()) // and from migrations, both of which can run before the settings table exists @@ -34,30 +31,36 @@ public static function get(string $path, mixed $default = null): mixed return self::castValue($setting->value, $setting->type); } - public static function set(string $path, mixed $value, string $type = 'text', mixed $default = null): SettingModel + public static function set(string $path, mixed $value, string $type = 'text', mixed $default = null, ?string $source = null): SettingModel { $s = self::parsePath($path); + $setting = self::findForScope($s, null, null); - return SettingModel::updateOrCreate( - self::baseConditions($s, null, null, $type), - [ - 'value' => $value, - 'default_value' => $default, - ] + if ($setting) { + $setting->value = $value; + $setting->save(); + + return $setting; + } + + self::assertSource($source, 'set'); + + return self::createDefinition( + s: $s, + value: $value, + default: $default, + type: $type, + properties: [], + settingableType: null, + settingableId: null, + source: $source, ); } public static function getValueForType(string $resourceType, string $path, mixed $default = null): mixed { $s = self::parsePath($path); - - $setting = SettingModel::query() - ->select('value') - ->where('category', $s['category']) - ->where('key', $s['key']) - ->where('settingable_type', $resourceType) - ->whereNull('settingable_id') - ->first(); + $setting = self::findForScope($s, $resourceType, null, ['value']); if (!$setting) { return $default; @@ -66,18 +69,31 @@ public static function getValueForType(string $resourceType, string $path, mixed return $setting->value; } - public static function setValueForType(string $resourceType, string $path, mixed $value, string $type = 'text'): SettingModel + public static function setValueForType(string $resourceType, string $path, mixed $value, string $type = 'text', ?string $source = null): SettingModel { self::assertHasSettingsTrait($resourceType); $s = self::parsePath($path); + $setting = self::findForScope($s, $resourceType, null); - return SettingModel::updateOrCreate( - self::baseConditions($s, $resourceType, null), - [ - 'value' => $value, - 'type' => $type, - ] + if ($setting) { + $setting->value = $value; + $setting->save(); + + return $setting; + } + + self::assertSource($source, 'setValueForType'); + + return self::createDefinition( + s: $s, + value: $value, + default: null, + type: $type, + properties: [], + settingableType: $resourceType, + settingableId: null, + source: $source, ); } @@ -102,43 +118,77 @@ public static function getForModel(Model $resource, string $path, mixed $default return $setting->value; } - public static function setValueForModel(Model $resource, string $path, mixed $value, string $type = 'text'): SettingModel + public static function setValueForModel(Model $resource, string $path, mixed $value, string $type = 'text', ?string $source = null): SettingModel { self::assertHasSettingsTrait($resource); $s = self::parsePath($path); + $setting = self::findForScope($s, $resource::class, (string)$resource->id); - return SettingModel::updateOrCreate( - self::baseConditions($s, $resource::class, $resource->id), - [ - 'value' => $value, - 'type' => $type, - ] + if ($setting) { + $setting->value = $value; + $setting->save(); + + return $setting; + } + + self::assertSource($source, 'setValueForModel'); + + return self::createDefinition( + s: $s, + value: $value, + default: null, + type: $type, + properties: [], + settingableType: $resource::class, + settingableId: (string)$resource->id, + source: $source, ); } - public static function add(string $path, mixed $value, mixed $default = null, string $type = 'string', array $properties = [], ?string $settingableType = null, ?string $settingableId = null): void - { + public static function add( + string $path, + mixed $value, + mixed $default = null, + string $type = 'string', + array $properties = [], + ?string $settingableType = null, + ?string $settingableId = null, + ?string $source = null, + ): SettingModel { $s = self::parsePath($path); + self::assertSource($source, 'add'); - $data = [ - 'category' => $s['category'], - 'key' => $s['key'], - 'value' => $value, - 'default_value' => $default, - 'type' => $type, - 'properties' => $properties, - ]; + $settingableType = $settingableType && class_exists($settingableType) ? $settingableType : null; + $setting = self::findForScope($s, $settingableType, $settingableId); + $definitionKey = self::registerDefinition($s, $source); - if ($settingableType && class_exists($settingableType)) { - $data['settingable_type'] = $settingableType; - $data['settingable_id'] = $settingableId; + if ($setting) { + $setting->fill([ + 'value' => $value, + 'default_value' => $default, + 'type' => $type, + 'properties' => $properties, + ]); + $setting->save(); + + return $setting; } - SettingModel::query()->create($data); + return self::createDefinition( + s: $s, + value: $value, + default: $default, + type: $type, + properties: $properties, + settingableType: $settingableType, + settingableId: $settingableId, + source: $source, + definitionKey: $definitionKey, + ); } - public static function addFromArray(array $setting): void + public static function addFromArray(array $setting, ?string $source = null): void { $setting['category'] ??= 'general'; @@ -153,25 +203,26 @@ public static function addFromArray(array $setting): void self::add( path: "{$setting['category']}.{$setting['key']}", value: $setting['value'] ?? null, - default: $setting['default'] ?? null, + default: $setting['default'] ?? $setting['default_value'] ?? null, type: $setting['type'], properties: $setting['properties'] ?? [], settingableType: $setting['settingable_type'] ?? null, settingableId: $setting['settingable_id'] ?? null, + source: $source, ); } private static function parsePath(string $path): array { - $parts = explode('.', $path); + $parts = explode('.', $path, 2); - if (count($parts) < 2) { + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { throw new Exception("Invalid settings path: {$path}"); } return [ - 'category' => array_shift($parts), - 'key' => implode('.', $parts), + 'category' => $parts[0], + 'key' => $parts[1], ]; } @@ -184,15 +235,108 @@ private static function castValue(mixed $value, ?string $type): mixed }; } - private static function baseConditions(array $s, ?string $type, ?string $id, ?string $settingType = null): array + private static function findForScope(array $s, ?string $settingableType, ?string $settingableId, ?array $columns = null): ?SettingModel { - return array_filter([ + $query = SettingModel::query() + ->when($columns !== null, fn($query) => $query->select($columns)) + ->where('category', $s['category']) + ->where('key', $s['key']); + + if ($settingableType === null) { + $query->whereNull('settingable_type'); + } else { + $query->where('settingable_type', $settingableType); + } + + if ($settingableId === null) { + $query->whereNull('settingable_id'); + } else { + $query->where('settingable_id', $settingableId); + } + + return $query->first(); + } + + private static function registerDefinition(array $s, string $source): string + { + $existing = SettingModel::query() + ->where('category', $s['category']) + ->where('key', $s['key']) + ->get(['owner_package', 'definition_key']); + + if ($existing->contains(fn(SettingModel $setting): bool => !$setting->owner_package || !$setting->definition_key)) { + throw new LogicException(sprintf( + 'Setting "%s.%s" has no complete ownership metadata and must be explicitly adopted before it can be changed.', + $s['category'], + $s['key'], + )); + } + + $foreignOwner = $existing->pluck('owner_package')->first(fn(?string $owner): bool => $owner !== $source); + if ($foreignOwner !== null) { + throw new LogicException(sprintf( + 'Setting path "%s.%s" is owned by "%s" and cannot be registered by "%s".', + $s['category'], + $s['key'], + $foreignOwner, + $source, + )); + } + + $definitionKeys = $existing->pluck('definition_key')->unique()->values(); + if ($definitionKeys->count() > 1) { + throw new LogicException(sprintf('Setting "%s.%s" has multiple definition identifiers.', $s['category'], $s['key'])); + } + + SettingRegistry::register([ + [ + 'category' => $s['category'], + 'key' => $s['key'], + 'definition_key' => $definitionKeys->first(), + ], + ], $source); + + return SettingRegistry::definitionKey($s['category'] . '.' . $s['key']) + ?? throw new LogicException('Unable to resolve the registered setting definition key.'); + } + + private static function createDefinition( + array $s, + mixed $value, + mixed $default, + string $type, + array $properties, + ?string $settingableType, + ?string $settingableId, + string $source, + ?string $definitionKey = null, + ): SettingModel { + $definitionKey ??= self::registerDefinition($s, $source); + + $data = [ 'category' => $s['category'], 'key' => $s['key'], - 'settingable_type' => $type, - 'settingable_id' => $id, - 'type' => $settingType, - ], fn($v) => $v !== null); + 'owner_package' => $source, + 'definition_key' => $definitionKey, + 'value' => $value, + 'default_value' => $default, + 'type' => $type, + 'properties' => $properties, + ]; + + if ($settingableType !== null) { + $data['settingable_type'] = $settingableType; + $data['settingable_id'] = $settingableId; + } + + return SettingModel::query()->create($data); + } + + private static function assertSource(?string $source, string $operation): void + { + if (!$source) { + throw new LogicException("Setting::{$operation}() requires the owning package source when creating a setting definition."); + } } private static function usesSettingsTrait(object|string $class): bool diff --git a/packages/core/src/Support/SettingRegistry.php b/packages/core/src/Support/SettingRegistry.php new file mode 100644 index 0000000..4442a5d --- /dev/null +++ b/packages/core/src/Support/SettingRegistry.php @@ -0,0 +1,292 @@ + + */ + private static array $settings = []; + + /** + * Register setting definitions exposed by a package. + * + * Setting paths are global definition identifiers. Resource-specific values still share + * the same definition, so a package cannot shadow a setting by registering it for another + * resource scope. + * + * @param array $settings + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function register(array $settings, string $source): void + { + self::assertSource($source); + + foreach ($settings as $setting) { + self::registerDefinition($setting, $source); + } + } + + /** + * Return all registered definitions sorted by path. + * + * @return array + */ + public static function all(): array + { + $settings = array_values(self::$settings); + + usort($settings, fn(array $left, array $right) => [$left['category'], $left['key']] <=> [$right['category'], $right['key']]); + + return $settings; + } + + /** + * Return the stable definition key for a registered path. + */ + public static function definitionKey(string $path): ?string + { + $parsed = self::parsePath($path); + $registered = self::$settings[self::registryKey($parsed)] ?? null; + + return $registered['definition_key'] ?? null; + } + + /** + * Rename a setting definition owned by the given package. + * + * All global, type-specific and instance-specific values belonging to the definition are + * moved together. Direct Eloquent updates of category/key remain blocked by the Setting + * model, so this is the single supported metadata mutation path. + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function rename(string $path, string $newPath, string $source): void + { + self::assertSource($source); + + $old = self::parsePath($path); + $new = self::parsePath($newPath); + + if ($old === $new) { + return; + } + + DB::transaction(function () use ($old, $new, $path, $newPath, $source): void { + $oldRows = Setting::query() + ->where('category', $old['category']) + ->where('key', $old['key']) + ->get(['id', 'owner_package', 'definition_key']); + + if ($oldRows->isEmpty()) { + throw new LogicException(sprintf('Setting "%s" does not exist and cannot be renamed.', $path)); + } + + $definitionKeys = $oldRows->pluck('definition_key')->filter()->unique()->values(); + $owners = $oldRows->pluck('owner_package')->unique()->values(); + + if ($owners->contains(null) || $definitionKeys->count() !== 1 || $owners->count() !== 1) { + throw new LogicException(sprintf('Setting "%s" has incomplete ownership metadata and cannot be renamed.', $path)); + } + + $owner = $owners->first(); + if ($owner !== $source) { + throw new LogicException(sprintf( + 'Setting "%s" is owned by "%s" and cannot be renamed by "%s".', + $path, + $owner, + $source, + )); + } + + $newRows = Setting::query() + ->where('category', $new['category']) + ->where('key', $new['key']) + ->whereNotIn('id', $oldRows->pluck('id')) + ->exists(); + + if ($newRows || isset(self::$settings[self::registryKey($new)])) { + throw new LogicException(sprintf('Setting path "%s" is already registered.', $newPath)); + } + + $definitionKey = $definitionKeys->first(); + + Setting::query() + ->whereIn('id', $oldRows->pluck('id')) + ->update([ + 'category' => $new['category'], + 'key' => $new['key'], + ]); + + unset(self::$settings[self::registryKey($old)]); + self::$settings[self::registryKey($new)] = [ + 'category' => $new['category'], + 'key' => $new['key'], + 'definition_key' => $definitionKey, + 'source' => $source, + ]; + }); + } + + /** + * Adopt legacy rows whose original package could not be inferred during the ownership + * migration. Package migrations should call this explicitly before registering the setting. + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function adopt(string $path, string $source): void + { + self::assertSource($source); + $parsed = self::parsePath($path); + $rows = Setting::query() + ->where('category', $parsed['category']) + ->where('key', $parsed['key']) + ->get(['id', 'owner_package', 'definition_key']); + + if ($rows->isEmpty()) { + throw new LogicException(sprintf('Setting "%s" does not exist and cannot be adopted.', $path)); + } + + $foreignOwner = $rows->pluck('owner_package')->first( + fn(?string $owner): bool => $owner !== null && $owner !== $source, + ); + if ($foreignOwner !== null) { + throw new LogicException(sprintf( + 'Setting "%s" is already owned by "%s" and cannot be adopted by "%s".', + $path, + $foreignOwner, + $source, + )); + } + + $definitionKeys = $rows->pluck('definition_key')->filter()->unique()->values(); + if ($definitionKeys->count() > 1) { + throw new LogicException(sprintf('Setting "%s" has multiple definition identifiers.', $path)); + } + + $definitionKey = $definitionKeys->first() ?? (string)Str::ulid(); + + self::register([ + [ + 'category' => $parsed['category'], + 'key' => $parsed['key'], + 'definition_key' => $definitionKey, + ], + ], $source); + + DB::transaction(function () use ($rows, $source, $definitionKey): void { + Setting::query() + ->whereIn('id', $rows->pluck('id')) + ->update([ + 'owner_package' => $source, + 'definition_key' => $definitionKey, + ]); + }); + } + + /** + * Guard used by the Setting model for direct definition metadata updates. + */ + public static function assertDefinitionIsImmutable(Setting $setting): void + { + throw new LogicException(sprintf( + 'Setting definition "%s.%s" is immutable; use SettingRegistry::rename() owned by "%s".', + $setting->category, + $setting->key, + $setting->owner_package ?? 'its package', + )); + } + + /** + * Register one definition and return its stable key. + * + * @param array{path?: string, category?: string, key?: string, definition_key?: string} $setting + */ + private static function registerDefinition(array $setting, string $source): string + { + $parsed = self::parseDefinition($setting); + $registryKey = self::registryKey($parsed); + $definitionKey = $setting['definition_key'] ?? self::$settings[$registryKey]['definition_key'] ?? (string)Str::ulid(); + $registered = self::$settings[$registryKey] ?? null; + + if ($registered !== null && ($registered['source'] !== $source || $registered['definition_key'] !== $definitionKey)) { + throw new LogicException(sprintf( + 'Setting path "%s.%s" is already registered by "%s" and cannot be registered by "%s".', + $parsed['category'], + $parsed['key'], + $registered['source'], + $source, + )); + } + + self::$settings[$registryKey] = [ + 'category' => $parsed['category'], + 'key' => $parsed['key'], + 'definition_key' => $definitionKey, + 'source' => $source, + ]; + + return $definitionKey; + } + + /** + * @return array{category: string, key: string} + */ + private static function parseDefinition(array $setting): array + { + if (isset($setting['path'])) { + return self::parsePath($setting['path']); + } + + if (empty($setting['category']) || !is_string($setting['category']) || empty($setting['key']) || !is_string($setting['key'])) { + throw new InvalidArgumentException('Registered settings require a non-empty category/key or path.'); + } + + return [ + 'category' => $setting['category'], + 'key' => $setting['key'], + ]; + } + + /** + * @return array{category: string, key: string} + */ + private static function parsePath(string $path): array + { + $parts = explode('.', $path, 2); + + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { + throw new InvalidArgumentException("Invalid settings path: {$path}"); + } + + return [ + 'category' => $parts[0], + 'key' => $parts[1], + ]; + } + + /** + * @param array{category: string, key: string} $parsed + */ + private static function registryKey(array $parsed): string + { + return $parsed['category'] . '.' . $parsed['key']; + } + + private static function assertSource(string $source): void + { + if ($source === '') { + throw new InvalidArgumentException('Setting definitions require a non-empty package source.'); + } + } +} diff --git a/packages/core/tests/Feature/SettingRegistryTest.php b/packages/core/tests/Feature/SettingRegistryTest.php new file mode 100644 index 0000000..bd157d3 --- /dev/null +++ b/packages/core/tests/Feature/SettingRegistryTest.php @@ -0,0 +1,86 @@ +where('category', 'tests-registry')->delete(); + } + + protected function tearDown(): void + { + SettingModel::query()->where('category', 'tests-registry')->delete(); + + parent::tearDown(); + } + + public function test_setting_paths_cannot_be_registered_by_two_packages(): void + { + Setting::add('tests-registry.collision', 'first', source: 'tests/package-a'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('is owned by "tests/package-a" and cannot be registered by "tests/package-b"'); + + Setting::add('tests-registry.collision', 'second', source: 'tests/package-b'); + } + + public function test_same_package_can_register_a_definition_idempotently(): void + { + $first = Setting::add('tests-registry.idempotent', 'first', source: 'tests/package-a'); + $second = Setting::add('tests-registry.idempotent', 'second', source: 'tests/package-a'); + + $this->assertSame($first->definition_key, $second->definition_key); + $this->assertSame('tests/package-a', $second->owner_package); + $this->assertSame('second', $second->value); + } + + public function test_only_the_owner_can_rename_a_setting_definition(): void + { + Setting::add('tests-registry.old_name', 'value', source: 'tests/package-a'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('is owned by "tests/package-a" and cannot be renamed by "tests/package-b"'); + + SettingRegistry::rename('tests-registry.old_name', 'tests-registry.new_name', 'tests/package-b'); + } + + public function test_owner_rename_keeps_the_definition_identity(): void + { + $setting = Setting::add('tests-registry.old_name', 'value', source: 'tests/package-a'); + + SettingRegistry::rename('tests-registry.old_name', 'tests-registry.new_name', 'tests/package-a'); + + $renamed = SettingModel::query() + ->where('category', 'tests-registry') + ->where('key', 'new_name') + ->firstOrFail(); + + $this->assertSame($setting->definition_key, $renamed->definition_key); + $this->assertSame('tests/package-a', $renamed->owner_package); + $this->assertDatabaseMissing('settings', [ + 'category' => 'tests-registry', + 'key' => 'old_name', + ]); + } + + public function test_direct_definition_metadata_updates_are_rejected(): void + { + $setting = Setting::add('tests-registry.immutable', 'value', source: 'tests/package-a'); + $setting->key = 'changed'; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Setting definition "tests-registry.changed" is immutable'); + + $setting->save(); + } +} diff --git a/packages/packages/src/Support/MarketplaceCredentials.php b/packages/packages/src/Support/MarketplaceCredentials.php index 1e9bdae..c3a4853 100644 --- a/packages/packages/src/Support/MarketplaceCredentials.php +++ b/packages/packages/src/Support/MarketplaceCredentials.php @@ -2,7 +2,9 @@ namespace Froxlor\Packages\Support; +use Froxlor\Core\Support\ComposerPackage; use Froxlor\Core\Support\Setting; +use RuntimeException; /** * Credentials used to authenticate against packages.froxlor.org — for now only to raise the @@ -31,7 +33,10 @@ public static function configured(): bool public static function save(?string $username, ?string $token): void { - Setting::set('packages.marketplace_username', $username ?: self::DEFAULT_USERNAME); - Setting::set('packages.marketplace_token', $token); + $source = ComposerPackage::forClass(self::class) + ?? throw new RuntimeException('Unable to resolve the composer package for ' . self::class); + + Setting::set('packages.marketplace_username', $username ?: self::DEFAULT_USERNAME, source: $source); + Setting::set('packages.marketplace_token', $token, source: $source); } } diff --git a/packages/packages/tests/Feature/EnsurePackageUpdatesAreCompleteTest.php b/packages/packages/tests/Feature/EnsurePackageUpdatesAreCompleteTest.php index 7e3b4b6..1d23e6f 100644 --- a/packages/packages/tests/Feature/EnsurePackageUpdatesAreCompleteTest.php +++ b/packages/packages/tests/Feature/EnsurePackageUpdatesAreCompleteTest.php @@ -17,7 +17,7 @@ protected function tearDown(): void // example's add_style_to_example_visits_table migration needs this setting present (see // FroxlorExampleServiceProvider), which is unrelated to what these tests exercise, so // make sure it's satisfied regardless of test order. - Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string'); + Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string', source: self::TEST_PACKAGE); app(PackageService::class)->findProvider(self::TEST_PACKAGE)?->completeCompletion(); @@ -59,7 +59,7 @@ public function test_completing_the_pending_action_lifts_the_lockout(): void // completeCompletion() below also runs any migrations that were held back — froxlor/ // example's add_style_to_example_visits_table migration needs this setting present. - Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string'); + Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string', source: self::TEST_PACKAGE); $provider->completeCompletion(); diff --git a/packages/packages/tests/Feature/PackagesSyncPreMigrationTest.php b/packages/packages/tests/Feature/PackagesSyncPreMigrationTest.php index 11977d9..f102964 100644 --- a/packages/packages/tests/Feature/PackagesSyncPreMigrationTest.php +++ b/packages/packages/tests/Feature/PackagesSyncPreMigrationTest.php @@ -36,7 +36,7 @@ protected function tearDown(): void // Whatever a given test left mid-way through, always finish in a fully configured, // fully migrated state — other test files also call completeCompletion() on this same // real package and don't expect its migration to be pending, regardless of run order. - Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string'); + Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string', source: self::TEST_PACKAGE); Artisan::call('migrate', ['--force' => true]); app(PackageService::class)->findProvider(self::TEST_PACKAGE)?->completeCompletion(); @@ -61,7 +61,7 @@ public function test_completing_the_gate_runs_the_deferred_migration_and_fires_u Artisan::call('froxlor:packages:sync', ['--updated' => [self::TEST_PACKAGE]]); $this->assertFalse(Schema::hasColumn('example_visits', 'style')); - Setting::set('example.greeting_style', 'casual', 'string'); + Setting::set('example.greeting_style', 'casual', 'string', source: self::TEST_PACKAGE); SettingModel::query()->where('category', 'example')->where('key', 'last_updated_at')->delete(); app(PackageService::class)->findProvider(self::TEST_PACKAGE)->completeCompletion(); @@ -74,7 +74,7 @@ public function test_completing_the_gate_runs_the_deferred_migration_and_fires_u public function test_sync_runs_migrations_immediately_when_the_setting_is_already_set(): void { - Setting::set('example.greeting_style', 'formal', 'string'); + Setting::set('example.greeting_style', 'formal', 'string', source: self::TEST_PACKAGE); Artisan::call('froxlor:packages:sync', ['--updated' => [self::TEST_PACKAGE]]); diff --git a/packages/packages/tests/Feature/PendingCompletionTest.php b/packages/packages/tests/Feature/PendingCompletionTest.php index 6f321f8..2516949 100644 --- a/packages/packages/tests/Feature/PendingCompletionTest.php +++ b/packages/packages/tests/Feature/PendingCompletionTest.php @@ -18,7 +18,7 @@ protected function tearDown(): void // example's add_style_to_example_visits_table migration needs this setting present (see // FroxlorExampleServiceProvider), which is unrelated to what these tests exercise, so // make sure it's satisfied regardless of test order. - Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string'); + Setting::set('example.greeting_style', Setting::get('example.greeting_style', 'casual'), 'string', source: self::TEST_PACKAGE); app(PackageService::class)->findProvider(self::TEST_PACKAGE)?->completeCompletion(); diff --git a/packages/ui/database/migrations/0001_01_02_000001_create_theme_settings.php b/packages/ui/database/migrations/0001_01_02_000001_create_theme_settings.php index 5defa97..4449145 100644 --- a/packages/ui/database/migrations/0001_01_02_000001_create_theme_settings.php +++ b/packages/ui/database/migrations/0001_01_02_000001_create_theme_settings.php @@ -85,6 +85,7 @@ public function up(): void ], 'sort' => 10, ], + source: 'froxlor/ui', ); $this->addColors(self::BASE_COLORS, 'colors.base', 'base_colors', 100); @@ -116,6 +117,7 @@ private function addColors(array $colors, string $keyPrefix, string $group, int 'sort' => $sort++, 'shades' => $name === 'color-primary' ? self::PRIMARY_SHADES : null, ]), + source: 'froxlor/ui', ); } } From 1ec9ed94dc439bc185ed6ecd03d527c713aa556a Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Tue, 25 Aug 2026 11:46:52 +0200 Subject: [PATCH 03/11] Use explicit audit action logging --- .../Http/Controllers/Api/ApiKeyController.php | 17 ++++++++++++ .../Http/Controllers/Api/NodeController.php | 10 +++++++ .../Api/Plan/PlanResourceController.php | 2 +- .../Http/Controllers/Api/PlanController.php | 10 +++++++ .../Api/Role/RolePermissionController.php | 2 +- .../Http/Controllers/Api/RoleController.php | 10 +++++++ .../Controllers/Api/SettingsController.php | 23 +++++++++++++++- .../Api/Tenant/Environment/UserController.php | 6 +++++ .../Api/Tenant/EnvironmentController.php | 10 +++++++ .../Controllers/Api/Tenant/NodeController.php | 10 +++++++ .../Tenant/Plan/PlanResourceController.php | 2 +- .../Controllers/Api/Tenant/PlanController.php | 10 +++++++ .../Controllers/Api/Tenant/RoleController.php | 10 +++++++ .../Controllers/Api/Tenant/UserController.php | 6 +++++ .../Http/Controllers/Api/TenantController.php | 16 ++++++++++- .../Http/Controllers/Api/UserController.php | 12 +++++++++ .../Http/Controllers/Web/ApiKeyController.php | 27 ++++++++++++++++++- .../Web/Auth/PasswordController.php | 8 +++++- .../src/Observers/EnvironmentObserver.php | 10 ------- packages/core/src/Observers/NodeObserver.php | 10 ------- .../src/Providers/EventServiceProvider.php | 21 --------------- .../Services/Bootstrap/BootstrapService.php | 7 +++++ packages/core/src/Support/Audit.php | 8 +++--- .../tests/Feature/NodeResourceUsageTest.php | 25 ++++++++++++++--- 24 files changed, 217 insertions(+), 55 deletions(-) diff --git a/packages/core/src/Http/Controllers/Api/ApiKeyController.php b/packages/core/src/Http/Controllers/Api/ApiKeyController.php index 8b68e13..32b9d66 100644 --- a/packages/core/src/Http/Controllers/Api/ApiKeyController.php +++ b/packages/core/src/Http/Controllers/Api/ApiKeyController.php @@ -6,6 +6,7 @@ use Froxlor\Core\Http\Controllers\Controller; use Froxlor\Core\Http\Requests\StoreApiKeyRequest; use Froxlor\Core\Models\User; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Laravel\Sanctum\PersonalAccessToken; @@ -57,6 +58,11 @@ public function store(StoreApiKeyRequest $request) $token->setAttribute('plain_text_token', $newToken->plainTextToken); + Audit::notice('api key "' . $token->name . '" created', $user->tenants()->first(), context: [ + 'api_key_id' => $token->id, + 'user_id' => $user->id, + ]); + return Response::jsonResource($token); } @@ -69,8 +75,19 @@ public function show(PersonalAccessToken $apiKey) public function destroy(PersonalAccessToken $apiKey) { + $apiKey->loadMissing('tokenable'); + $user = $apiKey->tokenable instanceof User ? $apiKey->tokenable : null; + $tenant = $user?->tenants()->first(); + $apiKeyId = $apiKey->id; + $apiKeyName = $apiKey->name; + $apiKey->delete(); + Audit::info('api key "' . $apiKeyName . '" deleted', $tenant, context: [ + 'api_key_id' => $apiKeyId, + 'user_id' => $user?->id, + ]); + return response()->noContent(); } } diff --git a/packages/core/src/Http/Controllers/Api/NodeController.php b/packages/core/src/Http/Controllers/Api/NodeController.php index 63ffa3d..8b4cc78 100644 --- a/packages/core/src/Http/Controllers/Api/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/NodeController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Jobs\Node\ExploreNode; use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Illuminate\Support\Facades\Gate; @@ -59,6 +60,9 @@ public function store(StoreNodeRequest $request) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($node, $eventData)); + Audit::notice('node "' . $node->name . '" created', $node->tenant, context: [ + 'node_id' => $node->id, + ]); // run explore-node job dispatch(new ExploreNode($node, true)); @@ -85,6 +89,9 @@ public function update(UpdateNodeRequest $request, Node $node) $node->update($this->normalizeNodeProperties($request->validated(), $node)); event(new ResourceUpdated($node, $this->validatedEventData($request))); + Audit::info('node "' . $node->name . '" updated', $node->tenant, context: [ + 'node_id' => $node->id, + ]); return Response::jsonResource($node); } @@ -98,6 +105,9 @@ public function destroy(Node $node) $node->delete(); event(new ResourceDeleted($node, [])); + Audit::info('node "' . $node->name . '" deleted', $node->tenant, context: [ + 'node_id' => $node->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php index eb26691..eae27d7 100644 --- a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -65,7 +65,7 @@ public function store(Request $request, Plan $plan) $resource = Resource::query()->findOrFail($data['resource_id']); PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit']); - Audit::info('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $plan->tenant, context: [ + Audit::notice('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $plan->tenant, context: [ 'plan_id' => $plan->id, 'resource_id' => $resource->id, 'resource_key' => $resource->key, diff --git a/packages/core/src/Http/Controllers/Api/PlanController.php b/packages/core/src/Http/Controllers/Api/PlanController.php index 7d9b047..b314410 100644 --- a/packages/core/src/Http/Controllers/Api/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/PlanController.php @@ -9,6 +9,7 @@ use Froxlor\Core\Http\Requests\StorePlanRequest; use Froxlor\Core\Http\Requests\UpdatePlanRequest; use Froxlor\Core\Models\Plan; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Support\Facades\Gate; @@ -40,6 +41,9 @@ public function store(StorePlanRequest $request) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($plan, $eventData)); + Audit::notice('plan "' . $plan->name . '" created', context: [ + 'plan_id' => $plan->id, + ]); // return resource return Response::jsonResource($plan->refresh()); @@ -64,6 +68,9 @@ public function update(UpdatePlanRequest $request, Plan $plan) $plan->update($request->validated()); event(new ResourceUpdated($plan, $this->validatedEventData($request))); + Audit::info('plan "' . $plan->name . '" updated', context: [ + 'plan_id' => $plan->id, + ]); return Response::jsonResource($plan->refresh()); } @@ -78,6 +85,9 @@ public function destroy(Plan $plan) $plan->delete(); event(new ResourceDeleted($plan, [])); + Audit::info('plan "' . $plan->name . '" deleted', context: [ + 'plan_id' => $plan->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php index 5ff8c80..26195c1 100644 --- a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php @@ -70,7 +70,7 @@ public function store(Request $request, Role $role) $permission->id => ['inheritable' => $data['inheritable'] ?? false], ]); - Audit::info('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $role->tenant, context: [ + Audit::notice('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $role->tenant, context: [ 'role_id' => $role->id, 'permission_id' => $permission->id, 'permission_key' => $permission->key, diff --git a/packages/core/src/Http/Controllers/Api/RoleController.php b/packages/core/src/Http/Controllers/Api/RoleController.php index 041aebe..1959850 100644 --- a/packages/core/src/Http/Controllers/Api/RoleController.php +++ b/packages/core/src/Http/Controllers/Api/RoleController.php @@ -11,6 +11,7 @@ use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Support\Response; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\RoleAssignments; use Illuminate\Support\Facades\Gate; @@ -51,6 +52,9 @@ public function store(StoreRoleRequest $request) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($role, $eventData)); + Audit::notice('role "' . $role->name . '" created', $role->tenant, context: [ + 'role_id' => $role->id, + ]); // return resource return Response::jsonResource($role->refresh()); @@ -75,6 +79,9 @@ public function update(UpdateRoleRequest $request, Role $role) $role->update($request->validated()); event(new ResourceUpdated($role, $this->validatedEventData($request))); + Audit::info('role "' . $role->name . '" updated', $role->tenant, context: [ + 'role_id' => $role->id, + ]); return Response::jsonResource($role->refresh()); } @@ -89,6 +96,9 @@ public function destroy(Role $role) $role->delete(); event(new ResourceDeleted($role, [])); + Audit::info('role "' . $role->name . '" deleted', $role->tenant, context: [ + 'role_id' => $role->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/SettingsController.php b/packages/core/src/Http/Controllers/Api/SettingsController.php index 2ef955d..e33ccdb 100644 --- a/packages/core/src/Http/Controllers/Api/SettingsController.php +++ b/packages/core/src/Http/Controllers/Api/SettingsController.php @@ -3,7 +3,11 @@ namespace Froxlor\Core\Http\Controllers\Api; use Froxlor\Core\Http\Controllers\Controller; +use Froxlor\Core\Models\Environment; +use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Setting; +use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; @@ -47,9 +51,26 @@ public function store(Request $request, ?string $resource = null, ?string $resou $this->validateIncomingValue($value, $setting, $fieldKey); - $setting->update([ + $updated = $setting->update([ 'value' => $value, ]); + + if ($updated && $setting->wasChanged('value')) { + $settingable = $setting->settingable; + $tenant = match (true) { + $settingable instanceof Tenant => $settingable, + $settingable instanceof Environment => $settingable->tenant, + $settingable instanceof Node => $settingable->tenant, + default => null, + }; + $environment = $settingable instanceof Environment ? $settingable : null; + + Audit::info('setting "' . $setting->category . '.' . $setting->key . '" updated', $tenant, $environment, [ + 'setting_id' => $setting->id, + 'settingable_type' => $setting->settingable_type, + 'settingable_id' => $setting->settingable_id, + ]); + } } return Response::jsonResource([ diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index 4990d5c..f916c13 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -135,6 +135,9 @@ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, En } event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $tenant, $environment, context: [ + 'user_id' => $user->id, + ]); return Response::jsonResource($user->refresh()); } @@ -148,6 +151,9 @@ public function destroy(Request $request, Tenant $tenant, Environment $environme $environment->users()->detach($user); event(new ResourceDeleted($user, [])); + Audit::info('user "' . $user->email . '" removed', $tenant, $environment, context: [ + 'user_id' => $user->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php index 0443127..65f6c23 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; @@ -50,6 +51,9 @@ public function store(StoreEnvironmentRequest $request, Tenant $tenant) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($env, $eventData)); + Audit::notice('environment "' . $env->name . '" created', $tenant, $env, [ + 'plan_id' => $env->plan_id, + ]); // connect to node and create environment if given if (!empty($node_id)) { $node = $this->nodeForTenant($node_id, $tenant); @@ -85,6 +89,9 @@ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Enviro $environment->update($envData); event(new ResourceUpdated($environment, $this->validatedEventData($request))); + Audit::info('environment "' . $environment->name . '" updated', $tenant, $environment, [ + 'plan_id' => $environment->plan_id, + ]); if (!empty($nodeId)) { $node = $this->nodeForTenant($nodeId, $tenant); @@ -103,6 +110,9 @@ public function destroy(Request $request, Tenant $tenant, Environment $environme $environment->delete(); event(new ResourceDeleted($environment, [])); + Audit::info('environment "' . $environment->name . '" deleted', $tenant, $environment, [ + 'plan_id' => $environment->plan_id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php index 8e5f6a5..5b50055 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Jobs\Node\ExploreNode; use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Illuminate\Support\Facades\Gate; @@ -52,6 +53,9 @@ public function store(StoreNodeRequest $request, Tenant $tenant) ]); event(new ResourceCreated($node, $this->validatedEventData($request))); + Audit::notice('node "' . $node->name . '" created', $tenant, context: [ + 'node_id' => $node->id, + ]); dispatch(new ExploreNode($node, true)); @@ -77,6 +81,9 @@ public function update(UpdateNodeRequest $request, Tenant $tenant, Node $node) $node->update($this->normalizeNodeProperties($request->validated(), $node)); event(new ResourceUpdated($node, $this->validatedEventData($request))); + Audit::info('node "' . $node->name . '" updated', $tenant, context: [ + 'node_id' => $node->id, + ]); return Response::jsonResource($node->refresh()); } @@ -90,6 +97,9 @@ public function destroy(Tenant $tenant, Node $node) $node->delete(); event(new ResourceDeleted($node, [])); + Audit::info('node "' . $node->name . '" deleted', $tenant, context: [ + 'node_id' => $node->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php index 04420b5..9955a35 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -66,7 +66,7 @@ public function store(Request $request, Tenant $tenant, Plan $plan) $resource = Resource::query()->findOrFail($data['resource_id']); PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit'], $tenant); - Audit::info('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ + Audit::notice('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ 'plan_id' => $plan->id, 'resource_id' => $resource->id, 'resource_key' => $resource->key, diff --git a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php index 1da1f48..b65ab8b 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php @@ -10,6 +10,7 @@ use Froxlor\Core\Http\Requests\UpdatePlanRequest; use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; @@ -46,6 +47,9 @@ public function store(StorePlanRequest $request, Tenant $tenant) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($plan, $eventData)); + Audit::notice('plan "' . $plan->name . '" created', $tenant, context: [ + 'plan_id' => $plan->id, + ]); // return resource return Response::jsonResource($plan->refresh()); @@ -78,6 +82,9 @@ public function update(UpdatePlanRequest $request, Tenant $tenant, Plan $plan) $plan->update($request->validated()); event(new ResourceUpdated($plan, $this->validatedEventData($request))); + Audit::info('plan "' . $plan->name . '" updated', $tenant, context: [ + 'plan_id' => $plan->id, + ]); return Response::jsonResource($plan->refresh()); } @@ -92,6 +99,9 @@ public function destroy(Request $request, Tenant $tenant, Plan $plan) $plan->delete(); event(new ResourceDeleted($plan, [])); + Audit::info('plan "' . $plan->name . '" deleted', $tenant, context: [ + 'plan_id' => $plan->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/RoleController.php b/packages/core/src/Http/Controllers/Api/Tenant/RoleController.php index c5bc6d9..c886274 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/RoleController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/RoleController.php @@ -10,6 +10,7 @@ use Froxlor\Core\Http\Requests\UpdateRoleRequest; use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Froxlor\Core\Support\RoleAssignments; use Illuminate\Http\Request; @@ -54,6 +55,9 @@ public function store(StoreTenantRoleRequest $request, Tenant $tenant) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($role, $eventData)); + Audit::notice('role "' . $role->name . '" created', $tenant, context: [ + 'role_id' => $role->id, + ]); // return resource return Response::jsonResource($role->refresh()); @@ -78,6 +82,9 @@ public function update(UpdateRoleRequest $request, Tenant $tenant, Role $role) $role->update($request->validated()); event(new ResourceUpdated($role, $this->validatedEventData($request))); + Audit::info('role "' . $role->name . '" updated', $tenant, context: [ + 'role_id' => $role->id, + ]); return Response::jsonResource($role->refresh()); } @@ -92,6 +99,9 @@ public function destroy(Request $request, Tenant $tenant, Role $role) $role->delete(); event(new ResourceDeleted($role, [])); + Audit::info('role "' . $role->name . '" deleted', $tenant, context: [ + 'role_id' => $role->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index ce76f7a..e504725 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -130,6 +130,9 @@ public function update(UpdateUserRequest $request, Tenant $tenant, User $user) } event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $tenant, context: [ + 'user_id' => $user->id, + ]); return Response::jsonResource($user->refresh()); } @@ -143,6 +146,9 @@ public function destroy(Request $request, Tenant $tenant, User $user) $tenant->users()->detach($user); event(new ResourceDeleted($user, [])); + Audit::info('user "' . $user->email . '" removed', $tenant, context: [ + 'user_id' => $user->id, + ]); return response()->json(['message' => 'User removed from environment successfully'], 200); } diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 879bcfc..950942a 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Support\PlanAssignments; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -69,6 +70,10 @@ public function store(StoreTenantRequest $request) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($tenant, $eventData)); + Audit::notice('tenant "' . $tenant->name . '" created', $parentTenant, context: [ + 'tenant_id' => $tenant->id, + 'plan_id' => $tenant->plan_id, + ]); // return resource return Response::jsonResource($tenant->refresh()); @@ -133,8 +138,13 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) }); event(new ResourceUpdated($tenant, $this->validatedEventData($request))); + $tenant->refresh(); + Audit::info('tenant "' . $tenant->name . '" updated', $tenant->parentTenant, context: [ + 'tenant_id' => $tenant->id, + 'plan_id' => $tenant->plan_id, + ]); - return Response::jsonResource($tenant->refresh()); + return Response::jsonResource($tenant); } /** @@ -150,8 +160,12 @@ public function destroy(Tenant $tenant) ]); } + $parentTenant = $tenant->parentTenant; $tenant->delete(); event(new ResourceDeleted($tenant, [])); + Audit::info('tenant "' . $tenant->name . '" deleted', $parentTenant, context: [ + 'tenant_id' => $tenant->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index 7b9fe11..5db691a 100644 --- a/packages/core/src/Http/Controllers/Api/UserController.php +++ b/packages/core/src/Http/Controllers/Api/UserController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; @@ -70,6 +71,9 @@ public function store(StoreUserRequest $request) $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data event(new ResourceCreated($user, $eventData)); + Audit::notice('user "' . $user->email . '" created', $targetTenant, context: [ + 'user_id' => $user->id, + ]); // return resource return Response::jsonResource($user->refresh()); @@ -110,6 +114,7 @@ public function update(UpdateUserRequest $request, User $user) $userData = $request->validated(); $tenantId = $this->getNonModelRequestData('tenant_id', $userData); + $targetTenant = null; $roleId = $this->getNonModelRequestData('role_id', $userData) ?? $this->getNonModelRequestData('role', $userData); $planProvided = $request->has('plan'); @@ -147,6 +152,9 @@ public function update(UpdateUserRequest $request, User $user) } event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $targetTenant ?? $user->tenants()->first(), context: [ + 'user_id' => $user->id, + ]); return Response::jsonResource($user); } @@ -158,8 +166,12 @@ public function destroy(User $user) { Gate::authorize('delete', $user); + $tenant = $user->tenants()->first(); $user->delete(); event(new ResourceDeleted($user, [])); + Audit::info('user "' . $user->email . '" deleted', $tenant, context: [ + 'user_id' => $user->id, + ]); return response()->noContent(); } diff --git a/packages/core/src/Http/Controllers/Web/ApiKeyController.php b/packages/core/src/Http/Controllers/Web/ApiKeyController.php index e850508..308ce2f 100644 --- a/packages/core/src/Http/Controllers/Web/ApiKeyController.php +++ b/packages/core/src/Http/Controllers/Web/ApiKeyController.php @@ -3,7 +3,9 @@ namespace Froxlor\Core\Http\Controllers\Web; use Froxlor\Core\Http\Controllers\Controller; +use Froxlor\Core\Models\User; use Froxlor\Core\Resources\ApiKeys\ApiKeyResource; +use Froxlor\Core\Support\Audit; use Froxlor\UI\Support\UI; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -36,8 +38,19 @@ public function show(Request $request, PersonalAccessToken $apiKey) public function destroy(PersonalAccessToken $apiKey): RedirectResponse { + $apiKey->loadMissing('tokenable'); + $user = $apiKey->tokenable instanceof User ? $apiKey->tokenable : null; + $tenant = $user?->tenants()->first(); + $apiKeyId = $apiKey->id; + $apiKeyName = $apiKey->name; + $apiKey->delete(); + Audit::info('api key "' . $apiKeyName . '" deleted', $tenant, context: [ + 'api_key_id' => $apiKeyId, + 'user_id' => $user?->id, + ]); + return redirect()->route('auth.api-keys.index'); } @@ -50,9 +63,21 @@ public function bulkDestroy(Request $request): RedirectResponse ->all(); if ($selected !== []) { - PersonalAccessToken::query() + $tokens = PersonalAccessToken::query() + ->with('tokenable') ->whereIn('id', $selected) + ->get(); + + PersonalAccessToken::query() + ->whereIn('id', $tokens->modelKeys()) ->delete(); + + if ($tokens->isNotEmpty()) { + Audit::info('api keys deleted', context: [ + 'api_key_ids' => $tokens->modelKeys(), + 'count' => $tokens->count(), + ]); + } } return redirect()->route('auth.api-keys.index'); diff --git a/packages/core/src/Http/Controllers/Web/Auth/PasswordController.php b/packages/core/src/Http/Controllers/Web/Auth/PasswordController.php index 1729bfa..91c8ee5 100644 --- a/packages/core/src/Http/Controllers/Web/Auth/PasswordController.php +++ b/packages/core/src/Http/Controllers/Web/Auth/PasswordController.php @@ -3,6 +3,7 @@ namespace Froxlor\Core\Http\Controllers\Web\Auth; use Froxlor\Core\Http\Controllers\Controller; +use Froxlor\Core\Support\Audit; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -20,10 +21,15 @@ public function update(Request $request): RedirectResponse 'password' => ['required', Password::defaults(), 'confirmed'], ]); - $request->user()->update([ + $user = $request->user(); + $user->update([ 'password' => Hash::make($validated['password']), ]); + Audit::info('user "' . $user->email . '" password updated', $user->tenants()->first(), context: [ + 'user_id' => $user->id, + ]); + return back()->with('status', 'password-updated'); } } diff --git a/packages/core/src/Observers/EnvironmentObserver.php b/packages/core/src/Observers/EnvironmentObserver.php index dbd9c58..fa2e84f 100644 --- a/packages/core/src/Observers/EnvironmentObserver.php +++ b/packages/core/src/Observers/EnvironmentObserver.php @@ -11,7 +11,6 @@ use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\TenantUsage; use Froxlor\Core\Models\Tenant; -use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Resource; use Throwable; @@ -72,9 +71,6 @@ public function created(Environment $environment): void Resource::addUsage($environment->tenant, $environment, auth()->user()); } - Audit::notice('environment "' . $environment->name . '" created', $environment->tenant, $environment, [ - 'plan_id' => $environment->plan_id, - ]); } /** @@ -82,9 +78,6 @@ public function created(Environment $environment): void */ public function updated(Environment $environment): void { - Audit::info('environment "' . $environment->name . '" updated', $environment->tenant, $environment, [ - 'plan_id' => $environment->plan_id, - ]); } /** @@ -110,8 +103,5 @@ public function deleted(Environment $environment): void ->where('resource_id', $environment->id) ->delete(); - Audit::info('environment "' . $environment->name . '" deleted', $environment->tenant, $environment, [ - 'plan_id' => $environment->plan_id, - ]); } } diff --git a/packages/core/src/Observers/NodeObserver.php b/packages/core/src/Observers/NodeObserver.php index d4fffac..8f910ed 100644 --- a/packages/core/src/Observers/NodeObserver.php +++ b/packages/core/src/Observers/NodeObserver.php @@ -8,7 +8,6 @@ use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\TenantUsage; -use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Resource; use RuntimeException; @@ -55,9 +54,6 @@ public function created(Node $node): void } } - Audit::info('node "' . $node->name . '" created', $node->tenant, null, [ - 'node_id' => $node->id, - ]); } /** @@ -65,9 +61,6 @@ public function created(Node $node): void */ public function updated(Node $node): void { - Audit::info('node "' . $node->name . '" updated', $node->tenant, null, [ - 'node_id' => $node->id, - ]); } /** @@ -92,9 +85,6 @@ public function deleted(Node $node): void ->delete(); } - Audit::info('node "' . $node->name . '" deleted', $node->tenant, null, [ - 'node_id' => $node->id, - ]); } /** diff --git a/packages/core/src/Providers/EventServiceProvider.php b/packages/core/src/Providers/EventServiceProvider.php index 974d4f4..bbb1f4b 100644 --- a/packages/core/src/Providers/EventServiceProvider.php +++ b/packages/core/src/Providers/EventServiceProvider.php @@ -4,10 +4,7 @@ use Froxlor\Core\Events; use Froxlor\Core\Listeners; -use Froxlor\Core\Support\Audit; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Event; class EventServiceProvider extends ServiceProvider { @@ -22,22 +19,4 @@ class EventServiceProvider extends ServiceProvider ] ]; - public function boot(): void - { - // audit log eloquent events - Event::listen('eloquent.*', function (string $eventName, array $data) { - [$event, $type] = array_map('trim', explode(': ', $eventName)); - if (in_array($event, ['eloquent.updated', 'eloquent.created', 'eloquent.deleted', 'eloquent.restored'])) { - Audit::info( - collect(Arr::dot([ - 'event' => $event, - 'type' => $type, - 'data' => $data[0]->getKey() - ])) - ->map(fn($v, $k) => "$k=$v") - ->implode(', ') - ); - } - }); - } } diff --git a/packages/core/src/Services/Bootstrap/BootstrapService.php b/packages/core/src/Services/Bootstrap/BootstrapService.php index e7fd182..dd72b7f 100644 --- a/packages/core/src/Services/Bootstrap/BootstrapService.php +++ b/packages/core/src/Services/Bootstrap/BootstrapService.php @@ -6,6 +6,7 @@ use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Froxlor\Core\Support\Audit; use Froxlor\Core\Support\Setting; use Illuminate\Support\Facades\Hash; @@ -19,6 +20,9 @@ public function initRootTenant(string $email, string $firstName, string $lastNam 'name' => 'Froxlor', 'description' => 'Froxlor Master Tenant' ]); + Audit::notice('tenant "' . $tenant->name . '" created', $tenant, context: [ + 'tenant_id' => $tenant->id, + ]); // create root user $user = User::query()->create([ @@ -27,6 +31,9 @@ public function initRootTenant(string $email, string $firstName, string $lastNam 'email' => $email, 'password' => Hash::make($password), ]); + Audit::notice('user "' . $user->email . '" created', $tenant, context: [ + 'user_id' => $user->id, + ]); // 'Super-Admin' role for the users on this tenant $superAdminRoleId = Role::query()->where('name', 'Super-Admin')->first()->id; diff --git a/packages/core/src/Support/Audit.php b/packages/core/src/Support/Audit.php index 382cf5c..d1d1a25 100644 --- a/packages/core/src/Support/Audit.php +++ b/packages/core/src/Support/Audit.php @@ -38,10 +38,10 @@ public static function __callStatic(string $method, array $args): void } self::log( - $args[0], - $args[1] ?? null, - $args[2] ?? null, - $args[3] ?? null, + $args[0] ?? $args['audit_content'], + $args[1] ?? $args['tenant'] ?? null, + $args[2] ?? $args['environment'] ?? null, + $args[3] ?? $args['context'] ?? null, self::LEVELS[$method] ); } diff --git a/packages/core/tests/Feature/NodeResourceUsageTest.php b/packages/core/tests/Feature/NodeResourceUsageTest.php index 1da365d..f856c2c 100644 --- a/packages/core/tests/Feature/NodeResourceUsageTest.php +++ b/packages/core/tests/Feature/NodeResourceUsageTest.php @@ -149,7 +149,7 @@ public function test_tenant_node_actions_write_audit_log_with_tenant_context(): $this->actingAs($user, 'sanctum'); - $node = $this->createTenantNode($tenant, 'Audited Node'); + $node = $this->createTenantNodeThroughApi($tenant, 'Audited Node'); $this->assertDatabaseHas('audit_logs', [ 'auditable_id' => $user->id, @@ -158,7 +158,10 @@ public function test_tenant_node_actions_write_audit_log_with_tenant_context(): 'action' => 'node "' . $node->name . '" created', ]); - $node->update(['name' => 'Audited Node Updated']); + $this->putJson('/api/tenants/' . $tenant->id . '/nodes/' . $node->id, [ + 'name' => 'Audited Node Updated', + ])->assertOk(); + $node->refresh(); $this->assertDatabaseHas('audit_logs', [ 'auditable_id' => $user->id, @@ -168,7 +171,8 @@ public function test_tenant_node_actions_write_audit_log_with_tenant_context(): ]); $nodeId = $node->id; - $node->delete(); + $this->deleteJson('/api/tenants/' . $tenant->id . '/nodes/' . $node->id) + ->assertNoContent(); $this->assertDatabaseHas('audit_logs', [ 'auditable_id' => $user->id, @@ -196,4 +200,19 @@ private function createTenantNode(Tenant $tenant, string $name): Node 'sudo' => true, ]); } + + private function createTenantNodeThroughApi(Tenant $tenant, string $name): Node + { + $nodeId = $this->postJson('/api/tenants/' . $tenant->id . '/nodes', [ + 'adapter' => FakeNodeAdapter::class, + 'name' => $name, + 'hostname' => str($name)->slug() . '.local', + 'username' => 'root', + 'sudo' => true, + ]) + ->assertCreated() + ->json('data.id'); + + return Node::query()->findOrFail($nodeId); + } } From fa8b8bbeaf88044288b086ab0a3ffb6b5278446b Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 10:43:35 +0200 Subject: [PATCH 04/11] Add extensible core node service setup --- packages/core/docs/node-services.md | 189 +++++++++++ .../Providers/FroxlorCoreServiceProvider.php | 9 + .../Node/Setup/AdapterNodeServiceExecutor.php | 63 ++++ .../Node/Setup/NodeServiceContext.php | 34 ++ .../Node/Setup/NodeServiceExecutor.php | 12 + .../Node/Setup/NodeServicePlanner.php | 94 ++++++ .../Node/Setup/NodeServiceProvider.php | 34 ++ .../Node/Setup/NodeServiceRegistry.php | 81 +++++ .../src/Services/Node/Setup/NodeSetupPlan.php | 40 +++ .../Services/Node/Setup/NodeSetupResult.php | 9 + .../Services/Node/Setup/NodeSetupScript.php | 220 +++++++++++++ .../Setup/Providers/BaseSystemProvider.php | 60 ++++ .../Services/Node/Setup/ServiceOperation.php | 81 +++++ .../src/Services/Node/Setup/ServicePlan.php | 84 +++++ .../Services/Node/Setup/ServiceSettings.php | 81 +++++ .../Services/Node/Setup/SettingDefinition.php | 68 ++++ .../Feature/NodeServiceIntegrationTest.php | 258 +++++++++++++++ .../tests/Feature/NodeServiceSetupTest.php | 306 ++++++++++++++++++ .../tests/Feature/NodeSetupScriptTest.php | 204 ++++++++++++ 19 files changed, 1927 insertions(+) create mode 100644 packages/core/docs/node-services.md create mode 100644 packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php create mode 100644 packages/core/src/Services/Node/Setup/NodeServiceContext.php create mode 100644 packages/core/src/Services/Node/Setup/NodeServiceExecutor.php create mode 100644 packages/core/src/Services/Node/Setup/NodeServicePlanner.php create mode 100644 packages/core/src/Services/Node/Setup/NodeServiceProvider.php create mode 100644 packages/core/src/Services/Node/Setup/NodeServiceRegistry.php create mode 100644 packages/core/src/Services/Node/Setup/NodeSetupPlan.php create mode 100644 packages/core/src/Services/Node/Setup/NodeSetupResult.php create mode 100644 packages/core/src/Services/Node/Setup/NodeSetupScript.php create mode 100644 packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php create mode 100644 packages/core/src/Services/Node/Setup/ServiceOperation.php create mode 100644 packages/core/src/Services/Node/Setup/ServicePlan.php create mode 100644 packages/core/src/Services/Node/Setup/ServiceSettings.php create mode 100644 packages/core/src/Services/Node/Setup/SettingDefinition.php create mode 100644 packages/core/tests/Feature/NodeServiceIntegrationTest.php create mode 100644 packages/core/tests/Feature/NodeServiceSetupTest.php create mode 100644 packages/core/tests/Feature/NodeSetupScriptTest.php diff --git a/packages/core/docs/node-services.md b/packages/core/docs/node-services.md new file mode 100644 index 0000000..91141d1 --- /dev/null +++ b/packages/core/docs/node-services.md @@ -0,0 +1,189 @@ +# Node service extension contract + +Core owns provider registration, setting validation, planning and execution contracts. +Service packages own their implementations. Environment provisioning, Unix accounts, +jails and customer resources are independent and are not part of this subsystem. + +The initial `froxlor/core:base-system` provider supports exactly Debian 13 and Ubuntu +24.04. It ensures these packages are installed: `ca-certificates`, `logrotate`, `sudo`, +`curl`, `dnsutils`, `iproute2`, `iputils-ping`, `procps`, `lsof`, `less`, `jq`. +It does not change sudo policy, install hosting daemons, create customer directories, +upgrade installed packages, or configure customer log rotation. + +## Register a provider + +Implement `Froxlor\Core\Services\Node\Setup\NodeServiceProvider`. In the package's +enabled Laravel service provider, register the implementation during `boot()`: + +```php +use Froxlor\Core\Services\Node\Setup\NodeServiceRegistry; + +$this->app->make(NodeServiceRegistry::class)->register(RspamdProvider::class); +``` + +Registration is application-scoped, idempotent for an identical definition, and +does not write to the database or connect to a node. A provider key has the format +`vendor/package:provider`, for example `froxlor/antispam:rspamd`. The declared package +must own that prefix. Registry metadata includes role, revision, platforms, +dependencies, conflicts and settings schemas. `available($node->platform())` filters +the catalog by exact supported platform. Unknown platforms fail closed. + +Provider methods: + +| Method | Meaning | +| --- | --- | +| `key()` | Stable package-qualified implementation ID | +| `role()` | Capability, e.g. `antispam`; one provider per selected role | +| `package()` | Composer package owning the provider/settings | +| `revision()` | Change whenever provider logic or templates change | +| `platforms()` | Exact keys, e.g. `debian@13`, `ubuntu@24.04` | +| `requires()` | Required roles that must be explicitly selected | +| `conflicts()` | Incompatible roles or qualified provider IDs | +| `settings()` | Local names mapped to `SettingDefinition` objects | +| `plan($context)` | Deterministic `ServicePlan`, no infrastructure side effects | + +Dependencies are topologically sorted. Missing dependencies, cycles, role mismatches, +duplicate configuration paths and duplicate service ownership reject the entire plan. +There is no silent provider replacement or platform fallback. Persist provider keys, +not PHP class names, in a future selection UI. Missing packages must remain a visible +error, never an instruction to uninstall an existing service. + +## Settings and templates + +```php +public function settings(): array +{ + return [ + 'workers' => SettingDefinition::integer(default: 4, min: 1, max: 32), + 'enabled' => SettingDefinition::boolean(default: true), + 'mode' => SettingDefinition::choice(default: 'normal', choices: ['normal', 'strict']), + ]; +} +``` + +Values use the existing settings precedence: node value, node-type value, global +value, provider default. The path is `services..`, e.g. +`services.froxlor/antispam:rspamd.workers`. Registry ownership is the provider's +package, not `froxlor/core` simply because the value belongs to a Node model. + +After the caller authorizes node administration, use: + +```php +ServiceSettings::store($node, $provider, ['workers' => 8]); +``` + +The entire update is validated before its transactional write. Unknown setting names +are rejected. These settings are for ordinary configuration, not secret storage. +Package names, filesystem targets, executables and service names belong in trusted +provider code rather than user-editable settings. + +`NodeServiceContext` exposes the node ID, `NodePlatform` and typed `ServiceSettings`, +but no adapter or Environment object. Use `$context->settings->integer('workers')`. +For platform views, `$context->platformTemplate('my-package', 'daemon')` resolves to +`my-package::node.services.debian-13.daemon` or +`my-package::node.services.ubuntu-24-04.daemon`. Missing views fail; no fallback is used. + +```php +return ServicePlan::make() + ->ensurePackages(['example-daemon']) + ->managedConfig( + path: '/etc/example/daemon.conf', + template: $context->platformTemplate('my-package', 'daemon'), + data: ['workers' => $context->settings->integer('workers')], + ) + ->validateCommand(['/usr/sbin/example-daemon', '--check-config']) + ->activateService('example-daemon', onChange: 'reload') + ->healthCheck(['/usr/sbin/example-daemon', '--check-health']); +``` + +This is illustrative; the executable and switches must be implemented by the actual +service package. Use service-specific template escaping: Blade's HTML escaping is +not shell/configuration escaping. Config templates must never embed arbitrary commands. + +The current operation vocabulary covers packages, root-owned configuration files, +validation commands, service enable/start/reload/restart and health checks. Config +files are restricted to canonical paths below `/etc` and modes 0600/0640/0644. Their +parent directory must already exist and be root-owned without group/world write +access. Each plan with files needs a validator; each plan with services needs a health +check. Commands are explicit argument arrays, not shell strings. Providers remain +trusted code: an argument array alone cannot make a dangerous executable safe. + +Plans are immutable. `toArray()` is a redacted preview; it omits rendered contents and +command arguments. `payload()` is execution-only and must never be returned from APIs, +logged or serialized into queue jobs. Fingerprints include rendered content; do not +put low-entropy secrets in these plans. + +## Plan and explicitly apply + +```php +$plan = app(NodeServicePlanner::class)->plan($node, [ + 'base-system' => 'froxlor/core:base-system', +]); + +$preview = $plan->toArray(); + +// Separate, explicitly authorized action: +$result = app(NodeServiceExecutor::class)->apply($node, $plan); +``` + +Planning permits validated temporary overrides for previews via its third argument, +keyed by provider ID. Applying rebuilds the plan from registered code and persisted +settings and rejects changed fingerprints. Persist approved settings first and then +generate a new plan. A submitted serialized plan is never an execution API. + +The default executor checks the Node `update` policy, validates node identity and +replans after refreshing the node. It writes start/success/failure audit events and +returns only run ID and fingerprint. It invokes the existing Node adapter explicitly. +Nothing runs during registration, exploration, package updates or application boot. +This change does not add routes, UI, automatic node onboarding, readiness gating or +an automatic Apache/nginx migration. + +## Execution and recovery boundaries + +`AdapterNodeServiceExecutor` is a compatibility implementation using the existing +privileged adapter. It is **not** the separate, least-privilege node helper discussed +for a hardened deployment. SSH identity/host-key validation and privilege separation +remain transport/bootstrap concerns. A different backend can implement +`NodeServiceExecutor` and replace the container binding. + +The adapter backend requires root, Bash, coreutils, util-linux/flock and apt/dpkg; +service operations additionally require systemd. The OS is rechecked on the node. +Execution is serialized by a node-local flock, with a 300-second acquisition timeout +and 1200-second process limit. Existing environment jobs do not acquire this lock; +node setup must be completed before provisioning environments. + +Missing packages are installed without recommends, explicit upgrades or removals. +A temporary `policy-rc.d` suppresses maintainer-script service starts. An existing +administrator policy is preserved: if packages are missing, installation fails +rather than overriding it. Packages with maintainer scripts bypassing this policy +are not supported. Installation can alter dependencies and is not rolled back. + +For each provider, all packages are ensured, changed files are backed up and replaced +atomically per file, the resulting configuration is validated, services are activated, +then health checks run. The running service is not intentionally reloaded before +validation. This is not an atomic transaction across multiple files/services; concurrent +manual service reloads must be avoided. Unchanged files retain their inode and active +services are not reloaded on a no-op run. Initially stopped services are started. + +On failure the backend restores files and attempts to restore previously activated +services. Newly enabled services are disabled again when appropriate. Root-owned +run directories under `/var/lib/froxlor/node-setup/` contain phase/status, +fingerprint, candidate files, backups and target/existence metadata. Raw command +output is suppressed to avoid leaking secrets. Audit logs contain no command output. +Run directories are retained for operator recovery and need an administrator-managed +retention policy, especially when configs contain credentials. + +On a killed process, power loss or failed rollback, the next run refuses mutation +while any journal is `running` or `recovery-required`. An administrator must inspect +the phase, backup/target records and current node state, restore as needed, then mark +the reviewed journal `recovered`. No automatic recovery is claimed. A connection +loss after remote success is also possible; the node journal is authoritative for +that run. A repeated apply will recheck the actual state. + +## Verification + +Run Core tests in the documented Docker/MariaDB stack. Unit/contract tests use fake +nodes/adapters and syntax-check compiled scripts. They must never apply a setup plan +to the development host. Before production use, provider releases require integration +tests on disposable Debian/Ubuntu VMs, including package installation, service health, +rollback, interrupted execution and repeated no-op application. diff --git a/packages/core/src/Providers/FroxlorCoreServiceProvider.php b/packages/core/src/Providers/FroxlorCoreServiceProvider.php index 9ff702a..4c5d8d6 100644 --- a/packages/core/src/Providers/FroxlorCoreServiceProvider.php +++ b/packages/core/src/Providers/FroxlorCoreServiceProvider.php @@ -20,6 +20,10 @@ use Froxlor\Core\Policies\TenantPolicy; use Froxlor\Core\Policies\UserPolicy; use Froxlor\Core\Services\Node\Adapter\Local; +use Froxlor\Core\Services\Node\Setup\AdapterNodeServiceExecutor; +use Froxlor\Core\Services\Node\Setup\NodeServiceExecutor; +use Froxlor\Core\Services\Node\Setup\NodeServiceRegistry; +use Froxlor\Core\Services\Node\Setup\Providers\BaseSystemProvider; use Froxlor\Core\Support\FroxlorVersion; use Froxlor\Core\Support\PackageServiceProvider; use Froxlor\Core\Support\PermissionRegistry; @@ -43,6 +47,8 @@ class FroxlorCoreServiceProvider extends PackageServiceProvider public function boot(): void { + $this->app->make(NodeServiceRegistry::class)->register(BaseSystemProvider::class); + AboutCommand::add('froxlor', fn() => [ 'version' => FroxlorVersion::release(), ]); @@ -123,6 +129,9 @@ public function boot(): void public function register(): void { + $this->app->singleton(NodeServiceRegistry::class); + $this->app->bind(NodeServiceExecutor::class, AdapterNodeServiceExecutor::class); + // Configs $this->mergeConfigFrom(__DIR__ . '/../../config/dev.php', 'dev'); } diff --git a/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php new file mode 100644 index 0000000..306ea2d --- /dev/null +++ b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php @@ -0,0 +1,63 @@ +exists || $plan->nodeId !== (string) $node->getKey()) { + throw new LogicException('Setup plan belongs to another or unpersisted node.'); + } + $node->refresh(); + Gate::authorize('update', $node); + + $selection = []; + foreach ($plan->services as $key => $service) { + $selection[$service['role']] = $key; + } + $current = $this->planner->plan($node, $selection); + if (! hash_equals($current->fingerprint(), $plan->fingerprint())) { + throw new LogicException('Node setup plan is stale. Generate a new plan.'); + } + + $runId = (string) Str::ulid(); + $context = ['node_id' => $node->id, 'run_id' => $runId, 'fingerprint' => $current->fingerprint()]; + Audit::notice('node service setup started', $node->tenant, context: $context); + + try { + $script = $this->scripts->compile($current, $runId); + // Only base64 enters the adapter heredoc. No rendered value can terminate it. + $command = 'printf %s '.escapeshellarg(base64_encode($script)) + .' | base64 -d | /usr/bin/timeout --signal=TERM --kill-after=30s 1200s /bin/bash -se'; + $output = $node->adapter()->exec([$command]); + // Some adapters do not reliably propagate exit codes; require the final marker too. + if (! is_string($output) || trim($output) !== 'FROXLOR_SETUP_OK:'.$runId) { + throw new RuntimeException('Node setup failed; inspect the root-owned node setup journal.'); + } + } catch (Throwable) { + Audit::error('node service setup failed', $node->tenant, context: $context); + // Adapter exceptions or stderr can contain secrets. Do not propagate them. + throw new RuntimeException('Node setup failed; inspect the root-owned node setup journal.'); + } + + Audit::notice('node service setup completed', $node->tenant, context: $context); + + return new NodeSetupResult($runId, $current->fingerprint()); + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeServiceContext.php b/packages/core/src/Services/Node/Setup/NodeServiceContext.php new file mode 100644 index 0000000..7ef6295 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeServiceContext.php @@ -0,0 +1,34 @@ +platform(); + if (! $platform->supported || ! in_array($platform->key(), $provider->platforms(), true)) { + throw new InvalidArgumentException('Node service does not support this platform.'); + } + + return new self((string) $node->getKey(), $platform, ServiceSettings::resolve($node, $provider, $overrides)); + } + + /** Resolve a package view without platform or provider fallback. */ + public function platformTemplate(string $namespace, string $name): string + { + return $namespace.'::node.services.'.$this->platform->id.'-' + .str_replace('.', '-', $this->platform->versionId).'.'.$name; + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeServiceExecutor.php b/packages/core/src/Services/Node/Setup/NodeServiceExecutor.php new file mode 100644 index 0000000..2b8da2b --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeServiceExecutor.php @@ -0,0 +1,12 @@ + $selection Service role => provider key. */ + public function plan(Node $node, array $selection, array $settings = []): NodeSetupPlan + { + if ((string) $node->getKey() === '' || ! $node->platform()->supported || $selection === []) { + throw new InvalidArgumentException('A node, supported platform and service selection are required.'); + } + if (array_diff_key($settings, array_flip(array_values($selection))) !== []) { + throw new InvalidArgumentException('Settings supplied for an unselected provider.'); + } + + ksort($selection); + $providers = []; + foreach ($selection as $role => $key) { + $provider = $this->registry->get($key); + if ($provider->role() !== $role) { + throw new InvalidArgumentException('Selected provider does not implement the requested role.'); + } + foreach ($provider->conflicts() as $conflict) { + if (isset($selection[$conflict]) || in_array($conflict, $selection, true)) { + throw new LogicException('Conflicting node services selected.'); + } + } + $providers[$role] = $provider; + } + + $visiting = []; + $ordered = []; + $visit = function (string $role) use (&$visit, &$visiting, &$ordered, $providers): void { + if (isset($ordered[$role])) { + return; + } + if (isset($visiting[$role])) { + throw new LogicException('Cyclic node service dependency.'); + } + $provider = $providers[$role] ?? throw new LogicException('Required node service role is not selected.'); + $visiting[$role] = true; + foreach ($provider->requires() as $dependency) { + $visit($dependency); + } + unset($visiting[$role]); + $ordered[$role] = $provider; + }; + foreach (array_keys($providers) as $role) { + $visit($role); + } + + $services = []; + $targets = []; + foreach ($ordered as $provider) { + $context = NodeServiceContext::forNode($node, $provider, $settings[$provider->key()] ?? []); + $plan = $provider->plan($context); + $plan->assertValid(); + foreach ($plan->operations() as $operation) { + $target = match ($operation->type) { + 'config' => 'file:'.$operation->payload()['path'], + 'service' => 'service:'.$operation->payload()['name'], + default => null, + }; + if ($target !== null) { + if (isset($targets[$target])) { + throw new LogicException('Multiple operations own the same configuration or service.'); + } + $targets[$target] = true; + } + } + $services[$provider->key()] = ['role' => $provider->role(), 'revision' => $provider->revision(), 'plan' => $plan]; + } + + // Metrics can change between exploration runs; connection identity must not. + $connection = array_intersect_key($node->properties ?? [], array_flip(['ssh_key', 'port', 'host_key'])); + ksort($connection); + $attributes = $node->getAttributes(); + $targetFingerprint = hash('sha256', json_encode([ + $attributes['adapter'] ?? null, $attributes['hostname'] ?? null, + $attributes['username'] ?? null, (bool) ($attributes['sudo'] ?? false), + $attributes['password'] ?? null, $connection, + ], JSON_THROW_ON_ERROR)); + + return new NodeSetupPlan((string) $node->getKey(), $node->platform()->key(), $services, $targetFingerprint); + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeServiceProvider.php b/packages/core/src/Services/Node/Setup/NodeServiceProvider.php new file mode 100644 index 0000000..6aa857e --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeServiceProvider.php @@ -0,0 +1,34 @@ + Exact platform keys; no wildcard or family fallback. */ + public function platforms(): array; + + /** @return list Required service roles, selected explicitly by the caller. */ + public function requires(): array; + + /** @return list Conflicting service roles or provider keys. */ + public function conflicts(): array; + + /** @return array Provider-local setting names. */ + public function settings(): array; + + public function plan(NodeServiceContext $context): ServicePlan; +} diff --git a/packages/core/src/Services/Node/Setup/NodeServiceRegistry.php b/packages/core/src/Services/Node/Setup/NodeServiceRegistry.php new file mode 100644 index 0000000..471036e --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeServiceRegistry.php @@ -0,0 +1,81 @@ + */ + private array $providers = []; + + /** @param class-string|NodeServiceProvider $provider */ + public function register(string|NodeServiceProvider $provider): void + { + if (is_string($provider)) { + $provider = app($provider); + } + if (! $provider instanceof NodeServiceProvider + || ! preg_match('/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/D', $provider->package()) + || ! str_starts_with($provider->key(), $provider->package().':') + || ! preg_match('/^[a-z0-9-]+\/[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/D', $provider->key()) + || ! preg_match('/^[a-z0-9][a-z0-9-]*$/D', $provider->role()) + || $provider->revision() === '' || $provider->platforms() === []) { + throw new InvalidArgumentException('Invalid node service provider definition.'); + } + + $existing = $this->providers[$provider->key()] ?? null; + if ($existing !== null) { + if ($existing::class !== $provider::class || $this->describe($existing) !== $this->describe($provider)) { + throw new LogicException('Node service provider key already registered.'); + } + + return; + } + + $settings = []; + foreach ($provider->settings() as $name => $definition) { + if (! is_string($name) || ! preg_match('/^[a-z][a-z0-9_]*$/D', $name) || ! $definition instanceof SettingDefinition) { + throw new InvalidArgumentException('Invalid node service setting definition.'); + } + $settings[] = ['path' => ServiceSettings::path($provider, $name)]; + } + SettingRegistry::register($settings, $provider->package()); + $this->providers[$provider->key()] = $provider; + } + + public function get(string $key): NodeServiceProvider + { + return $this->providers[$key] ?? throw new InvalidArgumentException('Node service provider is not registered.'); + } + + /** Metadata for future API/UI consumers; does not expose setting values. */ + public function available(?NodePlatform $platform = null): array + { + $providers = $this->providers; + ksort($providers); + + return array_values(array_map($this->describe(...), array_filter($providers, + fn (NodeServiceProvider $provider) => $platform === null + || ($platform->supported && in_array($platform->key(), $provider->platforms(), true)), + ))); + } + + private function describe(NodeServiceProvider $provider): array + { + return [ + 'key' => $provider->key(), + 'role' => $provider->role(), + 'package' => $provider->package(), + 'revision' => $provider->revision(), + 'platforms' => $provider->platforms(), + 'requires' => $provider->requires(), + 'conflicts' => $provider->conflicts(), + 'settings' => array_map(fn (SettingDefinition $setting) => $setting->toArray(), $provider->settings()), + ]; + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeSetupPlan.php b/packages/core/src/Services/Node/Setup/NodeSetupPlan.php new file mode 100644 index 0000000..6097c9c --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeSetupPlan.php @@ -0,0 +1,40 @@ + $services */ + public function __construct( + public string $nodeId, + public string $platform, + public array $services, + private string $targetFingerprint = '', + ) {} + + public function fingerprint(): string + { + $services = []; + foreach ($this->services as $key => $service) { + $services[$key] = [$service['role'], $service['revision'], $service['plan']->fingerprint()]; + } + + return hash('sha256', json_encode([$this->nodeId, $this->platform, $this->targetFingerprint, $services], JSON_THROW_ON_ERROR)); + } + + public function toArray(): array + { + $services = []; + foreach ($this->services as $key => $service) { + $services[$key] = [ + 'role' => $service['role'], + 'revision' => $service['revision'], + 'operations' => $service['plan']->toArray(), + ]; + } + + return ['node_id' => $this->nodeId, 'platform' => $this->platform, + 'fingerprint' => $this->fingerprint(), 'services' => $services]; + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeSetupResult.php b/packages/core/src/Services/Node/Setup/NodeSetupResult.php new file mode 100644 index 0000000..58a19a0 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeSetupResult.php @@ -0,0 +1,9 @@ +setup.lock +flock -w 300 9 +SH, + 'run='.escapeshellarg($runId), + '[ ! -e "$run" ] && mkdir -m 0700 "$run" || exit 1', + 'work="$PWD/$run"', + 'exec 3>&1', + // Output may contain secrets; retain only the phase journal, not raw tool output. + 'exec >/dev/null 2>&1', + 'printf "%s\n" running > "$work/status"', + 'printf "%s\n" '.escapeshellarg($plan->fingerprint()).' > "$work/fingerprint"', + 'targets=(); backups=(); existed=(); activated=(); was_active=(); was_enabled=()', + 'policy_tmp=', 'success=0', + <<<'SH' +cleanup() { + local code=$? failed=0 i + trap - EXIT INT TERM HUP + set +e + if [ -n "$policy_tmp" ]; then + if [ /usr/sbin/policy-rc.d -ef "$policy_tmp" ]; then + rm -- /usr/sbin/policy-rc.d || failed=1 + elif [ -e /usr/sbin/policy-rc.d ]; then + failed=1 + fi + rm -f -- "$policy_tmp" || failed=1 + fi + if [ "$code" != 0 ]; then + set +e + for ((i=${#targets[@]}-1; i>=0; i--)); do + if [ "${existed[$i]}" = 1 ]; then + cp -p -- "${backups[$i]}" "${targets[$i]}.froxlor-$run" && + mv -fT -- "${targets[$i]}.froxlor-$run" "${targets[$i]}" || failed=1 + else + rm -f -- "${targets[$i]}" || failed=1 + fi + done + for ((i=${#activated[@]}-1; i>=0; i--)); do + if [ "${was_active[$i]}" = 1 ]; then + systemctl reload-or-restart "${activated[$i]}" || failed=1 + else + systemctl stop "${activated[$i]}" || failed=1 + fi + if [ "${was_enabled[$i]}" = 0 ]; then + systemctl disable "${activated[$i]}" || failed=1 + fi + done + if [ "$failed" = 0 ]; then + printf '%s\n' failed > "$work/status" + else + printf '%s\n' recovery-required > "$work/status" + fi + elif [ "$failed" != 0 ] || [ "$success" != 1 ]; then + printf '%s\n' recovery-required > "$work/status" + code=1 + else + printf '%s\n' succeeded > "$work/status" + printf '%s\n' "FROXLOR_SETUP_OK:$run" >&3 + fi + exit "$code" +} +trap cleanup EXIT +trap 'exit 1' INT TERM HUP +# Do not start another mutation after an interrupted or unrecovered run. +for previous in */status; do + [ "$previous" = "$run/status" ] && continue + [ -f "$previous" ] || continue + state=$(cat "$previous") + [ "$state" != running ] && [ "$state" != recovery-required ] || exit 1 +done +SH, + // Read os-release as data, not shell input. + 'os_id=$(sed -n \'s/^ID=//p\' /etc/os-release | tr -d \'"\')', + 'os_version=$(sed -n \'s/^VERSION_ID=//p\' /etc/os-release | tr -d \'"\')', + '[ "$os_id@$os_version" = '.escapeshellarg($plan->platform).' ] || exit 1', + ]; + + $index = 0; + foreach ($plan->services as $service) { + $service['plan']->assertValid(); + $lines[] = 'changed=0'; + foreach (['packages', 'config', 'validate', 'service', 'health'] as $phase) { + $lines[] = 'printf "%s\n" '.escapeshellarg($service['role'].':'.$phase).' > "$work/phase"'; + foreach ($service['plan']->operations() as $operation) { + if ($operation->type !== $phase) { + continue; + } + $parameters = $operation->payload(); + array_push($lines, ...match ($phase) { + 'packages' => $this->packages($parameters['packages']), + 'config' => $this->config($parameters, $index++), + 'validate', 'health' => [$this->command($parameters['argv'])], + 'service' => $this->service($parameters), + }); + } + } + } + + $lines[] = 'success=1'; + + return implode("\n", $lines)."\n"; + } + + private function packages(array $packages): array + { + return [ + 'missing=()', + 'for package in '.implode(' ', array_map(escapeshellarg(...), $packages)).'; do', + ' [ "$(dpkg-query -W -f=\'${Status}\' "$package" 2>/dev/null || true)" = "install ok installed" ] || missing+=("$package")', + 'done', + 'if [ "${#missing[@]}" != 0 ]; then', + // Never replace an administrator's start policy. + ' [ ! -e /usr/sbin/policy-rc.d ] && [ ! -L /usr/sbin/policy-rc.d ] || exit 1', + ' assert_directory /usr/sbin', + ' printf \'#!/bin/sh\nexit 101\n\' > "$work/policy"', + ' chmod 0755 "$work/policy"', + ' policy_tmp=$(mktemp /usr/sbin/.froxlor-policy.XXXXXX)', + ' install -m 0755 "$work/policy" "$policy_tmp"', + ' ln "$policy_tmp" /usr/sbin/policy-rc.d', + ' export DEBIAN_FRONTEND=noninteractive', + ' apt-get -o DPkg::Lock::Timeout=120 update', + ' apt-get -o DPkg::Lock::Timeout=120 --no-install-recommends --no-upgrade --no-remove -y install "${missing[@]}"', + ' for package in "${missing[@]}"; do', + ' [ "$(dpkg-query -W -f=\'${Status}\' "$package")" = "install ok installed" ] || exit 1', + ' done', + ' [ /usr/sbin/policy-rc.d -ef "$policy_tmp" ] || exit 1', + ' rm -- /usr/sbin/policy-rc.d', + ' rm -- "$policy_tmp"', + ' policy_tmp=', + ' changed=1', + 'fi', + ]; + } + + private function config(array $parameters, int $index): array + { + $path = escapeshellarg($parameters['path']); + $mode = escapeshellarg($parameters['mode']); + + return [ + 'target='.$path, + 'assert_directory "$(dirname "$target")"', + '[ ! -L "$target" ] && { [ ! -e "$target" ] || [ -f "$target" ]; } || exit 1', + 'printf %s '.escapeshellarg(base64_encode($parameters['content'])).' | base64 -d > "$work/config-'.$index.'"', + 'if ! cmp -s "$work/config-'.$index.'" "$target" || [ "$(stat -c %u:%g:%a "$target" 2>/dev/null || true)" != "0:0:'.substr($parameters['mode'], 1).'" ]; then', + ' if [ -e "$target" ]; then', + ' cp -p -- "$target" "$work/backup-'.$index.'"', + ' original_exists=1', + ' else', + ' original_exists=0', + ' fi', + ' printf "%s\\n" "$target" > "$work/target-'.$index.'"', + ' printf "%s\\n" "$original_exists" > "$work/existed-'.$index.'"', + ' targets+=("$target"); backups+=("$work/backup-'.$index.'"); existed+=("$original_exists")', + ' [ ! -e "$target.froxlor-$run" ] && [ ! -L "$target.froxlor-$run" ] || exit 1', + ' install -o root -g root -m '.$mode.' "$work/config-'.$index.'" "$target.froxlor-$run"', + ' mv -fT -- "$target.froxlor-$run" "$target"', + ' changed=1', + 'fi', + ]; + } + + private function service(array $parameters): array + { + return [ + 'service='.escapeshellarg($parameters['name']), + 'active=0; enabled=0', + 'systemctl is-active --quiet "$service" && active=1', + 'systemctl is-enabled --quiet "$service" && enabled=1', + 'if [ "$active" = 0 ] || [ "$enabled" = 0 ] || [ "$changed" = 1 ]; then', + ' activated+=("$service"); was_active+=("$active"); was_enabled+=("$enabled")', + ' systemctl enable "$service"', + ' if [ "$active" = 0 ]; then systemctl start "$service";', + ' elif [ "$changed" = 1 ]; then systemctl '.$parameters['onChange'].' "$service"; fi', + 'fi', + ]; + } + + private function command(array $argv): string + { + return implode(' ', array_map(escapeshellarg(...), $argv)); + } +} diff --git a/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php b/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php new file mode 100644 index 0000000..eeddf20 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php @@ -0,0 +1,60 @@ +ensurePackages([ + 'ca-certificates', 'logrotate', 'sudo', + 'curl', 'dnsutils', 'iproute2', 'iputils-ping', + 'procps', 'lsof', 'less', 'jq', + ]); + } +} diff --git a/packages/core/src/Services/Node/Setup/ServiceOperation.php b/packages/core/src/Services/Node/Setup/ServiceOperation.php new file mode 100644 index 0000000..7bd7411 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/ServiceOperation.php @@ -0,0 +1,81 @@ + $packages */ + public static function packages(array $packages): self + { + if ($packages === []) { + throw new InvalidArgumentException('Package list cannot be empty.'); + } + foreach ($packages as $package) { + if (! is_string($package) || ! preg_match('/^[a-z0-9][a-z0-9+.-]+$/D', $package)) { + throw new InvalidArgumentException('Invalid package name.'); + } + } + $packages = array_values(array_unique($packages)); + sort($packages); + + return new self('packages', compact('packages')); + } + + /** Managed files must be within a root-owned configuration directory. */ + public static function config(string $path, string $content, string $mode = '0644'): self + { + if (! preg_match('~^/etc/[a-zA-Z0-9_./-]+$~D', $path) + || str_contains($path, '//') || preg_match('~(^|/)\.{1,2}(/|$)~', $path) + || str_ends_with($path, '/') || ! in_array($mode, ['0600', '0640', '0644'], true)) { + throw new InvalidArgumentException('Invalid managed configuration path or permissions.'); + } + + return new self('config', compact('path', 'content', 'mode')); + } + + /** @param list $argv Explicit arguments, never a shell command string. */ + public static function command(string $type, array $argv): self + { + if (! in_array($type, ['validate', 'health'], true) || ! array_is_list($argv) || $argv === [] + || ! is_string($argv[0]) || ! str_starts_with($argv[0], '/')) { + throw new InvalidArgumentException('Checks require an absolute executable and argument list.'); + } + foreach ($argv as $argument) { + if (! is_string($argument) || str_contains($argument, "\0")) { + throw new InvalidArgumentException('Invalid command argument.'); + } + } + + return new self($type, compact('argv')); + } + + public static function service(string $name, string $onChange = 'reload'): self + { + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.@-]*$/D', $name) + || ! in_array($onChange, ['reload', 'restart'], true)) { + throw new InvalidArgumentException('Invalid service definition.'); + } + + return new self('service', compact('name', 'onChange')); + } + + /** Execution-only payload: do not log, serialize to queues or return through an API. */ + public function payload(): array + { + return $this->parameters; + } + + /** Safe plan preview: contents and command arguments are deliberately omitted. */ + public function toArray(): array + { + $parameters = $this->parameters; + unset($parameters['content'], $parameters['argv']); + + return ['type' => $this->type] + $parameters; + } +} diff --git a/packages/core/src/Services/Node/Setup/ServicePlan.php b/packages/core/src/Services/Node/Setup/ServicePlan.php new file mode 100644 index 0000000..c0e56ae --- /dev/null +++ b/packages/core/src/Services/Node/Setup/ServicePlan.php @@ -0,0 +1,84 @@ + $operations */ + private function __construct(private array $operations = []) {} + + public static function make(): self + { + return new self; + } + + public function ensurePackages(array $packages): self + { + return $this->append(ServiceOperation::packages($packages)); + } + + public function managedConfig(string $path, string $template, array $data = [], string $mode = '0644'): self + { + return $this->config($path, view($template, $data)->render(), $mode); + } + + public function config(string $path, string $content, string $mode = '0644'): self + { + return $this->append(ServiceOperation::config($path, $content, $mode)); + } + + public function validateCommand(array $argv): self + { + return $this->append(ServiceOperation::command('validate', $argv)); + } + + /** Enable and start a service; reload/restart an active service only on changes. */ + public function activateService(string $name, string $onChange = 'reload'): self + { + return $this->append(ServiceOperation::service($name, $onChange)); + } + + public function healthCheck(array $argv): self + { + return $this->append(ServiceOperation::command('health', $argv)); + } + + /** @return list */ + public function operations(): array + { + return $this->operations; + } + + public function assertValid(): void + { + $types = array_map(fn (ServiceOperation $operation) => $operation->type, $this->operations); + if (in_array('config', $types, true) && ! in_array('validate', $types, true)) { + throw new LogicException('Managed configurations require a validation command.'); + } + if (in_array('service', $types, true) && ! in_array('health', $types, true)) { + throw new LogicException('Activated services require a health check.'); + } + } + + public function toArray(): array + { + return array_map(fn (ServiceOperation $operation) => $operation->toArray(), $this->operations); + } + + /** Hash includes rendered contents, but previews never expose them. */ + public function fingerprint(): string + { + return hash('sha256', json_encode(array_map( + fn (ServiceOperation $operation) => [$operation->type, $operation->payload()], + $this->operations, + ), JSON_THROW_ON_ERROR)); + } + + private function append(ServiceOperation $operation): self + { + return new self([...$this->operations, $operation]); + } +} diff --git a/packages/core/src/Services/Node/Setup/ServiceSettings.php b/packages/core/src/Services/Node/Setup/ServiceSettings.php new file mode 100644 index 0000000..4f7ab59 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/ServiceSettings.php @@ -0,0 +1,81 @@ +key().'.'.$name; + } + + public static function resolve(Node $node, NodeServiceProvider $provider, array $overrides = []): self + { + $definitions = $provider->settings(); + if (array_diff_key($overrides, $definitions) !== []) { + throw new InvalidArgumentException('Unknown node service settings.'); + } + + $values = []; + foreach ($definitions as $name => $definition) { + $value = array_key_exists($name, $overrides) + ? $overrides[$name] + : $node->getSetting(self::path($provider, $name), $definition->default); + $values[$name] = $definition->normalize($value); + } + + return new self($values); + } + + /** Validate the whole update before storing any value. Call after authorization. */ + public static function store(Node $node, NodeServiceProvider $provider, array $values): void + { + if (! $node->exists) { + throw new InvalidArgumentException('Settings require a persisted node.'); + } + $resolved = self::resolve($node, $provider, $values); + $node->getConnection()->transaction(function () use ($node, $provider, $values, $resolved): void { + foreach ($values as $name => $_) { + $value = $resolved->values[$name]; + Setting::setValueForModel( + $node, + self::path($provider, $name), + is_bool($value) ? (int) $value : $value, + type: $provider->settings()[$name]->type === 'choice' ? 'string' : $provider->settings()[$name]->type, + source: $provider->package(), + ); + } + }); + } + + public function integer(string $name): int + { + return $this->typed($name, 'integer'); + } + + public function boolean(string $name): bool + { + return $this->typed($name, 'boolean'); + } + + public function string(string $name): string + { + return $this->typed($name, 'string'); + } + + private function typed(string $name, string $type): int|bool|string + { + if (! array_key_exists($name, $this->values) || gettype($this->values[$name]) !== $type) { + throw new InvalidArgumentException('Unknown setting or incorrect setting accessor.'); + } + + return $this->values[$name]; + } +} diff --git a/packages/core/src/Services/Node/Setup/SettingDefinition.php b/packages/core/src/Services/Node/Setup/SettingDefinition.php new file mode 100644 index 0000000..b80b6b0 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/SettingDefinition.php @@ -0,0 +1,68 @@ +normalize($default); + } + + public static function integer(int $default, int $min, int $max): self + { + if ($min > $max) { + throw new InvalidArgumentException('Invalid setting bounds.'); + } + + return new self('integer', $default, $min, $max); + } + + public static function boolean(bool $default): self + { + return new self('boolean', $default); + } + + /** @param list $choices */ + public static function choice(string $default, array $choices): self + { + return new self('choice', $default, choices: $choices); + } + + /** Never include rejected values in exceptions: settings may contain sensitive data. */ + public function normalize(mixed $value): int|bool|string + { + if ($this->type === 'integer') { + if (is_string($value) && preg_match('/^-?(0|[1-9][0-9]*)$/D', $value)) { + $value = filter_var($value, FILTER_VALIDATE_INT); + } + if (is_int($value) && $value >= $this->min && $value <= $this->max) { + return $value; + } + } elseif ($this->type === 'boolean') { + if (in_array($value, [true, 1, '1'], true)) { + return true; + } + if (in_array($value, [false, 0, '0'], true)) { + return false; + } + } elseif (is_string($value) && in_array($value, $this->choices, true)) { + return $value; + } + + throw new InvalidArgumentException('Invalid node service setting value.'); + } + + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/packages/core/tests/Feature/NodeServiceIntegrationTest.php b/packages/core/tests/Feature/NodeServiceIntegrationTest.php new file mode 100644 index 0000000..a0183d3 --- /dev/null +++ b/packages/core/tests/Feature/NodeServiceIntegrationTest.php @@ -0,0 +1,258 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + config(['app.key' => 'base64:'.base64_encode(str_repeat('n', 32))]); + SetupIntegrationAdapter::$calls = []; + SetupIntegrationAdapter::$succeeds = true; + if (! in_array(SetupIntegrationAdapter::class, Node::adapters(), true)) { + Node::registerAdapter(SetupIntegrationAdapter::class); + } + app(NodeServiceRegistry::class)->register(new SetupIntegrationProvider); + } + + public function test_settings_keep_external_package_ownership_and_node_precedence(): void + { + $provider = new SetupIntegrationProvider; + $node = $this->node(); + $path = ServiceSettings::path($provider, 'workers'); + Setting::add($path, 2, source: $provider->package()); + $this->assertSame(2, ServiceSettings::resolve($node, $provider)->integer('workers')); + Setting::setValueForType(Node::class, $path, 6, source: $provider->package()); + $this->assertSame(6, ServiceSettings::resolve($node, $provider)->integer('workers')); + ServiceSettings::store($node, $provider, ['workers' => 8, 'enabled' => false]); + $resolved = ServiceSettings::resolve($node, $provider); + $this->assertSame(8, $resolved->integer('workers')); + $this->assertFalse($resolved->boolean('enabled')); + $this->assertDatabaseHas('settings', [ + 'category' => 'services', 'key' => $provider->key().'.workers', + 'settingable_id' => $node->id, 'owner_package' => $provider->package(), + ]); + } + + public function test_invalid_setting_batch_does_not_write_partial_values(): void + { + $node = $this->node(); + try { + ServiceSettings::store($node, new SetupIntegrationProvider, ['workers' => 8, 'enabled' => 'invalid']); + $this->fail('Expected validation failure.'); + } catch (InvalidArgumentException) { + $this->assertSame(0, SettingModel::query()->where('settingable_id', $node->id)->count()); + } + } + + public function test_executor_requires_authorization_before_transport(): void + { + Gate::before(fn ($user = null) => false); + $node = $this->node(); + $plan = $this->plan($node); + try { + app(NodeServiceExecutor::class)->apply($node, $plan); + $this->fail('Expected authorization failure.'); + } catch (AuthorizationException) { + $this->assertSame([], SetupIntegrationAdapter::$calls); + } + } + + public function test_executor_rejects_changed_settings_before_transport(): void + { + Gate::before(fn ($user = null) => true); + $node = $this->node(); + $plan = $this->plan($node); + ServiceSettings::store($node, new SetupIntegrationProvider, ['workers' => 8]); + try { + app(NodeServiceExecutor::class)->apply($node, $plan); + $this->fail('Expected stale-plan rejection.'); + } catch (LogicException) { + $this->assertSame([], SetupIntegrationAdapter::$calls); + } + } + + public function test_executor_accepts_only_the_success_marker_and_preserves_fingerprint(): void + { + Gate::before(fn ($user = null) => true); + $node = $this->node(); + $plan = $this->plan($node); + $result = app(NodeServiceExecutor::class)->apply($node, $plan); + $this->assertSame($plan->fingerprint(), $result->fingerprint); + $this->assertMatchesRegularExpression('/^[0-9A-HJKMNP-TV-Z]{26}$/D', $result->runId); + $this->assertCount(1, SetupIntegrationAdapter::$calls); + } + + public function test_executor_rejects_changed_connection_identity(): void + { + Gate::before(fn ($user = null) => true); + $node = $this->node(); + $plan = $this->plan($node); + Node::withoutEvents(fn () => $node->update(['hostname' => 'another-node.invalid'])); + try { + app(NodeServiceExecutor::class)->apply($node, $plan); + $this->fail('Expected stale target rejection.'); + } catch (LogicException) { + $this->assertSame([], SetupIntegrationAdapter::$calls); + } + } + + public function test_executor_does_not_treat_arbitrary_adapter_output_as_success(): void + { + Gate::before(fn ($user = null) => true); + SetupIntegrationAdapter::$succeeds = false; + try { + $node = $this->node(); + app(NodeServiceExecutor::class)->apply($node, $this->plan($node)); + $this->fail('Expected execution failure.'); + } catch (RuntimeException $exception) { + $this->assertStringNotContainsString('SECRET-OUTPUT', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + } + } + + private function plan(Node $node): NodeSetupPlan + { + return app(NodeServicePlanner::class)->plan($node, ['integration' => 'tests/node-integration:fixture']); + } + + private function node(): Node + { + return Node::withoutEvents(fn () => Node::query()->create([ + 'name' => 'Setup integration fixture', 'hostname' => 'setup.invalid', + 'username' => 'root', 'sudo' => false, 'adapter' => SetupIntegrationAdapter::class, + 'properties' => ['os' => ['id' => 'debian', 'version_id' => '13']], + ])); + } +} + +class SetupIntegrationProvider implements NodeServiceProvider +{ + public function key(): string + { + return 'tests/node-integration:fixture'; + } + + public function role(): string + { + return 'integration'; + } + + public function package(): string + { + return 'tests/node-integration'; + } + + public function revision(): string + { + return '1'; + } + + public function platforms(): array + { + return ['debian@13']; + } + + public function requires(): array + { + return []; + } + + public function conflicts(): array + { + return []; + } + + public function settings(): array + { + return [ + 'workers' => SettingDefinition::integer(4, 1, 32), + 'enabled' => SettingDefinition::boolean(true), + ]; + } + + public function plan(NodeServiceContext $context): ServicePlan + { + return ServicePlan::make()->config('/etc/fixture.conf', (string) $context->settings->integer('workers')) + ->validateCommand(['/usr/bin/true']); + } +} + +/** Never executes the command. Parses the opaque script only to return its run marker. */ +class SetupIntegrationAdapter extends Adapter +{ + public static string $name = 'setup-integration'; + + public static array $calls = []; + + public static bool $succeeds = true; + + public function exec(string|array $command): bool|string + { + self::$calls[] = $command; + if (! self::$succeeds) { + return 'SECRET-OUTPUT'; + } + preg_match("/printf %s '([A-Za-z0-9+\/=]+)'/", implode("\n", (array) $command), $encoded); + $script = base64_decode($encoded[1], true); + preg_match("/run='([0-9A-Z]{26})'/", $script, $run); + + return 'FROXLOR_SETUP_OK:'.$run[1]; + } + + public function isConnected(): bool + { + return true; + } + + public function storagePut(string $remote, string $data): bool + { + throw new LogicException('Unexpected I/O'); + } + + public function storageGet(string $remote, bool|string $local = false): bool|string + { + throw new LogicException('Unexpected I/O'); + } + + public function storageDelete(string $remote): bool + { + throw new LogicException('Unexpected I/O'); + } + + public function storageExists(string $remote): bool + { + throw new LogicException('Unexpected I/O'); + } + + public function storagePutAsRoot(string $remote, string $data, array $ownership = []): bool + { + throw new LogicException('Unexpected I/O'); + } +} diff --git a/packages/core/tests/Feature/NodeServiceSetupTest.php b/packages/core/tests/Feature/NodeServiceSetupTest.php new file mode 100644 index 0000000..0f672ef --- /dev/null +++ b/packages/core/tests/Feature/NodeServiceSetupTest.php @@ -0,0 +1,306 @@ +assertSame($registry, app(NodeServiceRegistry::class)); + $this->assertInstanceOf(BaseSystemProvider::class, $registry->get('froxlor/core:base-system')); + $plan = (new NodeServicePlanner($registry))->plan($this->node(), ['base-system' => 'froxlor/core:base-system']); + $operations = $plan->services['froxlor/core:base-system']['plan']->operations(); + $this->assertCount(1, $operations); + $packages = $operations[0]->payload()['packages']; + foreach (['sudo', 'ca-certificates', 'logrotate', 'curl', 'dnsutils', 'iproute2', 'iputils-ping', 'procps', 'lsof', 'less', 'jq'] as $package) { + $this->assertContains($package, $packages); + } + foreach (['nginx', 'apache2', 'postfix', 'jailkit', 'proftpd'] as $package) { + $this->assertNotContains($package, $packages); + } + } + + public function test_registration_is_idempotent_and_rejects_key_takeover(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider); + $registry->register(new SetupFixtureProvider); + $this->assertCount(1, $registry->available()); + $changed = new SetupFixtureProvider; + $changed->version = '2'; + $this->expectException(LogicException::class); + $registry->register($changed); + } + + public function test_exact_platform_support_is_required(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider); + $node = $this->node('ubuntu', '24.04'); + $this->assertSame([], $registry->available($node->platform())); + $this->expectException(InvalidArgumentException::class); + (new NodeServicePlanner($registry))->plan($node, ['fixture' => 'tests/node-setup:fixture']); + } + + public function test_unknown_platform_does_not_fall_back_to_debian(): void + { + $this->expectException(InvalidArgumentException::class); + app(NodeServicePlanner::class)->plan($this->node('debian', '12'), ['base-system' => 'froxlor/core:base-system']); + } + + public function test_dependency_order_is_deterministic(): void + { + $registry = new NodeServiceRegistry; + $dependency = new SetupFixtureProvider('dependency'); + $dependent = new SetupFixtureProvider('dependent', ['dependency']); + $registry->register($dependent); + $registry->register($dependency); + $planner = new NodeServicePlanner($registry); + $selection = ['dependent' => $dependent->key(), 'dependency' => $dependency->key()]; + $first = $planner->plan($this->node(), $selection); + $this->assertSame([$dependency->key(), $dependent->key()], array_keys($first->services)); + $this->assertSame($first->fingerprint(), $planner->plan($this->node(), array_reverse($selection, true))->fingerprint()); + } + + public function test_missing_dependency_is_not_implicitly_installed(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider('fixture', ['missing'])); + $this->expectException(LogicException::class); + (new NodeServicePlanner($registry))->plan($this->node(), ['fixture' => 'tests/node-setup:fixture']); + } + + public function test_dependency_cycles_are_rejected(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider('first', ['second'])); + $registry->register(new SetupFixtureProvider('second', ['first'])); + $this->expectException(LogicException::class); + (new NodeServicePlanner($registry))->plan($this->node(), [ + 'first' => 'tests/node-setup:first', 'second' => 'tests/node-setup:second', + ]); + } + + public function test_conflicting_providers_are_rejected(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider('first', conflicts: ['second'])); + $registry->register(new SetupFixtureProvider('second')); + $this->expectException(LogicException::class); + (new NodeServicePlanner($registry))->plan($this->node(), [ + 'first' => 'tests/node-setup:first', 'second' => 'tests/node-setup:second', + ]); + } + + public function test_two_providers_cannot_own_the_same_config(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider('first', config: true)); + $registry->register(new SetupFixtureProvider('second', config: true)); + $this->expectException(LogicException::class); + (new NodeServicePlanner($registry))->plan($this->node(), [ + 'first' => 'tests/node-setup:first', 'second' => 'tests/node-setup:second', + ]); + } + + public function test_provider_settings_resolve_defaults_node_values_and_validated_overrides(): void + { + $provider = new SetupFixtureProvider; + $node = $this->node(); + $this->assertSame(4, ServiceSettings::resolve($node, $provider)->integer('workers')); + $node->settingValues[ServiceSettings::path($provider, 'workers')] = '12'; + $this->assertSame(12, ServiceSettings::resolve($node, $provider)->integer('workers')); + $this->assertSame(8, ServiceSettings::resolve($node, $provider, ['workers' => 8])->integer('workers')); + $this->expectException(InvalidArgumentException::class); + ServiceSettings::resolve($node, $provider, ['workers' => 100000]); + } + + public function test_unknown_setting_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + ServiceSettings::resolve($this->node(), new SetupFixtureProvider, ['command' => 'id']); + } + + public function test_setting_accessor_rejects_type_mismatch(): void + { + $this->expectException(InvalidArgumentException::class); + ServiceSettings::resolve($this->node(), new SetupFixtureProvider)->boolean('workers'); + } + + public function test_boolean_and_choice_validation_are_strict(): void + { + $this->assertFalse(SettingDefinition::boolean(true)->normalize('0')); + $this->assertTrue(SettingDefinition::boolean(false)->normalize('1')); + $this->assertSame('daily', SettingDefinition::choice('daily', ['daily', 'weekly'])->normalize('daily')); + $this->expectException(InvalidArgumentException::class); + SettingDefinition::boolean(false)->normalize('false; touch /tmp/unwanted'); + } + + public function test_plan_is_immutable_and_preview_does_not_leak_contents_or_arguments(): void + { + $empty = ServicePlan::make(); + $plan = $empty->config('/etc/example.conf', 'private-value') + ->validateCommand(['/usr/bin/test', 'private-value']) + ->activateService('example')->healthCheck(['/usr/bin/true']); + $plan->assertValid(); + $this->assertSame([], $empty->operations()); + $this->assertStringNotContainsString('private-value', json_encode($plan->toArray())); + $this->assertNotSame($empty->fingerprint(), $plan->fingerprint()); + } + + public function test_unvalidated_configuration_is_rejected(): void + { + $this->expectException(LogicException::class); + ServicePlan::make()->config('/etc/example.conf', 'value')->assertValid(); + } + + #[DataProvider('unsafePaths')] + public function test_unsafe_config_targets_are_rejected(string $path): void + { + $this->expectException(InvalidArgumentException::class); + ServiceOperation::config($path, 'value'); + } + + public static function unsafePaths(): array + { + return [['/'], ['/tmp/example'], ['/etc/../root/key'], ['/etc//example'], ['/etc/example/'], ['/etc/test;id']]; + } + + public function test_package_arguments_cannot_be_options_or_shell_commands(): void + { + $this->expectException(InvalidArgumentException::class); + ServicePlan::make()->ensurePackages(['--allow-unauthenticated']); + } + + public function test_compiled_script_parses_without_executing_it(): void + { + $registry = new NodeServiceRegistry; + $registry->register(new SetupFixtureProvider(config: true)); + $plan = (new NodeServicePlanner($registry))->plan($this->node(), ['fixture' => 'tests/node-setup:fixture']); + $script = (new NodeSetupScript)->compile($plan, '01K00000000000000000000000'); + $process = new Process(['/bin/bash', '-n']); + $process->setInput($script); + $process->run(); + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertStringContainsString('flock -w 300', $script); + $this->assertStringContainsString('recovery-required', $script); + $this->assertStringNotContainsString('private-value', $script); + } + + public function test_executor_rejects_wrong_node_before_accessing_an_adapter(): void + { + Gate::before(fn ($user = null) => true); + $node = $this->node(); + $plan = app(NodeServicePlanner::class)->plan($node, ['base-system' => 'froxlor/core:base-system']); + $node->id = 'different-node'; + $node->exists = true; + $this->expectException(LogicException::class); + app(AdapterNodeServiceExecutor::class)->apply($node, $plan); + } + + private function node(string $os = 'debian', string $version = '13'): SetupFixtureNode + { + $node = new SetupFixtureNode; + $node->id = '01K00000000000000000000000'; + $node->detectedPlatform = (new PlatformResolver)->fromOsRelease(['id' => $os, 'version_id' => $version]); + + return $node; + } +} + +class SetupFixtureNode extends Node +{ + public array $settingValues = []; + + public NodePlatform $detectedPlatform; + + public function platform(): NodePlatform + { + return $this->detectedPlatform; + } + + public function getSetting(string $settings_path, mixed $default = null): mixed + { + return $this->settingValues[$settings_path] ?? $default; + } +} + +class SetupFixtureProvider implements NodeServiceProvider +{ + public string $version = '1'; + + public function __construct(private string $serviceRole = 'fixture', private array $dependencies = [], private array $conflicts = [], private bool $config = false) {} + + public function key(): string + { + return 'tests/node-setup:'.$this->serviceRole; + } + + public function role(): string + { + return $this->serviceRole; + } + + public function package(): string + { + return 'tests/node-setup'; + } + + public function revision(): string + { + return $this->version; + } + + public function platforms(): array + { + return ['debian@13']; + } + + public function requires(): array + { + return $this->dependencies; + } + + public function conflicts(): array + { + return $this->conflicts; + } + + public function settings(): array + { + return ['workers' => SettingDefinition::integer(default: 4, min: 1, max: 32)]; + } + + public function plan(NodeServiceContext $context): ServicePlan + { + $plan = ServicePlan::make()->ensurePackages(['ca-certificates']); + + return $this->config + ? $plan->config('/etc/example.conf', 'private-value') + ->validateCommand(['/usr/bin/true']) + ->activateService('example')->healthCheck(['/usr/bin/true']) + : $plan; + } +} diff --git a/packages/core/tests/Feature/NodeSetupScriptTest.php b/packages/core/tests/Feature/NodeSetupScriptTest.php new file mode 100644 index 0000000..1885756 --- /dev/null +++ b/packages/core/tests/Feature/NodeSetupScriptTest.php @@ -0,0 +1,204 @@ +markTestSkipped('Run in the isolated root Docker test container.'); + } + $this->sandbox = '/var/lib/froxlor-setup-test-'.bin2hex(random_bytes(8)); + mkdir($this->sandbox, 0700); + mkdir($this->sandbox.'/sbin', 0700); + mkdir($this->sandbox.'/installed', 0700); + } + + protected function tearDown(): void + { + if (isset($this->sandbox)) { + (new Filesystem)->deleteDirectory($this->sandbox); + } + parent::tearDown(); + } + + public function test_invalid_config_restores_previous_file_without_reloading(): void + { + file_put_contents($this->sandbox.'/example.conf', 'previous'); + $process = $this->execute($this->plan('/usr/bin/false')); + $this->assertNotSame(0, $process->getExitCode()); + $this->assertSame('previous', file_get_contents($this->sandbox.'/example.conf')); + $this->assertFileDoesNotExist($this->sandbox.'/service-calls'); + $this->assertSame("failed\n", file_get_contents($this->journal().'/status')); + } + + public function test_successful_run_is_repeatable_without_rewrite_or_reload(): void + { + $first = $this->execute($this->plan()); + $this->assertSame(0, $first->getExitCode(), $first->getErrorOutput()); + $this->assertStringContainsString('FROXLOR_SETUP_OK:', $first->getOutput()); + $this->assertSame('candidate', file_get_contents($this->sandbox.'/example.conf')); + $this->assertStringContainsString('reload example', file_get_contents($this->sandbox.'/service-calls')); + $inode = fileinode($this->sandbox.'/example.conf'); + file_put_contents($this->sandbox.'/service-calls', ''); + + $second = $this->execute($this->plan()); + $this->assertSame(0, $second->getExitCode(), $second->getErrorOutput()); + clearstatcache(); + $this->assertSame($inode, fileinode($this->sandbox.'/example.conf')); + $this->assertStringNotContainsString('reload example', file_get_contents($this->sandbox.'/service-calls')); + $this->assertSame("succeeded\n", file_get_contents($this->journal().'/status')); + } + + public function test_failed_health_check_restores_config_and_reactivates_previous_service(): void + { + file_put_contents($this->sandbox.'/example.conf', 'previous'); + $process = $this->execute($this->plan(health: '/usr/bin/false')); + $this->assertNotSame(0, $process->getExitCode()); + $this->assertSame('previous', file_get_contents($this->sandbox.'/example.conf')); + $this->assertStringContainsString('reload-or-restart example', file_get_contents($this->sandbox.'/service-calls')); + } + + public function test_failed_creation_removes_only_the_new_config(): void + { + $process = $this->execute($this->plan('/usr/bin/false')); + $this->assertNotSame(0, $process->getExitCode()); + $this->assertFileDoesNotExist($this->sandbox.'/example.conf'); + } + + public function test_symlink_target_is_rejected_without_touching_referent(): void + { + file_put_contents($this->sandbox.'/original', 'untouched'); + symlink($this->sandbox.'/original', $this->sandbox.'/example.conf'); + $process = $this->execute($this->plan()); + $this->assertNotSame(0, $process->getExitCode()); + $this->assertSame('untouched', file_get_contents($this->sandbox.'/original')); + $this->assertTrue(is_link($this->sandbox.'/example.conf')); + } + + public function test_unrecovered_run_blocks_next_mutation(): void + { + $this->assertSame(0, $this->execute($this->plan())->getExitCode()); + file_put_contents($this->journal().'/status', "running\n"); + file_put_contents($this->sandbox.'/example.conf', 'operator-value'); + $this->assertNotSame(0, $this->execute($this->plan())->getExitCode()); + $this->assertSame('operator-value', file_get_contents($this->sandbox.'/example.conf')); + } + + public function test_failed_recovery_is_reported_and_blocks_retries(): void + { + file_put_contents($this->sandbox.'/example.conf', 'previous'); + $process = $this->execute($this->plan(health: '/usr/bin/false'), failRecovery: true); + $this->assertNotSame(0, $process->getExitCode()); + $this->assertSame("recovery-required\n", file_get_contents($this->journal().'/status')); + } + + public function test_package_installs_are_repeatable_and_temporary_policy_is_removed(): void + { + $plan = ServicePlan::make()->ensurePackages(['ca-certificates', 'sudo']); + $first = $this->execute($plan); + $this->assertSame(0, $first->getExitCode(), $first->getErrorOutput()); + $this->assertFileExists($this->sandbox.'/installed/sudo'); + $this->assertFileDoesNotExist($this->sandbox.'/sbin/policy-rc.d'); + $calls = file_get_contents($this->sandbox.'/apt-calls'); + $this->assertSame(0, $this->execute($plan)->getExitCode()); + $this->assertSame($calls, file_get_contents($this->sandbox.'/apt-calls')); + } + + public function test_existing_administrator_start_policy_is_preserved(): void + { + file_put_contents($this->sandbox.'/sbin/policy-rc.d', 'administrator policy'); + $result = $this->execute(ServicePlan::make()->ensurePackages(['sudo'])); + $this->assertNotSame(0, $result->getExitCode()); + $this->assertSame('administrator policy', file_get_contents($this->sandbox.'/sbin/policy-rc.d')); + $this->assertFileDoesNotExist($this->sandbox.'/apt-calls'); + } + + public function test_failed_package_install_cleans_up_its_start_policy(): void + { + $result = $this->execute(ServicePlan::make()->ensurePackages(['sudo']), failPackages: true); + $this->assertNotSame(0, $result->getExitCode()); + $this->assertFileDoesNotExist($this->sandbox.'/sbin/policy-rc.d'); + $this->assertSame([], glob($this->sandbox.'/sbin/.froxlor-policy.*')); + $this->assertSame("failed\n", file_get_contents($this->journal().'/status')); + } + + private function plan(string $validator = '/usr/bin/true', string $health = '/usr/bin/true'): ServicePlan + { + return ServicePlan::make()->config('/etc/froxlor-setup-test.conf', 'candidate') + ->validateCommand([$validator])->activateService('example')->healthCheck([$health]); + } + + private function execute(ServicePlan $service, bool $failRecovery = false, bool $failPackages = false): Process + { + $this->sequence++; + $os = parse_ini_file('/etc/os-release'); + $plan = new NodeSetupPlan('fixture', $os['ID'].'@'.$os['VERSION_ID'], [ + 'tests/node-setup:fixture' => ['role' => 'fixture', 'revision' => '1', 'plan' => $service], + ]); + $script = (new NodeSetupScript)->compile($plan, $this->runId()); + $script = strtr($script, [ + '/var/lib/froxlor' => $this->sandbox.'/state', + '/etc/froxlor-setup-test.conf' => $this->sandbox.'/example.conf', + '/usr/bin/true' => '/bin/true', + '/usr/bin/false' => '/bin/false', + '/usr/sbin' => $this->sandbox.'/sbin', + ]); + // Fixtures contain no secrets; retain stderr to diagnose shell failures in tests. + $script = str_replace('exec >/dev/null 2>&1', 'set -x', $script); + // Panel image uses BusyBox: exercise a real nonblocking lock in these short tests. + // The production script uses util-linux's bounded wait on Debian/Ubuntu. + $mock = "flock() { shift 2; command flock -n \"\$@\"; }\n" + .'systemctl() { printf "%s\\n" "$*" >> '.escapeshellarg($this->sandbox.'/service-calls').'; ' + .($failRecovery ? '[ "$1" != reload-or-restart ];' : 'return 0;')." }\n"; + $mock .= 'test_root='.escapeshellarg($this->sandbox)."\n"; + $mock .= <<<'SH' +dpkg-query() { + [ -f "$test_root/installed/${!#}" ] || return 1 + printf 'install ok installed' +} +apt-get() { + printf '%s\n' "$*" >> "$test_root/apt-calls" + local install_mode=0 argument + for argument in "$@"; do + if [ "$argument" = install ]; then install_mode=1; continue; fi + if [ "$install_mode" = 1 ]; then + [ "$fail_packages" = 0 ] || return 1 + touch "$test_root/installed/$argument" + fi + done +} +SH; + $mock .= "\nfail_packages=".($failPackages ? '1' : '0')."\n"; + $process = new Process(['/bin/bash', '-se']); + $process->setInput($mock.$script); + $process->setTimeout(10); + $process->run(); + + return $process; + } + + private function runId(): string + { + return '01K0000000000000000000000'.$this->sequence; + } + + private function journal(): string + { + return $this->sandbox.'/state/node-setup/'.$this->runId(); + } +} From 07e8761e62c160443d44a777a277fd2255961bbd Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 12:10:45 +0200 Subject: [PATCH 05/11] Fix environment jail rollout --- .../views/node/scripts/create_jail.blade.php | 5 +- .../Jobs/Environment/CreateEnvironment.php | 102 +++++++++++++++--- .../Environment/CreateEnvironmentTest.php | 85 +++++++++++++++ 3 files changed, 173 insertions(+), 19 deletions(-) diff --git a/packages/core/resources/views/node/scripts/create_jail.blade.php b/packages/core/resources/views/node/scripts/create_jail.blade.php index 9ce3c4a..9057750 100644 --- a/packages/core/resources/views/node/scripts/create_jail.blade.php +++ b/packages/core/resources/views/node/scripts/create_jail.blade.php @@ -49,8 +49,9 @@ # Initialize jail with basic shells, editors, netutils and transfer tools. jk_init -j "$JAILBASE" basicshell jk_lsh editors netutils sftp scp rsync -# Create user inside jail -jk_jailuser -m -j "$JAILBASE" "$JAILUSER" +# Create user inside jail. The account already has its home below the jail +# (useradd -m above), so --move would try to copy the home onto itself. +jk_jailuser -j "$JAILBASE" "$JAILUSER" # Mount a dedicated proc filesystem for the jail. if ! mountpoint -q "$JAILBASE/proc"; then diff --git a/packages/core/src/Jobs/Environment/CreateEnvironment.php b/packages/core/src/Jobs/Environment/CreateEnvironment.php index 3aa91a3..5419e3d 100644 --- a/packages/core/src/Jobs/Environment/CreateEnvironment.php +++ b/packages/core/src/Jobs/Environment/CreateEnvironment.php @@ -31,10 +31,15 @@ public function __construct(private readonly Environment $environment, private r */ public function handle(): void { - Cache::lock("nodes:{$this->node->id}:environment-create", 120)->block(30, function () { + Cache::lock("nodes:{$this->node->id}:environment-create", 600)->block(60, function () { $node = $this->node->refresh(); $environment = $this->environment->refresh(); + // Queue retries must not provision an already attached environment again. + if ($environment->nodes()->whereKey($node->id)->exists()) { + return; + } + // base-directory for node... $nodeBaseDir = $node->getSetting('node.basedir', '/var/environments'); $adapter = $node->adapter(); @@ -68,28 +73,51 @@ public function handle(): void 'userGuid' => $guid, ])->render(); - if (!$adapter->storagePut('/tmp/createhome.sh', $createJailCommand)) { + $scriptPath = '/tmp/createhome-' . $environment->id . '.sh'; + + if (!$adapter->storagePut($scriptPath, $createJailCommand)) { throw new NodeException(trans('Unable to upload jail creation script.')); } - if ($adapter->exec([ - 'apt install sudo jailkit -y', - 'chmod +x /tmp/createhome.sh', - '/tmp/createhome.sh', - 'rm -f /tmp/createhome.sh' - ]) === false) { - $adapter->storageDelete('/tmp/createhome.sh'); + try { + if ($adapter->exec([ + 'if ! command -v jk_init >/dev/null 2>&1 || ! command -v jk_jailuser >/dev/null 2>&1; then', + 'export DEBIAN_FRONTEND=noninteractive', + 'apt-get update', + 'apt-get install -y sudo jailkit', + 'fi', + 'chmod +x ' . escapeshellarg($scriptPath), + escapeshellarg($scriptPath), + 'rm -f ' . escapeshellarg($scriptPath), + ]) === false) { + throw new NodeException(trans('Unable to create jail.')); + } - throw new NodeException(trans('Unable to create jail.')); + // connect environment with node (must be mode=main) + $environment->nodes()->attach($node, [ + 'unix_name' => $unixName, + 'guid' => $guid, + 'mode' => 'main' + ]); + } catch (Throwable $exception) { + $this->cleanupFailedProvisioning($adapter, $envBaseDir, $unixName, $scriptPath, $node, $environment); + + throw $exception; + } finally { + // The script is removed by the successful command chain as well; this also + // covers failures before that final command can run. + try { + $adapter->storageDelete($scriptPath); + } catch (Throwable $cleanupException) { + Log::warning('Unable to remove temporary environment creation script.', [ + 'node_id' => $node->id, + 'environment_id' => $environment->id, + 'script' => $scriptPath, + 'exception' => $cleanupException, + ]); + } } - // connect environment with node (must be mode=main) - $environment->nodes()->attach($node, [ - 'unix_name' => $unixName, - 'guid' => $guid, - 'mode' => 'main' - ]); - event(new EnvironmentCreated($environment)); Audit::notice('environment "' . $environment->name . '" created on node "' . $node->name . '"', $environment->tenant, $environment, [ 'node_id' => $node->id, @@ -99,6 +127,46 @@ public function handle(): void }); } + /** + * Remove remote state left behind by a failed jail creation or database attach. + */ + private function cleanupFailedProvisioning( + Adapter $adapter, + string $envBaseDir, + string $unixName, + string $scriptPath, + Node $node, + Environment $environment, + ): void { + try { + $cleanupResult = $adapter->exec([ + 'JAILBASE=' . escapeshellarg(rtrim($envBaseDir, '/')), + 'JAILUSER=' . escapeshellarg($unixName), + 'if mountpoint -q "$JAILBASE/dev/pts"; then umount -l "$JAILBASE/dev/pts" || true; fi', + 'if mountpoint -q "$JAILBASE/proc"; then umount -l "$JAILBASE/proc" || true; fi', + 'if getent passwd "$JAILUSER" >/dev/null; then pkill -u "$JAILUSER" || true; fi', + 'if getent passwd "$JAILUSER" >/dev/null; then userdel "$JAILUSER" || true; fi', + 'if getent group "$JAILUSER" >/dev/null; then groupdel "$JAILUSER" || true; fi', + 'if [ -n "$JAILBASE" ] && [ "$JAILBASE" != "/" ] && [ -d "$JAILBASE" ]; then rm -rf -- "$JAILBASE"; fi', + ]); + + if ($cleanupResult === false) { + Log::warning('Unable to clean up failed environment provisioning.', [ + 'node_id' => $node->id, + 'environment_id' => $environment->id, + 'script' => $scriptPath, + ]); + } + } catch (Throwable $cleanupException) { + Log::warning('Unable to clean up failed environment provisioning.', [ + 'node_id' => $node->id, + 'environment_id' => $environment->id, + 'script' => $scriptPath, + 'exception' => $cleanupException, + ]); + } + } + /** * Return the first UID/GID not already known to the target node. * diff --git a/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php b/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php index acadd44..bfeff2c 100644 --- a/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php +++ b/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php @@ -10,6 +10,7 @@ use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Services\Node\Adapter\Adapter; +use Froxlor\Core\Services\Node\Exceptions\NodeException; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -66,6 +67,8 @@ public function test_it_skips_occupied_system_guid_and_persists_the_next_free_gu $this->assertStringContainsString('candidate=\'10004\'', CreateEnvironmentFakeAdapter::$guidResolutionCommand); $this->assertStringContainsString('JAILUSER="usr5"', CreateEnvironmentFakeAdapter::$uploadedScript); $this->assertStringContainsString('GUID="10005"', CreateEnvironmentFakeAdapter::$uploadedScript); + $this->assertStringContainsString('jk_jailuser -j "$JAILBASE" "$JAILUSER"', CreateEnvironmentFakeAdapter::$uploadedScript); + $this->assertStringNotContainsString('jk_jailuser -m', CreateEnvironmentFakeAdapter::$uploadedScript); $auditLog = AuditLog::query() ->where('tenant_id', $tenant->id) @@ -131,6 +134,75 @@ public function test_environment_delete_removes_jail_from_assigned_node(): void $this->assertArrayNotHasKey('tenant_id', $auditLog->context); $this->assertArrayNotHasKey('environment_id', $auditLog->context); } + + public function test_retry_does_not_provision_an_already_attached_environment_again(): void + { + $tenant = Tenant::query()->firstOrFail(); + $plan = Plan::query()->firstOrFail(); + $node = Node::query()->create([ + 'adapter' => CreateEnvironmentFakeAdapter::class, + 'name' => 'Idempotent Environment Test Node', + 'hostname' => 'idempotent-environment-test-node.local', + 'username' => 'root', + 'sudo' => true, + ]); + $node->addSetting('node.basedir', '/srv/environments', Node::getTypeSetting('node.basedir')); + + $environment = Environment::query()->create([ + 'tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Idempotent Environment Test', + ]); + + CreateEnvironment::dispatchSync($environment->refresh(), $node); + $commandCount = count(CreateEnvironmentFakeAdapter::$executedCommands); + + CreateEnvironment::dispatchSync($environment->refresh(), $node); + + $this->assertSame($commandCount, count(CreateEnvironmentFakeAdapter::$executedCommands)); + $this->assertSame(1, DB::table('node_environments') + ->where('environment_id', $environment->id) + ->where('node_id', $node->id) + ->count()); + } + + public function test_failed_jail_creation_cleans_remote_artifacts(): void + { + CreateEnvironmentFakeAdapter::$failJailCreation = true; + + $tenant = Tenant::query()->firstOrFail(); + $plan = Plan::query()->firstOrFail(); + $node = Node::query()->create([ + 'adapter' => CreateEnvironmentFakeAdapter::class, + 'name' => 'Failed Environment Test Node', + 'hostname' => 'failed-environment-test-node.local', + 'username' => 'root', + 'sudo' => true, + ]); + $node->addSetting('node.basedir', '/srv/environments', Node::getTypeSetting('node.basedir')); + + $environment = Environment::query()->create([ + 'tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Failed Environment Test', + ]); + + try { + CreateEnvironment::dispatchSync($environment->refresh(), $node); + $this->fail('The jail creation should fail in this test.'); + } catch (NodeException $exception) { + $this->assertSame('Unable to create jail.', $exception->getMessage()); + } + + $this->assertDatabaseMissing('node_environments', [ + 'environment_id' => $environment->id, + 'node_id' => $node->id, + ]); + $this->assertContains('/tmp/createhome-' . $environment->id . '.sh', CreateEnvironmentFakeAdapter::$deletedFiles); + + $cleanupCommands = implode(PHP_EOL, end(CreateEnvironmentFakeAdapter::$executedCommands)); + $this->assertStringContainsString('rm -rf -- "$JAILBASE"', $cleanupCommands); + } } class CreateEnvironmentFakeAdapter extends Adapter @@ -143,6 +215,10 @@ class CreateEnvironmentFakeAdapter extends Adapter public static string $uploadedScript = ''; + public static bool $failJailCreation = false; + + public static array $deletedFiles = []; + public static array $executedCommands = []; public static function reset(): void @@ -150,6 +226,8 @@ public static function reset(): void self::$resolvedGuid = 10005; self::$guidResolutionCommand = ''; self::$uploadedScript = ''; + self::$failJailCreation = false; + self::$deletedFiles = []; self::$executedCommands = []; } @@ -164,6 +242,11 @@ public function exec(string|array $command): bool|string return (string)self::$resolvedGuid; } + $commandString = implode(PHP_EOL, $commands); + if (self::$failJailCreation && str_contains($commandString, 'chmod +x') && str_contains($commandString, 'createhome-')) { + return false; + } + return ''; } @@ -186,6 +269,8 @@ public function storageGet(string $remote, bool|string $local = false): bool|str public function storageDelete(string $remote): bool { + self::$deletedFiles[] = $remote; + return true; } From 3e24450dd872ba11b076fd9ae390ed772f4a9bc9 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 15:15:32 +0200 Subject: [PATCH 06/11] Add queued node setup lifecycle and retry endpoints --- ...0001_01_01_000094_add_node_setup_state.php | 36 ++ packages/core/docs/node-services.md | 81 +++- packages/core/docs/node-setup.openapi.yaml | 109 +++++ packages/core/routes/api.php | 4 + .../core/src/Console/Commands/ExploreNode.php | 11 +- .../core/src/Console/Commands/SetupNode.php | 31 ++ .../Http/Controllers/Api/NodeController.php | 2 +- .../Controllers/Api/NodeSetupController.php | 60 +++ .../Controllers/Api/Tenant/NodeController.php | 2 +- packages/core/src/Jobs/Node/ExploreNode.php | 20 +- packages/core/src/Jobs/Node/SetupNode.php | 38 ++ packages/core/src/Models/Node.php | 14 + .../Providers/FroxlorCoreServiceProvider.php | 9 + .../Node/Setup/AdapterNodeServiceExecutor.php | 3 +- .../Services/Node/Setup/NodeSetupActor.php | 32 ++ .../Services/Node/Setup/NodeSetupService.php | 149 +++++++ .../tests/Feature/NodeSetupLifecycleTest.php | 373 ++++++++++++++++++ 17 files changed, 964 insertions(+), 10 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000094_add_node_setup_state.php create mode 100644 packages/core/docs/node-setup.openapi.yaml create mode 100644 packages/core/src/Console/Commands/SetupNode.php create mode 100644 packages/core/src/Http/Controllers/Api/NodeSetupController.php create mode 100644 packages/core/src/Jobs/Node/SetupNode.php create mode 100644 packages/core/src/Services/Node/Setup/NodeSetupActor.php create mode 100644 packages/core/src/Services/Node/Setup/NodeSetupService.php create mode 100644 packages/core/tests/Feature/NodeSetupLifecycleTest.php diff --git a/packages/core/database/migrations/0001_01_01_000094_add_node_setup_state.php b/packages/core/database/migrations/0001_01_01_000094_add_node_setup_state.php new file mode 100644 index 0000000..2b04946 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000094_add_node_setup_state.php @@ -0,0 +1,36 @@ +string('setup_status', 16)->nullable(); + $table->ulid('setup_request_id')->nullable(); + // Retain the actor ID for diagnosis even if the user is subsequently deleted. + $table->ulid('setup_requested_by')->nullable(); + $table->json('setup_selection')->nullable(); + $table->char('setup_fingerprint', 64)->nullable(); + $table->ulid('setup_run_id')->nullable(); + $table->timestamp('setup_requested_at')->nullable(); + $table->timestamp('setup_started_at')->nullable(); + $table->timestamp('setup_finished_at')->nullable(); + $table->string('setup_error')->nullable(); + }); + } + + public function down(): void + { + Schema::table('nodes', function (Blueprint $table): void { + $table->dropColumn([ + 'setup_status', 'setup_request_id', 'setup_requested_by', 'setup_selection', + 'setup_fingerprint', 'setup_run_id', 'setup_requested_at', 'setup_started_at', + 'setup_finished_at', 'setup_error', + ]); + }); + } +}; diff --git a/packages/core/docs/node-services.md b/packages/core/docs/node-services.md index 91141d1..a1c0f12 100644 --- a/packages/core/docs/node-services.md +++ b/packages/core/docs/node-services.md @@ -134,9 +134,84 @@ generate a new plan. A submitted serialized plan is never an execution API. The default executor checks the Node `update` policy, validates node identity and replans after refreshing the node. It writes start/success/failure audit events and returns only run ID and fingerprint. It invokes the existing Node adapter explicitly. -Nothing runs during registration, exploration, package updates or application boot. -This change does not add routes, UI, automatic node onboarding, readiness gating or -an automatic Apache/nginx migration. +Nothing runs during registration, periodic exploration, package updates or application +boot. Successful **initial** exploration queues the Core base setup as described below. +There is no UI, Environment readiness gating or automatic Apache/nginx migration. + +## Initial setup, queue and repeat requests + +Apply the `0001_01_01_000094_add_node_setup_state` migration before deploying the code. +Both node creation endpoints pass the initiating user ID into initial `ExploreNode`. +After OS detection and successful exploration, it calls `NodeSetupService`, which +authorizes the user, persists a pending request and dispatches `Jobs\Node\SetupNode` +after the database transaction commits. Repeated initial explorations never reschedule +an existing setup request, including a failed one. Regular scheduled explorations do +not schedule setup at all. If no valid actor or supported plan is available, the node +records a failed initial setup instead of installing anything. + +`SetupNode` serializes only node/request IDs. At execution it atomically claims the +matching pending request, reloads the initiating user, checks current Node update +permissions and compares the current plan against the requested fingerprint. Deleted +users, revoked permissions and changed connection/configuration/provider revisions +fail closed. The user context is applied to Gate and Audit and restored in a `finally` +block, including synchronous execution. Raw adapter errors never enter job error text. + +The Node exposes `setup_status` (`null`, `pending`, `running`, `succeeded`, `failed`), +`setup_request_id`, `setup_requested_by`, `setup_selection`, `setup_fingerprint`, +`setup_run_id`, `setup_requested_at`, `setup_started_at`, `setup_finished_at` and +`setup_error`. `null` means setup has not been requested. These fields describe the +latest request, with audit events retaining its history. For the default executor, +request ID and remote journal run ID match, including failed attempts. + +The dedicated `node-setup` queue connection uses the application's Redis driver when +the default connection is `redis`; otherwise it uses the database queue. It has a +1500-second retry window. An explicit `queue.connections.node-setup` configuration +takes precedence and must retain a retry window greater than the 1260-second job +timeout. Setup makes one attempt; failures require an explicit new request. + +Run a dedicated supervised worker (inside the application container): + +```sh +php artisan queue:work node-setup --queue=node-setup --timeout=1260 --tries=1 +``` + +The ordinary default-queue worker does not consume this queue. If using Horizon, +configure a separate supervisor for connection/queue `node-setup` with a timeout +greater than 1260 seconds and a Redis-backed connection. No worker is started by +application boot. Restart long-lived workers after deployment. + +Authenticated API endpoints (also described in `node-setup.openapi.yaml`): + +| Endpoint | Authorization | Result | +| --- | --- | --- | +| `GET /api/nodes/{node}/setup` | Node `view` | Latest status, 200 | +| `POST /api/nodes/{node}/setup` | Node `update` | Queue initial/repeat setup, 202 | +| `GET /api/tenants/{tenant}/nodes/{node}/setup` | `tenantView` | Latest status, 200 | +| `POST /api/tenants/{tenant}/nodes/{node}/setup` | `tenantUpdate` and Node `update` | Queue setup for an owned node, 202 | + +POST has no request parameters: it reuses the persisted provider selection, defaulting +to `base-system => froxlor/core:base-system`. It does not accept a serialized plan, +arbitrary commands, settings or provider overrides. Pending/running requests return +409; an unsupported platform or invalid plan returns 422; unauthorized callers receive +401/403. Status responses wrap the fields in `data`, using `status`, `request_id`, +`requested_by`, `selection`, `fingerprint`, `run_id`, the three timestamps and `error`, +plus `node_id`. A successful/failed setup can be repeated via POST with a new request ID. +An inherited/shared node cannot be set up through another tenant's ownership route. + +CLI alternatives: + +```sh +php artisan core:explore-node node.example.test --initial --user= +php artisan core:setup-node --user= +``` + +Initial CLI exploration requires an explicit user. Regular exploration retains its +existing unattended behavior. CLI setup applies the same authorization as the API; +there is no implicit root/super-admin user. New requests are transactional, duplicate +job deliveries are ignored, and a timeout failure hook cannot overwrite a newer request. +After a hard worker crash, an explicit repeat may replace an active request older than +30 minutes. This never bypasses the node-side lock or unrecovered remote journal: +inspect and recover the remote state before repeating interrupted installations. ## Execution and recovery boundaries diff --git a/packages/core/docs/node-setup.openapi.yaml b/packages/core/docs/node-setup.openapi.yaml new file mode 100644 index 0000000..1ccb914 --- /dev/null +++ b/packages/core/docs/node-setup.openapi.yaml @@ -0,0 +1,109 @@ +openapi: 3.1.0 +info: + title: froxlor Core Node Setup API + version: 1.0.0 + description: Explicit setup of explored nodes. Requires the node setup state migration and a node-setup queue worker. +security: + - sanctum: [] +paths: + /api/nodes/{node}/setup: + parameters: + - $ref: '#/components/parameters/Node' + get: + operationId: getNodeSetup + summary: Read the latest setup status (Node view permission) + responses: + '200': { $ref: '#/components/responses/Status' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + operationId: requestNodeSetup + summary: Queue or repeat the saved selection (Node update permission) + description: No request body. Pending/running requests conflict, except abandoned requests older than 30 minutes. Remote recovery checks remain enforced. + responses: + '202': { $ref: '#/components/responses/Accepted' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/InvalidPlan' } + '500': { description: Queue dispatch failed. The setup request is marked failed when persistence is available. } + /api/tenants/{tenant}/nodes/{node}/setup: + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Node' + get: + operationId: getTenantNodeSetup + summary: Read setup status (tenantView permission) + responses: + '200': { $ref: '#/components/responses/Status' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + operationId: requestTenantNodeSetup + summary: Queue setup of an owned node (tenantUpdate and Node update permissions) + description: No request body. Inherited or foreign nodes cannot be set up through this endpoint. + responses: + '202': { $ref: '#/components/responses/Accepted' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/InvalidPlan' } + '500': { description: Queue dispatch failed. The setup request is marked failed when persistence is available. } +components: + securitySchemes: + sanctum: + type: http + scheme: bearer + parameters: + Node: + name: node + in: path + required: true + schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + Tenant: + name: tenant + in: path + required: true + schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + responses: + Status: + description: Latest persisted status; null status means no setup request yet. + content: + application/json: + schema: { $ref: '#/components/schemas/SetupResponse' } + Accepted: + description: Setup queued, not yet installed. + content: + application/json: + schema: { $ref: '#/components/schemas/SetupResponse' } + Unauthenticated: { description: Authentication required. } + Forbidden: { description: Insufficient permissions or node not owned by the requested tenant. } + NotFound: { description: Node or tenant does not exist. } + Conflict: { description: A current setup request is pending or running. } + InvalidPlan: { description: 'Platform, provider selection or settings do not yield a valid plan.' } + schemas: + SetupResponse: + type: object + required: [data] + properties: + data: + type: object + required: [node_id, status, request_id, requested_by, selection, fingerprint, run_id, requested_at, started_at, finished_at, error] + properties: + node_id: { type: string } + status: { type: [string, 'null'], enum: [null, pending, running, succeeded, failed] } + request_id: { type: [string, 'null'] } + requested_by: { type: [string, 'null'] } + selection: + type: [object, 'null'] + additionalProperties: { type: string } + fingerprint: { type: [string, 'null'] } + run_id: { type: [string, 'null'] } + requested_at: { type: [string, 'null'], format: date-time } + started_at: { type: [string, 'null'], format: date-time } + finished_at: { type: [string, 'null'], format: date-time } + error: { type: [string, 'null'], description: Sanitized failure description; no transport output or secrets. } diff --git a/packages/core/routes/api.php b/packages/core/routes/api.php index 4b1a210..6c00166 100644 --- a/packages/core/routes/api.php +++ b/packages/core/routes/api.php @@ -7,6 +7,8 @@ Route::apiResource('audit-log', Api\AuditLogController::class)->only(['index']); Route::apiResource('nodes', Api\NodeController::class); + Route::get('nodes/{node}/setup', [Api\NodeSetupController::class, 'show'])->name('nodes.setup.show'); + Route::post('nodes/{node}/setup', [Api\NodeSetupController::class, 'store'])->name('nodes.setup.store'); Route::apiResource('users', Api\UserController::class); Route::apiResource('api-keys', Api\ApiKeyController::class)->only(['index', 'store', 'show', 'destroy']); @@ -20,6 +22,8 @@ Route::apiResource('tenants', Api\TenantController::class); Route::apiResource('tenants.audit-log', Api\Tenant\AuditLogController::class)->only(['index']); Route::apiResource('tenants.nodes', Api\Tenant\NodeController::class); + Route::get('tenants/{tenant}/nodes/{node}/setup', [Api\NodeSetupController::class, 'tenantShow'])->name('tenants.nodes.setup.show'); + Route::post('tenants/{tenant}/nodes/{node}/setup', [Api\NodeSetupController::class, 'tenantStore'])->name('tenants.nodes.setup.store'); Route::apiResource('tenants.environments', Api\Tenant\EnvironmentController::class); Route::apiResource('tenants.environments.audit-log', Api\Tenant\Environment\AuditLogController::class)->only(['index']); Route::apiResource('tenants.environments.users', Api\Tenant\Environment\UserController::class); diff --git a/packages/core/src/Console/Commands/ExploreNode.php b/packages/core/src/Console/Commands/ExploreNode.php index dc08ced..589992a 100644 --- a/packages/core/src/Console/Commands/ExploreNode.php +++ b/packages/core/src/Console/Commands/ExploreNode.php @@ -4,6 +4,7 @@ use Froxlor\Core\Jobs\Node\ExploreNode as ExplodeNodeJob; use Froxlor\Core\Models\Node; +use Froxlor\Core\Models\User; use Illuminate\Console\Command; use Illuminate\Contracts\Console\PromptsForMissingInput; @@ -16,7 +17,8 @@ class ExploreNode extends Command implements PromptsForMissingInput */ protected $signature = 'core:explore-node {hostname? : Hostname of the node to be explored } - {--i|initial : Initial exploring includes ip-addresses of given node }'; + {--i|initial : Initial exploration includes IP addresses and queues node setup } + {--user= : Initiating user ULID, required for initial exploration }'; /** * The console command description. @@ -31,6 +33,11 @@ class ExploreNode extends Command implements PromptsForMissingInput public function handle(): int { $hostname = $this->argument('hostname'); + $actor = $this->option('user') ? User::query()->find($this->option('user')) : null; + if ($this->option('initial') && $actor === null) { + $this->error('Initial exploration requires --user= for node setup authorization.'); + return self::FAILURE; + } $nodes = Node::query() ->when($hostname, fn($query) => $query->where('hostname', $hostname)) @@ -40,7 +47,7 @@ public function handle(): int foreach ($nodes as $node) { $this->output->info(__('Exploring node :node', ['node' => $node->hostname])); $initial = $this->option('initial'); - ExplodeNodeJob::dispatchSync($node, $initial); + ExplodeNodeJob::dispatchSync($node, $initial, $actor?->id); } return self::SUCCESS; } diff --git a/packages/core/src/Console/Commands/SetupNode.php b/packages/core/src/Console/Commands/SetupNode.php new file mode 100644 index 0000000..8908df8 --- /dev/null +++ b/packages/core/src/Console/Commands/SetupNode.php @@ -0,0 +1,31 @@ +option('user') ? User::query()->find($this->option('user')) : null; + if ($actor === null) { + $this->error('An existing initiating user is required: --user=.'); + + return self::FAILURE; + } + $node = Node::query()->findOrFail($this->argument('node')); + $requested = $setups->request($node, $actor); + $this->info('Node setup queued: '.$requested->setup_request_id); + + return self::SUCCESS; + } +} diff --git a/packages/core/src/Http/Controllers/Api/NodeController.php b/packages/core/src/Http/Controllers/Api/NodeController.php index 8b4cc78..ec06a87 100644 --- a/packages/core/src/Http/Controllers/Api/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/NodeController.php @@ -64,7 +64,7 @@ public function store(StoreNodeRequest $request) 'node_id' => $node->id, ]); // run explore-node job - dispatch(new ExploreNode($node, true)); + dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); // return resource return Response::jsonResource($node->refresh()); diff --git a/packages/core/src/Http/Controllers/Api/NodeSetupController.php b/packages/core/src/Http/Controllers/Api/NodeSetupController.php new file mode 100644 index 0000000..3e2f25b --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/NodeSetupController.php @@ -0,0 +1,60 @@ +status($node); + } + + public function store(Request $request, Node $node, NodeSetupService $setups): JsonResponse + { + Gate::authorize('update', $node); + + return $this->status($setups->request($node, $request->user()), 202); + } + + public function tenantShow(Tenant $tenant, Node $node): JsonResponse + { + Gate::authorize('tenantView', [$node, $tenant]); + + return $this->status($node); + } + + public function tenantStore(Request $request, Tenant $tenant, Node $node, NodeSetupService $setups): JsonResponse + { + Gate::authorize('tenantUpdate', [$node, $tenant]); + + return $this->status($setups->request($node, $request->user()), 202); + } + + private function status(Node $node, int $code = 200): JsonResponse + { + return response()->json(['data' => [ + 'node_id' => $node->id, + 'status' => $node->setup_status, + 'request_id' => $node->setup_request_id, + 'requested_by' => $node->setup_requested_by, + 'selection' => $node->setup_selection, + 'fingerprint' => $node->setup_fingerprint, + 'run_id' => $node->setup_run_id, + 'requested_at' => $node->setup_requested_at, + 'started_at' => $node->setup_started_at, + 'finished_at' => $node->setup_finished_at, + 'error' => $node->setup_error, + ]], $code); + } +} diff --git a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php index 5b50055..e85c754 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php @@ -57,7 +57,7 @@ public function store(StoreNodeRequest $request, Tenant $tenant) 'node_id' => $node->id, ]); - dispatch(new ExploreNode($node, true)); + dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); return Response::jsonResource($node->refresh()); } diff --git a/packages/core/src/Jobs/Node/ExploreNode.php b/packages/core/src/Jobs/Node/ExploreNode.php index 58003f3..d0852e1 100644 --- a/packages/core/src/Jobs/Node/ExploreNode.php +++ b/packages/core/src/Jobs/Node/ExploreNode.php @@ -6,8 +6,11 @@ use Froxlor\Core\Events\Node\NodeExplored; use Froxlor\Core\Events\Node\NodeExploreUpdate; use Froxlor\Core\Models\Node; +use Froxlor\Core\Models\User; use Froxlor\Core\Services\Node\Adapter\Adapter; use Froxlor\Core\Services\Node\Platform\PlatformResolver; +use Froxlor\Core\Services\Node\Setup\NodeSetupService; +use Froxlor\Core\Services\Node\Setup\NodeSetupActor; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -15,7 +18,7 @@ class ExploreNode implements ShouldQueue { use Queueable; - public function __construct(private readonly Node $node, private readonly bool $initial = false) + public function __construct(private readonly Node $node, private readonly bool $initial = false, private readonly ?string $actorId = null) { // } @@ -28,6 +31,16 @@ public function __construct(private readonly Node $node, private readonly bool $ * @throws Exception */ public function handle(): void + { + $actor = $this->initial && $this->actorId ? User::query()->find($this->actorId) : null; + if ($actor !== null) { + app(NodeSetupActor::class)->run($actor, fn () => $this->explore()); + } else { + $this->explore(); + } + } + + private function explore(): void { $adapter = $this->node->adapter(); @@ -64,7 +77,7 @@ public function handle(): void // get node ip addresses assigned to the system $ips = $adapter->exec(['hostname -I']); foreach (explode(" ", trim($ips)) as $ipaddr) { - $this->node->nodeInterfaces()->create([ + $this->node->nodeInterfaces()->firstOrCreate([ 'bind_addr' => trim($ipaddr), ]); } @@ -77,6 +90,9 @@ public function handle(): void $this->node->save(); event(new NodeExploreUpdate($this->node)); } + if ($this->initial) { + app(NodeSetupService::class)->afterInitialExploration($this->node, $this->actorId); + } } else { throw new Exception('Unable to connect to node'); } diff --git a/packages/core/src/Jobs/Node/SetupNode.php b/packages/core/src/Jobs/Node/SetupNode.php new file mode 100644 index 0000000..29f45bc --- /dev/null +++ b/packages/core/src/Jobs/Node/SetupNode.php @@ -0,0 +1,38 @@ +onConnection('node-setup'); + $this->onQueue('node-setup'); + $this->afterCommit(); + } + + public function handle(NodeSetupService $setups): void + { + $setups->execute($this->nodeId, $this->requestId); + } + + public function failed(?Throwable $exception): void + { + app(NodeSetupService::class)->fail($this->nodeId, $this->requestId, + 'Node setup job failed or timed out. Inspect the node journal before retrying.'); + } +} diff --git a/packages/core/src/Models/Node.php b/packages/core/src/Models/Node.php index 37770da..8607cdd 100644 --- a/packages/core/src/Models/Node.php +++ b/packages/core/src/Models/Node.php @@ -33,6 +33,16 @@ * @property string|null $password * @property boolean $sudo * @property array $properties + * @property string|null $setup_status + * @property string|null $setup_request_id + * @property string|null $setup_requested_by + * @property array|null $setup_selection + * @property string|null $setup_fingerprint + * @property string|null $setup_run_id + * @property Carbon|null $setup_requested_at + * @property Carbon|null $setup_started_at + * @property Carbon|null $setup_finished_at + * @property string|null $setup_error * @property Carbon $created_at * @property Carbon $updated_at * @property Carbon $deleted_at @@ -60,6 +70,10 @@ class Node extends Model 'sudo' => 'boolean', 'password' => 'encrypted', 'properties' => 'encrypted:array', + 'setup_selection' => 'array', + 'setup_requested_at' => 'datetime', + 'setup_started_at' => 'datetime', + 'setup_finished_at' => 'datetime', ]; protected $appends = [ diff --git a/packages/core/src/Providers/FroxlorCoreServiceProvider.php b/packages/core/src/Providers/FroxlorCoreServiceProvider.php index 4c5d8d6..cf99b47 100644 --- a/packages/core/src/Providers/FroxlorCoreServiceProvider.php +++ b/packages/core/src/Providers/FroxlorCoreServiceProvider.php @@ -132,6 +132,15 @@ public function register(): void $this->app->singleton(NodeServiceRegistry::class); $this->app->bind(NodeServiceExecutor::class, AdapterNodeServiceExecutor::class); + // Isolate long-running setup jobs from ordinary queues with short retry windows. + if (!$this->app['config']->has('queue.connections.node-setup')) { + $driver = $this->app['config']->get('queue.default') === 'redis' ? 'redis' : 'database'; + $connection = $this->app['config']->get('queue.connections.' . $driver, []); + $this->app['config']->set('queue.connections.node-setup', array_merge($connection, [ + 'driver' => $driver, 'queue' => 'node-setup', 'retry_after' => 1500, 'after_commit' => true, + ])); + } + // Configs $this->mergeConfigFrom(__DIR__ . '/../../config/dev.php', 'dev'); } diff --git a/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php index 306ea2d..3992744 100644 --- a/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php +++ b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php @@ -36,7 +36,8 @@ public function apply(Node $node, NodeSetupPlan $plan): NodeSetupResult throw new LogicException('Node setup plan is stale. Generate a new plan.'); } - $runId = (string) Str::ulid(); + $runId = $node->setup_status === 'running' && $node->setup_request_id + ? $node->setup_request_id : (string) Str::ulid(); $context = ['node_id' => $node->id, 'run_id' => $runId, 'fingerprint' => $current->fingerprint()]; Audit::notice('node service setup started', $node->tenant, context: $context); diff --git a/packages/core/src/Services/Node/Setup/NodeSetupActor.php b/packages/core/src/Services/Node/Setup/NodeSetupActor.php new file mode 100644 index 0000000..10d6d6b --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeSetupActor.php @@ -0,0 +1,32 @@ +guard(); + $previousUser = $guard->user(); + $request = request(); + $previousResolver = $request->getUserResolver(); + + try { + $guard->setUser($actor); + $request->setUserResolver(fn () => $actor); + + return $callback(); + } finally { + if ($previousUser !== null) { + $guard->setUser($previousUser); + } else { + $guard->forgetUser(); + } + $request->setUserResolver($previousResolver); + } + } +} diff --git a/packages/core/src/Services/Node/Setup/NodeSetupService.php b/packages/core/src/Services/Node/Setup/NodeSetupService.php new file mode 100644 index 0000000..f84d0a6 --- /dev/null +++ b/packages/core/src/Services/Node/Setup/NodeSetupService.php @@ -0,0 +1,149 @@ +actors->run($actor, function () use ($node, $actor, $initial): Node { + return DB::transaction(function () use ($node, $actor, $initial): Node { + $node = Node::query()->lockForUpdate()->findOrFail($node->id); + Gate::forUser($actor)->authorize('update', $node); + if ($initial && $node->setup_status !== null) { + return $node; + } + $since = $node->setup_status === 'running' ? $node->setup_started_at : $node->setup_requested_at; + if (in_array($node->setup_status, ['pending', 'running'], true) + && $since !== null && $since->lt(now()->subMinutes(30))) { + // Explicit recovery only, beyond both worker timeout and broker retry window. + // The node-side journal/lock still blocks an unrecovered remote execution. + $node->setup_status = 'failed'; + Audit::warning('expired node setup request replaced', $node->tenant, + context: ['node_id' => $node->id, 'request_id' => $node->setup_request_id]); + } + abort_if(in_array($node->setup_status, ['pending', 'running'], true), 409, 'Node setup is already pending or running.'); + + $selection = $node->setup_selection ?: ['base-system' => 'froxlor/core:base-system']; + try { + $plan = $this->planner->plan($node, $selection); + } catch (InvalidArgumentException|LogicException) { + throw ValidationException::withMessages(['node' => 'Node setup cannot be planned. Check the explored platform, providers and settings.']); + } + $requestId = (string) Str::ulid(); + $node->forceFill([ + 'setup_status' => 'pending', 'setup_request_id' => $requestId, + 'setup_requested_by' => $actor->id, 'setup_selection' => $selection, + 'setup_fingerprint' => $plan->fingerprint(), 'setup_run_id' => null, + 'setup_requested_at' => now(), 'setup_started_at' => null, + 'setup_finished_at' => null, 'setup_error' => null, + ])->saveQuietly(); + Audit::notice('node setup queued', $node->tenant, context: ['node_id' => $node->id, 'request_id' => $requestId]); + + DB::afterCommit(function () use ($node, $requestId): void { + try { + Bus::dispatch(new SetupNode($node->id, $requestId)); + } catch (Throwable) { + $this->fail($node->id, $requestId, 'Node setup could not be queued.'); + throw new RuntimeException('Node setup could not be queued.'); + } + }); + + return $node; + }); + }); + } + + /** Called exclusively by a successfully completed initial exploration. */ + public function afterInitialExploration(Node $node, ?string $actorId): void + { + if ($node->fresh()?->setup_status !== null) { + return; + } + try { + $actor = $actorId ? User::query()->find($actorId) : null; + if ($actor === null) { + throw new RuntimeException('Initial setup requires an initiating user.'); + } + $this->request($node, $actor, initial: true); + } catch (Throwable) { + // Exploration itself succeeded. Expose setup scheduling failure separately. + Node::query()->whereKey($node->id)->whereNull('setup_status')->update([ + 'setup_status' => 'failed', 'setup_requested_by' => $actorId, + 'setup_requested_at' => now(), 'setup_finished_at' => now(), + 'setup_error' => 'Initial setup could not be scheduled. Check the initiating user, permissions and platform.', + ]); + } + } + + public function execute(string $nodeId, string $requestId): void + { + // A duplicate delivery or an old retried job never starts a second installation. + $claimed = Node::query()->whereKey($nodeId)->where('setup_request_id', $requestId) + ->where('setup_status', 'pending')->update([ + 'setup_status' => 'running', 'setup_started_at' => now(), 'setup_run_id' => $requestId, + ]); + if (! $claimed) { + return; + } + + try { + $node = Node::query()->findOrFail($nodeId); + $actor = User::query()->find($node->setup_requested_by); + if ($actor === null) { + throw new RuntimeException('Initiating user no longer exists.'); + } + $this->actors->run($actor, function () use ($node, $actor, $requestId): void { + Gate::forUser($actor)->authorize('update', $node); + $plan = $this->planner->plan($node, $node->setup_selection); + if (! hash_equals($node->setup_fingerprint, $plan->fingerprint())) { + throw new LogicException('Queued setup plan is stale.'); + } + $result = app(NodeServiceExecutor::class)->apply($node, $plan); + Node::query()->whereKey($node->id)->where('setup_request_id', $requestId) + ->where('setup_status', 'running')->update([ + 'setup_status' => 'succeeded', 'setup_run_id' => $result->runId, + 'setup_finished_at' => now(), 'setup_error' => null, + ]); + }); + } catch (Throwable) { + $this->fail($nodeId, $requestId, 'Node setup failed. Check permissions, current configuration and the node setup journal.'); + // Never put raw adapter errors or rendered settings into failed_jobs. + throw new RuntimeException('Node setup failed. See the node setup status and journal.'); + } + } + + /** Safe for timeout hooks and old jobs; only the matching active request can fail. */ + public function fail(string $nodeId, string $requestId, string $message): void + { + $changed = Node::query()->whereKey($nodeId)->where('setup_request_id', $requestId) + ->whereIn('setup_status', ['pending', 'running'])->update([ + 'setup_status' => 'failed', 'setup_finished_at' => now(), 'setup_error' => $message, + ]); + if ($changed && ($node = Node::query()->find($nodeId))) { + $actor = User::query()->find($node->setup_requested_by); + if ($actor !== null) { + $this->actors->run($actor, fn () => Audit::error('node setup request failed', $node->tenant, + context: ['node_id' => $nodeId, 'request_id' => $requestId])); + } + } + } +} diff --git a/packages/core/tests/Feature/NodeSetupLifecycleTest.php b/packages/core/tests/Feature/NodeSetupLifecycleTest.php new file mode 100644 index 0000000..f6a74af --- /dev/null +++ b/packages/core/tests/Feature/NodeSetupLifecycleTest.php @@ -0,0 +1,373 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + config(['app.key' => 'base64:'.base64_encode(str_repeat('n', 32))]); + $this->actor = User::query()->create([ + 'email' => 'node-setup-'.str()->ulid().'@example.test', 'password' => 'test-password', + ]); + Gate::before(fn (User $user) => $this->usePolicies ? null : ($user->id === $this->actor->id && $this->allowed)); + Bus::fake(); + if (! in_array(SetupLifecycleAdapter::class, Node::adapters(), true)) { + Node::registerAdapter(SetupLifecycleAdapter::class); + } + SetupLifecycleAdapter::$connected = true; + $this->app->instance(NodeServiceExecutor::class, new class($this->apply(...)) implements NodeServiceExecutor + { + public function __construct(private \Closure $apply) {} + + public function apply(Node $node, NodeSetupPlan $plan): NodeSetupResult + { + return ($this->apply)($node, $plan); + } + }); + } + + public function test_initial_exploration_queues_one_setup_with_the_original_actor(): void + { + $node = $this->node(); + (new ExploreNode($node, true, $this->actor->id))->handle(); + $node->refresh(); + $this->assertSame('pending', $node->setup_status); + $this->assertSame($this->actor->id, $node->setup_requested_by); + Bus::assertDispatched(SetupNode::class, fn (SetupNode $job) => $job->nodeId === $node->id && $job->requestId === $node->setup_request_id); + (new ExploreNode($node, true, $this->actor->id))->handle(); + Bus::assertDispatchedTimes(SetupNode::class, 1); + $this->assertSame(1, $node->nodeInterfaces()->where('bind_addr', '192.0.2.10')->count()); + } + + public function test_regular_exploration_never_queues_setup(): void + { + $node = $this->node(); + (new ExploreNode($node))->handle(); + $this->assertNull($node->refresh()->setup_status); + Bus::assertNotDispatched(SetupNode::class); + } + + public function test_failed_exploration_never_queues_setup(): void + { + SetupLifecycleAdapter::$connected = false; + try { + (new ExploreNode($this->node(), true, $this->actor->id))->handle(); + $this->fail('Expected connection failure.'); + } catch (\Exception) { + Bus::assertNotDispatched(SetupNode::class); + } + } + + public function test_missing_initial_actor_fails_closed_and_is_visible(): void + { + $node = $this->node(); + (new ExploreNode($node, true))->handle(); + $this->assertSame('failed', $node->refresh()->setup_status); + Bus::assertNotDispatched(SetupNode::class); + } + + public function test_job_runs_as_original_user_and_restores_worker_context(): void + { + Setting::set('auditlog.enabled', true, source: 'froxlor/core'); + Setting::set('auditlog.severity', 7, source: 'froxlor/core'); + $node = $this->request(); + $priorUser = User::query()->create(['email' => 'prior-'.str()->ulid().'@example.test', 'password' => 'test-password']); + auth()->guard()->setUser($priorUser); + $resolver = fn () => $priorUser; + request()->setUserResolver($resolver); + + $job = unserialize(serialize(new SetupNode($node->id, $node->setup_request_id))); + $job->handle(app(NodeSetupService::class)); + $this->assertSame('succeeded', $node->refresh()->setup_status); + $this->assertNotNull($node->setup_started_at); + $this->assertNotNull($node->setup_finished_at); + $this->assertSame($node->setup_request_id, $node->setup_run_id); + $this->assertSame($priorUser->id, auth()->id()); + $this->assertSame($resolver, request()->getUserResolver()); + $audit = AuditLog::query()->where('action', 'setup fixture executed')->firstOrFail(); + $this->assertSame($this->actor->id, $audit->auditable_id); + } + + public function test_duplicate_job_delivery_does_not_repeat_execution(): void + { + $node = $this->request(); + $job = new SetupNode($node->id, $node->setup_request_id); + $job->handle(app(NodeSetupService::class)); + $job->handle(app(NodeSetupService::class)); + $this->assertSame(1, $this->executions); + } + + public function test_revoked_permission_prevents_queued_execution(): void + { + $node = $this->request(); + $this->allowed = false; + $this->expectFailedJob($node); + $this->assertSame(0, $this->executions); + } + + public function test_deleted_actor_prevents_queued_execution(): void + { + $node = $this->request(); + $this->actor->delete(); + $this->expectFailedJob($node); + $this->assertSame(0, $this->executions); + } + + public function test_changed_target_invalidates_queued_plan(): void + { + $node = $this->request(); + $node->forceFill(['hostname' => 'different.example.test'])->saveQuietly(); + $this->expectFailedJob($node); + $this->assertSame(0, $this->executions); + } + + public function test_execution_failure_is_sanitized_and_restores_guest_context(): void + { + $node = $this->request(); + $this->executionFails = true; + $this->expectFailedJob($node); + $this->assertStringNotContainsString('SECRET', $node->refresh()->setup_error); + $this->assertNull(auth()->user()); + $this->assertNull(request()->user()); + } + + public function test_api_exposes_status_and_rejects_duplicate_requests(): void + { + $node = $this->node(); + $this->actingAs($this->actor, 'sanctum')->getJson('/api/nodes/'.$node->id.'/setup') + ->assertOk()->assertJsonPath('data.status', null); + $this->postJson('/api/nodes/'.$node->id.'/setup')->assertStatus(202)->assertJsonPath('data.status', 'pending'); + $this->postJson('/api/nodes/'.$node->id.'/setup')->assertStatus(409); + Bus::assertDispatchedTimes(SetupNode::class, 1); + } + + public function test_api_rejects_unauthenticated_and_unauthorized_requests(): void + { + $node = $this->node(); + $this->postJson('/api/nodes/'.$node->id.'/setup')->assertUnauthorized(); + $this->allowed = false; + $this->actingAs($this->actor, 'sanctum')->postJson('/api/nodes/'.$node->id.'/setup')->assertForbidden(); + Bus::assertNotDispatched(SetupNode::class); + } + + public function test_tenant_setup_uses_ownership_policy_and_rejects_inherited_nodes(): void + { + $this->usePolicies = true; + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $owned = $this->node(); + $owned->forceFill(['tenant_id' => $tenant->id])->saveQuietly(); + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/'.$tenant->id.'/nodes/'.$owned->id.'/setup')->assertStatus(202); + $this->getJson('/api/tenants/'.$tenant->id.'/nodes/'.$owned->id.'/setup') + ->assertOk()->assertJsonPath('data.requested_by', $user->id); + + $inherited = $this->node(); + $inherited->tenants()->attach($tenant, ['inheritable' => true]); + $this->postJson('/api/tenants/'.$tenant->id.'/nodes/'.$inherited->id.'/setup')->assertForbidden(); + Bus::assertDispatchedTimes(SetupNode::class, 1); + } + + public function test_unsupported_platform_is_rejected_without_queueing(): void + { + $node = $this->node(); + $node->forceFill(['properties' => ['os' => ['id' => 'unknown', 'version_id' => '1']]])->saveQuietly(); + $this->actingAs($this->actor, 'sanctum')->postJson('/api/nodes/'.$node->id.'/setup') + ->assertUnprocessable()->assertJsonValidationErrors('node'); + Bus::assertNotDispatched(SetupNode::class); + } + + public function test_broker_failure_leaves_failed_status_and_does_not_leak_output(): void + { + $node = $this->node(); + Bus::shouldReceive('dispatch')->once()->andThrow(new RuntimeException('SECRET broker credentials')); + try { + app(NodeSetupService::class)->request($node, $this->actor); + $this->fail('Expected broker failure.'); + } catch (RuntimeException $exception) { + $this->assertStringNotContainsString('SECRET', $exception->getMessage()); + $this->assertSame('failed', $node->refresh()->setup_status); + } + } + + public function test_retry_gets_new_request_and_old_failure_hook_cannot_overwrite_it(): void + { + $node = $this->request(); + $old = new SetupNode($node->id, $node->setup_request_id); + $old->failed(new RuntimeException('timeout SECRET')); + $this->assertSame('failed', $node->refresh()->setup_status); + $this->actingAs($this->actor, 'sanctum')->postJson('/api/nodes/'.$node->id.'/setup')->assertStatus(202); + $this->assertNotSame($old->requestId, $node->refresh()->setup_request_id); + $old->failed(new RuntimeException('old delivery')); + $this->assertSame('pending', $node->refresh()->setup_status); + } + + public function test_transaction_rollback_does_not_dispatch_job(): void + { + $node = $this->node(); + DB::beginTransaction(); + app(NodeSetupService::class)->request($node, $this->actor); + Bus::assertNotDispatched(SetupNode::class); + DB::rollBack(); + $this->assertNull($node->refresh()->setup_status); + Bus::assertNotDispatched(SetupNode::class); + } + + public function test_explicit_retry_can_replace_an_abandoned_request_after_30_minutes(): void + { + $node = $this->request(); + $old = new SetupNode($node->id, $node->setup_request_id); + $node->forceFill(['setup_status' => 'running', 'setup_started_at' => now()->subMinutes(31)])->saveQuietly(); + $new = app(NodeSetupService::class)->request($node, $this->actor); + $this->assertNotSame($old->requestId, $new->setup_request_id); + $old->handle(app(NodeSetupService::class)); + $this->assertSame(0, $this->executions); + $this->assertSame('pending', $new->refresh()->setup_status); + } + + public function test_cli_requires_explicit_user_and_queues_authorized_request(): void + { + $node = $this->node(); + $this->artisan('core:setup-node', ['node' => $node->id])->assertFailed(); + $this->artisan('core:setup-node', ['node' => $node->id, '--user' => $this->actor->id])->assertSuccessful(); + Bus::assertDispatchedTimes(SetupNode::class, 1); + } + + public function test_setup_has_a_separate_queue_and_long_enough_retry_window(): void + { + $job = new SetupNode('node', 'request'); + $this->assertSame('node-setup', $job->connection); + $this->assertSame('node-setup', $job->queue); + $this->assertGreaterThan($job->timeout, config('queue.connections.node-setup.retry_after')); + $this->assertTrue($job->failOnTimeout); + $this->assertSame(1, $job->tries); + } + + private function apply(Node $node, NodeSetupPlan $plan): NodeSetupResult + { + $this->executions++; + $this->assertSame($this->actor->id, auth()->id()); + $this->assertSame($this->actor->id, request()->user()->id); + if ($this->executionFails) { + throw new RuntimeException('SECRET connection output'); + } + Audit::info('setup fixture executed', $node->tenant, context: ['node_id' => $node->id]); + + return new NodeSetupResult($node->setup_request_id, $plan->fingerprint()); + } + + private function expectFailedJob(Node $node): void + { + try { + (new SetupNode($node->id, $node->setup_request_id))->handle(app(NodeSetupService::class)); + $this->fail('Expected job failure.'); + } catch (RuntimeException $exception) { + $this->assertNull($exception->getPrevious()); + $this->assertStringNotContainsString('SECRET', $exception->getMessage()); + $this->assertSame('failed', $node->refresh()->setup_status); + } + } + + private function request(): Node + { + return app(NodeSetupService::class)->request($this->node(), $this->actor); + } + + private function node(): Node + { + return Node::withoutEvents(fn () => Node::query()->create([ + 'name' => 'Setup lifecycle test', 'hostname' => 'setup.example.test', 'username' => 'root', + 'adapter' => SetupLifecycleAdapter::class, 'sudo' => false, + 'properties' => ['os' => ['id' => 'debian', 'version_id' => '13']], + ])); + } +} + +class SetupLifecycleAdapter extends Adapter +{ + public static string $name = 'setup-lifecycle-test'; + + public static bool $connected = true; + + public function isConnected(): bool + { + return self::$connected; + } + + public function exec(string|array $command): bool|string + { + $command = implode("\n", (array) $command); + + return match (true) { + $command === 'cat /etc/os-release' => "ID=debian\nVERSION_ID=13\nVERSION_CODENAME=trixie\nPRETTY_NAME=Debian", + $command === 'uname -r' => '6.1', + $command === 'nproc' => '4', + $command === 'cat /proc/stat' => 'cpu 10 0 10 100 0 0 0', + $command === 'cat /proc/meminfo' => "MemTotal: 1000 kB\nMemFree: 500 kB\nMemAvailable: 600 kB", + str_starts_with($command, 'df ') => '/dev/vda 1000 100 900 /', + str_starts_with($command, 'awk ') => '9999', + $command === 'hostname -I' => '192.0.2.10', + default => throw new RuntimeException('Unexpected transport invocation in exploration fixture'), + }; + } + + public function storagePut(string $remote, string $data): bool + { + throw new RuntimeException('Unexpected I/O'); + } + + public function storageGet(string $remote, bool|string $local = false): bool|string + { + throw new RuntimeException('Unexpected I/O'); + } + + public function storageDelete(string $remote): bool + { + throw new RuntimeException('Unexpected I/O'); + } + + public function storageExists(string $remote): bool + { + throw new RuntimeException('Unexpected I/O'); + } + + public function storagePutAsRoot(string $remote, string $data, array $ownership = []): bool + { + throw new RuntimeException('Unexpected I/O'); + } +} From 5cba91675aedd29e0dfe4d372d35056a75fedc1e Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 21:45:59 +0200 Subject: [PATCH 07/11] Harden scoped user management and protect global administration --- ...0095_allow_roleless_scoped_memberships.php | 33 ++ packages/core/docs/user-security.md | 81 ++++ packages/core/docs/user-security.openapi.yaml | 170 ++++++++ .../Api/Role/RolePermissionController.php | 32 +- .../Api/Tenant/Environment/UserController.php | 11 +- .../Controllers/Api/Tenant/UserController.php | 15 +- .../Http/Controllers/Api/TenantController.php | 9 +- .../Http/Controllers/Api/UserController.php | 11 +- .../UpdateEnvironmentUserRequest.php | 12 +- .../Tenant/UpdateTenantUserRequest.php | 42 ++ packages/core/src/Models/EnvironmentUser.php | 7 +- packages/core/src/Models/TenantUser.php | 7 +- .../core/src/Support/AdministrationGuard.php | 70 ++++ .../Feature/UserSecurityBoundariesTest.php | 390 ++++++++++++++++++ 14 files changed, 850 insertions(+), 40 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000095_allow_roleless_scoped_memberships.php create mode 100644 packages/core/docs/user-security.md create mode 100644 packages/core/docs/user-security.openapi.yaml create mode 100644 packages/core/src/Http/Requests/Tenant/UpdateTenantUserRequest.php create mode 100644 packages/core/src/Support/AdministrationGuard.php create mode 100644 packages/core/tests/Feature/UserSecurityBoundariesTest.php diff --git a/packages/core/database/migrations/0001_01_01_000095_allow_roleless_scoped_memberships.php b/packages/core/database/migrations/0001_01_01_000095_allow_roleless_scoped_memberships.php new file mode 100644 index 0000000..4e21d93 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000095_allow_roleless_scoped_memberships.php @@ -0,0 +1,33 @@ +ulid('role_id')->nullable()->change(); + }); + } + } + + public function down(): void + { + // Never invent permissions or remove memberships to satisfy a rollback. + foreach (['tenant_user', 'environment_user'] as $table) { + if (DB::table($table)->whereNull('role_id')->exists()) { + throw new RuntimeException('Assign roles to roleless memberships before rolling back this migration.'); + } + } + foreach (['tenant_user', 'environment_user'] as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->ulid('role_id')->nullable(false)->change(); + }); + } + } +}; diff --git a/packages/core/docs/user-security.md b/packages/core/docs/user-security.md new file mode 100644 index 0000000..47e9756 --- /dev/null +++ b/packages/core/docs/user-security.md @@ -0,0 +1,81 @@ +# User accounts, scoped memberships and administration continuity + +## Separate account credentials from scoped administration + +`PATCH`/`PUT /api/tenants/{tenant}/users/{user}` and +`PATCH`/`PUT /api/tenants/{tenant}/environments/{environment}/users/{user}` +reject `email` and `password` with HTTP 422, even if their values are null or the +caller is a global administrator. A rejected request does not partially update +the profile or memberships. Clients must omit these fields from scoped edit forms. +Names/company remain editable under the existing scoped user-update permission. + +Global credentials may still be changed through `PATCH`/`PUT /api/users/{user}`, +authorized by the existing **global** `users.update` permission. A tenant or +environment pivot permission does not satisfy that permission, even for the caller's +own account. This change does not add a self-service credentials endpoint or change +authentication-package flows. Creating a new account still accepts initial credentials; +the scoped create endpoints do not attach/overwrite an existing account by email. + +## Explicit role revocation + +Apply `0001_01_01_000095_allow_roleless_scoped_memberships` before deploying. + +- Tenant update: `{"role_id": null}` (or the legacy alias `{"role": null}`). +- Environment update: `{"environment_role": null}`. +- Environment update with `{"tenant_role": null}` additionally requires the target + user's tenant-update authorization, just like changes to `tenant_plan`. Environment + user administration alone does not permit changing tenant privileges. +- An omitted role field leaves the assignment unchanged. Non-null role assignments + still require scope availability and delegability of every permission. +- Aliases and canonical fields cannot both be submitted, including null combinations. + Environment updates reject tenant-route aliases (`role`, `role_id`, `plan`, `plan_id`). + +Revocation sets only the relevant pivot's `role_id` to null. The membership and its +plan are preserved. A user with permissions only in an environment still belongs to +that environment's customer; customer membership alone grants no tenant permissions. +Permission/delegation checks on a roleless membership return false. Other independent +roles may still grant permissions. This is not a deny/override mechanism. + +The migration keeps foreign keys and permits null role IDs. Its rollback refuses to +run while roleless memberships exist rather than assigning arbitrary roles or deleting +memberships. Existing create contracts are otherwise unchanged. + +## Last global administrator + +The Core's bootstrap semantics define a recoverable global administrator as a +non-soft-deleted user with a globally assigned global role granting `*` with +`inheritable=true`. Role names are irrelevant. Scoped `*` grants and deleted users +do not qualify; multiple users sharing one role do not protect against removal of +that role's wildcard. Removing delegation also counts as loss of administration. + +`AdministrationGuard::run()` wraps the mutation in a database transaction and takes +an exclusive lock on the persistent `permissions.key = '*'` row. It uses locking +current reads before and after the mutation, not cached roles or snapshot-only counts. +It rejects a transition from existing global administration to none with HTTP 422 +(`errors.administration`) and rolls back the mutation. It also preserves administrator +membership for every previously administered root tenant. Changes remain allowed when +another independent administrator retains the necessary access, including self-demotion. + +The guard covers global user deletion, global role-permission removal/downgrade, +tenant membership removal, and tenant deletion/reparenting. Assigned-role deletion +already has a separate rejection check. No global role-assignment write endpoint is +introduced by this change. + +Packages implementing global role assignment/revocation or other administration-affecting +writes must call the same guard **before** reading/checking/mutating security state, +and perform their authorization inside its callback. Do not bypass it with direct SQL. +The guard is an application contract, not a database trigger. It does not repair an +installation that already has no administrators or guarantee that credentials/MFA are +usable. Missing bootstrap `*` metadata fails closed and requires registry recovery. + +## Verification + +`tests/Feature/UserSecurityBoundariesTest.php` uses its own users/roles/memberships and +MariaDB transactions. It does not depend on named development seed users, reset the +database, or persist its fixtures. Tests exercise actual policies, credential rejection, +null/omitted roles, foreign scope rejection, self-demotion, shared admin roles, soft +deletion, root access, rollback, and mutex contention from a second database connection. +The second-connection test verifies locking; it is not a full parallel HTTP load test. + +The accompanying `user-security.openapi.yaml` describes the changed scoped-update +contract and administration-protection error, not the entire user/role API. diff --git a/packages/core/docs/user-security.openapi.yaml b/packages/core/docs/user-security.openapi.yaml new file mode 100644 index 0000000..e382dee --- /dev/null +++ b/packages/core/docs/user-security.openapi.yaml @@ -0,0 +1,170 @@ +openapi: 3.1.0 +info: + title: froxlor Core user security changes + version: 1.0.0 + description: Scoped update contracts only; not the complete user and role API. +security: + - bearerAuth: [] +paths: + /api/tenants/{tenant}/users/{user}: + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/User' + patch: &tenantUpdate + summary: Update a tenant user's profile and scoped membership + description: Requires tenants.users.update in the target tenant. Global credentials are not accepted. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TenantUserUpdate' + responses: + '200': + description: Updated user resource wrapped in data + '401': + description: Authentication required + '403': + description: Missing scoped authorization + '404': + description: Resource not found + '422': + description: Invalid fields, conflicting aliases or non-delegable role + put: *tenantUpdate + delete: + summary: Remove tenant membership + description: Requires tenants.users.destroy. Last root administrator membership cannot be removed. + responses: + '200': + description: Membership removed + '401': + description: Authentication required + '403': + description: Missing scoped authorization + '404': + description: Resource not found + '422': + $ref: '#/components/responses/AdministrationProtected' + /api/tenants/{tenant}/environments/{environment}/users/{user}: + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/User' + - name: environment + in: path + required: true + schema: + type: string + patch: &environmentUpdate + summary: Update an environment user's profile and scoped membership + description: >- + Requires tenants.environments.users.update in the environment. + tenant_role and tenant_plan also require tenant user update authorization. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentUserUpdate' + responses: + '200': + description: Updated user resource wrapped in data + '401': + description: Authentication required + '403': + description: Missing environment or tenant authorization + '404': + description: Resource not found + '422': + description: Invalid fields or non-delegable role + put: *environmentUpdate +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + Tenant: + name: tenant + in: path + required: true + schema: + type: string + User: + name: user + in: path + required: true + schema: + type: string + responses: + AdministrationProtected: + description: Mutation would remove the last global administrator or existing root access; nothing is committed. + content: + application/json: + schema: + type: object + required: [message, errors] + properties: + message: + type: string + errors: + type: object + properties: + administration: + type: array + items: + type: string + schemas: + NullableUlid: + type: [string, 'null'] + pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' + Profile: + type: object + properties: + first_name: + type: string + minLength: 1 + last_name: + type: string + minLength: 1 + company_name: + type: [string, 'null'] + email: false + password: false + tenant_id: false + TenantUserUpdate: + allOf: + - $ref: '#/components/schemas/Profile' + - type: object + description: Null revokes a role without detaching membership. Omission leaves it unchanged. + properties: + role_id: + $ref: '#/components/schemas/NullableUlid' + role: + $ref: '#/components/schemas/NullableUlid' + plan_id: + $ref: '#/components/schemas/NullableUlid' + plan: + $ref: '#/components/schemas/NullableUlid' + allOf: + - not: + required: [role_id, role] + - not: + required: [plan_id, plan] + EnvironmentUserUpdate: + allOf: + - $ref: '#/components/schemas/Profile' + - type: object + description: Null revokes only the selected role; membership and other assignments remain. + properties: + tenant_role: + $ref: '#/components/schemas/NullableUlid' + environment_role: + $ref: '#/components/schemas/NullableUlid' + tenant_plan: + $ref: '#/components/schemas/NullableUlid' + environment_plan: + $ref: '#/components/schemas/NullableUlid' + role: false + role_id: false + plan: false + plan_id: false diff --git a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php index 26195c1..c31410c 100644 --- a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php @@ -6,6 +6,7 @@ use Froxlor\Core\Models\Permission; use Froxlor\Core\Models\Role; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\AdministrationGuard; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; @@ -64,11 +65,13 @@ public function store(Request $request, Role $role) $permission = Permission::findOrFail($data['permission_id']); - abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); - - $role->permissions()->syncWithoutDetaching([ - $permission->id => ['inheritable' => $data['inheritable'] ?? false], - ]); + AdministrationGuard::run(function () use ($request, $role, $permission, $data) { + Gate::authorize('roleCreate', [Permission::class, $role]); + abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); + $role->permissions()->syncWithoutDetaching([ + $permission->id => ['inheritable' => $data['inheritable'] ?? false], + ]); + }); Audit::notice('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $role->tenant, context: [ 'role_id' => $role->id, @@ -87,15 +90,16 @@ public function destroy(Request $request, Role $role, Permission $permission) { Gate::authorize('roleDelete', [$permission, $role]); - abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); - - if (!$role->permissions()->where('permissions.id', $permission->id)->exists()) { - throw ValidationException::withMessages([ - 'permission_id' => 'The selected permission is not assigned to this role.', - ]); - } - - $role->permissions()->detach($permission); + AdministrationGuard::run(function () use ($request, $role, $permission) { + Gate::authorize('roleDelete', [$permission, $role]); + abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); + if (!$role->permissions()->where('permissions.id', $permission->id)->exists()) { + throw ValidationException::withMessages([ + 'permission_id' => 'The selected permission is not assigned to this role.', + ]); + } + $role->permissions()->detach($permission); + }); Audit::info('permission "' . $permission->key . '" removed from role "' . $role->name . '"', $role->tenant, context: [ 'role_id' => $role->id, diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index f916c13..27ecbae 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -86,6 +86,8 @@ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, En Gate::authorize('tenantEnvUpdate', [$user, $tenant, $environment]); $userData = $request->validated(); + $tenantRoleProvided = $request->exists('tenant_role'); + $environmentRoleProvided = $request->exists('environment_role'); $tenantRoleId = $this->getNonModelRequestData('tenant_role', $userData); $tenantPlanProvided = $request->has('tenant_plan'); $tenantPlanId = $this->getNonModelRequestData('tenant_plan', $userData); @@ -93,6 +95,11 @@ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, En $environmentPlanProvided = $request->has('environment_plan'); $environmentPlanId = $this->getNonModelRequestData('environment_plan', $userData); + // Environment membership administration alone cannot mutate tenant privileges. + if ($tenantRoleProvided || $tenantPlanProvided) { + Gate::authorize('tenantUpdate', [$user, $tenant]); + } + if (!empty($tenantRoleId)) { RoleAssignments::ensureAssignable($request->user(), $tenantRoleId, 'tenant_role', $tenant); } @@ -109,7 +116,7 @@ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, En $user->update($userData); $tenantPivotData = []; - if (!empty($tenantRoleId)) { + if ($tenantRoleProvided) { $tenantPivotData['role_id'] = $tenantRoleId; } if ($tenantPlanProvided) { @@ -122,7 +129,7 @@ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, En } $environmentPivotData = []; - if (!empty($environmentRoleId)) { + if ($environmentRoleProvided) { $environmentPivotData['role_id'] = $environmentRoleId; } if ($environmentPlanProvided) { diff --git a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index e504725..069cf33 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -7,12 +7,13 @@ use Froxlor\Core\Events\Api\ResourceUpdated; use Froxlor\Core\Http\Controllers\Controller; use Froxlor\Core\Http\Requests\Tenant\StoreTenantUserRequest; -use Froxlor\Core\Http\Requests\UpdateUserRequest; +use Froxlor\Core\Http\Requests\Tenant\UpdateTenantUserRequest; use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\AdministrationGuard; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; @@ -94,11 +95,12 @@ public function show(Request $request, Tenant $tenant, User $user) /** * Update the specified resource in storage. */ - public function update(UpdateUserRequest $request, Tenant $tenant, User $user) + public function update(UpdateTenantUserRequest $request, Tenant $tenant, User $user) { Gate::authorize('tenantUpdate', [$user, $tenant]); $userData = $request->validated(); + $roleProvided = $request->exists('role_id') || $request->exists('role'); unset($userData['tenant_id']); $roleId = $this->getNonModelRequestData('role_id', $userData) ?? $this->getNonModelRequestData('role', $userData); @@ -117,7 +119,7 @@ public function update(UpdateUserRequest $request, Tenant $tenant, User $user) $user->update($userData); $pivotData = []; - if (!empty($roleId)) { + if ($roleProvided) { $pivotData['role_id'] = $roleId; } if ($planProvided) { @@ -142,9 +144,10 @@ public function update(UpdateUserRequest $request, Tenant $tenant, User $user) */ public function destroy(Request $request, Tenant $tenant, User $user) { - Gate::authorize('tenantDelete', [$user, $tenant]); - - $tenant->users()->detach($user); + AdministrationGuard::run(function () use ($tenant, $user) { + Gate::authorize('tenantDelete', [$user, $tenant]); + $tenant->users()->detach($user); + }); event(new ResourceDeleted($user, [])); Audit::info('user "' . $user->email . '" removed', $tenant, context: [ 'user_id' => $user->id, diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 950942a..4572d56 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -13,6 +13,7 @@ use Froxlor\Core\Models\Tenant; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\AdministrationGuard; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -116,7 +117,8 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) : $tenant->plan; $oldParentTenant = $tenant->parentTenant; - DB::transaction(function () use ($tenant, $tenantData, $oldParentTenant, $parentTenant, $plan): void { + AdministrationGuard::run(function () use ($tenant, $tenantData, $oldParentTenant, $parentTenant, $plan): void { + Gate::authorize('update', $tenant); if ($oldParentTenant !== null && ($parentTenant === null || $oldParentTenant->id !== $parentTenant->id)) { PlanAssignments::lockTenantBudget($oldParentTenant); } @@ -161,7 +163,10 @@ public function destroy(Tenant $tenant) } $parentTenant = $tenant->parentTenant; - $tenant->delete(); + AdministrationGuard::run(function () use ($tenant): void { + Gate::authorize('delete', $tenant); + $tenant->delete(); + }); event(new ResourceDeleted($tenant, [])); Audit::info('tenant "' . $tenant->name . '" deleted', $parentTenant, context: [ 'tenant_id' => $tenant->id, diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index 5db691a..f97297f 100644 --- a/packages/core/src/Http/Controllers/Api/UserController.php +++ b/packages/core/src/Http/Controllers/Api/UserController.php @@ -13,6 +13,7 @@ use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\AdministrationGuard; use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; @@ -164,10 +165,12 @@ public function update(UpdateUserRequest $request, User $user) */ public function destroy(User $user) { - Gate::authorize('delete', $user); - - $tenant = $user->tenants()->first(); - $user->delete(); + $tenant = AdministrationGuard::run(function () use ($user) { + Gate::authorize('delete', $user); + $tenant = $user->tenants()->first(); + $user->delete(); + return $tenant; + }); event(new ResourceDeleted($user, [])); Audit::info('user "' . $user->email . '" deleted', $tenant, context: [ 'user_id' => $user->id, diff --git a/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php b/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php index 276a96e..539b8a4 100644 --- a/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php +++ b/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php @@ -2,9 +2,9 @@ namespace Froxlor\Core\Http\Requests\Tenant\Environment; -use Froxlor\Core\Http\Requests\UpdateUserRequest; +use Froxlor\Core\Http\Requests\Tenant\UpdateTenantUserRequest; -class UpdateEnvironmentUserRequest extends UpdateUserRequest +class UpdateEnvironmentUserRequest extends UpdateTenantUserRequest { /** * Get the validation rules that apply to the request. @@ -17,9 +17,13 @@ class UpdateEnvironmentUserRequest extends UpdateUserRequest public function rules(): array { return array_merge(parent::rules(), [ - 'tenant_role' => ['sometimes', 'string', 'ulid', 'exists:roles,id'], + 'role' => ['missing'], + 'role_id' => ['missing'], + 'plan' => ['missing'], + 'plan_id' => ['missing'], + 'tenant_role' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:roles,id'], 'tenant_plan' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], - 'environment_role' => ['sometimes', 'string', 'ulid', 'exists:roles,id'], + 'environment_role' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:roles,id'], 'environment_plan' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], ]); } diff --git a/packages/core/src/Http/Requests/Tenant/UpdateTenantUserRequest.php b/packages/core/src/Http/Requests/Tenant/UpdateTenantUserRequest.php new file mode 100644 index 0000000..ad83c46 --- /dev/null +++ b/packages/core/src/Http/Requests/Tenant/UpdateTenantUserRequest.php @@ -0,0 +1,42 @@ + ['sometimes', 'required', 'string'], + 'last_name' => ['sometimes', 'required', 'string'], + 'company_name' => ['sometimes', 'nullable', 'string'], + 'email' => ['missing'], + 'password' => ['missing'], + 'tenant_id' => ['missing'], + 'role_id' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:roles,id'], + 'role' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:roles,id'], + 'plan_id' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], + 'plan' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + foreach (['role', 'plan'] as $alias) { + if ($this->exists($alias) && $this->exists($alias.'_id')) { + $validator->errors()->add($alias, 'Use either the alias or its _id field, not both.'); + } + } + }); + } +} diff --git a/packages/core/src/Models/EnvironmentUser.php b/packages/core/src/Models/EnvironmentUser.php index cce42d9..388ba35 100644 --- a/packages/core/src/Models/EnvironmentUser.php +++ b/packages/core/src/Models/EnvironmentUser.php @@ -12,11 +12,11 @@ * @property string $id * @property string $environment_id * @property string $user_id - * @property string $role_id + * @property string|null $role_id * @property string|null $plan_id * @property Environment $environment * @property User $user - * @property Role $role + * @property Role|null $role * @property Plan|null $plan */ class EnvironmentUser extends Pivot @@ -94,8 +94,7 @@ public function hasResourceAvailable(string $resource): bool public function hasPermission(string|array $permission): bool { $possible_permissions = Permission::generatePermissionPath($permission); - return $this->role->permissions() - ->whereIn('key', $possible_permissions) + return $this->role()->whereHas('permissions', fn ($query) => $query->whereIn('key', $possible_permissions)) ->exists(); } } diff --git a/packages/core/src/Models/TenantUser.php b/packages/core/src/Models/TenantUser.php index a97ddf9..b955dcb 100644 --- a/packages/core/src/Models/TenantUser.php +++ b/packages/core/src/Models/TenantUser.php @@ -15,11 +15,11 @@ * @property string $id * @property string $tenant_id * @property string $user_id - * @property string $role_id + * @property string|null $role_id * @property string|null $plan_id * @property Tenant $tenant * @property User $user - * @property Role $role + * @property Role|null $role * @property Plan|null $plan */ #[ObservedBy(TenantUserObserver::class)] @@ -58,8 +58,7 @@ public function plan(): BelongsTo public function hasPermission(string|array $permission): bool { $possible_permissions = Permission::generatePermissionPath($permission); - return $this->role->permissions() - ->whereIn('key', $possible_permissions) + return $this->role()->whereHas('permissions', fn ($query) => $query->whereIn('key', $possible_permissions)) ->exists(); } diff --git a/packages/core/src/Support/AdministrationGuard.php b/packages/core/src/Support/AdministrationGuard.php new file mode 100644 index 0000000..dc6a94d --- /dev/null +++ b/packages/core/src/Support/AdministrationGuard.php @@ -0,0 +1,70 @@ +where('key', '*')->lockForUpdate()->first(); + if ($permission === null) { + throw ValidationException::withMessages(['administration' => 'The global administration permission is missing. Restore the permission registry first.']); + } + + $before = self::administrators($permission->id); + $roots = self::administeredRoots($before); + $result = $mutation(); + + $after = self::administrators($permission->id); + if (($before !== [] && $after === []) + || array_diff($roots, self::administeredRoots($after)) !== []) { + throw ValidationException::withMessages([ + 'administration' => 'This change would remove the last global administrator or their root tenant access.', + ]); + } + + return $result; + }); + } + + /** Current/locking reads also work under MariaDB REPEATABLE READ. Role names are irrelevant. */ + private static function administrators(string $permissionId): array + { + return DB::table('users') + ->join('role_user', 'role_user.user_id', '=', 'users.id') + ->join('roles', 'roles.id', '=', 'role_user.role_id') + ->join('permission_role', 'permission_role.role_id', '=', 'roles.id') + ->whereNull('users.deleted_at') + ->whereNull('roles.tenant_id') + ->where('permission_role.permission_id', $permissionId) + ->where('permission_role.inheritable', true) + ->orderBy('users.id')->lockForUpdate() + ->pluck('users.id')->unique()->values()->all(); + } + + /** Preserve each existing root's administrator membership, not only global permissions. */ + private static function administeredRoots(array $administrators): array + { + if ($administrators === []) { + return []; + } + + return DB::table('tenant_user') + ->join('tenants', 'tenants.id', '=', 'tenant_user.tenant_id') + ->whereNull('tenants.parent_tenant_id') + ->whereIn('tenant_user.user_id', $administrators) + ->orderBy('tenants.id')->lockForUpdate() + ->pluck('tenants.id')->unique()->values()->all(); + } +} diff --git a/packages/core/tests/Feature/UserSecurityBoundariesTest.php b/packages/core/tests/Feature/UserSecurityBoundariesTest.php new file mode 100644 index 0000000..704a33d --- /dev/null +++ b/packages/core/tests/Feature/UserSecurityBoundariesTest.php @@ -0,0 +1,390 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + config(['app.key' => 'base64:'.base64_encode(str_repeat('s', 32))]); + $this->wildcard = Permission::query()->firstOrCreate(['key' => '*'], ['name' => 'Everything']); + // Isolate the administration invariant from any existing development administrators. + // DatabaseTransactions rolls this and every fixture back after each test. + DB::table('permission_role')->where('permission_id', $this->wildcard->id)->update(['inheritable' => false]); + Model::withoutEvents(function (): void { + $plan = Plan::query()->create(['name' => 'Security '.str()->ulid()]); + $this->tenant = Tenant::query()->create(['name' => 'Security tenant', 'plan_id' => $plan->id]); + $this->environment = Environment::query()->create([ + 'name' => 'Security environment', 'tenant_id' => $this->tenant->id, 'plan_id' => $plan->id, + ]); + }); + } + + public function test_tenant_update_rejects_credentials_without_partial_changes(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $target = $this->tenantUser($role = $this->role(['tenants.index'])); + $before = $target->getRawOriginal(); + foreach ([['password' => 'replacement-password'], ['email' => 'changed@example.test'], ['password' => null]] as $credentials) { + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), [ + ...$credentials, 'role_id' => null, 'first_name' => 'Must not persist', + ])->assertUnprocessable(); + $this->assertSame($before['email'], $target->fresh()->email); + $this->assertSame($before['password'], $target->fresh()->getRawOriginal('password')); + $this->assertSame($before['first_name'], $target->fresh()->first_name); + $this->assertSame($role->id, $target->tenants()->first()->pivot->role_id); + } + } + + public function test_environment_update_rejects_credentials_even_for_global_admin(): void + { + $actor = $this->admin(); + $target = $this->environmentUser($this->role(['tenants.environments.index'])); + $before = $target->getRawOriginal('password'); + $this->actingAs($actor, 'sanctum')->patchJson($this->environmentPath($target), [ + 'email' => 'changed@example.test', 'password' => 'replacement-password', + ])->assertUnprocessable()->assertJsonValidationErrors(['email', 'password']); + $this->assertSame($before, $target->fresh()->getRawOriginal('password')); + } + + public function test_scoped_admin_cannot_use_global_account_update_even_on_self(): void + { + $actor = $this->environmentUser($this->role(['tenants.environments.users.*'])); + $this->actingAs($actor, 'sanctum')->patchJson('/api/users/'.$actor->id, [ + 'password' => 'replacement-password', + ])->assertForbidden(); + } + + public function test_global_account_permission_still_allows_credentials_update(): void + { + $actor = $this->admin(); + $target = $this->tenantUser(null); + $this->actingAs($actor, 'sanctum')->patchJson('/api/users/'.$target->id, [ + 'email' => 'changed-'.str()->ulid().'@example.test', 'password' => 'replacement-password', + ])->assertOk(); + $this->assertTrue(Hash::check('replacement-password', $target->fresh()->password)); + } + + public function test_null_tenant_role_revokes_permissions_but_preserves_membership_and_plan(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $target = $this->tenantUser($this->role(['tenants.index'])); + $target->tenants()->updateExistingPivot($this->tenant->id, ['plan_id' => $this->tenant->plan_id]); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), ['role_id' => null])->assertOk(); + $pivot = $target->tenants()->firstOrFail()->pivot; + $this->assertNull($pivot->role_id); + $this->assertSame($this->tenant->plan_id, $pivot->plan_id); + $this->assertFalse($pivot->hasPermission('tenants.index')); + $this->assertFalse($pivot->canDelegatePermission('tenants.index')); + } + + public function test_role_alias_can_revoke_and_omission_keeps_role(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $target = $this->tenantUser($role = $this->role(['tenants.index'])); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), ['first_name' => 'Updated'])->assertOk(); + $this->assertSame($role->id, $target->tenants()->first()->pivot->role_id); + $this->patchJson($this->tenantPath($target), ['role' => null])->assertOk(); + $this->assertNull($target->tenants()->first()->pivot->role_id); + } + + public function test_conflicting_aliases_are_rejected_including_null(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $target = $this->tenantUser($role = $this->role(['tenants.index'])); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), [ + 'role_id' => null, 'role' => $role->id, + ])->assertUnprocessable()->assertJsonValidationErrors('role'); + $this->assertSame($role->id, $target->tenants()->first()->pivot->role_id); + } + + public function test_environment_role_can_be_revoked_without_removing_customer_membership(): void + { + $actor = $this->environmentUser($this->role(['tenants.environments.users.*'])); + $target = $this->environmentUser($this->role(['tenants.environments.index'])); + $this->actingAs($actor, 'sanctum')->patchJson($this->environmentPath($target), ['environment_role' => null])->assertOk(); + $pivot = $target->environments()->firstOrFail()->pivot; + $this->assertNull($pivot->role_id); + $this->assertFalse($pivot->hasPermission('tenants.environments.index')); + $this->assertFalse($pivot->canDelegatePermission('tenants.environments.index')); + $this->assertTrue($target->tenants()->whereKey($this->tenant->id)->exists()); + } + + public function test_environment_admin_cannot_revoke_tenant_role(): void + { + $actor = $this->environmentUser($this->role(['tenants.environments.users.*'])); + $target = $this->environmentUser($this->role([])); + $role = $this->role(['tenants.users.*']); + $target->tenants()->updateExistingPivot($this->tenant->id, ['role_id' => $role->id]); + $this->actingAs($actor, 'sanctum')->patchJson($this->environmentPath($target), [ + 'tenant_role' => null, 'environment_role' => null, + ])->assertForbidden(); + $this->assertSame($role->id, $target->tenants()->first()->pivot->role_id); + $this->assertNotNull($target->environments()->first()->pivot->role_id); + } + + public function test_environment_route_rejects_global_assignment_fields(): void + { + $actor = $this->admin(); + $target = $this->environmentUser($this->role([])); + $this->actingAs($actor, 'sanctum')->patchJson($this->environmentPath($target), ['role_id' => null]) + ->assertUnprocessable()->assertJsonValidationErrors('role_id'); + } + + public function test_tenant_admin_can_revoke_tenant_role_through_environment_route(): void + { + $actor = $this->admin(); + $target = $this->environmentUser($this->role([])); + $target->tenants()->updateExistingPivot($this->tenant->id, ['role_id' => $this->role(['tenants.index'])->id]); + $this->actingAs($actor, 'sanctum')->patchJson($this->environmentPath($target), ['tenant_role' => null])->assertOk(); + $this->assertNull($target->tenants()->first()->pivot->role_id); + } + + public function test_foreign_user_cannot_revoke_scoped_role(): void + { + $actor = $this->user(); + $target = $this->tenantUser($this->role(['tenants.index'])); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), ['role_id' => null])->assertForbidden(); + } + + public function test_non_null_role_assignment_still_requires_delegation(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $target = $this->tenantUser(null); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), ['role_id' => $this->role(['*'])->id]) + ->assertUnprocessable()->assertJsonValidationErrors('role_id'); + $this->assertNull($target->tenants()->first()->pivot->role_id); + $role = $this->role(['tenants.users.index']); + $this->patchJson($this->tenantPath($target), ['role_id' => $role->id])->assertOk(); + $this->assertSame($role->id, $target->tenants()->first()->pivot->role_id); + } + + public function test_guard_preserves_root_status_and_allows_normal_tenant_update(): void + { + $actor = $this->admin(); + $this->actingAs($actor, 'sanctum')->patchJson('/api/tenants/'.$this->tenant->id, ['name' => 'Updated root'])->assertOk(); + $other = Tenant::query()->create(['name' => 'Another root', 'plan_id' => $this->tenant->plan_id]); + try { + AdministrationGuard::run(fn () => $this->tenant->update(['parent_tenant_id' => $other->id])); + $this->fail('Expected root administration protection.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('administration', $exception->errors()); + } + $this->assertNull($this->tenant->fresh()->parent_tenant_id); + } + + public function test_last_global_administrator_cannot_be_deleted(): void + { + $actor = $this->admin(); + $this->actingAs($actor, 'sanctum')->deleteJson('/api/users/'.$actor->id) + ->assertUnprocessable()->assertJsonValidationErrors('administration'); + $this->assertNull($actor->fresh()->deleted_at); + } + + public function test_last_global_permission_and_delegation_cannot_be_removed(): void + { + $actor = $this->admin(); + $role = $actor->roles()->firstOrFail(); + $path = '/api/roles/'.$role->id.'/permissions'; + $this->actingAs($actor, 'sanctum')->deleteJson($path.'/'.$this->wildcard->id) + ->assertUnprocessable()->assertJsonValidationErrors('administration'); + foreach ([['inheritable' => false], []] as $options) { + $this->postJson($path, ['permission_id' => $this->wildcard->id, ...$options]) + ->assertUnprocessable()->assertJsonValidationErrors('administration'); + } + $this->assertTrue($actor->canDelegatePermission('*')); + } + + public function test_two_users_sharing_the_last_admin_role_do_not_make_its_removal_safe(): void + { + $actor = $this->admin(); + $other = $this->tenantUser(null); + $role = $actor->roles()->first(); + $other->roles()->attach($role); + $this->actingAs($actor, 'sanctum')->deleteJson('/api/roles/'.$role->id.'/permissions/'.$this->wildcard->id) + ->assertUnprocessable(); + } + + public function test_self_demotion_is_allowed_when_independent_admin_remains(): void + { + $actor = $this->admin(); + $other = $this->admin(); + $role = $actor->roles()->first(); + $this->actingAs($actor, 'sanctum')->deleteJson('/api/roles/'.$role->id.'/permissions/'.$this->wildcard->id)->assertOk(); + $this->assertFalse($actor->hasPermission('*')); + $this->assertTrue($other->canDelegatePermission('*')); + } + + public function test_self_deletion_is_allowed_when_another_admin_remains(): void + { + $actor = $this->admin(); + $other = $this->admin(); + $this->actingAs($actor, 'sanctum')->deleteJson('/api/users/'.$actor->id)->assertNoContent(); + $this->assertNull(User::query()->find($actor->id)); + $this->assertTrue($other->canDelegatePermission('*')); + } + + public function test_soft_deleted_or_scoped_admins_are_not_global_fallbacks(): void + { + $actor = $this->admin(); + $deleted = $this->admin(); + $deleted->delete(); + $this->tenantUser($this->role(['*'])); + $this->actingAs($actor, 'sanctum')->deleteJson('/api/users/'.$actor->id)->assertUnprocessable(); + } + + public function test_last_root_admin_membership_cannot_be_detached(): void + { + $actor = $this->admin(); + $this->actingAs($actor, 'sanctum')->deleteJson($this->tenantPath($actor)) + ->assertUnprocessable()->assertJsonValidationErrors('administration'); + $this->assertTrue($actor->tenants()->whereKey($this->tenant->id)->exists()); + } + + public function test_root_membership_can_be_detached_when_another_root_admin_remains(): void + { + $actor = $this->admin(); + $this->admin(); + $this->actingAs($actor, 'sanctum')->deleteJson($this->tenantPath($actor))->assertOk(); + $this->assertFalse($actor->tenants()->whereKey($this->tenant->id)->exists()); + } + + public function test_guard_rolls_back_extension_global_role_revocation(): void + { + $actor = $this->admin(); + try { + AdministrationGuard::run(fn () => $actor->roles()->detach()); + $this->fail('Expected last administrator protection.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('administration', $exception->errors()); + } + $this->assertTrue($actor->canDelegatePermission('*')); + } + + public function test_guard_holds_database_mutex_against_a_second_connection(): void + { + $name = 'administration-lock-test'; + config(['database.connections.'.$name => config('database.connections.'.DB::getDefaultConnection())]); + $other = DB::connection($name); + try { + $other->statement('SET SESSION innodb_lock_wait_timeout = 1'); + // A shared read must succeed before the guard, even with fixture FK locks. + $other->beginTransaction(); + $this->assertNotNull($other->table('permissions')->where('key', '*')->sharedLock()->first()); + $other->rollBack(); + AdministrationGuard::run(function () use ($other): void { + $other->beginTransaction(); + try { + $other->table('permissions')->where('key', '*')->sharedLock()->first(); + $this->fail('A concurrent security mutation acquired the administration mutex.'); + } catch (QueryException $exception) { + $this->assertSame(1205, $exception->errorInfo[1]); + } finally { + $other->rollBack(); + } + }); + } finally { + DB::purge($name); + } + } + + public function test_sequential_demotions_cannot_remove_both_independent_admins(): void + { + $first = $this->admin(); + $second = $this->admin(); + $this->actingAs($first, 'sanctum')->deleteJson('/api/users/'.$first->id)->assertNoContent(); + $this->actingAs($second, 'sanctum')->deleteJson('/api/users/'.$second->id) + ->assertUnprocessable()->assertJsonValidationErrors('administration'); + $this->assertNotNull($second->fresh()); + } + + public function test_scoped_user_can_revoke_own_role_without_losing_membership(): void + { + $actor = $this->tenantUser($this->role(['tenants.users.*'])); + $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($actor), ['role_id' => null])->assertOk(); + $this->assertTrue($actor->tenants()->whereKey($this->tenant->id)->exists()); + $this->patchJson($this->tenantPath($actor), ['first_name' => 'Denied'])->assertForbidden(); + } + + private function role(array $keys): Role + { + $role = Role::query()->create(['name' => 'Security role '.str()->ulid()]); + foreach ($keys as $key) { + $permission = Permission::query()->firstOrCreate(['key' => $key], ['name' => $key]); + $role->permissions()->attach($permission, ['inheritable' => true]); + } + + return $role; + } + + private function tenantUser(?Role $role): User + { + return Model::withoutEvents(function () use ($role): User { + $user = $this->user(); + $user->tenants()->attach($this->tenant, ['role_id' => $role?->id]); + + return $user; + }); + } + + private function environmentUser(Role $role): User + { + $user = $this->tenantUser(null); + $user->environments()->attach($this->environment, ['role_id' => $role->id]); + + return $user; + } + + private function user(): User + { + return User::query()->create([ + 'first_name' => 'Security', 'last_name' => 'Test', + 'email' => 'security-'.str()->ulid().'@example.test', + 'password' => 'original-password', + ]); + } + + private function admin(): User + { + $user = $this->tenantUser(null); + $user->roles()->attach($this->role(['*'])); + + return $user; + } + + private function tenantPath(User $user): string + { + return '/api/tenants/'.$this->tenant->id.'/users/'.$user->id; + } + + private function environmentPath(User $user): string + { + return '/api/tenants/'.$this->tenant->id.'/environments/'.$this->environment->id.'/users/'.$user->id; + } +} From 8d424257b2bd5b714948383f1d21a900436b6f14 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 22:32:50 +0200 Subject: [PATCH 08/11] Enforce atomic core resource quotas and validate plan dependencies --- .../0001_01_01_000096_create_quota_lock.php | 22 + packages/core/docs/quotas.md | 114 +++++ packages/core/docs/quotas.openapi.yaml | 125 +++++ .../src/Exceptions/ResourceLimitException.php | 5 +- .../Http/Controllers/Api/NodeController.php | 64 +-- .../Api/Tenant/Environment/UserController.php | 190 ++++---- .../Api/Tenant/EnvironmentController.php | 92 ++-- .../Controllers/Api/Tenant/NodeController.php | 34 +- .../Controllers/Api/Tenant/PlanController.php | 5 +- .../Controllers/Api/Tenant/UserController.php | 130 ++--- .../Http/Controllers/Api/TenantController.php | 11 + .../Http/Controllers/Api/UserController.php | 162 ++++--- packages/core/src/Models/EnvUsage.php | 2 +- packages/core/src/Models/Environment.php | 8 +- packages/core/src/Models/EnvironmentUser.php | 35 +- packages/core/src/Models/Node.php | 1 + packages/core/src/Models/Plan.php | 10 + packages/core/src/Models/Role.php | 1 + packages/core/src/Models/Tenant.php | 2 + packages/core/src/Models/TenantUsage.php | 2 +- packages/core/src/Models/TenantUser.php | 30 +- .../core/src/Observers/EnvUsageObserver.php | 7 +- .../src/Observers/EnvironmentObserver.php | 28 +- .../src/Observers/EnvironmentUserObserver.php | 29 ++ packages/core/src/Observers/NodeObserver.php | 15 +- .../src/Observers/TenantQuotaObserver.php | 56 +++ .../core/src/Observers/TenantUserObserver.php | 10 +- .../Traits/SavesWithinQuotaTransaction.php | 14 + .../src/Services/Traits/TracksTenantQuota.php | 26 + .../core/src/Support/AdministrationGuard.php | 4 + packages/core/src/Support/PlanAssignments.php | 212 ++++---- packages/core/src/Support/Quota.php | 122 +++++ packages/core/src/Support/Resource.php | 246 +++------- .../Fakes/BuildsResourceUsageFixtures.php | 62 +++ .../Feature/EnvironmentResourceUsageTest.php | 42 +- .../tests/Feature/NodeResourceUsageTest.php | 41 +- .../tests/Feature/QuotaConcurrencyTest.php | 102 ++++ .../core/tests/Feature/QuotaIntegrityTest.php | 452 ++++++++++++++++++ .../Feature/UserSecurityBoundariesTest.php | 6 +- 39 files changed, 1774 insertions(+), 745 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000096_create_quota_lock.php create mode 100644 packages/core/docs/quotas.md create mode 100644 packages/core/docs/quotas.openapi.yaml create mode 100644 packages/core/src/Observers/EnvironmentUserObserver.php create mode 100644 packages/core/src/Observers/TenantQuotaObserver.php create mode 100644 packages/core/src/Services/Traits/SavesWithinQuotaTransaction.php create mode 100644 packages/core/src/Services/Traits/TracksTenantQuota.php create mode 100644 packages/core/src/Support/Quota.php create mode 100644 packages/core/tests/Fakes/BuildsResourceUsageFixtures.php create mode 100644 packages/core/tests/Feature/QuotaConcurrencyTest.php create mode 100644 packages/core/tests/Feature/QuotaIntegrityTest.php diff --git a/packages/core/database/migrations/0001_01_01_000096_create_quota_lock.php b/packages/core/database/migrations/0001_01_01_000096_create_quota_lock.php new file mode 100644 index 0000000..c3d3115 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000096_create_quota_lock.php @@ -0,0 +1,22 @@ +unsignedTinyInteger('id')->primary(); + }); + DB::table('quota_locks')->insert(['id' => 1]); + } + + public function down(): void + { + Schema::dropIfExists('quota_locks'); + } +}; diff --git a/packages/core/docs/quotas.md b/packages/core/docs/quotas.md new file mode 100644 index 0000000..d5a71a4 --- /dev/null +++ b/packages/core/docs/quotas.md @@ -0,0 +1,114 @@ +# Plans and resource quotas + +## Contract + +A resource is identified by `(type, key)`, not by its key alone. `tenant:users` +and `environment:users` are separate resources. A missing limit or `0` disables +creation; `-1` means unlimited; positive integers are finite limits. Quotas count +objects/memberships, not bytes or sampled runtime consumption. + +Every booking must fit all applicable budgets: + +- Tenant resources: owner's total usage **plus child reservations**, and the + consuming user's optional personal plan (otherwise the tenant plan). +- Environment resources: aggregate usage across the owner's environments plus + child reservations, the environment's effective plan, and the environment + user's effective plan. An explicit tenant-user plan additionally limits that + user's usage across all of the customer's environments. +- An environment without a plan inherits its customer's plan. An environment + user without a personal plan inherits the effective environment plan. +- A child's plan reserves its limits in its immediate parent. Actual child + objects are booked only to the owner, not again to ancestors. The child tenant + itself consumes one `tenant:tenants` slot in its parent. +- The same user in two environments consumes two environment memberships. + Environment-only users still have a tenant membership; a null tenant role + grants no customer-wide permissions. + +Core books tenant-owned nodes, environments, plans, roles, child tenants and +tenant memberships. Environment memberships are booked separately. The creator +is the consuming user; subsequent idempotent bookings preserve that attribution. +Removal releases the matching booking. An existing account is not charged +globally: membership is the quota-bearing user resource. + +Plan edits and switches validate existing usage, omitted resources, child +reservations, owned plan templates and dependent explicit user/environment plans. +All affected reservations of a shared plan change together or roll back together. +An assigned plan cannot be deleted. Personal plans are caps, not reservations. +Permissions remain independent: a quota check never replaces a policy check. + +## Atomic operations and packages + +Register resource models using `IsResource` plus `IsTenantResource` and/or +`IsEnvironmentResource` and `ResourceRegistry::registerModel(...)` (standard +package model discovery also uses these markers). Registry registration alone +does **not** implement booking for a package model. + +Create the object and book it inside the same transaction. Example for an +environment resource in an external package: + +```php +Gate::authorize('create', [Mailbox::class, $environment]); + +$mailbox = Resource::transaction(function () use ($environment, $actor, $data) { + $mailbox = $environment->mailboxes()->create($data); + Resource::addEnvironmentUsage($environment, $mailbox, $actor); + return $mailbox; +}); + +ProvisionMailbox::dispatch($mailbox)->afterCommit(); +``` + +`Resource` above is `Froxlor\Core\Support\Resource`. For tenant resources use +`addUsage($tenant, $object, $actor)`. Use `removeEnvironmentUsage` / `removeUsage` +in the matching database deletion transaction. Bookings/removals are idempotent +per owner, key and object. `hasUsageAvailable` and model availability helpers are +advisory; booking always rechecks. Background work must supply an actor explicitly. + +Core model saves and synchronous observers share a transaction through +`SavesWithinQuotaTransaction`. HTTP creation flows additionally include account +and pivot creation so rejection leaves no orphan account/object. Unauthenticated +bootstrap creation of nodes/environments/metadata does not automatically book an +actor; package jobs must use the explicit facade contract. Do not use quiet saves, +bulk updates, raw ledger writes or direct plan-resource pivot writes to implement +quota-bearing application operations. + +Use `PlanAssignments::updatePlanResourceLimit` / `removePlanResource` for plan +limits. Extensions performing assignment changes must validate and persist them +inside `Resource::transaction`; standalone `ensure*` calls do not reserve capacity. + +The `quota_locks` row serializes quota transactions across workers. Always acquire +it before reading budgets or modifying quota-bearing rows. This deliberately +conservative global mutex favors correctness over write throughput; transactions +must be short. Never perform SSH, network provisioning or other external effects +under the quota lock. Queue infrastructure work after commit. If an operation also +needs `AdministrationGuard`, acquire the administration guard first, then quota. + +If a package already owns an outer database transaction, that caller owns retries +of the **entire** operation. In particular, MariaDB can reject a locking read after +an older snapshot with error 1020. Use Laravel's bounded outer transaction retry +for replay-safe callbacks; retrying just an inner savepoint is insufficient. Do +not replay network effects or reuse mutated Eloquent instances from a failed attempt. + +## API and rollout + +Quota exhaustion returns HTTP 422 with `errors.resources`; invalid plan edits or +assignments return 422 with their validation field. See `quotas.openapi.yaml` for +the focused plan-resource API and shared error schema. Existing authorization +and request-field contracts remain in effect. + +Apply migration `0001_01_01_000096_create_quota_lock.php` before deploying the +new code. This migration creates only the quota mutex. It does not fabricate +historical creators or rewrite old usage ledgers. Before applying finite limits +to an existing installation, reconcile historical unbooked objects/memberships +and old ancestor double-bookings against actual ownership. Do not infer missing +creators or silently discard historical data. Bootstrap/quiet/raw writes are +outside automatic accounting and must be explicitly accounted for when importing. + +## Verification + +Use Docker/MariaDB, not SQLite. Focused suites: `QuotaIntegrityTest`, +`QuotaConcurrencyTest`, `NodeResourceUsageTest`, `EnvironmentResourceUsageTest` +and `UserSecurityBoundariesTest`. They use owned fixtures and rollbacks; +the two-process test needs committed fixtures and removes only its exact IDs in +`finally`. It verifies one successful booking and one rejection even when both +processes start with an old snapshot. No seed reset is required. diff --git a/packages/core/docs/quotas.openapi.yaml b/packages/core/docs/quotas.openapi.yaml new file mode 100644 index 0000000..7c19a26 --- /dev/null +++ b/packages/core/docs/quotas.openapi.yaml @@ -0,0 +1,125 @@ +openapi: 3.1.0 +info: + title: froxlor Core plan-resource quotas + version: 1.0.0 + description: Focused quota mutation contract, not the complete Core API. +security: + - bearerAuth: [] +paths: + /api/plans/{plan}/resources: + parameters: + - $ref: '#/components/parameters/Plan' + post: &setLimit + summary: Assign or update a resource limit + description: >- + Requires the matching plan resource store permission. Validates all + assignments and dependent contracts. All affected reservations are + updated atomically. Tenant-owned plans must fit their owner's plan. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [resource_id, limit] + properties: + resource_id: + type: string + description: ULID identifying one resource key and scope. + limit: + type: integer + minimum: -1 + description: '-1 unlimited, 0 unavailable, positive finite object count.' + responses: + '200': + description: Assigned resources wrapped in data. + '401': + description: Authentication required. + '403': + description: Missing authorization or wrong plan scope. + '404': + description: Plan not found. + '422': + $ref: '#/components/responses/QuotaValidation' + /api/tenants/{tenant}/plans/{plan}/resources: + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Plan' + post: *setLimit + /api/plans/{plan}/resources/{resource}: + parameters: + - $ref: '#/components/parameters/Plan' + - $ref: '#/components/parameters/Resource' + delete: &removeLimit + summary: Remove a resource limit + description: >- + Requires the matching plan resource destroy permission. Removal means + unavailable and is rejected while usage or a dependent contract requires + this resource. Rejection rolls back the plan and reservations. + responses: + '200': + description: Remaining assigned resources wrapped in data. + '401': + description: Authentication required. + '403': + description: Missing authorization or wrong plan scope. + '404': + description: Plan or resource not found. + '422': + $ref: '#/components/responses/QuotaValidation' + /api/tenants/{tenant}/plans/{plan}/resources/{resource}: + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Plan' + - $ref: '#/components/parameters/Resource' + delete: *removeLimit +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + Tenant: + name: tenant + in: path + required: true + schema: {type: string} + Plan: + name: plan + in: path + required: true + schema: {type: string} + Resource: + name: resource + in: path + required: true + schema: {type: string} + responses: + QuotaValidation: + description: Invalid limit or incompatible usage, assignment or dependent plan. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + QuotaExceeded: + description: A resource creation exceeded an applicable quota; database creation is rolled back. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + example: + message: Resource limit exceeded (users) + errors: + resources: ['Resource limit exceeded (users)'] + schemas: + ValidationError: + type: object + required: [message, errors] + properties: + message: + type: string + errors: + type: object + additionalProperties: + type: array + items: {type: string} diff --git a/packages/core/src/Exceptions/ResourceLimitException.php b/packages/core/src/Exceptions/ResourceLimitException.php index 1c31f7a..0324c11 100644 --- a/packages/core/src/Exceptions/ResourceLimitException.php +++ b/packages/core/src/Exceptions/ResourceLimitException.php @@ -6,5 +6,8 @@ class ResourceLimitException extends Exception { - // + public function render($request) + { + return response()->json(['message' => $this->getMessage(), 'errors' => ['resources' => [$this->getMessage()]]], 422); + } } diff --git a/packages/core/src/Http/Controllers/Api/NodeController.php b/packages/core/src/Http/Controllers/Api/NodeController.php index ec06a87..be925f7 100644 --- a/packages/core/src/Http/Controllers/Api/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/NodeController.php @@ -35,39 +35,41 @@ public function index() */ public function store(StoreNodeRequest $request) { - Gate::authorize('create', Node::class); - - // get validated data only for ourselves - $nodeData = $request->validatedResource(); - $inheritable = (bool)($nodeData['inheritable'] ?? false); - unset($nodeData['inheritable']); - $nodeData = $this->normalizeNodeProperties($nodeData); - - $tenant = null; - if (!empty($nodeData['tenant_id'])) { - $tenant = Tenant::query()->findOrFail($nodeData['tenant_id']); - Gate::authorize('view', $tenant); - } - - // create resource - $node = Node::query()->create($nodeData); - if ($tenant !== null) { - $node->tenants()->syncWithoutDetaching([ - $tenant->id => ['inheritable' => $inheritable], + return \Froxlor\Core\Support\Quota::transaction(function () use ($request) { + Gate::authorize('create', Node::class); + + // get validated data only for ourselves + $nodeData = $request->validatedResource(); + $inheritable = (bool)($nodeData['inheritable'] ?? false); + unset($nodeData['inheritable']); + $nodeData = $this->normalizeNodeProperties($nodeData); + + $tenant = null; + if (!empty($nodeData['tenant_id'])) { + $tenant = Tenant::query()->findOrFail($nodeData['tenant_id']); + Gate::authorize('view', $tenant); + } + + // create resource + $node = Node::query()->create($nodeData); + if ($tenant !== null) { + $node->tenants()->syncWithoutDetaching([ + $tenant->id => ['inheritable' => $inheritable], + ]); + } + // build up validated data for others + $eventData = $this->validatedEventData($request); + // throw event that resource was created and append validated data + event(new ResourceCreated($node, $eventData)); + Audit::notice('node "' . $node->name . '" created', $node->tenant, context: [ + 'node_id' => $node->id, ]); - } - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($node, $eventData)); - Audit::notice('node "' . $node->name . '" created', $node->tenant, context: [ - 'node_id' => $node->id, - ]); - // run explore-node job - dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); + // run explore-node job + dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); - // return resource - return Response::jsonResource($node->refresh()); + // return resource + return Response::jsonResource($node->refresh()); + }); } /** diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index 27ecbae..5dddb2e 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -35,37 +35,39 @@ public function index(Request $request, Tenant $tenant, Environment $environment */ public function store(StoreEnvironmentUserRequest $request, Tenant $tenant, Environment $environment) { - Gate::authorize('tenantEnvCreate', [User::class, $tenant, $environment]); - - if ($environment->userHasResourceAvailable($request->user(), User::getResourceKey())) { - - // get validated data only for ourselves - $userData = $request->validatedResource(); - $tenant_role = $this->getNonModelRequestData('tenant_role', $userData); - $tenant_plan = $this->getNonModelRequestData('tenant_plan', $userData); - $env_role = $this->getNonModelRequestData('environment_role', $userData); - $env_plan = $this->getNonModelRequestData('environment_plan', $userData); - - RoleAssignments::ensureAssignable($request->user(), $tenant_role, 'tenant_role', $tenant); - RoleAssignments::ensureAssignable($request->user(), $env_role, 'environment_role', $tenant, $environment); - PlanAssignments::ensureAssignableToTenantUser($tenant_plan, $tenant, 'tenant_plan'); - PlanAssignments::ensureAssignableToEnvironmentUser($env_plan, $tenant, $environment); - - // create resource - $user = User::query()->create($userData); - $tenant->users()->attach($user, ['role_id' => $tenant_role, 'plan_id' => $tenant_plan]); - // connect environment - $user->environments()->attach($environment, ['role_id' => $env_role, 'plan_id' => $env_plan]); - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($user, $eventData)); - - Audit::notice('user "' . $user->email . '" created', $tenant, $environment); - // return resource - return Response::jsonResource($user->refresh()); - } - return response()->json(['error' => 'Unsufficient resources'], 406); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant, $environment) { + Gate::authorize('tenantEnvCreate', [User::class, $tenant, $environment]); + + if ($environment->userHasResourceAvailable($request->user(), User::getResourceKey())) { + + // get validated data only for ourselves + $userData = $request->validatedResource(); + $tenant_role = $this->getNonModelRequestData('tenant_role', $userData); + $tenant_plan = $this->getNonModelRequestData('tenant_plan', $userData); + $env_role = $this->getNonModelRequestData('environment_role', $userData); + $env_plan = $this->getNonModelRequestData('environment_plan', $userData); + + RoleAssignments::ensureAssignable($request->user(), $tenant_role, 'tenant_role', $tenant); + RoleAssignments::ensureAssignable($request->user(), $env_role, 'environment_role', $tenant, $environment); + PlanAssignments::ensureAssignableToTenantUser($tenant_plan, $tenant, 'tenant_plan'); + PlanAssignments::ensureAssignableToEnvironmentUser($env_plan, $tenant, $environment); + + // create resource + $user = User::query()->create($userData); + $tenant->users()->attach($user, ['role_id' => $tenant_role, 'plan_id' => $tenant_plan]); + // connect environment + $user->environments()->attach($environment, ['role_id' => $env_role, 'plan_id' => $env_plan]); + // build up validated data for others + $eventData = $this->validatedEventData($request); + // throw event that resource was created and append validated data + event(new ResourceCreated($user, $eventData)); + + Audit::notice('user "' . $user->email . '" created', $tenant, $environment); + // return resource + return Response::jsonResource($user->refresh()); + } + return response()->json(['error' => 'Unsufficient resources'], 406); + }); } /** @@ -83,70 +85,72 @@ public function show(Request $request, Tenant $tenant, Environment $environment, */ public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, Environment $environment, User $user) { - Gate::authorize('tenantEnvUpdate', [$user, $tenant, $environment]); - - $userData = $request->validated(); - $tenantRoleProvided = $request->exists('tenant_role'); - $environmentRoleProvided = $request->exists('environment_role'); - $tenantRoleId = $this->getNonModelRequestData('tenant_role', $userData); - $tenantPlanProvided = $request->has('tenant_plan'); - $tenantPlanId = $this->getNonModelRequestData('tenant_plan', $userData); - $environmentRoleId = $this->getNonModelRequestData('environment_role', $userData); - $environmentPlanProvided = $request->has('environment_plan'); - $environmentPlanId = $this->getNonModelRequestData('environment_plan', $userData); - - // Environment membership administration alone cannot mutate tenant privileges. - if ($tenantRoleProvided || $tenantPlanProvided) { - Gate::authorize('tenantUpdate', [$user, $tenant]); - } - - if (!empty($tenantRoleId)) { - RoleAssignments::ensureAssignable($request->user(), $tenantRoleId, 'tenant_role', $tenant); - } - if (!empty($environmentRoleId)) { - RoleAssignments::ensureAssignable($request->user(), $environmentRoleId, 'environment_role', $tenant, $environment); - } - if ($tenantPlanProvided) { - PlanAssignments::ensureAssignableToTenantUser($tenantPlanId, $tenant, 'tenant_plan', $user->id); - } - if ($environmentPlanProvided) { - PlanAssignments::ensureAssignableToEnvironmentUser($environmentPlanId, $tenant, $environment, 'environment_plan', $user->id); - } - - $user->update($userData); - - $tenantPivotData = []; - if ($tenantRoleProvided) { - $tenantPivotData['role_id'] = $tenantRoleId; - } - if ($tenantPlanProvided) { - $tenantPivotData['plan_id'] = $tenantPlanId; - } - if ($tenantPivotData !== []) { - $user->tenants()->syncWithoutDetaching([ - $tenant->id => $tenantPivotData, - ]); - } - - $environmentPivotData = []; - if ($environmentRoleProvided) { - $environmentPivotData['role_id'] = $environmentRoleId; - } - if ($environmentPlanProvided) { - $environmentPivotData['plan_id'] = $environmentPlanId; - } - if ($environmentPivotData !== []) { - $user->environments()->syncWithoutDetaching([ - $environment->id => $environmentPivotData, + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant, $environment, $user) { + Gate::authorize('tenantEnvUpdate', [$user, $tenant, $environment]); + + $userData = $request->validated(); + $tenantRoleProvided = $request->exists('tenant_role'); + $environmentRoleProvided = $request->exists('environment_role'); + $tenantRoleId = $this->getNonModelRequestData('tenant_role', $userData); + $tenantPlanProvided = $request->has('tenant_plan'); + $tenantPlanId = $this->getNonModelRequestData('tenant_plan', $userData); + $environmentRoleId = $this->getNonModelRequestData('environment_role', $userData); + $environmentPlanProvided = $request->has('environment_plan'); + $environmentPlanId = $this->getNonModelRequestData('environment_plan', $userData); + + // Environment membership administration alone cannot mutate tenant privileges. + if ($tenantRoleProvided || $tenantPlanProvided) { + Gate::authorize('tenantUpdate', [$user, $tenant]); + } + + if (!empty($tenantRoleId)) { + RoleAssignments::ensureAssignable($request->user(), $tenantRoleId, 'tenant_role', $tenant); + } + if (!empty($environmentRoleId)) { + RoleAssignments::ensureAssignable($request->user(), $environmentRoleId, 'environment_role', $tenant, $environment); + } + if ($tenantPlanProvided) { + PlanAssignments::ensureAssignableToTenantUser($tenantPlanId, $tenant, 'tenant_plan', $user->id); + } + if ($environmentPlanProvided) { + PlanAssignments::ensureAssignableToEnvironmentUser($environmentPlanId, $tenant, $environment, 'environment_plan', $user->id); + } + + $user->update($userData); + + $tenantPivotData = []; + if ($tenantRoleProvided) { + $tenantPivotData['role_id'] = $tenantRoleId; + } + if ($tenantPlanProvided) { + $tenantPivotData['plan_id'] = $tenantPlanId; + } + if ($tenantPivotData !== []) { + $user->tenants()->syncWithoutDetaching([ + $tenant->id => $tenantPivotData, + ]); + } + + $environmentPivotData = []; + if ($environmentRoleProvided) { + $environmentPivotData['role_id'] = $environmentRoleId; + } + if ($environmentPlanProvided) { + $environmentPivotData['plan_id'] = $environmentPlanId; + } + if ($environmentPivotData !== []) { + $user->environments()->syncWithoutDetaching([ + $environment->id => $environmentPivotData, + ]); + } + + event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $tenant, $environment, context: [ + 'user_id' => $user->id, ]); - } - - event(new ResourceUpdated($user, $this->validatedEventData($request))); - Audit::info('user "' . $user->email . '" updated', $tenant, $environment, context: [ - 'user_id' => $user->id, - ]); - return Response::jsonResource($user->refresh()); + return Response::jsonResource($user->refresh()); + }); } /** diff --git a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php index 65f6c23..77365d4 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php @@ -36,32 +36,34 @@ public function index(Request $request, Tenant $tenant) */ public function store(StoreEnvironmentRequest $request, Tenant $tenant) { - Gate::authorize('tenantCreate', [Environment::class, $tenant]); - - // get validated data only for ourselves - $envData = $request->validatedResource(); - // fixed values - $envData['tenant_id'] = $tenant->id; - // non-model values - $node_id = $this->getNonModelRequestData('node_id', $envData); - PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'] ?? null, $tenant); - // create resource - $env = Environment::query()->create($envData); - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($env, $eventData)); - Audit::notice('environment "' . $env->name . '" created', $tenant, $env, [ - 'plan_id' => $env->plan_id, - ]); - // connect to node and create environment if given - if (!empty($node_id)) { - $node = $this->nodeForTenant($node_id, $tenant); - dispatch(new CreateEnvironment($env->refresh(), $node)); - } - - // return resource - return Response::jsonResource($env->refresh()); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant) { + Gate::authorize('tenantCreate', [Environment::class, $tenant]); + + // get validated data only for ourselves + $envData = $request->validatedResource(); + // fixed values + $envData['tenant_id'] = $tenant->id; + // non-model values + $node_id = $this->getNonModelRequestData('node_id', $envData); + PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'] ?? null, $tenant); + // create resource + $env = Environment::query()->create($envData); + // build up validated data for others + $eventData = $this->validatedEventData($request); + // throw event that resource was created and append validated data + event(new ResourceCreated($env, $eventData)); + Audit::notice('environment "' . $env->name . '" created', $tenant, $env, [ + 'plan_id' => $env->plan_id, + ]); + // connect to node and create environment if given + if (!empty($node_id)) { + $node = $this->nodeForTenant($node_id, $tenant); + dispatch((new CreateEnvironment($env->refresh(), $node))->afterCommit()); + } + + // return resource + return Response::jsonResource($env->refresh()); + }); } /** @@ -79,26 +81,28 @@ public function show(Request $request, Tenant $tenant, Environment $environment) */ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Environment $environment) { - Gate::authorize('tenantUpdate', [$environment, $tenant]); - - $envData = $request->validated(); - $nodeId = $this->getNonModelRequestData('node_id', $envData); - if (array_key_exists('plan_id', $envData)) { - PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'], $tenant, 'plan_id', $environment); - } - - $environment->update($envData); - event(new ResourceUpdated($environment, $this->validatedEventData($request))); - Audit::info('environment "' . $environment->name . '" updated', $tenant, $environment, [ - 'plan_id' => $environment->plan_id, - ]); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant, $environment) { + Gate::authorize('tenantUpdate', [$environment, $tenant]); + + $envData = $request->validated(); + $nodeId = $this->getNonModelRequestData('node_id', $envData); + if (array_key_exists('plan_id', $envData)) { + PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'], $tenant, 'plan_id', $environment); + } + + $environment->update($envData); + event(new ResourceUpdated($environment, $this->validatedEventData($request))); + Audit::info('environment "' . $environment->name . '" updated', $tenant, $environment, [ + 'plan_id' => $environment->plan_id, + ]); - if (!empty($nodeId)) { - $node = $this->nodeForTenant($nodeId, $tenant); - dispatch(new CreateEnvironment($environment->refresh(), $node)); - } + if (!empty($nodeId)) { + $node = $this->nodeForTenant($nodeId, $tenant); + dispatch((new CreateEnvironment($environment->refresh(), $node))->afterCommit()); + } - return Response::jsonResource($environment->refresh()); + return Response::jsonResource($environment->refresh()); + }); } /** diff --git a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php index e85c754..06c25e0 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/NodeController.php @@ -39,27 +39,29 @@ public function index(Tenant $tenant) */ public function store(StoreNodeRequest $request, Tenant $tenant) { - Gate::authorize('tenantCreate', [Node::class, $tenant]); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant) { + Gate::authorize('tenantCreate', [Node::class, $tenant]); - $nodeData = $request->validatedResource(); - $inheritable = (bool)($nodeData['inheritable'] ?? false); - unset($nodeData['tenant_id'], $nodeData['inheritable']); - $nodeData['tenant_id'] = $tenant->id; - $nodeData = $this->normalizeNodeProperties($nodeData); + $nodeData = $request->validatedResource(); + $inheritable = (bool)($nodeData['inheritable'] ?? false); + unset($nodeData['tenant_id'], $nodeData['inheritable']); + $nodeData['tenant_id'] = $tenant->id; + $nodeData = $this->normalizeNodeProperties($nodeData); - $node = Node::query()->create($nodeData); - $node->tenants()->syncWithoutDetaching([ - $tenant->id => ['inheritable' => $inheritable], - ]); + $node = Node::query()->create($nodeData); + $node->tenants()->syncWithoutDetaching([ + $tenant->id => ['inheritable' => $inheritable], + ]); - event(new ResourceCreated($node, $this->validatedEventData($request))); - Audit::notice('node "' . $node->name . '" created', $tenant, context: [ - 'node_id' => $node->id, - ]); + event(new ResourceCreated($node, $this->validatedEventData($request))); + Audit::notice('node "' . $node->name . '" created', $tenant, context: [ + 'node_id' => $node->id, + ]); - dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); + dispatch((new ExploreNode($node, true, $request->user()->id))->afterCommit()); - return Response::jsonResource($node->refresh()); + return Response::jsonResource($node->refresh()); + }); } /** diff --git a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php index b65ab8b..44840bc 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php @@ -62,11 +62,10 @@ public function show(Request $request, Tenant $tenant, Plan $plan) { Gate::authorize('tenantView', [$plan, $tenant]); - $resourceUsages = $tenant->tenantUsageList; $plan->load('resources'); - $plan = $plan->resources->map(function ($resource) use ($resourceUsages) { - $resource->used = $resourceUsages[$resource->key] ?? 0; + $plan = $plan->resources->map(function ($resource) use ($tenant) { + $resource->used = \Froxlor\Core\Support\Quota::tenantUsed($tenant->id, $resource->key, $resource->type); return $resource; }); diff --git a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index 069cf33..e58b623 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -37,33 +37,35 @@ public function index(Request $request, Tenant $tenant) */ public function store(StoreTenantUserRequest $request, Tenant $tenant) { - Gate::authorize('tenantCreate', [User::class, $tenant]); - - if ($tenant->userHasResourceAvailable($request->user(), User::getResourceKey())) { - - // get validated data only for ourselves - $userData = $request->validatedResource(); - $role = $this->getNonModelRequestData('role_id', $userData) - ?? $this->getNonModelRequestData('role', $userData); - $plan = $this->getNonModelRequestData('plan_id', $userData) - ?? $this->getNonModelRequestData('plan', $userData); - - RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $tenant); - PlanAssignments::ensureAssignableToTenantUser($plan, $tenant); - - // create resource - $user = User::query()->create($userData); - $tenant->users()->attach($user, ['role_id' => $role, 'plan_id' => $plan]); - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($user, $eventData)); - - Audit::notice('user "' . $user->email . '" created', $tenant); - // return resource - return Response::jsonResource($user->refresh()); - } - return response()->json(['error' => 'Unsufficient resources'], 406); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant) { + Gate::authorize('tenantCreate', [User::class, $tenant]); + + if ($tenant->userHasResourceAvailable($request->user(), User::getResourceKey())) { + + // get validated data only for ourselves + $userData = $request->validatedResource(); + $role = $this->getNonModelRequestData('role_id', $userData) + ?? $this->getNonModelRequestData('role', $userData); + $plan = $this->getNonModelRequestData('plan_id', $userData) + ?? $this->getNonModelRequestData('plan', $userData); + + RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $tenant); + PlanAssignments::ensureAssignableToTenantUser($plan, $tenant); + + // create resource + $user = User::query()->create($userData); + $tenant->users()->attach($user, ['role_id' => $role, 'plan_id' => $plan]); + // build up validated data for others + $eventData = $this->validatedEventData($request); + // throw event that resource was created and append validated data + event(new ResourceCreated($user, $eventData)); + + Audit::notice('user "' . $user->email . '" created', $tenant); + // return resource + return Response::jsonResource($user->refresh()); + } + return response()->json(['error' => 'Unsufficient resources'], 406); + }); } /** @@ -97,46 +99,48 @@ public function show(Request $request, Tenant $tenant, User $user) */ public function update(UpdateTenantUserRequest $request, Tenant $tenant, User $user) { - Gate::authorize('tenantUpdate', [$user, $tenant]); - - $userData = $request->validated(); - $roleProvided = $request->exists('role_id') || $request->exists('role'); - unset($userData['tenant_id']); - $roleId = $this->getNonModelRequestData('role_id', $userData) - ?? $this->getNonModelRequestData('role', $userData); - $planProvided = $request->has('plan'); - if ($request->has('plan_id')) { - $planProvided = true; - } - $planId = $this->getNonModelRequestData('plan_id', $userData) - ?? $this->getNonModelRequestData('plan', $userData); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $tenant, $user) { + Gate::authorize('tenantUpdate', [$user, $tenant]); - RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $tenant); - if ($planProvided) { - PlanAssignments::ensureAssignableToTenantUser($planId, $tenant, 'plan_id', $user->id); - } - - $user->update($userData); + $userData = $request->validated(); + $roleProvided = $request->exists('role_id') || $request->exists('role'); + unset($userData['tenant_id']); + $roleId = $this->getNonModelRequestData('role_id', $userData) + ?? $this->getNonModelRequestData('role', $userData); + $planProvided = $request->has('plan'); + if ($request->has('plan_id')) { + $planProvided = true; + } + $planId = $this->getNonModelRequestData('plan_id', $userData) + ?? $this->getNonModelRequestData('plan', $userData); - $pivotData = []; - if ($roleProvided) { - $pivotData['role_id'] = $roleId; - } - if ($planProvided) { - $pivotData['plan_id'] = $planId; - } - if ($pivotData !== []) { - $user->tenants()->syncWithoutDetaching([ - $tenant->id => $pivotData, + RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $tenant); + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $tenant, 'plan_id', $user->id); + } + + $user->update($userData); + + $pivotData = []; + if ($roleProvided) { + $pivotData['role_id'] = $roleId; + } + if ($planProvided) { + $pivotData['plan_id'] = $planId; + } + if ($pivotData !== []) { + $user->tenants()->syncWithoutDetaching([ + $tenant->id => $pivotData, + ]); + } + + event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $tenant, context: [ + 'user_id' => $user->id, ]); - } - event(new ResourceUpdated($user, $this->validatedEventData($request))); - Audit::info('user "' . $user->email . '" updated', $tenant, context: [ - 'user_id' => $user->id, - ]); - - return Response::jsonResource($user->refresh()); + return Response::jsonResource($user->refresh()); + }); } /** diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 4572d56..24cfecc 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -118,6 +118,16 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) $oldParentTenant = $tenant->parentTenant; AdministrationGuard::run(function () use ($tenant, $tenantData, $oldParentTenant, $parentTenant, $plan): void { + \Froxlor\Core\Support\Quota::lock(); + $tenant = Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail(); + $oldParentTenant = $tenant->parentTenant()->lockForUpdate()->first(); + $parentTenant = array_key_exists('parent_tenant_id', $tenantData) + ? Tenant::query()->whereKey($tenantData['parent_tenant_id'])->lockForUpdate()->first() + : $oldParentTenant; + $plan = Plan::query()->whereKey($tenantData['plan_id'] ?? $tenant->plan_id)->lockForUpdate()->firstOrFail(); + if (!$tenant->canHaveParent($parentTenant)) { + throw ValidationException::withMessages(['parent_tenant_id' => 'Invalid tenant parent.']); + } Gate::authorize('update', $tenant); if ($oldParentTenant !== null && ($parentTenant === null || $oldParentTenant->id !== $parentTenant->id)) { PlanAssignments::lockTenantBudget($oldParentTenant); @@ -164,6 +174,7 @@ public function destroy(Tenant $tenant) $parentTenant = $tenant->parentTenant; AdministrationGuard::run(function () use ($tenant): void { + \Froxlor\Core\Support\Quota::lock(); Gate::authorize('delete', $tenant); $tenant->delete(); }); diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index f97297f..1b8a82b 100644 --- a/packages/core/src/Http/Controllers/Api/UserController.php +++ b/packages/core/src/Http/Controllers/Api/UserController.php @@ -39,45 +39,47 @@ public function index(Request $request) */ public function store(StoreUserRequest $request) { - Gate::authorize('create', User::class); - - // get validated data only for ourselves - $userData = $request->validatedResource(); - // fixed values - if (empty($userData['tenant_id'])) { - $targetTenant = $request->user()?->tenants()?->first(); - } else { - // validate that selected tenant can be used - $targetTenant = Tenant::query()->where('id', $userData['tenant_id'])->first(); - if (!$targetTenant->exists()) { - return $this->errorResponse('No target tenant found for user assignment.', 404); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request) { + Gate::authorize('create', User::class); + + // get validated data only for ourselves + $userData = $request->validatedResource(); + // fixed values + if (empty($userData['tenant_id'])) { + $targetTenant = $request->user()?->tenants()?->first(); + } else { + // validate that selected tenant can be used + $targetTenant = Tenant::query()->where('id', $userData['tenant_id'])->first(); + if (!$targetTenant->exists()) { + return $this->errorResponse('No target tenant found for user assignment.', 404); + } } - } - if (!$targetTenant) { - return $this->errorResponse('No target tenant found for user assignment.', 422); - } - $this->getNonModelRequestData('tenant_id', $userData); - $role = $this->getNonModelRequestData('role_id', $userData) - ?? $this->getNonModelRequestData('role', $userData); - $plan = $this->getNonModelRequestData('plan_id', $userData) - ?? $this->getNonModelRequestData('plan', $userData); - - RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $targetTenant); - PlanAssignments::ensureAssignableToTenantUser($plan, $targetTenant); - - // create resource - $user = User::query()->create($userData); - $targetTenant->users()->attach($user, ['role_id' => $role, 'plan_id' => $plan]); - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($user, $eventData)); - Audit::notice('user "' . $user->email . '" created', $targetTenant, context: [ - 'user_id' => $user->id, - ]); - - // return resource - return Response::jsonResource($user->refresh()); + if (!$targetTenant) { + return $this->errorResponse('No target tenant found for user assignment.', 422); + } + $this->getNonModelRequestData('tenant_id', $userData); + $role = $this->getNonModelRequestData('role_id', $userData) + ?? $this->getNonModelRequestData('role', $userData); + $plan = $this->getNonModelRequestData('plan_id', $userData) + ?? $this->getNonModelRequestData('plan', $userData); + + RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $targetTenant); + PlanAssignments::ensureAssignableToTenantUser($plan, $targetTenant); + + // create resource + $user = User::query()->create($userData); + $targetTenant->users()->attach($user, ['role_id' => $role, 'plan_id' => $plan]); + // build up validated data for others + $eventData = $this->validatedEventData($request); + // throw event that resource was created and append validated data + event(new ResourceCreated($user, $eventData)); + Audit::notice('user "' . $user->email . '" created', $targetTenant, context: [ + 'user_id' => $user->id, + ]); + + // return resource + return Response::jsonResource($user->refresh()); + }); } /** @@ -111,53 +113,55 @@ public function show(User $user) */ public function update(UpdateUserRequest $request, User $user) { - Gate::authorize('update', $user); - - $userData = $request->validated(); - $tenantId = $this->getNonModelRequestData('tenant_id', $userData); - $targetTenant = null; - $roleId = $this->getNonModelRequestData('role_id', $userData) - ?? $this->getNonModelRequestData('role', $userData); - $planProvided = $request->has('plan'); - if ($request->has('plan_id')) { - $planProvided = true; - } - $planId = $this->getNonModelRequestData('plan_id', $userData) - ?? $this->getNonModelRequestData('plan', $userData); - - if ($tenantId) { - $targetTenant = Tenant::query()->findOrFail($tenantId); - if ($roleId) { - RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $targetTenant); + return \Froxlor\Core\Support\Quota::transaction(function () use ($request, $user) { + Gate::authorize('update', $user); + + $userData = $request->validated(); + $tenantId = $this->getNonModelRequestData('tenant_id', $userData); + $targetTenant = null; + $roleId = $this->getNonModelRequestData('role_id', $userData) + ?? $this->getNonModelRequestData('role', $userData); + $planProvided = $request->has('plan'); + if ($request->has('plan_id')) { + $planProvided = true; } - if ($planProvided) { - PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant, 'plan_id', $user->id); + $planId = $this->getNonModelRequestData('plan_id', $userData) + ?? $this->getNonModelRequestData('plan', $userData); + + if ($tenantId) { + $targetTenant = Tenant::query()->findOrFail($tenantId); + if ($roleId) { + RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $targetTenant); + } + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant, 'plan_id', $user->id); + } } - } - - $user->update($userData); - if ($tenantId) { - $pivotData = []; - if (!empty($roleId)) { - $pivotData['role_id'] = $roleId; - } - if ($planProvided) { - $pivotData['plan_id'] = $planId; + $user->update($userData); + + if ($tenantId) { + $pivotData = []; + if (!empty($roleId)) { + $pivotData['role_id'] = $roleId; + } + if ($planProvided) { + $pivotData['plan_id'] = $planId; + } + if ($pivotData !== []) { + $user->tenants()->syncWithoutDetaching([ + $tenantId => $pivotData, + ]); + } } - if ($pivotData !== []) { - $user->tenants()->syncWithoutDetaching([ - $tenantId => $pivotData, - ]); - } - } - event(new ResourceUpdated($user, $this->validatedEventData($request))); - Audit::info('user "' . $user->email . '" updated', $targetTenant ?? $user->tenants()->first(), context: [ - 'user_id' => $user->id, - ]); + event(new ResourceUpdated($user, $this->validatedEventData($request))); + Audit::info('user "' . $user->email . '" updated', $targetTenant ?? $user->tenants()->first(), context: [ + 'user_id' => $user->id, + ]); - return Response::jsonResource($user); + return Response::jsonResource($user); + }); } /** diff --git a/packages/core/src/Models/EnvUsage.php b/packages/core/src/Models/EnvUsage.php index 3a9d3b6..2117702 100644 --- a/packages/core/src/Models/EnvUsage.php +++ b/packages/core/src/Models/EnvUsage.php @@ -46,7 +46,7 @@ public function resource(): Attribute abort(404, 'Given resource-type could not be found'); } return Attribute::make( - get: fn() => $resource_fqcn::query()->find($this->resource_id)->first(), + get: fn() => $resource_fqcn::query()->find($this->resource_id), ); } } diff --git a/packages/core/src/Models/Environment.php b/packages/core/src/Models/Environment.php index 585aba1..6f1aaa3 100644 --- a/packages/core/src/Models/Environment.php +++ b/packages/core/src/Models/Environment.php @@ -36,6 +36,7 @@ #[ObservedBy(EnvironmentObserver::class)] class Environment extends Model { + use \Froxlor\Core\Services\Traits\SavesWithinQuotaTransaction; use HasUlids, IsResource, IsTenantResource, HasPermissions; protected $guarded = []; @@ -89,12 +90,7 @@ public function envUsageList(): Attribute */ public function userHasResourceAvailable(User $user, string $resource): bool { - /** @var EnvironmentUser $pivot */ - $pivot = $this->users()->where('user_id', $user->id)->first(); - if (empty($pivot)) { - throw new UnknownEnvironmentUserException("Unknown environment users"); - } - return $pivot->pivot->hasResourceAvailable($resource); + return \Froxlor\Core\Support\Quota::environmentAvailable($this, $resource, $user); } /** diff --git a/packages/core/src/Models/EnvironmentUser.php b/packages/core/src/Models/EnvironmentUser.php index 388ba35..c1d454d 100644 --- a/packages/core/src/Models/EnvironmentUser.php +++ b/packages/core/src/Models/EnvironmentUser.php @@ -3,7 +3,6 @@ namespace Froxlor\Core\Models; use Froxlor\Core\Services\Traits\CanDelegatePermissions; -use Froxlor\Core\Support\Resource; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; @@ -19,8 +18,10 @@ * @property Role|null $role * @property Plan|null $plan */ +#[\Illuminate\Database\Eloquent\Attributes\ObservedBy(\Froxlor\Core\Observers\EnvironmentUserObserver::class)] class EnvironmentUser extends Pivot { + use \Froxlor\Core\Services\Traits\SavesWithinQuotaTransaction; use HasUlids, CanDelegatePermissions; public $timestamps = true; @@ -52,37 +53,7 @@ public function plan(): BelongsTo */ public function hasResourceAvailable(string $resource): bool { - /** @var Plan $plan */ - $plan = $this->plan; - if (is_null($plan)) { - // use environment plan if no users-plan is set - $plan = $this->environment->plan; - if (is_null($plan)) { - // use tenant plan if no environment-plan is set - $plan = $this->environment->tenant->plan; - } - } - /** @var Resource $resource_to_check */ - $resource_to_check = $plan->resources() - ->where('resources.key', $resource) - ->where('resources.type', 'environment') - ->first(); - if (empty($resource_to_check)) { - // don't have this resource assigned to plan at all - return false; - } - $max = $resource_to_check->pivot->limit; - if (empty($max)) { - // not allowed (0 value) - return false; - } elseif ($max == -1) { - // unlimited - return true; - } else { - // limit set - check for already used resources - $used = Resource::getEnvironmentUsage($this->environment, $resource_to_check->model_type, $this->user); - return $used < $max; - } + return \Froxlor\Core\Support\Quota::environmentAvailable($this->environment, $resource, $this->user); } /** diff --git a/packages/core/src/Models/Node.php b/packages/core/src/Models/Node.php index 8607cdd..2938e03 100644 --- a/packages/core/src/Models/Node.php +++ b/packages/core/src/Models/Node.php @@ -57,6 +57,7 @@ #[ObservedBy(NodeObserver::class)] class Node extends Model { + use \Froxlor\Core\Services\Traits\SavesWithinQuotaTransaction; use HasUlids, HasAdapter, HasPermissions, HasSettings, IsResource, IsTenantResource; protected $guarded = []; diff --git a/packages/core/src/Models/Plan.php b/packages/core/src/Models/Plan.php index 836c64a..0b46013 100644 --- a/packages/core/src/Models/Plan.php +++ b/packages/core/src/Models/Plan.php @@ -28,12 +28,22 @@ */ class Plan extends Model { + use \Froxlor\Core\Services\Traits\TracksTenantQuota; use HasUlids, IsResource, IsTenantResource, HasPermissions { HasPermissions::getAllPermissions as protected getBasePermissions; } protected $guarded = []; + /** Keep the assignment check and deletion atomic, including extension callers. */ + public function delete() + { + return \Froxlor\Core\Support\Quota::transaction(function () { + \Froxlor\Core\Support\PlanAssignments::ensureNotAssigned($this); + return parent::delete(); + }); + } + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); diff --git a/packages/core/src/Models/Role.php b/packages/core/src/Models/Role.php index 6827238..d55fb90 100644 --- a/packages/core/src/Models/Role.php +++ b/packages/core/src/Models/Role.php @@ -27,6 +27,7 @@ */ class Role extends Model { + use \Froxlor\Core\Services\Traits\TracksTenantQuota; use HasUlids, IsResource, IsTenantResource, HasPermissions; protected $guarded = []; diff --git a/packages/core/src/Models/Tenant.php b/packages/core/src/Models/Tenant.php index 7174226..431fa53 100644 --- a/packages/core/src/Models/Tenant.php +++ b/packages/core/src/Models/Tenant.php @@ -38,8 +38,10 @@ * @property Collection $allSubTenants * @property Tenant|null $parentTenant */ +#[\Illuminate\Database\Eloquent\Attributes\ObservedBy(\Froxlor\Core\Observers\TenantQuotaObserver::class)] class Tenant extends Model { + use \Froxlor\Core\Services\Traits\SavesWithinQuotaTransaction; use HasUlids, IsResource, IsTenantResource, HasPermissions, Notifiable; public $guarded = []; diff --git a/packages/core/src/Models/TenantUsage.php b/packages/core/src/Models/TenantUsage.php index c0c5880..99a1f4e 100644 --- a/packages/core/src/Models/TenantUsage.php +++ b/packages/core/src/Models/TenantUsage.php @@ -45,7 +45,7 @@ public function resource(): Attribute abort(404, 'Given resource-type could not be found'); } return Attribute::make( - get: fn() => $resource_fqcn::query()->find($this->resource_id)->first(), + get: fn() => $resource_fqcn::query()->find($this->resource_id), ); } } diff --git a/packages/core/src/Models/TenantUser.php b/packages/core/src/Models/TenantUser.php index b955dcb..3d9f6c1 100644 --- a/packages/core/src/Models/TenantUser.php +++ b/packages/core/src/Models/TenantUser.php @@ -5,7 +5,6 @@ use Exception; use Froxlor\Core\Observers\TenantUserObserver; use Froxlor\Core\Services\Traits\CanDelegatePermissions; -use Froxlor\Core\Support\Resource; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,6 +24,7 @@ #[ObservedBy(TenantUserObserver::class)] class TenantUser extends Pivot { + use \Froxlor\Core\Services\Traits\SavesWithinQuotaTransaction; use HasUlids, CanDelegatePermissions; public $timestamps = true; @@ -69,32 +69,6 @@ public function hasPermission(string|array $permission): bool */ public function hasResourceAvailable(string $resource): bool { - /** @var Plan $plan */ - $plan = $this->plan; - if (is_null($plan)) { - // use tenant plan if no users-plan is set - $plan = $this->tenant->plan; - } - /** @var Resource $resource_to_check */ - $resource_to_check = $plan->resources() - ->where('resources.key', $resource) - ->where('resources.type', 'tenant') - ->first(); - if (empty($resource_to_check)) { - // don't have this resource assigned to plan at all - return false; - } - $max = $resource_to_check->pivot->limit; - if (empty($max)) { - // not allowed (0 value) - return false; - } elseif ($max == -1) { - // unlimited - return true; - } else { - // limit set - check for already used resources - $used = Resource::getUsage($this->tenant, $resource_to_check->model_type, $this->user); - return $used < $max; - } + return \Froxlor\Core\Support\Quota::tenantAvailable($this->tenant, $resource, $this->user); } } diff --git a/packages/core/src/Observers/EnvUsageObserver.php b/packages/core/src/Observers/EnvUsageObserver.php index 92fcd55..d68ab44 100644 --- a/packages/core/src/Observers/EnvUsageObserver.php +++ b/packages/core/src/Observers/EnvUsageObserver.php @@ -3,7 +3,6 @@ namespace Froxlor\Core\Observers; use Froxlor\Core\Models\EnvUsage; -use Froxlor\Core\Support\Resource; class EnvUsageObserver { @@ -12,8 +11,7 @@ class EnvUsageObserver */ public function created(EnvUsage $envUsage): void { - // add usage of environment also to the owning tenant - Resource::addEnvironmentUsage($envUsage->environment, $envUsage->resource); + // Tenant totals aggregate env_usage through environments. Never book a second row. } /** @@ -29,8 +27,7 @@ public function updated(EnvUsage $envUsage): void */ public function deleted(EnvUsage $envUsage): void { - // remove usage of environment also from the owning tenant - Resource::removeEnvironmentUsage($envUsage->environment, $envUsage->resource); + // Removing this ledger row already removes it from the owner's aggregate. } /** diff --git a/packages/core/src/Observers/EnvironmentObserver.php b/packages/core/src/Observers/EnvironmentObserver.php index fa2e84f..76b7152 100644 --- a/packages/core/src/Observers/EnvironmentObserver.php +++ b/packages/core/src/Observers/EnvironmentObserver.php @@ -19,8 +19,7 @@ class EnvironmentObserver /** * Ensure the target tenant may consume another environment resource. * - * When a parent tenant user creates an environment for a subtenant, both - * the acting tenant and the target tenant must have capacity available. + * Check the owning tenant. Ancestors already reserved this child's budget. * * @throws InvalidResourceException * @throws ResourceLimitException @@ -33,11 +32,7 @@ public function creating(Environment $environment): void } $targetTenant = Tenant::query()->findOrFail($environment->tenant_id); - $actingTenant = Resource::actingTenantFor(auth()->user(), $targetTenant); - - if ($actingTenant === null - || !Resource::hasUsageAvailable($actingTenant, Environment::class, auth()->user()) - || (!$actingTenant->is($targetTenant) && !Resource::hasUsageAvailable($targetTenant, Environment::class, auth()->user()))) { + if (!Resource::hasUsageAvailable($targetTenant, Environment::class, auth()->user())) { throw new ResourceLimitException('Resource limit exceeded (' . Environment::getResourceKey() . ')'); } } @@ -45,8 +40,8 @@ public function creating(Environment $environment): void /** * Record tenant-level usage for a newly created environment. * - * Usage is booked on the tenant the user acts from and, when creating for a - * subtenant, also on the target tenant so both scopes reflect consumption. + * Usage is booked only on the owning tenant; child reservations account for + * delegated capacity at each ancestor without charging that capacity twice. * * @param Environment $environment * @throws InvalidResourceException @@ -61,15 +56,8 @@ public function created(Environment $environment): void return; } - $actingTenant = Resource::actingTenantFor(auth()->user(), $environment->tenant); - if ($actingTenant === null) { - return; - } - - Resource::addUsage($actingTenant, $environment, auth()->user()); - if (!$actingTenant->is($environment->tenant)) { - Resource::addUsage($environment->tenant, $environment, auth()->user()); - } + // Ancestors already reserve the child's plan; charge only the owner. + Resource::addUsage($environment->tenant, $environment, auth()->user()); } @@ -78,6 +66,10 @@ public function created(Environment $environment): void */ public function updated(Environment $environment): void { + if ($environment->wasChanged('plan_id')) { + \Froxlor\Core\Support\PlanAssignments::ensureAssignableToEnvironment( + $environment->plan_id, $environment->tenant()->lockForUpdate()->firstOrFail(), 'plan_id', $environment); + } } /** diff --git a/packages/core/src/Observers/EnvironmentUserObserver.php b/packages/core/src/Observers/EnvironmentUserObserver.php new file mode 100644 index 0000000..1b4e730 --- /dev/null +++ b/packages/core/src/Observers/EnvironmentUserObserver.php @@ -0,0 +1,29 @@ +environment, $membership->user, auth()->user() ?? $membership->user); + } + + public function updated(EnvironmentUser $membership): void + { + if ($membership->wasChanged('plan_id')) { + $environment = $membership->environment()->lockForUpdate()->firstOrFail(); + PlanAssignments::ensureAssignableToEnvironmentUser($membership->plan_id, + $environment->tenant()->lockForUpdate()->firstOrFail(), $environment, 'environment_plan', $membership->user_id); + } + } + + public function deleted(EnvironmentUser $membership): void + { + Resource::removeEnvironmentUsage($membership->environment, $membership->user); + } +} diff --git a/packages/core/src/Observers/NodeObserver.php b/packages/core/src/Observers/NodeObserver.php index 8f910ed..fc03954 100644 --- a/packages/core/src/Observers/NodeObserver.php +++ b/packages/core/src/Observers/NodeObserver.php @@ -25,11 +25,7 @@ public function creating(Node $node): void } $targetTenant = Tenant::query()->findOrFail($node->tenant_id); - $actingTenant = Resource::actingTenantFor(auth()->user(), $targetTenant); - - if ($actingTenant === null - || !Resource::hasUsageAvailable($actingTenant, Node::class, auth()->user()) - || (!$actingTenant->is($targetTenant) && !Resource::hasUsageAvailable($targetTenant, Node::class, auth()->user()))) { + if (!Resource::hasUsageAvailable($targetTenant, Node::class, auth()->user())) { throw new ResourceLimitException('Resource limit exceeded (' . Node::getResourceKey() . ')'); } } @@ -45,13 +41,8 @@ public function created(Node $node): void $node->addSetting('node.last_guid_number', 9999, null, 'integer', ['visible' => false]); if (!empty($node->tenant_id) && auth()->check()) { - $actingTenant = Resource::actingTenantFor(auth()->user(), $node->tenant); - if ($actingTenant !== null) { - Resource::addUsage($actingTenant, $node, auth()->user()); - if (!$actingTenant->is($node->tenant)) { - Resource::addUsage($node->tenant, $node, auth()->user()); - } - } + // Ancestors already reserve the child's plan; charge only the owner. + Resource::addUsage($node->tenant, $node, auth()->user()); } } diff --git a/packages/core/src/Observers/TenantQuotaObserver.php b/packages/core/src/Observers/TenantQuotaObserver.php new file mode 100644 index 0000000..6d78890 --- /dev/null +++ b/packages/core/src/Observers/TenantQuotaObserver.php @@ -0,0 +1,56 @@ +parentTenant()->lockForUpdate()->first(); + if ($parent === null) { + return; + } + if (auth()->check()) { + Resource::addUsage($parent, $tenant, auth()->user()); + } + $plan = $tenant->plan()->lockForUpdate()->firstOrFail(); + PlanAssignments::ensureAssignableToChildTenant($plan, $parent, $tenant); + PlanAssignments::syncTenantReservations($parent, $tenant, $plan); + } + + public function updated(Tenant $tenant): void + { + if ($tenant->wasChanged(['plan_id', 'parent_tenant_id'])) { + $parent = $tenant->parentTenant()->lockForUpdate()->first(); + $plan = $tenant->plan()->lockForUpdate()->firstOrFail(); + $oldParent = $tenant->getRawOriginal('parent_tenant_id'); + if ($oldParent !== null && $oldParent !== $tenant->parent_tenant_id) { + TenantResourceReservation::query()->where('tenant_id', $oldParent) + ->where('reserved_for_tenant_id', $tenant->id)->delete(); + TenantUsage::query()->where('tenant_id', $oldParent) + ->where('resource_key', Tenant::getResourceKey())->where('resource_id', $tenant->id)->delete(); + } + if ($oldParent !== $tenant->parent_tenant_id && $parent !== null && auth()->check()) { + Resource::addUsage($parent, $tenant, auth()->user()); + } + if ($parent !== null) { + PlanAssignments::ensureAssignableToChildTenant($plan, $parent, $tenant); + PlanAssignments::syncTenantReservations($parent, $tenant, $plan); + } + PlanAssignments::ensureTenantContract($tenant, $plan); + } + } + + public function deleted(Tenant $tenant): void + { + if (($parent = $tenant->parentTenant()->first()) !== null) { + Resource::removeUsage($parent, $tenant); + } + } +} diff --git a/packages/core/src/Observers/TenantUserObserver.php b/packages/core/src/Observers/TenantUserObserver.php index be3ec27..061ffac 100644 --- a/packages/core/src/Observers/TenantUserObserver.php +++ b/packages/core/src/Observers/TenantUserObserver.php @@ -3,7 +3,6 @@ namespace Froxlor\Core\Observers; use Froxlor\Core\Models\TenantUser; -use Froxlor\Core\Models\User; use Froxlor\Core\Support\Resource; class TenantUserObserver @@ -14,7 +13,7 @@ class TenantUserObserver public function created(TenantUser $tenantUser): void { // add usage to the owning tenant - Resource::addUsage($tenantUser->tenant, $tenantUser->user, $tenantUser->user); + Resource::addUsage($tenantUser->tenant, $tenantUser->user, auth()->user() ?? $tenantUser->user); } /** @@ -22,7 +21,10 @@ public function created(TenantUser $tenantUser): void */ public function updated(TenantUser $tenantUser): void { - // + if ($tenantUser->wasChanged('plan_id')) { + \Froxlor\Core\Support\PlanAssignments::ensureAssignableToTenantUser( + $tenantUser->plan_id, $tenantUser->tenant()->lockForUpdate()->firstOrFail(), 'plan_id', $tenantUser->user_id); + } } /** @@ -30,7 +32,7 @@ public function updated(TenantUser $tenantUser): void */ public function deleted(TenantUser $tenantUser): void { - // remove usage of environment also from the owning tenant + // Release the removed tenant membership's slot. Resource::removeUsage($tenantUser->tenant, $tenantUser->user); } diff --git a/packages/core/src/Services/Traits/SavesWithinQuotaTransaction.php b/packages/core/src/Services/Traits/SavesWithinQuotaTransaction.php new file mode 100644 index 0000000..036709b --- /dev/null +++ b/packages/core/src/Services/Traits/SavesWithinQuotaTransaction.php @@ -0,0 +1,14 @@ + parent::save($options)); + } +} diff --git a/packages/core/src/Services/Traits/TracksTenantQuota.php b/packages/core/src/Services/Traits/TracksTenantQuota.php new file mode 100644 index 0000000..4cac375 --- /dev/null +++ b/packages/core/src/Services/Traits/TracksTenantQuota.php @@ -0,0 +1,26 @@ +tenant_id !== null && auth()->check()) { + Resource::addUsage(Tenant::query()->whereKey($model->tenant_id)->lockForUpdate()->firstOrFail(), $model, auth()->user()); + } + }); + static::deleted(function ($model): void { + if ($model->tenant_id !== null && ($tenant = Tenant::query()->find($model->tenant_id))) { + Resource::removeUsage($tenant, $model); + } + }); + } +} diff --git a/packages/core/src/Support/AdministrationGuard.php b/packages/core/src/Support/AdministrationGuard.php index dc6a94d..1fd7c28 100644 --- a/packages/core/src/Support/AdministrationGuard.php +++ b/packages/core/src/Support/AdministrationGuard.php @@ -22,6 +22,10 @@ public static function run(Closure $mutation): mixed throw ValidationException::withMessages(['administration' => 'The global administration permission is missing. Restore the permission registry first.']); } + // Acquire quota before tenant/user rows: guarded membership mutations + // may book or release quota and must not invert that lock ordering. + Quota::lock(); + $before = self::administrators($permission->id); $roots = self::administeredRoots($before); $result = $mutation(); diff --git a/packages/core/src/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php index e7efeb0..bcceabf 100644 --- a/packages/core/src/Support/PlanAssignments.php +++ b/packages/core/src/Support/PlanAssignments.php @@ -23,21 +23,22 @@ class PlanAssignments */ public static function ensureAssignableToTenantUser(?string $planId, Tenant $tenant, string $field = 'plan_id', ?string $userId = null): void { + $tenant = Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail(); if (empty($planId)) { - if ($userId !== null && $tenant->plan !== null) { - self::ensureTenantUserUsageWithinPlan($tenant, $userId, $tenant->plan, $field); + if ($userId !== null && $tenant->plan()->lockForUpdate()->first() !== null) { + self::ensureTenantUserUsageWithinPlan($tenant, $userId, $tenant->plan()->lockForUpdate()->first(), $field); } return; } - $plan = Plan::query()->with('resources')->findOrFail($planId); + $plan = Plan::query()->whereKey($planId)->lockForUpdate()->firstOrFail(); if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, 'The selected plan is not available for this tenant.'); } self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); - self::ensureWithinParentPlan($plan, $tenant->plan, $field); + self::ensureWithinParentPlan($plan, $tenant->plan()->lockForUpdate()->first(), $field); if ($userId !== null) { self::ensureTenantUserUsageWithinPlan($tenant, $userId, $plan, $field); @@ -60,6 +61,10 @@ public static function ensureAssignableToEnvironmentUser( string $field = 'environment_plan', ?string $userId = null, ): void { + $environment = Environment::query()->whereKey($environment->id)->lockForUpdate()->firstOrFail(); + if ($environment->tenant_id !== $tenant->id) { + throw self::validationException($field, 'The environment does not belong to this tenant.'); + } if (empty($planId)) { $parentPlan = self::environmentParentPlan($environment); if ($userId !== null && $parentPlan !== null) { @@ -68,7 +73,7 @@ public static function ensureAssignableToEnvironmentUser( return; } - $plan = Plan::query()->with('resources')->findOrFail($planId); + $plan = Plan::query()->whereKey($planId)->lockForUpdate()->firstOrFail(); if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, 'The selected plan is not available for this environment.'); @@ -94,11 +99,11 @@ public static function ensureAssignableToEnvironmentUser( public static function ensureNotAssigned(Plan $plan): void { $assignments = [ - 'tenants' => DB::table('tenants')->where('plan_id', $plan->id)->count(), - 'environments' => DB::table('environments')->where('plan_id', $plan->id)->count(), - 'tenant users' => DB::table('tenant_user')->where('plan_id', $plan->id)->count(), - 'environment users' => DB::table('environment_user')->where('plan_id', $plan->id)->count(), - 'tenant reservations' => DB::table('tenant_resource_reservations')->where('plan_id', $plan->id)->count(), + 'tenants' => DB::table('tenants')->where('plan_id', $plan->id)->lockForUpdate()->get(['id'])->count(), + 'environments' => DB::table('environments')->where('plan_id', $plan->id)->lockForUpdate()->get(['id'])->count(), + 'tenant users' => DB::table('tenant_user')->where('plan_id', $plan->id)->lockForUpdate()->get(['id'])->count(), + 'environment users' => DB::table('environment_user')->where('plan_id', $plan->id)->lockForUpdate()->get(['id'])->count(), + 'tenant reservations' => DB::table('tenant_resource_reservations')->where('plan_id', $plan->id)->lockForUpdate()->get(['id'])->count(), ]; $usedBy = collect($assignments) @@ -115,23 +120,27 @@ public static function ensureNotAssigned(Plan $plan): void * Assign or update one resource limit and revalidate every existing plan assignment. * * Used plans stay editable, but the resulting plan must still fit all places where - * it is assigned. For child tenants, reservations are synchronized after successful - * validation so parent budgets immediately reflect the changed limits. + * it is assigned. All child reservations are changed together before validation; + * a failed validation rolls back both the plan and every reservation. * * @throws ValidationException */ public static function updatePlanResourceLimit(Plan $plan, Resource $resource, int $limit, ?Tenant $tenant = null): void { - DB::transaction(function () use ($plan, $resource, $limit, $tenant): void { + Quota::transaction(function () use ($plan, $resource, $limit, $tenant): void { + if ($limit < -1) { + throw self::validationException('limit', 'A limit must be -1, zero or positive.'); + } self::lockPlanAssignments($plan); + $tenant = $plan->tenant_id === null ? null : Tenant::query()->whereKey($plan->tenant_id)->lockForUpdate()->firstOrFail(); self::ensureResourceCanBeAttached($plan, $resource, $limit, $tenant, 'limit'); $plan->resources()->syncWithoutDetaching([ $resource->id => ['limit' => $limit], ]); - self::ensureAssignedPlanRemainsValid($plan->refresh()); self::syncReservationsForAssignedTenants($plan->refresh()); + self::ensureAssignedPlanRemainsValid($plan->refresh()); }); } @@ -145,13 +154,13 @@ public static function updatePlanResourceLimit(Plan $plan, Resource $resource, i */ public static function removePlanResource(Plan $plan, Resource $resource): void { - DB::transaction(function () use ($plan, $resource): void { + Quota::transaction(function () use ($plan, $resource): void { self::lockPlanAssignments($plan); $plan->resources()->detach($resource); - self::ensureAssignedPlanRemainsValid($plan->refresh()); self::syncReservationsForAssignedTenants($plan->refresh()); + self::ensureAssignedPlanRemainsValid($plan->refresh()); }); } @@ -175,7 +184,7 @@ public static function ensureResourceCanBeAttached( return; } - $parentPlan = $tenant->plan; + $parentPlan = $tenant->plan()->lockForUpdate()->first(); if ($parentPlan === null) { throw self::validationException('limit', 'The resource cannot be assigned without a parent plan.'); } @@ -183,7 +192,7 @@ public static function ensureResourceCanBeAttached( $parentResource = $parentPlan->resources() ->where('resources.key', $resource->key) ->where('resources.type', $resource->type) - ->first(); + ->lockForUpdate()->first(); $parentLimit = $parentResource === null ? null : (int)$parentResource->pivot->limit; if ($parentLimit === null || $parentLimit === 0) { @@ -216,10 +225,10 @@ public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan } $parentResources = $parentPlan->resources() - ->get() + ->lockForUpdate()->get() ->mapWithKeys(fn($resource) => [self::resourceIdentifier($resource->key, $resource->type) => (int)$resource->pivot->limit]); - $childResources = $childPlan->resources()->get(); + $childResources = $childPlan->resources()->lockForUpdate()->get(); foreach ($childResources as $childResource) { $childLimit = (int)$childResource->pivot->limit; @@ -262,7 +271,7 @@ public static function ensureAssignableToChildTenant(Plan $plan, Tenant $parentT throw self::validationException($field, 'The selected plan is not available for child tenants.'); } - self::ensureWithinParentPlan($plan->loadMissing('resources'), $parentTenant->plan, $field); + self::ensureWithinParentPlan($plan, $parentTenant->plan()->lockForUpdate()->first(), $field); self::ensureWithinAvailableTenantBudget($plan, $parentTenant, $childTenant, $field); if ($childTenant !== null) { @@ -281,7 +290,7 @@ public static function ensurePlanAvailableForTenant(?string $planId, Tenant $ten return; } - $plan = Plan::query()->findOrFail($planId); + $plan = Plan::query()->whereKey($planId)->lockForUpdate()->firstOrFail(); if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, trans('validation.exists', ['attribute' => $field])); @@ -299,24 +308,59 @@ public static function ensurePlanAvailableForTenant(?string $planId, Tenant $ten */ public static function ensureAssignableToEnvironment(?string $planId, Tenant $tenant, string $field = 'plan_id', ?Environment $environment = null): void { + $tenant = Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail(); if ($planId === null) { - if ($environment !== null && $tenant->plan !== null) { - self::ensureEnvironmentUsageWithinPlan($environment, $tenant->plan, $field); + if ($environment !== null && $tenant->plan()->lockForUpdate()->first() !== null) { + self::ensureEnvironmentUsageWithinPlan($environment, $tenant->plan()->lockForUpdate()->first(), $field); + self::ensureEnvironmentDependents($environment, $tenant->plan()->lockForUpdate()->first(), $field); } return; } - $plan = Plan::query()->with('resources')->findOrFail($planId); + $plan = Plan::query()->whereKey($planId)->lockForUpdate()->firstOrFail(); if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, trans('validation.exists', ['attribute' => $field])); } self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); - self::ensureWithinParentPlan($plan, $tenant->plan, $field); + self::ensureWithinParentPlan($plan, $tenant->plan()->lockForUpdate()->first(), $field); if ($environment !== null) { self::ensureEnvironmentUsageWithinPlan($environment, $plan, $field); + self::ensureEnvironmentDependents($environment, $plan, $field); + } + } + + /** Validate both usage and every explicit downstream contract against a new tenant plan. */ + public static function ensureTenantContract(Tenant $tenant, Plan $plan, string $field = 'plan_id'): void + { + self::ensureTenantUsageWithinPlan($tenant, $plan, $field); + foreach (Plan::query()->where('tenant_id', $tenant->id)->lockForUpdate()->get() as $ownedPlan) { + self::ensureWithinParentPlan($ownedPlan, $plan, $field); + } + foreach (DB::table('tenant_user')->where('tenant_id', $tenant->id)->lockForUpdate()->get() as $membership) { + $userPlan = $membership->plan_id === null ? $plan : Plan::query()->whereKey($membership->plan_id)->lockForUpdate()->firstOrFail(); + self::ensureWithinParentPlan($userPlan, $plan, $field); + self::ensureTenantUserUsageWithinPlan($tenant, $membership->user_id, $userPlan, $field); + } + foreach (Environment::query()->where('tenant_id', $tenant->id)->lockForUpdate()->get() as $environment) { + $environmentPlan = $environment->plan_id === null ? $plan : $environment->plan()->lockForUpdate()->firstOrFail(); + self::ensureWithinParentPlan($environmentPlan, $plan, $field); + self::ensureEnvironmentUsageWithinPlan($environment, $environmentPlan, $field); + self::ensureEnvironmentDependents($environment, $environmentPlan, $field); + } + foreach (Tenant::query()->where('parent_tenant_id', $tenant->id)->lockForUpdate()->get() as $child) { + self::ensureWithinParentPlan($child->plan()->lockForUpdate()->firstOrFail(), $plan, $field); + } + } + + private static function ensureEnvironmentDependents(Environment $environment, Plan $plan, string $field): void + { + foreach (DB::table('environment_user')->where('environment_id', $environment->id)->lockForUpdate()->get() as $membership) { + $userPlan = $membership->plan_id === null ? $plan : Plan::query()->whereKey($membership->plan_id)->lockForUpdate()->firstOrFail(); + self::ensureWithinParentPlan($userPlan, $plan, $field); + self::ensureEnvironmentUserUsageWithinPlan($environment, $membership->user_id, $userPlan, $field); } } @@ -327,15 +371,14 @@ public static function ensureAssignableToEnvironment(?string $planId, Tenant $te */ public static function lockTenantBudget(Tenant $tenant): void { + Quota::lock(); Tenant::query() ->whereKey($tenant->id) - ->lockForUpdate() - ->first(); + ->lockForUpdate()->first(); TenantResourceReservation::query() ->where('tenant_id', $tenant->id) - ->lockForUpdate() - ->get(); + ->lockForUpdate()->get(); } /** @@ -352,8 +395,8 @@ private static function lockPlanAssignments(Plan $plan): void DB::table('tenant_resource_reservations')->where('plan_id', $plan->id)->lockForUpdate()->get(); foreach (self::tenantsAssignedToPlan($plan) as $tenant) { - if ($tenant->parentTenant !== null) { - self::lockTenantBudget($tenant->parentTenant); + if (($parent = $tenant->parentTenant()->lockForUpdate()->first()) !== null) { + self::lockTenantBudget($parent); } self::lockTenantBudget($tenant); } @@ -367,6 +410,7 @@ private static function lockPlanAssignments(Plan $plan): void */ public static function syncTenantReservations(Tenant $parentTenant, Tenant $childTenant, Plan $plan): void { + Quota::lock(); TenantResourceReservation::query() ->where('tenant_id', $parentTenant->id) ->where('reserved_for_tenant_id', $childTenant->id) @@ -394,11 +438,12 @@ public static function syncTenantReservations(Tenant $parentTenant, Tenant $chil private static function syncReservationsForAssignedTenants(Plan $plan): void { foreach (self::tenantsAssignedToPlan($plan) as $tenant) { - if ($plan->tenant_id === null || $tenant->parentTenant === null) { + $parent = $tenant->parentTenant()->lockForUpdate()->first(); + if ($plan->tenant_id === null || $parent === null) { continue; } - self::syncTenantReservations($tenant->parentTenant, $tenant, $plan); + self::syncTenantReservations($parent, $tenant, $plan); } } @@ -407,6 +452,7 @@ private static function syncReservationsForAssignedTenants(Plan $plan): void */ public static function removeTenantReservations(Tenant $parentTenant, Tenant $childTenant): void { + Quota::lock(); TenantResourceReservation::query() ->where('tenant_id', $parentTenant->id) ->where('reserved_for_tenant_id', $childTenant->id) @@ -425,7 +471,7 @@ public static function availableTenantBudget(Tenant $tenant, ?Tenant $ignoreChil { $budget = []; - foreach (self::planLimits($tenant->plan) as $identifier => $resourceLimit) { + foreach (self::planLimits($tenant->plan()->lockForUpdate()->first()) as $identifier => $resourceLimit) { $limit = $resourceLimit['limit']; if ($limit === -1) { @@ -435,12 +481,14 @@ public static function availableTenantBudget(Tenant $tenant, ?Tenant $ignoreChil $used = self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); - $reserved = TenantResourceReservation::query() + $reservations = TenantResourceReservation::query() ->where('tenant_id', $tenant->id) ->where('resource_key', $resourceLimit['key']) ->where('resource_type', $resourceLimit['type']) ->when($ignoreChildTenant !== null, fn($query) => $query->where('reserved_for_tenant_id', '!=', $ignoreChildTenant->id)) - ->sum('limit'); + ->lockForUpdate()->pluck('limit'); + + $reserved = $reservations->contains(fn ($value) => (int) $value === -1) ? PHP_INT_MAX : (int) $reservations->sum(); $budget[$identifier] = max(0, $limit - $used - (int)$reserved); } @@ -484,27 +532,29 @@ private static function ensureWithinAvailableTenantBudget(Plan $plan, Tenant $pa private static function ensureAssignedPlanRemainsValid(Plan $plan): void { foreach (self::tenantsAssignedToPlan($plan) as $tenant) { - if ($plan->tenant_id !== null && $tenant->parentTenant !== null) { - self::ensureAssignableToChildTenant($plan, $tenant->parentTenant, $tenant, 'plan'); + $parent = $tenant->parentTenant()->lockForUpdate()->first(); + if ($plan->tenant_id !== null && $parent !== null) { + self::ensureAssignableToChildTenant($plan, $parent, $tenant, 'plan'); } - self::ensureTenantUsageWithinPlan($tenant, $plan, 'plan'); + self::ensureTenantContract($tenant, $plan, 'plan'); } - foreach (Environment::query()->where('plan_id', $plan->id)->with('tenant')->get() as $environment) { - self::ensureWithinParentPlan($plan, $environment->tenant->plan, 'plan'); + foreach (Environment::query()->where('plan_id', $plan->id)->lockForUpdate()->get() as $environment) { + self::ensureWithinParentPlan($plan, $environment->tenant()->lockForUpdate()->firstOrFail()->plan()->lockForUpdate()->first(), 'plan'); self::ensureEnvironmentUsageWithinPlan($environment, $plan, 'plan'); + self::ensureEnvironmentDependents($environment, $plan, 'plan'); } - foreach (DB::table('tenant_user')->where('plan_id', $plan->id)->get() as $assignment) { - $tenant = Tenant::query()->findOrFail($assignment->tenant_id); + foreach (DB::table('tenant_user')->where('plan_id', $plan->id)->lockForUpdate()->get() as $assignment) { + $tenant = Tenant::query()->whereKey($assignment->tenant_id)->lockForUpdate()->firstOrFail(); self::ensureAssignableToTenantUser($plan->id, $tenant, 'plan'); self::ensureTenantUserUsageWithinPlan($tenant, (string)$assignment->user_id, $plan, 'plan'); } - foreach (DB::table('environment_user')->where('plan_id', $plan->id)->get() as $assignment) { - $environment = Environment::query()->with('tenant')->findOrFail($assignment->environment_id); - self::ensureAssignableToEnvironmentUser($plan->id, $environment->tenant, $environment, 'plan'); + foreach (DB::table('environment_user')->where('plan_id', $plan->id)->lockForUpdate()->get() as $assignment) { + $environment = Environment::query()->whereKey($assignment->environment_id)->lockForUpdate()->firstOrFail(); + self::ensureAssignableToEnvironmentUser($plan->id, $environment->tenant()->lockForUpdate()->firstOrFail(), $environment, 'plan'); self::ensureEnvironmentUserUsageWithinPlan($environment, (string)$assignment->user_id, $plan, 'plan'); } } @@ -518,8 +568,7 @@ private static function tenantsAssignedToPlan(Plan $plan): \Illuminate\Database\ { return Tenant::query() ->where('plan_id', $plan->id) - ->with('parentTenant') - ->get(); + ->lockForUpdate()->get(); } /** @@ -530,8 +579,9 @@ private static function tenantsAssignedToPlan(Plan $plan): \Illuminate\Database\ private static function ensureTenantUsageWithinPlan(Tenant $tenant, Plan $plan, string $field): void { foreach (self::planLimits($plan) as $identifier => $resourceLimit) { - $used = self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']) - + self::reservedByTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); + $reserved = self::reservedByTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); + $used = $reserved === PHP_INT_MAX ? PHP_INT_MAX + : self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']) + $reserved; self::ensureLimitCoversUsage($resourceLimit['limit'], $used, $field); } @@ -569,16 +619,18 @@ private static function ensureEnvironmentUsageWithinPlan(Environment $environmen private static function ensureTenantUserUsageWithinPlan(Tenant $tenant, string $userId, Plan $plan, string $field): void { foreach (self::planLimits($plan) as $resourceLimit) { - if ($resourceLimit['type'] !== 'tenant') { - continue; - } - self::ensureLimitCoversUsage( $resourceLimit['limit'], - self::usageForTenant($tenant, $resourceLimit['key'], 'tenant', $userId), + self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type'], $userId), $field, ); } + $tenantKeys = DB::table('tenant_usage')->where('tenant_id', $tenant->id)->where('user_id', $userId) + ->lockForUpdate()->pluck('resource_key')->map(fn ($key) => 'tenant:'.$key); + $environmentKeys = DB::table('env_usage')->join('environments', 'environments.id', '=', 'env_usage.environment_id') + ->where('environments.tenant_id', $tenant->id)->where('env_usage.user_id', $userId) + ->lockForUpdate()->pluck('env_usage.resource_key')->map(fn ($key) => 'environment:'.$key); + self::ensureNoUsageOutsidePlan($plan, $tenantKeys->merge($environmentKeys)->unique()->all(), $field); } /** @@ -599,6 +651,9 @@ private static function ensureEnvironmentUserUsageWithinPlan(Environment $enviro $field, ); } + $keys = DB::table('env_usage')->where('environment_id', $environment->id)->where('user_id', $userId) + ->lockForUpdate()->pluck('resource_key')->map(fn ($key) => 'environment:'.$key)->unique()->all(); + self::ensureNoUsageOutsidePlan($plan, $keys, $field); } /** @@ -636,11 +691,12 @@ private static function ensureNoUsageOutsidePlan(Plan $plan, array $usageIdentif private static function reservedByTenant(Tenant $tenant, string $resourceKey, string $resourceType): int { - return (int)TenantResourceReservation::query() + $limits = TenantResourceReservation::query() ->where('tenant_id', $tenant->id) ->where('resource_key', $resourceKey) ->where('resource_type', $resourceType) - ->sum('limit'); + ->lockForUpdate()->pluck('limit'); + return $limits->contains(fn ($value) => (int) $value === -1) ? PHP_INT_MAX : (int) $limits->sum(); } /** @@ -648,10 +704,13 @@ private static function reservedByTenant(Tenant $tenant, string $resourceKey, st * * @return array */ - private static function planLimits(Plan $plan): array + private static function planLimits(?Plan $plan): array { + if ($plan === null) { + return []; + } return $plan->resources() - ->get() + ->lockForUpdate()->get() ->mapWithKeys(fn($resource) => [ self::resourceIdentifier($resource->key, $resource->type) => [ 'key' => $resource->key, @@ -674,7 +733,7 @@ private static function ensureHasEnabledResources(Plan $plan, string $field, str { $hasEnabledResource = $plan->resources() ->wherePivot('limit', '!=', 0) - ->exists(); + ->lockForUpdate()->first() !== null; if (!$hasEnabledResource) { throw self::validationException($field, $message); @@ -688,29 +747,12 @@ private static function resourceIdentifier(string $key, string $type): string private static function usageForTenant(Tenant $tenant, string $resourceKey, string $resourceType, ?string $userId = null): int { - if ($resourceType === 'environment') { - return (int)DB::table('env_usage') - ->join('environments', 'env_usage.environment_id', '=', 'environments.id') - ->where('environments.tenant_id', $tenant->id) - ->where('env_usage.resource_key', $resourceKey) - ->when($userId !== null, fn($query) => $query->where('env_usage.user_id', $userId)) - ->count(); - } - - return (int)DB::table('tenant_usage') - ->where('tenant_id', $tenant->id) - ->where('resource_key', $resourceKey) - ->when($userId !== null, fn($query) => $query->where('user_id', $userId)) - ->count(); + return Quota::tenantUsed($tenant->id, $resourceKey, $resourceType, $userId); } private static function usageForEnvironment(Environment $environment, string $resourceKey, ?string $userId = null): int { - return (int)DB::table('env_usage') - ->where('environment_id', $environment->id) - ->where('resource_key', $resourceKey) - ->when($userId !== null, fn($query) => $query->where('user_id', $userId)) - ->count(); + return Quota::environmentUsed($environment->id, $resourceKey, $userId); } /** @@ -722,7 +764,7 @@ private static function tenantUsageIdentifiers(Tenant $tenant): array ->where('tenant_id', $tenant->id) ->select('resource_key') ->distinct() - ->pluck('resource_key') + ->lockForUpdate()->pluck('resource_key') ->map(fn(string $key) => self::resourceIdentifier($key, 'tenant')); $environmentUsage = DB::table('env_usage') @@ -730,14 +772,14 @@ private static function tenantUsageIdentifiers(Tenant $tenant): array ->where('environments.tenant_id', $tenant->id) ->select('env_usage.resource_key') ->distinct() - ->pluck('resource_key') + ->lockForUpdate()->pluck('resource_key') ->map(fn(string $key) => self::resourceIdentifier($key, 'environment')); $reserved = TenantResourceReservation::query() ->where('tenant_id', $tenant->id) ->select('resource_key', 'resource_type') ->distinct() - ->get() + ->lockForUpdate()->get() ->map(fn(TenantResourceReservation $reservation) => self::resourceIdentifier($reservation->resource_key, $reservation->resource_type)); return $tenantUsage @@ -757,7 +799,7 @@ private static function environmentUsageIdentifiers(Environment $environment): a ->where('environment_id', $environment->id) ->select('resource_key') ->distinct() - ->pluck('resource_key') + ->lockForUpdate()->pluck('resource_key') ->map(fn(string $key) => self::resourceIdentifier($key, 'environment')) ->values() ->all(); @@ -765,7 +807,7 @@ private static function environmentUsageIdentifiers(Environment $environment): a private static function environmentParentPlan(Environment $environment): ?Plan { - return $environment->plan ?: $environment->tenant->plan; + return $environment->plan()->lockForUpdate()->first() ?: $environment->tenant()->lockForUpdate()->firstOrFail()->plan()->lockForUpdate()->first(); } private static function validationException(string $field, string $message): ValidationException diff --git a/packages/core/src/Support/Quota.php b/packages/core/src/Support/Quota.php new file mode 100644 index 0000000..390a81f --- /dev/null +++ b/packages/core/src/Support/Quota.php @@ -0,0 +1,122 @@ +where('id', 1)->lockForUpdate()->first() === null) { + throw new LogicException('Quota lock is missing. Apply the Core quota migration.'); + } + } + + public static function tenantAvailable(Tenant $tenant, string $key, User $user): bool + { + return self::transaction(function () use ($tenant, $key, $user): bool { + $tenant = Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail(); + $membership = DB::table('tenant_user')->where('tenant_id', $tenant->id)->where('user_id', $user->id)->lockForUpdate()->first(); + if ($membership === null && Resource::actingTenantFor($user, $tenant) === null) { + throw new UnknownTenantUserException('Unknown tenant user'); + } + + return self::tenantTotalAvailable($tenant, $key, 'tenant') + && self::allows(self::limit($membership?->plan_id ?? $tenant->plan_id, $key, 'tenant'), + self::tenantUsed($tenant->id, $key, 'tenant', $user->id)); + }); + } + + public static function environmentAvailable(Environment $environment, string $key, User $user): bool + { + return self::transaction(function () use ($environment, $key, $user): bool { + $environment = Environment::query()->whereKey($environment->id)->lockForUpdate()->firstOrFail(); + $tenant = Tenant::query()->whereKey($environment->tenant_id)->lockForUpdate()->firstOrFail(); + $membership = DB::table('environment_user')->where('environment_id', $environment->id)->where('user_id', $user->id)->lockForUpdate()->first(); + if ($membership === null && Resource::actingTenantFor($user, $tenant) === null) { + throw new UnknownEnvironmentUserException('Unknown environment user'); + } + $tenantMembership = DB::table('tenant_user')->where('tenant_id', $tenant->id)->where('user_id', $user->id)->lockForUpdate()->first(); + $environmentPlan = $environment->plan_id ?? $tenant->plan_id; + + return self::tenantTotalAvailable($tenant, $key, 'environment') + && self::allows(self::limit($environmentPlan, $key, 'environment'), self::environmentUsed($environment->id, $key)) + && self::allows(self::limit($membership?->plan_id ?? $environmentPlan, $key, 'environment'), self::environmentUsed($environment->id, $key, $user->id)) + && ($tenantMembership?->plan_id === null || self::allows( + self::limit($tenantMembership->plan_id, $key, 'environment'), self::tenantUsed($tenant->id, $key, 'environment', $user->id))); + }); + } + + private static function tenantTotalAvailable(Tenant $tenant, string $key, string $type): bool + { + $limit = self::limit($tenant->plan_id, $key, $type); + if ($limit === -1) { + return true; + } + $reservations = DB::table('tenant_resource_reservations')->where('tenant_id', $tenant->id) + ->where('resource_key', $key)->where('resource_type', $type)->lockForUpdate()->pluck('limit'); + if ($reservations->contains(fn ($value) => (int) $value === -1)) { + return false; + } + + return self::allows($limit, self::tenantUsed($tenant->id, $key, $type) + (int) $reservations->sum()); + } + + private static function allows(int $limit, int $used): bool + { + return $limit === -1 || ($limit > 0 && $used < $limit); + } + + public static function limit(?string $planId, string $key, string $type): int + { + if ($planId === null) { + return 0; + } + + return (int) (DB::table('plan_resource')->join('resources', 'resources.id', '=', 'plan_resource.resource_id') + ->where('plan_resource.plan_id', $planId)->where('resources.key', $key)->where('resources.type', $type) + ->lockForUpdate()->value('plan_resource.limit') ?? 0); + } + + public static function tenantUsed(string $tenantId, string $key, string $type, ?string $userId = null): int + { + $query = $type === 'environment' + ? DB::table('env_usage')->join('environments', 'environments.id', '=', 'env_usage.environment_id')->where('environments.tenant_id', $tenantId) + : DB::table('tenant_usage')->where('tenant_id', $tenantId); + $table = $type === 'environment' ? 'env_usage' : 'tenant_usage'; + $columns = $type === 'environment' ? ['env_usage.environment_id', 'env_usage.resource_id'] : ['tenant_usage.resource_id']; + + return $query->where($table.'.resource_key', $key) + ->when($userId !== null, fn ($query) => $query->where($table.'.user_id', $userId)) + ->lockForUpdate()->get($columns)->unique(fn ($row) => ($row->environment_id ?? '').':'.$row->resource_id)->count(); + } + + public static function environmentUsed(string $environmentId, string $key, ?string $userId = null): int + { + return DB::table('env_usage')->where('environment_id', $environmentId)->where('resource_key', $key) + ->when($userId !== null, fn ($query) => $query->where('user_id', $userId)) + ->lockForUpdate()->get(['resource_id'])->pluck('resource_id')->unique()->count(); + } +} diff --git a/packages/core/src/Support/Resource.php b/packages/core/src/Support/Resource.php index 011f3ed..355fac7 100644 --- a/packages/core/src/Support/Resource.php +++ b/packages/core/src/Support/Resource.php @@ -4,238 +4,112 @@ use Froxlor\Core\Exceptions\InvalidResourceException; use Froxlor\Core\Exceptions\ResourceLimitException; -use Froxlor\Core\Exceptions\ResourceNotFoundException; -use Froxlor\Core\Exceptions\UnknownEnvironmentUserException; -use Froxlor\Core\Exceptions\UnknownTenantUserException; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Froxlor\Core\Services\Traits\IsResource; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; +/** Quota facade: availability is advisory; booking always rechecks under the shared lock. */ class Resource { - - /** - * @param Tenant $tenant - * @param string|Model $resource resource to check - * @param User|null $user optional to check usage of a specific users in the environment - * @return int - * @throws InvalidResourceException - */ public static function getUsage(Tenant $tenant, string|Model $resource, ?User $user = null): int { - if (!is_string($resource) && !in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - $usage = $tenant->tenantUsages() - ->where('resource_key', '=', self::resourceKey($resource)); - if ($user) { - $usage->where('user_id', $user->id); - } - return $usage->count(); + return Quota::tenantUsed($tenant->id, self::resourceKey($resource), 'tenant', $user?->id); } - /** - * @param Environment $environment - * @param string|Model $resource resource to check - * @param User|null $user optional to check usage of a specific users in the environment - * @return int - * @throws InvalidResourceException - */ public static function getEnvironmentUsage(Environment $environment, string|Model $resource, ?User $user = null): int { - if (!is_string($resource) && !in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - $usage = $environment->envUsages() - ->where('resource_key', '=', self::resourceKey($resource)); - if ($user) { - $usage->where('user_id', $user->id); - } - return $usage->count(); + return Quota::environmentUsed($environment->id, self::resourceKey($resource), $user?->id); + } + + /** Use this boundary around resource creation AND booking in external packages. */ + public static function transaction(\Closure $operation): mixed + { + return Quota::transaction($operation); } - /** - * @param Tenant $tenant - * @param Model $resource used resource - * @param User|null $user optional set origin user or use currently logged-in user - * @return Model - * @throws ResourceLimitException - * @throws UnknownEnvironmentUserException - * @throws ResourceNotFoundException - * @throws InvalidResourceException - * @throws UnknownTenantUserException - */ + /** Idempotent per owning tenant/resource, retaining the original consuming user. */ public static function addUsage(Tenant $tenant, Model $resource, ?User $user = null): Model { - if (!in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - if (empty($user)) { - $user = auth()->user(); - } - if (self::hasUsageAvailable($tenant, $resource, $user)) { + return Quota::transaction(function () use ($tenant, $resource, $user): Model { + $key = self::resourceKey($resource); + $user ??= auth()->user(); + if (!$resource->exists || $user === null) { + throw new InvalidResourceException('Usage requires a persisted resource and an explicit user.'); + } + $existing = $tenant->tenantUsages()->where('resource_key', $key)->where('resource_id', $resource->id)->lockForUpdate()->first(); + if ($existing !== null) { + return $existing; + } + if (!self::hasUsageAvailable($tenant, $resource, $user)) { + throw new ResourceLimitException('Resource limit exceeded ('.$key.')'); + } return $tenant->tenantUsages()->create([ - 'user_id' => $user->id, - 'created_at' => Carbon::now(), - 'updated_at' => Carbon::now(), - 'resource_key' => $resource::getResourceKey(), - 'resource_id' => $resource->id + 'user_id' => $user->id, 'resource_key' => $key, 'resource_id' => $resource->id, ]); - } - throw new ResourceLimitException("Resource limit exceeded (" . $resource::getResourceKey() . ")"); + }); } - /** - * @throws InvalidResourceException - * @throws UnknownTenantUserException - */ public static function hasUsageAvailable(Tenant $tenant, string|Model $resource, User $user): bool { - if (!is_string($resource) && !in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - - try { - return $tenant->userHasResourceAvailable($user, self::resourceKey($resource)); - } catch (UnknownTenantUserException $exception) { - if (!self::userControlsTenant($user, $tenant)) { - throw $exception; - } - - return self::tenantPlanHasResourceAvailable($tenant, $resource); - } + return Quota::tenantAvailable($tenant, self::resourceKey($resource), $user); } - /** - * @param Environment $environment - * @param Model $resource used resource - * @param User|null $user optional set origin user or use currently logged-in user - * @return Model - * @throws ResourceLimitException - * @throws UnknownEnvironmentUserException - * @throws InvalidResourceException - */ + /** Environment usage is aggregated for the owner tenant, never recursively duplicated. */ public static function addEnvironmentUsage(Environment $environment, Model $resource, ?User $user = null): Model { - if (!in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - if (empty($user)) { - $user = auth()->user(); - } - if ($environment->userHasResourceAvailable($user, $resource::getResourceKey())) { + return Quota::transaction(function () use ($environment, $resource, $user): Model { + $key = self::resourceKey($resource); + $user ??= auth()->user(); + if (!$resource->exists || $user === null) { + throw new InvalidResourceException('Usage requires a persisted resource and an explicit user.'); + } + $existing = $environment->envUsages()->where('resource_key', $key)->where('resource_id', $resource->id)->lockForUpdate()->first(); + if ($existing !== null) { + return $existing; + } + if (!Quota::environmentAvailable($environment, $key, $user)) { + throw new ResourceLimitException('Resource limit exceeded ('.$key.')'); + } return $environment->envUsages()->create([ - 'user_id' => !empty($user) ? $user->id : auth()->user()->id, - 'created_at' => Carbon::now(), - 'updated_at' => Carbon::now(), - 'resource_key' => $resource::getResourceKey(), - 'resource_id' => $resource->id + 'user_id' => $user->id, 'resource_key' => $key, 'resource_id' => $resource->id, ]); - } - throw new ResourceLimitException("Resource limit exceeded (" . $resource::getResourceKey() . ")"); + }); } - /** - * @param Tenant $tenant - * @param Model $resource resource to remove - * @return bool - * @throws InvalidResourceException - */ public static function removeUsage(Tenant $tenant, Model $resource): bool { - if (!in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - $tenant->tenantUsages() - ->where('resource_key', '=', $resource::getResourceKey()) - ->where('resource_id', $resource->id) - ->delete(); - return true; + return Quota::transaction(function () use ($tenant, $resource): bool { + $tenant->tenantUsages()->where('resource_key', self::resourceKey($resource))->where('resource_id', $resource->id)->delete(); + return true; + }); } - /** - * @param Environment $environment - * @param Model $resource resource to remove - * @return bool - * @throws InvalidResourceException - */ public static function removeEnvironmentUsage(Environment $environment, Model $resource): bool { - if (!in_array(IsResource::class, class_uses_recursive($resource))) { - throw new InvalidResourceException("Given resource does not implement " . IsResource::class); - } - $envUsage = $environment->envUsages() - ->where('resource_key', '=', $resource::getResourceKey()) - ->where('resource_id', $resource->id) - ->first(); - $envUsage->delete(); - return true; + return Quota::transaction(function () use ($environment, $resource): bool { + $environment->envUsages()->where('resource_key', self::resourceKey($resource))->where('resource_id', $resource->id)->delete(); + return true; + }); } public static function resourceKey(string|Model $resource): string { - if (is_string($resource) && class_exists($resource) && in_array(IsResource::class, class_uses_recursive($resource))) { - return $resource::getResourceKey(); - } - - return is_string($resource) ? $resource : $resource::getResourceKey(); - } - - /** - * Resolve the tenant context a user acts from when consuming resources for a target tenant. - * - * Returns the target tenant if the user belongs to it directly, otherwise the first user tenant that owns the - * target tenant as a subtenant. Returns null when the user cannot act for the target tenant. - */ - public static function actingTenantFor(User $user, Tenant $targetTenant): ?Tenant - { - /** @var \Illuminate\Support\Collection $tenants */ - $tenants = $user->tenants()->get(); - - /** @var Tenant|null $directTenant */ - $directTenant = $tenants->firstWhere('id', $targetTenant->id); - if ($directTenant !== null) { - return $directTenant; + if (is_string($resource) && !class_exists($resource)) { + return $resource; } - - /** @var Tenant|null $parentTenant */ - $parentTenant = $tenants->first( - fn(Tenant $tenant) => in_array($targetTenant->id, $tenant->descendantIds(), true) - ); - - return $parentTenant; - } - - private static function tenantPlanHasResourceAvailable(Tenant $tenant, string|Model $resource): bool - { - $resourceToCheck = $tenant->plan?->resources() - ->where('resources.key', self::resourceKey($resource)) - ->where('resources.type', 'tenant') - ->first(); - - if (empty($resourceToCheck)) { - return false; - } - - $max = $resourceToCheck->pivot->limit; - if (empty($max)) { - return false; - } - - if ($max == -1) { - return true; + if (!in_array(IsResource::class, class_uses_recursive($resource))) { + throw new InvalidResourceException('Given resource does not implement '.IsResource::class); } - - return self::getUsage($tenant, $resourceToCheck->model_type) < $max; + return $resource::getResourceKey(); } - private static function userControlsTenant(User $user, Tenant $targetTenant): bool + /** Customer ownership, not permission authorization. Callers must still use policies. */ + public static function actingTenantFor(User $user, Tenant $targetTenant): ?Tenant { - return $user->tenants() - ->get() - ->contains(fn(Tenant $tenant) => in_array($targetTenant->id, $tenant->descendantIds(), true)); + $tenants = $user->tenants()->lockForUpdate()->get(); + return $tenants->firstWhere('id', $targetTenant->id) + ?? $tenants->first(fn (Tenant $tenant) => in_array($targetTenant->id, $tenant->descendantIds(), true)); } } diff --git a/packages/core/tests/Fakes/BuildsResourceUsageFixtures.php b/packages/core/tests/Fakes/BuildsResourceUsageFixtures.php new file mode 100644 index 0000000..2e3ae7c --- /dev/null +++ b/packages/core/tests/Fakes/BuildsResourceUsageFixtures.php @@ -0,0 +1,62 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + config(['app.key' => 'base64:'.base64_encode(str_repeat('q', 32))]); + Bus::fake(); + Model::withoutEvents(function (): void { + $this->quotaPlan = Plan::query()->create(['name' => 'Resource test '.str()->ulid()]); + foreach (Resource::query()->get() as $resource) { + $this->quotaPlan->resources()->attach($resource, ['limit' => -1]); + } + $this->quotaTenant = Tenant::query()->create(['name' => 'Resource test', 'plan_id' => $this->quotaPlan->id]); + $this->quotaActor = User::query()->create([ + 'first_name' => 'Resource', 'last_name' => 'Test', + 'email' => 'resource-'.str()->ulid().'@example.test', 'password' => 'test-password', + ]); + $role = Role::query()->create(['name' => 'Resource test administrator '.str()->ulid()]); + $permission = Permission::query()->firstOrCreate(['key' => '*'], ['name' => 'Everything']); + $role->permissions()->attach($permission, ['inheritable' => true]); + $this->quotaActor->roles()->attach($role); + $this->quotaActor->tenants()->attach($this->quotaTenant, ['role_id' => $role->id]); + }); + } + + private function quotaChild(): Tenant + { + return Model::withoutEvents(function (): Tenant { + $plan = Plan::query()->create(['name' => 'Resource child plan', 'tenant_id' => $this->quotaTenant->id]); + foreach ($this->quotaPlan->resources as $resource) { + $plan->resources()->attach($resource, ['limit' => -1]); + } + $child = Tenant::query()->create(['name' => 'Resource child', 'parent_tenant_id' => $this->quotaTenant->id, 'plan_id' => $plan->id]); + Quota::transaction(fn () => PlanAssignments::syncTenantReservations($this->quotaTenant, $child, $plan)); + + return $child; + }); + } +} diff --git a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php index 1668754..75e79fe 100644 --- a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php +++ b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php @@ -9,15 +9,26 @@ use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Tests\TestCase; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Tests\Fakes\BuildsResourceUsageFixtures; + +require_once dirname(__DIR__) . '/Fakes/BuildsResourceUsageFixtures.php'; class EnvironmentResourceUsageTest extends TestCase { + use DatabaseTransactions, BuildsResourceUsageFixtures; + protected function setUp(): void + { + parent::setUp(); + $this->buildResourceUsageFixtures(); + } + public function test_tenant_environment_creation_records_resource_usage(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => $this->quotaPlan->id]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ @@ -36,8 +47,8 @@ public function test_tenant_environment_creation_records_resource_usage(): void public function test_tenant_environment_creation_respects_plan_resource_limit(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $resource = Resource::query()->where('key', Environment::getResourceKey())->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); $plan = Plan::query()->create([ @@ -56,19 +67,18 @@ public function test_tenant_environment_creation_respects_plan_resource_limit(): ->postJson('/api/tenants/' . $tenant->id . '/environments', [ 'name' => 'Rejected Environment ' . str()->ulid(), ]) - ->assertStatus(500); + ->assertUnprocessable(); } - public function test_parent_tenant_user_creating_environment_for_subtenant_counts_usage_on_both_tenants(): void + public function test_parent_tenant_user_creating_environment_for_subtenant_charges_only_the_owner_with_parent_reservations(): void { - $parentTenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $subTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $parentTenant = $this->quotaTenant; + $subTenant = $this->quotaChild(); + $user = $this->quotaActor; $parentTenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); $subTenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); - $subTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $parentTenant->update(['plan_id' => $this->quotaPlan->id]); $this->actingAs($user, 'sanctum'); @@ -77,7 +87,7 @@ public function test_parent_tenant_user_creating_environment_for_subtenant_count 'name' => 'Subtenant Usage Test Environment ' . str()->ulid(), ]); - $this->assertDatabaseHas('tenant_usage', [ + $this->assertDatabaseMissing('tenant_usage', [ 'tenant_id' => $parentTenant->id, 'user_id' => $user->id, 'resource_key' => Environment::getResourceKey(), @@ -93,10 +103,10 @@ public function test_parent_tenant_user_creating_environment_for_subtenant_count public function test_tenant_environment_actions_write_audit_log_with_tenant_and_environment_context(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => $this->quotaPlan->id]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ diff --git a/packages/core/tests/Feature/NodeResourceUsageTest.php b/packages/core/tests/Feature/NodeResourceUsageTest.php index f856c2c..6e2d975 100644 --- a/packages/core/tests/Feature/NodeResourceUsageTest.php +++ b/packages/core/tests/Feature/NodeResourceUsageTest.php @@ -13,12 +13,18 @@ use RuntimeException; use Tests\Fakes\FakeNodeAdapter; use Tests\TestCase; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Tests\Fakes\BuildsResourceUsageFixtures; + +require_once dirname(__DIR__) . '/Fakes/BuildsResourceUsageFixtures.php'; class NodeResourceUsageTest extends TestCase { + use DatabaseTransactions, BuildsResourceUsageFixtures; protected function setUp(): void { parent::setUp(); + $this->buildResourceUsageFixtures(); require_once dirname(__DIR__) . '/Fakes/FakeNodeAdapter.php'; @@ -29,10 +35,10 @@ protected function setUp(): void public function test_tenant_owned_node_creates_and_removes_resource_usage(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => $this->quotaPlan->id]); $this->actingAs($user, 'sanctum'); @@ -56,8 +62,8 @@ public function test_tenant_owned_node_creates_and_removes_resource_usage(): voi public function test_tenant_owned_node_creation_respects_plan_resource_limit(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $resource = Resource::query()->where('key', Node::getResourceKey())->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $plan = Plan::query()->create([ @@ -75,22 +81,21 @@ public function test_tenant_owned_node_creation_respects_plan_resource_limit(): $this->createTenantNode($tenant, 'Rejected Node'); } - public function test_parent_tenant_user_creating_node_for_subtenant_counts_usage_on_both_tenants(): void + public function test_parent_tenant_user_creating_node_for_subtenant_charges_only_the_owner_with_parent_reservations(): void { - $parentTenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $subTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $parentTenant = $this->quotaTenant; + $subTenant = $this->quotaChild(); + $user = $this->quotaActor; $parentTenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $subTenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); - $subTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $parentTenant->update(['plan_id' => $this->quotaPlan->id]); $this->actingAs($user, 'sanctum'); $node = $this->createTenantNode($subTenant, 'Forced Subtenant Node'); - $this->assertDatabaseHas('tenant_usage', [ + $this->assertDatabaseMissing('tenant_usage', [ 'tenant_id' => $parentTenant->id, 'user_id' => $user->id, 'resource_key' => Node::getResourceKey(), @@ -106,9 +111,9 @@ public function test_parent_tenant_user_creating_node_for_subtenant_counts_usage public function test_node_with_assigned_environments_cannot_be_deleted_and_keeps_usage(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); - $plan = Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; + $plan = $this->quotaPlan; $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $tenant->update(['plan_id' => $plan->id]); @@ -142,10 +147,10 @@ public function test_node_with_assigned_environments_cannot_be_deleted_and_keeps public function test_tenant_node_actions_write_audit_log_with_tenant_context(): void { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant = $this->quotaTenant; + $user = $this->quotaActor; $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => $this->quotaPlan->id]); $this->actingAs($user, 'sanctum'); diff --git a/packages/core/tests/Feature/QuotaConcurrencyTest.php b/packages/core/tests/Feature/QuotaConcurrencyTest.php new file mode 100644 index 0000000..090ee86 --- /dev/null +++ b/packages/core/tests/Feature/QuotaConcurrencyTest.php @@ -0,0 +1,102 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + $tenant = $plan = null; + $users = $processes = $inputs = []; + try { + Model::withoutEvents(function () use (&$tenant, &$plan, &$users): void { + $definition = Resource::query()->where('key', 'users')->where('type', 'tenant')->firstOrFail(); + $plan = Plan::query()->create(['name' => 'Concurrent quota '.str()->ulid()]); + $plan->resources()->attach($definition, ['limit' => 1]); + $tenant = Tenant::query()->create(['name' => 'Concurrent quota', 'plan_id' => $plan->id]); + for ($i = 0; $i < 4; $i++) { + $users[] = User::query()->create([ + 'first_name' => 'Concurrent', 'last_name' => 'Quota', + 'email' => 'quota-race-'.str()->ulid().'@example.test', 'password' => 'test-password', + ]); + } + foreach (array_slice($users, 0, 2) as $user) { + $user->tenants()->attach($tenant, ['role_id' => null]); + } + }); + + $script = <<<'PHP' +require 'vendor/autoload.php'; +$app = require 'bootstrap/app.php'; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$started = false; +try { + // A caller that owns an outer transaction must retry that entire transaction, + // not just its quota savepoint, if MariaDB rejects a stale snapshot (1020). + Illuminate\Support\Facades\DB::transaction(function () use ($argv, &$started) { + $tenant = Froxlor\Core\Models\Tenant::findOrFail($argv[1]); + $actor = Froxlor\Core\Models\User::findOrFail($argv[2]); + $target = Froxlor\Core\Models\User::findOrFail($argv[3]); + Illuminate\Support\Facades\DB::table('tenant_usage')->where('tenant_id', $tenant->id)->get(); + if (!$started) { + $started = true; + echo "READY\n"; + flush(); + fgets(STDIN); + } + Froxlor\Core\Support\Resource::addUsage($tenant, $target, $actor); + }, 3); + exit(0); +} catch (Froxlor\Core\Exceptions\ResourceLimitException) { + exit(3); +} catch (Throwable $error) { + fwrite(STDERR, get_class($error).': '.$error->getMessage()."\n".$error->getTraceAsString()); + exit(4); +} +PHP; + for ($i = 0; $i < 2; $i++) { + $inputs[$i] = new InputStream; + $processes[$i] = new Process([PHP_BINARY, '-r', $script, $tenant->id, $users[$i]->id, $users[$i + 2]->id], base_path()); + $processes[$i]->setTimeout(15)->setInput($inputs[$i])->start(); + $ready = $processes[$i]->waitUntil(fn ($type, $output) => str_contains($output, 'READY')); + $this->assertTrue($ready, $processes[$i]->getErrorOutput()); + } + foreach ($inputs as $input) { + $input->write("GO\n"); + $input->close(); + } + $codes = array_map(fn (Process $process) => $process->wait(), $processes); + sort($codes); + $this->assertSame([0, 3], $codes, implode("\n", array_map(fn (Process $process) => $process->getErrorOutput(), $processes))); + $this->assertSame(1, DB::table('tenant_usage')->where('tenant_id', $tenant->id)->count()); + } finally { + foreach ($processes as $process) { + if ($process->isRunning()) { + $process->stop(); + } + } + if ($tenant !== null) { + DB::table('tenant_usage')->where('tenant_id', $tenant->id)->delete(); + DB::table('tenant_user')->where('tenant_id', $tenant->id)->delete(); + DB::table('tenants')->where('id', $tenant->id)->delete(); + } + if ($plan !== null) { + DB::table('plan_resource')->where('plan_id', $plan->id)->delete(); + DB::table('plans')->where('id', $plan->id)->delete(); + } + DB::table('users')->whereIn('id', array_map(fn (User $user) => $user->id, $users))->delete(); + } + } +} diff --git a/packages/core/tests/Feature/QuotaIntegrityTest.php b/packages/core/tests/Feature/QuotaIntegrityTest.php new file mode 100644 index 0000000..24fee47 --- /dev/null +++ b/packages/core/tests/Feature/QuotaIntegrityTest.php @@ -0,0 +1,452 @@ +assertSame('mariadb', DB::connection()->getDriverName()); + config(['app.key' => 'base64:'.base64_encode(str_repeat('q', 32))]); + } + + public function test_tenant_limit_is_shared_by_users(): void + { + $tenant = $this->tenant(['tenant:users' => 1]); + $a = $this->member($tenant); + $b = $this->member($tenant); + Resource::addUsage($tenant, $this->user(), $a); + $this->assertFalse(Resource::hasUsageAvailable($tenant, User::class, $b)); + $this->expectException(ResourceLimitException::class); + Resource::addUsage($tenant, $this->user(), $b); + } + + public function test_personal_limit_cannot_replace_total_and_can_restrict_it(): void + { + $tenant = $this->tenant(['tenant:users' => 1]); + $a = $this->member($tenant, $this->plan(['tenant:users' => -1])); + Resource::addUsage($tenant, $this->user(), $a); + $this->assertFalse(Resource::hasUsageAvailable($tenant, 'users', $a)); + $tenant = $this->tenant(['tenant:users' => -1]); + $b = $this->member($tenant, $this->plan(['tenant:users' => 0])); + $this->assertFalse(Resource::hasUsageAvailable($tenant, 'users', $b)); + } + + public function test_missing_resource_and_zero_deny_usage(): void + { + foreach ([[], ['tenant:users' => 0]] as $limits) { + $tenant = $this->tenant($limits); + $this->assertFalse(Resource::hasUsageAvailable($tenant, 'users', $this->member($tenant))); + } + } + + public function test_environment_users_share_environment_and_customer_budgets(): void + { + $tenant = $this->tenant(['environment:users' => 2]); + $environment = $this->environment($tenant, $this->plan(['environment:users' => 1])); + $a = $this->environmentMember($environment); + $b = $this->environmentMember($environment); + Resource::addEnvironmentUsage($environment, $this->user(), $a); + $this->assertFalse($environment->userHasResourceAvailable($b, 'users')); + $second = $this->environment($tenant); + $c = $this->environmentMember($second); + Resource::addEnvironmentUsage($second, $this->user(), $c); + $this->assertFalse($second->userHasResourceAvailable($c, 'users')); + $this->assertSame(2, Quota::tenantUsed($tenant->id, 'users', 'environment')); + } + + public function test_environment_and_tenant_resource_keys_do_not_collide(): void + { + $tenant = $this->tenant(['tenant:users' => 0, 'environment:users' => 1]); + $environment = $this->environment($tenant); + $actor = $this->environmentMember($environment); + $this->assertTrue($environment->userHasResourceAvailable($actor, 'users')); + $this->assertFalse(Resource::hasUsageAvailable($tenant, 'users', $actor)); + } + + public function test_tenant_user_environment_limit_is_shared_across_environments(): void + { + $tenant = $this->tenant(['environment:users' => 10]); + $actor = $this->member($tenant, $this->plan(['environment:users' => 1])); + $a = $this->environment($tenant); + $b = $this->environment($tenant); + $this->attachEnvironment($a, $actor); + $this->attachEnvironment($b, $actor); + Resource::addEnvironmentUsage($a, $this->user(), $actor); + $this->assertFalse($b->userHasResourceAvailable($actor, 'users')); + } + + public function test_usage_booking_and_removal_are_idempotent_without_observer_recursion(): void + { + $tenant = $this->tenant(['tenant:users' => -1, 'environment:users' => -1]); + $environment = $this->environment($tenant); + $actor = $this->environmentMember($environment); + $target = $this->user(); + $one = Resource::addEnvironmentUsage($environment, $target, $actor); + $two = Resource::addEnvironmentUsage($environment, $target, $actor); + $this->assertSame($one->id, $two->id); + $this->assertSame($target->id, $one->resource->id); + $this->assertSame(1, Resource::getEnvironmentUsage($environment, 'users')); + Resource::removeEnvironmentUsage($environment, $target); + Resource::removeEnvironmentUsage($environment, $target); + $this->assertSame(0, Resource::getEnvironmentUsage($environment, 'users')); + $one = Resource::addUsage($tenant, $target, $actor); + $this->assertSame($one->id, Resource::addUsage($tenant, $target, $actor)->id); + $this->assertSame($target->id, $one->resource->id); + Resource::removeUsage($tenant, $target); + Resource::removeUsage($tenant, $target); + $this->assertSame(0, Resource::getUsage($tenant, 'users')); + } + + public function test_same_user_membership_in_two_environments_counts_twice_for_customer(): void + { + $tenant = $this->tenant(['environment:users' => 2]); + $a = $this->environment($tenant); + $b = $this->environment($tenant); + $actor = $this->environmentMember($a); + $this->attachEnvironment($b, $actor); + $target = $this->user(); + Resource::addEnvironmentUsage($a, $target, $actor); + Resource::addEnvironmentUsage($b, $target, $actor); + $this->assertSame(2, Quota::tenantUsed($tenant->id, 'users', 'environment')); + } + + public function test_reservations_reduce_parent_capacity_but_child_usage_is_not_double_charged(): void + { + $parent = $this->tenant(['tenant:environments' => 2]); + $actor = $this->member($parent); + $plan = $this->plan(['tenant:environments' => 2], $parent); + $child = Model::withoutEvents(fn () => Tenant::query()->create([ + 'name' => 'Quota child', 'parent_tenant_id' => $parent->id, 'plan_id' => $plan->id, + ])); + Quota::transaction(fn () => PlanAssignments::syncTenantReservations($parent, $child, $plan)); + $this->assertFalse(Resource::hasUsageAvailable($parent, Environment::class, $actor)); + $this->actingAs($actor); + $environment = Environment::query()->create(['tenant_id' => $child->id, 'name' => 'Owned by child']); + $this->assertSame(0, Resource::getUsage($parent, Environment::class)); + $this->assertSame(1, Resource::getUsage($child, Environment::class)); + $this->assertSame(0, PlanAssignments::availableTenantBudget($parent)['tenant:environments']); + $this->assertDatabaseHas('tenant_usage', ['tenant_id' => $child->id, 'resource_id' => $environment->id]); + } + + public function test_multi_child_plan_growth_is_validated_as_a_single_reservation_change(): void + { + $parent = $this->tenant(['tenant:users' => 10]); + $plan = $this->plan(['tenant:users' => 4], $parent); + foreach ([1, 2] as $i) { + $child = Model::withoutEvents(fn () => Tenant::query()->create([ + 'name' => 'Child '.$i, 'plan_id' => $plan->id, 'parent_tenant_id' => $parent->id, + ])); + Quota::transaction(fn () => PlanAssignments::syncTenantReservations($parent, $child, $plan)); + } + $this->rejects(fn () => PlanAssignments::updatePlanResourceLimit($plan, $this->definition('tenant:users'), 6, $parent)); + $this->assertSame(4, Quota::limit($plan->id, 'users', 'tenant')); + $this->assertSame(8, (int) DB::table('tenant_resource_reservations')->where('tenant_id', $parent->id)->sum('limit')); + PlanAssignments::updatePlanResourceLimit($plan, $this->definition('tenant:users'), 5, $parent); + $this->assertSame(10, (int) DB::table('tenant_resource_reservations')->where('tenant_id', $parent->id)->sum('limit')); + } + + public function test_parent_downgrade_cannot_leave_explicit_user_or_environment_plans_above_it(): void + { + $tenant = $this->tenant(['tenant:users' => 10, 'environment:users' => 10]); + $this->member($tenant, $this->plan(['tenant:users' => 8])); + $this->environment($tenant, $this->plan(['environment:users' => 8])); + foreach (['tenant:users', 'environment:users'] as $key) { + $this->rejects(fn () => PlanAssignments::updatePlanResourceLimit($tenant->plan, $this->definition($key), 5)); + } + $this->assertSame(10, Quota::limit($tenant->plan_id, 'users', 'environment')); + } + + public function test_environment_plan_downgrade_and_switch_validate_explicit_member_plans(): void + { + $tenant = $this->tenant(['environment:users' => 10]); + $plan = $this->plan(['environment:users' => 10]); + $environment = $this->environment($tenant, $plan); + $member = $this->environmentMember($environment); + Model::withoutEvents(fn () => $member->environments()->updateExistingPivot($environment->id, ['plan_id' => $this->plan(['environment:users' => 8])->id])); + $this->rejects(fn () => PlanAssignments::updatePlanResourceLimit($plan, $this->definition('environment:users'), 5)); + $smaller = $this->plan(['environment:users' => 5]); + $this->rejects(fn () => $environment->update(['plan_id' => $smaller->id])); + $this->assertSame($plan->id, $environment->fresh()->plan_id); + } + + public function test_inherited_environment_plan_change_validates_explicit_environment_user_plan(): void + { + $tenant = $this->tenant(['environment:users' => 10]); + $environment = $this->environment($tenant); + $actor = $this->environmentMember($environment); + Model::withoutEvents(fn () => $actor->environments()->updateExistingPivot($environment->id, ['plan_id' => $this->plan(['environment:users' => 8])->id])); + $this->rejects(fn () => PlanAssignments::updatePlanResourceLimit($tenant->plan, $this->definition('environment:users'), 5)); + } + + public function test_user_plan_cannot_drop_a_resource_with_existing_usage(): void + { + $tenant = $this->tenant(['tenant:users' => 10, 'tenant:environments' => 10]); + $plan = $this->plan(['tenant:users' => 5, 'tenant:environments' => 5]); + $actor = $this->member($tenant, $plan); + Resource::addUsage($tenant, $this->user(), $actor); + $this->rejects(fn () => PlanAssignments::removePlanResource($plan, $this->definition('tenant:users'))); + $newPlan = $this->plan(['tenant:environments' => 5]); + $this->rejects(fn () => PlanAssignments::ensureAssignableToTenantUser($newPlan->id, $tenant, 'plan_id', $actor->id)); + } + + public function test_environment_user_plan_cannot_drop_a_resource_with_existing_usage(): void + { + $tenant = $this->tenant(['environment:users' => 10, 'tenant:users' => 10]); + $environment = $this->environment($tenant); + $plan = $this->plan(['environment:users' => 5, 'tenant:users' => 5]); + $actor = $this->environmentMember($environment); + Model::withoutEvents(fn () => $actor->environments()->updateExistingPivot($environment->id, ['plan_id' => $plan->id])); + Resource::addEnvironmentUsage($environment, $this->user(), $actor); + $this->rejects(fn () => PlanAssignments::removePlanResource($plan, $this->definition('environment:users'))); + } + + public function test_unlimited_reservation_cannot_survive_a_finite_parent_limit(): void + { + $parent = $this->tenant(['tenant:users' => -1]); + $plan = $this->plan(['tenant:users' => -1], $parent); + $child = Model::withoutEvents(fn () => Tenant::query()->create([ + 'name' => 'Unlimited child', 'plan_id' => $plan->id, 'parent_tenant_id' => $parent->id, + ])); + Quota::transaction(fn () => PlanAssignments::syncTenantReservations($parent, $child, $plan)); + $this->rejects(fn () => PlanAssignments::updatePlanResourceLimit($parent->plan, $this->definition('tenant:users'), 100)); + $this->assertSame(-1, Quota::limit($parent->plan_id, 'users', 'tenant')); + } + + public function test_failed_booking_rolls_back_the_new_object(): void + { + $tenant = $this->tenant(['tenant:users' => 0]); + $actor = $this->member($tenant); + $email = 'rollback-'.str()->ulid().'@example.test'; + try { + Resource::transaction(function () use ($tenant, $actor, $email): void { + $target = $this->user($email); + Resource::addUsage($tenant, $target, $actor); + }); + $this->fail('Expected quota failure.'); + } catch (ResourceLimitException) { + $this->assertDatabaseMissing('users', ['email' => $email]); + } + } + + public function test_environment_api_returns_422_and_keeps_one_object_at_limit(): void + { + $tenant = $this->tenant(['tenant:environments' => 1]); + $actor = $this->member($tenant); + $this->makeAdmin($actor); + $path = '/api/tenants/'.$tenant->id.'/environments'; + $this->actingAs($actor, 'sanctum')->postJson($path, ['name' => 'Allowed'])->assertCreated(); + $this->postJson($path, ['name' => 'Rejected'])->assertUnprocessable()->assertJsonValidationErrors('resources'); + $this->assertSame(1, $tenant->environments()->count()); + $this->assertSame(1, Resource::getUsage($tenant, Environment::class)); + } + + public function test_environment_user_creation_rolls_back_when_tenant_budget_rejects_membership(): void + { + $tenant = $this->tenant(['tenant:users' => 0, 'environment:users' => 1]); + $environment = $this->environment($tenant); + $actor = $this->environmentMember($environment); + $role = $this->makeAdmin($actor); + $email = 'rollback-api-'.str()->ulid().'@example.test'; + $this->actingAs($actor, 'sanctum')->postJson('/api/tenants/'.$tenant->id.'/environments/'.$environment->id.'/users', [ + 'first_name' => 'Rejected', 'last_name' => 'User', 'email' => $email, 'password' => 'test-password', + 'tenant_role' => $role->id, 'environment_role' => $role->id, + ])->assertUnprocessable(); + $this->assertDatabaseMissing('users', ['email' => $email]); + $this->assertSame(0, Resource::getUsage($tenant, 'users')); + } + + public function test_environment_membership_books_creator_and_detach_releases_quota(): void + { + $tenant = $this->tenant(['tenant:users' => 5, 'environment:users' => 1]); + $environment = $this->environment($tenant); + $actor = $this->environmentMember($environment); + $target = $this->member($tenant); + $this->actingAs($actor); + $target->environments()->attach($environment, ['role_id' => null]); + $this->assertSame(1, Resource::getEnvironmentUsage($environment, 'users', $actor)); + $target->environments()->detach($environment); + $this->assertSame(0, Resource::getEnvironmentUsage($environment, 'users')); + } + + public function test_tenant_administrator_can_book_environment_usage_without_environment_membership(): void + { + $tenant = $this->tenant(['tenant:users' => 5, 'environment:users' => 1]); + $environment = $this->environment($tenant); + $actor = $this->member($tenant); + $target = $this->member($tenant); + $this->actingAs($actor); + $target->environments()->attach($environment, ['role_id' => null]); + $this->assertSame(1, Resource::getEnvironmentUsage($environment, 'users', $actor)); + $this->assertFalse($environment->userHasResourceAvailable($actor, 'users')); + } + + public function test_assigned_plan_deletion_is_rejected_and_unused_plan_can_be_deleted(): void + { + $tenant = $this->tenant(['tenant:users' => 5]); + $this->rejects(fn () => $tenant->plan->delete()); + $this->assertDatabaseHas('plans', ['id' => $tenant->plan_id]); + $unused = $this->plan(['tenant:users' => 1]); + $unused->delete(); + $this->assertDatabaseMissing('plans', ['id' => $unused->id]); + } + + public function test_tenant_owned_plan_and_role_creation_consume_metadata_quota(): void + { + $tenant = $this->tenant(['tenant:plans' => 1, 'tenant:roles' => 1]); + $actor = $this->member($tenant); + $this->actingAs($actor); + $plan = Plan::query()->create(['name' => 'Counted plan', 'tenant_id' => $tenant->id]); + $role = Role::query()->create(['name' => 'Counted role', 'tenant_id' => $tenant->id]); + $this->assertSame(1, Resource::getUsage($tenant, Plan::class)); + $this->assertSame(1, Resource::getUsage($tenant, Role::class)); + $plan->delete(); + $role->delete(); + $this->assertSame(0, Resource::getUsage($tenant, Plan::class)); + } + + public function test_shared_quota_mutex_blocks_a_second_connection(): void + { + $name = 'quota-lock-test'; + config(['database.connections.'.$name => config('database.connections.'.DB::getDefaultConnection())]); + $other = DB::connection($name); + try { + $other->statement('SET SESSION innodb_lock_wait_timeout = 1'); + $other->beginTransaction(); + $this->assertNotNull($other->table('quota_locks')->where('id', 1)->sharedLock()->first()); + $other->rollBack(); + Quota::transaction(function () use ($other): void { + $other->beginTransaction(); + try { + $other->table('quota_locks')->where('id', 1)->sharedLock()->first(); + $this->fail('Quota lock was not held.'); + } catch (QueryException $exception) { + $this->assertSame(1205, $exception->errorInfo[1]); + } finally { + $other->rollBack(); + } + }); + } finally { + DB::purge($name); + } + } + + public function test_child_tenants_consume_parent_slots_and_reserve_their_plan_atomically(): void + { + $parent = $this->tenant(['tenant:tenants' => 1, 'tenant:users' => 10]); + $plan = $this->plan(['tenant:users' => 2], $parent); + $this->actingAs($this->member($parent)); + $child = Tenant::query()->create(['name' => 'Counted child', 'parent_tenant_id' => $parent->id, 'plan_id' => $plan->id]); + $this->assertSame(1, Resource::getUsage($parent, Tenant::class)); + $this->assertDatabaseHas('tenant_resource_reservations', ['tenant_id' => $parent->id, 'reserved_for_tenant_id' => $child->id, 'limit' => 2]); + try { + Tenant::query()->create(['name' => 'Rejected child', 'parent_tenant_id' => $parent->id, 'plan_id' => $plan->id]); + $this->fail('Expected quota failure.'); + } catch (ResourceLimitException) { + $this->assertSame(1, $parent->subTenants()->count()); + } + $child->delete(); + $this->assertSame(0, Resource::getUsage($parent, Tenant::class)); + $this->assertSame(10, PlanAssignments::availableTenantBudget($parent)['tenant:users']); + } + + private function rejects(callable $operation): void + { + try { + $operation(); + $this->fail('Expected plan validation failure.'); + } catch (ValidationException $exception) { + $this->assertNotEmpty($exception->errors()); + } + } + + private function definition(string $identifier): Definition + { + [$type, $key] = explode(':', $identifier); + $model = match ($key) { + 'users' => User::class, 'tenants' => Tenant::class, 'environments' => Environment::class, 'plans' => Plan::class, 'roles' => Role::class + }; + + return Definition::query()->firstOrCreate(['key' => $key, 'type' => $type], ['name' => $key, 'model_type' => $model]); + } + + private function plan(array $limits, ?Tenant $owner = null): Plan + { + return Model::withoutEvents(function () use ($limits, $owner): Plan { + $plan = Plan::query()->create(['name' => 'Quota '.str()->ulid(), 'tenant_id' => $owner?->id]); + foreach ($limits as $identifier => $limit) { + $plan->resources()->attach($this->definition($identifier), ['limit' => $limit]); + } + + return $plan; + }); + } + + private function tenant(array $limits): Tenant + { + return Model::withoutEvents(fn () => Tenant::query()->create(['name' => 'Quota tenant', 'plan_id' => $this->plan($limits)->id])); + } + + private function environment(Tenant $tenant, ?Plan $plan = null): Environment + { + return Model::withoutEvents(fn () => Environment::query()->create(['name' => 'Quota environment', 'tenant_id' => $tenant->id, 'plan_id' => $plan?->id])); + } + + private function user(?string $email = null): User + { + return User::query()->create(['first_name' => 'Quota', 'last_name' => 'Test', 'email' => $email ?? 'quota-'.str()->ulid().'@example.test', 'password' => 'test-password']); + } + + private function member(Tenant $tenant, ?Plan $plan = null): User + { + $user = $this->user(); + Model::withoutEvents(fn () => $user->tenants()->attach($tenant, ['role_id' => null, 'plan_id' => $plan?->id])); + + return $user; + } + + private function environmentMember(Environment $environment): User + { + $actor = $this->member($environment->tenant); + $this->attachEnvironment($environment, $actor); + + return $actor; + } + + private function attachEnvironment(Environment $environment, User $actor): void + { + Model::withoutEvents(fn () => $actor->environments()->attach($environment, ['role_id' => null])); + } + + private function makeAdmin(User $user): Role + { + $role = Role::query()->create(['name' => 'Quota administrator '.str()->ulid()]); + $permission = Permission::query()->firstOrCreate(['key' => '*'], ['name' => 'Everything']); + $role->permissions()->attach($permission, ['inheritable' => true]); + $user->roles()->attach($role); + + return $role; + } +} diff --git a/packages/core/tests/Feature/UserSecurityBoundariesTest.php b/packages/core/tests/Feature/UserSecurityBoundariesTest.php index 704a33d..1daf44e 100644 --- a/packages/core/tests/Feature/UserSecurityBoundariesTest.php +++ b/packages/core/tests/Feature/UserSecurityBoundariesTest.php @@ -95,7 +95,7 @@ public function test_null_tenant_role_revokes_permissions_but_preserves_membersh { $actor = $this->tenantUser($this->role(['tenants.users.*'])); $target = $this->tenantUser($this->role(['tenants.index'])); - $target->tenants()->updateExistingPivot($this->tenant->id, ['plan_id' => $this->tenant->plan_id]); + Model::withoutEvents(fn () => $target->tenants()->updateExistingPivot($this->tenant->id, ['plan_id' => $this->tenant->plan_id])); $this->actingAs($actor, 'sanctum')->patchJson($this->tenantPath($target), ['role_id' => null])->assertOk(); $pivot = $target->tenants()->firstOrFail()->pivot; $this->assertNull($pivot->role_id); @@ -191,7 +191,7 @@ public function test_guard_preserves_root_status_and_allows_normal_tenant_update $this->actingAs($actor, 'sanctum')->patchJson('/api/tenants/'.$this->tenant->id, ['name' => 'Updated root'])->assertOk(); $other = Tenant::query()->create(['name' => 'Another root', 'plan_id' => $this->tenant->plan_id]); try { - AdministrationGuard::run(fn () => $this->tenant->update(['parent_tenant_id' => $other->id])); + AdministrationGuard::run(fn () => Model::withoutEvents(fn () => $this->tenant->update(['parent_tenant_id' => $other->id]))); $this->fail('Expected root administration protection.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('administration', $exception->errors()); @@ -356,7 +356,7 @@ private function tenantUser(?Role $role): User private function environmentUser(Role $role): User { $user = $this->tenantUser(null); - $user->environments()->attach($this->environment, ['role_id' => $role->id]); + Model::withoutEvents(fn () => $user->environments()->attach($this->environment, ['role_id' => $role->id])); return $user; } From 32944185486962224e44c20cec6367a46450d088 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 23:07:01 +0200 Subject: [PATCH 09/11] Add extensible environment jail reconciliation --- ...1_01_000097_add_environment_jail_state.php | 21 ++ packages/core/docs/environment-jails.md | 156 ++++++++ .../core/resources/node/reconcile_jail.py | 335 ++++++++++++++++++ .../views/node/scripts/create_jail.blade.php | 36 +- .../Jobs/Environment/CreateEnvironment.php | 126 +++---- .../Jobs/Environment/DeleteEnvironment.php | 42 +-- .../Jobs/Environment/SyncEnvironmentJail.php | 34 ++ packages/core/src/Models/Environment.php | 2 +- packages/core/src/Models/NodeEnvironment.php | 8 +- .../Providers/FroxlorCoreServiceProvider.php | 15 +- .../Services/Environment/Jail/JailContext.php | 33 ++ .../Services/Environment/Jail/JailPlan.php | 57 +++ .../Environment/Jail/JailProvider.php | 13 + .../Environment/Jail/JailReconciler.php | 37 ++ .../Environment/Jail/JailRegistry.php | 52 +++ .../Environment/CreateEnvironmentTest.php | 131 +++++-- .../Feature/Environment/JailPlanTest.php | 94 +++++ .../Environment/test_jail_filesystem.py | 210 +++++++++++ 18 files changed, 1266 insertions(+), 136 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000097_add_environment_jail_state.php create mode 100644 packages/core/docs/environment-jails.md create mode 100644 packages/core/resources/node/reconcile_jail.py create mode 100644 packages/core/src/Jobs/Environment/SyncEnvironmentJail.php create mode 100644 packages/core/src/Services/Environment/Jail/JailContext.php create mode 100644 packages/core/src/Services/Environment/Jail/JailPlan.php create mode 100644 packages/core/src/Services/Environment/Jail/JailProvider.php create mode 100644 packages/core/src/Services/Environment/Jail/JailReconciler.php create mode 100644 packages/core/src/Services/Environment/Jail/JailRegistry.php create mode 100644 packages/core/tests/Feature/Environment/JailPlanTest.php create mode 100644 packages/core/tests/Feature/Environment/test_jail_filesystem.py diff --git a/packages/core/database/migrations/0001_01_01_000097_add_environment_jail_state.php b/packages/core/database/migrations/0001_01_01_000097_add_environment_jail_state.php new file mode 100644 index 0000000..4038666 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000097_add_environment_jail_state.php @@ -0,0 +1,21 @@ +string('jail_path')->nullable(); + $table->json('jail_manifest')->nullable(); + }); + } + + public function down(): void + { + Schema::table('node_environments', fn (Blueprint $table) => $table->dropColumn(['jail_path', 'jail_manifest'])); + } +}; diff --git a/packages/core/docs/environment-jails.md b/packages/core/docs/environment-jails.md new file mode 100644 index 0000000..8437c85 --- /dev/null +++ b/packages/core/docs/environment-jails.md @@ -0,0 +1,156 @@ +# Environment jails and package extensions + +Node service installation and environment provisioning remain independent. +`CreateEnvironment` creates the base Jailkit jail; installed packages declare +additional jail contents through `Services/Environment/Jail`. No web/mail/FTP +service is installed by a jail provider. + +## Package contract + +Register a `JailProvider` in the package service provider's `boot()`: + +```php +app(JailRegistry::class)->register(PhpCliJailProvider::class); +``` + +The imports are from `Froxlor\Core\Services\Environment\Jail`. A minimal example: + +```php +final class PhpCliJailProvider implements JailProvider +{ + public function key(): string + { + return 'froxlor/web:php-cli'; + } + + public function plan(JailContext $context): JailPlan + { + // The package reads its own persisted, validated configuration using + // $context->environmentId, $context->tenantId and $context->nodeId. + // Return new JailPlan when CLI access is disabled for this environment. + return (new JailPlan)->binary('/usr/bin/php8.4'); + } +} +``` + +The example version is illustrative: the web package selects the actual binary +from its configuration, and its separate node service provider installs it on the +host. The jail layer copies it at the same path plus its Jailkit-discovered +dependencies. It does not install arbitrary OS packages requested by providers. + +For a jail-local identity, a provider can additionally declare: + +```php +$plan->user('webworker', $allocatedUid, $allocatedGid, + home: '/home/worker', shell: '/usr/sbin/nologin'); +``` + +Packages must choose/persist suitable UID/GID mappings. UID/GID 0, the primary +environment identity, duplicate IDs and conflicting user definitions are rejected. +These are **jail-local passwd/group identities**, not panel users or host accounts. +Passwords remain locked. No SSH login, host account, home directory, recursive +chown or process restart is implied. A future SSH/FTP package must separately +define its authentication and host-account lifecycle. Changing a UID/GID does not +change file ownership; packages must coordinate existing data/processes explicitly. + +Providers receive an immutable identity context, not an adapter. Their output is +declarative: absolute system binary paths and typed user definitions, not shell +snippets. Providers are nevertheless installed, trusted PHP code, not a sandbox. +Do not expose arbitrary provider registration or filesystem paths to customer input. + +## Applying changes + +The registry combines **all** active providers deterministically. Shared binaries +and identical user definitions are merged; conflicting declarations fail before +node writes. Returning an empty plan disables a provider's contribution. + +After an authorized package configuration/resource mutation has committed: + +```php +use Froxlor\Core\Jobs\Environment\SyncEnvironmentJail; + +foreach ($environment->nodes as $node) { + SyncEnvironmentJail::dispatch($environment, $node)->afterCommit(); +} +``` + +The job recomputes the complete current plan at execution time, not dispatch time. +Package upgrades, removal and settings changes must enqueue affected environments; +there is deliberately no global settings observer or implicit periodic scan. +After uninstalling a provider, reload long-running workers before reconciliation. +No new HTTP endpoint is introduced. Calling packages must retain their existing +policy checks before dispatching; the reconciler is an internal trusted service. + +Creation runs the same reconciler immediately after attaching the base jail. A +package failure leaves that attachment/jail intact; retry applies package state +without recreating or deleting customer data. A delayed sync for a removed +attachment is a no-op. Base provisioning failure cleans only artifacts carrying +that attempt's random creation token, never pre-existing host accounts. + +## Ownership and safety + +- The jail must be below root-owned, non-group/world-writable real directories. + Traversal, account-file symlinks and escaping/writable directory symlinks fail. +- Creation scripts are shell-escaped and streamed as base64, not uploaded to a + predictable executable in `/tmp`. A final marker confirms remote success even + for adapters that do not propagate exit status reliably. +- Host binaries must be root-owned and non-writable, without setuid/setgid bits. + Jailkit copies into a private root-owned staging tree under `/var/lib`; the + reconciler then installs checked files. It never executes jail binaries as root. +- Existing base-jail files are borrowed: never overwritten, adopted or deleted by + a package. Such files continue to require base-jail maintenance separately. +- Only files introduced by this reconciler are updated or removed. Dependencies + are retained while present in the combined desired dependency tree, and removed + after the last declared consumer disappears. Unmanaged/customer files remain. +- A root-owned `.froxlor-jail-state.json` records ownership and content hashes. + Changed managed files or account entries cause refusal, not blind overwrite. + Write-ahead records allow retries after partial changes; individual file writes + are atomic, but the entire remote operation is **not** a filesystem transaction. + Package workload/session coordination remains the calling package's responsibility. +- Removing a user removes only managed passwd/group/shadow/gshadow entries. Home + directories and customer files are retained, and the primary identity is protected. +- Create, sync and delete share the node environment lifecycle cache lock. Sync + and delete also use the same node-side per-jail file lock. Deletion checks host + identity, refuses unexpected mounts and uses normal (not lazy) unmounts before + removing files. Busy/unexpected mounts leave the attachment available for retry. + +The current base jail still uses the existing Jailkit profile groups +`basicshell jk_lsh editors netutils sftp scp rsync`. Their contents are not claimed +by package providers. A chroot is **not** container/VM isolation: this work does not +add PID/network/user namespaces or an isolation guarantee against hostile native +code. In particular, the existing proc mount shares the node's PID view. + +## Deployment and operations + +Apply migration `0001_01_01_000097_add_environment_jail_state.php` first. Each +`node_environments` attachment records its actual `jail_path` and the last applied +package declarations (`jail_manifest`). Later `node.basedir` changes do not relocate +existing jails. Legacy attachments resolve the current base setting on their first +sync; verify it still points to their actual jail before applying the update. The +node-side identity check rejects a mismatch; there is no automatic path migration. + +Provisioning requires Jailkit and Python 3.9+ on the node. New jail creation installs +`sudo jailkit python3` if needed, independently of base node service setup. Existing +jails need these prerequisites before sync/delete. Missing runtime or binary paths +fail closed rather than silently skipping work. + +Create/sync jobs use the separate `environment-jails` queue connection, with +`retry_after=2100`, job timeout 1860 seconds and cache-lock lease 2040 seconds. +Configure a worker (or equivalent Horizon supervisor): + +```sh +php artisan queue:work environment-jails --queue=environment-jails --timeout=1860 --tries=3 +``` + +Use a shared cache store across workers. Root helpers have their own timeouts. +Environment deletion remains synchronous in Core so remote failure prevents +database deletion. Remote provisioning must never be wrapped in a quota database +transaction. Crash recovery may need operator intervention if base creation was +interrupted before the database attachment; unknown directories are not adopted. + +Tests: `CreateEnvironmentTest`, `JailPlanTest` and `test_jail_filesystem.py`. +The PHP tests use transactional MariaDB fixtures and a recording adapter. The +Python suite runs as root **only on the isolated Docker node**, including real +Jailkit copying and an optional full create/reconcile/delete lifecycle. Test trees, +users and mounts are independently allocated and cleaned; mounted leftovers cause +cleanup to stop instead of recursively removing mounted data. diff --git a/packages/core/resources/node/reconcile_jail.py b/packages/core/resources/node/reconcile_jail.py new file mode 100644 index 0000000..bf01555 --- /dev/null +++ b/packages/core/resources/node/reconcile_jail.py @@ -0,0 +1,335 @@ +"""Root-side, package-declared jail reconciliation. No code from inside the jail runs. + +The root-owned journal records both sides of a pending write before touching it. +A killed run can converge on retry; drift and unmanaged identities fail closed. +""" +import base64 +import fcntl +import hashlib +import json +import os +import pwd +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def trusted_directory(path): + path = Path(path) + for directory in [path, *path.parents]: + info = directory.lstat() + require(stat.S_ISDIR(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o022, + "Jail path must consist of root-owned, non-writable real directories") + + +def fingerprint(path): + if path.is_symlink(): + return "link:" + os.readlink(path) + if not path.exists(): + return None + info = path.stat() + require(stat.S_ISREG(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o022, + "Managed file is not a protected regular file") + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def destination(root, name, create=False): + require(name.startswith("/") and ".." not in name.split("/") and "\0" not in name, + "Invalid jail-relative path") + parent = (root / name.lstrip("/")).parent.resolve() + require(parent == root or root in parent.parents, "Jail symlink escapes the jail") + missing = [] + cursor = parent + while not cursor.exists(): + missing.append(cursor) + cursor = cursor.parent + trusted_directory(cursor) + if create: + for directory in reversed(missing): + directory.mkdir(mode=0o755) + return parent / Path(name).name + + +def atomic_write(path, contents, mode): + fd, temporary = tempfile.mkstemp(prefix=".froxlor-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(contents) + stream.flush() + os.fchmod(stream.fileno(), mode) + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def stage_binaries(paths, stage): + for path in paths: + require(re.fullmatch(r"/(?:usr/)?s?bin/[a-zA-Z0-9_+.-]+", path) and Path(path).name not in (".", ".."), + "Invalid executable path") + source = Path(path).resolve(strict=True) + trusted_directory(source.parent) + info = source.stat() + require(stat.S_ISREG(info.st_mode) and info.st_uid == 0 and info.st_mode & 0o111 + and not info.st_mode & 0o6022, "Unsafe host executable") + # Jailkit only writes to a new root-owned staging jail, never to customer paths. + subprocess.run(["/usr/sbin/jk_cp", "-f", "-j", str(stage), path], check=True, + stdout=subprocess.DEVNULL, timeout=900) + + +def account_lines(definition): + name = definition["name"] + return { + "passwd": f'{name}:!:{definition["uid"]}:{definition["gid"]}::{definition["home"]}:{definition["shell"]}', + "group": f'{name}:x:{definition["gid"]}:', + "shadow": f"{name}:!:0:0:99999:7:::", + "gshadow": f"{name}:!::", + } + + +def reconcile(payload): + root = Path(payload["root"]) + require(re.fullmatch(r"/(?:[a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)+[0-9A-HJKMNP-TV-Z]{26}", str(root), re.I), + "Invalid jail root") + trusted_directory(root) + host_user = pwd.getpwnam(payload["user"]) + require(host_user.pw_uid == payload["guid"] and host_user.pw_gid == payload["guid"] + and Path(host_user.pw_dir).resolve().is_relative_to(root), "Primary host identity does not match jail") + lock_path = destination(root, "/.froxlor-jail.lock") + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + require(os.fstat(lock_fd).st_uid == 0 and os.fstat(lock_fd).st_nlink == 1, "Unsafe jail lock") + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + # Jailkit rejects any jail below world-writable /tmp, even a private subdir. + trusted_directory(Path('/var/lib')) + with tempfile.TemporaryDirectory(prefix="froxlor-jail-", dir='/var/lib') as temporary: + stage = Path(temporary) + stage_binaries(payload["plan"]["binaries"], stage) + apply_state(root, payload, stage) + finally: + os.close(lock_fd) + + +def delete_jail(payload): + root = Path(payload["root"]) + require(re.fullmatch(r"/(?:[a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)+[0-9A-HJKMNP-TV-Z]{26}", str(root), re.I), "Invalid jail root") + require(payload["guid"] > 0 and payload["user"] != "root", "Protected host identity") + trusted_directory(root if root.exists() else root.parent) + if payload.get("cleanup_token"): + marker = root / ".froxlor-creation-token" + if not marker.exists(): + return + require(not marker.is_symlink() and marker.stat().st_uid == 0 + and marker.read_text() == payload["cleanup_token"], "Creation cleanup ownership mismatch") + try: + user = pwd.getpwnam(payload["user"]) + except KeyError: + user = None + if user is not None: + require(user.pw_uid == payload["guid"] and user.pw_gid == payload["guid"] + and Path(user.pw_dir).resolve().is_relative_to(root), "Primary host identity does not match jail") + else: + require(not root.exists() or (root / ".froxlor-jail-state.json").is_file() + or (root / ".froxlor-creation-token").is_file(), "Unknown jail without primary account") + import grp + try: + group = grp.getgrnam(payload["user"]) + except KeyError: + group = None + require(group is None or group.gr_gid == payload["guid"], "Primary host group does not match jail") + lock_fd = None + if root.exists(): + lock_fd = os.open(root / ".froxlor-jail.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + # No lazy unmount: never recursively delete through still-mounted filesystems. + mounts = [] + for line in Path("/proc/self/mountinfo").read_text().splitlines(): + target = line.split()[4] + if target == str(root) or target.startswith(str(root) + "/"): + require(target in (str(root / "dev/pts"), str(root / "proc")), "Unexpected jail mount; manual intervention required") + mounts.append(target) + if user is not None: + subprocess.run(["pkill", "-u", str(payload["guid"])], check=False) + for target in sorted(mounts, key=len, reverse=True): + subprocess.run(["umount", target], check=True, timeout=30) + if user is not None: + subprocess.run(["userdel", payload["user"]], check=True, timeout=30) + # Debian may remove the private group as part of userdel (USERGROUPS_ENAB). + try: + group = grp.getgrnam(payload["user"]) + except KeyError: + group = None + if group is not None: + require(group.gr_gid == payload["guid"], "Primary host group changed during deletion") + subprocess.run(["groupdel", payload["user"]], check=True, timeout=30) + if root.exists(): + shutil.rmtree(root) + finally: + if lock_fd is not None: + os.close(lock_fd) + + +def apply_state(root, payload, stage): + """Separated from transport/staging for Linux filesystem regression tests.""" + state_path = destination(root, "/.froxlor-jail-state.json") + require(not state_path.is_symlink(), "Unsafe jail journal") + state = {"version": 1, "files": {}, "users": {}, "entrypoints": []} + if state_path.exists(): + fingerprint(state_path) + state = json.loads(state_path.read_text()) + require(state.get("version") == 1, "Unsupported jail journal version") + plan = payload["plan"] + desired_users = plan["users"] or {} + require(isinstance(desired_users, dict), "Invalid user map") + users = state["users"] + new_users = {} + for name, user in desired_users.items(): + require(name == user["name"] and re.fullmatch(r"[a-z_][a-z0-9_-]{0,30}", name) + and name not in ("root", payload["user"]), "Protected jail user") + require(all(isinstance(user[key], int) and 0 < user[key] <= 2147483647 + and user[key] != payload["guid"] for key in ("uid", "gid")), "Protected jail UID/GID") + for key in ("home", "shell"): + require(re.fullmatch(r"/[a-zA-Z0-9_./+-]*", user[key]) and ".." not in user[key].split("/"), + "Invalid account path") + new_users[name] = account_lines(user) + + account_writes = {} + for kind in ("passwd", "group", "shadow", "gshadow"): + path = destination(root, "/etc/" + kind) + require(not path.is_symlink(), "Symlink account database") + fingerprint(path) + lines = path.read_text().splitlines() if path.exists() else [] + records = {} + for line in lines: + name = line.split(":", 1)[0] + require(name not in records, "Duplicate account database entry") + records[name] = line + for name in set(users) | set(new_users): + current = records.get(name) + allowed = users.get(name, {}).get(kind, []) + require(current is None or current in allowed, "Unmanaged or modified jail identity: " + name) + # Never alias another account's UID/GID, including a shared primary identity. + if kind in ("passwd", "group"): + ids = {} + for name, line in records.items(): + if name not in users: + ids[int(line.split(":")[2])] = name + for name, user in desired_users.items(): + identifier = user["uid" if kind == "passwd" else "gid"] + require(identifier not in ids or ids[identifier] == name, "Jail UID/GID collision") + ids[identifier] = name + for name in users: + records.pop(name, None) + for name, definition in new_users.items(): + records[name] = definition[kind] + # No account changes means do not manufacture missing shadow files. + if users or new_users: + account_writes[kind] = (path, ("\n".join(records.values()) + "\n").encode()) + + writes = {} + staged_names = set() + protected = {"/etc/passwd", "/etc/group", "/etc/shadow", "/etc/gshadow"} + for source in sorted(stage.rglob("*")): + if source.is_dir() and not source.is_symlink(): + continue + name = "/" + str(source.relative_to(stage)) + staged_names.add(name) + require(name not in protected and not name.startswith("/.froxlor"), "Protected staged path") + path = destination(root, name) + if path.is_symlink(): + resolved = path.resolve() + require(resolved.is_relative_to(root), "Existing symlink escapes jail") + trusted_directory(resolved.parent) + previous = fingerprint(path) + if source.is_symlink(): + target = os.readlink(source) + # Absolute symlinks inside a chroot become relative for safe host-side access. + target_path = root / target.lstrip("/") if target.startswith("/") else path.parent / target + target_path = target_path.resolve() + require(target_path == root or root in target_path.parents, "Unsafe staged symlink") + target = os.path.relpath(target_path, path.parent) + new_fingerprint = "link:" + target + contents = target + else: + require(source.is_file(), "Unsupported staged file type") + contents = source.read_bytes() + new_fingerprint = "sha256:" + hashlib.sha256(contents).hexdigest() + if previous is not None and name not in state["files"]: + # Existing base-jail files remain borrowed, never adopted or overwritten. + continue + require(previous is None or previous in state["files"].get(name, []), "Managed binary drift: " + name) + writes[name] = (contents, new_fingerprint, source.is_symlink(), source.stat().st_mode & 0o755 if not source.is_symlink() else 0) + + # The staging tree is the union of dependencies of ALL desired binaries. + # Only files first introduced by this reconciler can be garbage-collected. + retired = set(state["files"]) - staged_names + removals = [] + for name in retired: + if name not in state["files"] or name in writes: + continue + path = destination(root, name) + current = fingerprint(path) + require(current is None or current in state["files"][name], "Managed binary drift: " + name) + removals.append(name) + + # Write-ahead ownership journal: both old and intended contents are valid on retry. + for name, (_, new_fingerprint, _, _) in writes.items(): + state["files"][name] = sorted(set(state["files"].get(name, []) + [new_fingerprint])) + for name, definitions in new_users.items(): + for kind, line in definitions.items(): + state["users"].setdefault(name, {}).setdefault(kind, []) + state["users"][name][kind] = sorted(set(state["users"][name][kind] + [line])) + state["entrypoints"] = sorted(set(state["entrypoints"]) | set(plan["binaries"])) + atomic_write(state_path, json.dumps(state, sort_keys=True).encode(), 0o600) + + for name, (contents, _, symlink, mode) in writes.items(): + path = destination(root, name, create=True) + if symlink: + with tempfile.TemporaryDirectory(prefix=".froxlor-", dir=path.parent) as temporary: + link = Path(temporary) / "link" + link.symlink_to(contents) + os.replace(link, path) + else: + atomic_write(path, contents, mode) + for name in removals: + destination(root, name).unlink(missing_ok=True) + state["files"].pop(name, None) + for kind, (path, contents) in account_writes.items(): + destination(root, "/etc/" + kind, create=True) + atomic_write(path, contents, 0o600 if kind in ("shadow", "gshadow") else 0o644) + + for name, (_, new_fingerprint, _, _) in writes.items(): + state["files"][name] = [new_fingerprint] + state["users"] = {name: {kind: [line] for kind, line in definition.items()} for name, definition in new_users.items()} + state["entrypoints"] = plan["binaries"] + state["providers"] = plan["providers"] + atomic_write(state_path, json.dumps(state, sort_keys=True).encode(), 0o600) + + +if __name__ == "__main__": + try: + payload = json.loads(base64.b64decode(sys.argv[1], validate=True)) + if payload.get("operation") == "delete": + delete_jail(payload) + else: + reconcile(payload) + print("FROXLOR_JAIL_OK") + except Exception as error: + print("Jail reconciliation rejected: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/packages/core/resources/views/node/scripts/create_jail.blade.php b/packages/core/resources/views/node/scripts/create_jail.blade.php index 9057750..b899a92 100644 --- a/packages/core/resources/views/node/scripts/create_jail.blade.php +++ b/packages/core/resources/views/node/scripts/create_jail.blade.php @@ -1,11 +1,31 @@ {{ '#!/bin/bash' }} # Exit on error -set -e +set -euo pipefail -JAILUSER="{{ $userName }}" -JAILBASE="{{ $userRootDir }}" -HOMEDIR="{{ $userHomeDir }}" -GUID="{{ $userGuid }}" +JAILUSER={!! escapeshellarg($userName) !!} +JAILBASE={!! escapeshellarg($userRootDir) !!} +HOMEDIR={!! escapeshellarg($userHomeDir) !!} +GUID={!! escapeshellarg((string) $userGuid) !!} + +# Keep stdout exclusively for the success marker, including on verbose Jailkit versions. +exec 3>&1 +exec 1>&2 + +# Refuse adoption of existing host identities, even with matching numeric IDs. +! getent passwd "$JAILUSER" >/dev/null || exit 1 +! getent group "$JAILUSER" >/dev/null || exit 1 + +# Every ancestor must be an actual root-owned directory, not a customer-controlled link. +parent=$(dirname "$JAILBASE") +while [ "$parent" != / ]; do + if [ -e "$parent" ] || [ -L "$parent" ]; then + [ -d "$parent" ] && [ ! -L "$parent" ] && [ "$(stat -c %u "$parent")" = 0 ] || exit 1 + mode=$(stat -c %a "$parent") + (( (8#$mode & 8#022) == 0 )) || exit 1 + fi + parent=$(dirname "$parent") +done +mkdir -p "$(dirname "$JAILBASE")" echo "Creating jail for user $JAILUSER at $JAILBASE" @@ -34,9 +54,11 @@ fi # Create base structure -mkdir -p "$JAILBASE" +mkdir "$JAILBASE" chown root:root "$JAILBASE" chmod 755 "$JAILBASE" +printf %s {!! escapeshellarg($creationToken) !!} > "$JAILBASE/.froxlor-creation-token" +chmod 600 "$JAILBASE/.froxlor-creation-token" if ! getent group "$JAILUSER" >/dev/null; then groupadd -g "$GUID" "$JAILUSER" @@ -72,3 +94,5 @@ mkdir -p "$HOMEDIR/logs" chown -R "$JAILUSER:$JAILUSER" "$HOMEDIR/web" chown -R "$JAILUSER:$JAILUSER" "$HOMEDIR/logs" + +printf FROXLOR_JAIL_CREATED >&3 diff --git a/packages/core/src/Jobs/Environment/CreateEnvironment.php b/packages/core/src/Jobs/Environment/CreateEnvironment.php index 5419e3d..ca09364 100644 --- a/packages/core/src/Jobs/Environment/CreateEnvironment.php +++ b/packages/core/src/Jobs/Environment/CreateEnvironment.php @@ -5,128 +5,120 @@ use Froxlor\Core\Events\Tenant\EnvironmentCreated; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Node; +use Froxlor\Core\Services\Environment\Jail\JailContext; +use Froxlor\Core\Services\Environment\Jail\JailReconciler; +use Froxlor\Core\Services\Environment\Jail\JailRegistry; use Froxlor\Core\Services\Node\Adapter\Adapter; use Froxlor\Core\Services\Node\Exceptions\NodeException; use Froxlor\Core\Support\Audit; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Throwable; class CreateEnvironment implements ShouldQueue { use Queueable; + public int $timeout = 1860; + /** * Create a new job instance. */ public function __construct(private readonly Environment $environment, private readonly Node $node) { + $this->onConnection('environment-jails')->onQueue('environment-jails')->afterCommit(); } /** * Execute the job. + * * @throws Throwable */ public function handle(): void { - Cache::lock("nodes:{$this->node->id}:environment-create", 600)->block(60, function () { + Cache::lock("nodes:{$this->node->id}:environments", 2040)->block(60, function () { $node = $this->node->refresh(); $environment = $this->environment->refresh(); // Queue retries must not provision an already attached environment again. - if ($environment->nodes()->whereKey($node->id)->exists()) { + if (($attached = $environment->nodes()->whereKey($node->id)->first()) !== null) { + app(JailReconciler::class)->sync($environment, $node); + if ($attached->pivot->jail_manifest === null) { + $this->recordCreated($environment, $node, $attached->pivot->unix_name, (int) $attached->pivot->guid); + } + return; } - // base-directory for node... - $nodeBaseDir = $node->getSetting('node.basedir', '/var/environments'); $adapter = $node->adapter(); - if (!$adapter->isConnected()) { + if (! $adapter->isConnected()) { throw new NodeException(trans('Unable to connect to node ":node"...', ['node' => $node->hostname])); } $unixName = $node->latestUnixName; $guid = $this->resolveNextFreeGuid($adapter, $node->nextGuid); - - if (!$adapter->storageExists($nodeBaseDir)) { - Log::notice(trans('Data base-directory ":dir" does not exists. Creating...', ['dir' => $nodeBaseDir])); - if ($adapter->exec([ - 'mkdir -p ' . escapeshellarg($nodeBaseDir) - ]) === false) { - throw new NodeException(trans('Unable to create node base-directory ":dir".', ['dir' => $nodeBaseDir])); - } - } + $context = JailContext::forEnvironment($environment, $node, $unixName, $guid); + // Provider conflicts and unsafe paths must fail before creating anything. + app(JailRegistry::class)->plan($context); // base-directory for environment... - $envBaseDir = $nodeBaseDir . '/' . $environment->id; + $envBaseDir = $context->root; if ($adapter->storageExists($envBaseDir)) { throw new NodeException(trans('Data environment-directory ":dir" already exists.', ['dir' => $envBaseDir])); } + $token = (string) Str::uuid(); $createJailCommand = view('froxlor-core::node.scripts.create_jail', [ 'userRootDir' => rtrim($envBaseDir, '/'), - 'userHomeDir' => $envBaseDir . '/home', + 'userHomeDir' => $envBaseDir.'/home', 'userName' => $unixName, 'userGuid' => $guid, + 'creationToken' => $token, ])->render(); - $scriptPath = '/tmp/createhome-' . $environment->id . '.sh'; - - if (!$adapter->storagePut($scriptPath, $createJailCommand)) { - throw new NodeException(trans('Unable to upload jail creation script.')); - } - try { if ($adapter->exec([ - 'if ! command -v jk_init >/dev/null 2>&1 || ! command -v jk_jailuser >/dev/null 2>&1; then', - 'export DEBIAN_FRONTEND=noninteractive', - 'apt-get update', - 'apt-get install -y sudo jailkit', + 'if ! command -v jk_init >/dev/null 2>&1 || ! command -v jk_jailuser >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then', + 'timeout 180s /bin/bash -ec '.escapeshellarg('export DEBIAN_FRONTEND=noninteractive; apt-get update; apt-get install -y sudo jailkit python3').' >&2', 'fi', - 'chmod +x ' . escapeshellarg($scriptPath), - escapeshellarg($scriptPath), - 'rm -f ' . escapeshellarg($scriptPath), - ]) === false) { + 'printf %s '.escapeshellarg(base64_encode($createJailCommand)).' | base64 -d | /usr/bin/timeout --signal=TERM --kill-after=30s 300s /bin/bash -se', + ]) !== 'FROXLOR_JAIL_CREATED') { throw new NodeException(trans('Unable to create jail.')); } // connect environment with node (must be mode=main) - $environment->nodes()->attach($node, [ + DB::transaction(fn () => $environment->nodes()->attach($node, [ 'unix_name' => $unixName, 'guid' => $guid, - 'mode' => 'main' - ]); + 'jail_path' => $envBaseDir, + 'mode' => 'main', + ])); } catch (Throwable $exception) { - $this->cleanupFailedProvisioning($adapter, $envBaseDir, $unixName, $scriptPath, $node, $environment); + $this->cleanupFailedProvisioning($adapter, $envBaseDir, $unixName, $guid, $token, $node, $environment); throw $exception; - } finally { - // The script is removed by the successful command chain as well; this also - // covers failures before that final command can run. - try { - $adapter->storageDelete($scriptPath); - } catch (Throwable $cleanupException) { - Log::warning('Unable to remove temporary environment creation script.', [ - 'node_id' => $node->id, - 'environment_id' => $environment->id, - 'script' => $scriptPath, - 'exception' => $cleanupException, - ]); - } } - event(new EnvironmentCreated($environment)); - Audit::notice('environment "' . $environment->name . '" created on node "' . $node->name . '"', $environment->tenant, $environment, [ - 'node_id' => $node->id, - 'unix_name' => $unixName, - 'guid' => $guid, - ]); + // Extension failure must NOT destroy an attached jail. Retry only reconciles it. + app(JailReconciler::class)->sync($environment, $node); + + $this->recordCreated($environment, $node, $unixName, $guid); }); } + private function recordCreated(Environment $environment, Node $node, string $unixName, int $guid): void + { + event(new EnvironmentCreated($environment)); + Audit::notice('environment "'.$environment->name.'" created on node "'.$node->name.'"', $environment->tenant, $environment, [ + 'node_id' => $node->id, 'unix_name' => $unixName, 'guid' => $guid, + ]); + } + /** * Remove remote state left behind by a failed jail creation or database attach. */ @@ -134,34 +126,28 @@ private function cleanupFailedProvisioning( Adapter $adapter, string $envBaseDir, string $unixName, - string $scriptPath, + int $guid, + string $token, Node $node, Environment $environment, ): void { try { - $cleanupResult = $adapter->exec([ - 'JAILBASE=' . escapeshellarg(rtrim($envBaseDir, '/')), - 'JAILUSER=' . escapeshellarg($unixName), - 'if mountpoint -q "$JAILBASE/dev/pts"; then umount -l "$JAILBASE/dev/pts" || true; fi', - 'if mountpoint -q "$JAILBASE/proc"; then umount -l "$JAILBASE/proc" || true; fi', - 'if getent passwd "$JAILUSER" >/dev/null; then pkill -u "$JAILUSER" || true; fi', - 'if getent passwd "$JAILUSER" >/dev/null; then userdel "$JAILUSER" || true; fi', - 'if getent group "$JAILUSER" >/dev/null; then groupdel "$JAILUSER" || true; fi', - 'if [ -n "$JAILBASE" ] && [ "$JAILBASE" != "/" ] && [ -d "$JAILBASE" ]; then rm -rf -- "$JAILBASE"; fi', - ]); + $payload = base64_encode(json_encode(['operation' => 'delete', 'root' => $envBaseDir, + 'user' => $unixName, 'guid' => $guid, 'cleanup_token' => $token], JSON_THROW_ON_ERROR)); + $helper = file_get_contents(__DIR__.'/../../../resources/node/reconcile_jail.py'); + $cleanupResult = $adapter->exec(['printf %s '.escapeshellarg(base64_encode($helper)) + .' | base64 -d | timeout --signal=TERM --kill-after=30s 120s /usr/bin/python3 - '.escapeshellarg($payload)]); - if ($cleanupResult === false) { + if (trim((string) $cleanupResult) !== 'FROXLOR_JAIL_OK') { Log::warning('Unable to clean up failed environment provisioning.', [ 'node_id' => $node->id, 'environment_id' => $environment->id, - 'script' => $scriptPath, ]); } } catch (Throwable $cleanupException) { Log::warning('Unable to clean up failed environment provisioning.', [ 'node_id' => $node->id, 'environment_id' => $environment->id, - 'script' => $scriptPath, 'exception' => $cleanupException, ]); } @@ -175,17 +161,17 @@ private function cleanupFailedProvisioning( private function resolveNextFreeGuid(Adapter $adapter, int $guid): int { $resolvedGuid = $adapter->exec([ - 'candidate=' . escapeshellarg((string)$guid), + 'candidate='.escapeshellarg((string) $guid), 'while getent passwd "$candidate" >/dev/null || getent group "$candidate" >/dev/null; do', 'candidate=$((candidate + 1))', 'done', 'printf "%s" "$candidate"', ]); - if ($resolvedGuid === false || !ctype_digit(trim($resolvedGuid))) { + if ($resolvedGuid === false || ! ctype_digit(trim($resolvedGuid))) { throw new NodeException(trans('Unable to resolve next free guid.')); } - return (int)trim($resolvedGuid); + return (int) trim($resolvedGuid); } } diff --git a/packages/core/src/Jobs/Environment/DeleteEnvironment.php b/packages/core/src/Jobs/Environment/DeleteEnvironment.php index ff4b016..978097a 100644 --- a/packages/core/src/Jobs/Environment/DeleteEnvironment.php +++ b/packages/core/src/Jobs/Environment/DeleteEnvironment.php @@ -3,6 +3,7 @@ namespace Froxlor\Core\Jobs\Environment; use Froxlor\Core\Models\Environment; +use Froxlor\Core\Services\Environment\Jail\JailContext; use Froxlor\Core\Services\Node\Exceptions\NodeException; use Froxlor\Core\Support\Audit; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,9 +18,7 @@ class DeleteEnvironment implements ShouldQueue /** * Create a new job instance. */ - public function __construct(private readonly Environment $environment) - { - } + public function __construct(private readonly Environment $environment) {} /** * Remove the environment jail from every assigned node. @@ -31,26 +30,29 @@ public function handle(): void $this->environment->loadMissing(['nodes', 'tenant']); foreach ($this->environment->nodes as $node) { - Cache::lock("nodes:{$node->id}:environment-delete", 120)->block(30, function () use ($node) { - $node = $node->refresh(); + Cache::lock("nodes:{$node->id}:environments", 2040)->block(30, function () use ($node) { + $node = $this->environment->nodes()->whereKey($node->id)->first(); + if ($node === null) { + return; + } $adapter = $node->adapter(); - if (!$adapter->isConnected()) { + if (! $adapter->isConnected()) { throw new NodeException(trans('Unable to connect to node ":node"...', ['node' => $node->hostname])); } - $nodeBaseDir = $node->getSetting('node.basedir', '/var/environments'); - $envBaseDir = $nodeBaseDir . '/' . $this->environment->id; $unixName = $node->pivot->unix_name; $guid = $node->pivot->guid; + $context = JailContext::forEnvironment($this->environment, $node, $unixName, (int) $guid, $node->pivot->jail_path); + $envBaseDir = $context->root; - if ($adapter->exec($this->deleteCommands($envBaseDir, $unixName)) === false) { + if (trim((string) $adapter->exec($this->deleteCommands($context))) !== 'FROXLOR_JAIL_OK') { throw new NodeException(trans('Unable to delete environment-directory ":dir".', ['dir' => $envBaseDir])); } $this->environment->nodes()->detach($node->id); - Audit::notice('environment "' . $this->environment->name . '" deleted from node "' . $node->name . '"', $this->environment->tenant, $this->environment, [ + Audit::notice('environment "'.$this->environment->name.'" deleted from node "'.$node->name.'"', $this->environment->tenant, $this->environment, [ 'node_id' => $node->id, 'unix_name' => $unixName, 'guid' => $guid, @@ -62,22 +64,16 @@ public function handle(): void /** * Build the shell commands that remove a jail and its system account. * - * Mounts are lazily unmounted before deleting files so still-open handles - * from previous sessions do not leave the jail directory behind. + * Reject identity/path mismatches and unexpected mounts before destructive changes. * * @return array */ - private function deleteCommands(string $envBaseDir, string $unixName): array + private function deleteCommands(JailContext $context): array { - return [ - 'JAILBASE=' . escapeshellarg(rtrim($envBaseDir, '/')), - 'JAILUSER=' . escapeshellarg($unixName), - 'if mountpoint -q "$JAILBASE/dev/pts"; then umount -l "$JAILBASE/dev/pts"; fi', - 'if mountpoint -q "$JAILBASE/proc"; then umount -l "$JAILBASE/proc"; fi', - 'if getent passwd "$JAILUSER" >/dev/null; then pkill -u "$JAILUSER" || true; fi', - 'if getent passwd "$JAILUSER" >/dev/null; then userdel "$JAILUSER"; fi', - 'if getent group "$JAILUSER" >/dev/null; then groupdel "$JAILUSER"; fi', - 'if [ -n "$JAILBASE" ] && [ "$JAILBASE" != "/" ] && [ -d "$JAILBASE" ]; then rm -rf -- "$JAILBASE"; fi', - ]; + $payload = base64_encode(json_encode(['operation' => 'delete', 'root' => $context->root, + 'user' => $context->user, 'guid' => $context->guid], JSON_THROW_ON_ERROR)); + $helper = file_get_contents(__DIR__.'/../../../resources/node/reconcile_jail.py'); + + return ['printf %s '.escapeshellarg(base64_encode($helper)).' | base64 -d | timeout --signal=TERM --kill-after=30s 120s /usr/bin/python3 - '.escapeshellarg($payload)]; } } diff --git a/packages/core/src/Jobs/Environment/SyncEnvironmentJail.php b/packages/core/src/Jobs/Environment/SyncEnvironmentJail.php new file mode 100644 index 0000000..917355c --- /dev/null +++ b/packages/core/src/Jobs/Environment/SyncEnvironmentJail.php @@ -0,0 +1,34 @@ +onConnection('environment-jails')->onQueue('environment-jails')->afterCommit(); + } + + public function handle(JailReconciler $reconciler): void + { + Cache::lock("nodes:{$this->node->id}:environments", 2040)->block(30, function () use ($reconciler): void { + $environment = $this->environment->fresh(); + $node = $this->node->fresh(); + // Deletion before a queued sync is a harmless no-op, not recreation. + if ($environment !== null && $node !== null && $environment->nodes()->whereKey($node->id)->exists()) { + $reconciler->sync($environment, $node); + } + }); + } +} diff --git a/packages/core/src/Models/Environment.php b/packages/core/src/Models/Environment.php index 6f1aaa3..51f6d40 100644 --- a/packages/core/src/Models/Environment.php +++ b/packages/core/src/Models/Environment.php @@ -48,7 +48,7 @@ public function nodes(): BelongsToMany 'node_environments', 'environment_id', 'node_id' - )->withPivot(['unix_name', 'guid'])->using(NodeEnvironment::class); + )->withPivot(['unix_name', 'guid', 'jail_path', 'jail_manifest'])->using(NodeEnvironment::class); } public function tenant(): BelongsTo diff --git a/packages/core/src/Models/NodeEnvironment.php b/packages/core/src/Models/NodeEnvironment.php index c342d6a..85d96d9 100644 --- a/packages/core/src/Models/NodeEnvironment.php +++ b/packages/core/src/Models/NodeEnvironment.php @@ -16,6 +16,8 @@ * @property string $unix_name * @property string $guid * @property string $mode + * @property string|null $jail_path + * @property array|null $jail_manifest Last successfully applied package declarations (not ownership authority). * @property Carbon $created_at * @property Carbon $updated_at * @property Node $node @@ -28,6 +30,11 @@ class NodeEnvironment extends Pivot public $timestamps = true; + protected function casts(): array + { + return ['guid' => 'integer', 'jail_manifest' => 'array']; + } + public function node(): BelongsTo { return $this->belongsTo(Node::class); @@ -37,5 +44,4 @@ public function environment(): BelongsTo { return $this->belongsTo(Environment::class); } - } diff --git a/packages/core/src/Providers/FroxlorCoreServiceProvider.php b/packages/core/src/Providers/FroxlorCoreServiceProvider.php index cf99b47..620f261 100644 --- a/packages/core/src/Providers/FroxlorCoreServiceProvider.php +++ b/packages/core/src/Providers/FroxlorCoreServiceProvider.php @@ -129,16 +129,19 @@ public function boot(): void public function register(): void { + $this->app->singleton(\Froxlor\Core\Services\Environment\Jail\JailRegistry::class); $this->app->singleton(NodeServiceRegistry::class); $this->app->bind(NodeServiceExecutor::class, AdapterNodeServiceExecutor::class); // Isolate long-running setup jobs from ordinary queues with short retry windows. - if (!$this->app['config']->has('queue.connections.node-setup')) { - $driver = $this->app['config']->get('queue.default') === 'redis' ? 'redis' : 'database'; - $connection = $this->app['config']->get('queue.connections.' . $driver, []); - $this->app['config']->set('queue.connections.node-setup', array_merge($connection, [ - 'driver' => $driver, 'queue' => 'node-setup', 'retry_after' => 1500, 'after_commit' => true, - ])); + foreach (['node-setup' => 1500, 'environment-jails' => 2100] as $queue => $retryAfter) { + if (!$this->app['config']->has('queue.connections.' . $queue)) { + $driver = $this->app['config']->get('queue.default') === 'redis' ? 'redis' : 'database'; + $connection = $this->app['config']->get('queue.connections.' . $driver, []); + $this->app['config']->set('queue.connections.' . $queue, array_merge($connection, [ + 'driver' => $driver, 'queue' => $queue, 'retry_after' => $retryAfter, 'after_commit' => true, + ])); + } } // Configs diff --git a/packages/core/src/Services/Environment/Jail/JailContext.php b/packages/core/src/Services/Environment/Jail/JailContext.php new file mode 100644 index 0000000..cadfa93 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/JailContext.php @@ -0,0 +1,33 @@ + 2147483647 + || ! preg_match('#^/(?:[a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)+'.preg_quote($environmentId, '#').'$#D', $root)) { + throw new InvalidArgumentException('Unsafe jail identity or root path.'); + } + } + + public static function forEnvironment(Environment $environment, Node $node, string $user, int $guid, ?string $root = null): self + { + return new self($environment->id, $environment->tenant_id, $node->id, + $root ?? rtrim($node->getSetting('node.basedir', '/var/environments'), '/').'/'.$environment->id, + $user, $guid); + } +} diff --git a/packages/core/src/Services/Environment/Jail/JailPlan.php b/packages/core/src/Services/Environment/Jail/JailPlan.php new file mode 100644 index 0000000..8275e91 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/JailPlan.php @@ -0,0 +1,57 @@ +binaries[$path] = $path; + + return $this; + } + + /** Jail-only identity; no host account, password, home creation or recursive chown. */ + public function user(string $name, int $uid, int $gid, string $home = '/', string $shell = '/usr/sbin/nologin'): self + { + if (! preg_match('/^[a-z_][a-z0-9_-]{0,30}$/D', $name) || $name === 'root' + || $uid < 1 || $gid < 1 || $uid > 2147483647 || $gid > 2147483647 + || ! self::safePath($home) || ! self::safePath($shell)) { + throw new InvalidArgumentException('Invalid jail user definition.'); + } + $definition = compact('name', 'uid', 'gid', 'home', 'shell'); + if (isset($this->users[$name]) && $this->users[$name] !== $definition) { + throw new InvalidArgumentException('Conflicting jail user definition.'); + } + $this->users[$name] = $definition; + + return $this; + } + + public function toArray(): array + { + $binaries = array_values($this->binaries); + $users = $this->users; + sort($binaries); + ksort($users); + + return ['binaries' => $binaries, 'users' => $users]; + } + + private static function safePath(string $path): bool + { + return preg_match('#^/[a-zA-Z0-9_./+-]*$#D', $path) + && ! in_array('..', explode('/', $path), true) && ! str_contains($path, '//'); + } +} diff --git a/packages/core/src/Services/Environment/Jail/JailProvider.php b/packages/core/src/Services/Environment/Jail/JailProvider.php new file mode 100644 index 0000000..6b97856 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/JailProvider.php @@ -0,0 +1,13 @@ +nodes()->whereKey($node->id)->firstOrFail()->pivot; + $context = JailContext::forEnvironment($environment, $node, $attachment->unix_name, (int) $attachment->guid, $attachment->jail_path); + $plan = $this->registry->plan($context); + $payload = ['root' => $context->root, 'user' => $context->user, 'guid' => $context->guid, 'plan' => $plan]; + $encoded = base64_encode(json_encode($payload, JSON_THROW_ON_ERROR)); + $helper = file_get_contents(__DIR__.'/../../../../resources/node/reconcile_jail.py'); + $command = 'printf %s '.escapeshellarg(base64_encode($helper)) + .' | base64 -d | /usr/bin/timeout --signal=TERM --kill-after=30s 1200s /usr/bin/python3 - '.escapeshellarg($encoded); + // Marker validates success even when an adapter swallows remote exit codes. + if (trim((string) $node->adapter()->exec([$command])) !== 'FROXLOR_JAIL_OK') { + throw new NodeException('Jail reconciliation failed. Inspect the node; managed state is retained for retry.'); + } + DB::table('node_environments')->where('node_id', $node->id)->where('environment_id', $environment->id) + ->update(['jail_path' => $context->root, 'jail_manifest' => json_encode($plan, JSON_THROW_ON_ERROR), 'updated_at' => now()]); + Audit::info('environment jail reconciled', $environment->tenant, $environment, [ + 'node_id' => $node->id, 'providers' => array_keys($plan['providers']), + ]); + } +} diff --git a/packages/core/src/Services/Environment/Jail/JailRegistry.php b/packages/core/src/Services/Environment/Jail/JailRegistry.php new file mode 100644 index 0000000..27e96a8 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/JailRegistry.php @@ -0,0 +1,52 @@ +key())) { + throw new InvalidArgumentException('Invalid jail provider.'); + } + if (isset($this->providers[$provider->key()]) && $this->providers[$provider->key()] !== $provider) { + throw new LogicException('Jail provider key already registered.'); + } + $this->providers[$provider->key()] = $provider; + } + + /** Union shared binaries/users; conflicting identities fail before infrastructure changes. */ + public function plan(JailContext $context): array + { + $result = ['binaries' => [], 'users' => [], 'providers' => []]; + $providers = $this->providers; + ksort($providers); + foreach ($providers as $key => $provider) { + $plan = $provider->plan($context)->toArray(); + $result['providers'][$key] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR)); + $result['binaries'] = array_values(array_unique([...$result['binaries'], ...$plan['binaries']])); + foreach ($plan['users'] as $name => $user) { + if ($name === $context->user || $user['uid'] === $context->guid || $user['gid'] === $context->guid + || (isset($result['users'][$name]) && $result['users'][$name] !== $user)) { + throw new LogicException('Provider conflicts with a managed or primary jail identity.'); + } + foreach ($result['users'] as $otherName => $other) { + if ($otherName !== $name && ($other['uid'] === $user['uid'] || $other['gid'] === $user['gid'])) { + throw new LogicException('Jail user UID/GID collision.'); + } + } + $result['users'][$name] = $user; + } + } + sort($result['binaries']); + ksort($result['users']); + + return $result; + } +} diff --git a/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php b/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php index bfeff2c..0488656 100644 --- a/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php +++ b/packages/core/tests/Feature/Environment/CreateEnvironmentTest.php @@ -4,25 +4,38 @@ use Froxlor\Core\Jobs\Environment\CreateEnvironment; use Froxlor\Core\Jobs\Environment\DeleteEnvironment; +use Froxlor\Core\Jobs\Environment\SyncEnvironmentJail; use Froxlor\Core\Models\AuditLog; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Services\Environment\Jail\JailReconciler; use Froxlor\Core\Services\Node\Adapter\Adapter; use Froxlor\Core\Services\Node\Exceptions\NodeException; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Tests\TestCase; class CreateEnvironmentTest extends TestCase { + use DatabaseTransactions; + + private Tenant $tenant; + + private Plan $plan; + protected function setUp(): void { parent::setUp(); + $this->assertSame('mariadb', DB::connection()->getDriverName()); + $this->plan = Model::withoutEvents(fn () => Plan::query()->create(['name' => 'Jail test'])); + $this->tenant = Model::withoutEvents(fn () => Tenant::query()->create(['name' => 'Jail test', 'plan_id' => $this->plan->id])); CreateEnvironmentFakeAdapter::reset(); - if (!in_array(CreateEnvironmentFakeAdapter::class, Node::adapters(), true)) { + if (! in_array(CreateEnvironmentFakeAdapter::class, Node::adapters(), true)) { Node::registerAdapter(CreateEnvironmentFakeAdapter::class); } } @@ -31,8 +44,8 @@ public function test_it_skips_occupied_system_guid_and_persists_the_next_free_gu { CreateEnvironmentFakeAdapter::$resolvedGuid = 10005; - $tenant = Tenant::query()->firstOrFail(); - $plan = Plan::query()->firstOrFail(); + $tenant = $this->tenant; + $plan = $this->plan; $node = Node::query()->create([ 'adapter' => CreateEnvironmentFakeAdapter::class, 'name' => 'Create Environment Test Node', @@ -50,7 +63,7 @@ public function test_it_skips_occupied_system_guid_and_persists_the_next_free_gu 'name' => 'Create Environment Test', ]); - CreateEnvironment::dispatchSync($environment->refresh(), $node); + (new CreateEnvironment($environment->refresh(), $node))->handle(); $pivot = DB::table('node_environments') ->where('environment_id', $environment->id) @@ -65,8 +78,8 @@ public function test_it_skips_occupied_system_guid_and_persists_the_next_free_gu $this->assertSame(5, $node->getSetting('node.last_username_number')); $this->assertStringContainsString('candidate=\'10004\'', CreateEnvironmentFakeAdapter::$guidResolutionCommand); - $this->assertStringContainsString('JAILUSER="usr5"', CreateEnvironmentFakeAdapter::$uploadedScript); - $this->assertStringContainsString('GUID="10005"', CreateEnvironmentFakeAdapter::$uploadedScript); + $this->assertStringContainsString("JAILUSER='usr5'", CreateEnvironmentFakeAdapter::$uploadedScript); + $this->assertStringContainsString("GUID='10005'", CreateEnvironmentFakeAdapter::$uploadedScript); $this->assertStringContainsString('jk_jailuser -j "$JAILBASE" "$JAILUSER"', CreateEnvironmentFakeAdapter::$uploadedScript); $this->assertStringNotContainsString('jk_jailuser -m', CreateEnvironmentFakeAdapter::$uploadedScript); @@ -85,8 +98,8 @@ public function test_it_skips_occupied_system_guid_and_persists_the_next_free_gu public function test_environment_delete_removes_jail_from_assigned_node(): void { - $tenant = Tenant::query()->firstOrFail(); - $plan = Plan::query()->firstOrFail(); + $tenant = $this->tenant; + $plan = $this->plan; $node = Node::query()->create([ 'adapter' => CreateEnvironmentFakeAdapter::class, 'name' => 'Delete Environment Test Node', @@ -114,13 +127,11 @@ public function test_environment_delete_removes_jail_from_assigned_node(): void 'node_id' => $node->id, ]); - $deleteCommands = implode(PHP_EOL, CreateEnvironmentFakeAdapter::$executedCommands[0]); + $deleteCommands = CreateEnvironmentFakeAdapter::$lastPayload; - $this->assertStringContainsString("JAILBASE='/srv/environments/" . $environment->id . "'", $deleteCommands); - $this->assertStringContainsString("JAILUSER='usr7'", $deleteCommands); - $this->assertStringContainsString('userdel "$JAILUSER"', $deleteCommands); - $this->assertStringContainsString('groupdel "$JAILUSER"', $deleteCommands); - $this->assertStringContainsString('rm -rf -- "$JAILBASE"', $deleteCommands); + $this->assertSame('/srv/environments/'.$environment->id, $deleteCommands['root']); + $this->assertSame('usr7', $deleteCommands['user']); + $this->assertSame('delete', $deleteCommands['operation']); $auditLog = AuditLog::query() ->where('tenant_id', $tenant->id) @@ -137,8 +148,8 @@ public function test_environment_delete_removes_jail_from_assigned_node(): void public function test_retry_does_not_provision_an_already_attached_environment_again(): void { - $tenant = Tenant::query()->firstOrFail(); - $plan = Plan::query()->firstOrFail(); + $tenant = $this->tenant; + $plan = $this->plan; $node = Node::query()->create([ 'adapter' => CreateEnvironmentFakeAdapter::class, 'name' => 'Idempotent Environment Test Node', @@ -154,12 +165,13 @@ public function test_retry_does_not_provision_an_already_attached_environment_ag 'name' => 'Idempotent Environment Test', ]); - CreateEnvironment::dispatchSync($environment->refresh(), $node); + (new CreateEnvironment($environment->refresh(), $node))->handle(); $commandCount = count(CreateEnvironmentFakeAdapter::$executedCommands); - CreateEnvironment::dispatchSync($environment->refresh(), $node); + (new CreateEnvironment($environment->refresh(), $node))->handle(); - $this->assertSame($commandCount, count(CreateEnvironmentFakeAdapter::$executedCommands)); + $this->assertSame($commandCount + 1, count(CreateEnvironmentFakeAdapter::$executedCommands)); + $this->assertSame(1, CreateEnvironmentFakeAdapter::$creationCount); $this->assertSame(1, DB::table('node_environments') ->where('environment_id', $environment->id) ->where('node_id', $node->id) @@ -170,8 +182,8 @@ public function test_failed_jail_creation_cleans_remote_artifacts(): void { CreateEnvironmentFakeAdapter::$failJailCreation = true; - $tenant = Tenant::query()->firstOrFail(); - $plan = Plan::query()->firstOrFail(); + $tenant = $this->tenant; + $plan = $this->plan; $node = Node::query()->create([ 'adapter' => CreateEnvironmentFakeAdapter::class, 'name' => 'Failed Environment Test Node', @@ -188,7 +200,7 @@ public function test_failed_jail_creation_cleans_remote_artifacts(): void ]); try { - CreateEnvironment::dispatchSync($environment->refresh(), $node); + (new CreateEnvironment($environment->refresh(), $node))->handle(); $this->fail('The jail creation should fail in this test.'); } catch (NodeException $exception) { $this->assertSame('Unable to create jail.', $exception->getMessage()); @@ -198,10 +210,51 @@ public function test_failed_jail_creation_cleans_remote_artifacts(): void 'environment_id' => $environment->id, 'node_id' => $node->id, ]); - $this->assertContains('/tmp/createhome-' . $environment->id . '.sh', CreateEnvironmentFakeAdapter::$deletedFiles); + $this->assertSame('delete', CreateEnvironmentFakeAdapter::$lastPayload['operation']); + $this->assertNotEmpty(CreateEnvironmentFakeAdapter::$lastPayload['cleanup_token']); + } + + public function test_failed_package_reconcile_keeps_jail_and_retries_without_recreation(): void + { + $node = Node::query()->create(['adapter' => CreateEnvironmentFakeAdapter::class, + 'name' => 'Jail retry', 'hostname' => 'jail-retry.local', 'username' => 'root', 'sudo' => true]); + $node->addSetting('node.basedir', '/srv/environments', Node::getTypeSetting('node.basedir')); + $environment = Environment::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Retry extension']); + CreateEnvironmentFakeAdapter::$failReconcile = true; + try { + (new CreateEnvironment($environment, $node))->handle(); + $this->fail('Expected package reconciliation failure.'); + } catch (NodeException) { + $this->assertDatabaseHas('node_environments', ['node_id' => $node->id, 'environment_id' => $environment->id]); + $this->assertArrayNotHasKey('cleanup_token', CreateEnvironmentFakeAdapter::$lastPayload); + } + CreateEnvironmentFakeAdapter::$failReconcile = false; + (new CreateEnvironment($environment, $node))->handle(); + $this->assertSame(1, CreateEnvironmentFakeAdapter::$creationCount); + $this->assertNotNull(DB::table('node_environments')->where('environment_id', $environment->id)->value('jail_manifest')); + } - $cleanupCommands = implode(PHP_EOL, end(CreateEnvironmentFakeAdapter::$executedCommands)); - $this->assertStringContainsString('rm -rf -- "$JAILBASE"', $cleanupCommands); + public function test_reconcile_uses_persisted_jail_path_after_node_setting_changes(): void + { + $node = Node::query()->create(['adapter' => CreateEnvironmentFakeAdapter::class, + 'name' => 'Jail path', 'hostname' => 'jail-path.local', 'username' => 'root', 'sudo' => true]); + $node->addSetting('node.basedir', '/srv/environments', Node::getTypeSetting('node.basedir')); + $environment = Environment::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Stable path']); + (new CreateEnvironment($environment, $node))->handle(); + $node->setSetting('node.basedir', '/new/location'); + (new SyncEnvironmentJail($environment, $node))->handle(app(JailReconciler::class)); + $this->assertSame('/srv/environments/'.$environment->id, CreateEnvironmentFakeAdapter::$lastPayload['root']); + DeleteEnvironment::dispatchSync($environment); + $this->assertSame('/srv/environments/'.$environment->id, CreateEnvironmentFakeAdapter::$lastPayload['root']); + } + + public function test_delayed_sync_does_not_recreate_a_removed_attachment(): void + { + $node = Node::query()->create(['adapter' => CreateEnvironmentFakeAdapter::class, + 'name' => 'Detached jail', 'hostname' => 'detached-jail.local', 'username' => 'root', 'sudo' => true]); + $environment = Environment::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Detached']); + (new SyncEnvironmentJail($environment, $node))->handle(app(JailReconciler::class)); + $this->assertSame([], CreateEnvironmentFakeAdapter::$executedCommands); } } @@ -211,6 +264,12 @@ class CreateEnvironmentFakeAdapter extends Adapter public static int $resolvedGuid = 10005; + public static int $creationCount = 0; + + public static array $lastPayload = []; + + public static bool $failReconcile = false; + public static string $guidResolutionCommand = ''; public static string $uploadedScript = ''; @@ -224,6 +283,9 @@ class CreateEnvironmentFakeAdapter extends Adapter public static function reset(): void { self::$resolvedGuid = 10005; + self::$creationCount = 0; + self::$lastPayload = []; + self::$failReconcile = false; self::$guidResolutionCommand = ''; self::$uploadedScript = ''; self::$failJailCreation = false; @@ -233,18 +295,29 @@ public static function reset(): void public function exec(string|array $command): bool|string { - $commands = (array)$command; + $commands = (array) $command; self::$executedCommands[] = $commands; if (str_starts_with($commands[0] ?? '', 'candidate=')) { self::$guidResolutionCommand = implode(PHP_EOL, $commands); - return (string)self::$resolvedGuid; + return (string) self::$resolvedGuid; } $commandString = implode(PHP_EOL, $commands); - if (self::$failJailCreation && str_contains($commandString, 'chmod +x') && str_contains($commandString, 'createhome-')) { - return false; + if (preg_match("/printf %s '([A-Za-z0-9+\\/=]+)'/", $commandString, $matches)) { + $script = base64_decode($matches[1]); + if (str_contains($script, 'jk_jailuser -j')) { + self::$uploadedScript = $script; + self::$creationCount++; + + return self::$failJailCreation ? false : 'FROXLOR_JAIL_CREATED'; + } + if (preg_match("/python3 - '([A-Za-z0-9+\\/=]+)'/", $commandString, $payload)) { + self::$lastPayload = json_decode(base64_decode($payload[1]), true); + } + + return self::$failReconcile && ! isset(self::$lastPayload['operation']) ? false : 'FROXLOR_JAIL_OK'; } return ''; diff --git a/packages/core/tests/Feature/Environment/JailPlanTest.php b/packages/core/tests/Feature/Environment/JailPlanTest.php new file mode 100644 index 0000000..851dc4b --- /dev/null +++ b/packages/core/tests/Feature/Environment/JailPlanTest.php @@ -0,0 +1,94 @@ +key; + } + + public function plan(JailContext $context): JailPlan + { + return $this->definition; + } + }; + } + + public function test_providers_union_shared_binaries_and_users_deterministically(): void + { + $registry = new JailRegistry; + $plan = (new JailPlan)->binary('/usr/bin/php')->user('worker', 20001, 20001); + $registry->register($this->provider('froxlor/web:php', $plan)); + $registry->register($this->provider('froxlor/jobs:php', $plan)); + $result = $registry->plan($this->context()); + $this->assertSame(['/usr/bin/php'], $result['binaries']); + $this->assertCount(1, $result['users']); + $this->assertCount(2, $result['providers']); + } + + public function test_conflicting_package_user_definitions_fail_before_execution(): void + { + $registry = new JailRegistry; + $registry->register($this->provider('froxlor/web:worker', (new JailPlan)->user('worker', 20001, 20001))); + $registry->register($this->provider('froxlor/jobs:worker', (new JailPlan)->user('worker', 20002, 20002))); + $this->expectException(LogicException::class); + $registry->plan($this->context()); + } + + public function test_primary_identity_cannot_be_overridden(): void + { + $registry = new JailRegistry; + $registry->register($this->provider('froxlor/web:worker', (new JailPlan)->user('other', 10001, 20001))); + $this->expectException(LogicException::class); + $registry->plan($this->context()); + } + + public function test_unsafe_paths_are_rejected(): void + { + foreach (['/bin/../etc/passwd', '/usr/bin/php;id', '/usr/bin/..', 'php', '/tmp/php'] as $path) { + try { + (new JailPlan)->binary($path); + $this->fail('Unsafe binary path accepted.'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + $this->expectException(InvalidArgumentException::class); + new JailContext('01K00000000000000000000000', 'tenant', 'node', '/srv/../01K00000000000000000000000', 'usr1', 10001); + } + + public function test_root_user_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + (new JailPlan)->user('root', 0, 0); + } + + public function test_duplicate_registry_keys_fail_closed(): void + { + $registry = new JailRegistry; + $registry->register($this->provider('froxlor/web:php', new JailPlan)); + $this->expectException(LogicException::class); + $registry->register($this->provider('froxlor/web:php', new JailPlan)); + } +} diff --git a/packages/core/tests/Feature/Environment/test_jail_filesystem.py b/packages/core/tests/Feature/Environment/test_jail_filesystem.py new file mode 100644 index 0000000..41ce7c8 --- /dev/null +++ b/packages/core/tests/Feature/Environment/test_jail_filesystem.py @@ -0,0 +1,210 @@ +"""Run as root ONLY on the isolated Docker node. Creates/removes its own temp trees.""" +import base64 +import json +import os +from pathlib import Path +import shutil +import tempfile +import subprocess +import uuid +import pwd +import grp +import types +import unittest +from unittest.mock import patch + +jail = types.ModuleType("jail_helper") +exec(compile(base64.b64decode(os.environ["FROXLOR_JAIL_HELPER_B64"]), "reconcile_jail.py", "exec"), jail.__dict__) + + +class JailFilesystemTest(unittest.TestCase): + def setUp(self): + self.base = Path(tempfile.mkdtemp(prefix="froxlor-jail-test-", dir="/var/lib")) + self.root = self.base / "01K00000000000000000000000" + self.root.mkdir() + self.stage = self.base / "stage" + self.stage.mkdir() + self.payload = {"root": str(self.root), "user": "usr1", "guid": 10001, + "plan": {"binaries": [], "users": {}, "providers": {}}} + (self.root / "etc").mkdir() + (self.root / "etc/passwd").write_text("root:x:0:0::/root:/bin/bash\nusr1:!:10001:10001::/home:/bin/bash\n") + (self.root / "etc/group").write_text("root:x:0:\nusr1:x:10001:\n") + + def tearDown(self): + for line in Path('/proc/self/mountinfo').read_text().splitlines(): + if line.split()[4].startswith(str(self.base) + '/'): + raise RuntimeError('Preserving test tree with remaining mount: ' + str(self.base)) + shutil.rmtree(self.base) + + def staged(self, path="/usr/bin/example", contents=b"binary v1"): + target = self.stage / path.lstrip("/") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(contents) + target.chmod(0o755) + self.payload["plan"]["binaries"] = [path] + return target + + def user(self, uid=20001, shell="/bin/bash"): + self.payload["plan"]["users"] = {"worker": {"name": "worker", "uid": uid, "gid": uid, "home": "/home/worker", "shell": shell}} + + def apply(self): + jail.apply_state(self.root, self.payload, self.stage) + + def test_binary_add_update_delete_and_idempotence(self): + source = self.staged() + self.apply() + self.apply() + target = self.root / "usr/bin/example" + self.assertEqual(target.read_bytes(), b"binary v1") + source.write_bytes(b"binary v2") + self.apply() + self.assertEqual(target.read_bytes(), b"binary v2") + source.unlink() + self.payload["plan"]["binaries"] = [] + self.apply() + self.assertFalse(target.exists()) + + def test_borrowed_base_binary_is_never_overwritten_or_deleted(self): + source = self.staged() + target = self.root / "usr/bin/example" + target.parent.mkdir(parents=True) + target.write_bytes(b"base") + self.apply() + self.assertEqual(target.read_bytes(), b"base") + source.unlink() + self.payload["plan"]["binaries"] = [] + self.apply() + self.assertEqual(target.read_bytes(), b"base") + + def test_managed_drift_rejects_removal(self): + source = self.staged() + self.apply() + target = self.root / "usr/bin/example" + target.write_bytes(b"manually changed") + source.unlink() + self.payload["plan"]["binaries"] = [] + with self.assertRaisesRegex(RuntimeError, "drift"): + self.apply() + self.assertTrue(target.exists()) + + def test_symlink_parent_escape_is_rejected(self): + self.staged() + (self.root / "usr").symlink_to(self.base) + with self.assertRaisesRegex(RuntimeError, "escapes"): + self.apply() + + def test_writable_parent_is_rejected(self): + self.staged() + (self.root / "usr").mkdir(mode=0o777) + (self.root / "usr").chmod(0o777) + with self.assertRaisesRegex(RuntimeError, "root-owned"): + self.apply() + + def test_account_add_change_remove_preserves_customer_home(self): + home = self.root / "home/worker" + home.mkdir(parents=True) + (home / "data").write_text("keep") + self.user() + self.apply() + self.apply() + self.assertIn("worker:!:20001:20001:", (self.root / "etc/passwd").read_text()) + self.user(20002, "/usr/sbin/nologin") + self.apply() + self.assertIn("worker:!:20002:20002:", (self.root / "etc/passwd").read_text()) + self.payload["plan"]["users"] = {} + self.apply() + self.assertNotIn("worker:", (self.root / "etc/passwd").read_text()) + self.assertEqual((home / "data").read_text(), "keep") + self.assertIn("usr1:", (self.root / "etc/passwd").read_text()) + + def test_unmanaged_or_primary_accounts_cannot_be_adopted(self): + self.user() + with (self.root / "etc/passwd").open("a") as file: + file.write("worker:!:20001:20001::/:/bin/bash\n") + with self.assertRaisesRegex(RuntimeError, "Unmanaged"): + self.apply() + self.user(10001) + with self.assertRaisesRegex(RuntimeError, "Protected"): + self.apply() + + def test_account_database_symlinks_are_rejected(self): + self.user() + (self.root / "etc/shadow").symlink_to(self.base / "outside") + with self.assertRaisesRegex(RuntimeError, "Symlink account"): + self.apply() + + def test_write_ahead_journal_allows_retry_after_partial_write(self): + self.user() + self.staged() + original = jail.atomic_write + def failing(path, contents, mode): + if path.name == "group": + raise RuntimeError("simulated interruption") + original(path, contents, mode) + with patch.object(jail, "atomic_write", failing): + with self.assertRaisesRegex(RuntimeError, "interruption"): + self.apply() + self.apply() + self.assertIn("worker:", (self.root / "etc/group").read_text()) + self.assertTrue((self.root / "usr/bin/example").exists()) + + def test_shared_dependency_lives_until_last_consumer_is_removed(self): + first = self.staged("/usr/bin/first") + second = self.staged("/usr/bin/second") + library = self.staged("/usr/lib/libexample.so") + self.payload["plan"]["binaries"] = ["/usr/bin/first", "/usr/bin/second"] + self.apply() + first.unlink() + self.payload["plan"]["binaries"] = ["/usr/bin/second"] + self.apply() + self.assertTrue((self.root / "usr/lib/libexample.so").exists()) + second.unlink() + library.unlink() + self.payload["plan"]["binaries"] = [] + self.apply() + self.assertFalse((self.root / "usr/lib/libexample.so").exists()) + + def test_real_jailkit_copy_with_dependencies(self): + self.payload["plan"]["binaries"] = ["/usr/bin/printf"] + jail.stage_binaries(self.payload["plan"]["binaries"], self.stage) + self.apply() + self.assertTrue((self.root / "usr/bin/printf").exists()) + self.assertGreater(len(json.loads((self.root / ".froxlor-jail-state.json").read_text())["files"]), 1) + + @unittest.skipUnless(os.environ.get("FROXLOR_JAIL_CREATE_SCRIPT_B64"), "Rendered creation template required") + def test_real_create_reconcile_update_remove_and_delete(self): + # A distinct sibling root, no database or existing jail is touched. + root = self.base / "01k00000000000000000000001" + name = "fjt" + uuid.uuid4().hex[:10] + used = {entry.pw_uid for entry in pwd.getpwall()} | {entry.gr_gid for entry in grp.getgrall()} + guid = next(value for value in range(410000, 420000) if value not in used) + payload = {"root": str(root), "user": name, "guid": guid, + "plan": {"binaries": ["/usr/bin/printf"], "users": {}, "providers": {"test/package:runtime": "1"}}} + script = base64.b64decode(os.environ["FROXLOR_JAIL_CREATE_SCRIPT_B64"]).decode() + script = script.replace("TEST_JAIL_ROOT", str(root)).replace("TEST_JAIL_HOME", str(root / "home")) + script = script.replace("TEST_JAIL_USER", name).replace("123456789", str(guid)) + try: + result = subprocess.run(["/bin/bash", "-se"], input=script, text=True, capture_output=True, timeout=180) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "FROXLOR_JAIL_CREATED") + self.assertEqual(pwd.getpwnam(name).pw_uid, guid) + jail.reconcile(payload) + payload["plan"]["users"] = {"worker": {"name": "worker", "uid": guid + 1, "gid": guid + 1, "home": "/home/worker", "shell": "/bin/bash"}} + jail.reconcile(payload) + self.assertIn("worker:", (root / "etc/passwd").read_text()) + payload["plan"]["users"]["worker"]["shell"] = "/usr/sbin/nologin" + jail.reconcile(payload) + self.assertIn("/home/worker:/usr/sbin/nologin", (root / "etc/passwd").read_text()) + payload["plan"]["users"] = {} + payload["plan"]["binaries"] = [] + jail.reconcile(payload) + self.assertNotIn("worker:", (root / "etc/passwd").read_text()) + self.assertTrue((root / "home/web").is_dir()) + finally: + jail.delete_jail({**payload, "cleanup_token": "test-creation-token"}) + self.assertFalse(root.exists()) + with self.assertRaises(KeyError): + pwd.getpwnam(name) + + +unittest.main() From e770cc238292c20f89f76a646a25a76dbe17740a Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 16 Sep 2026 23:24:41 +0200 Subject: [PATCH 10/11] Extend package jail reconciliation interfaces --- packages/core/docs/environment-jails.md | 33 +++++++--- .../core/resources/node/reconcile_jail.py | 51 ++++++++++++++- .../Jobs/Environment/CreateEnvironment.php | 2 +- .../Providers/FroxlorCoreServiceProvider.php | 9 +++ .../EnvironmentJailReconcileDispatcher.php | 35 ++++++++++ .../Environment/Jail/EnvironmentSettings.php | 64 +++++++++++++++++++ .../Services/Environment/Jail/JailPlan.php | 58 ++++++++++++++++- .../Environment/Jail/JailProvider.php | 6 +- .../Environment/Jail/JailReconciler.php | 11 +++- .../Environment/Jail/JailRegistry.php | 20 ++++-- .../src/Support/PackageServiceProvider.php | 25 ++++++-- .../Feature/Environment/JailPlanTest.php | 34 ++++++++-- 12 files changed, 321 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/Services/Environment/Jail/EnvironmentJailReconcileDispatcher.php create mode 100644 packages/core/src/Services/Environment/Jail/EnvironmentSettings.php diff --git a/packages/core/docs/environment-jails.md b/packages/core/docs/environment-jails.md index 8437c85..c9c548b 100644 --- a/packages/core/docs/environment-jails.md +++ b/packages/core/docs/environment-jails.md @@ -7,10 +7,15 @@ service is installed by a jail provider. ## Package contract -Register a `JailProvider` in the package service provider's `boot()`: +Register providers through the package service provider hooks: ```php -app(JailRegistry::class)->register(PhpCliJailProvider::class); +public function registerEnvironmentJailProviders(JailRegistry $registry): void +{ + if ($this->isEnabled()) { + $registry->register(PhpCliJailProvider::class); + } +} ``` The imports are from `Froxlor\Core\Services\Environment\Jail`. A minimal example: @@ -23,7 +28,11 @@ final class PhpCliJailProvider implements JailProvider return 'froxlor/web:php-cli'; } - public function plan(JailContext $context): JailPlan + public function package(): string { return 'froxlor/web'; } + + public function settings(): array { return []; } + + public function plan(JailContext $context, EnvironmentSettings $settings): JailPlan { // The package reads its own persisted, validated configuration using // $context->environmentId, $context->tenantId and $context->nodeId. @@ -53,18 +62,24 @@ chown or process restart is implied. A future SSH/FTP package must separately define its authentication and host-account lifecycle. Changing a UID/GID does not change file ownership; packages must coordinate existing data/processes explicitly. -Providers receive an immutable identity context, not an adapter. Their output is -declarative: absolute system binary paths and typed user definitions, not shell -snippets. Providers are nevertheless installed, trusted PHP code, not a sandbox. +Providers receive an immutable identity context and typed, validated `EnvironmentSettings`, not an adapter. +Their output is declarative: absolute system binary paths, users, directories, files and environment +variables, not shell snippets. Providers are nevertheless installed, trusted PHP code, not a sandbox. Do not expose arbitrary provider registration or filesystem paths to customer input. ## Applying changes The registry combines **all** active providers deterministically. Shared binaries and identical user definitions are merged; conflicting declarations fail before -node writes. Returning an empty plan disables a provider's contribution. +node writes. Returning an empty plan disables a provider's contribution. Managed +files are written atomically, directories are created with the declared mode, and +environment variables are rendered into `/etc/environment`. -After an authorized package configuration/resource mutation has committed: +After an authorized package configuration/resource mutation has committed, call +`EnvironmentSettings::store(...)` (which validates and fans out automatically), or +explicitly call the package provider's public `reconcileEnvironmentJails()` hook. +The package lifecycle hooks `installed`, `enabled`, `disabled` and `updated` also +fan out reconciliation automatically: ```php use Froxlor\Core\Jobs\Environment\SyncEnvironmentJail; @@ -107,6 +122,8 @@ that attempt's random creation token, never pre-existing host accounts. Write-ahead records allow retries after partial changes; individual file writes are atomic, but the entire remote operation is **not** a filesystem transaction. Package workload/session coordination remains the calling package's responsibility. +- The persisted `jail_manifest` stores hashes for managed file contents and environment values, + never their plaintext payloads. - Removing a user removes only managed passwd/group/shadow/gshadow entries. Home directories and customer files are retained, and the primary identity is protected. - Create, sync and delete share the node environment lifecycle cache lock. Sync diff --git a/packages/core/resources/node/reconcile_jail.py b/packages/core/resources/node/reconcile_jail.py index bf01555..31966f7 100644 --- a/packages/core/resources/node/reconcile_jail.py +++ b/packages/core/resources/node/reconcile_jail.py @@ -189,7 +189,7 @@ def apply_state(root, payload, stage): """Separated from transport/staging for Linux filesystem regression tests.""" state_path = destination(root, "/.froxlor-jail-state.json") require(not state_path.is_symlink(), "Unsafe jail journal") - state = {"version": 1, "files": {}, "users": {}, "entrypoints": []} + state = {"version": 1, "files": {}, "users": {}, "entrypoints": [], "directories": []} if state_path.exists(): fingerprint(state_path) state = json.loads(state_path.read_text()) @@ -243,6 +243,44 @@ def apply_state(root, payload, stage): account_writes[kind] = (path, ("\n".join(records.values()) + "\n").encode()) writes = {} + desired_directories = plan.get("directories", {}) + require(isinstance(desired_directories, dict), "Invalid directory map") + for name, mode in desired_directories.items(): + require(name.startswith('/') and '..' not in name.split('/') and isinstance(mode, int) and 0 <= mode <= 0o777, + "Invalid jail directory") + directory = destination(root, name, create=True) + if not directory.exists(): + directory.mkdir(mode=mode) + require(directory.is_dir() and not directory.is_symlink(), "Managed jail directory is unsafe") + os.chmod(directory, mode) + + desired_files = plan.get("files", {}) + require(isinstance(desired_files, dict), "Invalid file map") + for name, definition in desired_files.items(): + require(name not in ("/etc/passwd", "/etc/group", "/etc/shadow", "/etc/gshadow") + and not name.startswith("/.froxlor"), "Protected jail file") + require(isinstance(definition, dict) and isinstance(definition.get("content"), str) + and isinstance(definition.get("mode"), int) and 0 <= definition["mode"] <= 0o777, + "Invalid jail file") + path = destination(root, name, create=True) + require(not path.is_symlink(), "Managed jail file is a symlink") + content = definition["content"].encode() + previous = fingerprint(path) + new_fingerprint = "sha256:" + hashlib.sha256(content).hexdigest() + require(previous is None or previous in state["files"].get(name, []), "Managed file drift: " + name) + writes[name] = (content, new_fingerprint, False, definition["mode"]) + state["files"].setdefault(name, []) + + environment = plan.get("environment", {}) + require(isinstance(environment, dict), "Invalid environment map") + if environment: + lines = [f'{key}="{value.replace(chr(92), chr(92)+chr(92)).replace(chr(34), chr(92)+chr(34))}"' for key, value in sorted(environment.items())] + path = destination(root, "/etc/environment", create=True) + content = ("\n".join(lines) + "\n").encode() + previous = fingerprint(path) + new_fingerprint = "sha256:" + hashlib.sha256(content).hexdigest() + require(previous is None or previous in state["files"].get("/etc/environment", []), "Managed environment drift") + writes["/etc/environment"] = (content, new_fingerprint, False, 0o644) staged_names = set() protected = {"/etc/passwd", "/etc/group", "/etc/shadow", "/etc/gshadow"} for source in sorted(stage.rglob("*")): @@ -278,7 +316,7 @@ def apply_state(root, payload, stage): # The staging tree is the union of dependencies of ALL desired binaries. # Only files first introduced by this reconciler can be garbage-collected. - retired = set(state["files"]) - staged_names + retired = set(state["files"]) - staged_names - set(desired_files) - ({"/etc/environment"} if environment else set()) removals = [] for name in retired: if name not in state["files"] or name in writes: @@ -287,6 +325,7 @@ def apply_state(root, payload, stage): current = fingerprint(path) require(current is None or current in state["files"][name], "Managed binary drift: " + name) removals.append(name) + retired_directories = sorted(set(state.get("directories", [])) - set(desired_directories), key=len, reverse=True) # Write-ahead ownership journal: both old and intended contents are valid on retry. for name, (_, new_fingerprint, _, _) in writes.items(): @@ -310,6 +349,13 @@ def apply_state(root, payload, stage): for name in removals: destination(root, name).unlink(missing_ok=True) state["files"].pop(name, None) + for name in retired_directories: + directory = destination(root, name) + require(directory.is_dir() and not directory.is_symlink(), "Managed jail directory drift: " + name) + try: + directory.rmdir() + except OSError: + raise RuntimeError("Managed jail directory is not empty: " + name) for kind, (path, contents) in account_writes.items(): destination(root, "/etc/" + kind, create=True) atomic_write(path, contents, 0o600 if kind in ("shadow", "gshadow") else 0o644) @@ -318,6 +364,7 @@ def apply_state(root, payload, stage): state["files"][name] = [new_fingerprint] state["users"] = {name: {kind: [line] for kind, line in definition.items()} for name, definition in new_users.items()} state["entrypoints"] = plan["binaries"] + state["directories"] = sorted(desired_directories) state["providers"] = plan["providers"] atomic_write(state_path, json.dumps(state, sort_keys=True).encode(), 0o600) diff --git a/packages/core/src/Jobs/Environment/CreateEnvironment.php b/packages/core/src/Jobs/Environment/CreateEnvironment.php index ca09364..ff05388 100644 --- a/packages/core/src/Jobs/Environment/CreateEnvironment.php +++ b/packages/core/src/Jobs/Environment/CreateEnvironment.php @@ -64,7 +64,7 @@ public function handle(): void $guid = $this->resolveNextFreeGuid($adapter, $node->nextGuid); $context = JailContext::forEnvironment($environment, $node, $unixName, $guid); // Provider conflicts and unsafe paths must fail before creating anything. - app(JailRegistry::class)->plan($context); + app(JailRegistry::class)->plan($context, $environment); // base-directory for environment... $envBaseDir = $context->root; diff --git a/packages/core/src/Providers/FroxlorCoreServiceProvider.php b/packages/core/src/Providers/FroxlorCoreServiceProvider.php index 620f261..d29b038 100644 --- a/packages/core/src/Providers/FroxlorCoreServiceProvider.php +++ b/packages/core/src/Providers/FroxlorCoreServiceProvider.php @@ -24,6 +24,8 @@ use Froxlor\Core\Services\Node\Setup\NodeServiceExecutor; use Froxlor\Core\Services\Node\Setup\NodeServiceRegistry; use Froxlor\Core\Services\Node\Setup\Providers\BaseSystemProvider; +use Froxlor\Core\Services\Environment\Jail\EnvironmentJailReconcileDispatcher; +use Froxlor\Core\Services\Environment\Jail\JailRegistry; use Froxlor\Core\Support\FroxlorVersion; use Froxlor\Core\Support\PackageServiceProvider; use Froxlor\Core\Support\PermissionRegistry; @@ -48,6 +50,12 @@ class FroxlorCoreServiceProvider extends PackageServiceProvider public function boot(): void { $this->app->make(NodeServiceRegistry::class)->register(BaseSystemProvider::class); + $this->app->booted(function (): void { + foreach ($this->app->getProviders(PackageServiceProvider::class) as $provider) { + $provider->registerNodeServices($this->app->make(NodeServiceRegistry::class)); + $provider->registerEnvironmentJailProviders($this->app->make(JailRegistry::class)); + } + }); AboutCommand::add('froxlor', fn() => [ 'version' => FroxlorVersion::release(), @@ -130,6 +138,7 @@ public function boot(): void public function register(): void { $this->app->singleton(\Froxlor\Core\Services\Environment\Jail\JailRegistry::class); + $this->app->singleton(EnvironmentJailReconcileDispatcher::class); $this->app->singleton(NodeServiceRegistry::class); $this->app->bind(NodeServiceExecutor::class, AdapterNodeServiceExecutor::class); diff --git a/packages/core/src/Services/Environment/Jail/EnvironmentJailReconcileDispatcher.php b/packages/core/src/Services/Environment/Jail/EnvironmentJailReconcileDispatcher.php new file mode 100644 index 0000000..11497b7 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/EnvironmentJailReconcileDispatcher.php @@ -0,0 +1,35 @@ +with('nodes')->chunkById(100, function ($environments) use (&$count): void { + foreach ($environments as $environment) { + foreach ($environment->nodes as $node) { + SyncEnvironmentJail::dispatch($environment, $node)->afterCommit(); + $count++; + } + } + }); + return $count; + } + + public function dispatchForEnvironment(Environment $environment): int + { + $count = 0; + foreach ($environment->nodes as $node) { + SyncEnvironmentJail::dispatch($environment, $node)->afterCommit(); + $count++; + } + return $count; + } +} diff --git a/packages/core/src/Services/Environment/Jail/EnvironmentSettings.php b/packages/core/src/Services/Environment/Jail/EnvironmentSettings.php new file mode 100644 index 0000000..b2f1f23 --- /dev/null +++ b/packages/core/src/Services/Environment/Jail/EnvironmentSettings.php @@ -0,0 +1,64 @@ +key().'.'.$name; + } + + public static function resolve(Environment $environment, JailProvider $provider, array $overrides = []): self + { + $definitions = $provider->settings(); + if (array_diff_key($overrides, $definitions) !== []) { + throw new InvalidArgumentException('Unknown environment jail settings.'); + } + $values = []; + foreach ($definitions as $name => $definition) { + if (!$definition instanceof SettingDefinition) { + throw new InvalidArgumentException('Invalid environment jail setting definition.'); + } + $value = array_key_exists($name, $overrides) + ? $overrides[$name] + : Setting::getForModel($environment, self::path($provider, $name), $definition->default); + $values[$name] = $definition->normalize($value); + } + return new self($values); + } + + public static function store(Environment $environment, JailProvider $provider, array $values): void + { + $resolved = self::resolve($environment, $provider, $values); + DB::transaction(function () use ($environment, $provider, $resolved): void { + foreach ($provider->settings() as $name => $definition) { + Setting::setValueForModel($environment, self::path($provider, $name), $resolved->values[$name], $definition->type, $provider->package()); + } + }); + if (app()->bound(EnvironmentJailReconcileDispatcher::class)) { + app(EnvironmentJailReconcileDispatcher::class)->dispatchForEnvironment($environment); + } + } + + public function integer(string $name): int { return $this->typed($name, 'integer'); } + public function boolean(string $name): bool { return $this->typed($name, 'boolean'); } + public function string(string $name): string { return $this->typed($name, 'string'); } + + private function typed(string $name, string $type): int|bool|string + { + if (!array_key_exists($name, $this->values) || gettype($this->values[$name]) !== $type) { + throw new InvalidArgumentException('Unknown environment setting or incorrect accessor.'); + } + return $this->values[$name]; + } +} diff --git a/packages/core/src/Services/Environment/Jail/JailPlan.php b/packages/core/src/Services/Environment/Jail/JailPlan.php index 8275e91..442675d 100644 --- a/packages/core/src/Services/Environment/Jail/JailPlan.php +++ b/packages/core/src/Services/Environment/Jail/JailPlan.php @@ -10,6 +10,12 @@ final class JailPlan private array $users = []; + private array $files = []; + + private array $directories = []; + + private array $environment = []; + /** Host binary at the same absolute path in the jail; dependencies use Jailkit. */ public function binary(string $path): self { @@ -39,14 +45,56 @@ public function user(string $name, int $uid, int $gid, string $home = '/', strin return $this; } + public function file(string $path, string $content, int $mode = 0644): self + { + $this->validatePath($path); + if ($mode < 0 || $mode > 0777 || str_contains($content, "\0")) { + throw new InvalidArgumentException('Invalid jail file definition.'); + } + $definition = ['content' => $content, 'mode' => $mode]; + if (isset($this->files[$path]) && $this->files[$path] !== $definition) { + throw new InvalidArgumentException('Conflicting jail file definition.'); + } + $this->files[$path] = $definition; + return $this; + } + + public function directory(string $path, int $mode = 0755): self + { + $this->validatePath($path); + if ($mode < 0 || $mode > 0777) { + throw new InvalidArgumentException('Invalid jail directory definition.'); + } + if (isset($this->directories[$path]) && $this->directories[$path] !== $mode) { + throw new InvalidArgumentException('Conflicting jail directory definition.'); + } + $this->directories[$path] = $mode; + return $this; + } + + public function environment(string $name, string $value): self + { + if (! preg_match('/^[A-Z_][A-Z0-9_]{0,127}$/D', $name) || str_contains($value, "\0") || str_contains($value, "\n")) { + throw new InvalidArgumentException('Invalid jail environment variable.'); + } + if (isset($this->environment[$name]) && $this->environment[$name] !== $value) { + throw new InvalidArgumentException('Conflicting jail environment variable.'); + } + $this->environment[$name] = $value; + return $this; + } + public function toArray(): array { $binaries = array_values($this->binaries); $users = $this->users; sort($binaries); ksort($users); + ksort($this->files); + ksort($this->directories); + ksort($this->environment); - return ['binaries' => $binaries, 'users' => $users]; + return ['binaries' => $binaries, 'users' => $users, 'files' => $this->files, 'directories' => $this->directories, 'environment' => $this->environment]; } private static function safePath(string $path): bool @@ -54,4 +102,12 @@ private static function safePath(string $path): bool return preg_match('#^/[a-zA-Z0-9_./+-]*$#D', $path) && ! in_array('..', explode('/', $path), true) && ! str_contains($path, '//'); } + + private function validatePath(string $path): void + { + if (! self::safePath($path) || $path === '/' || str_starts_with($path, '/.froxlor') + || in_array($path, ['/etc/passwd', '/etc/group', '/etc/shadow', '/etc/gshadow'], true)) { + throw new InvalidArgumentException('Invalid jail-managed path.'); + } + } } diff --git a/packages/core/src/Services/Environment/Jail/JailProvider.php b/packages/core/src/Services/Environment/Jail/JailProvider.php index 6b97856..3085197 100644 --- a/packages/core/src/Services/Environment/Jail/JailProvider.php +++ b/packages/core/src/Services/Environment/Jail/JailProvider.php @@ -5,9 +5,13 @@ /** Trusted package code declares desired state, never arbitrary shell operations. */ interface JailProvider { + public function package(): string; /** Globally unique package-qualified key, e.g. froxlor/web:php-cli. */ public function key(): string; + /** @return array */ + public function settings(): array; + /** Return an empty plan when this environment does not use the package feature. */ - public function plan(JailContext $context): JailPlan; + public function plan(JailContext $context, EnvironmentSettings $settings): JailPlan; } diff --git a/packages/core/src/Services/Environment/Jail/JailReconciler.php b/packages/core/src/Services/Environment/Jail/JailReconciler.php index ff0d7fa..c0a63d9 100644 --- a/packages/core/src/Services/Environment/Jail/JailReconciler.php +++ b/packages/core/src/Services/Environment/Jail/JailReconciler.php @@ -18,7 +18,7 @@ public function sync(Environment $environment, Node $node): void { $attachment = $environment->nodes()->whereKey($node->id)->firstOrFail()->pivot; $context = JailContext::forEnvironment($environment, $node, $attachment->unix_name, (int) $attachment->guid, $attachment->jail_path); - $plan = $this->registry->plan($context); + $plan = $this->registry->plan($context, $environment); $payload = ['root' => $context->root, 'user' => $context->user, 'guid' => $context->guid, 'plan' => $plan]; $encoded = base64_encode(json_encode($payload, JSON_THROW_ON_ERROR)); $helper = file_get_contents(__DIR__.'/../../../../resources/node/reconcile_jail.py'); @@ -28,8 +28,15 @@ public function sync(Environment $environment, Node $node): void if (trim((string) $node->adapter()->exec([$command])) !== 'FROXLOR_JAIL_OK') { throw new NodeException('Jail reconciliation failed. Inspect the node; managed state is retained for retry.'); } + $manifest = $plan; + foreach ($manifest['files'] as $path => $definition) { + $manifest['files'][$path] = ['sha256' => hash('sha256', $definition['content']), 'mode' => $definition['mode']]; + } + foreach ($manifest['environment'] as $name => $value) { + $manifest['environment'][$name] = 'sha256:'.hash('sha256', $value); + } DB::table('node_environments')->where('node_id', $node->id)->where('environment_id', $environment->id) - ->update(['jail_path' => $context->root, 'jail_manifest' => json_encode($plan, JSON_THROW_ON_ERROR), 'updated_at' => now()]); + ->update(['jail_path' => $context->root, 'jail_manifest' => json_encode($manifest, JSON_THROW_ON_ERROR), 'updated_at' => now()]); Audit::info('environment jail reconciled', $environment->tenant, $environment, [ 'node_id' => $node->id, 'providers' => array_keys($plan['providers']), ]); diff --git a/packages/core/src/Services/Environment/Jail/JailRegistry.php b/packages/core/src/Services/Environment/Jail/JailRegistry.php index 27e96a8..c5a2874 100644 --- a/packages/core/src/Services/Environment/Jail/JailRegistry.php +++ b/packages/core/src/Services/Environment/Jail/JailRegistry.php @@ -21,16 +21,25 @@ public function register(string|JailProvider $provider): void $this->providers[$provider->key()] = $provider; } - /** Union shared binaries/users; conflicting identities fail before infrastructure changes. */ - public function plan(JailContext $context): array + /** Merge declarative package state; all conflicts fail before infrastructure changes. */ + public function plan(JailContext $context, ?\Froxlor\Core\Models\Environment $environment = null): array { - $result = ['binaries' => [], 'users' => [], 'providers' => []]; + $result = ['binaries' => [], 'users' => [], 'files' => [], 'directories' => [], 'environment' => [], 'providers' => []]; $providers = $this->providers; ksort($providers); foreach ($providers as $key => $provider) { - $plan = $provider->plan($context)->toArray(); + $settings = EnvironmentSettings::resolve($environment ?? throw new InvalidArgumentException('Environment is required for jail planning.'), $provider); + $plan = $provider->plan($context, $settings)->toArray(); $result['providers'][$key] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR)); $result['binaries'] = array_values(array_unique([...$result['binaries'], ...$plan['binaries']])); + foreach (['files', 'directories', 'environment'] as $kind) { + foreach ($plan[$kind] as $name => $definition) { + if (isset($result[$kind][$name]) && $result[$kind][$name] !== $definition) { + throw new LogicException('Conflicting jail '.$kind.' definition.'); + } + $result[$kind][$name] = $definition; + } + } foreach ($plan['users'] as $name => $user) { if ($name === $context->user || $user['uid'] === $context->guid || $user['gid'] === $context->guid || (isset($result['users'][$name]) && $result['users'][$name] !== $user)) { @@ -46,6 +55,9 @@ public function plan(JailContext $context): array } sort($result['binaries']); ksort($result['users']); + ksort($result['files']); + ksort($result['directories']); + ksort($result['environment']); return $result; } diff --git a/packages/core/src/Support/PackageServiceProvider.php b/packages/core/src/Support/PackageServiceProvider.php index 20c04b3..50901d1 100644 --- a/packages/core/src/Support/PackageServiceProvider.php +++ b/packages/core/src/Support/PackageServiceProvider.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; +use Froxlor\Core\Services\Environment\Jail\JailRegistry; +use Froxlor\Core\Services\Node\Setup\NodeServiceRegistry; use RuntimeException; /** @@ -22,6 +24,21 @@ */ abstract class PackageServiceProvider extends ServiceProvider { + /** Register node-wide service providers owned by this package. */ + public function registerNodeServices(NodeServiceRegistry $registry): void {} + + /** Register environment jail providers owned by this package. */ + public function registerEnvironmentJailProviders(JailRegistry $registry): void {} + + /** Queue a complete environment-jail reconciliation after package state changes. */ + public function reconcileEnvironmentJails(): int + { + if ($this->app->bound(\Froxlor\Core\Services\Environment\Jail\EnvironmentJailReconcileDispatcher::class)) { + return $this->app->make(\Froxlor\Core\Services\Environment\Jail\EnvironmentJailReconcileDispatcher::class) + ->dispatchForPackage($this->packageName()); + } + return 0; + } /** * The composer package name (e.g. "froxlor/example") that this provider belongs to. */ @@ -54,7 +71,7 @@ public function installing(): void */ public function installed(): void { - // + $this->reconcileEnvironmentJails(); } /** @@ -67,7 +84,7 @@ public function enabling(): void public function enabled(): void { - // + $this->reconcileEnvironmentJails(); } /** @@ -80,7 +97,7 @@ public function disabling(): void public function disabled(): void { - // + $this->reconcileEnvironmentJails(); } /** @@ -100,7 +117,7 @@ public function updating(): void */ public function updated(): void { - // + $this->reconcileEnvironmentJails(); } /** diff --git a/packages/core/tests/Feature/Environment/JailPlanTest.php b/packages/core/tests/Feature/Environment/JailPlanTest.php index 851dc4b..defcef6 100644 --- a/packages/core/tests/Feature/Environment/JailPlanTest.php +++ b/packages/core/tests/Feature/Environment/JailPlanTest.php @@ -28,7 +28,17 @@ public function key(): string return $this->key; } - public function plan(JailContext $context): JailPlan + public function package(): string + { + return 'froxlor/test'; + } + + public function settings(): array + { + return []; + } + + public function plan(JailContext $context, \Froxlor\Core\Services\Environment\Jail\EnvironmentSettings $settings): JailPlan { return $this->definition; } @@ -41,7 +51,7 @@ public function test_providers_union_shared_binaries_and_users_deterministically $plan = (new JailPlan)->binary('/usr/bin/php')->user('worker', 20001, 20001); $registry->register($this->provider('froxlor/web:php', $plan)); $registry->register($this->provider('froxlor/jobs:php', $plan)); - $result = $registry->plan($this->context()); + $result = $registry->plan($this->context(), new \Froxlor\Core\Models\Environment); $this->assertSame(['/usr/bin/php'], $result['binaries']); $this->assertCount(1, $result['users']); $this->assertCount(2, $result['providers']); @@ -53,7 +63,7 @@ public function test_conflicting_package_user_definitions_fail_before_execution( $registry->register($this->provider('froxlor/web:worker', (new JailPlan)->user('worker', 20001, 20001))); $registry->register($this->provider('froxlor/jobs:worker', (new JailPlan)->user('worker', 20002, 20002))); $this->expectException(LogicException::class); - $registry->plan($this->context()); + $registry->plan($this->context(), new \Froxlor\Core\Models\Environment); } public function test_primary_identity_cannot_be_overridden(): void @@ -61,7 +71,7 @@ public function test_primary_identity_cannot_be_overridden(): void $registry = new JailRegistry; $registry->register($this->provider('froxlor/web:worker', (new JailPlan)->user('other', 10001, 20001))); $this->expectException(LogicException::class); - $registry->plan($this->context()); + $registry->plan($this->context(), new \Froxlor\Core\Models\Environment); } public function test_unsafe_paths_are_rejected(): void @@ -91,4 +101,20 @@ public function test_duplicate_registry_keys_fail_closed(): void $this->expectException(LogicException::class); $registry->register($this->provider('froxlor/web:php', new JailPlan)); } + + public function test_declarative_files_directories_and_environment_are_exported(): void + { + $plan = (new JailPlan) + ->directory('/etc/php', 0750) + ->file('/etc/php/php.ini', "memory_limit=128M\n", 0640) + ->environment('APP_ENV', 'production'); + + $this->assertSame([ + 'binaries' => [], + 'users' => [], + 'files' => ['/etc/php/php.ini' => ['content' => "memory_limit=128M\n", 'mode' => 0640]], + 'directories' => ['/etc/php' => 0750], + 'environment' => ['APP_ENV' => 'production'], + ], $plan->toArray()); + } } From 339c4299da5dfb3d707d1929d8a7b9596c116e0b Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Fri, 18 Sep 2026 20:38:06 +0200 Subject: [PATCH 11/11] Implement node setup lifecycle and UI diagnostics --- composer.json | 7 ++- ...1_01_01_000098_add_node_setup_services.php | 22 +++++++ packages/core/lang/en/generic.php | 13 ++++ packages/core/routes/web.php | 2 + .../Http/Controllers/Api/NodeController.php | 2 + .../Controllers/Api/NodeSetupController.php | 1 + .../Http/Controllers/Web/NodeController.php | 11 ++++ packages/core/src/Models/Node.php | 2 + .../src/Resources/Nodes/Schemas/NodeView.php | 29 +++++++++ .../Node/Platform/PlatformResolver.php | 1 + .../Node/Setup/AdapterNodeServiceExecutor.php | 33 +++++++++- .../src/Services/Node/Setup/NodeSetupPlan.php | 29 +++++++++ .../Services/Node/Setup/NodeSetupResult.php | 3 +- .../Services/Node/Setup/NodeSetupScript.php | 32 ++++++---- .../Services/Node/Setup/NodeSetupService.php | 9 ++- .../Setup/Providers/BaseSystemProvider.php | 2 +- packages/ui/README.md | 30 +++++++++ .../views/schema/components/action.blade.php | 4 +- .../views/schema/components/text.blade.php | 1 + packages/ui/src/Concerns/HasAssets.php | 63 +++++++++++++++++-- packages/ui/src/Contracts/Action.php | 15 ++++- packages/ui/src/Tables/Actions/Action.php | 11 +--- 22 files changed, 281 insertions(+), 41 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000098_add_node_setup_services.php create mode 100644 packages/ui/README.md diff --git a/composer.json b/composer.json index fbb6c48..1a94228 100644 --- a/composer.json +++ b/composer.json @@ -63,10 +63,13 @@ "scripts": { "dev": [ "Composer\\Config::disableProcessTimeout", - "cd ../bundle && npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev --prefix ../framework/packages/ui\" --names=server,queue,logs,vite --kill-others" + "cd ../froxlor && npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev --prefix ../framework/packages/ui\" --names=server,queue,logs,vite --kill-others" ], "dev:assets": [ - "rm -f ../bundle/public/vendor/froxlor/ui && ln -s ../../../../framework/packages/ui/dist ../bundle/public/vendor/froxlor/ui && echo 'Linked assets for development.'" + "mkdir -p ../froxlor/public/vendor/froxlor && if [ -e ../froxlor/public/vendor/froxlor/ui ] || [ -L ../froxlor/public/vendor/froxlor/ui ]; then rm -rf ../froxlor/public/vendor/froxlor/ui; fi && ln -s ../../../../framework/packages/ui/dist ../froxlor/public/vendor/froxlor/ui && echo 'Linked UI assets for development.'" + ], + "assets:publish": [ + "cd ../froxlor && php artisan vendor:publish --tag=froxlor-ui-assets --ansi --force" ] }, "replace": { diff --git a/packages/core/database/migrations/0001_01_01_000098_add_node_setup_services.php b/packages/core/database/migrations/0001_01_01_000098_add_node_setup_services.php new file mode 100644 index 0000000..23e142e --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000098_add_node_setup_services.php @@ -0,0 +1,22 @@ +json('setup_services')->nullable(); + }); + } + + public function down(): void + { + Schema::table('nodes', function (Blueprint $table): void { + $table->dropColumn('setup_services'); + }); + } +}; diff --git a/packages/core/lang/en/generic.php b/packages/core/lang/en/generic.php index 5c28448..b01bdab 100644 --- a/packages/core/lang/en/generic.php +++ b/packages/core/lang/en/generic.php @@ -146,6 +146,19 @@ 'node_interfaces_description' => 'Configured interfaces and reachable addresses.', 'node_next_identity_description' => 'The next generated unix identity values on this node.', 'node_meta_description' => 'Lifecycle metadata for this node resource.', + 'node_setup' => 'Node setup', + 'node_setup_description' => 'Installation and health state of the selected node services.', + 'node_setup_not_started' => 'Not started', + 'node_setup_queued' => 'Node setup queued.', + 'node_setup_pending' => 'Queued', + 'node_setup_running' => 'Running', + 'node_setup_succeeded' => 'Completed', + 'node_setup_failed' => 'Failed', + 'node_setup_retry' => 'Run setup again', + 'node_setup_start' => 'Start initial setup', + 'node_setup_last_verified' => 'Last verified', + 'node_setup_services' => 'Services', + 'node_setup_no_services' => 'No service snapshot is available yet.', 'resource_distribution' => 'Resource distribution', 'resource_distribution_description' => 'How the main managed resource types are currently distributed.', 'resource_distribution_footer' => 'Snapshot across the currently available core resources.', diff --git a/packages/core/routes/web.php b/packages/core/routes/web.php index 696e6f0..01e0691 100644 --- a/packages/core/routes/web.php +++ b/packages/core/routes/web.php @@ -21,6 +21,8 @@ Route::prefix('resources')->name('resources.')->group(function () { Route::resource('plans', Web\PlanController::class); Route::resource('nodes', Web\NodeController::class); + Route::post('nodes/{node}/setup', [Web\NodeController::class, 'setup']) + ->name('nodes.setup'); Route::resource('tenants', Web\TenantController::class); }); diff --git a/packages/core/src/Http/Controllers/Api/NodeController.php b/packages/core/src/Http/Controllers/Api/NodeController.php index be925f7..dcce117 100644 --- a/packages/core/src/Http/Controllers/Api/NodeController.php +++ b/packages/core/src/Http/Controllers/Api/NodeController.php @@ -79,6 +79,8 @@ public function show(Node $node) { Gate::authorize('view', $node); + $node->setAttribute('setup_status', $node->setup_status ?? 'not_started'); + return Response::jsonResource($node->load(['nodeInterfaces', 'environments.tenant'])); } diff --git a/packages/core/src/Http/Controllers/Api/NodeSetupController.php b/packages/core/src/Http/Controllers/Api/NodeSetupController.php index 3e2f25b..7a4de35 100644 --- a/packages/core/src/Http/Controllers/Api/NodeSetupController.php +++ b/packages/core/src/Http/Controllers/Api/NodeSetupController.php @@ -55,6 +55,7 @@ private function status(Node $node, int $code = 200): JsonResponse 'started_at' => $node->setup_started_at, 'finished_at' => $node->setup_finished_at, 'error' => $node->setup_error, + 'services' => $node->setup_services, ]], $code); } } diff --git a/packages/core/src/Http/Controllers/Web/NodeController.php b/packages/core/src/Http/Controllers/Web/NodeController.php index 4f06042..ea13fbc 100644 --- a/packages/core/src/Http/Controllers/Web/NodeController.php +++ b/packages/core/src/Http/Controllers/Web/NodeController.php @@ -5,6 +5,8 @@ use Froxlor\Core\Http\Controllers\Controller; use Froxlor\Core\Models\Node; use Froxlor\Core\Resources\Nodes\NodeResource; +use Froxlor\Core\Services\Node\Setup\NodeSetupService; +use Illuminate\Http\RedirectResponse; use Froxlor\UI\Support\UI; class NodeController extends Controller @@ -32,4 +34,13 @@ public function edit(Node $node) 'node' => $node ]); } + + public function setup(Node $node, NodeSetupService $setups): RedirectResponse + { + $setups->request($node, request()->user()); + + return redirect() + ->route('resources.nodes.show', ['node' => $node]) + ->with('message', trans('froxlor-core::generic.node_setup_queued')); + } } diff --git a/packages/core/src/Models/Node.php b/packages/core/src/Models/Node.php index 2938e03..8287506 100644 --- a/packages/core/src/Models/Node.php +++ b/packages/core/src/Models/Node.php @@ -37,6 +37,7 @@ * @property string|null $setup_request_id * @property string|null $setup_requested_by * @property array|null $setup_selection + * @property array|null $setup_services * @property string|null $setup_fingerprint * @property string|null $setup_run_id * @property Carbon|null $setup_requested_at @@ -72,6 +73,7 @@ class Node extends Model 'password' => 'encrypted', 'properties' => 'encrypted:array', 'setup_selection' => 'array', + 'setup_services' => 'array', 'setup_requested_at' => 'datetime', 'setup_started_at' => 'datetime', 'setup_finished_at' => 'datetime', diff --git a/packages/core/src/Resources/Nodes/Schemas/NodeView.php b/packages/core/src/Resources/Nodes/Schemas/NodeView.php index 52331f9..5bdeec6 100644 --- a/packages/core/src/Resources/Nodes/Schemas/NodeView.php +++ b/packages/core/src/Resources/Nodes/Schemas/NodeView.php @@ -71,6 +71,24 @@ public static function schema(Node $node): array ->default($node->sudo ? trans('froxlor-core::generic.yes') : trans('froxlor-core::generic.no')), ]), + Schemas\Components\Section::make('resources.nodes.show.details.setup') + ->title(trans('froxlor-core::generic.node_setup')) + ->description(trans('froxlor-core::generic.node_setup_description')) + ->components([ + Schemas\Components\Text::make('setup_status') + ->label(trans('froxlor-core::generic.status')), + + Schemas\Components\Text::make('setup_finished_at') + ->label(trans('froxlor-core::generic.node_setup_last_verified')), + + Schemas\Components\Text::make('setup_services') + ->label(trans('froxlor-core::generic.node_setup_services')) + ->default(trans('froxlor-core::generic.node_setup_no_services')), + + Schemas\Components\Text::make('setup_error') + ->label(trans('froxlor-core::generic.last_error')), + ]), + Schemas\Components\Section::make('resources.nodes.show.details.network') ->title(trans('froxlor-core::generic.network')) ->description(trans('froxlor-core::generic.node_network_description')) @@ -136,6 +154,17 @@ public static function actions(Node $node): array Schemas\Actions\Action::make('back') ->label(trans('froxlor-core::generic.backto', ['entity' => trans('froxlor-core::generic.nodes')])) ->href(route('resources.nodes.index')), + + Schemas\Actions\Action::make('setup') + ->label(fn() => $node->setup_status === null + ? trans('froxlor-core::generic.node_setup_start') + : trans('froxlor-core::generic.node_setup_retry')) + ->href(route('resources.nodes.setup', ['node' => $node])) + ->method('POST') + ->visible(fn() => ! in_array($node->setup_status, ['pending', 'running'], true)) + ->confirm() + ->icon('wrench') + ->variant('secondary'), ]; } diff --git a/packages/core/src/Services/Node/Platform/PlatformResolver.php b/packages/core/src/Services/Node/Platform/PlatformResolver.php index ea6f0a2..f971c8c 100644 --- a/packages/core/src/Services/Node/Platform/PlatformResolver.php +++ b/packages/core/src/Services/Node/Platform/PlatformResolver.php @@ -10,6 +10,7 @@ class PlatformResolver 'debian' => [ 'family' => 'debian', 'versions' => [ + '12' => 'bookworm', '13' => 'trixie', ], ], diff --git a/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php index 3992744..eaa8932 100644 --- a/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php +++ b/packages/core/src/Services/Node/Setup/AdapterNodeServiceExecutor.php @@ -49,16 +49,43 @@ public function apply(Node $node, NodeSetupPlan $plan): NodeSetupResult $output = $node->adapter()->exec([$command]); // Some adapters do not reliably propagate exit codes; require the final marker too. if (! is_string($output) || trim($output) !== 'FROXLOR_SETUP_OK:'.$runId) { - throw new RuntimeException('Node setup failed; inspect the root-owned node setup journal.'); + $phase = trim((string) $node->adapter()->exec([ + 'cat '.escapeshellarg('/var/lib/froxlor/node-setup/'.$runId.'/phase').' 2>/dev/null || true', + ])); + $phase = preg_match('/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/', $phase) === 1 + ? $phase + : null; + $exitCode = trim((string) $node->adapter()->exec([ + 'cat '.escapeshellarg('/var/lib/froxlor/node-setup/'.$runId.'/exit-code').' 2>/dev/null || true', + ])); + $exitCode = preg_match('/^[1-9][0-9]*$/', $exitCode) === 1 ? $exitCode : null; + $missing = trim((string) $node->adapter()->exec([ + 'cat '.escapeshellarg('/var/lib/froxlor/node-setup/'.$runId.'/missing-packages').' 2>/dev/null || true', + ])); + $missing = preg_match('/^(?:[a-z0-9][a-z0-9+.-]*\n?){1,32}$/', $missing) === 1 + ? preg_replace('/\s+/', ', ', $missing) + : null; + $details = $exitCode === null ? '' : ' (remote exit '.$exitCode.')'; + if ($missing !== null && $missing !== '') { + $details .= ' Missing packages: '.$missing; + } + + throw new RuntimeException($phase === null + ? 'Node setup failed; inspect the root-owned node setup journal.' + : 'Node setup failed during '.$phase.$details.'.'); } - } catch (Throwable) { + } catch (Throwable $exception) { Audit::error('node service setup failed', $node->tenant, context: $context); // Adapter exceptions or stderr can contain secrets. Do not propagate them. + if ($exception instanceof RuntimeException + && str_starts_with($exception->getMessage(), 'Node setup failed during ')) { + throw $exception; + } throw new RuntimeException('Node setup failed; inspect the root-owned node setup journal.'); } Audit::notice('node service setup completed', $node->tenant, context: $context); - return new NodeSetupResult($runId, $current->fingerprint()); + return new NodeSetupResult($runId, $current->fingerprint(), $current->serviceSnapshot()); } } diff --git a/packages/core/src/Services/Node/Setup/NodeSetupPlan.php b/packages/core/src/Services/Node/Setup/NodeSetupPlan.php index 6097c9c..8eed577 100644 --- a/packages/core/src/Services/Node/Setup/NodeSetupPlan.php +++ b/packages/core/src/Services/Node/Setup/NodeSetupPlan.php @@ -37,4 +37,33 @@ public function toArray(): array return ['node_id' => $this->nodeId, 'platform' => $this->platform, 'fingerprint' => $this->fingerprint(), 'services' => $services]; } + + /** Safe, persisted inventory metadata; rendered config and check commands are excluded. */ + public function serviceSnapshot(): array + { + $services = []; + foreach ($this->services as $key => $service) { + $packages = []; + $units = []; + foreach ($service['plan']->operations() as $operation) { + $payload = $operation->payload(); + if ($operation->type === 'packages') { + $packages = array_values(array_unique([...$packages, ...$payload['packages']])); + } elseif ($operation->type === 'service') { + $units[] = $payload['name']; + } + } + sort($packages); + sort($units); + $services[$key] = [ + 'role' => $service['role'], + 'revision' => $service['revision'], + 'packages' => $packages, + 'units' => array_values(array_unique($units)), + 'installed' => true, + 'running' => $units === [] ? null : true, + ]; + } + return $services; + } } diff --git a/packages/core/src/Services/Node/Setup/NodeSetupResult.php b/packages/core/src/Services/Node/Setup/NodeSetupResult.php index 58a19a0..71b73c6 100644 --- a/packages/core/src/Services/Node/Setup/NodeSetupResult.php +++ b/packages/core/src/Services/Node/Setup/NodeSetupResult.php @@ -5,5 +5,6 @@ /** Safe result: no command output, settings or rendered configuration contents. */ final readonly class NodeSetupResult { - public function __construct(public string $runId, public string $fingerprint) {} + /** @param array> $services */ + public function __construct(public string $runId, public string $fingerprint, public array $services = []) {} } diff --git a/packages/core/src/Services/Node/Setup/NodeSetupScript.php b/packages/core/src/Services/Node/Setup/NodeSetupScript.php index 282e77b..36340e5 100644 --- a/packages/core/src/Services/Node/Setup/NodeSetupScript.php +++ b/packages/core/src/Services/Node/Setup/NodeSetupScript.php @@ -47,6 +47,8 @@ public function compile(NodeSetupPlan $plan, string $runId): string 'exec >/dev/null 2>&1', 'printf "%s\n" running > "$work/status"', 'printf "%s\n" '.escapeshellarg($plan->fingerprint()).' > "$work/fingerprint"', + // Keep a non-sensitive failure code next to the phase for UI diagnostics. + 'trap \'code=$?; printf "%s\\n" "$code" > "$work/exit-code"; exit "$code"\' ERR', 'targets=(); backups=(); existed=(); activated=(); was_active=(); was_enabled=()', 'policy_tmp=', 'success=0', <<<'SH' @@ -146,23 +148,27 @@ private function packages(array $packages): array ' [ "$(dpkg-query -W -f=\'${Status}\' "$package" 2>/dev/null || true)" = "install ok installed" ] || missing+=("$package")', 'done', 'if [ "${#missing[@]}" != 0 ]; then', - // Never replace an administrator's start policy. - ' [ ! -e /usr/sbin/policy-rc.d ] && [ ! -L /usr/sbin/policy-rc.d ] || exit 1', - ' assert_directory /usr/sbin', - ' printf \'#!/bin/sh\nexit 101\n\' > "$work/policy"', - ' chmod 0755 "$work/policy"', - ' policy_tmp=$(mktemp /usr/sbin/.froxlor-policy.XXXXXX)', - ' install -m 0755 "$work/policy" "$policy_tmp"', - ' ln "$policy_tmp" /usr/sbin/policy-rc.d', + ' printf "%s\\n" "${missing[@]}" > "$work/missing-packages"', + // Respect an administrator-managed policy; only create a temporary one when absent. + ' if [ ! -e /usr/sbin/policy-rc.d ] && [ ! -L /usr/sbin/policy-rc.d ]; then', + ' assert_directory /usr/sbin', + ' printf \'#!/bin/sh\nexit 101\n\' > "$work/policy"', + ' chmod 0755 "$work/policy"', + ' policy_tmp=$(mktemp /usr/sbin/.froxlor-policy.XXXXXX)', + ' install -m 0755 "$work/policy" "$policy_tmp"', + ' ln "$policy_tmp" /usr/sbin/policy-rc.d', + ' fi', ' export DEBIAN_FRONTEND=noninteractive', - ' apt-get -o DPkg::Lock::Timeout=120 update', - ' apt-get -o DPkg::Lock::Timeout=120 --no-install-recommends --no-upgrade --no-remove -y install "${missing[@]}"', + ' if ! apt-get -o DPkg::Lock::Timeout=120 update; then printf "%s\\n" 100 > "$work/exit-code"; exit 100; fi', + ' if ! apt-get -o DPkg::Lock::Timeout=120 --no-install-recommends --no-upgrade --no-remove -y install "${missing[@]}"; then printf "%s\\n" 100 > "$work/exit-code"; exit 100; fi', ' for package in "${missing[@]}"; do', ' [ "$(dpkg-query -W -f=\'${Status}\' "$package")" = "install ok installed" ] || exit 1', ' done', - ' [ /usr/sbin/policy-rc.d -ef "$policy_tmp" ] || exit 1', - ' rm -- /usr/sbin/policy-rc.d', - ' rm -- "$policy_tmp"', + ' if [ -n "$policy_tmp" ]; then', + ' [ /usr/sbin/policy-rc.d -ef "$policy_tmp" ] || exit 1', + ' rm -- /usr/sbin/policy-rc.d', + ' rm -- "$policy_tmp"', + ' fi', ' policy_tmp=', ' changed=1', 'fi', diff --git a/packages/core/src/Services/Node/Setup/NodeSetupService.php b/packages/core/src/Services/Node/Setup/NodeSetupService.php index f84d0a6..5b16786 100644 --- a/packages/core/src/Services/Node/Setup/NodeSetupService.php +++ b/packages/core/src/Services/Node/Setup/NodeSetupService.php @@ -122,10 +122,15 @@ public function execute(string $nodeId, string $requestId): void ->where('setup_status', 'running')->update([ 'setup_status' => 'succeeded', 'setup_run_id' => $result->runId, 'setup_finished_at' => now(), 'setup_error' => null, + 'setup_services' => $result->services, ]); }); - } catch (Throwable) { - $this->fail($nodeId, $requestId, 'Node setup failed. Check permissions, current configuration and the node setup journal.'); + } catch (Throwable $exception) { + $message = $exception instanceof RuntimeException + && str_starts_with($exception->getMessage(), 'Node setup failed during ') + ? $exception->getMessage() + : 'Node setup failed. Check permissions, current configuration and the node setup journal.'; + $this->fail($nodeId, $requestId, $message); // Never put raw adapter errors or rendered settings into failed_jobs. throw new RuntimeException('Node setup failed. See the node setup status and journal.'); } diff --git a/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php b/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php index eeddf20..6743725 100644 --- a/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php +++ b/packages/core/src/Services/Node/Setup/Providers/BaseSystemProvider.php @@ -31,7 +31,7 @@ public function revision(): string public function platforms(): array { - return ['debian@13', 'ubuntu@24.04']; + return ['debian@12', 'debian@13', 'ubuntu@24.04']; } public function requires(): array diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..04de1a4 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,30 @@ +# froxlor UI + +The UI package provides the shared Blade components, Livewire views, layouts, +navigation collectors and browser assets used by the framework and optional +feature packages. + +## Development assets + +Run from `framework/`: + +```bash +composer run dev:assets +``` + +This creates `froxlor/public/vendor/froxlor/ui` as a symlink to the package's +`dist` directory. Build or watch the package from `framework/packages/ui` with +`npm run build` or `npm run dev`. + +## Published assets + +For a deployable application, publish the built package assets from `framework/`: + +```bash +composer run assets:publish +``` + +The application also publishes the assets during Composer install/update. UI +providers register their bundle through `UI::assetsDirective(...)`; all +registered bundles are rendered by `@froxlorHead`, and missing files fail +explicitly instead of silently producing an incomplete page. diff --git a/packages/ui/resources/views/schema/components/action.blade.php b/packages/ui/resources/views/schema/components/action.blade.php index e72f36d..53fff11 100644 --- a/packages/ui/resources/views/schema/components/action.blade.php +++ b/packages/ui/resources/views/schema/components/action.blade.php @@ -113,10 +113,10 @@ {{ $cancelLabel }} {{ $confirmLabel }} diff --git a/packages/ui/resources/views/schema/components/text.blade.php b/packages/ui/resources/views/schema/components/text.blade.php index 7f32498..d252a34 100644 --- a/packages/ui/resources/views/schema/components/text.blade.php +++ b/packages/ui/resources/views/schema/components/text.blade.php @@ -14,6 +14,7 @@ @php $value = data_get($data, $schema->key, $schema->default ?? null); + $value ??= $schema->default ?? null; if (is_array($value)) { $value = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/packages/ui/src/Concerns/HasAssets.php b/packages/ui/src/Concerns/HasAssets.php index 05ade9e..dca6d39 100644 --- a/packages/ui/src/Concerns/HasAssets.php +++ b/packages/ui/src/Concerns/HasAssets.php @@ -6,9 +6,14 @@ use Froxlor\Core\Support\Setting; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Blade; +use InvalidArgumentException; +use RuntimeException; trait HasAssets { + /** @var array> */ + protected static array $assetBundles = []; + /** * Register the Blade directive, this is loaded once, so we need to wrap render assets. * @@ -17,11 +22,35 @@ trait HasAssets */ public static function assetsDirective(string $publicPath, array $assets): void { - Blade::directive('froxlorHead', function () use ($publicPath, $assets) { - return ""; + self::assertAssetPath($publicPath); + foreach ($assets as $asset) { + if (! is_string($asset) || $asset === '' || str_starts_with($asset, '/') || str_contains($asset, '..')) { + throw new InvalidArgumentException('UI assets must be relative paths without traversal segments.'); + } + } + + self::$assetBundles[$publicPath] = array_values(array_unique([ + ...self::$assetBundles[$publicPath] ?? [], + ...$assets, + ])); + + Blade::directive('froxlorHead', static function () { + return ''; }); } + /** Render every asset bundle registered by the loaded packages. */ + public static function renderRegisteredAssets(): string + { + $html = []; + foreach (self::$assetBundles as $publicPath => $assets) { + $html[] = self::renderBundle($publicPath, $assets); + } + $html[] = self::getCssVariables(); + + return implode("\n", array_filter($html)); + } + /** * Generate HTML tags for assets (CSS and JS) with cache-busting query parameters based on file hashes. * @@ -30,17 +59,34 @@ public static function assetsDirective(string $publicPath, array $assets): void * @throws \Exception */ public static function renderAssets(string $publicPath, array $assets): string + { + self::assertAssetPath($publicPath); + + return self::renderBundle($publicPath, $assets)."\n".self::getCssVariables(); + } + + /** @param list $assets */ + private static function renderBundle(string $publicPath, array $assets): string { $html = []; foreach ($assets as $asset) { + if (! is_string($asset) || $asset === '' || str_starts_with($asset, '/') || str_contains($asset, '..')) { + throw new InvalidArgumentException('UI assets must be relative paths without traversal segments.'); + } $path = public_path($publicPath . '/' . $asset); - if (!file_exists($path)) { - continue; + if (! is_file($path)) { + throw new RuntimeException(sprintf( + 'UI asset is missing: %s. Publish or link the package assets before rendering the application.', + $path, + )); } $hash = md5_file($path); + if ($hash === false) { + throw new RuntimeException('Unable to hash UI asset: '.$path); + } $url = asset($publicPath . '/' . $asset) . '?v=' . $hash; if (str_ends_with($asset, '.css')) { @@ -51,11 +97,16 @@ public static function renderAssets(string $publicPath, array $assets): string } } - $html[] = self::getCssVariables(); - return implode("\n", $html); } + private static function assertAssetPath(string $path): void + { + if ($path === '' || str_starts_with($path, '/') || str_contains($path, '..')) { + throw new InvalidArgumentException('UI asset bundle paths must be relative and traversal-safe.'); + } + } + /** * Get the style tag for theme adjustments, built from the individual * `appearance.colors.*` settings rows (one setting per CSS variable). diff --git a/packages/ui/src/Contracts/Action.php b/packages/ui/src/Contracts/Action.php index caf80b4..082b98f 100644 --- a/packages/ui/src/Contracts/Action.php +++ b/packages/ui/src/Contracts/Action.php @@ -15,7 +15,7 @@ abstract class Action implements Payloadable, Resolvable public string $key; - public ?string $label = null; + public mixed $label = null; public ?string $href = null; @@ -35,6 +35,8 @@ abstract class Action implements Payloadable, Resolvable public ?string $variant = null; + public mixed $visible = true; + protected string $view; public function __construct(string $key) @@ -49,7 +51,7 @@ public static function make(string $key): static public function label(callable|string|null $value): static { - $this->label = trans($value); + $this->label = is_string($value) ? trans($value) : $value; return $this; } @@ -140,12 +142,20 @@ public function variant(callable|string|null $value): static return $this; } + public function visible(callable|bool $value = true): static + { + $this->visible = $value; + + return $this; + } + public function resolve(array $context = []): static { $clone = clone $this; $clone->label = AttributeResolver::value($this->label, $context); $clone->href = AttributeResolver::value($this->href, $context); $clone->variant = AttributeResolver::value($this->variant, $context); + $clone->visible = AttributeResolver::value($this->visible, $context) ?? true; $clone->handler = $this->handler; $clone->handlerToken = $this->handlerToken; $confirm = $this->confirm; @@ -180,6 +190,7 @@ public function toPayload(): array 'icon' => $resolved->icon, 'method' => $resolved->method, 'variant' => $resolved->variant, + 'visible' => $resolved->visible, 'view' => $this->view, ]; } diff --git a/packages/ui/src/Tables/Actions/Action.php b/packages/ui/src/Tables/Actions/Action.php index 33933fb..14297b5 100644 --- a/packages/ui/src/Tables/Actions/Action.php +++ b/packages/ui/src/Tables/Actions/Action.php @@ -8,15 +8,8 @@ class Action extends BaseAction { public string $view = 'ui::schema.components.action'; - public function visible(callable|bool $value): static + public function visible(callable|bool $value = true): static { - if ($value instanceof \Closure) { - $this->attributes['visible'] = app()->call($value); - return $this; - } - - $this->attributes['visible'] = $value; - - return $this; + return parent::visible($value); } }