diff --git a/app/Modules/Comments/CommentAuthors.php b/app/Modules/Comments/CommentAuthors.php index de96d3a6..7f14a289 100644 --- a/app/Modules/Comments/CommentAuthors.php +++ b/app/Modules/Comments/CommentAuthors.php @@ -9,12 +9,17 @@ use App\Modules\Identity\UserType; /** * Who may write a comment (Setting::CommentsAuthors). * - * This is a setting rather than a permission on purpose. Roles are only - * editable in the community edition — the cloud edition gates the whole - * roles screen behind Capability::UsersManage — so a permission key would - * be unconfigurable for half our installs. It also expresses something a - * permission structurally cannot: `Everyone` includes anonymous visitors, - * who have no account and therefore no role to hold a key. + * This is a setting rather than a permission on purpose, and one of the + * two reasons has since expired. It used to be that roles were editable + * only in the community edition — the cloud edition gated the whole roles + * screen behind Capability::UsersManage — so a permission key would have + * been unconfigurable for half our installs. That stopped being true in + * 2.2.0, when users.manage opened on both editions. + * + * The reason that carries it now is the one a permission structurally + * cannot express: `Everyone` includes anonymous visitors, who have no + * account and therefore no role to hold a key. That was always the + * stronger half; it is now the whole of it. */ enum CommentAuthors: string { diff --git a/app/Modules/Platform/Capabilities/Capability.php b/app/Modules/Platform/Capabilities/Capability.php index 327d8b51..6fd2f235 100644 --- a/app/Modules/Platform/Capabilities/Capability.php +++ b/app/Modules/Platform/Capabilities/Capability.php @@ -19,7 +19,14 @@ namespace App\Modules\Platform\Capabilities; */ enum Capability: string { - // Community-only — cut where the installation is managed for you. + // Both editions. It was Community-only while a managed installation's + // staff accounts were expected to be created from outside — but a + // platform does not know whether Alice should be an Account Manager, + // any more than it knows where her files go when she leaves, and the + // seat count it does own is enforced by PROJECTSEND_PLATFORM_MAX_STAFF_USERS + // rather than by closing the screen. Capacity is the platform's; who + // fills it is the tenant's. Same division managed storage already uses: + // the bucket is provisioned, what goes in it is not. case UsersManage = 'users.manage'; case StorageConfigure = 'storage.configure'; case EmailTransportConfigure = 'email.transport.configure'; @@ -91,13 +98,14 @@ enum Capability: string public function editions(): array { return match ($this) { - self::UsersManage, self::StorageConfigure, self::EmailTransportConfigure, self::SystemUpdates, self::SchedulerMonitoring, self::CustomAssets => [Edition::Community], + self::UsersManage => [Edition::Community, Edition::Cloud], + self::Branding, self::StorageManaged, self::CaptchaManagedKeys, diff --git a/docs/api-guide.md b/docs/api-guide.md index 15c2782e..003c1ea9 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -90,8 +90,8 @@ list for your account. | `manage_groups` | list groups | | `moderate_comments` | list what is awaiting approval, and approve it | -| `manage_users` | list staff accounts and the roles you may assign — **Community only** | -| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication — **Community only** | +| `manage_users` | list staff accounts and the roles you may assign | +| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication | There is no ability for *writing* a comment. Who may comment is an installation setting rather than a per-role permission, so the file abilities are the gate — the same question the web asks, which is @@ -334,11 +334,10 @@ two are narrowed to what your token may see: a counterpart outside your reach re `/users` manages the people who administer the installation, and the role assigned to each of them. Clients are a different population with their own `/clients` endpoints and never appear here. -**Community only.** Every endpoint under `/users` and `/roles` answers `403` with -`type: capability_unavailable` on a managed installation, where staff accounts are created outside -the application instead. The paths are still registered there, and still in this document, so that the -specification is identical on every install — the refusal tells you why, where a missing route would -not. +Available on every edition. A managed installation may cap how many staff accounts exist — the +operator supplies the number, and creating one past it is refused with a validation error naming the +limit — but who fills those seats, and which role each of them holds, is the installation's own +decision and always was. Two abilities are needed for each call: `manage_users` to reach the area at all, then the one for the action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole diff --git a/routes/api.php b/routes/api.php index 70d7f9e3..5172a3bd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -217,17 +217,23 @@ Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function | Staff accounts and the roles assigned to them |---------------------------------------------------------------------- | - | Community only. Managed installations create staff accounts outside - | the application, so an API able to mint them there would be a second, - | unmanaged door into the same thing — `capability:users.manage` is the - | same gate routes/web.php puts on the /users screens. + | Both editions since 2.2.0. This was Community-only while a managed + | installation's staff accounts were expected to arrive from outside; + | a platform provisions the seat count and the tenant decides who + | fills it, so the screen and the endpoints stay open and + | PROJECTSEND_PLATFORM_MAX_STAFF_USERS is what a platform actually + | owns. `capability:users.manage` is the same gate routes/web.php puts + | on the /users screens, and both now pass in both editions. | - | Registered in every edition even so, and refused by the middleware - | rather than by not existing: the OpenAPI document is committed, - | served unauthenticated, and must be identical on every install - | (OpenApiContractTest compares it against the route table). A cloud - | caller gets 403 `capability_unavailable`, which says what is wrong; - | an endpoint that silently was not there would not. + | The middleware stays rather than being deleted: the capability is + | the seam an edition difference has to travel through, and a + | capability that is currently true everywhere is still where a future + | edition would say otherwise. A caller in an edition without it gets + | 403 `capability_unavailable`, which says what is wrong; an endpoint + | that silently was not there would not. The OpenAPI document is + | committed and served unauthenticated, so it must be identical on + | every install either way (OpenApiContractTest compares it against + | the route table). | | Permissions mirror routes/web.php exactly: `manage_users` to reach | the area at all, then a key per action on top of it. Two `token-can:` diff --git a/tests/Feature/Api/AbilityCapabilityTest.php b/tests/Feature/Api/AbilityCapabilityTest.php index 82ff7623..c70818e2 100644 --- a/tests/Feature/Api/AbilityCapabilityTest.php +++ b/tests/Feature/Api/AbilityCapabilityTest.php @@ -68,10 +68,20 @@ test('capability-gated abilities are unavailable in an edition that lacks them', asCloud(); $abilities = app(TokenAbilities::class); - foreach (['manage_users', 'create_users', 'manage_updates', 'create_assets', 'delete_assets'] as $key) { + // manage_users and create_users used to be in this list. They stopped + // being edition-gated in 2.2.0 — a platform caps how many seats exist + // rather than closing the screen — so they would now assert the + // opposite of what this test is about. + foreach (['manage_updates', 'create_assets', 'delete_assets'] as $key) { expect($abilities->isAvailable($key))->toBeFalse(); } + // And the pair that moved: available in both editions now, which is + // worth pinning here rather than only in CapabilityRegistryTest, + // because this is the surface that would silently stop offering them. + expect($abilities->isAvailable('manage_users'))->toBeTrue() + ->and($abilities->isAvailable('create_users'))->toBeTrue(); + // Ungated abilities are unaffected by the edition. expect($abilities->isAvailable('upload'))->toBeTrue() ->and($abilities->isAvailable('edit_settings'))->toBeTrue(); @@ -100,11 +110,12 @@ test('the rendered checkbox list is never empty of implemented abilities', funct test('issuance rejects a capability-unavailable ability even if the role grants it', function () { asCloud(); - // The role still grants manage_users — an administrator holds every - // permission — so only the capability half can refuse this. + // The role still grants manage_updates — an administrator holds every + // permission — so only the capability half can refuse this. Was + // manage_users until 2.2.0 opened that one on both editions. $this->actingAs($this->admin)->post('/settings/api-tokens', [ - 'name' => 'Cloud user management', - 'abilities' => [Permission::ManageUsers->value], + 'name' => 'Cloud update management', + 'abilities' => [Permission::ManageUpdates->value], 'expires_in_days' => 30, ])->assertSessionHasErrors('abilities.0'); @@ -112,17 +123,17 @@ test('issuance rejects a capability-unavailable ability even if the role grants }); test('a token minted under one edition stops working under another', function () { - Route::middleware(['auth:sanctum', 'api-active', 'staff-token', 'token-can:manage_users']) - ->get('api/v1/_test/users', fn () => response()->json(['ok' => true])); + Route::middleware(['auth:sanctum', 'api-active', 'staff-token', 'token-can:manage_updates']) + ->get('api/v1/_test/updates', fn () => response()->json(['ok' => true])); // Minted on community, where the capability exists. - $token = $this->admin->createToken('t', [Permission::ManageUsers->value])->plainTextToken; - $this->withToken($token)->getJson('/api/v1/_test/users')->assertOk(); + $token = $this->admin->createToken('t', [Permission::ManageUpdates->value])->plainTextToken; + $this->withToken($token)->getJson('/api/v1/_test/updates')->assertOk(); asCloud(); forgetRequestState(); - $this->withToken($token)->getJson('/api/v1/_test/users')->assertForbidden(); + $this->withToken($token)->getJson('/api/v1/_test/updates')->assertForbidden(); }); test('me reports only abilities the edition can honour', function () { @@ -130,7 +141,7 @@ test('me reports only abilities the edition can honour', function () { $token = $this->admin->createToken('t', [ Permission::Upload->value, - Permission::ManageUsers->value, + Permission::ManageUpdates->value, ])->plainTextToken; $this->withToken($token)->getJson('/api/v1/me') diff --git a/tests/Feature/Api/UsersTest.php b/tests/Feature/Api/UsersTest.php index bd2edb53..c4a001a1 100644 --- a/tests/Feature/Api/UsersTest.php +++ b/tests/Feature/Api/UsersTest.php @@ -72,15 +72,27 @@ function userManager(array $permissions = ['manage_users', 'create_users', 'edit | */ -test('every staff endpoint is refused on cloud, with a reason a caller can branch on', function (string $method, string $uri) { +/* + * Staff endpoints used to be refused on cloud — users.manage was + * Community-only, on the reasoning that a managed installation's accounts + * arrived from outside. That reversed in 2.2.0: a platform provisions how + * many seats exist, the tenant decides who fills them, and the cap is an + * environment variable rather than a closed screen. + * + * The capability itself stays in front of these routes even though it is + * currently true in both editions — it is the seam an edition difference + * has to travel through, and deleting it would mean re-inventing one later. + */ +test('every staff endpoint answers on cloud, the same as on community', function (string $method, string $uri) { config(['projectsend.edition' => Edition::Cloud]); - $this->withToken($this->token)->json($method, $uri) - ->assertForbidden() - ->assertHeader('Content-Type', 'application/problem+json') - ->assertJsonPath('type', 'capability_unavailable') - ->assertJsonPath('capability', 'users.manage') - ->assertJsonPath('edition', 'cloud'); + // Not asserting the status, which varies by route and payload — only + // that none of them is the capability refusal any more. Read as raw + // content rather than JSON: a successful delete answers 204 with an + // empty body, which json() cannot parse. + $response = $this->withToken($this->token)->json($method, $uri); + + expect((string) $response->getContent())->not->toContain('capability_unavailable'); })->with([ 'list' => ['GET', '/api/v1/users'], 'roles' => ['GET', '/api/v1/roles'], @@ -88,19 +100,9 @@ test('every staff endpoint is refused on cloud, with a reason a caller can branc 'create' => ['POST', '/api/v1/users'], 'update' => ['PATCH', '/api/v1/users/1'], 'delete' => ['DELETE', '/api/v1/users/1'], - 'reset two-factor' => ['DELETE', '/api/v1/users/1/two-factor'], + 'two-factor' => ['DELETE', '/api/v1/users/1/two-factor'], ]); -// The routes exist in every edition so the committed OpenAPI document is -// identical everywhere — the middleware refuses, the route table does not -// lie. A 404 here would mean the document described a path that was not -// registered. -test('the routes are registered on cloud even though they refuse', function () { - config(['projectsend.edition' => Edition::Cloud]); - - $this->withToken($this->token)->getJson('/api/v1/users')->assertStatus(403); -}); - /* |-------------------------------------------------------------------------- | Privacy diff --git a/tests/Feature/Comments/ModerateCommentsPermissionTest.php b/tests/Feature/Comments/ModerateCommentsPermissionTest.php index 237a28ab..2b47e2e5 100644 --- a/tests/Feature/Comments/ModerateCommentsPermissionTest.php +++ b/tests/Feature/Comments/ModerateCommentsPermissionTest.php @@ -11,12 +11,13 @@ use Inertia\Testing\AssertableInertia; * The one permission this feature adds, from the screen that grants it to * the page it gates. * - * Forced to the community edition because the cloud edition gates the - * whole roles screen behind Capability::UsersManage — which is exactly why - * commenting itself is governed by settings rather than permission keys - * (see App\Modules\Comments\CommentAuthors). Without this, the roles half - * of the feature would be untestable on a cloud-configured install and - * unverifiable by hand on one. + * Forced to the community edition when the cloud edition gated the whole + * roles screen behind Capability::UsersManage, which would have made the + * roles half of this feature untestable on a cloud-configured install. + * That gate opened on both editions in 2.2.0, so the forcing is no longer + * load-bearing — it is kept because pinning the edition keeps this test + * about the permission rather than about whichever edition the suite + * happens to be configured for. */ beforeEach(function () { config()->set('projectsend.edition', Edition::Community); diff --git a/tests/Feature/Identity/AccountConversionTest.php b/tests/Feature/Identity/AccountConversionTest.php index 12a68770..950604ad 100644 --- a/tests/Feature/Identity/AccountConversionTest.php +++ b/tests/Feature/Identity/AccountConversionTest.php @@ -49,10 +49,14 @@ function accountManager(): User |-------------------------------------------------------------------------- */ -test('the tool is absent in the cloud edition', function () { +test('the tool is present in the cloud edition too', function () { + // It follows users.manage, which opened on both editions in 2.2.0. + // Converting a client to staff takes a staff seat and frees a client + // one, which is why SeatAllowance has to see this door as well as the + // two creation controllers. config()->set('projectsend.edition', Edition::Cloud); - $this->actingAs($this->admin)->get('/users/convert')->assertNotFound(); + $this->actingAs($this->admin)->get('/users/convert')->assertOk(); }); // "convert" must be registered before users/{user} or it binds as a route diff --git a/tests/Feature/Identity/RolesManagementTest.php b/tests/Feature/Identity/RolesManagementTest.php index 69c2741b..6087c08e 100644 --- a/tests/Feature/Identity/RolesManagementTest.php +++ b/tests/Feature/Identity/RolesManagementTest.php @@ -161,10 +161,12 @@ test('roles management requires the manage_users permission', function () { $this->get('/roles')->assertForbidden(); }); -test('roles management is absent in the cloud edition', function () { +test('roles management is present in the cloud edition too', function () { + // Follows users.manage, which gates both screens — see the capability's + // own comment for why that opened in 2.2.0. config()->set('projectsend.edition', Edition::Cloud); $this->actingAs(User::factory()->create()); - $this->get('/roles')->assertNotFound(); + $this->get('/roles')->assertOk(); }); diff --git a/tests/Feature/Identity/UsersManagementTest.php b/tests/Feature/Identity/UsersManagementTest.php index 5b80870f..5c46e4ce 100644 --- a/tests/Feature/Identity/UsersManagementTest.php +++ b/tests/Feature/Identity/UsersManagementTest.php @@ -361,10 +361,14 @@ test('users management requires granular permissions', function () { $this->get('/users')->assertForbidden(); }); -test('users management is absent in the cloud edition', function () { +test('users management is present in the cloud edition too', function () { + // It was absent until 2.2.0, on the reasoning that a managed + // installation's accounts arrived from outside it. A platform + // provisions how many seats exist; who fills them, and which role each + // holds, is knowledge the platform does not have. config()->set('projectsend.edition', Edition::Cloud); $this->actingAs(admin()); - $this->get('/users')->assertNotFound(); + $this->get('/users')->assertOk(); }); diff --git a/tests/Feature/Platform/EnsureCapabilityMiddlewareTest.php b/tests/Feature/Platform/EnsureCapabilityMiddlewareTest.php index 2b474227..f75b1679 100644 --- a/tests/Feature/Platform/EnsureCapabilityMiddlewareTest.php +++ b/tests/Feature/Platform/EnsureCapabilityMiddlewareTest.php @@ -6,7 +6,13 @@ use App\Modules\Platform\Capabilities\Edition; use Illuminate\Support\Facades\Route; beforeEach(function () { - Route::middleware('capability:users.manage')->get('/test/community-only', fn () => 'ok'); + // storage.configure rather than users.manage: this test needs a + // capability that is genuinely Community-only, and users.manage stopped + // being one in 2.2.0 when a platform's seats became a cap rather than a + // closed screen. Any Community-only key would do — this one is picked + // because a managed installation is given its storage, which is the + // clearest example of the edition line the middleware exists to draw. + Route::middleware('capability:storage.configure')->get('/test/community-only', fn () => 'ok'); Route::middleware('capability:branding.customize')->get('/test/cloud-only', fn () => 'ok'); // Under api/, because ProblemDetails is scoped to the API on purpose — diff --git a/tests/Feature/Platform/GettingStartedTest.php b/tests/Feature/Platform/GettingStartedTest.php index 2ed6ca22..9b22622f 100644 --- a/tests/Feature/Platform/GettingStartedTest.php +++ b/tests/Feature/Platform/GettingStartedTest.php @@ -157,16 +157,15 @@ test('it only lists what this person may actually do', function () { ->and($keys)->not->toContain('theme'); }); -// The example the brief named: a managed installation has no staff -// accounts of its own to hand out, no mail server to point anywhere and -// no scheduler to check. +// A managed installation has no mail server to point anywhere and no +// scheduler to check. It does now have staff accounts of its own — that +// changed in 2.2.0 — so 'team' belongs on its list where it once did not. test('a managed installation is not sent to screens it does not have', function () { config()->set('projectsend.edition', Edition::Cloud); $keys = quickStartKeys($this->admin); - expect($keys)->toContain('client', 'upload', 'theme', 'email-theme') - ->and($keys)->not->toContain('team') + expect($keys)->toContain('client', 'upload', 'theme', 'email-theme', 'team') ->and($keys)->not->toContain('email') ->and($keys)->not->toContain('scheduler'); }); diff --git a/tests/Unit/CapabilityRegistryTest.php b/tests/Unit/CapabilityRegistryTest.php index 0b54e5ec..964a2702 100644 --- a/tests/Unit/CapabilityRegistryTest.php +++ b/tests/Unit/CapabilityRegistryTest.php @@ -32,8 +32,10 @@ test('cloud edition has cloud exclusives and none of the community-only capabili expect($registry->has(Capability::Branding))->toBeTrue() ->and($registry->has(Capability::PlatformManaged))->toBeTrue() + // Both editions since 2.2.0: a platform provisions seats, it does + // not decide who fills them. See the case's own comment. + ->and($registry->has(Capability::UsersManage))->toBeTrue() ->and($registry->has(Capability::StorageManaged))->toBeTrue() - ->and($registry->has(Capability::UsersManage))->toBeFalse() ->and($registry->has(Capability::StorageConfigure))->toBeFalse() ->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse() ->and($registry->has(Capability::SystemUpdates))->toBeFalse() @@ -47,6 +49,7 @@ test('enabledKeys returns the string keys of enabled capabilities', function () // 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([ + 'users.manage', 'branding.customize', 'storage.managed', 'captcha.managed_keys',