Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Pioch
28ad3c9c0f Attach scope claim to JWTs issued to wings
Without a scope, a JWT minted for one purpose (e.g. a websocket token
any user can request) is interchangeable against unrelated wings
endpoints since the daemon only validates signature + server UUID.

Adds a JwtScope enum and a setScopes() method on NodeJWTService that
must be called before handle(); every existing call site (websocket,
file download/upload, backup download, server transfer) now declares
its scope. The hash algorithm parameter is dropped — sha256 always.

Wings must be updated to verify the scope claim for this to take
effect; the new claim is ignored by older daemons.
2026-06-11 14:59:56 -04:00
21 changed files with 105 additions and 53 deletions

View File

@@ -89,7 +89,7 @@ jobs:
MYSQL_DATABASE: testing
ports:
- 3306
options: --tmpfs /var/lib/mysql:rw --health-cmd="mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
@@ -129,6 +129,12 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
@@ -151,7 +157,7 @@ jobs:
MYSQL_DATABASE: testing
ports:
- 3306
options: --tmpfs /var/lib/mysql:rw --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
options: --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
DB_CONNECTION: mariadb
DB_HOST: 127.0.0.1
@@ -191,6 +197,12 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
@@ -205,13 +217,6 @@ jobs:
matrix:
php: [8.5]
database: ["postgres:17", "postgres:18"]
include:
# postgres:17 stores data in /var/lib/postgresql/data, 18+ requires a
# single mount at /var/lib/postgresql and errors on a data submount
- database: "postgres:17"
tmpfs: /var/lib/postgresql/data
- database: "postgres:18"
tmpfs: /var/lib/postgresql
services:
database:
image: ${{ matrix.database }}
@@ -223,11 +228,10 @@ jobs:
ports:
- 5432:5432
options: >-
--tmpfs ${{ matrix.tmpfs }}:rw
--health-cmd pg_isready
--health-interval 2s
--health-interval 10s
--health-timeout 5s
--health-retries 30
--health-retries 5
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
@@ -265,5 +269,11 @@ jobs:
- name: Run Migrations
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning

12
app/Enums/JwtScope.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum JwtScope: string
{
case Websocket = 'websocket';
case FileUpload = 'file-upload';
case FileDownload = 'file-download';
case BackupDownload = 'backup-download';
case ServerTransfer = 'transfer';
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
@@ -44,6 +45,7 @@ class DownloadFiles extends Page
'file_path' => rawurldecode($path),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, user()?->id . $server->uuid);
Activity::event('server:file.download')

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Widgets;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
@@ -59,6 +60,7 @@ class ServerConsole extends Widget
'server_uuid' => $this->server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($this->server->node, $this->user->id . $this->server->uuid)->toString();
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest;
@@ -98,6 +99,7 @@ class FileController extends ClientApiController
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, $request->user()->id . $server->uuid);
Activity::event('server:file.download')->property('file', $request->get('file'))->log();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
use App\Models\Server;
@@ -47,6 +48,7 @@ class FileUploadController extends ClientApiController
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims(['server_uuid' => $server->uuid])
->setScopes(JwtScope::FileUpload)
->handle($server->node, $user->id . $server->uuid);
return sprintf(

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Api\Client\ClientApiController;
@@ -63,6 +64,7 @@ class WebsocketController extends ClientApiController
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($node, $user->id . $server->uuid);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());

View File

