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:
ignacionelson
2026-08-27 00:28:08 -03:00
parent 5d99ab94fd
commit 553f5fd2bf
3 changed files with 83 additions and 1 deletions
@@ -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],
};
}
+55
View File
@@ -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
+10 -1
View File
@@ -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',
]);
});