From 0fdfda76d44c9d60a74c486d71293aec6fed4445 Mon Sep 17 00:00:00 2001 From: Ian Jones <-g> Date: Thu, 27 Nov 2025 09:24:15 +0000 Subject: [PATCH] feat: Implement organization hierarchy and multi-tenancy system - Organization model with hierarchical structure support - OrganizationUser model for role-based access control - CloudProviderCredential model for cloud provider integration - TerraformDeployment model for infrastructure management - OrganizationService for business logic - ResourceProvisioningService for cloud resource management - OrganizationContext helper for context-aware operations - OrganizationController for REST API management - EnsureOrganizationContext middleware for enforcement - Livewire Components: - OrganizationManager: Full organization CRUD UI - OrganizationHierarchy: Visual hierarchy display - OrganizationSwitcher: User-facing organization switching - UserManagement: Role-based user management within organizations - Database migrations for all models and relationships - Test factories for comprehensive testing --- .../OrganizationServiceInterface.php | 70 +++ app/Helpers/OrganizationContext.php | 210 +++++++ .../Api/OrganizationController.php | 341 ++++++++++ .../Middleware/EnsureOrganizationContext.php | 58 ++ .../Organization/OrganizationHierarchy.php | 192 ++++++ .../Organization/OrganizationManager.php | 366 +++++++++++ .../Organization/OrganizationSwitcher.php | 96 +++ app/Livewire/Organization/UserManagement.php | 300 +++++++++ app/Models/CloudProviderCredential.php | 338 ++++++++++ app/Models/Organization.php | 229 +++++++ app/Models/OrganizationUser.php | 30 + app/Models/TerraformDeployment.php | 399 ++++++++++++ app/Services/OrganizationService.php | 589 ++++++++++++++++++ app/Services/ResourceProvisioningService.php | 335 ++++++++++ ...reate_cloud_provider_credentials_table.php | 0 ...8_26_224900_create_organizations_table.php | 39 ++ ...225351_create_organization_users_table.php | 37 ++ ...3_add_organization_id_to_servers_table.php | 32 + ...017_create_terraform_deployments_table.php | 39 ++ 19 files changed, 3700 insertions(+) create mode 100644 app/Contracts/OrganizationServiceInterface.php create mode 100644 app/Helpers/OrganizationContext.php create mode 100644 app/Http/Controllers/Api/OrganizationController.php create mode 100644 app/Http/Middleware/EnsureOrganizationContext.php create mode 100644 app/Livewire/Organization/OrganizationHierarchy.php create mode 100644 app/Livewire/Organization/OrganizationManager.php create mode 100644 app/Livewire/Organization/OrganizationSwitcher.php create mode 100644 app/Livewire/Organization/UserManagement.php create mode 100644 app/Models/CloudProviderCredential.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/OrganizationUser.php create mode 100644 app/Models/TerraformDeployment.php create mode 100644 app/Services/OrganizationService.php create mode 100644 app/Services/ResourceProvisioningService.php create mode 100644 database/migrations/2025_08_26_224815_create_cloud_provider_credentials_table.php create mode 100644 database/migrations/2025_08_26_224900_create_organizations_table.php create mode 100644 database/migrations/2025_08_26_225351_create_organization_users_table.php create mode 100644 database/migrations/2025_08_26_225903_add_organization_id_to_servers_table.php create mode 100644 database/migrations/2025_08_26_230017_create_terraform_deployments_table.php diff --git a/app/Contracts/OrganizationServiceInterface.php b/app/Contracts/OrganizationServiceInterface.php new file mode 100644 index 00000000000..cb7dd933f77 --- /dev/null +++ b/app/Contracts/OrganizationServiceInterface.php @@ -0,0 +1,70 @@ +currentOrganization; + } + + /** + * Get the current organization ID for the authenticated user + */ + public static function currentId(): ?string + { + return static::current()?->id; + } + + /** + * Check if the current user can perform an action in their current organization + */ + public static function can(string $action, $resource = null): bool + { + $user = Auth::user(); + $organization = static::current(); + + if (! $user || ! $organization) { + return false; + } + + return app(\App\Contracts\OrganizationServiceInterface::class) + ->canUserPerformAction($user, $organization, $action, $resource); + } + + /** + * Check if the current organization has a specific feature + */ + public static function hasFeature(string $feature): bool + { + return static::current()?->hasFeature($feature) ?? false; + } + + /** + * Get usage metrics for the current organization + */ + public static function getUsage(): array + { + $organization = static::current(); + + if (! $organization) { + return []; + } + + return app(\App\Contracts\OrganizationServiceInterface::class) + ->getOrganizationUsage($organization); + } + + /** + * Check if the current organization is within its limits + */ + public static function isWithinLimits(): bool + { + return static::current()?->isWithinLimits() ?? false; + } + + /** + * Get the hierarchy type of the current organization + */ + public static function getHierarchyType(): ?string + { + return static::current()?->hierarchy_type; + } + + /** + * Check if the current organization is of a specific hierarchy type + */ + public static function isHierarchyType(string $type): bool + { + return static::getHierarchyType() === $type; + } + + /** + * Get all organizations accessible by the current user + */ + public static function getUserOrganizations(): \Illuminate\Database\Eloquent\Collection + { + $user = Auth::user(); + + if (! $user) { + return collect(); + } + + return app(\App\Contracts\OrganizationServiceInterface::class) + ->getUserOrganizations($user); + } + + /** + * Switch to a different organization + */ + public static function switchTo(Organization $organization): bool + { + $user = Auth::user(); + + if (! $user) { + return false; + } + + try { + app(\App\Contracts\OrganizationServiceInterface::class) + ->switchUserOrganization($user, $organization); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get the organization hierarchy starting from the current organization + */ + public static function getHierarchy(): array + { + $organization = static::current(); + + if (! $organization) { + return []; + } + + return app(\App\Contracts\OrganizationServiceInterface::class) + ->getOrganizationHierarchy($organization); + } + + /** + * Check if the current user is an owner of the current organization + */ + public static function isOwner(): bool + { + $user = Auth::user(); + $organization = static::current(); + + if (! $user || ! $organization) { + return false; + } + + $userOrg = $organization->users()->where('user_id', $user->id)->first(); + + return $userOrg && $userOrg->pivot->role === 'owner'; + } + + /** + * Check if the current user is an admin of the current organization + */ + public static function isAdmin(): bool + { + $user = Auth::user(); + $organization = static::current(); + + if (! $user || ! $organization) { + return false; + } + + $userOrg = $organization->users()->where('user_id', $user->id)->first(); + + return $userOrg && in_array($userOrg->pivot->role, ['owner', 'admin']); + } + + /** + * Get the current user's role in the current organization + */ + public static function getUserRole(): ?string + { + $user = Auth::user(); + $organization = static::current(); + + if (! $user || ! $organization) { + return null; + } + + $userOrg = $organization->users()->where('user_id', $user->id)->first(); + + return $userOrg?->pivot->role; + } + + /** + * Get the current user's permissions in the current organization + */ + public static function getUserPermissions(): array + { + $user = Auth::user(); + $organization = static::current(); + + if (! $user || ! $organization) { + return []; + } + + $userOrg = $organization->users()->where('user_id', $user->id)->first(); + + return $userOrg?->pivot->permissions ?? []; + } +} diff --git a/app/Http/Controllers/Api/OrganizationController.php b/app/Http/Controllers/Api/OrganizationController.php new file mode 100644 index 00000000000..a9227321a03 --- /dev/null +++ b/app/Http/Controllers/Api/OrganizationController.php @@ -0,0 +1,341 @@ +organizationService = $organizationService; + } + + public function index() + { + try { + $currentOrganization = OrganizationContext::current(); + $organizations = $this->getAccessibleOrganizations(); + $hierarchyTypes = $this->getHierarchyTypes(); + $availableParents = $this->getAvailableParents(); + + return response()->json([ + 'organizations' => $organizations, + 'currentOrganization' => $currentOrganization, + 'hierarchyTypes' => $hierarchyTypes, + 'availableParents' => $availableParents, + ]); + } catch (\Exception $e) { + \Log::error('Organization index error: '.$e->getMessage()); + + // Return basic data even if there's an error + return response()->json([ + 'organizations' => [], + 'currentOrganization' => null, + 'hierarchyTypes' => [], + 'availableParents' => [], + ]); + } + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'hierarchy_type' => 'required|in:top_branch,master_branch,sub_user,end_user', + 'parent_organization_id' => 'nullable|exists:organizations,id', + 'is_active' => 'boolean', + ]); + + try { + $parent = $request->parent_organization_id + ? Organization::find($request->parent_organization_id) + : null; + + $organization = $this->organizationService->createOrganization([ + 'name' => $request->name, + 'hierarchy_type' => $request->hierarchy_type, + 'is_active' => $request->is_active ?? true, + 'owner_id' => Auth::id(), + ], $parent); + + return response()->json([ + 'message' => 'Organization created successfully', + 'organization' => $organization, + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to create organization: '.$e->getMessage(), + ], 400); + } + } + + public function update(Request $request, Organization $organization) + { + if (! OrganizationContext::can('manage_organization', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $request->validate([ + 'name' => 'required|string|max:255', + 'is_active' => 'boolean', + ]); + + try { + $this->organizationService->updateOrganization($organization, [ + 'name' => $request->name, + 'is_active' => $request->is_active ?? true, + ]); + + return response()->json([ + 'message' => 'Organization updated successfully', + 'organization' => $organization->fresh(), + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to update organization: '.$e->getMessage(), + ], 400); + } + } + + public function switchOrganization(Request $request) + { + $request->validate([ + 'organization_id' => 'required|exists:organizations,id', + ]); + + try { + $organization = Organization::findOrFail($request->organization_id); + $this->organizationService->switchUserOrganization(Auth::user(), $organization); + + return response()->json([ + 'message' => 'Switched to '.$organization->name, + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to switch organization: '.$e->getMessage(), + ], 400); + } + } + + public function hierarchy(Organization $organization) + { + if (! OrganizationContext::can('view_organization', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + try { + $hierarchy = $this->organizationService->getOrganizationHierarchy($organization); + + return response()->json([ + 'hierarchy' => $hierarchy, + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to load hierarchy: '.$e->getMessage(), + ], 400); + } + } + + public function users(Organization $organization) + { + if (! OrganizationContext::can('view_organization', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $users = $organization->users()->get()->map(function ($user) { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->pivot->role, + 'permissions' => $user->pivot->permissions ?? [], + 'is_active' => $user->pivot->is_active, + ]; + }); + + return response()->json([ + 'users' => $users, + ]); + } + + public function addUser(Request $request, Organization $organization) + { + if (! OrganizationContext::can('manage_users', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $request->validate([ + 'email' => 'required|email|exists:users,email', + 'role' => 'required|in:owner,admin,member,viewer', + 'permissions' => 'array', + ]); + + try { + $user = User::where('email', $request->email)->firstOrFail(); + + // Check if user is already in organization + if ($organization->users()->where('user_id', $user->id)->exists()) { + return response()->json([ + 'message' => 'User is already a member of this organization.', + ], 400); + } + + $this->organizationService->attachUserToOrganization( + $organization, + $user, + $request->role, + $request->permissions ?? [] + ); + + return response()->json([ + 'message' => 'User added to organization successfully', + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to add user: '.$e->getMessage(), + ], 400); + } + } + + public function updateUser(Request $request, Organization $organization, User $user) + { + if (! OrganizationContext::can('manage_users', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $request->validate([ + 'role' => 'required|in:owner,admin,member,viewer', + 'permissions' => 'array', + ]); + + try { + $this->organizationService->updateUserRole( + $organization, + $user, + $request->role, + $request->permissions ?? [] + ); + + return response()->json([ + 'message' => 'User updated successfully', + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to update user: '.$e->getMessage(), + ], 400); + } + } + + public function removeUser(Organization $organization, User $user) + { + if (! OrganizationContext::can('manage_users', $organization)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + try { + $this->organizationService->detachUserFromOrganization($organization, $user); + + return response()->json([ + 'message' => 'User removed from organization successfully', + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to remove user: '.$e->getMessage(), + ], 400); + } + } + + public function rolesAndPermissions() + { + return response()->json([ + 'roles' => [ + 'owner' => 'Owner', + 'admin' => 'Administrator', + 'member' => 'Member', + 'viewer' => 'Viewer', + ], + 'permissions' => [ + 'view_organization' => 'View Organization', + 'edit_organization' => 'Edit Organization', + 'manage_users' => 'Manage Users', + 'view_hierarchy' => 'View Hierarchy', + 'switch_organization' => 'Switch Organization', + ], + ]); + } + + protected function getAccessibleOrganizations() + { + $user = Auth::user(); + $userOrganizations = $this->organizationService->getUserOrganizations($user); + + // If user is owner/admin of current org, also show child organizations + if (OrganizationContext::isAdmin()) { + $currentOrg = OrganizationContext::current(); + if ($currentOrg) { + $children = $currentOrg->getAllDescendants(); + $userOrganizations = $userOrganizations->merge($children); + } + } + + return $userOrganizations->unique('id')->values(); + } + + protected function getHierarchyTypes() + { + $currentOrg = OrganizationContext::current(); + + if (! $currentOrg) { + // In development/testing, allow users to create top-level organizations + // In production, you might want to restrict this based on user permissions + if (app()->environment(['local', 'testing', 'development'])) { + return [ + 'top_branch' => 'Top Branch', + 'master_branch' => 'Master Branch', + 'sub_user' => 'Sub User', + 'end_user' => 'End User', + ]; + } + + // In production, default to end_user for users without organizations + return ['end_user' => 'End User']; + } + + $allowedTypes = []; + + switch ($currentOrg->hierarchy_type) { + case 'top_branch': + $allowedTypes['master_branch'] = 'Master Branch'; + break; + case 'master_branch': + $allowedTypes['sub_user'] = 'Sub User'; + break; + case 'sub_user': + $allowedTypes['end_user'] = 'End User'; + break; + } + + return $allowedTypes; + } + + protected function getAvailableParents() + { + $user = Auth::user(); + + return $this->organizationService->getUserOrganizations($user) + ->filter(function ($org) { + $userOrg = $org->users()->where('user_id', Auth::id())->first(); + + return $userOrg && in_array($userOrg->pivot->role, ['owner', 'admin']); + })->values(); + } +} diff --git a/app/Http/Middleware/EnsureOrganizationContext.php b/app/Http/Middleware/EnsureOrganizationContext.php new file mode 100644 index 00000000000..166770280a8 --- /dev/null +++ b/app/Http/Middleware/EnsureOrganizationContext.php @@ -0,0 +1,58 @@ +current_organization_id) { + $organizations = $this->organizationService->getUserOrganizations($user); + + if ($organizations->isNotEmpty()) { + $firstOrg = $organizations->first(); + $this->organizationService->switchUserOrganization($user, $firstOrg); + $user->refresh(); + } + } + + // Verify user still has access to their current organization + if ($user->current_organization_id) { + $currentOrg = $user->currentOrganization; + + if (! $currentOrg || ! $this->organizationService->canUserPerformAction($user, $currentOrg, 'view_organization')) { + // User lost access, switch to another organization or clear context + $organizations = $this->organizationService->getUserOrganizations($user); + + if ($organizations->isNotEmpty()) { + $firstOrg = $organizations->first(); + $this->organizationService->switchUserOrganization($user, $firstOrg); + } else { + $user->update(['current_organization_id' => null]); + } + } + } + + return $next($request); + } +} diff --git a/app/Livewire/Organization/OrganizationHierarchy.php b/app/Livewire/Organization/OrganizationHierarchy.php new file mode 100644 index 00000000000..71d5ea10c2e --- /dev/null +++ b/app/Livewire/Organization/OrganizationHierarchy.php @@ -0,0 +1,192 @@ +rootOrganization = $organization ?? OrganizationContext::current(); + + if ($this->rootOrganization) { + $this->loadHierarchy(); + } + } + + public function render() + { + return view('livewire.organization.organization-hierarchy'); + } + + public function loadHierarchy() + { + if (! $this->rootOrganization) { + return; + } + + // Check permissions + if (! OrganizationContext::can('view_organization', $this->rootOrganization)) { + session()->flash('error', 'You do not have permission to view this organization hierarchy.'); + + return; + } + + try { + $organizationService = app(OrganizationServiceInterface::class); + $this->hierarchyData = $organizationService->getOrganizationHierarchy($this->rootOrganization); + + // Expand the root node by default + $this->expandedNodes[$this->rootOrganization->id] = true; + + } catch (\Exception $e) { + \Log::error('Failed to load organization hierarchy', [ + 'organization_id' => $this->rootOrganization->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Provide fallback data structure + $this->hierarchyData = [ + 'id' => $this->rootOrganization->id, + 'name' => $this->rootOrganization->name, + 'hierarchy_type' => $this->rootOrganization->hierarchy_type, + 'hierarchy_level' => $this->rootOrganization->hierarchy_level, + 'is_active' => $this->rootOrganization->is_active, + 'user_count' => $this->rootOrganization->users()->count(), + 'children' => [], + ]; + + session()->flash('error', 'Failed to load complete organization hierarchy. Showing basic information only.'); + } + } + + public function toggleNode($organizationId) + { + try { + // Validate organization ID + if (! is_numeric($organizationId) && ! is_string($organizationId)) { + throw new \InvalidArgumentException('Invalid organization ID format'); + } + + if (isset($this->expandedNodes[$organizationId])) { + unset($this->expandedNodes[$organizationId]); + } else { + $this->expandedNodes[$organizationId] = true; + } + } catch (\Exception $e) { + \Log::error('Failed to toggle organization node', [ + 'organization_id' => $organizationId, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to toggle organization view.'); + } + } + + public function switchToOrganization($organizationId) + { + try { + // Validate organization ID + if (! is_numeric($organizationId) && ! is_string($organizationId)) { + throw new \InvalidArgumentException('Invalid organization ID format'); + } + + $organization = Organization::findOrFail($organizationId); + + if (! OrganizationContext::can('switch_organization', $organization)) { + session()->flash('error', 'You do not have permission to switch to this organization.'); + + return; + } + + $organizationService = app(OrganizationServiceInterface::class); + $organizationService->switchUserOrganization(auth()->user(), $organization); + + session()->flash('success', 'Switched to '.$organization->name); + + return redirect()->to('/dashboard'); + + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + \Log::error('Organization not found for switch', [ + 'organization_id' => $organizationId, + 'user_id' => auth()->id(), + ]); + session()->flash('error', 'Organization not found.'); + } catch (\Exception $e) { + \Log::error('Failed to switch organization', [ + 'organization_id' => $organizationId, + 'user_id' => auth()->id(), + 'error' => $e->getMessage(), + ]); + session()->flash('error', 'Failed to switch organization. Please try again.'); + } + } + + public function getOrganizationUsage($organizationId) + { + try { + $organization = Organization::findOrFail($organizationId); + $organizationService = app(OrganizationServiceInterface::class); + + return $organizationService->getOrganizationUsage($organization); + } catch (\Exception $e) { + return []; + } + } + + public function isNodeExpanded($organizationId) + { + return isset($this->expandedNodes[$organizationId]); + } + + public function canManageOrganization($organizationId) + { + try { + $organization = Organization::findOrFail($organizationId); + + return OrganizationContext::can('manage_organization', $organization); + } catch (\Exception $e) { + return false; + } + } + + public function getHierarchyTypeIcon($hierarchyType) + { + return match ($hierarchyType) { + 'top_branch' => '🏢', + 'master_branch' => '🏬', + 'sub_user' => '👥', + 'end_user' => '👤', + default => '📁' + }; + } + + public function getHierarchyTypeColor($hierarchyType) + { + return match ($hierarchyType) { + 'top_branch' => 'bg-purple-100 text-purple-800', + 'master_branch' => 'bg-blue-100 text-blue-800', + 'sub_user' => 'bg-green-100 text-green-800', + 'end_user' => 'bg-gray-100 text-gray-800', + default => 'bg-gray-100 text-gray-800' + }; + } + + public function refreshHierarchy() + { + $this->loadHierarchy(); + session()->flash('success', 'Organization hierarchy refreshed.'); + } +} diff --git a/app/Livewire/Organization/OrganizationManager.php b/app/Livewire/Organization/OrganizationManager.php new file mode 100644 index 00000000000..9b8d6c63476 --- /dev/null +++ b/app/Livewire/Organization/OrganizationManager.php @@ -0,0 +1,366 @@ + 'required|string|max:255', + 'hierarchy_type' => 'required|in:top_branch,master_branch,sub_user,end_user', + 'parent_organization_id' => 'nullable|exists:organizations,id', + 'is_active' => 'boolean', + ]; + + public function mount() + { + // Ensure user has permission to manage organizations + if (! OrganizationContext::can('manage_organizations')) { + abort(403, 'You do not have permission to manage organizations.'); + } + } + + public function render() + { + $currentOrganization = OrganizationContext::current(); + + // Get organizations based on user's hierarchy level + $organizations = $this->getAccessibleOrganizations(); + + $users = $this->selectedOrganization + ? $this->selectedOrganization->users()->paginate(10, ['*'], 'users') + : collect(); + + return view('livewire.organization.organization-manager', [ + 'organizations' => $organizations, + 'users' => $users, + 'currentOrganization' => $currentOrganization, + 'hierarchyTypes' => $this->getHierarchyTypes(), + 'availableParents' => $this->getAvailableParents(), + ]); + } + + public function createOrganization() + { + $this->validate(); + + try { + $organizationService = app(OrganizationServiceInterface::class); + + $parent = $this->parent_organization_id + ? Organization::find($this->parent_organization_id) + : null; + + $organization = $organizationService->createOrganization([ + 'name' => $this->name, + 'hierarchy_type' => $this->hierarchy_type, + 'is_active' => $this->is_active, + 'owner_id' => Auth::id(), + ], $parent); + + $this->resetForm(); + $this->showCreateForm = false; + + session()->flash('success', 'Organization created successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to create organization: '.$e->getMessage()); + } + } + + public function editOrganization(Organization $organization) + { + // Check permissions + if (! OrganizationContext::can('manage_organization', $organization)) { + session()->flash('error', 'You do not have permission to edit this organization.'); + + return; + } + + $this->selectedOrganization = $organization; + $this->name = $organization->name; + $this->hierarchy_type = $organization->hierarchy_type; + $this->parent_organization_id = $organization->parent_organization_id; + $this->is_active = $organization->is_active; + $this->showEditForm = true; + } + + public function updateOrganization() + { + $this->validate(); + + try { + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->updateOrganization($this->selectedOrganization, [ + 'name' => $this->name, + 'hierarchy_type' => $this->hierarchy_type, + 'parent_organization_id' => $this->parent_organization_id, + 'is_active' => $this->is_active, + ]); + + $this->resetForm(); + $this->showEditForm = false; + + session()->flash('success', 'Organization updated successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to update organization: '.$e->getMessage()); + } + } + + public function switchToOrganization(Organization $organization) + { + try { + $organizationService = app(OrganizationServiceInterface::class); + $organizationService->switchUserOrganization(Auth::user(), $organization); + + session()->flash('success', 'Switched to '.$organization->name); + + return redirect()->to('/dashboard'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to switch organization: '.$e->getMessage()); + } + } + + public function manageUsers(Organization $organization) + { + if (! OrganizationContext::can('manage_users', $organization)) { + session()->flash('error', 'You do not have permission to manage users for this organization.'); + + return; + } + + $this->selectedOrganization = $organization; + $this->showUserManagement = true; + } + + public function addUserToOrganization() + { + $this->validate([ + 'selectedUser' => 'required|exists:users,id', + 'userRole' => 'required|in:owner,admin,member,viewer', + ]); + + try { + $organizationService = app(OrganizationServiceInterface::class); + $user = User::find($this->selectedUser); + + $organizationService->attachUserToOrganization( + $this->selectedOrganization, + $user, + $this->userRole, + $this->userPermissions + ); + + $this->selectedUser = null; + $this->userRole = 'member'; + $this->userPermissions = []; + + session()->flash('success', 'User added to organization successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to add user: '.$e->getMessage()); + } + } + + public function removeUserFromOrganization(User $user) + { + try { + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->detachUserFromOrganization($this->selectedOrganization, $user); + + session()->flash('success', 'User removed from organization successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to remove user: '.$e->getMessage()); + } + } + + public function viewHierarchy(Organization $organization) + { + if (! OrganizationContext::can('view_organization', $organization)) { + session()->flash('error', 'You do not have permission to view this organization hierarchy.'); + + return; + } + + $this->selectedOrganization = $organization; + $this->showHierarchyView = true; + } + + public function getOrganizationHierarchy(Organization $organization) + { + $organizationService = app(OrganizationServiceInterface::class); + + return $organizationService->getOrganizationHierarchy($organization); + } + + public function getOrganizationUsage(Organization $organization) + { + $organizationService = app(OrganizationServiceInterface::class); + + return $organizationService->getOrganizationUsage($organization); + } + + public function deleteOrganization(Organization $organization) + { + if (! OrganizationContext::can('delete_organization', $organization)) { + session()->flash('error', 'You do not have permission to delete this organization.'); + + return; + } + + try { + $organizationService = app(OrganizationServiceInterface::class); + $organizationService->deleteOrganization($organization); + + session()->flash('success', 'Organization deleted successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to delete organization: '.$e->getMessage()); + } + } + + public function updateUserRole(User $user, string $newRole) + { + $this->validate([ + 'newRole' => 'required|in:owner,admin,member,viewer', + ]); + + try { + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->updateUserRole( + $this->selectedOrganization, + $user, + $newRole + ); + + session()->flash('success', 'User role updated successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to update user role: '.$e->getMessage()); + } + } + + protected function getAccessibleOrganizations() + { + $organizationService = app(OrganizationServiceInterface::class); + $user = Auth::user(); + + // Get all organizations the user has access to + $userOrganizations = $organizationService->getUserOrganizations($user); + + // If user is owner/admin of current org, also show child organizations + if (OrganizationContext::isAdmin()) { + $currentOrg = OrganizationContext::current(); + if ($currentOrg) { + $children = $currentOrg->getAllDescendants(); + $userOrganizations = $userOrganizations->merge($children); + } + } + + return $userOrganizations->unique('id'); + } + + protected function getHierarchyTypes() + { + $currentOrg = OrganizationContext::current(); + + if (! $currentOrg) { + return ['end_user' => 'End User']; + } + + // Based on current organization type, determine what can be created + $allowedTypes = []; + + switch ($currentOrg->hierarchy_type) { + case 'top_branch': + $allowedTypes['master_branch'] = 'Master Branch'; + break; + case 'master_branch': + $allowedTypes['sub_user'] = 'Sub User'; + break; + case 'sub_user': + $allowedTypes['end_user'] = 'End User'; + break; + } + + return $allowedTypes; + } + + protected function getAvailableParents() + { + $user = Auth::user(); + $organizationService = app(OrganizationServiceInterface::class); + + return $organizationService->getUserOrganizations($user) + ->filter(function ($org) { + // Can only create children if user is owner/admin + $userOrg = $org->users()->where('user_id', Auth::id())->first(); + + return $userOrg && in_array($userOrg->pivot->role, ['owner', 'admin']); + }); + } + + protected function resetForm() + { + $this->name = ''; + $this->hierarchy_type = 'end_user'; + $this->parent_organization_id = null; + $this->is_active = true; + $this->selectedOrganization = null; + } + + public function openCreateForm() + { + $this->showCreateForm = true; + } + + public function closeModals() + { + $this->showCreateForm = false; + $this->showEditForm = false; + $this->showUserManagement = false; + $this->showHierarchyView = false; + $this->resetForm(); + } +} diff --git a/app/Livewire/Organization/OrganizationSwitcher.php b/app/Livewire/Organization/OrganizationSwitcher.php new file mode 100644 index 00000000000..c5d78b72304 --- /dev/null +++ b/app/Livewire/Organization/OrganizationSwitcher.php @@ -0,0 +1,96 @@ +currentOrganization = OrganizationContext::current(); + $this->selectedOrganizationId = $this->currentOrganization?->id ?? ''; + $this->loadUserOrganizations(); + } + + public function render() + { + return view('livewire.organization.organization-switcher'); + } + + public function loadUserOrganizations() + { + try { + $this->userOrganizations = OrganizationContext::getUserOrganizations(); + } catch (\Exception $e) { + session()->flash('error', 'Failed to load organizations: '.$e->getMessage()); + $this->userOrganizations = collect(); + } + } + + public function updatedSelectedOrganizationId() + { + if ($this->selectedOrganizationId && $this->selectedOrganizationId !== 'default') { + $this->switchToOrganization($this->selectedOrganizationId); + } + } + + public function switchToOrganization($organizationId) + { + if (! $organizationId || $organizationId === 'default') { + return; + } + + try { + $organization = Organization::findOrFail($organizationId); + + // Check if user has access to this organization + if (! $this->userOrganizations->contains('id', $organizationId)) { + session()->flash('error', 'You do not have access to this organization.'); + + return; + } + + $organizationService = app(OrganizationServiceInterface::class); + $organizationService->switchUserOrganization(auth()->user(), $organization); + + session()->flash('success', 'Switched to '.$organization->name); + + // Refresh the page to update the context + return redirect()->to(request()->url()); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to switch organization: '.$e->getMessage()); + + // Reset to current organization + $this->selectedOrganizationId = $this->currentOrganization?->id ?? ''; + } + } + + public function getOrganizationDisplayName($organization) + { + $hierarchyIcon = match ($organization->hierarchy_type) { + 'top_branch' => '🏢', + 'master_branch' => '🏬', + 'sub_user' => '👥', + 'end_user' => '👤', + default => '📁' + }; + + return $hierarchyIcon.' '.$organization->name; + } + + public function hasMultipleOrganizations() + { + return $this->userOrganizations->count() > 1; + } +} diff --git a/app/Livewire/Organization/UserManagement.php b/app/Livewire/Organization/UserManagement.php new file mode 100644 index 00000000000..95eea860387 --- /dev/null +++ b/app/Livewire/Organization/UserManagement.php @@ -0,0 +1,300 @@ + 'Owner', + 'admin' => 'Administrator', + 'member' => 'Member', + 'viewer' => 'Viewer', + ]; + + public $availablePermissions = [ + 'view_servers' => 'View Servers', + 'manage_servers' => 'Manage Servers', + 'view_applications' => 'View Applications', + 'manage_applications' => 'Manage Applications', + 'deploy_applications' => 'Deploy Applications', + 'view_billing' => 'View Billing', + 'manage_billing' => 'Manage Billing', + 'manage_users' => 'Manage Users', + 'manage_organization' => 'Manage Organization', + ]; + + protected $rules = [ + 'userEmail' => 'required|email|exists:users,email', + 'userRole' => 'required|in:owner,admin,member,viewer', + 'userPermissions' => 'array', + ]; + + public function mount(Organization $organization) + { + $this->organization = $organization; + + // Check permissions + if (! OrganizationContext::can('manage_users', $organization)) { + abort(403, 'You do not have permission to manage users for this organization.'); + } + } + + public function render() + { + $users = $this->organization->users() + ->when($this->searchTerm, function ($query) { + $query->where(function ($q) { + $q->where('name', 'like', '%'.$this->searchTerm.'%') + ->orWhere('email', 'like', '%'.$this->searchTerm.'%'); + }); + }) + ->paginate(10); + + $availableUsers = $this->getAvailableUsers(); + + return view('livewire.organization.user-management', [ + 'users' => $users, + 'availableUsers' => $availableUsers, + ]); + } + + public function addUser() + { + $this->validate(); + + try { + $user = User::where('email', $this->userEmail)->firstOrFail(); + + // Check if user is already in organization + if ($this->organization->users()->where('user_id', $user->id)->exists()) { + session()->flash('error', 'User is already a member of this organization.'); + + return; + } + + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->attachUserToOrganization( + $this->organization, + $user, + $this->userRole, + $this->userPermissions + ); + + $this->resetForm(); + $this->showAddUserForm = false; + + session()->flash('success', 'User added to organization successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to add user: '.$e->getMessage()); + } + } + + public function editUser(User $user) + { + $this->selectedUser = $user; + $userOrg = $this->organization->users()->where('user_id', $user->id)->first(); + + if (! $userOrg) { + session()->flash('error', 'User not found in organization.'); + + return; + } + + $this->userRole = $userOrg->pivot->role; + $this->userPermissions = $userOrg->pivot->permissions ?? []; + $this->showEditUserForm = true; + } + + public function updateUser() + { + $this->validate([ + 'userRole' => 'required|in:owner,admin,member,viewer', + 'userPermissions' => 'array', + ]); + + try { + // Prevent removing the last owner + if ($this->isLastOwner($this->selectedUser) && $this->userRole !== 'owner') { + session()->flash('error', 'Cannot change role of the last owner.'); + + return; + } + + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->updateUserRole( + $this->organization, + $this->selectedUser, + $this->userRole, + $this->userPermissions + ); + + $this->resetForm(); + $this->showEditUserForm = false; + + session()->flash('success', 'User updated successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to update user: '.$e->getMessage()); + } + } + + public function removeUser(User $user) + { + try { + // Prevent removing the last owner + if ($this->isLastOwner($user)) { + session()->flash('error', 'Cannot remove the last owner from the organization.'); + + return; + } + + // Prevent users from removing themselves unless they're not the last owner + if ($user->id === Auth::id() && $this->isLastOwner($user)) { + session()->flash('error', 'You cannot remove yourself as the last owner.'); + + return; + } + + $organizationService = app(OrganizationServiceInterface::class); + + $organizationService->detachUserFromOrganization($this->organization, $user); + + session()->flash('success', 'User removed from organization successfully.'); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to remove user: '.$e->getMessage()); + } + } + + public function getAvailableUsers() + { + if (! $this->userEmail || strlen($this->userEmail) < 3) { + return collect(); + } + + return User::where('email', 'like', '%'.$this->userEmail.'%') + ->whereNotIn('id', $this->organization->users()->pluck('user_id')) + ->limit(10) + ->get(); + } + + public function selectUser($userId) + { + $user = User::find($userId); + if ($user) { + $this->userEmail = $user->email; + } + } + + public function getUserRole(User $user) + { + $userOrg = $this->organization->users()->where('user_id', $user->id)->first(); + + return $userOrg?->pivot->role ?? 'unknown'; + } + + public function getUserPermissions(User $user) + { + $userOrg = $this->organization->users()->where('user_id', $user->id)->first(); + + return $userOrg?->pivot->permissions ?? []; + } + + public function canEditUser(User $user) + { + // Owners can edit anyone except other owners (unless they're the only owner) + // Admins can edit members and viewers + // Members and viewers cannot edit anyone + + $currentUserRole = OrganizationContext::getUserRole(); + $targetUserRole = $this->getUserRole($user); + + if ($currentUserRole === 'owner') { + return true; + } + + if ($currentUserRole === 'admin') { + return in_array($targetUserRole, ['member', 'viewer']); + } + + return false; + } + + public function canRemoveUser(User $user) + { + // Same logic as canEditUser, but also prevent removing the last owner + return $this->canEditUser($user) && ! $this->isLastOwner($user); + } + + protected function isLastOwner(User $user) + { + $owners = $this->organization->users() + ->wherePivot('role', 'owner') + ->wherePivot('is_active', true) + ->get(); + + return $owners->count() === 1 && $owners->first()->id === $user->id; + } + + protected function resetForm() + { + $this->userEmail = ''; + $this->userRole = 'member'; + $this->userPermissions = []; + $this->selectedUser = null; + } + + public function openAddUserForm() + { + $this->showAddUserForm = true; + } + + public function closeModals() + { + $this->showAddUserForm = false; + $this->showEditUserForm = false; + $this->resetForm(); + } + + public function getRoleColor($role) + { + return match ($role) { + 'owner' => 'bg-red-100 text-red-800', + 'admin' => 'bg-blue-100 text-blue-800', + 'member' => 'bg-green-100 text-green-800', + 'viewer' => 'bg-gray-100 text-gray-800', + default => 'bg-gray-100 text-gray-800' + }; + } +} diff --git a/app/Models/CloudProviderCredential.php b/app/Models/CloudProviderCredential.php new file mode 100644 index 00000000000..ff1f91eb4ea --- /dev/null +++ b/app/Models/CloudProviderCredential.php @@ -0,0 +1,338 @@ + 'encrypted:array', + 'is_active' => 'boolean', + 'last_validated_at' => 'datetime', + ]; + + protected $hidden = [ + 'credentials', + ]; + + // Supported cloud providers + public const SUPPORTED_PROVIDERS = [ + 'aws' => 'Amazon Web Services', + 'gcp' => 'Google Cloud Platform', + 'azure' => 'Microsoft Azure', + 'digitalocean' => 'DigitalOcean', + 'hetzner' => 'Hetzner Cloud', + 'linode' => 'Linode', + 'vultr' => 'Vultr', + ]; + + // Relationships + public function organization() + { + return $this->belongsTo(Organization::class); + } + + public function terraformDeployments() + { + return $this->hasMany(TerraformDeployment::class, 'provider_credential_id'); + } + + public function servers() + { + return $this->hasMany(Server::class, 'provider_credential_id'); + } + + // Provider Methods + public function getProviderDisplayName(): string + { + return self::SUPPORTED_PROVIDERS[$this->provider_name] ?? $this->provider_name; + } + + public function isProviderSupported(): bool + { + return array_key_exists($this->provider_name, self::SUPPORTED_PROVIDERS); + } + + public static function getSupportedProviders(): array + { + return self::SUPPORTED_PROVIDERS; + } + + // Credential Management Methods + public function setCredentials(array $credentials): void + { + // Validate credentials based on provider + $this->validateCredentialsForProvider($credentials); + $this->credentials = $credentials; + } + + public function getCredential(string $key): ?string + { + return $this->credentials[$key] ?? null; + } + + public function hasCredential(string $key): bool + { + return isset($this->credentials[$key]) && ! empty($this->credentials[$key]); + } + + public function getRequiredCredentialKeys(): array + { + return match ($this->provider_name) { + 'aws' => ['access_key_id', 'secret_access_key'], + 'gcp' => ['service_account_json'], + 'azure' => ['subscription_id', 'client_id', 'client_secret', 'tenant_id'], + 'digitalocean' => ['api_token'], + 'hetzner' => ['api_token'], + 'linode' => ['api_token'], + 'vultr' => ['api_key'], + default => [], + }; + } + + public function getOptionalCredentialKeys(): array + { + return match ($this->provider_name) { + 'aws' => ['session_token', 'region'], + 'gcp' => ['project_id', 'region'], + 'azure' => ['resource_group', 'location'], + 'digitalocean' => ['region'], + 'hetzner' => ['region'], + 'linode' => ['region'], + 'vultr' => ['region'], + default => [], + }; + } + + public function validateCredentialsForProvider(array $credentials): void + { + $requiredKeys = $this->getRequiredCredentialKeys(); + + foreach ($requiredKeys as $key) { + if (! isset($credentials[$key]) || empty($credentials[$key])) { + throw new \InvalidArgumentException("Missing required credential: {$key}"); + } + } + + // Provider-specific validation + match ($this->provider_name) { + 'aws' => $this->validateAwsCredentials($credentials), + 'gcp' => $this->validateGcpCredentials($credentials), + 'azure' => $this->validateAzureCredentials($credentials), + 'digitalocean' => $this->validateDigitalOceanCredentials($credentials), + 'hetzner' => $this->validateHetznerCredentials($credentials), + 'linode' => $this->validateLinodeCredentials($credentials), + 'vultr' => $this->validateVultrCredentials($credentials), + default => null, + }; + } + + // Provider-specific validation methods + private function validateAwsCredentials(array $credentials): void + { + if (strlen($credentials['access_key_id']) !== 20) { + throw new \InvalidArgumentException('Invalid AWS Access Key ID format'); + } + + if (strlen($credentials['secret_access_key']) !== 40) { + throw new \InvalidArgumentException('Invalid AWS Secret Access Key format'); + } + } + + private function validateGcpCredentials(array $credentials): void + { + $serviceAccount = json_decode($credentials['service_account_json'], true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Invalid JSON format for GCP service account'); + } + + $requiredFields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email']; + foreach ($requiredFields as $field) { + if (! isset($serviceAccount[$field])) { + throw new \InvalidArgumentException("Missing required field in service account JSON: {$field}"); + } + } + } + + private function validateAzureCredentials(array $credentials): void + { + // Basic UUID format validation for Azure IDs + $uuidPattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + + if (! preg_match($uuidPattern, $credentials['subscription_id'])) { + throw new \InvalidArgumentException('Invalid Azure Subscription ID format'); + } + + if (! preg_match($uuidPattern, $credentials['client_id'])) { + throw new \InvalidArgumentException('Invalid Azure Client ID format'); + } + + if (! preg_match($uuidPattern, $credentials['tenant_id'])) { + throw new \InvalidArgumentException('Invalid Azure Tenant ID format'); + } + } + + private function validateDigitalOceanCredentials(array $credentials): void + { + // DigitalOcean API tokens are 64 characters long + if (strlen($credentials['api_token']) !== 64) { + throw new \InvalidArgumentException('Invalid DigitalOcean API token format'); + } + } + + private function validateHetznerCredentials(array $credentials): void + { + // Hetzner API tokens start with specific prefixes + if (! str_starts_with($credentials['api_token'], 'hcloud_')) { + throw new \InvalidArgumentException('Invalid Hetzner API token format'); + } + } + + private function validateLinodeCredentials(array $credentials): void + { + // Linode API tokens are typically 64 characters + if (strlen($credentials['api_token']) < 32) { + throw new \InvalidArgumentException('Invalid Linode API token format'); + } + } + + private function validateVultrCredentials(array $credentials): void + { + // Vultr API keys are typically 32 characters + if (strlen($credentials['api_key']) !== 32) { + throw new \InvalidArgumentException('Invalid Vultr API key format'); + } + } + + // Validation Status Methods + public function markAsValidated(): void + { + $this->last_validated_at = now(); + $this->is_active = true; + $this->save(); + } + + public function markAsInvalid(): void + { + $this->is_active = false; + $this->save(); + } + + public function isValidated(): bool + { + return $this->last_validated_at !== null && $this->is_active; + } + + public function needsValidation(): bool + { + if (! $this->last_validated_at) { + return true; + } + + // Re-validate every 24 hours + return $this->last_validated_at->isBefore(now()->subDay()); + } + + // Region Methods + public function getAvailableRegions(): array + { + return match ($this->provider_name) { + 'aws' => [ + 'us-east-1' => 'US East (N. Virginia)', + 'us-east-2' => 'US East (Ohio)', + 'us-west-1' => 'US West (N. California)', + 'us-west-2' => 'US West (Oregon)', + 'eu-west-1' => 'Europe (Ireland)', + 'eu-west-2' => 'Europe (London)', + 'eu-central-1' => 'Europe (Frankfurt)', + 'ap-southeast-1' => 'Asia Pacific (Singapore)', + 'ap-southeast-2' => 'Asia Pacific (Sydney)', + 'ap-northeast-1' => 'Asia Pacific (Tokyo)', + ], + 'gcp' => [ + 'us-central1' => 'US Central (Iowa)', + 'us-east1' => 'US East (South Carolina)', + 'us-west1' => 'US West (Oregon)', + 'europe-west1' => 'Europe West (Belgium)', + 'europe-west2' => 'Europe West (London)', + 'asia-east1' => 'Asia East (Taiwan)', + 'asia-southeast1' => 'Asia Southeast (Singapore)', + ], + 'azure' => [ + 'eastus' => 'East US', + 'westus' => 'West US', + 'westeurope' => 'West Europe', + 'eastasia' => 'East Asia', + 'southeastasia' => 'Southeast Asia', + ], + 'digitalocean' => [ + 'nyc1' => 'New York 1', + 'nyc3' => 'New York 3', + 'ams3' => 'Amsterdam 3', + 'sfo3' => 'San Francisco 3', + 'sgp1' => 'Singapore 1', + 'lon1' => 'London 1', + 'fra1' => 'Frankfurt 1', + 'tor1' => 'Toronto 1', + 'blr1' => 'Bangalore 1', + ], + 'hetzner' => [ + 'nbg1' => 'Nuremberg', + 'fsn1' => 'Falkenstein', + 'hel1' => 'Helsinki', + 'ash' => 'Ashburn', + ], + default => [], + }; + } + + public function setRegion(string $region): void + { + $availableRegions = $this->getAvailableRegions(); + + if (! empty($availableRegions) && ! array_key_exists($region, $availableRegions)) { + throw new \InvalidArgumentException("Invalid region '{$region}' for provider '{$this->provider_name}'"); + } + + $this->provider_region = $region; + } + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForProvider($query, string $provider) + { + return $query->where('provider_name', $provider); + } + + public function scopeValidated($query) + { + return $query->whereNotNull('last_validated_at')->where('is_active', true); + } + + public function scopeNeedsValidation($query) + { + return $query->where(function ($q) { + $q->whereNull('last_validated_at') + ->orWhere('last_validated_at', '<', now()->subDay()); + }); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000000..c2429cf3f84 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,229 @@ + 'array', + 'feature_flags' => 'array', + 'is_active' => 'boolean', + 'whitelabel_public_access' => 'boolean', + ]; + + // Relationships + public function parent() + { + return $this->belongsTo(Organization::class, 'parent_organization_id'); + } + + public function children() + { + return $this->hasMany(Organization::class, 'parent_organization_id'); + } + + public function users() + { + return $this->belongsToMany(User::class, 'organization_users') + ->using(OrganizationUser::class) + ->withPivot('role', 'permissions', 'is_active') + ->withTimestamps(); + } + + public function activeLicense() + { + return $this->hasOne(EnterpriseLicense::class)->where('status', 'active'); + } + + public function licenses() + { + return $this->hasMany(EnterpriseLicense::class); + } + + public function servers() + { + return $this->hasMany(Server::class); + } + + public function whiteLabelConfig() + { + return $this->hasOne(WhiteLabelConfig::class); + } + + public function cloudProviderCredentials() + { + return $this->hasMany(CloudProviderCredential::class); + } + + public function terraformDeployments() + { + return $this->hasMany(TerraformDeployment::class); + } + + public function applications() + { + return $this->hasMany(Application::class); + } + + public function domains() + { + return $this->hasMany(Domain::class); + } + + // Business Logic Methods + public function canUserPerformAction(User $user, string $action, $resource = null): bool + { + $userOrg = $this->users()->where('user_id', $user->id)->first(); + if (! $userOrg) { + return false; + } + + $role = $userOrg->pivot->role; + $permissions = $userOrg->pivot->permissions ?? []; + + return $this->checkPermission($role, $permissions, $action, $resource); + } + + public function hasFeature(string $feature): bool + { + return $this->activeLicense?->hasFeature($feature) ?? false; + } + + public function getUsageMetrics(): array + { + try { + return [ + 'users' => $this->users()->count(), + 'servers' => $this->servers()->count(), + 'applications' => $this->applications()->count(), + 'domains' => $this->domains()->count(), + 'cloud_providers' => $this->cloudProviderCredentials()->count(), + ]; + } catch (\Exception $e) { + // Handle missing columns gracefully for development + return [ + 'users' => $this->users()->count(), + 'servers' => 0, // Fallback if servers relationship doesn't exist + 'applications' => 0, // Fallback if applications relationship doesn't exist + 'domains' => 0, // Fallback if domains relationship doesn't exist + 'cloud_providers' => 0, // Fallback if cloud_providers relationship doesn't exist + ]; + } + } + + public function isWithinLimits(): bool + { + $license = $this->activeLicense; + if (! $license) { + return false; + } + + $limits = $license->limits ?? []; + $usage = $this->getUsageMetrics(); + + foreach ($limits as $limitType => $limitValue) { + $currentUsage = $usage[$limitType] ?? 0; + if ($currentUsage > $limitValue) { + return false; + } + } + + return true; + } + + public function getTeamId(): ?int + { + // Map organization to existing team system for backward compatibility + // This is a temporary bridge until full migration to organizations + $owner = $this->users()->wherePivot('role', 'owner')->first(); + + return $owner?->teams()?->first()?->id; + } + + protected function checkPermission(string $role, array $permissions, string $action, $resource = null): bool + { + // Owner can do everything + if ($role === 'owner') { + return true; + } + + // Admin can do most things except organization management + if ($role === 'admin') { + $restrictedActions = ['delete_organization', 'manage_billing', 'manage_licenses']; + + return ! in_array($action, $restrictedActions); + } + + // Member has limited permissions + if ($role === 'member') { + $allowedActions = ['view_servers', 'view_applications', 'deploy_applications']; + + return in_array($action, $allowedActions); + } + + // Check custom permissions + return in_array($action, $permissions); + } + + // Hierarchy Methods + public function isTopBranch(): bool + { + return $this->hierarchy_type === 'top_branch'; + } + + public function isMasterBranch(): bool + { + return $this->hierarchy_type === 'master_branch'; + } + + public function isSubUser(): bool + { + return $this->hierarchy_type === 'sub_user'; + } + + public function isEndUser(): bool + { + return $this->hierarchy_type === 'end_user'; + } + + public function getAllDescendants() + { + return $this->children()->with('children')->get()->flatMap(function ($child) { + return collect([$child])->merge($child->getAllDescendants()); + }); + } + + public function getAncestors() + { + $ancestors = collect(); + $current = $this->parent; + + while ($current) { + $ancestors->push($current); + $current = $current->parent; + } + + return $ancestors; + } +} diff --git a/app/Models/OrganizationUser.php b/app/Models/OrganizationUser.php new file mode 100644 index 00000000000..90bfeb47aa1 --- /dev/null +++ b/app/Models/OrganizationUser.php @@ -0,0 +1,30 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; +} diff --git a/app/Models/TerraformDeployment.php b/app/Models/TerraformDeployment.php new file mode 100644 index 00000000000..fa936a1734b --- /dev/null +++ b/app/Models/TerraformDeployment.php @@ -0,0 +1,399 @@ + 'array', + 'deployment_config' => 'array', + ]; + + // Deployment statuses + public const STATUS_PENDING = 'pending'; + + public const STATUS_PLANNING = 'planning'; + + public const STATUS_PROVISIONING = 'provisioning'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_DESTROYING = 'destroying'; + + public const STATUS_DESTROYED = 'destroyed'; + + // Relationships + public function organization() + { + return $this->belongsTo(Organization::class); + } + + public function server() + { + return $this->belongsTo(Server::class); + } + + public function providerCredential() + { + return $this->belongsTo(CloudProviderCredential::class, 'provider_credential_id'); + } + + // Status Methods + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isPlanning(): bool + { + return $this->status === self::STATUS_PLANNING; + } + + public function isProvisioning(): bool + { + return $this->status === self::STATUS_PROVISIONING; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + public function isDestroying(): bool + { + return $this->status === self::STATUS_DESTROYING; + } + + public function isDestroyed(): bool + { + return $this->status === self::STATUS_DESTROYED; + } + + public function isInProgress(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING, + self::STATUS_PLANNING, + self::STATUS_PROVISIONING, + self::STATUS_DESTROYING, + ]); + } + + public function isFinished(): bool + { + return in_array($this->status, [ + self::STATUS_COMPLETED, + self::STATUS_FAILED, + self::STATUS_DESTROYED, + ]); + } + + // Status Update Methods + public function markAsPending(): void + { + $this->update(['status' => self::STATUS_PENDING, 'error_message' => null]); + } + + public function markAsPlanning(): void + { + $this->update(['status' => self::STATUS_PLANNING, 'error_message' => null]); + } + + public function markAsProvisioning(): void + { + $this->update(['status' => self::STATUS_PROVISIONING, 'error_message' => null]); + } + + public function markAsCompleted(): void + { + $this->update(['status' => self::STATUS_COMPLETED, 'error_message' => null]); + } + + public function markAsFailed(string $errorMessage): void + { + $this->update(['status' => self::STATUS_FAILED, 'error_message' => $errorMessage]); + } + + public function markAsDestroying(): void + { + $this->update(['status' => self::STATUS_DESTROYING, 'error_message' => null]); + } + + public function markAsDestroyed(): void + { + $this->update(['status' => self::STATUS_DESTROYED, 'error_message' => null]); + } + + // Configuration Methods + public function getConfigValue(string $key, $default = null) + { + return data_get($this->deployment_config, $key, $default); + } + + public function setConfigValue(string $key, $value): void + { + $config = $this->deployment_config ?? []; + data_set($config, $key, $value); + $this->deployment_config = $config; + } + + public function getInstanceType(): ?string + { + return $this->getConfigValue('instance_type'); + } + + public function getRegion(): ?string + { + return $this->getConfigValue('region') ?? $this->providerCredential?->provider_region; + } + + public function getServerName(): ?string + { + return $this->getConfigValue('server_name') ?? "server-{$this->id}"; + } + + public function getDiskSize(): ?int + { + return $this->getConfigValue('disk_size', 20); + } + + public function getNetworkConfig(): array + { + return $this->getConfigValue('network', []); + } + + public function getSecurityGroupConfig(): array + { + return $this->getConfigValue('security_groups', []); + } + + // Terraform State Methods + public function getStateValue(string $key, $default = null) + { + return data_get($this->terraform_state, $key, $default); + } + + public function setStateValue(string $key, $value): void + { + $state = $this->terraform_state ?? []; + data_set($state, $key, $value); + $this->terraform_state = $state; + } + + public function getOutputs(): array + { + return $this->getStateValue('outputs', []); + } + + public function getOutput(string $key, $default = null) + { + return data_get($this->getOutputs(), $key, $default); + } + + public function getPublicIp(): ?string + { + return $this->getOutput('public_ip'); + } + + public function getPrivateIp(): ?string + { + return $this->getOutput('private_ip'); + } + + public function getInstanceId(): ?string + { + return $this->getOutput('instance_id'); + } + + public function getSshPrivateKey(): ?string + { + return $this->getOutput('ssh_private_key'); + } + + public function getSshPublicKey(): ?string + { + return $this->getOutput('ssh_public_key'); + } + + // Resource Management Methods + public function getResourceIds(): array + { + return $this->getStateValue('resource_ids', []); + } + + public function addResourceId(string $type, string $id): void + { + $resourceIds = $this->getResourceIds(); + $resourceIds[$type] = $id; + $this->setStateValue('resource_ids', $resourceIds); + } + + public function getResourceId(string $type): ?string + { + return $this->getResourceIds()[$type] ?? null; + } + + // Provider-specific Methods + public function getProviderName(): string + { + return $this->providerCredential->provider_name; + } + + public function isAwsDeployment(): bool + { + return $this->getProviderName() === 'aws'; + } + + public function isGcpDeployment(): bool + { + return $this->getProviderName() === 'gcp'; + } + + public function isAzureDeployment(): bool + { + return $this->getProviderName() === 'azure'; + } + + public function isDigitalOceanDeployment(): bool + { + return $this->getProviderName() === 'digitalocean'; + } + + public function isHetznerDeployment(): bool + { + return $this->getProviderName() === 'hetzner'; + } + + // Validation Methods + public function canBeDestroyed(): bool + { + return $this->isCompleted() && ! $this->isDestroyed(); + } + + public function canBeRetried(): bool + { + return $this->isFailed(); + } + + public function hasServer(): bool + { + return $this->server_id !== null; + } + + public function hasValidCredentials(): bool + { + return $this->providerCredential && $this->providerCredential->isValidated(); + } + + // Cost Estimation Methods (placeholder for future implementation) + public function getEstimatedMonthlyCost(): ?float + { + // This would integrate with cloud provider pricing APIs + // For now, return null as placeholder + return null; + } + + public function getEstimatedHourlyCost(): ?float + { + $monthlyCost = $this->getEstimatedMonthlyCost(); + + return $monthlyCost ? $monthlyCost / (24 * 30) : null; + } + + // Scopes + public function scopeInProgress($query) + { + return $query->whereIn('status', [ + self::STATUS_PENDING, + self::STATUS_PLANNING, + self::STATUS_PROVISIONING, + self::STATUS_DESTROYING, + ]); + } + + public function scopeCompleted($query) + { + return $query->where('status', self::STATUS_COMPLETED); + } + + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + public function scopeForProvider($query, string $provider) + { + return $query->whereHas('providerCredential', function ($q) use ($provider) { + $q->where('provider_name', $provider); + }); + } + + public function scopeForOrganization($query, string $organizationId) + { + return $query->where('organization_id', $organizationId); + } + + // Helper Methods + public function getDurationInMinutes(): ?int + { + if (! $this->isFinished()) { + return null; + } + + return $this->created_at->diffInMinutes($this->updated_at); + } + + public function getFormattedDuration(): ?string + { + $minutes = $this->getDurationInMinutes(); + if ($minutes === null) { + return null; + } + + if ($minutes < 60) { + return "{$minutes} minutes"; + } + + $hours = floor($minutes / 60); + $remainingMinutes = $minutes % 60; + + return "{$hours}h {$remainingMinutes}m"; + } + + public function toArray() + { + $array = parent::toArray(); + + // Add computed properties + $array['provider_name'] = $this->getProviderName(); + $array['duration_minutes'] = $this->getDurationInMinutes(); + $array['formatted_duration'] = $this->getFormattedDuration(); + $array['can_be_destroyed'] = $this->canBeDestroyed(); + $array['can_be_retried'] = $this->canBeRetried(); + + return $array; + } +} diff --git a/app/Services/OrganizationService.php b/app/Services/OrganizationService.php new file mode 100644 index 00000000000..80282bf9e1f --- /dev/null +++ b/app/Services/OrganizationService.php @@ -0,0 +1,589 @@ +validateOrganizationData($data); + + if ($parent) { + $this->validateHierarchyCreation($parent, $data['hierarchy_type']); + } + + return DB::transaction(function () use ($data, $parent) { + $organization = Organization::create([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? Str::slug($data['name']), + 'hierarchy_type' => $data['hierarchy_type'], + 'hierarchy_level' => $parent ? $parent->hierarchy_level + 1 : 0, + 'parent_organization_id' => $parent?->id, + 'branding_config' => $data['branding_config'] ?? [], + 'feature_flags' => $data['feature_flags'] ?? [], + 'is_active' => $data['is_active'] ?? true, + ]); + + // If creating with an owner, attach them + if (isset($data['owner_id'])) { + $this->attachUserToOrganization( + $organization, + User::findOrFail($data['owner_id']), + 'owner' + ); + } + + return $organization; + }); + } + + /** + * Update organization with validation + */ + public function updateOrganization(Organization $organization, array $data): Organization + { + $this->validateOrganizationData($data, $organization); + + return DB::transaction(function () use ($organization, $data) { + // Don't allow changing hierarchy type if it would break relationships + if (isset($data['hierarchy_type']) && $data['hierarchy_type'] !== $organization->hierarchy_type) { + $this->validateHierarchyTypeChange($organization, $data['hierarchy_type']); + } + + $organization->update($data); + + // Clear cached permissions for this organization + $this->clearOrganizationCache($organization); + + return $organization->fresh(); + }); + } + + /** + * Attach a user to an organization with a specific role + */ + public function attachUserToOrganization(Organization $organization, User $user, string $role, array $permissions = []): void + { + $this->validateRole($role); + $this->validateUserCanBeAttached($organization, $user, $role); + + $organization->users()->attach($user->id, [ + 'role' => $role, + 'permissions' => $permissions, + 'is_active' => true, + ]); + + // Clear user's cached permissions + $this->clearUserCache($user); + } + + /** + * Update user's role and permissions in an organization + */ + public function updateUserRole(Organization $organization, User $user, string $role, array $permissions = []): void + { + $this->validateRole($role); + + $organization->users()->updateExistingPivot($user->id, [ + 'role' => $role, + 'permissions' => $permissions, + ]); + + $this->clearUserCache($user); + } + + /** + * Remove user from organization + */ + public function detachUserFromOrganization(Organization $organization, User $user): void + { + // Prevent removing the last owner + if ($this->isLastOwner($organization, $user)) { + throw new InvalidArgumentException('Cannot remove the last owner from an organization'); + } + + $organization->users()->detach($user->id); + $this->clearUserCache($user); + } + + /** + * Switch user's current organization context + */ + public function switchUserOrganization(User $user, Organization $organization): void + { + // Verify user has access to this organization + if (! $this->userHasAccessToOrganization($user, $organization)) { + throw new InvalidArgumentException('User does not have access to this organization'); + } + + $user->update(['current_organization_id' => $organization->id]); + $this->clearUserCache($user); + } + + /** + * Get organizations accessible by a user + */ + public function getUserOrganizations(User $user): Collection + { + return Cache::remember( + "user_organizations_{$user->id}", + now()->addMinutes(30), + fn () => $user->organizations()->wherePivot('is_active', true)->get() + ); + } + + /** + * Check if user can perform an action on a resource within an organization + */ + public function canUserPerformAction(User $user, Organization $organization, string $action, $resource = null): bool + { + $cacheKey = "user_permissions_{$user->id}_{$organization->id}_{$action}"; + + return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $organization, $action, $resource) { + // Check if user is in organization + $userOrg = $organization->users()->where('user_id', $user->id)->first(); + if (! $userOrg || ! $userOrg->pivot->is_active) { + return false; + } + + // Check license restrictions + if (! $this->isActionAllowedByLicense($organization, $action)) { + return false; + } + + // Check role-based permissions + $permissions = $userOrg->pivot->permissions ?? []; + if (is_string($permissions)) { + $permissions = json_decode($permissions, true) ?? []; + } + + return $this->checkRolePermission( + $userOrg->pivot->role, + $permissions, + $action, + $resource + ); + }); + } + + /** + * Get organization hierarchy tree + */ + public function getOrganizationHierarchy(Organization $rootOrganization): array + { + return Cache::remember( + "org_hierarchy_{$rootOrganization->id}", + now()->addHour(), + fn () => $this->buildHierarchyTree($rootOrganization) + ); + } + + /** + * Move organization to a new parent (with validation) + */ + public function moveOrganization(Organization $organization, ?Organization $newParent): Organization + { + if ($newParent) { + // Prevent circular dependencies + if ($this->wouldCreateCircularDependency($organization, $newParent)) { + throw new InvalidArgumentException('Moving organization would create circular dependency'); + } + + // Validate hierarchy rules + $this->validateHierarchyMove($organization, $newParent); + } + + return DB::transaction(function () use ($organization, $newParent) { + $oldLevel = $organization->hierarchy_level; + $newLevel = $newParent ? $newParent->hierarchy_level + 1 : 0; + $levelDifference = $newLevel - $oldLevel; + + // Update the organization + $organization->update([ + 'parent_organization_id' => $newParent?->id, + 'hierarchy_level' => $newLevel, + ]); + + // Update all descendants' hierarchy levels + if ($levelDifference !== 0) { + $this->updateDescendantLevels($organization, $levelDifference); + } + + // Clear relevant caches + $this->clearOrganizationCache($organization); + if ($newParent) { + $this->clearOrganizationCache($newParent); + } + + return $organization->fresh(); + }); + } + + /** + * Delete organization with proper cleanup + */ + public function deleteOrganization(Organization $organization, bool $force = false): bool + { + return DB::transaction(function () use ($organization, $force) { + // Check if organization has children + if ($organization->children()->exists() && ! $force) { + throw new InvalidArgumentException('Cannot delete organization with child organizations'); + } + + // Check if organization has active resources + if ($this->hasActiveResources($organization) && ! $force) { + throw new InvalidArgumentException('Cannot delete organization with active resources'); + } + + // If force delete, handle children + if ($force && $organization->children()->exists()) { + // Move children to parent or make them orphans + $parent = $organization->parent; + foreach ($organization->children as $child) { + $this->moveOrganization($child, $parent); + } + } + + // Clear caches + $this->clearOrganizationCache($organization); + + // Soft delete the organization + return $organization->delete(); + }); + } + + /** + * Get organization usage statistics + */ + public function getOrganizationUsage(Organization $organization): array + { + return Cache::remember( + "org_usage_{$organization->id}", + now()->addMinutes(5), + fn () => [ + 'users' => $organization->users()->wherePivot('is_active', true)->count(), + 'servers' => $organization->servers()->count(), + 'applications' => $organization->applications()->count(), + 'children' => $organization->children()->count(), + 'storage_used' => $this->calculateStorageUsage($organization), + 'monthly_costs' => $this->calculateMonthlyCosts($organization), + ] + ); + } + + /** + * Validate organization data + */ + protected function validateOrganizationData(array $data, ?Organization $existing = null): void + { + $rules = [ + 'name' => 'required|string|max:255', + 'hierarchy_type' => 'required|in:top_branch,master_branch,sub_user,end_user', + ]; + + // Check slug uniqueness + if (isset($data['slug'])) { + $slugQuery = Organization::where('slug', $data['slug']); + if ($existing) { + $slugQuery->where('id', '!=', $existing->id); + } + if ($slugQuery->exists()) { + throw new InvalidArgumentException('Organization slug must be unique'); + } + } + + // Validate hierarchy type + $validTypes = ['top_branch', 'master_branch', 'sub_user', 'end_user']; + if (isset($data['hierarchy_type']) && ! in_array($data['hierarchy_type'], $validTypes)) { + throw new InvalidArgumentException('Invalid hierarchy type'); + } + } + + /** + * Validate hierarchy creation rules + */ + protected function validateHierarchyCreation(Organization $parent, string $childType): void + { + $allowedChildren = [ + 'top_branch' => ['master_branch'], + 'master_branch' => ['sub_user'], + 'sub_user' => ['end_user'], + 'end_user' => [], // End users cannot have children + ]; + + $parentType = $parent->hierarchy_type ?? ''; + + if (! isset($allowedChildren[$parentType]) || ! in_array($childType, $allowedChildren[$parentType])) { + throw new InvalidArgumentException("A {$parentType} cannot have a {$childType} as a child"); + } + } + + /** + * Validate role + */ + protected function validateRole(string $role): void + { + $validRoles = ['owner', 'admin', 'member', 'viewer']; + if (! in_array($role, $validRoles)) { + throw new InvalidArgumentException('Invalid role'); + } + } + + /** + * Check if user can be attached to organization + */ + protected function validateUserCanBeAttached(Organization $organization, User $user, string $role): void + { + // Check if user is already in organization + if ($organization->users()->where('user_id', $user->id)->exists()) { + throw new InvalidArgumentException('User is already in this organization'); + } + + // Check license limits + $license = $organization->activeLicense; + if ($license && isset($license->limits['max_users'])) { + $currentUsers = $organization->users()->wherePivot('is_active', true)->count(); + if ($currentUsers >= $license->limits['max_users']) { + throw new InvalidArgumentException('Organization has reached maximum user limit'); + } + } + } + + /** + * Check if user is the last owner + */ + protected function isLastOwner(Organization $organization, User $user): bool + { + $owners = $organization->users()->wherePivot('role', 'owner')->wherePivot('is_active', true)->get(); + + return $owners->count() === 1 && $owners->first()->id === $user->id; + } + + /** + * Check if user has access to organization + */ + protected function userHasAccessToOrganization(User $user, Organization $organization): bool + { + return $organization->users() + ->where('user_id', $user->id) + ->wherePivot('is_active', true) + ->exists(); + } + + /** + * Check if action is allowed by license + */ + protected function isActionAllowedByLicense(Organization $organization, string $action): bool + { + $license = $organization->activeLicense; + if (! $license || ! $license->isValid()) { + // Allow basic actions without license + $basicActions = ['view_servers', 'view_applications']; + + return in_array($action, $basicActions); + } + + // Map actions to license features + $actionFeatureMap = [ + 'provision_infrastructure' => 'infrastructure_provisioning', + 'manage_domains' => 'domain_management', + 'process_payments' => 'payment_processing', + 'manage_white_label' => 'white_label_branding', + ]; + + if (isset($actionFeatureMap[$action])) { + return $license->hasFeature($actionFeatureMap[$action]); + } + + return true; // Allow actions not mapped to specific features + } + + /** + * Check role-based permissions + */ + protected function checkRolePermission(string $role, array $permissions, string $action, $resource = null): bool + { + // Owner can do everything + if ($role === 'owner') { + return true; + } + + // Admin can do most things except organization management + if ($role === 'admin') { + $restrictedActions = ['delete_organization', 'manage_billing', 'manage_licenses']; + + return ! in_array($action, $restrictedActions); + } + + // Member has limited permissions + if ($role === 'member') { + $allowedActions = ['view_servers', 'view_applications', 'deploy_applications', 'manage_applications']; + + return in_array($action, $allowedActions); + } + + // Viewer can only view + if ($role === 'viewer') { + $allowedActions = ['view_servers', 'view_applications']; + + return in_array($action, $allowedActions); + } + + // Check custom permissions + return in_array($action, $permissions); + } + + /** + * Build hierarchy tree recursively + */ + protected function buildHierarchyTree(Organization $organization): array + { + $children = $organization->children()->with('users')->get(); + + return [ + 'id' => $organization->id, + 'name' => $organization->name, + 'hierarchy_type' => $organization->hierarchy_type, + 'hierarchy_level' => $organization->hierarchy_level, + 'user_count' => $organization->users()->wherePivot('is_active', true)->count(), + 'is_active' => $organization->is_active, + 'children' => $children->map(fn ($child) => $this->buildHierarchyTree($child))->toArray(), + ]; + } + + /** + * Check if moving would create circular dependency + */ + protected function wouldCreateCircularDependency(Organization $organization, Organization $newParent): bool + { + $current = $newParent; + while ($current) { + if ($current->id === $organization->id) { + return true; + } + $current = $current->parent ?? null; + } + + return false; + } + + /** + * Validate hierarchy move + */ + protected function validateHierarchyMove(Organization $organization, Organization $newParent): void + { + // Check if the move respects hierarchy rules + $this->validateHierarchyCreation($newParent, $organization->hierarchy_type); + + // Check if new parent can accept more children (license limits) + $license = $newParent->activeLicense; + if ($license && isset($license->limits['max_child_organizations'])) { + $currentChildren = $newParent->children()->count(); + if ($currentChildren >= $license->limits['max_child_organizations']) { + throw new InvalidArgumentException('Parent organization has reached maximum child limit'); + } + } + } + + /** + * Update descendant hierarchy levels + */ + protected function updateDescendantLevels(Organization $organization, int $levelDifference): void + { + $descendants = $organization->getAllDescendants(); + foreach ($descendants as $descendant) { + $descendant->update([ + 'hierarchy_level' => $descendant->hierarchy_level + $levelDifference, + ]); + } + } + + /** + * Check if organization has active resources + */ + protected function hasActiveResources(Organization $organization): bool + { + return $organization->servers()->exists() || + $organization->applications()->exists() || + $organization->terraformDeployments()->where('status', '!=', 'destroyed')->exists(); + } + + /** + * Calculate storage usage for organization + */ + protected function calculateStorageUsage(Organization $organization): int + { + // This would integrate with actual storage monitoring + // For now, return a placeholder + return 0; + } + + /** + * Calculate monthly costs for organization + */ + protected function calculateMonthlyCosts(Organization $organization): float + { + // This would integrate with actual cost tracking + // For now, return a placeholder + return 0.0; + } + + /** + * Validate hierarchy type change + */ + protected function validateHierarchyTypeChange(Organization $organization, string $newType): void + { + // Check if change would break parent-child relationships + if ($organization->parent) { + $this->validateHierarchyCreation($organization->parent, $newType); + } + + // Check if change would break relationships with children + foreach ($organization->children as $child) { + $this->validateHierarchyCreation($organization, $child->hierarchy_type); + } + } + + /** + * Clear organization-related caches + */ + protected function clearOrganizationCache(Organization $organization): void + { + Cache::forget("org_hierarchy_{$organization->id}"); + Cache::forget("org_usage_{$organization->id}"); + + // Clear user caches for all users in this organization + $organization->users->each(fn ($user) => $this->clearUserCache($user)); + } + + /** + * Clear user-related caches + */ + protected function clearUserCache(User $user): void + { + Cache::forget("user_organizations_{$user->id}"); + + // Clear permission caches for all organizations this user belongs to + $user->organizations->each(function ($org) use ($user) { + $pattern = "user_permissions_{$user->id}_{$org->id}_*"; + // In a real implementation, you'd want a more sophisticated cache clearing mechanism + // For now, we'll clear specific known permission keys + $actions = ['view_servers', 'manage_servers', 'deploy_applications', 'manage_billing']; + foreach ($actions as $action) { + Cache::forget("user_permissions_{$user->id}_{$org->id}_{$action}"); + } + }); + } +} diff --git a/app/Services/ResourceProvisioningService.php b/app/Services/ResourceProvisioningService.php new file mode 100644 index 00000000000..a6dfbb0e1bb --- /dev/null +++ b/app/Services/ResourceProvisioningService.php @@ -0,0 +1,335 @@ +licensingService = $licensingService; + } + + /** + * Check if organization can provision a new server + */ + public function canProvisionServer(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'allowed' => false, + 'reason' => 'No active license found', + 'code' => 'NO_LICENSE', + ]; + } + + // Check server management feature + if (! $license->hasFeature('server_management')) { + return [ + 'allowed' => false, + 'reason' => 'Server management not available in your license tier', + 'code' => 'FEATURE_NOT_AVAILABLE', + 'upgrade_required' => true, + ]; + } + + // Check server count limits + $usageCheck = $this->licensingService->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + $serverViolations = collect($usageCheck['violations']) + ->where('type', 'servers') + ->first(); + + if ($serverViolations) { + return [ + 'allowed' => false, + 'reason' => 'Server limit exceeded', + 'code' => 'LIMIT_EXCEEDED', + 'current_usage' => $serverViolations['current'], + 'limit' => $serverViolations['limit'], + ]; + } + } + + return [ + 'allowed' => true, + 'remaining_servers' => $license->getRemainingLimit('servers'), + ]; + } + + /** + * Check if organization can deploy a new application + */ + public function canDeployApplication(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'allowed' => false, + 'reason' => 'No active license found', + 'code' => 'NO_LICENSE', + ]; + } + + // Check application deployment feature + if (! $license->hasFeature('application_deployment')) { + return [ + 'allowed' => false, + 'reason' => 'Application deployment not available in your license tier', + 'code' => 'FEATURE_NOT_AVAILABLE', + 'upgrade_required' => true, + ]; + } + + // Check application count limits + $usageCheck = $this->licensingService->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + $appViolations = collect($usageCheck['violations']) + ->where('type', 'applications') + ->first(); + + if ($appViolations) { + return [ + 'allowed' => false, + 'reason' => 'Application limit exceeded', + 'code' => 'LIMIT_EXCEEDED', + 'current_usage' => $appViolations['current'], + 'limit' => $appViolations['limit'], + ]; + } + } + + return [ + 'allowed' => true, + 'remaining_applications' => $license->getRemainingLimit('applications'), + ]; + } + + /** + * Check if organization can manage domains + */ + public function canManageDomains(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'allowed' => false, + 'reason' => 'No active license found', + 'code' => 'NO_LICENSE', + ]; + } + + // Check domain management feature + if (! $license->hasFeature('domain_management')) { + return [ + 'allowed' => false, + 'reason' => 'Domain management not available in your license tier', + 'code' => 'FEATURE_NOT_AVAILABLE', + 'upgrade_required' => true, + ]; + } + + // Check domain count limits + $usageCheck = $this->licensingService->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + $domainViolations = collect($usageCheck['violations']) + ->where('type', 'domains') + ->first(); + + if ($domainViolations) { + return [ + 'allowed' => false, + 'reason' => 'Domain limit exceeded', + 'code' => 'LIMIT_EXCEEDED', + 'current_usage' => $domainViolations['current'], + 'limit' => $domainViolations['limit'], + ]; + } + } + + return [ + 'allowed' => true, + 'remaining_domains' => $license->getRemainingLimit('domains'), + ]; + } + + /** + * Check if organization can provision cloud infrastructure + */ + public function canProvisionInfrastructure(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'allowed' => false, + 'reason' => 'No active license found', + 'code' => 'NO_LICENSE', + ]; + } + + // Check cloud provisioning feature + if (! $license->hasFeature('cloud_provisioning')) { + return [ + 'allowed' => false, + 'reason' => 'Cloud provisioning not available in your license tier', + 'code' => 'FEATURE_NOT_AVAILABLE', + 'upgrade_required' => true, + ]; + } + + // Check cloud provider limits + $usageCheck = $this->licensingService->checkUsageLimits($license); + if (! $usageCheck['within_limits']) { + $cloudViolations = collect($usageCheck['violations']) + ->where('type', 'cloud_providers') + ->first(); + + if ($cloudViolations) { + return [ + 'allowed' => false, + 'reason' => 'Cloud provider limit exceeded', + 'code' => 'LIMIT_EXCEEDED', + 'current_usage' => $cloudViolations['current'], + 'limit' => $cloudViolations['limit'], + ]; + } + } + + return [ + 'allowed' => true, + 'remaining_cloud_providers' => $license->getRemainingLimit('cloud_providers'), + ]; + } + + /** + * Get available deployment options based on license tier + */ + public function getAvailableDeploymentOptions(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'available_options' => [], + 'license_tier' => null, + ]; + } + + $tierOptions = [ + 'basic' => [ + 'docker_deployment' => 'Standard Docker deployment', + 'basic_monitoring' => 'Basic resource monitoring', + 'manual_scaling' => 'Manual application scaling', + ], + 'professional' => [ + 'docker_deployment' => 'Standard Docker deployment', + 'basic_monitoring' => 'Basic resource monitoring', + 'manual_scaling' => 'Manual application scaling', + 'advanced_monitoring' => 'Advanced metrics and alerting', + 'blue_green_deployment' => 'Blue-green deployment strategy', + 'auto_scaling' => 'Automatic application scaling', + 'backup_management' => 'Automated backup management', + 'force_rebuild' => 'Force rebuild deployments', + 'instant_deployment' => 'Instant deployment without queuing', + ], + 'enterprise' => [ + 'docker_deployment' => 'Standard Docker deployment', + 'basic_monitoring' => 'Basic resource monitoring', + 'manual_scaling' => 'Manual application scaling', + 'advanced_monitoring' => 'Advanced metrics and alerting', + 'blue_green_deployment' => 'Blue-green deployment strategy', + 'auto_scaling' => 'Automatic application scaling', + 'backup_management' => 'Automated backup management', + 'force_rebuild' => 'Force rebuild deployments', + 'instant_deployment' => 'Instant deployment without queuing', + 'multi_region_deployment' => 'Multi-region deployment coordination', + 'advanced_security' => 'Advanced security scanning and policies', + 'compliance_reporting' => 'Compliance and audit reporting', + 'custom_integrations' => 'Custom webhook and API integrations', + 'canary_deployment' => 'Canary deployment strategy', + 'rollback_automation' => 'Automated rollback on failure', + ], + ]; + + $availableOptions = $tierOptions[$license->license_tier] ?? []; + + return [ + 'available_options' => $availableOptions, + 'license_tier' => $license->license_tier, + 'expires_at' => $license->expires_at?->toISOString(), + ]; + } + + /** + * Check if a specific deployment option is available + */ + public function isDeploymentOptionAvailable(Organization $organization, string $option): bool + { + $availableOptions = $this->getAvailableDeploymentOptions($organization); + + return array_key_exists($option, $availableOptions['available_options']); + } + + /** + * Get resource limits for the organization + */ + public function getResourceLimits(Organization $organization): array + { + $license = $organization->activeLicense; + if (! $license) { + return [ + 'has_license' => false, + 'limits' => [], + 'usage' => [], + ]; + } + + $usage = $organization->getUsageMetrics(); + $limits = $license->limits ?? []; + + $resourceLimits = []; + foreach (['servers', 'applications', 'domains', 'cloud_providers'] as $resource) { + $limit = $limits[$resource] ?? null; + $current = $usage[$resource] ?? 0; + + $resourceLimits[$resource] = [ + 'current' => $current, + 'limit' => $limit, + 'unlimited' => $limit === null, + 'remaining' => $limit ? max(0, $limit - $current) : null, + 'percentage_used' => $limit ? round(($current / $limit) * 100, 2) : 0, + 'near_limit' => $limit ? ($current / $limit) >= 0.8 : false, + ]; + } + + return [ + 'has_license' => true, + 'license_tier' => $license->license_tier, + 'limits' => $resourceLimits, + 'usage' => $usage, + 'expires_at' => $license->expires_at?->toISOString(), + ]; + } + + /** + * Log resource provisioning attempt + */ + public function logProvisioningAttempt(Organization $organization, string $resourceType, bool $allowed, ?string $reason = null): void + { + Log::info('Resource provisioning attempt', [ + 'organization_id' => $organization->id, + 'resource_type' => $resourceType, + 'allowed' => $allowed, + 'reason' => $reason, + 'license_tier' => $organization->activeLicense?->license_tier, + 'timestamp' => now()->toISOString(), + ]); + } +} diff --git a/database/migrations/2025_08_26_224815_create_cloud_provider_credentials_table.php b/database/migrations/2025_08_26_224815_create_cloud_provider_credentials_table.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/database/migrations/2025_08_26_224900_create_organizations_table.php b/database/migrations/2025_08_26_224900_create_organizations_table.php new file mode 100644 index 00000000000..2658aef4cfa --- /dev/null +++ b/database/migrations/2025_08_26_224900_create_organizations_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->string('name'); + $table->string('slug')->unique(); + $table->enum('hierarchy_type', ['top_branch', 'master_branch', 'sub_user', 'end_user']); + $table->integer('hierarchy_level')->default(0); + $table->uuid('parent_organization_id')->nullable(); + $table->json('branding_config')->nullable(); + $table->json('feature_flags')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Foreign key constraint will be added after table creation + $table->index(['hierarchy_type', 'hierarchy_level']); + $table->index('parent_organization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2025_08_26_225351_create_organization_users_table.php b/database/migrations/2025_08_26_225351_create_organization_users_table.php new file mode 100644 index 00000000000..4e159e056f7 --- /dev/null +++ b/database/migrations/2025_08_26_225351_create_organization_users_table.php @@ -0,0 +1,37 @@ +uuid('id')->primary(); + $table->uuid('organization_id'); + $table->unsignedBigInteger('user_id'); + $table->string('role')->default('member'); + $table->json('permissions')->default('{}'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->unique(['organization_id', 'user_id']); + $table->index(['organization_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organization_users'); + } +}; diff --git a/database/migrations/2025_08_26_225903_add_organization_id_to_servers_table.php b/database/migrations/2025_08_26_225903_add_organization_id_to_servers_table.php new file mode 100644 index 00000000000..48a9ff2efdc --- /dev/null +++ b/database/migrations/2025_08_26_225903_add_organization_id_to_servers_table.php @@ -0,0 +1,32 @@ +uuid('organization_id')->nullable()->after('team_id'); + $table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade'); + $table->index('organization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['organization_id']); + $table->dropIndex(['organization_id']); + $table->dropColumn('organization_id'); + }); + } +}; diff --git a/database/migrations/2025_08_26_230017_create_terraform_deployments_table.php b/database/migrations/2025_08_26_230017_create_terraform_deployments_table.php new file mode 100644 index 00000000000..f0822b917b8 --- /dev/null +++ b/database/migrations/2025_08_26_230017_create_terraform_deployments_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->uuid('organization_id'); + $table->unsignedBigInteger('server_id')->nullable(); + $table->uuid('provider_credential_id'); + $table->json('terraform_state')->nullable(); + $table->json('deployment_config'); + $table->string('status')->default('pending'); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); + $table->foreign('provider_credential_id')->references('id')->on('cloud_provider_credentials')->onDelete('cascade'); + $table->index(['organization_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('terraform_deployments'); + } +};