@@ -7,7 +7,6 @@ use App\Models\WebhookConfiguration;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
@@ -35,9 +34,7 @@ class ProcessWebhook implements ShouldQueue
$data = reset($data);
}
if ($data instanceof Arrayable) {
$data = $data->toArray();
} elseif (is_object($data)) {
if (is_object($data)) {
$data = get_object_vars($data);
}
@@ -67,7 +64,7 @@ class ProcessWebhook implements ShouldQueue
$headers = [];
if ($this->webhookConfiguration->type === WebhookType::Regular) {
foreach ($this->webhookConfiguration->headers ?? [] as $key => $value) {
foreach ($this->webhookConfiguration->headers as $key => $value) {
$headers[$key] = $this->webhookConfiguration->replaceVars($data, $value);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Enums\JwtScope;
use App\Enums\TablerIcon;
use App\Models\Node;
use App\Services\Nodes\NodeJWTService;
@@ -52,6 +53,7 @@ class NodeClientConnectivity extends Component
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($this->node, $user->id . $server->uuid)->toString();
$wsUrl = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $this->node->getConnectionAddress());

View File

@@ -2,6 +2,7 @@
namespace App\Services\Backups;
use App\Enums\JwtScope;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Models\Backup;
@@ -33,6 +34,7 @@ class DownloadLinkService
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->setScopes(JwtScope::BackupDownload)
->handle($backup->server->node, $user->id . $backup->server->uuid);
return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString());

View File

@@ -2,6 +2,7 @@
namespace App\Services\Nodes;
use App\Enums\JwtScope;
use App\Extensions\Lcobucci\JWT\Encoding\TimestampDates;
use App\Models\Node;
use App\Models\User;
@@ -12,12 +13,16 @@ use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\UnencryptedToken;
use Webmozart\Assert\Assert;
class NodeJWTService
{
/** @var array<array-key, mixed> */
private array $claims = [];
/** @var JwtScope[] */
private array $scopes = [];
private ?User $user = null;
private DateTimeImmutable $expiresAt;
@@ -36,6 +41,13 @@ class NodeJWTService
return $this;
}
public function setScopes(JwtScope ...$scopes): self
{
$this->scopes = $scopes;
return $this;
}
/**
* Attaches a user to the JWT being created and will automatically inject the
* "user_uuid" key into the final claims array with the user's UUID.
@@ -64,9 +76,9 @@ class NodeJWTService
/**
* Generate a new JWT for a given node.
*/
public function handle(Node $node, ?string $identifiedBy, string $algo = 'sha256'): UnencryptedToken
public function handle(Node $node, ?string $identifiedBy): UnencryptedToken
{
$identifier = hash($algo, $identifiedBy);
$identifier = hash('sha256', $identifiedBy);
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->daemon_token));
$builder = $config->builder(new TimestampDates())
@@ -89,6 +101,10 @@ class NodeJWTService
$builder = $builder->withClaim($key, $value);
}
Assert::notEmpty($this->scopes, 'Cannot generate a JWT without providing at least one scope.');
$builder = $builder->withClaim('scope', implode(' ', array_map(fn (JwtScope $scope) => $scope->value, $this->scopes)));
if (!is_null($this->user)) {
$builder = $builder->withClaim('user_uuid', $this->user->uuid);
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Servers;
use App\Enums\JwtScope;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Node;
@@ -100,7 +101,8 @@ class TransferServerService
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
->setScopes(JwtScope::ServerTransfer)
->handle($transfer->newNode, $server->uuid);
// Notify the source node of the pending outgoing transfer.
$this->notify($transfer, $token, $backup_uuid);

View File

@@ -0,0 +1,17 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class PermissionFactory extends Factory
{
protected $model = Permission::class;
public function definition(): array
{
return [
];
}
}

View File

@@ -11,12 +11,6 @@
<testsuite name="Unit">
<directory>./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>./tests/Feature</directory>
</testsuite>
<testsuite name="Filament">
<directory>./tests/Filament</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>

View File

@@ -68,7 +68,7 @@ class DispatchWebhooksTest extends TestCase
'events' => ['eloquent.created: '.Server::class],
]);
$webhookConfig->update(['events' => ['eloquent.deleted: '.Server::class]]);
$webhookConfig->update(['events' => 'eloquent.deleted: '.Server::class]);
$this->createServer();

View File

