mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
84e9f6e2fe
ApiUsage::recentActions() is the only ActivityLog query outside ActivityLogger and AccountEraser that does not run through ActivityLogScope::apply(). Its whole boundary is view_actions_log -- the permission whose own scope class says, in as many words, that it "is not the whole answer for a client-scoped staff member". The Client Manager system role is client_scoped and ships with that permission, so this is the default configuration. Such a viewer opening /api?all=1 reads the fifteen most recent API log rows for the entire installation, each with its subject_name: the names of files and clients they get a 403 on. /activity, the download history and the dashboard's recent-activity widget all narrow the same rows; the API dashboard was missed. The scope is applied on both sides of the install-wide branch. The own-actor filter for the narrow view already stays inside what the scope allows, and a boundary that exists in only one arm of an `if` is one refactor away from not existing. Three tests: the scoped viewer sees only the entry about a file in their library, their own actions stay whole even when the subject is outside it, and an unscoped viewer's feed is unchanged. Without the fix the first goes red; the other two are green either way and guard against narrowing too far.
320 lines
12 KiB
PHP
320 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Api\Models\ApiRequestLog;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Audit\ActivityOrigin;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->staff = User::factory()->create();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| What a request log row contains
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Two privacy decisions are baked into the schema and have to stay that
|
|
| way: the route *pattern* rather than the resolved URI, and no IP.
|
|
|
|
|
*/
|
|
|
|
test('a request is recorded with its route pattern, not the resolved URI', function () {
|
|
// A deliberately distinctive id: the natural one is 1, which also
|
|
// appears in the "v1" of the path, so the absence assertion below
|
|
// would pass for the wrong reason.
|
|
$file = File::factory()->create(['id' => 987654, 'uploaded_by' => $this->staff->id]);
|
|
$token = $this->staff->createToken('Zapier', [Permission::Upload->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->getJson("/api/v1/files/{$file->id}")->assertOk();
|
|
|
|
$log = ApiRequestLog::query()->latest('id')->firstOrFail();
|
|
|
|
// The id would turn every row into a record of which file was touched,
|
|
// which is the audit log's job and already covered there.
|
|
expect($log->route)->toBe('api/v1/files/{file}')
|
|
->and($log->route)->not->toContain('987654')
|
|
->and($log->method)->toBe('GET')
|
|
->and($log->status)->toBe(200);
|
|
});
|
|
|
|
test('no IP address is stored', function () {
|
|
$token = $this->staff->createToken('Zapier', [Permission::Upload->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->getJson('/api/v1/me')->assertOk();
|
|
|
|
// The token identifies the caller, which is the question this table
|
|
// exists to answer; an IP would be a new personal-data surface.
|
|
expect(array_keys(ApiRequestLog::query()->latest('id')->firstOrFail()->getAttributes()))
|
|
->not->toContain('ip_address');
|
|
});
|
|
|
|
test('the calling token is recorded by id and by name', function () {
|
|
$created = $this->staff->createToken('Zapier', [Permission::Upload->value]);
|
|
|
|
$this->withToken($created->plainTextToken)->getJson('/api/v1/me')->assertOk();
|
|
|
|
$log = ApiRequestLog::query()->latest('id')->firstOrFail();
|
|
|
|
expect($log->api_token_id)->toBe($created->accessToken->getKey())
|
|
->and($log->api_token_name)->toBe('Zapier')
|
|
->and($log->user_id)->toBe($this->staff->id);
|
|
});
|
|
|
|
test('failed and unauthenticated requests are recorded too', function () {
|
|
// Error rates are half the point; only logging successes would make
|
|
// the dashboard flatter to the eye and useless in an incident.
|
|
$this->getJson('/api/v1/me')->assertUnauthorized();
|
|
|
|
$log = ApiRequestLog::query()->latest('id')->firstOrFail();
|
|
|
|
expect($log->status)->toBe(401)
|
|
->and($log->api_token_id)->toBeNull();
|
|
});
|
|
|
|
test('web requests are not recorded', function () {
|
|
$this->actingAs($this->staff)->get('/dashboard');
|
|
|
|
expect(ApiRequestLog::query()->count())->toBe(0);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Retention
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('the prune command respects the configured window', function () {
|
|
$settings = app(Settings::class);
|
|
$original = $settings->get(Setting::ApiRequestLogRetentionDays);
|
|
|
|
try {
|
|
$settings->set(Setting::ApiRequestLogRetentionDays, 7);
|
|
|
|
ApiRequestLog::query()->create([
|
|
'method' => 'GET', 'route' => 'api/v1/me', 'status' => 200,
|
|
'duration_ms' => 5, 'created_at' => now()->subDays(30),
|
|
]);
|
|
ApiRequestLog::query()->create([
|
|
'method' => 'GET', 'route' => 'api/v1/me', 'status' => 200,
|
|
'duration_ms' => 5, 'created_at' => now()->subDay(),
|
|
]);
|
|
|
|
$this->artisan('projectsend:purge-api-request-logs')->assertSuccessful();
|
|
|
|
expect(ApiRequestLog::query()->count())->toBe(1);
|
|
} finally {
|
|
$settings->set(Setting::ApiRequestLogRetentionDays, $original);
|
|
}
|
|
});
|
|
|
|
test('a retention of zero keeps everything', function () {
|
|
$settings = app(Settings::class);
|
|
$original = $settings->get(Setting::ApiRequestLogRetentionDays);
|
|
|
|
try {
|
|
$settings->set(Setting::ApiRequestLogRetentionDays, 0);
|
|
|
|
ApiRequestLog::query()->create([
|
|
'method' => 'GET', 'route' => 'api/v1/me', 'status' => 200,
|
|
'duration_ms' => 5, 'created_at' => now()->subYears(2),
|
|
]);
|
|
|
|
$this->artisan('projectsend:purge-api-request-logs')->assertSuccessful();
|
|
|
|
expect(ApiRequestLog::query()->count())->toBe(1);
|
|
} finally {
|
|
$settings->set(Setting::ApiRequestLogRetentionDays, $original);
|
|
}
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Who sees whose usage
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| A token is a personal credential. Without a boundary the dashboard would
|
|
| show every staff member which integrations their colleagues run.
|
|
|
|
|
*/
|
|
|
|
test('the dashboard shows only the viewer own tokens by default', function () {
|
|
$colleague = User::factory()->create();
|
|
$colleague->createToken('Theirs', [Permission::Upload->value]);
|
|
$this->staff->createToken('Mine', [Permission::Upload->value]);
|
|
|
|
$props = $this->actingAs($this->staff)->get('/api')->assertOk()->viewData('page')['props'];
|
|
|
|
expect(collect($props['tokens'])->pluck('name')->all())->toBe(['Mine']);
|
|
});
|
|
|
|
test('a viewer without view_actions_log cannot widen the scope', function () {
|
|
$limited = staffWithPermissions([Permission::Upload->value]);
|
|
$colleague = User::factory()->create();
|
|
$colleague->createToken('Theirs', [Permission::Upload->value]);
|
|
|
|
// Asking for everything is not a way to get everything.
|
|
$props = $this->actingAs($limited)->get('/api?all=1')->assertOk()->viewData('page')['props'];
|
|
|
|
expect($props['scope']['install_wide'])->toBeFalse()
|
|
->and($props['scope']['can_view_install_wide'])->toBeFalse()
|
|
->and(collect($props['tokens'])->pluck('name')->all())->not->toContain('Theirs');
|
|
});
|
|
|
|
test('a viewer with view_actions_log may see every token', function () {
|
|
$auditor = staffWithPermissions([Permission::ViewActionsLog->value]);
|
|
$colleague = User::factory()->create();
|
|
$colleague->createToken('Theirs', [Permission::Upload->value]);
|
|
|
|
$props = $this->actingAs($auditor)->get('/api?all=1')->assertOk()->viewData('page')['props'];
|
|
|
|
expect($props['scope']['install_wide'])->toBeTrue()
|
|
->and(collect($props['tokens'])->pluck('name')->all())->toContain('Theirs');
|
|
});
|
|
|
|
test('request counts follow the same scope', function () {
|
|
$colleague = User::factory()->create();
|
|
|
|
ApiRequestLog::query()->create([
|
|
'user_id' => $colleague->id, 'method' => 'GET', 'route' => 'api/v1/me',
|
|
'status' => 200, 'duration_ms' => 5, 'created_at' => now(),
|
|
]);
|
|
ApiRequestLog::query()->create([
|
|
'user_id' => $this->staff->id, 'method' => 'GET', 'route' => 'api/v1/me',
|
|
'status' => 200, 'duration_ms' => 5, 'created_at' => now(),
|
|
]);
|
|
|
|
$props = $this->actingAs($this->staff)->get('/api')->assertOk()->viewData('page')['props'];
|
|
|
|
expect($props['summary']['requests_7d'])->toBe(1);
|
|
});
|
|
|
|
/** An API-origin log entry about $file, shaped as ActivityLogger writes it. */
|
|
function apiActionOn(User $actor, File $file): void
|
|
{
|
|
ActivityLog::query()->create([
|
|
'actor_id' => $actor->id,
|
|
'actor_name' => $actor->name,
|
|
'actor_type' => $actor->type->value,
|
|
'origin' => ActivityOrigin::Api,
|
|
'api_token_name' => 'Zapier',
|
|
'action' => Action::FileUploaded,
|
|
'subject_type' => $file->getMorphClass(),
|
|
'subject_id' => $file->id,
|
|
'subject_name' => $file->name,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/** A client-scoped viewer holding view_actions_log, as Client Manager ships. */
|
|
function scopedApiLogReader(): array
|
|
{
|
|
$role = Role::query()->create(['name' => 'Scoped log reader', 'client_scoped' => true]);
|
|
RolePermission::query()->insert([
|
|
['role_id' => $role->id, 'permission' => Permission::ViewActionsLog->value],
|
|
['role_id' => $role->id, 'permission' => Permission::Upload->value],
|
|
]);
|
|
|
|
$viewer = User::factory()->create(['role_id' => $role->id]);
|
|
$client = User::factory()->client()->create();
|
|
$viewer->assignedClients()->attach($client->id);
|
|
|
|
return [$viewer, $client];
|
|
}
|
|
|
|
test('the recent actions feed narrows to what the viewer may read', function () {
|
|
// Tokens are scoped by view_actions_log alone; the activity log is not.
|
|
// A row carries the subject's name, so an unscoped feed reads out the
|
|
// name of every file in the installation to a Client Manager who gets a
|
|
// 403 on the files themselves — the same reasoning /activity, the
|
|
// download history and the dashboard widget already act on.
|
|
[$scoped, $client] = scopedApiLogReader();
|
|
|
|
$theirs = File::factory()->create(['uploaded_by' => $this->staff->id, 'name' => 'Q3 delinquent accounts']);
|
|
|
|
$mine = File::factory()->create(['uploaded_by' => $this->staff->id, 'name' => 'Statement']);
|
|
shareFileWith($mine, $client);
|
|
|
|
apiActionOn($this->staff, $theirs);
|
|
apiActionOn($this->staff, $mine);
|
|
|
|
$props = $this->actingAs($scoped)->get('/api?all=1')->assertOk()->viewData('page')['props'];
|
|
|
|
expect($props['scope']['install_wide'])->toBeTrue()
|
|
->and(collect($props['recent_actions'])->pluck('replacements.subject')->all())->toBe(['Statement']);
|
|
});
|
|
|
|
test('a scoped viewer keeps their own actions, even about a file they cannot open', function () {
|
|
// Guards against narrowing further than /activity does: their own audit
|
|
// trail stays whole, which is the rule ActivityLogScope states.
|
|
[$scoped] = scopedApiLogReader();
|
|
|
|
$stranger = File::factory()->create(['uploaded_by' => $this->staff->id, 'name' => 'Not in my library']);
|
|
apiActionOn($scoped, $stranger);
|
|
|
|
$props = $this->actingAs($scoped)->get('/api?all=1')->assertOk()->viewData('page')['props'];
|
|
|
|
expect(collect($props['recent_actions'])->pluck('replacements.subject')->all())
|
|
->toBe(['Not in my library']);
|
|
});
|
|
|
|
test('an unscoped viewer still sees every API action in the installation', function () {
|
|
$auditor = staffWithPermissions([Permission::ViewActionsLog->value]);
|
|
$file = File::factory()->create(['uploaded_by' => $this->staff->id, 'name' => 'Anything']);
|
|
|
|
apiActionOn($this->staff, $file);
|
|
|
|
$props = $this->actingAs($auditor)->get('/api?all=1')->assertOk()->viewData('page')['props'];
|
|
|
|
expect(collect($props['recent_actions'])->pluck('replacements.subject')->all())->toBe(['Anything']);
|
|
});
|
|
|
|
test('clients cannot reach the dashboard', function () {
|
|
$client = User::factory()->client()->create();
|
|
|
|
$this->actingAs($client)->get('/api')->assertRedirect();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The widget
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('the dashboard widget reports the viewer own usage', function () {
|
|
$this->staff->createToken('Mine', [Permission::Upload->value]);
|
|
|
|
ApiRequestLog::query()->create([
|
|
'user_id' => $this->staff->id, 'method' => 'GET', 'route' => 'api/v1/me',
|
|
'status' => 200, 'duration_ms' => 5, 'created_at' => now(),
|
|
]);
|
|
|
|
$props = $this->actingAs($this->staff)->get('/dashboard')->assertOk()->viewData('page')['props'];
|
|
|
|
expect($props['api']['tokens'])->toBe(1)
|
|
->and($props['api']['requests_7d'])->toBe(1);
|
|
});
|
|
|
|
test('the widget key is accepted by the layout endpoint', function () {
|
|
// The Widgets dialog round-trips the full layout, so a key missing
|
|
// from the controller's allowlist 422s the whole save — which is
|
|
// exactly how the expired_files widget silently broke once.
|
|
$this->actingAs($this->staff)->put('/dashboard/widgets', [
|
|
'columns' => 2,
|
|
'widgets' => [
|
|
['widget_key' => 'api', 'enabled' => true, 'column_index' => 1, 'position' => 4],
|
|
],
|
|
])->assertRedirect()->assertSessionHasNoErrors();
|
|
});
|