From 8c29ba660019e131dfc3796a9115560bf9e422bc Mon Sep 17 00:00:00 2001 From: Ian Jones <-g> Date: Thu, 27 Nov 2025 09:23:02 +0000 Subject: [PATCH] feat: Implement enterprise license management system - EnterpriseLicense model with complete CRUD operations - LicenseController for REST API management - LicenseStatusController for real-time license validation - LicensingService with core business logic - LicenseValidation trait for shared validation logic - ApiLicenseValidation middleware for API token validation - ServerProvisioningLicense middleware for server operations - ValidateLicense middleware for general license checks - LicenseValidationMiddleware for comprehensive validation - LicensingServiceInterface contract for dependency injection - LicenseValidationResult data class for structured responses - LicenseException for custom error handling - Licensing facade for convenient access - LicensingServiceProvider for service registration - License configuration file with default settings --- app/Contracts/LicensingServiceInterface.php | 59 ++ app/Data/LicenseValidationResult.php | 57 ++ app/Exceptions/LicenseException.php | 55 ++ app/Facades/Licensing.php | 25 + .../Controllers/Api/LicenseController.php | 611 ++++++++++++++++++ .../Api/LicenseStatusController.php | 278 ++++++++ app/Http/Middleware/ApiLicenseValidation.php | 369 +++++++++++ .../LicenseValidationMiddleware.php | 187 ++++++ .../Middleware/ServerProvisioningLicense.php | 349 ++++++++++ app/Http/Middleware/ValidateLicense.php | 318 +++++++++ app/Models/EnterpriseLicense.php | 315 +++++++++ app/Providers/LicensingServiceProvider.php | 30 + app/Services/LicensingService.php | 347 ++++++++++ app/Traits/LicenseValidation.php | 288 +++++++++ config/licensing.php | 169 +++++ .../factories/EnterpriseLicenseFactory.php | 132 ++++ ...25529_create_enterprise_licenses_table.php | 42 ++ 17 files changed, 3631 insertions(+) create mode 100644 app/Contracts/LicensingServiceInterface.php create mode 100644 app/Data/LicenseValidationResult.php create mode 100644 app/Exceptions/LicenseException.php create mode 100644 app/Facades/Licensing.php create mode 100644 app/Http/Controllers/Api/LicenseController.php create mode 100644 app/Http/Controllers/Api/LicenseStatusController.php create mode 100644 app/Http/Middleware/ApiLicenseValidation.php create mode 100644 app/Http/Middleware/LicenseValidationMiddleware.php create mode 100644 app/Http/Middleware/ServerProvisioningLicense.php create mode 100644 app/Http/Middleware/ValidateLicense.php create mode 100644 app/Models/EnterpriseLicense.php create mode 100644 app/Providers/LicensingServiceProvider.php create mode 100644 app/Services/LicensingService.php create mode 100644 app/Traits/LicenseValidation.php create mode 100644 config/licensing.php create mode 100644 database/factories/EnterpriseLicenseFactory.php create mode 100644 database/migrations/2025_08_26_225529_create_enterprise_licenses_table.php diff --git a/app/Contracts/LicensingServiceInterface.php b/app/Contracts/LicensingServiceInterface.php new file mode 100644 index 00000000000..1ac9e410f19 --- /dev/null +++ b/app/Contracts/LicensingServiceInterface.php @@ -0,0 +1,59 @@ +isValid; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getLicense(): ?EnterpriseLicense + { + return $this->license; + } + + public function getViolations(): array + { + return $this->violations; + } + + public function getMetadata(): array + { + return $this->metadata; + } + + public function hasViolations(): bool + { + return ! empty($this->violations); + } + + public function toArray(): array + { + return [ + 'is_valid' => $this->isValid, + 'message' => $this->message, + 'license_id' => $this->license?->id, + 'violations' => $this->violations, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Exceptions/LicenseException.php b/app/Exceptions/LicenseException.php new file mode 100644 index 00000000000..5af6518beaa --- /dev/null +++ b/app/Exceptions/LicenseException.php @@ -0,0 +1,55 @@ +licensingService = $licensingService; + } + + /** + * Get license data for the current user/organization + */ + public function index(Request $request): JsonResponse + { + try { + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization) { + return response()->json([ + 'licenses' => [], + 'currentLicense' => null, + 'usageStats' => null, + 'canIssueLicenses' => false, + 'canManageAllLicenses' => false, + ]); + } + + // Get current license + $currentLicense = $currentOrganization->activeLicense; + + // Get usage statistics if license exists + $usageStats = null; + if ($currentLicense) { + $usageStats = $this->licensingService->getUsageStatistics($currentLicense); + } + + // Check permissions + $canIssueLicenses = $currentOrganization->canUserPerformAction($user, 'issue_licenses'); + $canManageAllLicenses = $currentOrganization->canUserPerformAction($user, 'manage_all_licenses'); + + // Get all licenses if user can manage them + $licenses = []; + if ($canManageAllLicenses) { + $licenses = EnterpriseLicense::with('organization') + ->orderBy('created_at', 'desc') + ->get(); + } elseif ($currentLicense) { + $licenses = [$currentLicense]; + } + + return response()->json([ + 'licenses' => $licenses, + 'currentLicense' => $currentLicense, + 'usageStats' => $usageStats, + 'canIssueLicenses' => $canIssueLicenses, + 'canManageAllLicenses' => $canManageAllLicenses, + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to load license data', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Issue a new license + */ + public function store(Request $request): JsonResponse + { + try { + $validator = Validator::make($request->all(), [ + 'organization_id' => 'required|exists:organizations,id', + 'license_type' => 'required|in:trial,subscription,perpetual', + 'license_tier' => 'required|in:basic,professional,enterprise', + 'expires_at' => 'nullable|date|after:now', + 'features' => 'array', + 'features.*' => 'string', + 'limits' => 'array', + 'limits.max_users' => 'nullable|integer|min:1', + 'limits.max_servers' => 'nullable|integer|min:1', + 'limits.max_applications' => 'nullable|integer|min:1', + 'limits.max_domains' => 'nullable|integer|min:1', + 'authorized_domains' => 'array', + 'authorized_domains.*' => 'string|max:255', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || ! $currentOrganization->canUserPerformAction($user, 'issue_licenses')) { + return response()->json([ + 'message' => 'Insufficient permissions to issue licenses', + ], 403); + } + + $organization = Organization::findOrFail($request->organization_id); + + $config = [ + 'license_type' => $request->license_type, + 'license_tier' => $request->license_tier, + 'expires_at' => $request->expires_at ? new \DateTime($request->expires_at) : null, + 'features' => $request->features ?? [], + 'limits' => array_filter($request->limits ?? []), + 'authorized_domains' => array_filter($request->authorized_domains ?? []), + ]; + + $license = $this->licensingService->issueLicense($organization, $config); + + return response()->json([ + 'message' => 'License issued successfully', + 'license' => $license->load('organization'), + ], 201); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to issue license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Get license details and usage statistics + */ + public function show(string $id): JsonResponse + { + try { + $license = EnterpriseLicense::with('organization')->findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + return response()->json([ + 'message' => 'Insufficient permissions to view this license', + ], 403); + } + + $usageStats = $this->licensingService->getUsageStatistics($license); + + return response()->json([ + 'license' => $license, + 'usageStats' => $usageStats, + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to load license details', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Validate a license + */ + public function validateLicense(Request $request, string $id): JsonResponse + { + try { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + return response()->json([ + 'message' => 'Insufficient permissions to validate this license', + ], 403); + } + + $domain = $request->input('domain', $request->getHost()); + $result = $this->licensingService->validateLicense($license->license_key, $domain); + + return response()->json([ + 'valid' => $result->isValid(), + 'message' => $result->getMessage(), + 'violations' => $result->getViolations(), + 'metadata' => $result->getMetadata(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to validate license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Suspend a license + */ + public function suspend(Request $request, string $id): JsonResponse + { + try { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || ! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses')) { + return response()->json([ + 'message' => 'Insufficient permissions to suspend licenses', + ], 403); + } + + $reason = $request->input('reason', 'Suspended by administrator'); + $success = $this->licensingService->suspendLicense($license, $reason); + + if ($success) { + return response()->json([ + 'message' => 'License suspended successfully', + ]); + } else { + return response()->json([ + 'message' => 'Failed to suspend license', + ], 500); + } + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to suspend license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Reactivate a license + */ + public function reactivate(string $id): JsonResponse + { + try { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || ! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses')) { + return response()->json([ + 'message' => 'Insufficient permissions to reactivate licenses', + ], 403); + } + + $success = $this->licensingService->reactivateLicense($license); + + if ($success) { + return response()->json([ + 'message' => 'License reactivated successfully', + ]); + } else { + return response()->json([ + 'message' => 'Failed to reactivate license', + ], 500); + } + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to reactivate license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Revoke a license + */ + public function revoke(string $id): JsonResponse + { + try { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || ! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses')) { + return response()->json([ + 'message' => 'Insufficient permissions to revoke licenses', + ], 403); + } + + $success = $this->licensingService->revokeLicense($license); + + if ($success) { + return response()->json([ + 'message' => 'License revoked successfully', + ]); + } else { + return response()->json([ + 'message' => 'Failed to revoke license', + ], 500); + } + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to revoke license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Renew a license + */ + public function renew(Request $request, string $id): JsonResponse + { + try { + $validator = Validator::make($request->all(), [ + 'renewal_period' => 'required|in:1_month,3_months,1_year,custom', + 'custom_expires_at' => 'required_if:renewal_period,custom|date|after:now', + 'auto_renewal' => 'boolean', + 'payment_method' => 'required|in:credit_card,bank_transfer,invoice', + 'new_expires_at' => 'required|date|after:now', + 'cost' => 'required|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + return response()->json([ + 'message' => 'Insufficient permissions to renew this license', + ], 403); + } + + // Update license expiration + $license->expires_at = new \DateTime($request->new_expires_at); + $license->save(); + + // Here you would typically process payment based on payment_method + // For now, we'll just update the license + + return response()->json([ + 'message' => 'License renewed successfully', + 'license' => $license->fresh(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to renew license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Upgrade a license + */ + public function upgrade(Request $request, string $id): JsonResponse + { + try { + $validator = Validator::make($request->all(), [ + 'new_tier' => 'required|in:basic,professional,enterprise', + 'upgrade_type' => 'required|in:immediate,next_billing', + 'payment_method' => 'required_if:upgrade_type,immediate|in:credit_card,bank_transfer', + 'prorated_cost' => 'required_if:upgrade_type,immediate|numeric|min:0', + 'new_monthly_cost' => 'required|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + return response()->json([ + 'message' => 'Insufficient permissions to upgrade this license', + ], 403); + } + + // Validate upgrade path + $tierHierarchy = ['basic', 'professional', 'enterprise']; + $currentIndex = array_search($license->license_tier, $tierHierarchy); + $newIndex = array_search($request->new_tier, $tierHierarchy); + + if ($newIndex <= $currentIndex) { + return response()->json([ + 'message' => 'Cannot downgrade or upgrade to the same tier', + ], 422); + } + + // Update license tier and features based on new tier + $license->license_tier = $request->new_tier; + + // Set features based on tier + $tierFeatures = [ + 'basic' => ['application_deployment', 'database_management', 'ssl_certificates'], + 'professional' => [ + 'application_deployment', 'database_management', 'ssl_certificates', + 'server_provisioning', 'terraform_integration', 'white_label_branding', + 'organization_hierarchy', 'mfa_authentication', 'audit_logging', + ], + 'enterprise' => [ + 'application_deployment', 'database_management', 'ssl_certificates', + 'server_provisioning', 'terraform_integration', 'white_label_branding', + 'organization_hierarchy', 'mfa_authentication', 'audit_logging', + 'multi_cloud_support', 'payment_processing', 'domain_management', + 'advanced_rbac', 'compliance_reporting', + ], + ]; + + $license->features = $tierFeatures[$request->new_tier]; + + // Update limits based on tier + $tierLimits = [ + 'basic' => ['max_users' => 5, 'max_servers' => 3, 'max_applications' => 10], + 'professional' => ['max_users' => 25, 'max_servers' => 15, 'max_applications' => 50], + 'enterprise' => [], // Unlimited + ]; + + $license->limits = $tierLimits[$request->new_tier]; + $license->save(); + + // Here you would typically process payment if upgrade_type is 'immediate' + // For now, we'll just update the license + + return response()->json([ + 'message' => 'License upgraded successfully', + 'license' => $license->fresh(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to upgrade license', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Get usage history for a license + */ + public function usageHistory(string $id): JsonResponse + { + try { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + return response()->json([ + 'message' => 'Insufficient permissions to view usage history', + ], 403); + } + + // Mock usage history - in real implementation, this would come from a usage tracking system + $history = []; + for ($i = 30; $i >= 0; $i--) { + $date = now()->subDays($i); + $usage = $license->organization->getUsageMetrics(); + + // Add some variation to make it realistic + $variation = rand(-2, 2); + $history[] = [ + 'date' => $date->toDateString(), + 'users' => max(1, $usage['users'] + $variation), + 'servers' => max(0, $usage['servers'] + $variation), + 'applications' => max(0, $usage['applications'] + $variation), + 'domains' => max(0, $usage['domains'] + $variation), + 'within_limits' => $license->isWithinLimits(), + ]; + } + + return response()->json([ + 'history' => $history, + ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to load usage history', + 'error' => $e->getMessage(), + ], 500); + } + } + + /** + * Export usage data + */ + public function exportUsage(string $id): \Symfony\Component\HttpFoundation\StreamedResponse + { + $license = EnterpriseLicense::findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + abort(403, 'Insufficient permissions to export usage data'); + } + + $filename = "usage-data-{$license->license_key}-".now()->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($license) { + $handle = fopen('php://output', 'w'); + + // CSV headers + fputcsv($handle, ['Date', 'Users', 'Servers', 'Applications', 'Domains', 'Within Limits']); + + // Mock data - in real implementation, get from usage tracking system + for ($i = 30; $i >= 0; $i--) { + $date = now()->subDays($i); + $usage = $license->organization->getUsageMetrics(); + + fputcsv($handle, [ + $date->toDateString(), + $usage['users'], + $usage['servers'], + $usage['applications'], + $usage['domains'], + $license->isWithinLimits() ? 'Yes' : 'No', + ]); + } + + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv', + ]); + } + + /** + * Export license data + */ + public function exportLicense(string $id): \Symfony\Component\HttpFoundation\StreamedResponse + { + $license = EnterpriseLicense::with('organization')->findOrFail($id); + + // Check permissions + $user = Auth::user(); + $currentOrganization = $user->currentOrganization; + + if (! $currentOrganization || + (! $currentOrganization->canUserPerformAction($user, 'manage_all_licenses') && + $license->organization_id !== $currentOrganization->id)) { + abort(403, 'Insufficient permissions to export license data'); + } + + $filename = "license-{$license->license_key}-".now()->format('Y-m-d').'.json'; + + return response()->streamDownload(function () use ($license) { + $data = [ + 'license' => $license->toArray(), + 'usage_stats' => $this->licensingService->getUsageStatistics($license), + 'exported_at' => now()->toISOString(), + ]; + + echo json_encode($data, JSON_PRETTY_PRINT); + }, $filename, [ + 'Content-Type' => 'application/json', + ]); + } +} diff --git a/app/Http/Controllers/Api/LicenseStatusController.php b/app/Http/Controllers/Api/LicenseStatusController.php new file mode 100644 index 00000000000..0be519894f2 --- /dev/null +++ b/app/Http/Controllers/Api/LicenseStatusController.php @@ -0,0 +1,278 @@ +provisioningService = $provisioningService; + } + + #[OA\Get( + summary: 'License Status', + description: 'Get current license status and available features.', + path: '/license/status', + operationId: 'get-license-status', + security: [ + ['bearerAuth' => []], + ], + tags: ['License'], + responses: [ + new OA\Response( + response: 200, + description: 'License status retrieved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'license_info' => [ + 'type' => 'object', + 'properties' => [ + 'license_tier' => ['type' => 'string'], + 'features' => ['type' => 'array', 'items' => ['type' => 'string']], + 'limits' => ['type' => 'object'], + 'expires_at' => ['type' => 'string', 'nullable' => true], + 'is_trial' => ['type' => 'boolean'], + 'days_until_expiration' => ['type' => 'integer', 'nullable' => true], + ], + ], + 'resource_limits' => ['type' => 'object'], + 'deployment_options' => ['type' => 'object'], + 'provisioning_status' => ['type' => 'object'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + ref: '#/components/responses/403', + ), + ] + )] + public function status(Request $request) + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $licenseInfo = $this->getLicenseFeatures(); + $resourceLimits = $this->provisioningService->getResourceLimits($organization); + $deploymentOptions = $this->provisioningService->getAvailableDeploymentOptions($organization); + + // Check provisioning status for each resource type + $provisioningStatus = [ + 'servers' => $this->provisioningService->canProvisionServer($organization), + 'applications' => $this->provisioningService->canDeployApplication($organization), + 'domains' => $this->provisioningService->canManageDomains($organization), + 'infrastructure' => $this->provisioningService->canProvisionInfrastructure($organization), + ]; + + return response()->json([ + 'license_info' => $licenseInfo, + 'resource_limits' => $resourceLimits, + 'deployment_options' => $deploymentOptions, + 'provisioning_status' => $provisioningStatus, + ]); + } + + #[OA\Get( + summary: 'Check Feature', + description: 'Check if a specific feature is available in the current license.', + path: '/license/features/{feature}', + operationId: 'check-license-feature', + security: [ + ['bearerAuth' => []], + ], + tags: ['License'], + parameters: [ + new OA\Parameter( + name: 'feature', + in: 'path', + required: true, + description: 'Feature name to check', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Feature availability checked.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'feature' => ['type' => 'string'], + 'available' => ['type' => 'boolean'], + 'license_tier' => ['type' => 'string'], + 'upgrade_required' => ['type' => 'boolean'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + ref: '#/components/responses/403', + ), + ] + )] + public function checkFeature(Request $request, string $feature) + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $license = $organization->activeLicense; + $available = $license ? $license->hasFeature($feature) : false; + + return response()->json([ + 'feature' => $feature, + 'available' => $available, + 'license_tier' => $license?->license_tier, + 'upgrade_required' => ! $available && $license !== null, + ]); + } + + #[OA\Get( + summary: 'Check Deployment Option', + description: 'Check if a specific deployment option is available in the current license.', + path: '/license/deployment-options/{option}', + operationId: 'check-deployment-option', + security: [ + ['bearerAuth' => []], + ], + tags: ['License'], + parameters: [ + new OA\Parameter( + name: 'option', + in: 'path', + required: true, + description: 'Deployment option to check', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deployment option availability checked.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'option' => ['type' => 'string'], + 'available' => ['type' => 'boolean'], + 'license_tier' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + ref: '#/components/responses/403', + ), + ] + )] + public function checkDeploymentOption(Request $request, string $option) + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $deploymentOptions = $this->provisioningService->getAvailableDeploymentOptions($organization); + $available = array_key_exists($option, $deploymentOptions['available_options']); + $description = $deploymentOptions['available_options'][$option] ?? null; + + return response()->json([ + 'option' => $option, + 'available' => $available, + 'license_tier' => $deploymentOptions['license_tier'], + 'description' => $description, + ]); + } + + #[OA\Get( + summary: 'Resource Limits', + description: 'Get current resource usage and limits.', + path: '/license/limits', + operationId: 'get-resource-limits', + security: [ + ['bearerAuth' => []], + ], + tags: ['License'], + responses: [ + new OA\Response( + response: 200, + description: 'Resource limits retrieved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'has_license' => ['type' => 'boolean'], + 'license_tier' => ['type' => 'string'], + 'limits' => ['type' => 'object'], + 'usage' => ['type' => 'object'], + 'expires_at' => ['type' => 'string', 'nullable' => true], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + ref: '#/components/responses/403', + ), + ] + )] + public function limits(Request $request) + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $resourceLimits = $this->provisioningService->getResourceLimits($organization); + + return response()->json($resourceLimits); + } +} diff --git a/app/Http/Middleware/ApiLicenseValidation.php b/app/Http/Middleware/ApiLicenseValidation.php new file mode 100644 index 00000000000..c9bf31f7119 --- /dev/null +++ b/app/Http/Middleware/ApiLicenseValidation.php @@ -0,0 +1,369 @@ +shouldSkipValidation($request)) { + return $next($request); + } + + $user = Auth::user(); + if (! $user) { + return $this->unauthorizedResponse('Authentication required'); + } + + $organization = $user->currentOrganization; + if (! $organization) { + return $this->forbiddenResponse( + 'No organization context available', + 'NO_ORGANIZATION_CONTEXT' + ); + } + + $license = $organization->activeLicense; + if (! $license) { + return $this->forbiddenResponse( + 'No valid license found for organization', + 'NO_VALID_LICENSE' + ); + } + + // Validate license + $domain = $request->getHost(); + $validationResult = $this->licensingService->validateLicense($license->license_key, $domain); + + if (! $validationResult->isValid()) { + return $this->handleInvalidLicense($request, $validationResult, $features); + } + + // Check feature-specific permissions + if (! empty($features)) { + $featureCheck = $this->validateFeatures($license, $features); + if (! $featureCheck['valid']) { + return $this->forbiddenResponse( + $featureCheck['message'], + 'INSUFFICIENT_LICENSE_FEATURES', + [ + 'required_features' => $features, + 'missing_features' => $featureCheck['missing_features'], + 'license_tier' => $license->license_tier, + 'available_features' => $license->features ?? [], + ] + ); + } + } + + // Apply rate limiting based on license tier + $this->applyRateLimiting($request, $license); + + // Add license context to request + $request->attributes->set('license', $license); + $request->attributes->set('license_validation', $validationResult); + $request->attributes->set('organization', $organization); + + // Add license information to response headers + $response = $next($request); + $this->addLicenseHeaders($response, $license, $validationResult); + + return $response; + } + + /** + * Determine if license validation should be skipped + */ + protected function shouldSkipValidation(Request $request): bool + { + $skipPaths = [ + 'api/health', + 'api/v1/health', + 'api/feedback', + ]; + + $path = trim($request->path(), '/'); + + foreach ($skipPaths as $skipPath) { + if (str_starts_with($path, $skipPath)) { + return true; + } + } + + return false; + } + + /** + * Handle invalid license validation results + */ + protected function handleInvalidLicense(Request $request, $validationResult, array $features): Response + { + $license = $validationResult->getLicense(); + $isExpired = $license && $license->isExpired(); + $isWithinGracePeriod = $isExpired && $license->isWithinGracePeriod(); + + Log::warning('API license validation failed', [ + 'user_id' => Auth::id(), + 'organization_id' => Auth::user()?->currentOrganization?->id, + 'license_id' => $license?->id, + 'endpoint' => $request->path(), + 'method' => $request->method(), + 'validation_message' => $validationResult->getMessage(), + 'violations' => $validationResult->getViolations(), + 'is_expired' => $isExpired, + 'is_within_grace_period' => $isWithinGracePeriod, + 'required_features' => $features, + 'user_agent' => $request->userAgent(), + 'ip_address' => $request->ip(), + ]); + + // Handle grace period with restricted access + if ($isExpired && $isWithinGracePeriod) { + return $this->handleGracePeriodAccess($request, $license, $features); + } + + $errorCode = $this->getErrorCode($validationResult); + $errorData = [ + 'validation_message' => $validationResult->getMessage(), + 'violations' => $validationResult->getViolations(), + 'required_features' => $features, + ]; + + if ($license) { + $errorData['license_status'] = $license->status; + $errorData['license_tier'] = $license->license_tier; + if ($isExpired) { + $errorData['expired_at'] = $license->expires_at?->toISOString(); + $errorData['days_expired'] = abs($license->getDaysUntilExpiration()); + } + } + + return $this->forbiddenResponse( + $validationResult->getMessage(), + $errorCode, + $errorData + ); + } + + /** + * Handle API access during license grace period + */ + protected function handleGracePeriodAccess(Request $request, $license, array $features): Response + { + // Define features that are restricted during grace period + $restrictedFeatures = [ + 'server_provisioning', + 'infrastructure_provisioning', + 'terraform_integration', + 'payment_processing', + 'domain_management', + 'bulk_operations', + ]; + + $hasRestrictedFeature = ! empty(array_intersect($features, $restrictedFeatures)); + + if ($hasRestrictedFeature) { + return $this->forbiddenResponse( + 'License expired. This feature is restricted during the grace period.', + 'LICENSE_GRACE_PERIOD_RESTRICTION', + [ + 'restricted_features' => array_intersect($features, $restrictedFeatures), + 'days_expired' => abs($license->getDaysUntilExpiration()), + 'grace_period_ends' => $license->getGracePeriodEndDate()?->toISOString(), + ] + ); + } + + // Allow read-only operations with warnings + return response()->json([ + 'success' => true, + 'message' => 'Request processed with license in grace period', + 'warnings' => [ + 'license_expired' => true, + 'days_expired' => abs($license->getDaysUntilExpiration()), + 'grace_period_ends' => $license->getGracePeriodEndDate()?->toISOString(), + 'restricted_features' => $restrictedFeatures, + ], + ], 200); + } + + /** + * Validate required features against license + */ + protected function validateFeatures($license, array $requiredFeatures): array + { + if (empty($requiredFeatures)) { + return ['valid' => true]; + } + + $licenseFeatures = $license->features ?? []; + $missingFeatures = array_diff($requiredFeatures, $licenseFeatures); + + if (! empty($missingFeatures)) { + return [ + 'valid' => false, + 'message' => 'License does not include required features: '.implode(', ', $missingFeatures), + 'missing_features' => $missingFeatures, + ]; + } + + return ['valid' => true]; + } + + /** + * Apply rate limiting based on license tier + */ + protected function applyRateLimiting(Request $request, $license): void + { + $tier = $license->license_tier ?? 'basic'; + $rateLimits = $this->getRateLimitsForTier($tier); + + $key = 'api_rate_limit:'.$license->organization_id.':'.$request->ip(); + + $executed = RateLimiter::attempt( + $key, + $rateLimits['max_attempts'], + function () { + // Rate limit passed + }, + $rateLimits['decay_minutes'] * 60 + ); + + if (! $executed) { + $retryAfter = RateLimiter::availableIn($key); + + throw new \Illuminate\Http\Exceptions\ThrottleRequestsException( + 'API rate limit exceeded for license tier: '.$tier, + null, + [], + $retryAfter + ); + } + } + + /** + * Get rate limits configuration for license tier + */ + protected function getRateLimitsForTier(string $tier): array + { + return match ($tier) { + 'enterprise' => [ + 'max_attempts' => 10000, + 'decay_minutes' => 60, + ], + 'professional' => [ + 'max_attempts' => 5000, + 'decay_minutes' => 60, + ], + 'basic' => [ + 'max_attempts' => 1000, + 'decay_minutes' => 60, + ], + default => [ + 'max_attempts' => 100, + 'decay_minutes' => 60, + ], + }; + } + + /** + * Add license information to response headers + */ + protected function addLicenseHeaders(Response $response, $license, $validationResult): void + { + $response->headers->set('X-License-Tier', $license->license_tier); + $response->headers->set('X-License-Status', $license->status); + + if ($license->expires_at) { + $response->headers->set('X-License-Expires', $license->expires_at->toISOString()); + $response->headers->set('X-License-Days-Remaining', $license->getDaysUntilExpiration()); + } + + $usageStats = $this->licensingService->getUsageStatistics($license); + if (isset($usageStats['statistics'])) { + foreach ($usageStats['statistics'] as $type => $stats) { + if (isset($stats['percentage'])) { + $response->headers->set("X-Usage-{$type}", $stats['percentage'].'%'); + } + } + } + } + + /** + * Get error code from validation result + */ + protected function getErrorCode($validationResult): string + { + $message = strtolower($validationResult->getMessage()); + + if (str_contains($message, 'expired')) { + return 'LICENSE_EXPIRED'; + } + + if (str_contains($message, 'revoked')) { + return 'LICENSE_REVOKED'; + } + + if (str_contains($message, 'suspended')) { + return 'LICENSE_SUSPENDED'; + } + + if (str_contains($message, 'domain')) { + return 'DOMAIN_NOT_AUTHORIZED'; + } + + if (str_contains($message, 'usage') || str_contains($message, 'limit')) { + return 'USAGE_LIMITS_EXCEEDED'; + } + + return 'LICENSE_VALIDATION_FAILED'; + } + + /** + * Return standardized unauthorized response + */ + protected function unauthorizedResponse(string $message): Response + { + return response()->json([ + 'success' => false, + 'message' => $message, + 'error_code' => 'UNAUTHORIZED', + ], 401); + } + + /** + * Return standardized forbidden response + */ + protected function forbiddenResponse(string $message, string $errorCode, array $data = []): Response + { + return response()->json([ + 'success' => false, + 'message' => $message, + 'error_code' => $errorCode, + ...$data, + ], 403); + } +} diff --git a/app/Http/Middleware/LicenseValidationMiddleware.php b/app/Http/Middleware/LicenseValidationMiddleware.php new file mode 100644 index 00000000000..fe3d7dd75da --- /dev/null +++ b/app/Http/Middleware/LicenseValidationMiddleware.php @@ -0,0 +1,187 @@ +licensingService = $licensingService; + } + + /** + * Handle an incoming request. + * + * @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next + * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse + */ + public function handle(Request $request, Closure $next, ?string $feature = null, ?string $action = null) + { + // Skip license validation for localhost/development + if (app()->environment('local') && config('app.debug')) { + return $next($request); + } + + // Get current user's organization + $user = Auth::user(); + if (! $user) { + return response()->json(['error' => 'Authentication required'], 401); + } + + $organization = $user->currentOrganization ?? $user->organizations()->first(); + if (! $organization) { + return response()->json(['error' => 'No organization found'], 403); + } + + // Get active license for the organization + $license = $organization->activeLicense; + if (! $license) { + return $this->handleNoLicense($request, $action); + } + + // Validate license + $domain = $request->getHost(); + $validationResult = $this->licensingService->validateLicense($license->license_key, $domain); + + if (! $validationResult->isValid) { + Log::warning('License validation failed', [ + 'organization_id' => $organization->id, + 'license_key' => $license->license_key, + 'domain' => $domain, + 'reason' => $validationResult->getMessage(), + 'action' => $action, + 'feature' => $feature, + ]); + + return $this->handleInvalidLicense($request, $validationResult, $action); + } + + // Check feature-specific permissions + if ($feature && ! $license->hasFeature($feature)) { + return response()->json([ + 'error' => 'Feature not available in your license tier', + 'feature' => $feature, + 'license_tier' => $license->license_tier, + 'upgrade_required' => true, + ], 403); + } + + // Check usage limits for resource creation actions + if ($this->isResourceCreationAction($action)) { + $usageCheck = $this->licensingService->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + return response()->json([ + 'error' => 'Usage limits exceeded', + 'violations' => $usageCheck['violations'], + 'current_usage' => $usageCheck['usage'], + 'limits' => $usageCheck['limits'], + ], 403); + } + } + + // Store license info in request for controllers to use + $request->attributes->set('license', $license); + $request->attributes->set('organization', $organization); + $request->attributes->set('license_validation', $validationResult); + + return $next($request); + } + + protected function handleNoLicense(Request $request, ?string $action): \Illuminate\Http\JsonResponse + { + // Allow basic read operations without license + if ($this->isReadOnlyAction($action)) { + return response()->json([ + 'warning' => 'No active license found. Some features may be limited.', + 'license_required' => true, + ]); + } + + return response()->json([ + 'error' => 'Valid license required for this operation', + 'action' => $action, + 'license_required' => true, + ], 403); + } + + protected function handleInvalidLicense(Request $request, $validationResult, ?string $action): \Illuminate\Http\JsonResponse + { + $license = $validationResult->getLicense(); + + // Check if license is expired but within grace period + if ($license && $license->isExpired() && $license->isWithinGracePeriod()) { + $daysRemaining = $license->getDaysRemainingInGracePeriod(); + + // Allow operations during grace period but show warning + if ($this->isGracePeriodAllowedAction($action)) { + $request->attributes->set('grace_period_warning', true); + $request->attributes->set('grace_period_days', $daysRemaining); + + return response()->json([ + 'warning' => "License expired but within grace period. {$daysRemaining} days remaining.", + 'grace_period' => true, + 'days_remaining' => $daysRemaining, + ]); + } + } + + return response()->json([ + 'error' => $validationResult->getMessage(), + 'license_status' => $license?->status, + 'expires_at' => $license?->expires_at?->toISOString(), + 'violations' => $validationResult->getViolations(), + ], 403); + } + + protected function isResourceCreationAction(?string $action): bool + { + $creationActions = [ + 'create_server', + 'create_application', + 'deploy_application', + 'create_domain', + 'provision_infrastructure', + 'create_database', + 'create_service', + ]; + + return in_array($action, $creationActions); + } + + protected function isReadOnlyAction(?string $action): bool + { + $readOnlyActions = [ + 'view_servers', + 'view_applications', + 'view_domains', + 'view_dashboard', + 'view_metrics', + 'list_resources', + ]; + + return in_array($action, $readOnlyActions); + } + + protected function isGracePeriodAllowedAction(?string $action): bool + { + $allowedActions = [ + 'view_servers', + 'view_applications', + 'view_domains', + 'view_dashboard', + 'manage_license', + 'renew_license', + ]; + + return in_array($action, $allowedActions); + } +} diff --git a/app/Http/Middleware/ServerProvisioningLicense.php b/app/Http/Middleware/ServerProvisioningLicense.php new file mode 100644 index 00000000000..c68deaf17a2 --- /dev/null +++ b/app/Http/Middleware/ServerProvisioningLicense.php @@ -0,0 +1,349 @@ +unauthorizedResponse('Authentication required for server provisioning'); + } + + $organization = $user->currentOrganization; + if (! $organization) { + return $this->forbiddenResponse( + 'Organization context required for server provisioning', + 'NO_ORGANIZATION_CONTEXT' + ); + } + + $license = $organization->activeLicense; + if (! $license) { + return $this->forbiddenResponse( + 'Valid license required for server provisioning', + 'NO_VALID_LICENSE' + ); + } + + // Validate license + $domain = $request->getHost(); + $validationResult = $this->licensingService->validateLicense($license->license_key, $domain); + + if (! $validationResult->isValid()) { + return $this->handleInvalidLicense($request, $validationResult); + } + + // Check server provisioning specific requirements + $provisioningCheck = $this->validateProvisioningCapabilities($license, $organization); + if (! $provisioningCheck['allowed']) { + return $this->forbiddenResponse( + $provisioningCheck['message'], + $provisioningCheck['error_code'], + $provisioningCheck['data'] + ); + } + + // Log provisioning attempt for audit + Log::info('Server provisioning authorized', [ + 'user_id' => $user->id, + 'organization_id' => $organization->id, + 'license_id' => $license->id, + 'license_tier' => $license->license_tier, + 'endpoint' => $request->path(), + 'method' => $request->method(), + 'current_server_count' => $organization->servers()->count(), + 'server_limit' => $license->limits['max_servers'] ?? 'unlimited', + ]); + + // Add provisioning context to request + $request->attributes->set('license', $license); + $request->attributes->set('organization', $organization); + $request->attributes->set('provisioning_authorized', true); + + return $next($request); + } + + /** + * Validate server provisioning capabilities against license + */ + protected function validateProvisioningCapabilities($license, $organization): array + { + // Check if license includes server provisioning feature + $requiredFeatures = ['server_provisioning']; + $licenseFeatures = $license->features ?? []; + $missingFeatures = array_diff($requiredFeatures, $licenseFeatures); + + if (! empty($missingFeatures)) { + return [ + 'allowed' => false, + 'message' => 'License does not include server provisioning capabilities', + 'error_code' => 'FEATURE_NOT_LICENSED', + 'data' => [ + 'required_features' => $requiredFeatures, + 'missing_features' => $missingFeatures, + 'license_tier' => $license->license_tier, + 'available_features' => $licenseFeatures, + ], + ]; + } + + // Check server count limits + $currentServerCount = $organization->servers()->count(); + $maxServers = $license->limits['max_servers'] ?? null; + + if ($maxServers !== null && $currentServerCount >= $maxServers) { + return [ + 'allowed' => false, + 'message' => "Server limit reached. Current: {$currentServerCount}, Limit: {$maxServers}", + 'error_code' => 'SERVER_LIMIT_EXCEEDED', + 'data' => [ + 'current_servers' => $currentServerCount, + 'max_servers' => $maxServers, + 'license_tier' => $license->license_tier, + ], + ]; + } + + // Check if license is expired (no grace period for provisioning) + if ($license->isExpired()) { + return [ + 'allowed' => false, + 'message' => 'Cannot provision servers with expired license', + 'error_code' => 'LICENSE_EXPIRED_NO_PROVISIONING', + 'data' => [ + 'expired_at' => $license->expires_at?->toISOString(), + 'days_expired' => abs($license->getDaysUntilExpiration()), + ], + ]; + } + + // Check infrastructure provisioning feature for Terraform-based provisioning + $isInfrastructureProvisioning = $this->isInfrastructureProvisioningRequest($request ?? request()); + if ($isInfrastructureProvisioning) { + $infraFeatures = ['infrastructure_provisioning', 'terraform_integration']; + $missingInfraFeatures = array_diff($infraFeatures, $licenseFeatures); + + if (! empty($missingInfraFeatures)) { + return [ + 'allowed' => false, + 'message' => 'License does not include infrastructure provisioning capabilities', + 'error_code' => 'INFRASTRUCTURE_FEATURE_NOT_LICENSED', + 'data' => [ + 'required_features' => $infraFeatures, + 'missing_features' => $missingInfraFeatures, + 'license_tier' => $license->license_tier, + ], + ]; + } + } + + // Check cloud provider limits if applicable + $cloudProviderCheck = $this->validateCloudProviderLimits($license, $organization); + if (! $cloudProviderCheck['allowed']) { + return $cloudProviderCheck; + } + + return ['allowed' => true]; + } + + /** + * Check if this is an infrastructure provisioning request (Terraform-based) + */ + protected function isInfrastructureProvisioningRequest(Request $request): bool + { + $path = $request->path(); + $infrastructurePaths = [ + 'api/v1/infrastructure', + 'api/v1/terraform', + 'api/v1/cloud-providers', + 'infrastructure/provision', + 'terraform/deploy', + ]; + + foreach ($infrastructurePaths as $infraPath) { + if (str_contains($path, $infraPath)) { + return true; + } + } + + // Check request data for infrastructure provisioning indicators + $data = $request->all(); + + return isset($data['provider_credential_id']) || + isset($data['terraform_config']) || + isset($data['cloud_provider']); + } + + /** + * Validate cloud provider specific limits + */ + protected function validateCloudProviderLimits($license, $organization): array + { + $limits = $license->limits ?? []; + + // Check cloud provider count limits + if (isset($limits['max_cloud_providers'])) { + $currentProviders = $organization->cloudProviderCredentials()->count(); + if ($currentProviders >= $limits['max_cloud_providers']) { + return [ + 'allowed' => false, + 'message' => "Cloud provider limit reached. Current: {$currentProviders}, Limit: {$limits['max_cloud_providers']}", + 'error_code' => 'CLOUD_PROVIDER_LIMIT_EXCEEDED', + 'data' => [ + 'current_providers' => $currentProviders, + 'max_providers' => $limits['max_cloud_providers'], + ], + ]; + } + } + + // Check concurrent provisioning limits + if (isset($limits['max_concurrent_provisioning'])) { + $activeProvisioningCount = $organization->terraformDeployments() + ->whereIn('status', ['pending', 'provisioning', 'in_progress']) + ->count(); + + if ($activeProvisioningCount >= $limits['max_concurrent_provisioning']) { + return [ + 'allowed' => false, + 'message' => "Concurrent provisioning limit reached. Active: {$activeProvisioningCount}, Limit: {$limits['max_concurrent_provisioning']}", + 'error_code' => 'CONCURRENT_PROVISIONING_LIMIT_EXCEEDED', + 'data' => [ + 'active_provisioning' => $activeProvisioningCount, + 'max_concurrent' => $limits['max_concurrent_provisioning'], + ], + ]; + } + } + + return ['allowed' => true]; + } + + /** + * Handle invalid license for provisioning + */ + protected function handleInvalidLicense(Request $request, $validationResult): Response + { + $license = $validationResult->getLicense(); + + Log::warning('Server provisioning blocked due to invalid license', [ + 'user_id' => Auth::id(), + 'organization_id' => Auth::user()?->currentOrganization?->id, + 'license_id' => $license?->id, + 'endpoint' => $request->path(), + 'validation_message' => $validationResult->getMessage(), + 'violations' => $validationResult->getViolations(), + ]); + + $errorCode = $this->getErrorCode($validationResult); + $errorData = [ + 'validation_message' => $validationResult->getMessage(), + 'violations' => $validationResult->getViolations(), + 'provisioning_blocked' => true, + ]; + + if ($license) { + $errorData['license_status'] = $license->status; + $errorData['license_tier'] = $license->license_tier; + if ($license->isExpired()) { + $errorData['expired_at'] = $license->expires_at?->toISOString(); + $errorData['days_expired'] = abs($license->getDaysUntilExpiration()); + } + } + + return $this->forbiddenResponse( + 'Server provisioning not allowed: '.$validationResult->getMessage(), + $errorCode, + $errorData + ); + } + + /** + * Get error code from validation result + */ + protected function getErrorCode($validationResult): string + { + $message = strtolower($validationResult->getMessage()); + + if (str_contains($message, 'expired')) { + return 'LICENSE_EXPIRED'; + } + + if (str_contains($message, 'revoked')) { + return 'LICENSE_REVOKED'; + } + + if (str_contains($message, 'suspended')) { + return 'LICENSE_SUSPENDED'; + } + + if (str_contains($message, 'domain')) { + return 'DOMAIN_NOT_AUTHORIZED'; + } + + if (str_contains($message, 'usage') || str_contains($message, 'limit')) { + return 'USAGE_LIMITS_EXCEEDED'; + } + + return 'LICENSE_VALIDATION_FAILED'; + } + + /** + * Return unauthorized response + */ + protected function unauthorizedResponse(string $message): Response + { + if (request()->expectsJson()) { + return response()->json([ + 'success' => false, + 'message' => $message, + 'error_code' => 'UNAUTHORIZED', + ], 401); + } + + return redirect()->route('login') + ->with('error', $message); + } + + /** + * Return forbidden response + */ + protected function forbiddenResponse(string $message, string $errorCode, array $data = []): Response + { + if (request()->expectsJson()) { + return response()->json([ + 'success' => false, + 'message' => $message, + 'error_code' => $errorCode, + ...$data, + ], 403); + } + + return redirect()->back() + ->with('error', $message) + ->with('error_data', $data); + } +} diff --git a/app/Http/Middleware/ValidateLicense.php b/app/Http/Middleware/ValidateLicense.php new file mode 100644 index 00000000000..340752b1ef0 --- /dev/null +++ b/app/Http/Middleware/ValidateLicense.php @@ -0,0 +1,318 @@ +shouldSkipValidation($request)) { + return $next($request); + } + + $user = Auth::user(); + $organization = $user?->currentOrganization; + + // If no organization context, check for system-wide license + if (! $organization) { + return $this->handleNoOrganization($request, $next, $features); + } + + // Get the active license for the organization + $license = $organization->activeLicense; + if (! $license) { + return $this->handleNoLicense($request, $features); + } + + // Validate the license + $domain = $request->getHost(); + $validationResult = $this->licensingService->validateLicense($license->license_key, $domain); + + if (! $validationResult->isValid()) { + return $this->handleInvalidLicense($request, $validationResult, $features); + } + + // Check feature-specific permissions + if (! empty($features) && ! $this->hasRequiredFeatures($license, $features)) { + return $this->handleMissingFeatures($request, $license, $features); + } + + // Add license information to request for downstream use + $request->attributes->set('license', $license); + $request->attributes->set('license_validation', $validationResult); + + return $next($request); + } + + /** + * Determine if license validation should be skipped for this request + */ + protected function shouldSkipValidation(Request $request): bool + { + $skipPaths = [ + '/health', + '/api/v1/health', + '/api/feedback', + '/login', + '/register', + '/password/reset', + '/email/verify', + ]; + + $path = $request->path(); + + foreach ($skipPaths as $skipPath) { + if (str_starts_with($path, trim($skipPath, '/'))) { + return true; + } + } + + return false; + } + + /** + * Handle requests when no organization context is available + */ + protected function handleNoOrganization(Request $request, Closure $next, array $features): Response + { + // For API requests, return JSON error + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + return response()->json([ + 'success' => false, + 'message' => 'No organization context available. Please ensure you are associated with an organization.', + 'error_code' => 'NO_ORGANIZATION_CONTEXT', + ], 403); + } + + // For web requests, redirect to organization setup + return redirect()->route('organization.setup') + ->with('error', 'Please set up or join an organization to continue.'); + } + + /** + * Handle requests when no valid license is found + */ + protected function handleNoLicense(Request $request, array $features): Response + { + Log::warning('License validation failed: No active license found', [ + 'user_id' => Auth::id(), + 'organization_id' => Auth::user()?->currentOrganization?->id, + 'path' => $request->path(), + 'required_features' => $features, + ]); + + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + return response()->json([ + 'success' => false, + 'message' => 'No valid license found. Please contact your administrator to obtain a license.', + 'error_code' => 'NO_VALID_LICENSE', + 'required_features' => $features, + ], 403); + } + + return redirect()->route('license.required') + ->with('error', 'A valid license is required to access this feature.') + ->with('required_features', $features); + } + + /** + * Handle requests when license validation fails + */ + protected function handleInvalidLicense(Request $request, $validationResult, array $features): Response + { + $license = $validationResult->getLicense(); + $isExpired = $license && $license->isExpired(); + $isWithinGracePeriod = $isExpired && $license->isWithinGracePeriod(); + + Log::warning('License validation failed', [ + 'user_id' => Auth::id(), + 'organization_id' => Auth::user()?->currentOrganization?->id, + 'license_id' => $license?->id, + 'path' => $request->path(), + 'validation_message' => $validationResult->getMessage(), + 'violations' => $validationResult->getViolations(), + 'is_expired' => $isExpired, + 'is_within_grace_period' => $isWithinGracePeriod, + 'required_features' => $features, + ]); + + // Handle expired licenses with graceful degradation + if ($isExpired && $isWithinGracePeriod) { + return $this->handleGracePeriodAccess($request, $next, $license, $features); + } + + $errorData = [ + 'success' => false, + 'message' => $validationResult->getMessage(), + 'error_code' => $this->getErrorCode($validationResult), + 'violations' => $validationResult->getViolations(), + 'required_features' => $features, + ]; + + if ($license) { + $errorData['license_status'] = $license->status; + $errorData['license_tier'] = $license->license_tier; + if ($isExpired) { + $errorData['expired_at'] = $license->expires_at?->toISOString(); + $errorData['days_expired'] = abs($license->getDaysUntilExpiration()); + } + } + + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + return response()->json($errorData, 403); + } + + return redirect()->route('license.invalid') + ->with('error', $validationResult->getMessage()) + ->with('license_data', $errorData); + } + + /** + * Handle access during license grace period with limited functionality + */ + protected function handleGracePeriodAccess(Request $request, Closure $next, EnterpriseLicense $license, array $features): Response + { + // During grace period, allow read-only operations but restrict critical features + $restrictedFeatures = [ + 'server_provisioning', + 'infrastructure_provisioning', + 'payment_processing', + 'domain_management', + 'terraform_integration', + ]; + + $hasRestrictedFeature = ! empty(array_intersect($features, $restrictedFeatures)); + + if ($hasRestrictedFeature) { + $errorMessage = 'License expired. Some features are restricted during the grace period.'; + + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + return response()->json([ + 'success' => false, + 'message' => $errorMessage, + 'error_code' => 'LICENSE_GRACE_PERIOD_RESTRICTION', + 'restricted_features' => array_intersect($features, $restrictedFeatures), + 'days_expired' => abs($license->getDaysUntilExpiration()), + ], 403); + } + + return redirect()->back() + ->with('warning', $errorMessage) + ->with('license_expired', true); + } + + // Allow the request but add warning headers/context + $response = $next($request); + + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + $response->headers->set('X-License-Status', 'expired-grace-period'); + $response->headers->set('X-License-Days-Expired', abs($license->getDaysUntilExpiration())); + } + + return $response; + } + + /** + * Handle requests when required features are missing from license + */ + protected function handleMissingFeatures(Request $request, EnterpriseLicense $license, array $features): Response + { + $missingFeatures = array_diff($features, $license->features ?? []); + + Log::warning('License feature validation failed', [ + 'user_id' => Auth::id(), + 'organization_id' => $license->organization_id, + 'license_id' => $license->id, + 'license_tier' => $license->license_tier, + 'path' => $request->path(), + 'required_features' => $features, + 'missing_features' => $missingFeatures, + 'available_features' => $license->features, + ]); + + $errorData = [ + 'success' => false, + 'message' => 'Your license does not include the required features for this operation.', + 'error_code' => 'INSUFFICIENT_LICENSE_FEATURES', + 'required_features' => $features, + 'missing_features' => $missingFeatures, + 'license_tier' => $license->license_tier, + 'available_features' => $license->features ?? [], + ]; + + if ($request->expectsJson() || str_starts_with($request->path(), 'api/')) { + return response()->json($errorData, 403); + } + + return redirect()->route('license.upgrade') + ->with('error', 'Your current license does not include the required features.') + ->with('license_data', $errorData); + } + + /** + * Check if license has all required features + */ + protected function hasRequiredFeatures(EnterpriseLicense $license, array $requiredFeatures): bool + { + if (empty($requiredFeatures)) { + return true; + } + + $licenseFeatures = $license->features ?? []; + + return empty(array_diff($requiredFeatures, $licenseFeatures)); + } + + /** + * Get appropriate error code based on validation result + */ + protected function getErrorCode($validationResult): string + { + $message = strtolower($validationResult->getMessage()); + + if (str_contains($message, 'expired')) { + return 'LICENSE_EXPIRED'; + } + + if (str_contains($message, 'revoked')) { + return 'LICENSE_REVOKED'; + } + + if (str_contains($message, 'suspended')) { + return 'LICENSE_SUSPENDED'; + } + + if (str_contains($message, 'domain')) { + return 'DOMAIN_NOT_AUTHORIZED'; + } + + if (str_contains($message, 'usage') || str_contains($message, 'limit')) { + return 'USAGE_LIMITS_EXCEEDED'; + } + + return 'LICENSE_VALIDATION_FAILED'; + } +} diff --git a/app/Models/EnterpriseLicense.php b/app/Models/EnterpriseLicense.php new file mode 100644 index 00000000000..591efe1c1c9 --- /dev/null +++ b/app/Models/EnterpriseLicense.php @@ -0,0 +1,315 @@ + 'array', + 'limits' => 'array', + 'authorized_domains' => 'array', + 'issued_at' => 'datetime', + 'expires_at' => 'datetime', + 'last_validated_at' => 'datetime', + ]; + + // Relationships + public function organization() + { + return $this->belongsTo(Organization::class); + } + + // Feature Checking Methods + public function hasFeature(string $feature): bool + { + return in_array($feature, $this->features ?? []); + } + + public function hasAnyFeature(array $features): bool + { + return ! empty(array_intersect($features, $this->features ?? [])); + } + + public function hasAllFeatures(array $features): bool + { + return empty(array_diff($features, $this->features ?? [])); + } + + // Validation Methods + public function isValid(): bool + { + return $this->status === 'active' && + ($this->expires_at === null || $this->expires_at->isFuture()); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function isSuspended(): bool + { + return $this->status === 'suspended'; + } + + public function isRevoked(): bool + { + return $this->status === 'revoked'; + } + + public function isDomainAuthorized(string $domain): bool + { + if (empty($this->authorized_domains)) { + return true; // No domain restrictions + } + + // Check exact match + if (in_array($domain, $this->authorized_domains)) { + return true; + } + + // Check wildcard domains + foreach ($this->authorized_domains as $authorizedDomain) { + if (str_starts_with($authorizedDomain, '*.')) { + $pattern = str_replace('*.', '', $authorizedDomain); + if (str_ends_with($domain, $pattern)) { + return true; + } + } + } + + return false; + } + + // Limit Checking Methods + public function isWithinLimits(): bool + { + if (! $this->organization) { + return false; + } + + $usage = $this->organization->getUsageMetrics(); + $limits = $this->limits ?? []; + + foreach ($limits as $limitType => $limitValue) { + $currentUsage = $usage[$limitType] ?? 0; + if ($currentUsage > $limitValue) { + return false; + } + } + + return true; + } + + public function getLimitViolations(): array + { + if (! $this->organization) { + return []; + } + + $usage = $this->organization->getUsageMetrics(); + $limits = $this->limits ?? []; + $violations = []; + + foreach ($limits as $limitType => $limitValue) { + $currentUsage = $usage[$limitType] ?? 0; + if ($currentUsage > $limitValue) { + $violations[] = [ + 'type' => $limitType, + 'limit' => $limitValue, + 'current' => $currentUsage, + 'message' => ucfirst($limitType)." count ({$currentUsage}) exceeds limit ({$limitValue})", + ]; + } + } + + return $violations; + } + + public function getLimit(string $limitType): ?int + { + return $this->limits[$limitType] ?? null; + } + + public function getRemainingLimit(string $limitType): ?int + { + $limit = $this->getLimit($limitType); + if ($limit === null) { + return null; // No limit set + } + + $usage = $this->organization?->getUsageMetrics()[$limitType] ?? 0; + + return max(0, $limit - $usage); + } + + // License Type Methods + public function isPerpetual(): bool + { + return $this->license_type === 'perpetual'; + } + + public function isSubscription(): bool + { + return $this->license_type === 'subscription'; + } + + public function isTrial(): bool + { + return $this->license_type === 'trial'; + } + + // License Tier Methods + public function isBasic(): bool + { + return $this->license_tier === 'basic'; + } + + public function isProfessional(): bool + { + return $this->license_tier === 'professional'; + } + + public function isEnterprise(): bool + { + return $this->license_tier === 'enterprise'; + } + + // Status Management + public function activate(): bool + { + $this->status = 'active'; + + return $this->save(); + } + + public function suspend(): bool + { + $this->status = 'suspended'; + + return $this->save(); + } + + public function revoke(): bool + { + $this->status = 'revoked'; + + return $this->save(); + } + + public function markAsExpired(): bool + { + $this->status = 'expired'; + + return $this->save(); + } + + // Validation Tracking + public function updateLastValidated(): bool + { + $this->last_validated_at = now(); + + return $this->save(); + } + + public function getDaysUntilExpiration(): ?int + { + if ($this->expires_at === null) { + return null; // Never expires + } + + return max(0, now()->diffInDays($this->expires_at, false)); + } + + public function isExpiringWithin(int $days): bool + { + if ($this->expires_at === null) { + return false; + } + + return $this->expires_at->isBefore(now()->addDays($days)); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function scopeValid($query) + { + return $query->where('status', 'active') + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + public function scopeExpired($query) + { + return $query->where('expires_at', '<', now()); + } + + public function scopeExpiringWithin($query, int $days) + { + return $query->where('expires_at', '<=', now()->addDays($days)) + ->where('expires_at', '>', now()); + } + + // Grace Period Methods + public function isWithinGracePeriod(): bool + { + if (! $this->isExpired()) { + return false; + } + + $gracePeriodDays = config('licensing.grace_period_days', 7); + $daysExpired = abs($this->getDaysUntilExpiration()); + + return $daysExpired <= $gracePeriodDays; + } + + public function getGracePeriodEndDate(): ?\Carbon\Carbon + { + if (! $this->expires_at) { + return null; + } + + $gracePeriodDays = config('licensing.grace_period_days', 7); + + return $this->expires_at->addDays($gracePeriodDays); + } + + public function getDaysRemainingInGracePeriod(): ?int + { + if (! $this->isExpired()) { + return null; + } + + $gracePeriodEnd = $this->getGracePeriodEndDate(); + if (! $gracePeriodEnd) { + return null; + } + + return max(0, now()->diffInDays($gracePeriodEnd, false)); + } +} diff --git a/app/Providers/LicensingServiceProvider.php b/app/Providers/LicensingServiceProvider.php new file mode 100644 index 00000000000..cbda1fabc2a --- /dev/null +++ b/app/Providers/LicensingServiceProvider.php @@ -0,0 +1,30 @@ +app->bind(LicensingServiceInterface::class, LicensingService::class); + + $this->app->singleton('licensing', function ($app) { + return $app->make(LicensingServiceInterface::class); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/app/Services/LicensingService.php b/app/Services/LicensingService.php new file mode 100644 index 00000000000..5e6a3ea6096 --- /dev/null +++ b/app/Services/LicensingService.php @@ -0,0 +1,347 @@ +first(); + + if (! $license) { + $result = new LicenseValidationResult(false, 'License not found'); + $this->cacheValidationResult($cacheKey, $result, 60); // Cache failures for 1 minute + + return $result; + } + + // Check license status + if ($license->isRevoked()) { + $result = new LicenseValidationResult(false, 'License has been revoked', $license); + $this->cacheValidationResult($cacheKey, $result, 60); + + return $result; + } + + if ($license->isSuspended()) { + $result = new LicenseValidationResult(false, 'License is suspended', $license); + $this->cacheValidationResult($cacheKey, $result, 60); + + return $result; + } + + // Check expiration with grace period + if ($license->isExpired()) { + $daysExpired = abs(now()->diffInDays($license->expires_at, false)); // Get absolute days expired + if ($daysExpired > self::GRACE_PERIOD_DAYS) { + $license->markAsExpired(); + $result = new LicenseValidationResult(false, 'License expired', $license); + $this->cacheValidationResult($cacheKey, $result, 60); + + return $result; + } else { + // Within grace period - log warning but allow + Log::warning("License {$licenseKey} is expired but within grace period", [ + 'license_id' => $license->id, + 'days_expired' => $daysExpired, + 'grace_period_days' => self::GRACE_PERIOD_DAYS, + ]); + } + } + + // Check domain authorization + if ($domain && ! $this->isDomainAuthorized($license, $domain)) { + $result = new LicenseValidationResult( + false, + "Domain '{$domain}' is not authorized for this license", + $license, + [], + ['unauthorized_domain' => $domain] + ); + $this->cacheValidationResult($cacheKey, $result, 60); + + return $result; + } + + // Check usage limits + $usageCheck = $this->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + $result = new LicenseValidationResult( + false, + 'Usage limits exceeded: '.implode(', ', array_column($usageCheck['violations'], 'message')), + $license, + $usageCheck['violations'], + ['usage' => $usageCheck['usage']] + ); + $this->cacheValidationResult($cacheKey, $result, 30); // Cache limit violations for 30 seconds + + return $result; + } + + // Update validation timestamp + $this->refreshValidation($license); + + $result = new LicenseValidationResult( + true, + 'License is valid', + $license, + [], + [ + 'usage' => $usageCheck['usage'], + 'expires_at' => $license->expires_at?->toISOString(), + 'license_tier' => $license->license_tier, + 'features' => $license->features, + ] + ); + + $this->cacheValidationResult($cacheKey, $result, self::CACHE_TTL); + + return $result; + + } catch (\Exception $e) { + Log::error('License validation error', [ + 'license_key' => $licenseKey, + 'domain' => $domain, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return new LicenseValidationResult(false, 'License validation failed due to system error'); + } + } + + public function issueLicense(Organization $organization, array $config): EnterpriseLicense + { + $licenseKey = $this->generateLicenseKey($organization, $config); + + $license = EnterpriseLicense::create([ + 'organization_id' => $organization->id, + 'license_key' => $licenseKey, + 'license_type' => $config['license_type'] ?? 'subscription', + 'license_tier' => $config['license_tier'] ?? 'basic', + 'features' => $config['features'] ?? [], + 'limits' => $config['limits'] ?? [], + 'issued_at' => now(), + 'expires_at' => $config['expires_at'] ?? null, + 'authorized_domains' => $config['authorized_domains'] ?? [], + 'status' => 'active', + ]); + + Log::info('License issued', [ + 'license_id' => $license->id, + 'organization_id' => $organization->id, + 'license_type' => $license->license_type, + 'license_tier' => $license->license_tier, + ]); + + // Clear any cached validation results for this organization + $this->clearLicenseCache($licenseKey); + + return $license; + } + + public function revokeLicense(EnterpriseLicense $license): bool + { + $success = $license->revoke(); + + if ($success) { + Log::warning('License revoked', [ + 'license_id' => $license->id, + 'organization_id' => $license->organization_id, + 'license_key' => $license->license_key, + ]); + + $this->clearLicenseCache($license->license_key); + } + + return $success; + } + + public function suspendLicense(EnterpriseLicense $license, ?string $reason = null): bool + { + $success = $license->suspend(); + + if ($success) { + Log::warning('License suspended', [ + 'license_id' => $license->id, + 'organization_id' => $license->organization_id, + 'license_key' => $license->license_key, + 'reason' => $reason, + ]); + + $this->clearLicenseCache($license->license_key); + } + + return $success; + } + + public function reactivateLicense(EnterpriseLicense $license): bool + { + $success = $license->activate(); + + if ($success) { + Log::info('License reactivated', [ + 'license_id' => $license->id, + 'organization_id' => $license->organization_id, + 'license_key' => $license->license_key, + ]); + + $this->clearLicenseCache($license->license_key); + } + + return $success; + } + + public function checkUsageLimits(EnterpriseLicense $license): array + { + if (! $license->organization) { + return [ + 'within_limits' => false, + 'violations' => [['message' => 'Organization not found']], + 'usage' => [], + ]; + } + + $usage = $license->organization->getUsageMetrics(); + $limits = $license->limits ?? []; + $violations = []; + + foreach ($limits as $limitType => $limitValue) { + $currentUsage = $usage[$limitType] ?? 0; + if ($currentUsage > $limitValue) { + $violations[] = [ + 'type' => $limitType, + 'limit' => $limitValue, + 'current' => $currentUsage, + 'message' => ucfirst(str_replace('_', ' ', $limitType))." count ({$currentUsage}) exceeds limit ({$limitValue})", + ]; + } + } + + return [ + 'within_limits' => empty($violations), + 'violations' => $violations, + 'usage' => $usage, + 'limits' => $limits, + ]; + } + + public function generateLicenseKey(Organization $organization, array $config): string + { + // Create a unique identifier based on organization and timestamp + $payload = [ + 'org_id' => $organization->id, + 'timestamp' => now()->timestamp, + 'tier' => $config['license_tier'] ?? 'basic', + 'type' => $config['license_type'] ?? 'subscription', + 'random' => Str::random(8), + ]; + + // Create a hash of the payload + $hash = hash('sha256', json_encode($payload).config('app.key')); + + // Take first 32 characters and format as license key + $key = strtoupper(substr($hash, 0, self::LICENSE_KEY_LENGTH)); + + // Format as XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX + return implode('-', str_split($key, 4)); + } + + public function refreshValidation(EnterpriseLicense $license): bool + { + return $license->updateLastValidated(); + } + + public function isDomainAuthorized(EnterpriseLicense $license, string $domain): bool + { + return $license->isDomainAuthorized($domain); + } + + public function getUsageStatistics(EnterpriseLicense $license): array + { + $usageCheck = $this->checkUsageLimits($license); + $usage = $usageCheck['usage']; + $limits = $usageCheck['limits']; + + $statistics = []; + foreach ($usage as $type => $current) { + $limit = $limits[$type] ?? null; + $statistics[$type] = [ + 'current' => $current, + 'limit' => $limit, + 'percentage' => $limit ? round(($current / $limit) * 100, 2) : 0, + 'remaining' => $limit ? max(0, $limit - $current) : null, + 'unlimited' => $limit === null, + ]; + } + + return [ + 'statistics' => $statistics, + 'within_limits' => $usageCheck['within_limits'], + 'violations' => $usageCheck['violations'], + 'last_validated' => $license->last_validated_at?->toISOString(), + 'expires_at' => $license->expires_at?->toISOString(), + 'days_until_expiration' => $license->getDaysUntilExpiration(), + ]; + } + + private function cacheValidationResult(string $cacheKey, LicenseValidationResult $result, int $ttl): void + { + try { + Cache::put($cacheKey, [ + $result->isValid, + $result->getMessage(), + $result->getLicense(), + $result->getViolations(), + $result->getMetadata(), + ], $ttl); + } catch (\Exception $e) { + Log::warning('Failed to cache license validation result', [ + 'cache_key' => $cacheKey, + 'error' => $e->getMessage(), + ]); + } + } + + private function clearLicenseCache(string $licenseKey): void + { + try { + // Clear all cached validation results for this license key + $patterns = [ + "license_validation:{$licenseKey}:*", + ]; + + foreach ($patterns as $pattern) { + Cache::forget($pattern); + } + } catch (\Exception $e) { + Log::warning('Failed to clear license cache', [ + 'license_key' => $licenseKey, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Traits/LicenseValidation.php b/app/Traits/LicenseValidation.php new file mode 100644 index 00000000000..736b12767f2 --- /dev/null +++ b/app/Traits/LicenseValidation.php @@ -0,0 +1,288 @@ +getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $license = $organization->activeLicense; + if (! $license) { + return response()->json([ + 'error' => 'Valid license required for this feature', + 'feature' => $feature, + 'license_required' => true, + ], 403); + } + + if (! $license->hasFeature($feature)) { + return response()->json([ + 'error' => 'Feature not available in your license tier', + 'feature' => $feature, + 'current_tier' => $license->license_tier, + 'upgrade_required' => true, + ], 403); + } + + return null; // License is valid + } + + /** + * Check if the current organization is within usage limits for resource creation + */ + protected function validateUsageLimits(?string $resourceType = null): ?JsonResponse + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $license = $organization->activeLicense; + if (! $license) { + return response()->json(['error' => 'Valid license required'], 403); + } + + $licensingService = app(LicensingServiceInterface::class); + $usageCheck = $licensingService->checkUsageLimits($license); + + if (! $usageCheck['within_limits']) { + // Check if specific resource type is over limit + if ($resourceType) { + $resourceViolations = collect($usageCheck['violations']) + ->where('type', $resourceType) + ->first(); + + if ($resourceViolations) { + return response()->json([ + 'error' => "Cannot create {$resourceType}: limit exceeded", + 'limit' => $resourceViolations['limit'], + 'current' => $resourceViolations['current'], + 'resource_type' => $resourceType, + ], 403); + } + } + + return response()->json([ + 'error' => 'Usage limits exceeded', + 'violations' => $usageCheck['violations'], + 'current_usage' => $usageCheck['usage'], + 'limits' => $usageCheck['limits'], + ], 403); + } + + return null; // Within limits + } + + /** + * Check if server creation is allowed based on license + */ + protected function validateServerCreation(): ?JsonResponse + { + // Check server management feature + $featureCheck = $this->validateLicenseForFeature('server_management'); + if ($featureCheck) { + return $featureCheck; + } + + // Check server count limits + return $this->validateUsageLimits('servers'); + } + + /** + * Check if application deployment is allowed based on license + */ + protected function validateApplicationDeployment(): ?JsonResponse + { + // Check application deployment feature + $featureCheck = $this->validateLicenseForFeature('application_deployment'); + if ($featureCheck) { + return $featureCheck; + } + + // Check application count limits + return $this->validateUsageLimits('applications'); + } + + /** + * Check if domain management is allowed based on license + */ + protected function validateDomainManagement(): ?JsonResponse + { + // Check domain management feature + $featureCheck = $this->validateLicenseForFeature('domain_management'); + if ($featureCheck) { + return $featureCheck; + } + + // Check domain count limits + return $this->validateUsageLimits('domains'); + } + + /** + * Check if infrastructure provisioning is allowed based on license + */ + protected function validateInfrastructureProvisioning(): ?JsonResponse + { + // Check cloud provisioning feature + $featureCheck = $this->validateLicenseForFeature('cloud_provisioning'); + if ($featureCheck) { + return $featureCheck; + } + + // Check cloud provider limits + return $this->validateUsageLimits('cloud_providers'); + } + + /** + * Get license-based feature flags for the current organization + */ + protected function getLicenseFeatures(): array + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return []; + } + + $license = $organization->activeLicense; + if (! $license) { + return []; + } + + return [ + 'license_tier' => $license->license_tier, + 'features' => $license->features ?? [], + 'limits' => $license->limits ?? [], + 'expires_at' => $license->expires_at?->toISOString(), + 'is_trial' => $license->isTrial(), + 'days_until_expiration' => $license->getDaysUntilExpiration(), + ]; + } + + /** + * Get current organization from user context + */ + protected function getCurrentOrganization(): ?Organization + { + $user = Auth::user(); + if (! $user) { + return null; + } + + return $user->currentOrganization ?? $user->organizations()->first(); + } + + /** + * Check if a specific deployment option is available based on license + */ + protected function validateDeploymentOption(string $option): ?JsonResponse + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $license = $organization->activeLicense; + if (! $license) { + return response()->json(['error' => 'Valid license required'], 403); + } + + // Define deployment options by license tier + $tierOptions = [ + 'basic' => [ + 'docker_deployment', + 'basic_monitoring', + ], + 'professional' => [ + 'docker_deployment', + 'basic_monitoring', + 'advanced_monitoring', + 'blue_green_deployment', + 'auto_scaling', + 'backup_management', + ], + 'enterprise' => [ + 'docker_deployment', + 'basic_monitoring', + 'advanced_monitoring', + 'blue_green_deployment', + 'auto_scaling', + 'backup_management', + 'multi_region_deployment', + 'advanced_security', + 'compliance_reporting', + 'custom_integrations', + ], + ]; + + $availableOptions = $tierOptions[$license->license_tier] ?? []; + + if (! in_array($option, $availableOptions)) { + return response()->json([ + 'error' => "Deployment option '{$option}' not available in {$license->license_tier} tier", + 'available_options' => $availableOptions, + 'upgrade_required' => true, + ], 403); + } + + return null; // Option is available + } + + /** + * Add license information to API responses + */ + protected function addLicenseInfoToResponse(array $data): array + { + $licenseFeatures = $this->getLicenseFeatures(); + + return array_merge($data, [ + 'license_info' => $licenseFeatures, + ]); + } + + /** + * Check if the current license allows a specific resource limit + */ + protected function checkResourceLimit(string $resourceType, int $requestedCount = 1): ?JsonResponse + { + $organization = $this->getCurrentOrganization(); + if (! $organization) { + return response()->json(['error' => 'No organization context found'], 403); + } + + $license = $organization->activeLicense; + if (! $license) { + return response()->json(['error' => 'Valid license required'], 403); + } + + $usage = $organization->getUsageMetrics(); + $limits = $license->limits ?? []; + + $currentUsage = $usage[$resourceType] ?? 0; + $limit = $limits[$resourceType] ?? null; + + if ($limit !== null && ($currentUsage + $requestedCount) > $limit) { + return response()->json([ + 'error' => "Cannot create {$requestedCount} {$resourceType}: would exceed limit", + 'current_usage' => $currentUsage, + 'requested' => $requestedCount, + 'limit' => $limit, + 'available' => max(0, $limit - $currentUsage), + ], 403); + } + + return null; // Within limits + } +} diff --git a/config/licensing.php b/config/licensing.php new file mode 100644 index 00000000000..f02c5be21c5 --- /dev/null +++ b/config/licensing.php @@ -0,0 +1,169 @@ + env('LICENSE_GRACE_PERIOD_DAYS', 7), + + 'cache_ttl' => env('LICENSE_CACHE_TTL', 300), // 5 minutes + + 'rate_limits' => [ + 'basic' => [ + 'max_attempts' => 1000, + 'decay_minutes' => 60, + ], + 'professional' => [ + 'max_attempts' => 5000, + 'decay_minutes' => 60, + ], + 'enterprise' => [ + 'max_attempts' => 10000, + 'decay_minutes' => 60, + ], + ], + + 'features' => [ + 'server_provisioning' => 'Server Provisioning', + 'infrastructure_provisioning' => 'Infrastructure Provisioning', + 'terraform_integration' => 'Terraform Integration', + 'payment_processing' => 'Payment Processing', + 'domain_management' => 'Domain Management', + 'white_label_branding' => 'White Label Branding', + 'api_access' => 'API Access', + 'bulk_operations' => 'Bulk Operations', + 'advanced_monitoring' => 'Advanced Monitoring', + 'multi_cloud_support' => 'Multi-Cloud Support', + 'sso_integration' => 'SSO Integration', + 'audit_logging' => 'Audit Logging', + 'backup_management' => 'Backup Management', + 'ssl_management' => 'SSL Management', + 'load_balancing' => 'Load Balancing', + ], + + 'default_limits' => [ + 'basic' => [ + 'max_servers' => 5, + 'max_applications' => 10, + 'max_domains' => 3, + 'max_users' => 3, + 'max_cloud_providers' => 1, + 'max_concurrent_provisioning' => 1, + ], + 'professional' => [ + 'max_servers' => 25, + 'max_applications' => 100, + 'max_domains' => 25, + 'max_users' => 10, + 'max_cloud_providers' => 3, + 'max_concurrent_provisioning' => 3, + ], + 'enterprise' => [ + 'max_servers' => null, // unlimited + 'max_applications' => null, + 'max_domains' => null, + 'max_users' => null, + 'max_cloud_providers' => null, + 'max_concurrent_provisioning' => 10, + ], + ], + + 'default_features' => [ + 'basic' => [ + 'server_provisioning', + 'api_access', + ], + 'professional' => [ + 'server_provisioning', + 'infrastructure_provisioning', + 'terraform_integration', + 'payment_processing', + 'domain_management', + 'api_access', + 'bulk_operations', + 'ssl_management', + ], + 'enterprise' => [ + 'server_provisioning', + 'infrastructure_provisioning', + 'terraform_integration', + 'payment_processing', + 'domain_management', + 'white_label_branding', + 'api_access', + 'bulk_operations', + 'advanced_monitoring', + 'multi_cloud_support', + 'sso_integration', + 'audit_logging', + 'backup_management', + 'ssl_management', + 'load_balancing', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Critical Routes Configuration + |-------------------------------------------------------------------------- + | + | Define which routes require specific license features + | + */ + + 'route_features' => [ + // Server management routes + 'servers.create' => ['server_provisioning'], + 'servers.store' => ['server_provisioning'], + 'servers.provision' => ['server_provisioning', 'infrastructure_provisioning'], + + // Infrastructure provisioning routes + 'infrastructure.*' => ['infrastructure_provisioning', 'terraform_integration'], + 'terraform.*' => ['terraform_integration'], + 'cloud-providers.*' => ['infrastructure_provisioning'], + + // Payment processing routes + 'payments.*' => ['payment_processing'], + 'billing.*' => ['payment_processing'], + 'subscriptions.*' => ['payment_processing'], + + // Domain management routes + 'domains.*' => ['domain_management'], + 'dns.*' => ['domain_management'], + + // White label routes + 'branding.*' => ['white_label_branding'], + 'white-label.*' => ['white_label_branding'], + + // Advanced features + 'monitoring.advanced' => ['advanced_monitoring'], + 'audit.*' => ['audit_logging'], + 'sso.*' => ['sso_integration'], + 'load-balancer.*' => ['load_balancing'], + ], + + /* + |-------------------------------------------------------------------------- + | Middleware Configuration + |-------------------------------------------------------------------------- + | + | Configure which middleware to apply to different route groups + | + */ + + 'middleware_groups' => [ + 'basic_license' => ['auth', 'license'], + 'api_license' => ['auth:sanctum', 'api.license'], + 'server_provisioning' => ['auth', 'license', 'server.provision'], + 'infrastructure' => ['auth', 'license:infrastructure_provisioning,terraform_integration'], + 'payments' => ['auth', 'license:payment_processing'], + 'domains' => ['auth', 'license:domain_management'], + 'white_label' => ['auth', 'license:white_label_branding'], + ], +]; diff --git a/database/factories/EnterpriseLicenseFactory.php b/database/factories/EnterpriseLicenseFactory.php new file mode 100644 index 00000000000..f993f4ca3dd --- /dev/null +++ b/database/factories/EnterpriseLicenseFactory.php @@ -0,0 +1,132 @@ + + */ +class EnterpriseLicenseFactory extends Factory +{ + protected $model = EnterpriseLicense::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'license_key' => 'CL-'.Str::upper(Str::random(32)), + 'license_type' => $this->faker->randomElement(['perpetual', 'subscription', 'trial']), + 'license_tier' => $this->faker->randomElement(['basic', 'professional', 'enterprise']), + 'features' => [ + 'infrastructure_provisioning', + 'domain_management', + 'white_label_branding', + ], + 'limits' => [ + 'max_users' => $this->faker->numberBetween(5, 100), + 'max_servers' => $this->faker->numberBetween(10, 500), + 'max_domains' => $this->faker->numberBetween(5, 50), + ], + 'issued_at' => now(), + 'expires_at' => now()->addYear(), + 'last_validated_at' => now(), + 'authorized_domains' => [ + $this->faker->domainName(), + $this->faker->domainName(), + ], + 'status' => 'active', + ]; + } + + /** + * Indicate that the license is expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + 'status' => 'expired', + ]); + } + + /** + * Indicate that the license is suspended. + */ + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'suspended', + ]); + } + + /** + * Indicate that the license is revoked. + */ + public function revoked(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'revoked', + ]); + } + + /** + * Indicate that the license is a trial. + */ + public function trial(): static + { + return $this->state(fn (array $attributes) => [ + 'license_type' => 'trial', + 'expires_at' => now()->addDays(30), + ]); + } + + /** + * Indicate that the license is perpetual. + */ + public function perpetual(): static + { + return $this->state(fn (array $attributes) => [ + 'license_type' => 'perpetual', + 'expires_at' => null, + ]); + } + + /** + * Set specific features for the license. + */ + public function withFeatures(array $features): static + { + return $this->state(fn (array $attributes) => [ + 'features' => $features, + ]); + } + + /** + * Set specific limits for the license. + */ + public function withLimits(array $limits): static + { + return $this->state(fn (array $attributes) => [ + 'limits' => $limits, + ]); + } + + /** + * Set authorized domains for the license. + */ + public function withDomains(array $domains): static + { + return $this->state(fn (array $attributes) => [ + 'authorized_domains' => $domains, + ]); + } +} diff --git a/database/migrations/2025_08_26_225529_create_enterprise_licenses_table.php b/database/migrations/2025_08_26_225529_create_enterprise_licenses_table.php new file mode 100644 index 00000000000..87892b7cf97 --- /dev/null +++ b/database/migrations/2025_08_26_225529_create_enterprise_licenses_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->uuid('organization_id'); + $table->string('license_key')->unique(); + $table->string('license_type'); // perpetual, subscription, trial + $table->string('license_tier'); // basic, professional, enterprise + $table->json('features')->default('{}'); + $table->json('limits')->default('{}'); // user limits, domain limits, resource limits + $table->timestamp('issued_at'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_validated_at')->nullable(); + $table->json('authorized_domains')->default('[]'); + $table->enum('status', ['active', 'expired', 'suspended', 'revoked'])->default('active'); + $table->timestamps(); + + $table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade'); + $table->index(['status', 'expires_at']); + $table->index('organization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('enterprise_licenses'); + } +};