Enforce tenant member permissions
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-16 13:33:36 +01:00
parent df60be826d
commit 7aa0a4c847
22 changed files with 592 additions and 112 deletions

View File

@@ -19,6 +19,7 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -83,6 +84,8 @@ class EventController extends Controller
public function store(EventStoreRequest $request): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$tenantId = $request->attributes->get('tenant_id');
@@ -383,6 +386,8 @@ class EventController extends Controller
);
}
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$validated = $request->validated();
if (isset($validated['event_date'])) {
@@ -586,6 +591,8 @@ class EventController extends Controller
);
}
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$event->delete();
return response()->json([

View File

@@ -11,6 +11,7 @@ use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Services\GuestNotificationService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
public function index(Request $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
@@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$data = $request->validated();

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
public function index(Request $request, Event $event): AnonymousResourceCollection
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$tokens = $event->joinTokens()
->orderByDesc('created_at')
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
public function store(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request);
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) {
abort(404);
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) {
abort(404);
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
return new EventJoinTokenResource($token);
}
private function authorizeEvent(Request $request, Event $event): void
private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found');
}
if ($permission) {
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
}
}
private function validatePayload(Request $request, bool $partial = false): array

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantMemberPermissions;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Request;
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
public function index(Request $request, Event $event, EventJoinToken $joinToken)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
@@ -46,6 +48,7 @@ class EventJoinTokenLayoutController extends Controller
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layoutConfig = JoinTokenLayoutRegistry::find($layout);

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\EventMember;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantMemberPermissions;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
public function index(Request $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
/** @var LengthAwarePaginator $members */
$members = $event->members()
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
$data = $request->validated();
$tenant = $this->resolveTenantFromRequest($request);
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
if ((int) $member->event_id !== (int) $event->id) {
throw ValidationException::withMessages([

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
public function show(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->ensureLiveShowToken();
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
public function rotate(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->rotateLiveShowToken();

View File

@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\Photo;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$liveStatus = $request->string('live_status', 'pending')->toString();
$perPage = (int) $request->input('per_page', 20);
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(

View File

@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\TenantMemberPermissions;
use App\Support\UploadStream;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
@@ -524,15 +525,8 @@ class PhotoController extends Controller
'alt_text' => ['sometimes', 'string', 'max:255'],
]);
// Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant-admin']
);
if (isset($validated['status'])) {
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
}
$photo->update($validated);
@@ -634,6 +628,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -657,6 +652,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -680,6 +676,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([
'photo_ids' => 'required|array',
@@ -725,6 +722,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([
'photo_ids' => 'required|array',

View File

@@ -11,6 +11,7 @@ use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -66,6 +67,8 @@ class TaskController extends Controller
*/
public function store(TaskStoreRequest $request): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request);
$collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
@@ -107,6 +110,8 @@ class TaskController extends Controller
*/
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request);
if ($task->tenant_id !== $tenant->id) {
@@ -138,6 +143,8 @@ class TaskController extends Controller
*/
public function destroy(Request $request, Task $task): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
if ($task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.');
}
@@ -154,6 +161,8 @@ class TaskController extends Controller
*/
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
@@ -176,6 +185,8 @@ class TaskController extends Controller
*/
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
@@ -230,6 +241,8 @@ class TaskController extends Controller
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
@@ -256,6 +269,8 @@ class TaskController extends Controller
public function reorderForEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {

View File

@@ -3,6 +3,7 @@
namespace App\Http\Resources\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
@@ -18,6 +19,12 @@ class EventResource extends JsonResource
$showSensitive = $this->tenant_id === $tenantId;
$settings = is_array($this->settings) ? $this->settings : [];
$eventPackage = null;
$memberPermissions = null;
$user = $request->user();
if ($user && $user->role === 'member') {
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
}
if ($this->relationLoaded('eventPackages')) {
$related = $this->getRelation('eventPackages');
@@ -86,6 +93,7 @@ class EventResource extends JsonResource
? $limitEvaluator->summarizeEventPackage($eventPackage)
: null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'member_permissions' => $memberPermissions,
];
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Support;
use App\Models\Event;
use App\Models\EventMember;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantMemberPermissions
{
/**
* @return array<int, string>
*/
public static function resolveEventPermissions(Request $request, Event $event): array
{
$user = $request->user();
if (! $user instanceof User) {
return [];
}
if (self::isTenantAdmin($user)) {
return ['*'];
}
$member = self::resolveEventMember($user, $event);
if (! $member) {
return [];
}
return self::normalizePermissions($member->permissions);
}
public static function ensureEventPermission(Request $request, Event $event, string $permission): void
{
if (self::allowsPermission(self::resolveEventPermissions($request, $event), $permission)) {
return;
}
throw new HttpResponseException(ApiError::response(
'insufficient_permission',
'Insufficient permission',
'You are not allowed to perform this action.',
Response::HTTP_FORBIDDEN,
['required_permission' => $permission]
));
}
public static function allowsEventPermission(Request $request, Event $event, string $permission): bool
{
return self::allowsPermission(self::resolveEventPermissions($request, $event), $permission);
}
public static function ensureTenantPermission(Request $request, string $permission): void
{
$user = $request->user();
if (! $user instanceof User) {
throw new HttpResponseException(ApiError::response(
'unauthenticated',
'Unauthenticated',
'You must be authenticated to perform this action.',
Response::HTTP_UNAUTHORIZED
));
}
if (self::isTenantAdmin($user)) {
return;
}
$permissions = self::resolveTenantMemberPermissions($user);
if (self::allowsPermission($permissions, $permission)) {
return;
}
throw new HttpResponseException(ApiError::response(
'insufficient_permission',
'Insufficient permission',
'You are not allowed to perform this action.',
Response::HTTP_FORBIDDEN,
['required_permission' => $permission]
));
}
/**
* @return array<int, string>
*/
private static function resolveTenantMemberPermissions(User $user): array
{
if (! $user->tenant_id) {
return [];
}
$memberships = EventMember::query()
->where('tenant_id', $user->tenant_id)
->whereIn('status', ['active', 'invited'])
->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhere('email', $user->email);
})
->get(['permissions']);
$permissions = [];
foreach ($memberships as $member) {
$permissions = array_merge($permissions, self::normalizePermissions($member->permissions));
}
return array_values(array_unique($permissions));
}
private static function isTenantAdmin(User $user): bool
{
return in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true);
}
private static function resolveEventMember(User $user, Event $event): ?EventMember
{
return EventMember::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->whereIn('status', ['active', 'invited'])
->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhere('email', $user->email);
})
->first();
}
/**
* @param array<int, string>|string|null $permissions
* @return array<int, string>
*/
private static function normalizePermissions(mixed $permissions): array
{
if (is_array($permissions)) {
return array_values(array_filter(array_map('strval', $permissions)));
}
if (is_string($permissions) && $permissions !== '') {
return array_values(array_filter(array_map('trim', explode(',', $permissions))));
}
return [];
}
/**
* @param array<int, string> $permissions
*/
private static function allowsPermission(array $permissions, string $permission): bool
{
foreach ($permissions as $entry) {
if ($entry === '*' || $entry === $permission) {
return true;
}
if (Str::endsWith($entry, ':*')) {
$prefix = Str::beforeLast($entry, '*');
if (Str::startsWith($permission, $prefix)) {
return true;
}
}
}
return false;
}
}