diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php index dae0791b1..86326fb80 100644 --- a/app/Activity/ActivityQueries.php +++ b/app/Activity/ActivityQueries.php @@ -27,14 +27,14 @@ class ActivityQueries 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); } @@ -59,14 +59,14 @@ class ActivityQueries $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)) @@ -82,7 +82,7 @@ class ActivityQueries 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) diff --git a/app/Activity/Controllers/AuditLogApiController.php b/app/Activity/Controllers/AuditLogApiController.php new file mode 100644 index 000000000..650d17446 --- /dev/null +++ b/app/Activity/Controllers/AuditLogApiController.php @@ -0,0 +1,28 @@ +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', + ]); + } +} diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index c3910a26b..641106d7f 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -32,7 +32,7 @@ class AuditLogController extends Controller $query = Activity::query() ->with([ - 'entity' => fn ($query) => $query->withTrashed(), + 'loggable' => fn ($query) => $query->withTrashed(), 'user', ]) ->orderBy($listOptions->getSort(), $listOptions->getOrder()); diff --git a/app/Activity/Models/Activity.php b/app/Activity/Models/Activity.php index 5fad9f1d3..ac9fec517 100644 --- a/app/Activity/Models/Activity.php +++ b/app/Activity/Models/Activity.php @@ -15,26 +15,24 @@ use Illuminate\Support\Str; /** * @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'); } /** @@ -47,8 +45,8 @@ class Activity extends Model 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'); } /** @@ -74,6 +72,6 @@ class Activity extends Model */ 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]; } } diff --git a/app/Activity/Tools/ActivityLogger.php b/app/Activity/Tools/ActivityLogger.php index adda36c1b..415d11084 100644 --- a/app/Activity/Tools/ActivityLogger.php +++ b/app/Activity/Tools/ActivityLogger.php @@ -32,8 +32,8 @@ class ActivityLogger $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(); @@ -64,9 +64,9 @@ class ActivityLogger 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, ]); } diff --git a/app/Console/Commands/ClearActivityCommand.php b/app/Console/Commands/ClearActivityCommand.php index 54085c12b..6ec2e1a2a 100644 --- a/app/Console/Commands/ClearActivityCommand.php +++ b/app/Console/Commands/ClearActivityCommand.php @@ -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. diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index f07d372c3..0de83c938 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -137,7 +137,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable */ public function activity(): MorphMany { - return $this->morphMany(Activity::class, 'entity') + return $this->morphMany(Activity::class, 'loggable') ->orderBy('created_at', 'desc'); } diff --git a/database/migrations/2024_05_04_154409_rename_activity_relation_columns.php b/database/migrations/2024_05_04_154409_rename_activity_relation_columns.php new file mode 100644 index 000000000..ee3358d73 --- /dev/null +++ b/database/migrations/2024_05_04_154409_rename_activity_relation_columns.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/dev/api/responses/audit-log-list.json b/dev/api/responses/audit-log-list.json new file mode 100644 index 000000000..15a25e106 --- /dev/null +++ b/dev/api/responses/audit-log-list.json @@ -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 +} \ No newline at end of file diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php index 89d44b152..1c970084f 100644 --- a/resources/views/common/activity-item.blade.php +++ b/resources/views/common/activity-item.blade.php @@ -16,12 +16,12 @@ {{ $activity->getText() }} - @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 - @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
diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 89d743fdc..28cdeb8a5 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -94,8 +94,8 @@ class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }} : {{ $activity->type }}
- @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())
{{ trans('settings.audit_deleted_item') }}
diff --git a/routes/api.php b/routes/api.php index 04c94a966..c0919d324 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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; @@ -89,3 +90,5 @@ Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiContro 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']); diff --git a/tests/Activity/AuditLogApiTest.php b/tests/Activity/AuditLogApiTest.php new file mode 100644 index 000000000..75cc364f8 --- /dev/null +++ b/tests/Activity/AuditLogApiTest.php @@ -0,0 +1,60 @@ +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, + ], + ] + ]]); + } +} diff --git a/tests/Actions/AuditLogTest.php b/tests/Activity/AuditLogTest.php similarity index 98% rename from tests/Actions/AuditLogTest.php rename to tests/Activity/AuditLogTest.php index 5e355ca09..350cd9287 100644 --- a/tests/Actions/AuditLogTest.php +++ b/tests/Activity/AuditLogTest.php @@ -1,6 +1,6 @@ ActivityType::PAGE_UPDATE, 'ip' => '192.123.45.1', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); $resp = $this->asAdmin()->get('/settings/audit'); @@ -207,7 +207,7 @@ class AuditLogTest extends TestCase 'type' => ActivityType::PAGE_UPDATE, 'ip' => '127.0.0.1', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); } @@ -229,7 +229,7 @@ class AuditLogTest extends TestCase 'type' => ActivityType::PAGE_UPDATE, 'ip' => '192.123.x.x', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); } } diff --git a/tests/Actions/WebhookCallTest.php b/tests/Activity/WebhookCallTest.php similarity index 99% rename from tests/Actions/WebhookCallTest.php rename to tests/Activity/WebhookCallTest.php index 16986ba2e..37c87267a 100644 --- a/tests/Actions/WebhookCallTest.php +++ b/tests/Activity/WebhookCallTest.php @@ -1,6 +1,6 @@ assertDatabaseHas('activities', [ 'type' => 'page_update', - 'entity_id' => $page->id, + 'loggable_id' => $page->id, 'user_id' => $this->users->editor()->id, ]); diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php index 8adc92f25..33284b4b3 100644 --- a/tests/Settings/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -153,22 +153,22 @@ class RecycleBinTest extends TestCase $this->assertDatabaseHas('activities', [ 'type' => 'page_delete', - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), + 'loggable_id' => $page->id, + 'loggable_type' => $page->getMorphClass(), ]); $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}"); $this->assertDatabaseMissing('activities', [ 'type' => 'page_delete', - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), + 'loggable_id' => $page->id, + 'loggable_type' => $page->getMorphClass(), ]); $this->assertDatabaseHas('activities', [ 'type' => 'page_delete', - 'entity_id' => null, - 'entity_type' => null, + 'loggable_id' => null, + 'loggable_type' => null, 'detail' => $page->name, ]); } diff --git a/tests/TestCase.php b/tests/TestCase.php index c59f843e9..b63de3076 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -248,8 +248,8 @@ abstract class TestCase extends BaseTestCase $detailsToCheck = ['type' => $type]; if ($entity) { - $detailsToCheck['entity_type'] = $entity->getMorphClass(); - $detailsToCheck['entity_id'] = $entity->id; + $detailsToCheck['loggable_type'] = $entity->getMorphClass(); + $detailsToCheck['loggable_id'] = $entity->id; } if ($detail) { diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index 4bfb3c878..065ae8dc8 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -2,8 +2,8 @@ namespace Tests\User; -use Activity; use BookStack\Activity\ActivityType; +use BookStack\Facades\Activity; use BookStack\Users\Models\User; use Tests\TestCase;