Fix support API audit logging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-28 21:02:25 +01:00
parent f0e8cee850
commit 0d2759b0d4
6 changed files with 308 additions and 143 deletions

View File

@@ -8,6 +8,7 @@ use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
use App\Http\Requests\Support\SupportResourceRequest; use App\Http\Requests\Support\SupportResourceRequest;
use App\Jobs\GenerateDataExport; use App\Jobs\GenerateDataExport;
use App\Models\DataExport; use App\Models\DataExport;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\SupportApiAuthorizer; use App\Support\SupportApiAuthorizer;
use App\Support\SupportApiRegistry; use App\Support\SupportApiRegistry;
@@ -77,7 +78,7 @@ class SupportResourceController extends Controller
public function store(SupportResourceRequest $request, string $resource): JsonResponse 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; return $response;
} }
@@ -110,6 +111,14 @@ class SupportResourceController extends Controller
$record = $modelClass::query()->create($payload); $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') { if ($resource === 'data-exports') {
GenerateDataExport::dispatch($record->id); GenerateDataExport::dispatch($record->id);
} }
@@ -121,7 +130,7 @@ class SupportResourceController extends Controller
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse 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; return $response;
} }
@@ -148,6 +157,14 @@ class SupportResourceController extends Controller
$model->fill($payload); $model->fill($payload);
$model->save(); $model->save();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'updated'),
$model,
SuperAdminAuditLogger::fieldsMetadata($payload),
actor: $request->user(),
source: static::class
);
return response()->json([ return response()->json([
'data' => $model->refresh(), 'data' => $model->refresh(),
]); ]);
@@ -155,7 +172,7 @@ class SupportResourceController extends Controller
public function destroy(Request $request, string $resource, string $record): JsonResponse 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; return $response;
} }
@@ -171,6 +188,14 @@ class SupportResourceController extends Controller
$model->delete(); $model->delete();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'deleted'),
$model,
SuperAdminAuditLogger::fieldsMetadata([]),
actor: $request->user(),
source: static::class
);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }

View File

