]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4987 from BookStackApp/audit_api
authorDan Brown <redacted>
Sun, 5 May 2024 15:14:09 +0000 (16:14 +0100)
committerGitHub <redacted>
Sun, 5 May 2024 15:14:09 +0000 (16:14 +0100)
Addition of Audit Log API Endpoint

21 files changed:
app/Activity/ActivityQueries.php
app/Activity/Controllers/AuditLogApiController.php [new file with mode: 0644]
app/Activity/Controllers/AuditLogController.php
app/Activity/Models/Activity.php
app/Activity/Tools/ActivityLogger.php
app/Console/Commands/ClearActivityCommand.php
app/Entities/Models/Entity.php
database/migrations/2024_05_04_154409_rename_activity_relation_columns.php [new file with mode: 0644]
dev/api/responses/audit-log-list.json [new file with mode: 0644]
resources/views/common/activity-item.blade.php
resources/views/settings/audit.blade.php
routes/api.php
tests/Activity/AuditLogApiTest.php [new file with mode: 0644]
tests/Activity/AuditLogTest.php [moved from tests/Actions/AuditLogTest.php with 98% similarity]
tests/Activity/WebhookCallTest.php [moved from tests/Actions/WebhookCallTest.php with 99% similarity]
tests/Activity/WebhookFormatTesting.php [moved from tests/Actions/WebhookFormatTesting.php with 98% similarity]
tests/Activity/WebhookManagementTest.php [moved from tests/Actions/WebhookManagementTest.php with 99% similarity]
tests/Commands/ClearActivityCommandTest.php
tests/Settings/RecycleBinTest.php
tests/TestCase.php
tests/User/UserProfileTest.php

index dae0791b1cd85d7ababf89c63ad1070bd7741131..86326fb80af5c93575d71e54dda143d95e7d8de0 100644 (file)
@@ -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 (file)
index 0000000..650d174
--- /dev/null
@@ -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',
+        ]);
+    }
+}
index c3910a26b4c1eb38bdd48371ffb1055f6613d8eb..641106d7f450697b1c189b128264feecc46c9b06 100644 (file)
@@ -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());
index 5fad9f1d3a288630e790b96503508da3d19e7a95..ac9fec5178b6836d8a373763f2f65616b70d554f 100644 (file)
@@ -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];
     }
 }
index adda36c1b813a3f2728ae4c3eec64416fd1e8b64..415d1108494822522963286a9ed8598c29f89053 100644 (file)
@@ -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,
         ]);
     }
 
index 54085c12be3c762c06a5e189996872bece641220..6ec2e1a2aaa63db2e8589e7ae26e4eedd9c6d5b3 100644 (file)
@@ -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.
index f07d372c3e9cdca77bc1571530f13e357b5bf266..0de83c93869332906842a6ad31fa28ad35ce27a2 100644 (file)
@@ -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 (file)
index 0000000..ee3358d
--- /dev/null
@@ -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');
+        });
+    }
+};
diff --git a/dev/api/responses/audit-log-list.json b/dev/api/responses/audit-log-list.json
new file mode 100644 (file)
index 0000000..15a25e1
--- /dev/null
@@ -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
index 89d44b15231a37037a4fe6475f6e4b8e3672cf15..1c970084f6a98785b54d9f13b2eebff2b5012248 100644 (file)
 
     {{ $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>
index 89d743fdc7ca2afa4679e640697069f46dbebe82..28cdeb8a5a7e5e8a5892b6fffc96bf9b4bb0888a 100644 (file)
@@ -94,8 +94,8 @@
                                     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>
index 04c94a9664d9b4697aff052ee013384b3aaad1af..c0919d3247ba12eae80dddc7199f9a3ca020e261 100644 (file)
@@ -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 (file)
index 0000000..75cc364
--- /dev/null
@@ -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,
+                ],
+            ]
+        ]]);
+    }
+}
similarity index 98%
rename from tests/Actions/AuditLogTest.php
rename to tests/Activity/AuditLogTest.php
index 5e355ca096b81914de60a0b2f3aff7859d195b90..350cd92871b05d69d5877e422f6967ff5edb6ddb 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests\Actions;
+namespace Activity;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Activity;
@@ -156,7 +156,7 @@ class AuditLogTest extends TestCase
             'type'      => 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,
         ]);
     }
 }
similarity index 99%
rename from tests/Actions/WebhookCallTest.php
rename to tests/Activity/WebhookCallTest.php
index 16986ba2e97ac11610ce4b8266767fe2d85b8533..37c87267a8ad96114ad4f5f0e98f814181249ca3 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests\Actions;
+namespace Activity;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Activity\DispatchWebhookJob;
similarity index 98%
rename from tests/Actions/WebhookFormatTesting.php
rename to tests/Activity/WebhookFormatTesting.php
index b4fa49532c44f1c2f592588532b4fd645ce3cfb7..edd16ebbad7b46e52291baf104375e771d56838d 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests\Actions;
+namespace Activity;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Webhook;
similarity index 99%
rename from tests/Actions/WebhookManagementTest.php
rename to tests/Activity/WebhookManagementTest.php
index 05475b6997c01bf371082eb9769a2cad72445401..eb7bb4c15a470a8e7356ea1c08d7f6604596ef3c 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests\Actions;
+namespace Activity;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Webhook;
index 410a39aa870e60209c0712e4826122dd9c42890d..a3d7456b68ebf427b6597d5cfbfacde0de06fc0e 100644 (file)
@@ -18,7 +18,7 @@ class ClearActivityCommandTest extends TestCase
 
         $this->assertDatabaseHas('activities', [
             'type'      => 'page_update',
-            'entity_id' => $page->id,
+            'loggable_id' => $page->id,
             'user_id'   => $this->users->editor()->id,
         ]);
 
index 8adc92f253853e75805dc6e4c52100cb9343bb04..33284b4b3ff85f2297c024873e5851c2e8544c7f 100644 (file)
@@ -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,
         ]);
     }
index c59f843e94bdd877c4e8601c6d214c1977405e40..b63de307624201899ee8734b0ceabf4b770f9799 100644 (file)
@@ -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) {
index 4bfb3c87822a5085fbb0f8c7a862c6177b5821c6..065ae8dc896c3c10a5af47fbb005f843425a0dcd 100644 (file)
@@ -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;