getMembership($user, $tenant); if ($membership === null) { return null; } return TenantRole::tryFrom($membership['role']); } /** * Check if user can perform a capability on a tenant */ public function can(User $user, Tenant $tenant, string $capability): bool { if (! Capabilities::isKnown($capability)) { throw new \InvalidArgumentException("Unknown capability: {$capability}"); } $role = $this->getRole($user, $tenant); if ($role === null) { $this->logDenial($user, $tenant, $capability); return false; } $allowed = RoleCapabilityMap::hasCapability($role, $capability); if (! $allowed) { $this->logDenial($user, $tenant, $capability); } return $allowed; } private function logDenial(User $user, Tenant $tenant, string $capability): void { $key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]); if (isset($this->loggedDenials[$key])) { return; } $this->loggedDenials[$key] = true; Log::warning('rbac.denied', [ 'capability' => $capability, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $user->getKey(), ]); } /** * Check if user has any membership for a tenant */ public function isMember(User $user, Tenant $tenant): bool { return $this->getMembership($user, $tenant) !== null; } /** * Get membership details (cached per request) */ private function getMembership(User $user, Tenant $tenant): ?array { $cacheKey = "membership_{$user->id}_{$tenant->id}"; if (! isset($this->resolvedMemberships[$cacheKey])) { $membership = TenantMembership::query() ->where('user_id', $user->id) ->where('tenant_id', $tenant->id) ->first(['role', 'source', 'source_ref']); $this->resolvedMemberships[$cacheKey] = $membership?->toArray(); } return $this->resolvedMemberships[$cacheKey]; } /** * Clear cached memberships (useful for testing or after membership changes) */ public function clearCache(): void { $this->resolvedMemberships = []; } }