mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
Declare the capability a managed installation's staff seats hang off
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.
This commit is contained in:
@@ -66,6 +66,23 @@ enum Capability: string
|
||||
// package is installed, not a flag an installation can set. Present
|
||||
// in this enum even so, because a package cannot extend a closed one
|
||||
// — core has to publish the key before anything can gate on it.
|
||||
// Cloud-only — staff seats on a managed instance belong to the
|
||||
// platform that sold them rather than to the instance, so the tenant's
|
||||
// own /users screens stay closed (see UsersManage above) and a control
|
||||
// plane creates, deactivates and password-resets them from outside.
|
||||
//
|
||||
// The seat *number* deliberately does not live here. There are no
|
||||
// billing or plan tiers in this application to key off — the same
|
||||
// reason config/api.php gives for not inventing an installation-level
|
||||
// rate limit — so the limit arrives from the environment and this
|
||||
// capability only says who is in charge.
|
||||
//
|
||||
// Declared before the module that implements it exists, and that is
|
||||
// the point: a capability added after a release is invisible to every
|
||||
// image built from one, which is exactly how StorageManaged came to
|
||||
// sit unusable for a fleet that had everything else in place.
|
||||
case PlatformManaged = 'platform.managed';
|
||||
|
||||
case AiConnector = 'ai.connector';
|
||||
|
||||
/**
|
||||
@@ -84,6 +101,7 @@ enum Capability: string
|
||||
self::Branding,
|
||||
self::StorageManaged,
|
||||
self::CaptchaManagedKeys,
|
||||
self::PlatformManaged,
|
||||
self::AiConnector => [Edition::Cloud],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,61 @@ test('no package route escapes the modules prefix', function () {
|
||||
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
|
||||
|
||||
@@ -31,6 +31,7 @@ test('cloud edition has cloud exclusives and none of the community-only capabili
|
||||
$registry = new CapabilityRegistry(Edition::Cloud);
|
||||
|
||||
expect($registry->has(Capability::Branding))->toBeTrue()
|
||||
->and($registry->has(Capability::PlatformManaged))->toBeTrue()
|
||||
->and($registry->has(Capability::StorageManaged))->toBeTrue()
|
||||
->and($registry->has(Capability::UsersManage))->toBeFalse()
|
||||
->and($registry->has(Capability::StorageConfigure))->toBeFalse()
|
||||
@@ -43,5 +44,13 @@ test('cloud edition has cloud exclusives and none of the community-only capabili
|
||||
test('enabledKeys returns the string keys of enabled capabilities', function () {
|
||||
$registry = new CapabilityRegistry(Edition::Cloud);
|
||||
|
||||
expect($registry->enabledKeys())->toBe(['branding.customize', 'storage.managed', 'captcha.managed_keys', 'ai.connector']);
|
||||
// Order follows the enum, which is the order the control plane reads
|
||||
// them in — see GET /platform/v1/status in cloud-modules.
|
||||
expect($registry->enabledKeys())->toBe([
|
||||
'branding.customize',
|
||||
'storage.managed',
|
||||
'captcha.managed_keys',
|
||||
'platform.managed',
|
||||
'ai.connector',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user