Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addition of Audit Log API Endpoint #4987

Merged
merged 3 commits into from
May 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions app/Activity/ActivityQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ public function __construct(
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->with(['user'])
->skip($count * $page)
->take($count)
->get();

$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);

return $this->filterSimilar($activityList);
}
Expand All @@ -59,14 +59,14 @@ public function entityActivity(Entity $entity, int $count = 20, int $page = 1):
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
$innerQuery->where('loggable_type', '=', $morphClass)
->whereIn('loggable_id', $idArr);
});
}
});

$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
->with(['loggable' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
Expand All @@ -82,7 +82,7 @@ public function entityActivity(Entity $entity, int $count = 20, int $page = 1):
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
Expand Down
28 changes: 28 additions & 0 deletions app/Activity/Controllers/AuditLogApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace BookStack\Activity\Controllers;

use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;

class AuditLogApiController extends ApiController
{
/**
* Get a listing of audit log events in the system.
* The loggable relation fields currently only relates to core
* content types (page, book, bookshelf, chapter) but this may be
* used more in the future across other types.
* Requires permission to manage both users and system settings.
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');

$query = Activity::query()->with(['user']);

return $this->apiListingResponse($query, [
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
]);
}
}
2 changes: 1 addition & 1 deletion app/Activity/Controllers/AuditLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function index(Request $request)

$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'loggable' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
Expand Down
26 changes: 12 additions & 14 deletions app/Activity/Models/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,24 @@
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property Entity $loggable
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property string $loggable_type
* @property int $loggable_id
* @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Activity extends Model
{
/**
* Get the entity for this activity.
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).
* Could be used for others but will need an audit of uses where assumed
* to be entities.
*/
public function entity(): MorphTo
public function loggable(): MorphTo
{
if ($this->entity_type === '') {
$this->entity_type = null;
}

return $this->morphTo('entity');
return $this->morphTo('loggable');
}

/**
Expand All @@ -47,8 +45,8 @@ public function user(): BelongsTo

public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
}

/**
Expand All @@ -74,6 +72,6 @@ public function isForEntity(): bool
*/
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
}
}
10 changes: 5 additions & 5 deletions app/Activity/Tools/ActivityLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public function add(string $type, string|Loggable $detail = ''): void
$activity->detail = $detailToStore;

if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
$activity->loggable_id = $detail->id;
$activity->loggable_type = $detail->getMorphClass();
}

$activity->save();
Expand Down Expand Up @@ -64,9 +64,9 @@ protected function newActivityForUser(string $type): Activity
public function removeEntity(Entity $entity): void
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
'detail' => $entity->name,
'loggable_id' => null,
'loggable_type' => null,
]);
}

Expand Down
2 changes: 1 addition & 1 deletion app/Console/Commands/ClearActivityCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class ClearActivityCommand extends Command
*
* @var string
*/
protected $description = 'Clear user activity from the system';
protected $description = 'Clear user (audit-log) activity from the system';

/**
* Execute the console command.
Expand Down
2 changes: 1 addition & 1 deletion app/Entities/Models/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public function matchesOrContains(self $entity): bool
*/
public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
return $this->morphMany(Activity::class, 'loggable')
->orderBy('created_at', 'desc');
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('activities', function (Blueprint $table) {
$table->renameColumn('entity_id', 'loggable_id');
$table->renameColumn('entity_type', 'loggable_type');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('activities', function (Blueprint $table) {
$table->renameColumn('loggable_id', 'entity_id');
$table->renameColumn('loggable_type', 'entity_type');
});
}
};
80 changes: 80 additions & 0 deletions dev/api/responses/audit-log-list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"data": [
{
"id": 1,
"type": "bookshelf_create",
"detail": "",
"user_id": 1,
"loggable_id": 1,
"loggable_type": "bookshelf",
"ip": "124.4.x.x",
"created_at": "2021-09-29T12:32:02.000000Z",
"user": {
"id": 1,
"name": "Admins",
"slug": "admins"
}
},
{
"id": 2,
"type": "auth_login",
"detail": "standard; (1) Admin",
"user_id": 1,
"loggable_id": null,
"loggable_type": null,
"ip": "127.0.x.x",
"created_at": "2021-09-29T12:32:04.000000Z",
"user": {
"id": 1,
"name": "Admins",
"slug": "admins"
}
},
{
"id": 3,
"type": "bookshelf_update",
"detail": "",
"user_id": 1,
"loggable_id": 1,
"loggable_type": "bookshelf",
"ip": "127.0.x.x",
"created_at": "2021-09-29T12:32:07.000000Z",
"user": {
"id": 1,
"name": "Admins",
"slug": "admins"
}
},
{
"id": 4,
"type": "page_create",
"detail": "",
"user_id": 1,
"loggable_id": 1,
"loggable_type": "page",
"ip": "127.0.x.x",
"created_at": "2021-09-29T12:32:13.000000Z",
"user": {
"id": 1,
"name": "Admins",
"slug": "admins"
}
},
{
"id": 5,
"type": "page_update",
"detail": "",
"user_id": 1,
"loggable_id": 1,
"loggable_type": "page",
"ip": "127.0.x.x",
"created_at": "2021-09-29T12:37:27.000000Z",
"user": {
"id": 1,
"name": "Admins",
"slug": "admins"
}
}
],
"total": 6088
}
8 changes: 4 additions & 4 deletions resources/views/common/activity-item.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

