mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-05 21:01:33 +00:00
db65731c3a
Your own email address, password and second factor are changed from your profile, which asks for your current password. The staff screen and the API changed the first two with no password at all, and the API removed the third on your own account without the confirmation the web asks for. StaffAccounts::ownCredentialChanges is the one rule both now ask: the staff screen refuses your own email or password with a validation error and points to the profile, and the API answers 403, as it does for removing your own second factor. Changing somebody else's password is unchanged: that is what edit_users and edit_clients mean, on the screen and over the API. It now also revokes that account's API tokens. Browser sessions already ended with the password hash; tokens did not. GHSA-j5cp-r8pr-m5cr
446 lines
17 KiB
PHP
446 lines
17 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\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Identity\Erasure\ErasureSchedule;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Platform\Seats\SeatAllowance;
|
|
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,
|
|
private readonly ErasureSchedule $erasure,
|
|
private readonly SeatAllowance $seats,
|
|
private readonly StaffLibraryScope $library,
|
|
) {}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* A role's `client_scoped` flag is part of that authority, and the
|
|
* larger part: a role without it reaches the whole library, while a
|
|
* client-scoped actor reaches only the clients assigned to them. So
|
|
* one may not hand out a role that is not client-scoped -- to a
|
|
* colleague, to a new account, or to themselves, which is the case
|
|
* that matters, since the role picker is how an account changes role
|
|
* and an account may edit its own.
|
|
*
|
|
* An administrator holds every permission by construction and is
|
|
* never client-scoped, 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;
|
|
}
|
|
|
|
if ($actor->isClientScoped() && ! $role->client_scoped) {
|
|
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());
|
|
}
|
|
|
|
/**
|
|
* Client ids this actor may put on a staff account's roster — the same
|
|
* rule as mayGrant(), applied to reach instead of to authority.
|
|
*
|
|
* An assigned client is not a label: it is everything that client can
|
|
* see, handed to whoever holds it. So a client-scoped actor may hand
|
|
* out the clients they hold and no others — including to themselves,
|
|
* which is the case that matters, since guardTarget() lets anybody
|
|
* edit their own account and `assigned_clients` was never checked
|
|
* against the actor at all. Without this a scoped staff member with
|
|
* `edit_users` could PATCH their own id with every client id on the
|
|
* installation and read the whole library from then on.
|
|
*
|
|
* An unrestricted actor gets the full roster back rather than null, so
|
|
* every caller can validate against one list instead of composing a
|
|
* conditional rule. That list is already client-typed, which is why it
|
|
* replaces the `exists:users,id where type = client` rule rather than
|
|
* joining it.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function assignableClientIds(User $actor): array
|
|
{
|
|
$ids = $this->library->assignableClientIds($actor);
|
|
|
|
if ($ids !== null) {
|
|
return $ids;
|
|
}
|
|
|
|
return array_values(User::query()->where('type', UserType::Client)
|
|
->pluck('id')->map(fn ($id): int => (int) $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);
|
|
}
|
|
|
|
/**
|
|
* Which of your own credentials this change would replace: a different
|
|
* email address, or a new password. Empty when the target is somebody
|
|
* else, or when nothing that signs the account in is changing.
|
|
*
|
|
* Your own are changed from your profile, which asks for your current
|
|
* password first (GHSA-f32x-fgmp-q353). The staff screen and the API
|
|
* must not be a second door to them. Over the API that door was wider
|
|
* still: a token limited to manage_users and edit_users could give its
|
|
* own owner a password it chose, and then sign in as the owner with
|
|
* every ability the token had been denied.
|
|
*
|
|
* The email address counts because it is how a password is recovered:
|
|
* an address you control is a password you can set.
|
|
*
|
|
* @return list<'email'|'password'>
|
|
*/
|
|
public function ownCredentialChanges(User $actor, User $target, ?string $email, ?string $password): array
|
|
{
|
|
if (! $target->is($actor)) {
|
|
return [];
|
|
}
|
|
|
|
$fields = [];
|
|
|
|
if ($email !== null && mb_strtolower($email) !== mb_strtolower($target->email)) {
|
|
$fields[] = 'email';
|
|
}
|
|
|
|
if ($password !== null && $password !== '') {
|
|
$fields[] = 'password';
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* The installation's principal administrator: the oldest active one.
|
|
*
|
|
* "Oldest" is the account created first, which on any installation
|
|
* that went through setup is the person who set it up — and on one
|
|
* imported from v1, the first administrator that import created.
|
|
* There is no *flag* for this because the application deliberately has
|
|
* no owner concept: administrators are equal in authority, and
|
|
* inventing a superior one to hold this would be a real change to the
|
|
* permission model in exchange for a greeting.
|
|
*
|
|
* Active, so that a founder who has since left the company does not
|
|
* silently swallow anything addressed here; it moves to the next
|
|
* administrator instead.
|
|
*/
|
|
public function mainAdministrator(): ?User
|
|
{
|
|
return User::query()
|
|
->where('type', UserType::Staff)
|
|
->where('active', true)
|
|
->whereHas('role', fn ($query) => $query->where('is_administrator', true))
|
|
->orderBy('id')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// Before the write, so a refusal creates nothing. Both staff
|
|
// controllers reach this, web and API; the other two doors into a
|
|
// staff seat are AccountConversion::toStaff() and the console
|
|
// command, which asks deliberately not to — see SeatAllowance.
|
|
$this->seats->guardStaff();
|
|
|
|
$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'])));
|
|
|
|
$passwordReplaced = is_string($attributes['password'] ?? null) && $attributes['password'] !== '';
|
|
|
|
if ($passwordReplaced) {
|
|
$user->password = $attributes['password'];
|
|
}
|
|
|
|
$user->save();
|
|
|
|
// Somebody else gave this account a new password: whatever had been
|
|
// holding it, a person or a stolen credential, is ended with it.
|
|
// Browser sessions end on their own (AuthenticateSession reads the
|
|
// password hash), but API tokens do not, and a reset that left the
|
|
// previous holder's token working would not be a reset. Never your
|
|
// own password: both callers refuse that (ownCredentialChanges).
|
|
if ($passwordReplaced) {
|
|
$user->tokens()->delete();
|
|
}
|
|
|
|
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, schedule its permanent erasure 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.
|
|
*
|
|
* **This is only half of deleting somebody.** What happens to the
|
|
* files and folders they own is the other half, and it lives in
|
|
* AccountContentDeletion: validate() to make the caller choose
|
|
* between cascading and reassigning, apply() to carry it out. A
|
|
* caller that stops here leaves their content pointing at an account
|
|
* that no longer exists.
|
|
*
|
|
* The trap is that it looks like it works. validate() returns an
|
|
* empty array when the account owns nothing, so an account with no
|
|
* files deletes perfectly through this method alone — and keeps
|
|
* doing so until somebody deletes a colleague who had actually done
|
|
* some work. Both existing callers pair the two; a new one must too.
|
|
*/
|
|
public function delete(User $user): string
|
|
{
|
|
$name = $user->name;
|
|
|
|
$this->erasure->apply($user);
|
|
$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 : []);
|
|
}
|
|
}
|