mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
553f5fd2bf
Cloud instances are sold seats rather than administering them, so the tenant's own /users screens stay closed — capability:users.manage is already Community-only — and a control plane creates, deactivates and password-resets staff from outside. This is the key that plane gates on. Only the declaration lives here, the same division StorageManaged and Branding already use. Everything behind it is a module in the private cloud-modules package. Declared before that module exists, deliberately. A capability added after a release is invisible to every image built from one, and that is not hypothetical: StorageManaged landed 36 commits after v2.1.0 and has never shipped, so a fleet with buckets provisioned, credentials scoped and eight environment variables in place still writes every upload to local disk — because the gate is here and the gate never left. Declaring this one now is refusing to make the same mistake twice. The seat *number* deliberately does not live here. There are no billing or plan tiers in this application to key off, which is the reason config/api.php gives for not inventing an installation-level rate limit, and it holds for the same reason: the number lives where the plans do. This capability says only who is in charge. ModuleBoundaryTest grows the other half of its own rule. It filtered on `api/v1/`, so a package claiming a route anywhere else passed — not because that was sanctioned, but because nothing was looking, and /platform/v1 is about to be somewhere else. What it polices now is machine surfaces, the roots something other than a browser authenticates to, with api/v1/modules and platform/v1 as the two sanctioned prefixes. Written twice, because the first version was wrong in a useful way: it policed every route and immediately caught community-modules' Custom Assets screens. Those are a module doing exactly what a module is for, through the host's session and capability middleware in plain sight, and listing them would be the hardcoded URI list the test above it explains it is avoiding. Web screens are not the boundary; trusted perimeters are. Verified by making it fail: a package controller on platform/v2 is caught and named.
237 lines
9.3 KiB
PHP
237 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Api\Events\RegisteringApiModules;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use Illuminate\Routing\Route as RouteInstance;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The frame core holds around module-provided endpoints
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| cloud-modules and community-modules register their API routes through
|
|
| RegisteringApiModules. These assert the constraints core applies on their
|
|
| behalf, and that nothing can register outside the space reserved for it.
|
|
|
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
User::factory()->create();
|
|
});
|
|
|
|
test('a module slug must be well formed', function (string $slug) {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register($slug, fn () => null, null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
})->with([
|
|
'uppercase' => 'Branding',
|
|
'underscore' => 'custom_assets',
|
|
'path traversal' => '../../etc',
|
|
'slash' => 'a/b',
|
|
'leading hyphen' => '-branding',
|
|
'empty' => '',
|
|
]);
|
|
|
|
test('two modules cannot share a slug', function () {
|
|
$event = new RegisteringApiModules;
|
|
$event->register('branding', fn () => null, null);
|
|
|
|
expect(fn () => $event->register('branding', fn () => null, null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('a module cannot declare a capability core does not know', function () {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register('branding', fn () => null, 'invented.capability'))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('a missing route file is rejected at registration', function () {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register('branding', '/nonexistent/api-routes.php', null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('registered modules are reported for discovery', function () {
|
|
$event = new RegisteringApiModules;
|
|
$event->register('branding', fn () => null, 'branding.customize');
|
|
$event->register('custom-assets', fn () => null, 'custom_assets.manage');
|
|
|
|
expect($event->slugs())->toBe(['branding', 'custom-assets']);
|
|
});
|
|
|
|
/*
|
|
* The boundary itself: a package may not claim a path under /api/v1
|
|
* except beneath /api/v1/modules. Without this a package could shadow or
|
|
* extend a core endpoint and inherit its trust.
|
|
*
|
|
* Keyed off the controller's namespace rather than a hardcoded list of
|
|
* core URIs — the packages are the thing being constrained, and a list of
|
|
* core routes would need editing every time core gains an endpoint, which
|
|
* makes it a chore that gets rubber-stamped rather than a guard.
|
|
*/
|
|
test('no package route escapes the modules prefix', function () {
|
|
$packageNamespace = 'ProjectSend\\';
|
|
|
|
$offenders = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/'))
|
|
->reject(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'))
|
|
->filter(function (RouteInstance $route) use ($packageNamespace): bool {
|
|
$action = $route->getAction('controller') ?? '';
|
|
|
|
return is_string($action) && str_starts_with($action, $packageNamespace);
|
|
})
|
|
->map(fn (RouteInstance $route): string => $route->uri())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
expect($offenders)->toBe([]);
|
|
});
|
|
|
|
/*
|
|
* The same boundary, from outside /api/v1.
|
|
*
|
|
* The test above only looks at paths beginning `api/v1/`, so a package
|
|
* claiming `/platform/v1` passed it — not because that was sanctioned,
|
|
* but because nothing was looking.
|
|
*
|
|
* What is policed here is *machine* surfaces: roots a non-browser caller
|
|
* authenticates to. A package route there is a package extending the
|
|
* host's trusted perimeter rather than plugging into it. Package web
|
|
* screens are deliberately not policed — community-modules' Custom
|
|
* Assets adds `system/settings/custom-assets`, that is the point of a
|
|
* module, and those go through the host's session and capability
|
|
* middleware in plain sight. Listing them here would be the hardcoded
|
|
* URI list the test above explains it is avoiding.
|
|
*
|
|
* `platform/v1` is a single documented exception, in the same shape as
|
|
* the openapi.json allowlist below: a control plane is not an
|
|
* integration and does not belong under the token-authenticated API.
|
|
* Adding a second exception has to be a deliberate edit here, with a
|
|
* reason beside it.
|
|
*/
|
|
test('a package claims no machine surface outside the two it is given', function () {
|
|
$packageNamespace = 'ProjectSend\\';
|
|
|
|
// Roots something other than a browser authenticates to.
|
|
$machineRoots = ['api/', 'platform/'];
|
|
|
|
$sanctioned = [
|
|
'api/v1/modules/',
|
|
// The Cloud control plane. Deliberately outside /api/v1: it is
|
|
// authenticated by a platform secret rather than a staff bearer
|
|
// token, so putting it under the API's stack would mean either
|
|
// loosening that stack or pretending the caller is a person.
|
|
'platform/v1/',
|
|
];
|
|
|
|
$offenders = collect(Route::getRoutes()->getRoutes())
|
|
->filter(function (RouteInstance $route) use ($packageNamespace): bool {
|
|
$action = $route->getAction('controller') ?? '';
|
|
|
|
return is_string($action) && str_starts_with($action, $packageNamespace);
|
|
})
|
|
->filter(fn (RouteInstance $route): bool => collect($machineRoots)
|
|
->contains(fn (string $root): bool => str_starts_with($route->uri(), $root)))
|
|
->reject(fn (RouteInstance $route): bool => collect($sanctioned)
|
|
->contains(fn (string $prefix): bool => str_starts_with($route->uri(), $prefix)))
|
|
->map(fn (RouteInstance $route): string => $route->uri())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
expect($offenders)->toBe([]);
|
|
});
|
|
|
|
test('core api routes all carry the staff token stack', function () {
|
|
// The other half of the same boundary: whatever core adds under
|
|
// /api/v1, it must not be reachable without a staff token. A new
|
|
// endpoint declared outside the group in routes/api.php would be.
|
|
//
|
|
// The allowlist is a single documented exception rather than a loosened
|
|
// rule: adding a second unauthenticated endpoint has to be a deliberate
|
|
// edit here, with a reason next to it.
|
|
$publicByDesign = [
|
|
// The specification itself — a client needs it before it can have a
|
|
// token, and it describes the API's shape, never this
|
|
// installation's data (asserted in OpenApiContractTest).
|
|
'api/v1/openapi.json',
|
|
];
|
|
|
|
$unguarded = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/'))
|
|
->reject(fn (RouteInstance $route): bool => in_array($route->uri(), $publicByDesign, true))
|
|
->reject(fn (RouteInstance $route): bool => in_array('staff-token', $route->gatherMiddleware(), true))
|
|
->map(fn (RouteInstance $route): string => $route->uri())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
expect($unguarded)->toBe([]);
|
|
});
|
|
|
|
/*
|
|
* With a real module installed, the edition gate must actually bite.
|
|
*
|
|
* Physical absence is the primary control — cloud-modules is simply not
|
|
* installed on a community deployment — but "present anyway" is the case
|
|
* that has gone wrong before: CustomAssets once shipped routes that
|
|
* answered 200 in an edition where the feature was never supposed to
|
|
* exist, because restating the capability was left to the package. Here
|
|
* the host applies it from the module's own declaration, so it cannot be
|
|
* forgotten.
|
|
*
|
|
* Skipped where no module is installed, which includes CI.
|
|
*/
|
|
test('a module endpoint is refused in an edition without its capability', function () {
|
|
$moduleRoute = collect(Route::getRoutes()->getRoutes())
|
|
->first(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'));
|
|
|
|
if ($moduleRoute === null) {
|
|
expect(true)->toBeTrue();
|
|
|
|
return;
|
|
}
|
|
|
|
$staff = User::factory()->create();
|
|
$token = $staff->createToken('t', ['edit_settings'])->plainTextToken;
|
|
|
|
// Branding is cloud-only; the suite runs as community by default.
|
|
$this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertForbidden();
|
|
|
|
config(['projectsend.edition' => Edition::Cloud]);
|
|
forgetRequestState();
|
|
|
|
$this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertOk();
|
|
});
|
|
|
|
test('every module route inherits the core auth stack', function () {
|
|
$moduleRoutes = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'));
|
|
|
|
if ($moduleRoutes->isEmpty()) {
|
|
// Neither package is installed in this checkout. Skipping is
|
|
// correct — the invariant is about installed modules, and a
|
|
// failure here would just mean "no packages present".
|
|
expect(true)->toBeTrue();
|
|
|
|
return;
|
|
}
|
|
|
|
$moduleRoutes->each(function (RouteInstance $route): void {
|
|
$middleware = $route->gatherMiddleware();
|
|
|
|
expect($middleware)->toContain('auth:sanctum')
|
|
->and($middleware)->toContain('staff-token')
|
|
->and($middleware)->toContain('api-active');
|
|
});
|
|
});
|