From 0d2759b0d4c308630bfe37f08d352b37dd2a65fd Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 28 Jan 2026 21:02:25 +0100 Subject: [PATCH] Fix support API audit logging --- .../Api/Support/SupportResourceController.php | 31 +- app/Services/Audit/SuperAdminAuditLogger.php | 6 +- app/Support/SupportApiAuthorizer.php | 35 ++ app/Support/SupportApiRegistry.php | 17 + docs/openapi/support-api.yaml | 340 +++++++++++------- tests/Feature/Support/SupportApiTest.php | 22 ++ 6 files changed, 308 insertions(+), 143 deletions(-) diff --git a/app/Http/Controllers/Api/Support/SupportResourceController.php b/app/Http/Controllers/Api/Support/SupportResourceController.php index 16d478e..b1e3305 100644 --- a/app/Http/Controllers/Api/Support/SupportResourceController.php +++ b/app/Http/Controllers/Api/Support/SupportResourceController.php @@ -8,6 +8,7 @@ use App\Http\Requests\Support\Resources\SupportResourceFormRequest; use App\Http\Requests\Support\SupportResourceRequest; use App\Jobs\GenerateDataExport; use App\Models\DataExport; +use App\Services\Audit\SuperAdminAuditLogger; use App\Support\ApiError; use App\Support\SupportApiAuthorizer; use App\Support\SupportApiRegistry; @@ -77,7 +78,7 @@ class SupportResourceController extends Controller public function store(SupportResourceRequest $request, string $resource): JsonResponse { - if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) { + if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) { return $response; } @@ -110,6 +111,14 @@ class SupportResourceController extends Controller $record = $modelClass::query()->create($payload); + app(SuperAdminAuditLogger::class)->record( + SupportApiRegistry::auditAction($resource, 'created'), + $record, + SuperAdminAuditLogger::fieldsMetadata($payload), + actor: $request->user(), + source: static::class + ); + if ($resource === 'data-exports') { GenerateDataExport::dispatch($record->id); } @@ -121,7 +130,7 @@ class SupportResourceController extends Controller public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse { - if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) { + if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) { return $response; } @@ -148,6 +157,14 @@ class SupportResourceController extends Controller $model->fill($payload); $model->save(); + app(SuperAdminAuditLogger::class)->record( + SupportApiRegistry::auditAction($resource, 'updated'), + $model, + SuperAdminAuditLogger::fieldsMetadata($payload), + actor: $request->user(), + source: static::class + ); + return response()->json([ 'data' => $model->refresh(), ]); @@ -155,7 +172,7 @@ class SupportResourceController extends Controller public function destroy(Request $request, string $resource, string $record): JsonResponse { - if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) { + if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) { return $response; } @@ -171,6 +188,14 @@ class SupportResourceController extends Controller $model->delete(); + app(SuperAdminAuditLogger::class)->record( + SupportApiRegistry::auditAction($resource, 'deleted'), + $model, + SuperAdminAuditLogger::fieldsMetadata([]), + actor: $request->user(), + source: static::class + ); + return response()->json(['ok' => true]); } diff --git a/app/Services/Audit/SuperAdminAuditLogger.php b/app/Services/Audit/SuperAdminAuditLogger.php index 4ebd942..fd84495 100644 --- a/app/Services/Audit/SuperAdminAuditLogger.php +++ b/app/Services/Audit/SuperAdminAuditLogger.php @@ -93,11 +93,15 @@ class SuperAdminAuditLogger return $panel->getId() === 'superadmin'; } + if (request()->is('super-admin*') || request()->is('api/v1/support*')) { + return true; + } + if (app()->runningInConsole()) { return false; } - return request()->is('super-admin*'); + return false; } /** diff --git a/app/Support/SupportApiAuthorizer.php b/app/Support/SupportApiAuthorizer.php index 73e98d6..796f346 100644 --- a/app/Support/SupportApiAuthorizer.php +++ b/app/Support/SupportApiAuthorizer.php @@ -48,4 +48,39 @@ class SupportApiAuthorizer return null; } + + /** + * @param array $abilities + */ + public static function authorizeAnyAbility(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse + { + if ($abilities === []) { + return null; + } + + $token = $request->user()?->currentAccessToken(); + + if (! $token) { + return ApiError::response( + 'unauthenticated', + 'Unauthenticated', + 'Missing access token for support request.', + 401 + ); + } + + foreach ($abilities as $ability) { + if ($token->can($ability)) { + return null; + } + } + + return ApiError::response( + 'forbidden', + 'Forbidden', + "Missing required ability for support {$actionLabel}.", + 403, + ['required' => $abilities] + ); + } } diff --git a/app/Support/SupportApiRegistry.php b/app/Support/SupportApiRegistry.php index c7fe810..4346e98 100644 --- a/app/Support/SupportApiRegistry.php +++ b/app/Support/SupportApiRegistry.php @@ -88,6 +88,23 @@ class SupportApiRegistry return (bool) ($config['read_only'] ?? false); } + public static function auditAction(string $resource, string $operation): string + { + $config = self::get($resource); + + $action = null; + + if ($config && is_array($config['audit'] ?? null)) { + $action = $config['audit'][$operation] ?? null; + } + + if (is_string($action) && $action !== '') { + return $action; + } + + return $resource.'.'.$operation; + } + public static function allowsMutation(string $resource, string $action): bool { if (self::isReadOnly($resource)) { diff --git a/docs/openapi/support-api.yaml b/docs/openapi/support-api.yaml index 4c4ac52..5beea2a 100644 --- a/docs/openapi/support-api.yaml +++ b/docs/openapi/support-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Fotospiel Support API - version: 1.0.0 + version: 1.1.0 description: Support-only management API for super admins. servers: - url: /api/v1 @@ -50,6 +50,143 @@ components: data: type: object additionalProperties: true + SupportError: + type: object + properties: + error: + type: object + properties: + code: + type: string + title: + type: string + message: + type: string + meta: + type: object + additionalProperties: true + SupportEventPayload: + type: object + properties: + data: + type: object + properties: + name: + type: object + properties: + de: { type: string } + en: { type: string } + description: + type: object + properties: + de: { type: string } + en: { type: string } + slug: { type: string } + date: { type: string, format: date-time } + location: { type: string, nullable: true } + max_participants: { type: integer, nullable: true } + event_type_id: { type: integer, nullable: true } + default_locale: { type: string } + is_active: { type: boolean } + status: { type: string, enum: [draft, published, archived] } + settings: { type: object, additionalProperties: true } + join_link_enabled: { type: boolean } + photo_upload_enabled: { type: boolean } + task_checklist_enabled: { type: boolean } + SupportPhotoPayload: + type: object + properties: + data: + type: object + properties: + status: { type: string, enum: [pending, approved, rejected, hidden] } + moderation_notes: { type: string, nullable: true } + is_featured: { type: boolean } + emotion_id: { type: integer, nullable: true } + task_id: { type: integer, nullable: true } + SupportBlogPostPayload: + type: object + properties: + data: + type: object + properties: + blog_category_id: { type: integer } + slug: { type: string } + banner: { type: string, nullable: true } + published_at: { type: string, format: date-time, nullable: true } + is_published: { type: boolean } + title: + type: object + properties: + de: { type: string } + en: { type: string } + content: + type: object + properties: + de: { type: string } + en: { type: string } + excerpt: + type: object + properties: + de: { type: string } + en: { type: string } + meta_title: + type: object + properties: + de: { type: string } + en: { type: string } + meta_description: + type: object + properties: + de: { type: string } + en: { type: string } + SupportEmotionPayload: + type: object + properties: + data: + type: object + properties: + name: + type: object + properties: + de: { type: string } + en: { type: string } + description: + type: object + properties: + de: { type: string } + en: { type: string } + icon: { type: string, nullable: true } + color: { type: string, nullable: true } + sort_order: { type: integer } + is_active: { type: boolean } + tenant_id: { type: integer, nullable: true } + SupportTaskPayload: + type: object + properties: + data: + type: object + properties: + emotion_id: { type: integer } + event_type_id: { type: integer, nullable: true } + title: + type: object + properties: + de: { type: string } + en: { type: string } + description: + type: object + properties: + de: { type: string } + en: { type: string } + example_text: + type: object + properties: + de: { type: string } + en: { type: string } + difficulty: { type: string, enum: [easy, medium, hard] } + sort_order: { type: integer } + is_active: { type: boolean } paths: /support/auth/token: post: @@ -62,14 +199,11 @@ paths: schema: type: object properties: - login: - type: string - password: - type: string + login: { type: string } + password: { type: string } abilities: type: array - items: - type: string + items: { type: string } required: [login, password] responses: '200': @@ -95,8 +229,7 @@ paths: additionalProperties: true abilities: type: array - items: - type: string + items: { type: string } /support/auth/logout: post: summary: Revoke current support token @@ -109,8 +242,7 @@ paths: schema: type: object properties: - ok: - type: boolean + ok: { type: boolean } /support/settings/guest-policy: get: summary: Get guest policy settings @@ -175,11 +307,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Activated + '200': { description: Activated } /support/tenants/{tenant}/actions/deactivate: post: summary: Deactivate tenant @@ -188,11 +318,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Deactivated + '200': { description: Deactivated } /support/tenants/{tenant}/actions/suspend: post: summary: Suspend tenant @@ -201,11 +329,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Suspended + '200': { description: Suspended } /support/tenants/{tenant}/actions/unsuspend: post: summary: Unsuspend tenant @@ -214,11 +340,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Unsuspended + '200': { description: Unsuspended } /support/tenants/{tenant}/actions/schedule-deletion: post: summary: Schedule tenant deletion @@ -227,8 +351,7 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } requestBody: required: true content: @@ -236,15 +359,11 @@ paths: schema: type: object properties: - pending_deletion_at: - type: string - format: date-time - send_warning: - type: boolean + pending_deletion_at: { type: string, format: date-time } + send_warning: { type: boolean } required: [pending_deletion_at] responses: - '200': - description: Scheduled + '200': { description: Scheduled } /support/tenants/{tenant}/actions/cancel-deletion: post: summary: Cancel tenant deletion @@ -253,11 +372,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Cancelled + '200': { description: Cancelled } /support/tenants/{tenant}/actions/anonymize: post: summary: Anonymize tenant @@ -266,11 +383,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Anonymize requested + '200': { description: Anonymize requested } /support/tenants/{tenant}/actions/add-package: post: summary: Add package to tenant @@ -279,8 +394,7 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } requestBody: required: true content: @@ -288,17 +402,12 @@ paths: schema: type: object properties: - package_id: - type: integer - expires_at: - type: string - format: date-time - reason: - type: string + package_id: { type: integer } + expires_at: { type: string, format: date-time } + reason: { type: string } required: [package_id] responses: - '200': - description: Package added + '200': { description: Package added } /support/tenants/{tenant}/actions/update-limits: post: summary: Update tenant limits @@ -307,8 +416,7 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } requestBody: required: true content: @@ -316,16 +424,12 @@ paths: schema: type: object properties: - max_photos_per_event: - type: integer - max_storage_mb: - type: integer - note: - type: string + max_photos_per_event: { type: integer } + max_storage_mb: { type: integer } + note: { type: string } required: [max_photos_per_event, max_storage_mb] responses: - '200': - description: Limits updated + '200': { description: Limits updated } /support/tenants/{tenant}/actions/update-subscription-expires-at: post: summary: Update tenant subscription expiry @@ -334,8 +438,7 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } requestBody: required: true content: @@ -343,14 +446,10 @@ paths: schema: type: object properties: - subscription_expires_at: - type: string - format: date-time - note: - type: string + subscription_expires_at: { type: string, format: date-time } + note: { type: string } responses: - '200': - description: Subscription expiry updated + '200': { description: Subscription expiry updated } /support/tenants/{tenant}/actions/set-grace-period: post: summary: Set tenant grace period @@ -359,8 +458,7 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } requestBody: required: true content: @@ -368,15 +466,11 @@ paths: schema: type: object properties: - grace_period_ends_at: - type: string - format: date-time - note: - type: string + grace_period_ends_at: { type: string, format: date-time } + note: { type: string } required: [grace_period_ends_at] responses: - '200': - description: Grace period set + '200': { description: Grace period set } /support/tenants/{tenant}/actions/clear-grace-period: post: summary: Clear tenant grace period @@ -385,11 +479,9 @@ paths: - in: path name: tenant required: true - schema: - type: integer + schema: { type: integer } responses: - '200': - description: Grace period cleared + '200': { description: Grace period cleared } /support/{resource}: get: summary: List support resource @@ -400,46 +492,16 @@ paths: required: true schema: type: string - enum: - - tenants - - users - - events - - event-types - - photos - - event-purchases - - purchases - - purchase-histories - - packages - - package-addons - - tenant-packages - - coupons - - gift-vouchers - - tenant-feedback - - tenant-announcements - - media-storage-targets - - retention-overrides - - data-exports - - photobooth-settings - - legal-pages - - blog-categories - - blog-posts - - emotions - - tasks - - task-collections - - super-admin-action-logs - - infrastructure-action-logs + enum: [tenants, users, events, event-types, photos, event-purchases, purchases, purchase-histories, packages, package-addons, tenant-packages, coupons, gift-vouchers, tenant-feedback, tenant-announcements, media-storage-targets, retention-overrides, data-exports, photobooth-settings, legal-pages, blog-categories, blog-posts, emotions, tasks, task-collections, super-admin-action-logs, infrastructure-action-logs] - in: query name: search - schema: - type: string + schema: { type: string } - in: query name: sort - schema: - type: string + schema: { type: string } - in: query name: per_page - schema: - type: integer + schema: { type: integer } responses: '200': description: Resource list @@ -454,14 +516,16 @@ paths: - in: path name: resource required: true - schema: - type: string + schema: { type: string } requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/SupportResourcePayload' + oneOf: + - $ref: '#/components/schemas/SupportBlogPostPayload' + - $ref: '#/components/schemas/SupportEmotionPayload' + - $ref: '#/components/schemas/SupportTaskPayload' responses: '201': description: Resource created @@ -477,13 +541,11 @@ paths: - in: path name: resource required: true - schema: - type: string + schema: { type: string } - in: path name: record required: true - schema: - type: string + schema: { type: string } responses: '200': description: Resource @@ -498,19 +560,22 @@ paths: - in: path name: resource required: true - schema: - type: string + schema: { type: string } - in: path name: record required: true - schema: - type: string + schema: { type: string } requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/SupportResourcePayload' + oneOf: + - $ref: '#/components/schemas/SupportEventPayload' + - $ref: '#/components/schemas/SupportPhotoPayload' + - $ref: '#/components/schemas/SupportBlogPostPayload' + - $ref: '#/components/schemas/SupportEmotionPayload' + - $ref: '#/components/schemas/SupportTaskPayload' responses: '200': description: Resource updated @@ -525,13 +590,11 @@ paths: - in: path name: resource required: true - schema: - type: string + schema: { type: string } - in: path name: record required: true - schema: - type: string + schema: { type: string } responses: '200': description: Resource deleted @@ -540,5 +603,4 @@ paths: schema: type: object properties: - ok: - type: boolean + ok: { type: boolean } diff --git a/tests/Feature/Support/SupportApiTest.php b/tests/Feature/Support/SupportApiTest.php index 04ec971..9159219 100644 --- a/tests/Feature/Support/SupportApiTest.php +++ b/tests/Feature/Support/SupportApiTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Support; use App\Models\BlogCategory; use App\Models\Photo; +use App\Models\SuperAdminActionLog; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -130,4 +131,25 @@ class SupportApiTest extends TestCase $response->assertStatus(422) ->assertJsonValidationErrors(['title', 'content']); } + + public function test_support_update_logs_audit_entry(): void + { + $user = User::factory()->create([ + 'role' => 'super_admin', + ]); + + $tenant = Tenant::factory()->create(); + + Sanctum::actingAs($user, ['support-admin', 'support:write']); + + $response = $this->patchJson('/api/v1/support/tenants/'.$tenant->id, [ + 'data' => [ + 'slug' => 'support-updated', + ], + ]); + + $response->assertOk(); + + $this->assertTrue(SuperAdminActionLog::query()->where('action', 'tenants.updated')->exists()); + } }