Files
ignacionelson 922be7226c Let a client edit and delete the files they uploaded
A client could upload a file and then never touch it again. No rename, no
description, no expiry, no categories, no delete — the portal has three
file routes and all three are GET. Meanwhile the Roles screen happily
grants the Client role edit_files, delete_files, set_file_categories,
set_file_expiration_date and upload_public, and every one of them was
inert, because the routes that honour them are `staff`-gated rather than
permission-gated. That is what #1771 hit: a permission granted, saved, and
silently doing nothing.

A client owns what they uploaded. Ownership is now what lets them edit and
delete it, subject to the same per-field keys staff are subject to.

The obvious implementation is a trap, and it is worth writing down. Both
policy methods began `if (! $user->isStaff()) return false;` and both end
in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped())
return true` — and isClientScoped() is `isStaff() && role->client_scoped`,
so it is false for every client. Delete the early return and a client
falls into the branch meaning "this staff member is unrestricted" and is
handed the whole library. Same for folders(), which returns an unfiltered
query: a client could move their file into any folder on the installation.
So clients get their own branch, reaching neither. The portal asks
Folder::uploadableBy() instead — a file cannot be moved somewhere it could
not have been uploaded.

edit_others_files and delete_others_files stay inert for clients by
construction. A client has no others' files, only files somebody showed
them, and being shown a file is not being given it.

Which fields an editor may write moved into ApplyFileEdits, shared by the
staff editor, /api/v1 and the portal. There were two copies of the same
eight permission checks and this would have been the third; the checks are
easy, which is exactly why the drift would have been invisible. Callers
normalise their own request shape, this gates and writes and logs. Expiry
reading and writing came along too, as FileExpiry — three copies, of which
only the API's could read a timestamp.

Clients do not choose the public slug. It is derived from the name they
already picked, because an installation-wide unique slug a client sets is
a name to squat and an existence oracle to probe with.

One consequence for later, written up in docs/api-todo.md: the policy now
says yes to a client for file writes, so `staff-token` is the only thing
holding the API boundary where there used to be two independent refusals.
ActorBoundaryTest pins it, and asserts the policy passes first so the test
cannot quietly stop testing the middleware.

Also corrects a stale comment that claimed a deleted file's bytes stay on
disk. They have not since File::booted() grew a `deleted` hook; nothing
ever forceDelete()s a File row, so "until a purge lands" would have meant
never — which is why a client's delete frees their quota by exactly what
it frees on disk.

The UI comes next; this is the authorization, the routes and the tests.

Fixes #1771
2026-09-07 02:37:26 -03:00

131 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission;
/*
|--------------------------------------------------------------------------
| Who may hold a working token at all
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->staff = User::factory()->create();
});
test('a staff token reaches the API', function () {
$token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken;
$this->withToken($token)->getJson('/api/v1/me')
->assertOk()
->assertJsonPath('data.type', 'staff');
});
/*
* Clients have no route to a token today — the settings page that issues
* them is behind `staff`. This covers the case where one exists anyway: a
* seeded fixture, a support script, or an account whose type changed after
* issuance. A client token acting on the API is the single largest privacy
* surface this design could have, so it fails closed rather than relying
* on the issuing page being the only door.
*/
test('a client token is refused everywhere', function () {
$client = User::factory()->client()->create();
$token = $client->createToken('t', [Permission::Upload->value])->plainTextToken;
$this->withToken($token)->getJson('/api/v1/me')->assertForbidden();
});
/*
* The write half of the same boundary, and it needs its own test now that
* FilePolicy has a client branch.
*
* Since clients may edit and delete their own uploads in the portal,
* `Gate::authorize('update', $file)` inside Api\FilesController *passes*
* for a client holding the key on a file they uploaded. The only thing
* standing between a client token and the API's write endpoints is the
* `staff-token` middleware. That was always true, but until the portal
* work it was belt-and-braces: the policy refused as well. It no longer
* does, so this pins the one remaining door rather than leaving the whole
* boundary resting on a middleware nothing tests against a *passing*
* policy.
*
* If client tokens are ever issued (see docs/api-todo.md), this test is
* where that decision has to be made deliberately.
*/
test('a client token cannot write through the API even to its own file', function () {
$client = User::factory()->client()->create();
foreach (['edit_files', 'delete_files'] as $permission) {
$client->role->permissions()->create(['permission' => $permission]);
}
$file = File::factory()->create(['uploaded_by' => $client->id]);
// The policy itself now says yes — this is the premise, not an aside.
expect(Gate::forUser($client)->allows('update', $file))->toBeTrue()
->and(Gate::forUser($client)->allows('delete', $file))->toBeTrue();
$token = $client->createToken('t', ['edit_files', 'delete_files'])->plainTextToken;
$this->withToken($token)->patchJson("/api/v1/files/{$file->id}", ['name' => 'taken'])->assertForbidden();
$this->withToken($token)->deleteJson("/api/v1/files/{$file->id}")->assertForbidden();
expect($file->refresh()->name)->not->toBe('taken')
->and($file->trashed())->toBeFalse();
});
test('a deactivated account loses API access on the next request', function () {
$token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken;
$this->withToken($token)->getJson('/api/v1/me')->assertOk();
$this->staff->forceFill(['active' => false])->save();
forgetRequestState();
$this->withToken($token)->getJson('/api/v1/me')->assertUnauthorized();
});
test('a soft-deleted account loses API access', function () {
$token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken;
$this->staff->delete();
$this->withToken($token)->getJson('/api/v1/me')->assertUnauthorized();
});
test('a session cookie cannot authenticate the API', function () {
// config/sanctum.php lists no stateful domains and no guards, so being
// logged into the web UI grants nothing here. If this ever starts
// passing, cookie auth has been reintroduced and with it CSRF exposure
// and XSS reach into the whole API.
$this->actingAs($this->staff)->getJson('/api/v1/me')->assertUnauthorized();
});
test('me reports the effective abilities, not the token is stored list', function () {
$limited = staffWithPermissions([Permission::Upload->value, Permission::EditFiles->value]);
// Granted both, then demoted to one. The stored list still says two.
$token = $limited->createToken('t', [Permission::Upload->value, Permission::EditFiles->value])->plainTextToken;
$limited->role->permissions()->where('permission', Permission::EditFiles->value)->delete();
$this->withToken($token)->getJson('/api/v1/me')
->assertOk()
->assertJsonPath('data.abilities', [Permission::Upload->value]);
});
test('a token can revoke itself but only itself', function () {
$other = $this->staff->createToken('other', [Permission::Upload->value]);
$current = $this->staff->createToken('current', [Permission::Upload->value]);
$this->withToken($current->plainTextToken)->deleteJson('/api/v1/tokens/current')->assertNoContent();
expect($this->staff->tokens()->pluck('name')->all())->toBe(['other'])
->and($other->accessToken->fresh())->not->toBeNull();
});