Skip to content

Commit

Permalink
Merge pull request #4987 from BookStackApp/audit_api
Browse files Browse the repository at this point in the history
Addition of Audit Log API Endpoint
  • Loading branch information
ssddanbrown committed May 5, 2024
2 parents dd251d9 + d54c7b4 commit baad7fa
Show file tree
Hide file tree
Showing 21 changed files with 250 additions and 51 deletions.
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

0 comments on commit baad7fa

Please sign in to comment.