Files
projectsend/app/Modules/Clients/Http/Controllers/Api/ClientsController.php
T
ignacionelson e7b5b6a757 Hold client records to the same boundary the rest of the library uses
The other half of the sweep. ClientsController and its API twin checked
`abort_unless($client->isClient(), 404)` and nothing else -- a type
check, not a boundary, which is the phrase #1701 used about the group
membership routes for exactly the same reason.

Measured before the fix, with a client-scoped role holding the client
permissions:

  GET    /clients            every client on the installation, name + email
  GET    /clients/{stranger} 200
  PATCH  /clients/{stranger} 302, name actually changed
  DELETE /clients/{stranger} 302, client gone

The tell was one route over. ClientFilesController::index already draws
this line with StaffLibraryScope::canAssignClient and calls it "the same
boundary StaffLibraryScope enforces everywhere else in the library". Its
neighbours in the same family did not.

So the predicate is not new here. What is new is StaffLibraryScope::clients(),
the listing half of canAssignClient, so a screen narrows by the rule its
own buttons are guarded with instead of restating it -- restating it is
how this went wrong, and how the last four of these went wrong.

Eight actions take it: edit, update, destroy and the two-factor reset on
both surfaces, plus both listings. Answering 404 rather than 403, since a
client outside the roster should not be distinguishable from one that is
not there -- matching the isClient() guard already above it.

Account requests stay installation-wide on purpose: a self-registered
client who has not been approved belongs to nobody yet, so there is no
roster to narrow by and narrowing would empty the screen.

The published API document is unchanged -- both routes already documented
the 404 that the type check produced.
2026-08-26 16:01:27 -03:00

377 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Clients\Http\Resources\Api\ClientResource;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
/**
* Client accounts over the API.
*
* Clients are `users` rows with type = client, so every response here goes
* through ClientResource's allowlist rather than the model. `abort_unless
* ($client->isClient(), 404)` on each single-client route mirrors the web
* controller: a staff account is not addressable through this surface even
* by id.
*
* Validation rules, custom-field handling and the deletion flow are the
* web controller's, reused or mirrored field for field — a client created
* through the API must be indistinguishable from one created in the UI.
*/
class ClientsController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
// Narrowed the same way the web listing is, and by the same
// rule the object routes below are guarded with.
$viewer = $request->user();
assert($viewer !== null);
$query = $this->scope->clients($viewer);
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
$query->where(fn (Builder $inner) => $inner
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}
if (($filters['status'] ?? null) !== null) {
$query->where('active', $filters['status'] === 'active');
}
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
}
public function show(Request $request, User $client): ClientResource
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: the token's `edit_clients`
// says its owner manages clients, not that they manage *this*
// one. Mirrors the web controller, as every API twin here does.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
return $this->resourceFor($client);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
// No `confirmed`: repeating a password is a defence against a
// human mistyping into a form, and an API caller has no second
// field to mistype. This installation's password policy still
// applies — a minimum length, and optionally a check against
// known breaches. Both are configured under Settings →
// Security, so read them from there rather than assuming the
// defaults; a password this endpoint accepts on one
// installation may be refused on another.
'password' => ['required', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
'custom_field_values' => ['array'],
]);
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 means "no custom quota" and inherits the site default at
// enforcement time — see ClientStorageUsage::quotaMb().
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return $this->resourceFor($client->refresh())->response()->setStatusCode(201);
}
public function update(Request $request, User $client): ClientResource
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: the token's `edit_clients`
// says its owner manages clients, not that they manage *this*
// one. Mirrors the web controller, as every API twin here does.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)],
'active' => ['sometimes', 'boolean'],
'password' => ['sometimes', 'nullable', Password::defaults()],
'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'],
'custom_field_values' => ['sometimes', 'array'],
]);
if ($request->has('custom_field_values')) {
$validated['custom_field_values'] = $this->validateCustomFieldValues($request, required: false);
}
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
// PATCH semantics, unlike the web form which always submits every
// field: an absent key means "leave alone", not "clear".
$client->fill(array_intersect_key($validated, array_flip(['name', 'email', 'active'])));
if (array_key_exists('storage_quota_mb', $validated)) {
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
}
if (($validated['active'] ?? false) && $client->account_requested) {
$client->account_requested = false;
}
if ($passwordChanged) {
$client->password = $validated['password'];
}
$client->save();
if (array_key_exists('custom_field_values', $validated)) {
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
}
$this->activity->log(Action::UserUpdated, subject: $client);
if ($wasActive && ! $client->active) {
$this->activity->log(Action::UserDeactivated, subject: $client);
} elseif (! $wasActive && $client->active) {
$this->activity->log(Action::UserActivated, subject: $client);
}
if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged)
&& $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountEditedNotification);
}
return $this->resourceFor($client->refresh());
}
/**
* Remove a client's two-factor authentication.
*
* The remedy for a locked-out account: a client whose authenticator
* app and recovery codes are both gone cannot sign in, and nobody else
* can open the account for them either. Afterwards they sign in with
* their password alone, and — if this installation enforces two-factor
* authentication for clients — are asked to enrol again on their next
* request.
*
* The client 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.
*/
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): JsonResponse
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: the token's `edit_clients`
// says its owner manages clients, not that they manage *this*
// one. Mirrors the web controller, as every API twin here does.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$twoFactor->reset($client);
return response()->json(status: 204);
}
/**
* Delete a client.
*
* If the client owns no files or folders, no body is needed.
*
* If they do, 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 a
* client's files and the other would silently hand them to somebody
* else.
*
* `GET /clients/{client}` reports the counts so you can decide before
* calling this.
*/
public function destroy(Request $request, User $client): JsonResponse
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: the token's `edit_clients`
// says its owner manages clients, not that they manage *this*
// one. Mirrors the web controller, as every API twin here does.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
return response()->json(status: 204);
}
private function resourceFor(User $client): ClientResource
{
return ClientResource::detailed(
$client,
customFieldValues: ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id')
->all(),
storage: $this->storageUsage,
content: $this->accountContent->summarize($client),
);
}
/**
* Validated separately from the main rule set, and deliberately so.
*
* The per-field rules are built by querying `client_custom_fields`, so
* they name this installation's actual field ids. Passing them to
* `$request->validate()` put those ids into the generated OpenAPI
* document — a document that is committed, served unauthenticated, and
* supposed to be identical on every install. It described one
* database's configuration and leaked which custom fields exist.
*
* The endpoint still validates exactly as before; only the shape the
* documentation generator can see has changed, to a plain object.
*
* @return array<int, mixed>
*/
private function validateCustomFieldValues(Request $request, bool $required = true): array
{
$rules = $this->customFieldRules($required);
if ($rules === []) {
return $request->input('custom_field_values', []);
}
return Validator::make($request->all(), $rules)->validate()['custom_field_values'] ?? [];
}
/**
* Mirrors ClientsController::customFieldRules(). On update the required
* flag is dropped, since PATCH may legitimately omit a field it is not
* changing — the value already stored satisfies the requirement.
*
* @return array<string, array<int, mixed>>
*/
private function customFieldRules(bool $required = true): array
{
$rules = [];
foreach (ClientCustomField::query()->get() as $field) {
$key = "custom_field_values.{$field->id}";
if ($field->type === ClientCustomFieldType::Checkbox) {
$rules[$key] = ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$required && $field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
: (is_string($submitted) ? $submitted : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $value === '' ? null : $value],
);
}
}
}