Files
projectsend/app/Modules/Identity/StaffAccounts.php
T
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

300 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
/**
* Creating, changing and deleting staff accounts — the rules and the side
* effects, shared by the web screens and the API.
*
* This exists because the rules here are about *authority*, not
* convenience. "Nobody hands out authority they do not hold" and "never
* leave the installation without an active administrator" are invariants,
* and an invariant enforced in one controller and re-implemented in
* another is an invariant that will eventually hold in only one of them.
* The API added a second surface onto this domain; rather than mirror the
* checks, both surfaces call these.
*
* What stays in the controllers is what genuinely differs: the shape of
* the request (a form posting every field vs. a PATCH sending a subset),
* validation rules, and the response.
*/
class StaffAccounts
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly PermissionChecker $permissions,
) {}
/**
* Nobody hands out authority they do not hold. `manage_users` is a
* permission like any other, so without this a non-administrator
* holding it could mint an administrator — or build a custom role
* carrying `edit_settings`, `delete_others_files`, anything — and
* assign it, to a new account or to themselves. That turns one
* permission into every permission and makes the rest of the matrix
* decorative.
*
* An administrator holds every permission by construction, so this is
* always true for them and the admin experience is unchanged.
*/
public function mayGrant(User $actor, Role $role): bool
{
if ($actor->role?->is_administrator === true) {
return true;
}
if ($role->is_administrator) {
return false;
}
$held = $this->permissions->grantedKeys($actor);
$granting = $role->permissions()->pluck('permission')->all();
return array_diff($granting, $held) === [];
}
/**
* Staff-assignable roles: everything except the Client system role,
* narrowed to what this actor may actually grant.
*
* @return Collection<int, Role>
*/
public function assignableRoles(User $actor): Collection
{
return Role::query()
->where('name', '!=', SystemRole::Client->value)
->orderByDesc('is_administrator')
->orderByDesc('is_system')
->orderBy('name')
->get()
->filter(fn (Role $role): bool => $this->mayGrant($actor, $role))
->values();
}
/**
* @return list<int>
*/
public function assignableRoleIds(User $actor): array
{
return array_values($this->assignableRoles($actor)->map(fn (Role $role): int => $role->id)->all());
}
/**
* The same rule applied to an existing account: if the actor could not
* grant the target's role, they have no business editing or deleting
* that account either. Without this, a non-administrator holding
* manage_users could still rename, deactivate or delete an
* administrator — the role picker would refuse the role, but everything
* around it would go through.
*/
public function guardTarget(User $actor, User $target): void
{
if ($target->is($actor)) {
return;
}
$role = $target->role;
abort_unless($role === null || $this->mayGrant($actor, $role), 403);
}
/**
* Refuse any change that would leave the installation without an
* active administrator.
*/
public function guardLastAdministrator(User $user, bool $removesAdmin): void
{
if (! $removesAdmin || ! $user->active) {
return;
}
$otherActiveAdmins = User::query()
->whereKeyNot($user->id)
->where('active', true)
->whereHas('role', fn ($query) => $query->where('is_administrator', true))
->exists();
if (! $otherActiveAdmins) {
throw ValidationException::withMessages([
'role_id' => __('This is the last active administrator account.'),
]);
}
}
public function isAdministratorRole(?int $roleId): bool
{
return $roleId !== null
&& Role::query()->whereKey($roleId)->where('is_administrator', true)->exists();
}
/**
* How many staff are active administrators right now — used to flag
* the sole one in the UI so its delete/demote/deactivate controls
* can be disabled before the server-side guard ever has to fire.
*/
public function activeAdministratorCount(): int
{
return User::query()
->where('type', UserType::Staff)
->where('active', true)
->whereHas('role', fn ($query) => $query->where('is_administrator', true))
->count();
}
/**
* @param array{name: string, email: string, role_id: int, password: string} $attributes
* @param list<int> $assignedClients
*/
public function create(array $attributes, array $assignedClients = []): User
{
$user = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => $attributes['role_id'],
'name' => $attributes['name'],
'email' => $attributes['email'],
'password' => $attributes['password'],
]);
// forceFill, not part of the create() array above: email_verified_at
// is deliberately absent from User::$fillable — it is a security
// decision, not an attribute — so mass assignment drops it silently.
// The intent is real: an account an administrator created on
// someone's behalf needs no verification step, because they vouched
// for the address by typing it, and the alternative is a new hire
// who cannot sign in. (Inert today, since MustVerifyEmail is not
// enabled on the model; several other creation paths pass the same
// key into a mass assignment and lose it the same way.)
$user->forceFill(['email_verified_at' => now()])->save();
$this->syncAssignedClients($user, $attributes['role_id'], $assignedClients);
$this->activity->log(Action::UserCreated, subject: $user);
return $user;
}
/**
* Apply a set of already-validated changes.
*
* Every key is optional, so a PATCH sending one field behaves the same
* as a form sending all of them: whatever is absent keeps its current
* value, including for the last-administrator guard, which has to
* reason about the state the account would end up in rather than the
* state the request happened to mention.
*
* @param array{name?: string, email?: string, role_id?: int, active?: bool, password?: string} $attributes
* @param list<int>|null $assignedClients null leaves the assignment alone
*/
public function update(User $user, array $attributes, ?array $assignedClients = null): User
{
$roleId = $attributes['role_id'] ?? $user->role_id;
$active = $attributes['active'] ?? $user->active;
$this->guardLastAdministrator(
$user,
removesAdmin: $this->isAdministratorRole($user->role_id)
&& (! $this->isAdministratorRole($roleId) || ! $active),
);
$wasActive = $user->active;
$oldRoleId = $user->role_id;
$oldRoleName = $user->role?->name;
$user->fill(array_intersect_key($attributes, array_flip(['name', 'email', 'role_id', 'active'])));
if (is_string($attributes['password'] ?? null) && $attributes['password'] !== '') {
$user->password = $attributes['password'];
}
$user->save();
if ($assignedClients !== null) {
$this->syncAssignedClients($user, (int) $user->role_id, $assignedClients);
} elseif ($oldRoleId !== $user->role_id) {
// The role moved but the caller said nothing about clients. A
// role that is not client-scoped must not keep a stale roster,
// so re-run the sync with what is already stored and let it
// decide.
$this->syncAssignedClients(
$user,
(int) $user->role_id,
array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all()),
);
}
$context = [];
if ($oldRoleId !== $user->role_id) {
$user->load('role');
$context['role'] = ['from' => $oldRoleName, 'to' => $user->role?->name];
}
$this->activity->log(Action::UserUpdated, subject: $user, context: $context);
if ($wasActive && ! $user->active) {
$this->activity->log(Action::UserDeactivated, subject: $user);
} elseif (! $wasActive && $user->active) {
$this->activity->log(Action::UserActivated, subject: $user);
}
return $user;
}
/**
* Every refusal that applies to deleting a staff account, in the order
* the screens ask them.
*/
public function guardDeletable(User $actor, User $target): void
{
$this->guardTarget($actor, $target);
if ($target->is($actor)) {
throw ValidationException::withMessages([
'user' => __('You cannot delete your own account.'),
]);
}
$this->guardLastAdministrator($target, removesAdmin: $this->isAdministratorRole($target->role_id));
}
/**
* Soft-delete the account and record it. Returns the name, which the
* caller needs afterwards for the content-reassignment step — by then
* the model is trashed and reading it back is needless ceremony.
*/
public function delete(User $user): string
{
$name = $user->name;
$user->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
return $name;
}
/**
* Sync a staff member's assigned clients — but only for a client-scoped
* role; any other role clears the list.
*
* @param list<int> $clientIds
*/
public function syncAssignedClients(User $user, int $roleId, array $clientIds): void
{
$scoped = Role::query()->whereKey($roleId)->where('client_scoped', true)->exists();
$user->assignedClients()->sync($scoped ? $clientIds : []);
}
}