diff --git a/app/Modules/Platform/Capabilities/Capability.php b/app/Modules/Platform/Capabilities/Capability.php index b71ce9c6..327d8b51 100644 --- a/app/Modules/Platform/Capabilities/Capability.php +++ b/app/Modules/Platform/Capabilities/Capability.php @@ -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], }; } diff --git a/tests/Feature/Api/ModuleBoundaryTest.php b/tests/Feature/Api/ModuleBoundaryTest.php index d78312fa..663f8fe0 100644 --- a/tests/Feature/Api/ModuleBoundaryTest.php +++ b/tests/Feature/Api/ModuleBoundaryTest.php @@ -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 diff --git a/tests/Unit/CapabilityRegistryTest.php b/tests/Unit/CapabilityRegistryTest.php index bc7c3ec1..0b54e5ec 100644 --- a/tests/Unit/CapabilityRegistryTest.php +++ b/tests/Unit/CapabilityRegistryTest.php @@ -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', + ]); });