@@ -93,11 +93,15 @@ class SuperAdminAuditLogger
return $panel->getId() === 'superadmin'; return $panel->getId() === 'superadmin';
} }
if (request()->is('super-admin*') || request()->is('api/v1/support*')) {
return true;
}
if (app()->runningInConsole()) { if (app()->runningInConsole()) {
return false; return false;
} }
return request()->is('super-admin*'); return false;
} }
/** /**

View File

@@ -48,4 +48,39 @@ class SupportApiAuthorizer
return null; return null;
} }
/**
* @param array<int, string> $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]
);
}
} }

View File

@@ -88,6 +88,23 @@ class SupportApiRegistry
return (bool) ($config['read_only'] ?? false); 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 public static function allowsMutation(string $resource, string $action): bool
{ {
if (self::isReadOnly($resource)) { if (self::isReadOnly($resource)) {

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Fotospiel Support API title: Fotospiel Support API
version: 1.0.0 version: 1.1.0
description: Support-only management API for super admins. description: Support-only management API for super admins.
servers: servers:
- url: /api/v1 - url: /api/v1
@@ -50,6 +50,143 @@ components:
data: data:
type: object type: object
additionalProperties: true 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: paths:
/support/auth/token: /support/auth/token:
post: post:
@@ -62,14 +199,11 @@ paths:
schema: schema:
type: object type: object
properties: properties:
login: login: { type: string }
type: string password: { type: string }
password:
type: string
abilities: abilities:
type: array type: array
items: items: { type: string }
type: string
required: [login, password] required: [login, password]
responses: responses:
'200': '200':
@@ -95,8 +229,7 @@ paths:
additionalProperties: true additionalProperties: true
abilities: abilities:
type: array type: array
items: items: { type: string }
type: string
/support/auth/logout: /support/auth/logout:
post: post:
summary: Revoke current support token summary: Revoke current support token
@@ -109,8 +242,7 @@ paths:
schema: schema:
type: object type: object
properties: properties:
ok: ok: { type: boolean }
type: boolean
/support/settings/guest-policy: /support/settings/guest-policy:
get: get:
summary: Get guest policy settings summary: Get guest policy settings
@@ -175,11 +307,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Activated }
description: Activated
/support/tenants/{tenant}/actions/deactivate: /support/tenants/{tenant}/actions/deactivate:
post: post:
summary: Deactivate tenant summary: Deactivate tenant
@@ -188,11 +318,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Deactivated }
description: Deactivated
/support/tenants/{tenant}/actions/suspend: /support/tenants/{tenant}/actions/suspend:
post: post:
summary: Suspend tenant summary: Suspend tenant
@@ -201,11 +329,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Suspended }
description: Suspended
/support/tenants/{tenant}/actions/unsuspend: /support/tenants/{tenant}/actions/unsuspend:
post: post:
summary: Unsuspend tenant summary: Unsuspend tenant
@@ -214,11 +340,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Unsuspended }
description: Unsuspended
/support/tenants/{tenant}/actions/schedule-deletion: /support/tenants/{tenant}/actions/schedule-deletion:
post: post:
summary: Schedule tenant deletion summary: Schedule tenant deletion
@@ -227,8 +351,7 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
requestBody: requestBody:
required: true required: true
content: content:
@@ -236,15 +359,11 @@ paths:
schema: schema:
type: object type: object
properties: properties:
pending_deletion_at: pending_deletion_at: { type: string, format: date-time }
type: string send_warning: { type: boolean }
format: date-time
send_warning:
type: boolean
required: [pending_deletion_at] required: [pending_deletion_at]
responses: responses:
'200': '200': { description: Scheduled }
description: Scheduled
/support/tenants/{tenant}/actions/cancel-deletion: /support/tenants/{tenant}/actions/cancel-deletion:
post: post:
summary: Cancel tenant deletion summary: Cancel tenant deletion
@@ -253,11 +372,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Cancelled }
description: Cancelled
/support/tenants/{tenant}/actions/anonymize: /support/tenants/{tenant}/actions/anonymize:
post: post:
summary: Anonymize tenant summary: Anonymize tenant
@@ -266,11 +383,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Anonymize requested }
description: Anonymize requested
/support/tenants/{tenant}/actions/add-package: /support/tenants/{tenant}/actions/add-package:
post: post:
summary: Add package to tenant summary: Add package to tenant
@@ -279,8 +394,7 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
requestBody: requestBody:
required: true required: true
content: content:
@@ -288,17 +402,12 @@ paths:
schema: schema:
type: object type: object
properties: properties:
package_id: package_id: { type: integer }
type: integer expires_at: { type: string, format: date-time }
expires_at: reason: { type: string }
type: string
format: date-time
reason:
type: string
required: [package_id] required: [package_id]
responses: responses:
'200': '200': { description: Package added }
description: Package added
/support/tenants/{tenant}/actions/update-limits: /support/tenants/{tenant}/actions/update-limits:
post: post:
summary: Update tenant limits summary: Update tenant limits
@@ -307,8 +416,7 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
requestBody: requestBody:
required: true required: true
content: content:
@@ -316,16 +424,12 @@ paths:
schema: schema:
type: object type: object
properties: properties:
max_photos_per_event: max_photos_per_event: { type: integer }
type: integer max_storage_mb: { type: integer }
max_storage_mb: note: { type: string }
type: integer
note:
type: string
required: [max_photos_per_event, max_storage_mb] required: [max_photos_per_event, max_storage_mb]
responses: responses:
'200': '200': { description: Limits updated }
description: Limits updated
/support/tenants/{tenant}/actions/update-subscription-expires-at: /support/tenants/{tenant}/actions/update-subscription-expires-at:
post: post:
summary: Update tenant subscription expiry summary: Update tenant subscription expiry
@@ -334,8 +438,7 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
requestBody: requestBody:
required: true required: true
content: content:
@@ -343,14 +446,10 @@ paths:
schema: schema:
type: object type: object
properties: properties:
subscription_expires_at: subscription_expires_at: { type: string, format: date-time }
type: string note: { type: string }
format: date-time
note:
type: string
responses: responses:
'200': '200': { description: Subscription expiry updated }
description: Subscription expiry updated
/support/tenants/{tenant}/actions/set-grace-period: /support/tenants/{tenant}/actions/set-grace-period:
post: post:
summary: Set tenant grace period summary: Set tenant grace period
@@ -359,8 +458,7 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
requestBody: requestBody:
required: true required: true
content: content:
@@ -368,15 +466,11 @@ paths:
schema: schema:
type: object type: object
properties: properties:
grace_period_ends_at: grace_period_ends_at: { type: string, format: date-time }
type: string note: { type: string }
format: date-time
note:
type: string
required: [grace_period_ends_at] required: [grace_period_ends_at]
responses: responses:
'200': '200': { description: Grace period set }
description: Grace period set
/support/tenants/{tenant}/actions/clear-grace-period: /support/tenants/{tenant}/actions/clear-grace-period:
post: post:
summary: Clear tenant grace period summary: Clear tenant grace period
@@ -385,11 +479,9 @@ paths:
- in: path - in: path
name: tenant name: tenant
required: true required: true
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200': { description: Grace period cleared }
description: Grace period cleared
/support/{resource}: /support/{resource}:
get: get:
summary: List support resource summary: List support resource
@@ -400,46 +492,16 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: 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]
- 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 - in: query
name: search name: search
schema: schema: { type: string }
type: string
- in: query - in: query
name: sort name: sort
schema: schema: { type: string }
type: string
- in: query - in: query
name: per_page name: per_page
schema: schema: { type: integer }
type: integer
responses: responses:
'200': '200':
description: Resource list description: Resource list
@@ -454,14 +516,16 @@ paths:
- in: path - in: path
name: resource name: resource
required: true required: true
schema: schema: { type: string }
type: string
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/SupportResourcePayload' oneOf:
- $ref: '#/components/schemas/SupportBlogPostPayload'
- $ref: '#/components/schemas/SupportEmotionPayload'
- $ref: '#/components/schemas/SupportTaskPayload'
responses: responses:
'201': '201':
description: Resource created description: Resource created
@@ -477,13 +541,11 @@ paths:
- in: path - in: path
name: resource name: resource
required: true required: true
schema: schema: { type: string }
type: string
- in: path - in: path
name: record name: record
required: true required: true
schema: schema: { type: string }
type: string
responses: responses:
'200': '200':
description: Resource description: Resource
@@ -498,19 +560,22 @@ paths:
- in: path - in: path
name: resource name: resource
required: true required: true
schema: schema: { type: string }
type: string
- in: path - in: path
name: record name: record
required: true required: true
schema: schema: { type: string }
type: string
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: 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: responses:
'200': '200':
description: Resource updated description: Resource updated
@@ -525,13 +590,11 @@ paths:
- in: path - in: path
name: resource name: resource
required: true required: true
schema: schema: { type: string }
type: string
- in: path - in: path
name: record name: record
required: true required: true
schema: schema: { type: string }
type: string
responses: responses:
'200': '200':
description: Resource deleted description: Resource deleted
@@ -540,5 +603,4 @@ paths:
schema: schema:
type: object type: object
properties: properties:
ok: ok: { type: boolean }
type: boolean

View File

@@ -4,6 +4,7 @@ namespace Tests\Feature\Support;
use App\Models\BlogCategory; use App\Models\BlogCategory;
use App\Models\Photo; use App\Models\Photo;
use App\Models\SuperAdminActionLog;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -130,4 +131,25 @@ class SupportApiTest extends TestCase
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']); ->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());
}
} }