Files
projectsend/app/Modules/Identity/Http/Controllers/Api/UsersController.php
T
ignacionelson d3230a4b64 Say what edit_clients and edit_users reach, and document the new refusals
Setting a password and removing a second factor are how an
administrator lets a locked-out person back in, so a token holding
edit_clients or edit_users can sign in as the accounts it may edit. That
stays what those abilities mean; it is now said where it is chosen. The
token form warns when either is ticked, and the API guide says it beside
the abilities, with the three refusals on your own account under "Staff
accounts". The OpenAPI document carries the new 403s, and CHANGELOG.md
an Unreleased entry.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:09:03 -03:00

333 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Http\Resources\Api\StaffUserResource;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
/**
* Staff accounts over the API — the API twin of the /users screens.
*
* Both editions since 2.2.0. Every route is behind
* `capability:users.manage`, which cloud installations now hold as well:
* a platform sells staff seats and the tenant fills them, so an API that
* creates one is the same door the screen is, not a second unmanaged one
* (see Capability::UsersManage). How many it may create is
* SeatAllowance's question, asked here through StaffAccounts, and an
* installation at its limit answers 422 rather than 403.
*
* The capability stays in front of the routes rather than being dropped:
* it is the seam an edition difference would have to travel through, and
* an installation without it answers 403 `capability_unavailable`. The
* routes are registered in every edition either way, so the committed
* OpenAPI document is identical everywhere — the middleware refuses, the
* route table does not lie.
*
* The rules this enforces are not restated here. Who may be granted which
* role, who may edit whom, and never leaving the installation without an
* active administrator all live in StaffAccounts, which the web
* controller calls too. That sharing is the point: these are invariants
* about authority, and an invariant implemented twice is an invariant
* that will eventually hold once.
*
* `abort_unless($user->isStaff(), 404)` on each single-account route
* mirrors the web controller: a client is not addressable through this
* surface even by id, and has its own endpoints under /clients.
*/
class UsersController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly StaffAccounts $accounts,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
) {}
/**
* List staff accounts.
*
* Clients never appear here — they are a different population with
* their own endpoints under `/clients`.
*/
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'search' => ['nullable', 'string', 'max:255'],
'role_id' => ['nullable', 'integer'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$query = User::query()->where('type', UserType::Staff)->with('role');
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
$query->where(fn (Builder $inner) => $inner
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}
if (($filters['role_id'] ?? null) !== null) {
$query->where('role_id', (int) $filters['role_id']);
}
if (($filters['status'] ?? null) !== null) {
$query->where('active', $filters['status'] === 'active');
}
return StaffUserResource::collection($this->polling->paginate($request, $query, 'users'));
}
/**
* Get one staff account.
*
* Adds `assigned_client_ids` and the `content` counts that `DELETE`
* needs, neither of which the listing carries.
*/
public function show(Request $request, User $user): StaffUserResource
{
abort_unless($user->isStaff(), 404);
$this->accounts->guardTarget($this->actor($request), $user);
return $this->resourceFor($user);
}
/**
* Create a staff account.
*
* `role_id` must name a role the calling token's owner could grant.
* You cannot hand out authority you do not hold: a caller who is not
* an administrator may not create one, nor assign any role carrying a
* permission they lack. `GET /roles` lists what is available to you.
*
* The account is created active and with its email already verified —
* an administrator vouched for the address by sending it.
*/
public function store(Request $request): JsonResponse
{
$actor = $this->actor($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
// No `confirmed`: repeating a password defends against a human
// mistyping into a form, and an API caller has no second field
// to mistype. Password::defaults() still applies.
'password' => ['required', Password::defaults()],
'assigned_clients' => ['array'],
// Only clients you can reach yourself: an unrestricted account may
// assign any client, a client-scoped one only the clients already
// assigned to it. Assigning a client hands over everything that
// client can see, so it follows the same rule as role_id above.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
$user = $this->accounts->create([
'name' => $validated['name'],
'email' => $validated['email'],
'role_id' => (int) $validated['role_id'],
'password' => $validated['password'],
], array_values($validated['assigned_clients'] ?? []));
return $this->resourceFor($user->refresh())->response()->setStatusCode(201);
}
/**
* Update a staff account, including the role assigned to it.
*
* PATCH semantics: an absent key means "leave alone", not "clear".
* Sending `assigned_clients` replaces the whole list; omitting it
* leaves it, except that moving to a role which is not client-scoped
* clears it either way.
*
* Refused with a 422 if the change would leave the installation with
* no active administrator, or if you would be deactivating yourself.
*
* Your own email address and password cannot be changed here: that is
* a `403`. Change them from your profile in the web interface, which
* asks for your current password. Setting somebody else's password
* signs them out of the API: every token they hold is revoked.
*/
public function update(Request $request, User $user): StaffUserResource
{
abort_unless($user->isStaff(), 404);
$actor = $this->actor($request);
$this->accounts->guardTarget($actor, $user);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'role_id' => ['sometimes', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
'active' => ['sometimes', 'boolean'],
'password' => ['sometimes', 'nullable', Password::defaults()],
'assigned_clients' => ['sometimes', 'array'],
// Only clients you can reach yourself: an unrestricted account may
// assign any client, a client-scoped one only the clients already
// assigned to it. Assigning a client hands over everything that
// client can see, so it follows the same rule as role_id above.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
// Your own email address and password are changed from your
// profile, which asks for your current password. A token cannot be
// asked for one, so here the answer is no.
abort_if(
$this->accounts->ownCredentialChanges($actor, $user, $validated['email'] ?? null, $validated['password'] ?? null) !== [],
403,
__('Change your own email address and password from your profile.'),
);
// Read through Request::boolean() rather than off the validated
// array, for the reason RolesController::guardScopeRemoval spells
// out: the `boolean` rule accepts 0 and "0" as well as false but
// does not cast, so a strict comparison lets through a value the
// model's own `boolean` cast then stores as false anyway. The same
// value goes to the guard and to the write.
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
// The same refusal the web screen makes, and for the same reason:
// locking yourself out is never what was meant.
if ($user->is($actor) && $deactivating) {
throw ValidationException::withMessages([
'active' => __('You cannot deactivate your own account.'),
]);
}
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
if (array_key_exists('active', $validated)) {
$attributes['active'] = $request->boolean('active');
}
if (array_key_exists('role_id', $validated)) {
$attributes['role_id'] = (int) $validated['role_id'];
}
$this->accounts->update(
$user,
$attributes,
array_key_exists('assigned_clients', $validated)
? array_values($validated['assigned_clients'])
: null,
);
return $this->resourceFor($user->refresh());
}
/**
* Delete a staff account.
*
* If the account owns no files or folders, no body is needed.
*
* If it does, you must say what happens to that content: send
* `content_action` as either `cascade_delete` (delete it along with
* the account) or `reassign`, and in the latter case a
* `reassign_to_id` naming the active account that inherits it.
* Omitting the choice is a 422 — there is no default, because one
* would silently destroy files and the other would silently hand them
* to somebody else. `GET /users/{user}` reports the counts.
*
* You cannot delete your own account, and you cannot delete the last
* active administrator.
*/
public function destroy(Request $request, User $user): JsonResponse
{
abort_unless($user->isStaff(), 404);
$this->accounts->guardDeletable($this->actor($request), $user);
$validated = $this->accountDeletion->validate($request, $user);
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
DB::transaction(function () use ($validated, $user): void {
$name = $this->accounts->delete($user);
$this->accountDeletion->apply($validated, $user, $name);
});
return response()->json(status: 204);
}
/**
* Remove a staff account's two-factor authentication.
*
* The remedy for a locked-out account: somebody whose authenticator
* app and recovery codes are both gone cannot sign in, and nobody else
* can open the account for them either. Afterwards the account signs
* in with its password alone, and — if this installation enforces
* two-factor authentication for staff — is asked to enrol again on its
* next request.
*
* The account holder is emailed that this happened, and the action is
* recorded in the activity log against the caller. Answers 204 whether
* or not a second factor was actually in force.
*
* Not for your own account, which is a `403`: remove your own second
* factor from your profile in the web interface.
*/
public function destroyTwoFactor(Request $request, User $user, TwoFactorAdministration $twoFactor): JsonResponse
{
abort_unless($user->isStaff(), 404);
$actor = $this->actor($request);
$this->accounts->guardTarget($actor, $user);
// The web asks for your password before this (password.confirm),
// and a token cannot give one. On your own account it would let a
// token clear the second factor standing between it and a browser
// session as you.
abort_if($user->is($actor), 403, __('Remove your own two-factor authentication from your profile.'));
$twoFactor->reset($user);
return response()->json(status: 204);
}
private function resourceFor(User $user): StaffUserResource
{
return StaffUserResource::detailed(
$user->load('role'),
content: $this->accountContent->summarize($user),
assignedClientIds: array_values(
$user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all()
),
);
}
/**
* The token's owner. Every guard in StaffAccounts is asked on their
* behalf, never on the token's — a token cannot exceed its owner's
* permissions (EnsureTokenCan re-checks that on every request), so the
* owner is the right subject for "may they grant this role".
*/
private function actor(Request $request): User
{
$actor = $request->user();
assert($actor instanceof User);
return $actor;
}
}