@@ -64,7 +64,7 @@ class ProcessWebhooksTest extends TestCase
ProcessWebhook::dispatchSync(
$webhook,
'eloquent.created: '.Server::class,
[$data],
$data,
);
$this->assertCount(1, cache()->get("webhooks.$eventName"));
@@ -73,7 +73,7 @@ class ProcessWebhooksTest extends TestCase
Http::assertSentCount(1);
Http::assertSent(function (Request $request) use ($webhook, $data) {
return $webhook->endpoint === $request->url()
&& $request->data() === array_merge($data, ['event' => 'created: Server']);
&& $request->data() === $data;
});
}
@@ -143,7 +143,7 @@ class ProcessWebhooksTest extends TestCase
$this->assertDatabaseCount(Webhook::class, 1);
$webhook = Webhook::query()->first();
$this->assertEquals($server->uuid, $webhook->payload['uuid']);
$this->assertEquals($server->uuid, $webhook->payload[0]['uuid']);
$this->assertDatabaseHas(Webhook::class, [
'endpoint' => $webhookConfig->endpoint,
@@ -165,11 +165,8 @@ class ProcessWebhooksTest extends TestCase
$server = $this->createServer();
$this->assertDatabaseCount(Webhook::class, 1);
$webhook = Webhook::query()->first();
$this->assertEquals($server->uuid, $webhook->payload['uuid']);
$this->assertDatabaseHas(Webhook::class, [
'payload' => json_encode([$server->toArray()]),
'endpoint' => $webhookConfig->endpoint,
'successful_at' => null,
'event' => 'eloquent.created: '.Server::class,

View File

@@ -4,7 +4,6 @@ use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\Eggs\Pages\ListEggs;
use App\Models\Egg;
use App\Models\Role;
use Spatie\Permission\Models\Permission;
use function Pest\Livewire\livewire;
@@ -23,7 +22,7 @@ it('root admin can see all eggs', function () {
it('non root admin cannot see any eggs', function () {
$role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']);
// Node Permission is on purpose, we check the wrong permissions.
$permission = Permission::create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount([]);
@@ -34,7 +33,7 @@ it('non root admin cannot see any eggs', function () {
it('non root admin with permissions can see eggs', function () {
$role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']);
$permission = Permission::create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
$eggs = Egg::all();

View File

@@ -5,8 +5,7 @@ use App\Filament\Admin\Resources\Nodes\Pages\ListNodes;
use App\Models\Node;
use App\Models\Role;
use App\Models\Server;
use Filament\Actions\Testing\TestAction;
use Spatie\Permission\Models\Permission;
use Filament\Actions\CreateAction;
use function Pest\Livewire\livewire;
@@ -25,7 +24,7 @@ it('root admin can see all nodes', function () {
it('non root admin cannot see any nodes', function () {
$role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']);
// Egg Permission is on purpose, we check the wrong permissions.
$permission = Permission::create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount();
@@ -36,7 +35,7 @@ it('non root admin cannot see any nodes', function () {
it('non root admin with permissions can see nodes', function () {
$role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']);
$permission = Permission::create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount();
@@ -61,6 +60,6 @@ it('displays the create button in the table instead of the header when 0 nodes',
$this->actingAs($admin);
livewire(ListNodes::class)
->assertSuccessful()
->assertActionDoesNotExist('create')
->assertActionExists(TestAction::make('create')->table());
->assertHeaderMissing(CreateAction::class)
->assertActionExists(CreateAction::class);
});

View File

@@ -99,11 +99,11 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
* the endpoint.
*/
#[DataProvider('authorizationTypeDataProvider')]
public function test_user_is_throttled_if_invalid_credentials_are_provided(string $type): void
public function test_user_is_throttled_if_invalid_credentials_are_provided(): void
{
for ($i = 0; $i <= 10; $i++) {
$this->postJson('/api/remote/sftp/auth', [
'type' => $type,
'type' => 'public_key',
'username' => $i % 2 === 0 ? $this->user->username : $this->getUsername(),
'password' => 'invalid key',
])

View File

@@ -32,7 +32,6 @@ use App\Models\Server;
use App\Models\Subuser;
use App\Models\User;
use App\Tests\Integration\IntegrationTestCase;
use Filament\Facades\Filament;
use Ramsey\Uuid\Uuid;
expect()->extend('toBeOne', function () {
@@ -43,11 +42,7 @@ expect()->extend('toLogActivities', function (int $times) {
expect(ActivityLog::count())->toBe($times);
});
uses(IntegrationTestCase::class)->in('Feature');
uses(IntegrationTestCase::class)
->beforeEach(fn () => Filament::setCurrentPanel('admin'))
->in('Filament');
uses(IntegrationTestCase::class)->in('Feature', 'Filament');
/*
|--------------------------------------------------------------------------

View File

@@ -18,7 +18,7 @@ class ConvertToUtf8Test extends TestCase
* Every output must be valid UTF-8, regardless of input encoding.
*/
#[DataProvider('helperDataProvider')]
public function test_output_is_valid_utf8(string $input, string $expected): void
public function test_output_is_valid_utf8(string $input): void
{
$result = convert_to_utf8($input);
$this->assertTrue(mb_check_encoding($result, 'UTF-8'), 'Output is not valid UTF-8: ' . bin2hex($result));
@@ -28,7 +28,7 @@ class ConvertToUtf8Test extends TestCase
* Running convert_to_utf8 twice must produce the same result as once.
*/
#[DataProvider('helperDataProvider')]
public function test_idempotent(string $input, string $expected): void
public function test_idempotent(string $input): void
{
$once = convert_to_utf8($input);
$twice = convert_to_utf8($once);