{{ $activity->getText() }}

@if($activity->entity && is_null($activity->entity->deleted_at))
<a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
@if($activity->loggable && is_null($activity->loggable->deleted_at))
<a href="{{ $activity->loggable->getUrl() }}">{{ $activity->loggable->name }}</a>
@endif

@if($activity->entity && !is_null($activity->entity->deleted_at))
"{{ $activity->entity->name }}"
@if($activity->loggable && !is_null($activity->loggable->deleted_at))
"{{ $activity->loggable->name }}"
@endif

<br>
Expand Down
4 changes: 2 additions & 2 deletions resources/views/settings/audit.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ class="text-item">{{ $type }}</a></li>
class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }}
:</strong> {{ $activity->type }}</div>
<div class="flex-3 px-m py-xxs min-width-l">
@if($activity->entity)
@include('entities.icon-link', ['entity' => $activity->entity])
@if($activity->loggable instanceof \BookStack\Entities\Models\Entity)
@include('entities.icon-link', ['entity' => $activity->loggable])
@elseif($activity->detail && $activity->isForEntity())
<div>
{{ trans('settings.audit_deleted_item') }} <br>
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Controllers all end with "ApiController"
*/

use BookStack\Activity\Controllers\AuditLogApiController;
use BookStack\Api\ApiDocsController;
use BookStack\Entities\Controllers as EntityControllers;
use BookStack\Permissions\ContentPermissionApiController;
Expand Down Expand Up @@ -89,3 +90,5 @@

Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']);
Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);

Route::get('audit-log', [AuditLogApiController::class, 'list']);
60 changes: 60 additions & 0 deletions tests/Activity/AuditLogApiTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Activity;

use BookStack\Activity\ActivityType;
use BookStack\Facades\Activity;
use Tests\Api\TestsApi;
use Tests\TestCase;

class AuditLogApiTest extends TestCase
{
use TestsApi;

public function test_user_and_settings_manage_permissions_needed()
{
$editor = $this->users->editor();

$assertPermissionErrorOnCall = function () use ($editor) {
$resp = $this->actingAsForApi($editor)->getJson('/api/audit-log');
$resp->assertStatus(403);
$resp->assertJson($this->permissionErrorResponse());
};

$assertPermissionErrorOnCall();
$this->permissions->grantUserRolePermissions($editor, ['users-manage']);
$assertPermissionErrorOnCall();
$this->permissions->removeUserRolePermissions($editor, ['users-manage']);
$this->permissions->grantUserRolePermissions($editor, ['settings-manage']);
$assertPermissionErrorOnCall();

$this->permissions->grantUserRolePermissions($editor, ['settings-manage', 'users-manage']);
$resp = $this->actingAsForApi($editor)->getJson('/api/audit-log');
$resp->assertOk();
}

public function test_index_endpoint_returns_expected_data()
{
$page = $this->entities->page();
$admin = $this->users->admin();
$this->actingAsForApi($admin);
Activity::add(ActivityType::PAGE_UPDATE, $page);

$resp = $this->get("/api/audit-log?filter[loggable_id]={$page->id}");
$resp->assertJson(['data' => [
[
'type' => 'page_update',
'detail' => "({$page->id}) {$page->name}",
'user_id' => $admin->id,
'loggable_id' => $page->id,
'loggable_type' => 'page',
'ip' => '127.0.0.1',
'user' => [
'id' => $admin->id,
'name' => $admin->name,
'slug' => $admin->slug,
],
]
]]);
}
}
Loading