accessScopeResolver->decision($user, $tenant); if (! $decision->workspaceMember || $decision->workspaceRole === null) { return null; } return WorkspaceRole::tryFrom($decision->workspaceRole); } /** * Check if user can perform a capability on a tenant */ public function can(User $user, ManagedEnvironment $tenant, string $capability): bool { if (! Capabilities::isKnown($capability)) { throw new \InvalidArgumentException("Unknown capability: {$capability}"); } $decision = $this->accessScopeResolver->decision($user, $tenant, $capability); if (! $decision->workspaceMember || ! $decision->managedEnvironmentAllowed) { $this->logDenial($user, $tenant, $capability, $decision->failedBoundary); return false; } if ($this->isLocallyDeniedByBackupHealthBrowserFixture($user, $tenant, $capability)) { $this->logDenial($user, $tenant, $capability, 'capability'); return false; } if (! $decision->capabilityAllowed) { $this->logDenial($user, $tenant, $capability, $decision->failedBoundary); } return $decision->capabilityAllowed; } private function isLocallyDeniedByBackupHealthBrowserFixture(User $user, ManagedEnvironment $tenant, string $capability): bool { if (! app()->environment(['local', 'testing'])) { return false; } $fixture = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough'); if (! is_array($fixture)) { return false; } $fixtureUserEmail = config('tenantpilot.backup_health.browser_smoke_fixture.user.email'); if (! is_string($fixtureUserEmail) || $fixtureUserEmail === '' || $user->email !== $fixtureUserEmail) { return false; } $fixtureTenantExternalId = $fixture['tenant_external_id'] ?? null; if (! is_string($fixtureTenantExternalId) || $fixtureTenantExternalId === '' || $tenant->external_id !== $fixtureTenantExternalId) { return false; } $deniedCapabilities = $fixture['capability_denials'] ?? []; if (! is_array($deniedCapabilities)) { return false; } return in_array($capability, $deniedCapabilities, true); } private function logDenial(User $user, ManagedEnvironment $tenant, string $capability, ?string $failedBoundary = null): 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, 'failed_boundary' => $failedBoundary, 'workspace_id' => is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null, 'managed_environment_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $user->getKey(), ]); } /** * Check if user has any membership for a tenant */ public function isMember(User $user, ManagedEnvironment $tenant): bool { return $this->accessScopeResolver->canAccess($user, $tenant); } /** * Prime workspace membership and managed-environment scope cache for a set of tenants. * * Used to avoid N+1 queries for bulk selection authorization while still * reflecting membership changes that may have happened earlier in the same * request or test process. * * @param array $tenantIds */ public function primeMemberships(User $user, array $tenantIds): void { $this->accessScopeResolver->prime($user, $tenantIds); } /** * Clear cached memberships (useful for testing or after membership changes) */ public function clearCache(): void { $this->accessScopeResolver->clearCache(); } }