Open user management on the cloud edition

A managed installation's staff accounts were expected to arrive from
outside it, so users.manage was Community-only and /users, /roles and
their API twins answered 404 there. The platform side spent a long
document designing its way around that gate; opening it is cheaper than
routing around it, and more honest about where the knowledge sits.

The division that settles it is the one managed storage already uses. We
do not manage a tenant's files from outside — a bucket is provisioned, a
scoped credential handed over, and what goes in it is the tenant's
business. Seats are the same kind of thing. A platform knows how many
staff accounts it sold; it does not know whether Alice should be an
Account Manager, and it certainly does not know where her files go when
she leaves. Capacity is the platform's, occupancy is the tenant's, and
the cap belongs in an environment variable rather than in a closed
screen.

The capability stays in front of the routes rather than being deleted.
It is currently true in both editions, but it is the seam an edition
difference has to travel through, and removing it would mean inventing
one again later.

Seven test files asserted the old rule, which is the tests doing their
job. Most flip. Two needed a different example instead: EnsureCapability
and AbilityCapability were both using users.manage to stand for
"Community-only", so they now use storage.configure and manage_updates —
keys that still are.

Two rationales half-expired and say so rather than being quietly
rewritten. CommentAuthors gave two reasons for being a setting rather
than a permission; the first was that roles are uneditable on cloud,
which stopped being true here, and the second — that `Everyone` includes
anonymous visitors, who have no role to hold a key — was always the
stronger and is now the whole of it.

The seat cap this makes necessary is the next commit, not this one. On
its own this change lets a managed tenant create staff accounts without
limit, which is why the two belong in the same release.
This commit is contained in:
ignacionelson
2026-08-27 02:18:25 -03:00
parent c05927c190
commit 623ad686da
13 changed files with 123 additions and 73 deletions
+11 -6
View File
@@ -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
{
@@ -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,
+6 -7
View File
@@ -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
+16 -10
View File
@@ -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:`
+22 -11
View File
@@ -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')
+20 -18
View File
@@ -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
@@ -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);
@@ -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
@@ -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();
});
@@ -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();
});
@@ -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 —
@@ -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');
});
+4 -1
View File
@@ -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',