--- description: Security architecture, authentication, authorization patterns, and enhanced form component security globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php alwaysApply: true --- # Coolify Security Architecture & Patterns ## Security Philosophy Coolify implements **defense-in-depth security** with multiple layers of protection including authentication, authorization, encryption, network isolation, and secure deployment practices. ## Authentication Architecture ### Multi-Provider Authentication - **[Laravel Fortify](mdc:config/fortify.php)** - Core authentication scaffolding (4.9KB, 149 lines) - **[Laravel Sanctum](mdc:config/sanctum.php)** - API token authentication (2.4KB, 69 lines) - **[Laravel Socialite](mdc:config/services.php)** - OAuth provider integration ### OAuth Integration - **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations - **Supported Providers**: - Google OAuth - Microsoft Azure AD - Clerk - Authentik - Discord - GitHub (via GitHub Apps) - GitLab ### Authentication Models ```php // User authentication with team-based access class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable; protected $fillable = [ 'name', 'email', 'password' ]; protected $hidden = [ 'password', 'remember_token' ]; protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; public function teams(): BelongsToMany { return $this->belongsToMany(Team::class) ->withPivot('role') ->withTimestamps(); } public function currentTeam(): BelongsTo { return $this->belongsTo(Team::class, 'current_team_id'); } } ``` ## Authorization & Access Control ### Enhanced Form Component Authorization System Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control. #### Component Authorization Parameters ```php // Available on all form components public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete') public mixed $canResource = null; // Resource to check against (model instance) public bool $autoDisable = true; // Auto-disable if no permission (default: true) ``` #### Smart Authorization Logic ```php // Automatic authorization handling in component constructor if ($this->canGate && $this->canResource && $this->autoDisable) { $hasPermission = Gate::allows($this->canGate, $this->canResource); if (! $hasPermission) { $this->disabled = true; // For Checkbox: also disables instantSave } } ``` #### Usage Examples **✅ Recommended Pattern (Single Line):** ```html Save Configuration ``` **❌ Old Pattern (Verbose, Deprecated):** ```html @can('update', $application) Save @else @endcan ``` #### Advanced Usage with Custom Control **Custom Authorization Logic:** ```html ``` **Multiple Permission Checks:** ```html ``` #### Supported Gates and Resources **Common Gates:** - `view` - Read access to resource - `update` - Modify resource configuration - `deploy` - Deploy/restart resource - `delete` - Remove resource - `createAnyResource` - Create new resources **Resource Types:** - `Application` - Application instances - `Service` - Docker Compose services - `Server` - Server instances - `Project` - Project containers - `Environment` - Environment contexts - `Database` - Database instances #### Benefits **🔥 Massive Code Reduction:** - **90% less code** for authorization-protected forms - **Single line** instead of 6-12 lines per form element - **No more @can/@else blocks** cluttering templates **🛡️ Consistent Security:** - **Unified authorization logic** across all form components - **Automatic disabling** for unauthorized users - **Smart behavior** (like disabling instantSave on checkboxes) **🎨 Better UX:** - **Consistent disabled styling** across all components - **Proper visual feedback** for restricted access - **Clean, professional interface** #### Implementation Details **Component Enhancement:** ```php // Enhanced in all form components use Illuminate\Support\Facades\Gate; public function __construct( // ... existing parameters public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { $hasPermission = Gate::allows($this->canGate, $this->canResource); if (! $hasPermission) { $this->disabled = true; // For Checkbox: $this->instantSave = false; } } } ``` **Backward Compatibility:** - All existing form components continue to work unchanged - New authorization parameters are optional - Legacy @can/@else patterns still function but are discouraged ### Custom Component Authorization Patterns When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components. #### Common Custom Components Requiring Manual Protection **⚠️ Custom Components That Need Manual Authorization:** - Custom dropdowns/selects with Alpine.js - Complex form widgets with JavaScript interactions - Multi-step wizards or dynamic forms - Third-party component integrations - Custom date/time pickers - File upload components with drag-and-drop #### Manual Authorization Pattern **✅ Proper Manual Authorization:** ```html
@can('update', $resource)
@else
@endcan
``` #### Implementation Checklist When implementing authorization for custom components: **🔍 1. Identify Custom Components:** - Look for Alpine.js `x-data` declarations - Find components not using `x-forms.*` prefix - Check for JavaScript-heavy interactions - Review complex form widgets **🛡️ 2. Wrap with Authorization:** - Use `@can('gate', $resource)` / `@else` / `@endcan` structure - Provide full functionality in the `@can` block - Create disabled/readonly version in the `@else` block **🎨 3. Design Disabled State:** - Apply `readonly disabled` attributes to inputs - Add `opacity-50 cursor-not-allowed` classes for visual feedback - Remove interactive JavaScript behaviors - Show current value or appropriate placeholder **🔒 4. Backend Protection:** - Ensure corresponding Livewire methods check authorization - Add `$this->authorize('gate', $resource)` in relevant methods - Validate permissions before processing any changes #### Real-World Examples **Custom Date Range Picker:** ```html @can('update', $application)
@else
@endcan ``` **Multi-Select Component:** ```html @can('update', $server)
@else
@foreach($selectedValues as $value)
{{ $value }}
@endforeach
@endcan ``` **File Upload Widget:** ```html @can('update', $application)
@else

File upload restricted

@if($currentFile)

Current: {{ $currentFile }}

@endif
@endcan ``` #### Key Principles **🎯 Consistency:** - Maintain similar visual styling between enabled/disabled states - Use consistent disabled patterns across the application - Apply the same opacity and cursor styling **🔐 Security First:** - Always implement backend authorization checks - Never rely solely on frontend hiding/disabling - Validate permissions on every server action **💡 User Experience:** - Show current values in disabled state when appropriate - Provide clear visual feedback about restricted access - Maintain layout stability between states **🚀 Performance:** - Minimize Alpine.js initialization for disabled components - Avoid loading unnecessary JavaScript for unauthorized users - Use simple HTML structures for read-only states ### Team-Based Multi-Tenancy - **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines) - **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration - **Role-based permissions** within teams - **Resource isolation** by team ownership ### Authorization Patterns ```php // Team-scoped authorization middleware class EnsureTeamAccess { public function handle(Request $request, Closure $next): Response { $user = $request->user(); $teamId = $request->route('team'); if (!$user->teams->contains('id', $teamId)) { abort(403, 'Access denied to team resources'); } // Set current team context $user->switchTeam($teamId); return $next($request); } } // Resource-level authorization policies class ApplicationPolicy { public function view(User $user, Application $application): bool { return $user->teams->contains('id', $application->team_id); } public function deploy(User $user, Application $application): bool { return $this->view($user, $application) && $user->hasTeamPermission($application->team_id, 'deploy'); } public function delete(User $user, Application $application): bool { return $this->view($user, $application) && $user->hasTeamRole($application->team_id, 'admin'); } } ``` ### Global Scopes for Data Isolation ```php // Automatic team-based filtering class Application extends Model { protected static function booted(): void { static::addGlobalScope('team', function (Builder $builder) { if (auth()->check() && auth()->user()->currentTeam) { $builder->whereHas('environment.project', function ($query) { $query->where('team_id', auth()->user()->currentTeam->id); }); } }); } } ``` ## API Security ### Token-Based Authentication ```php // Sanctum API token management class PersonalAccessToken extends Model { protected $fillable = [ 'name', 'token', 'abilities', 'expires_at' ]; protected $casts = [ 'abilities' => 'array', 'expires_at' => 'datetime', 'last_used_at' => 'datetime', ]; public function tokenable(): MorphTo { return $this->morphTo(); } public function hasAbility(string $ability): bool { return in_array('*', $this->abilities) || in_array($ability, $this->abilities); } } ``` ### API Rate Limiting ```php // Rate limiting configuration RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('deployments', function (Request $request) { return Limit::perMinute(10)->by($request->user()->id); }); RateLimiter::for('webhooks', function (Request $request) { return Limit::perMinute(100)->by($request->ip()); }); ``` ### API Input Validation ```php // Comprehensive input validation class StoreApplicationRequest extends FormRequest { public function authorize(): bool { return $this->user()->can('create', Application::class); } public function rules(): array { return [ 'name' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/', 'git_repository' => 'required|url|starts_with:https://', 'git_branch' => 'required|string|max:100|regex:/^[a-zA-Z0-9\-_\/]+$/', 'server_id' => 'required|exists:servers,id', 'environment_id' => 'required|exists:environments,id', 'environment_variables' => 'array', 'environment_variables.*' => 'string|max:1000', ]; } public function prepareForValidation(): void { $this->merge([ 'name' => strip_tags($this->name), 'git_repository' => filter_var($this->git_repository, FILTER_SANITIZE_URL), ]); } } ``` ## SSH Security ### Private Key Management - **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - Secure SSH key storage (6.5KB, 247 lines) - **Encrypted key storage** in database - **Key rotation** capabilities - **Access logging** for key usage ### SSH Connection Security ```php class SshConnection { private string $host; private int $port; private string $username; private PrivateKey $privateKey; public function __construct(Server $server) { $this->host = $server->ip; $this->port = $server->port; $this->username = $server->user; $this->privateKey = $server->privateKey; } public function connect(): bool { $connection = ssh2_connect($this->host, $this->port); if (!$connection) { throw new SshConnectionException('Failed to connect to server'); } // Use private key authentication $privateKeyContent = decrypt($this->privateKey->private_key); $publicKeyContent = decrypt($this->privateKey->public_key); if (!ssh2_auth_pubkey_file($connection, $this->username, $publicKeyContent, $privateKeyContent)) { throw new SshAuthenticationException('SSH authentication failed'); } return true; } public function execute(string $command): string { // Sanitize command to prevent injection $command = escapeshellcmd($command); $stream = ssh2_exec($this->connection, $command); if (!$stream) { throw new SshExecutionException('Failed to execute command'); } return stream_get_contents($stream); } } ``` ## Container Security ### Docker Security Patterns ```php class DockerSecurityService { public function createSecureContainer(Application $application): array { return [ 'image' => $this->validateImageName($application->docker_image), 'user' => '1000:1000', // Non-root user 'read_only' => true, 'no_new_privileges' => true, 'security_opt' => [ 'no-new-privileges:true', 'apparmor:docker-default' ], 'cap_drop' => ['ALL'], 'cap_add' => ['CHOWN', 'SETUID', 'SETGID'], // Minimal capabilities 'tmpfs' => [ '/tmp' => 'rw,noexec,nosuid,size=100m', '/var/tmp' => 'rw,noexec,nosuid,size=50m' ], 'ulimits' => [ 'nproc' => 1024, 'nofile' => 1024 ] ]; } private function validateImageName(string $image): string { // Validate image name against allowed registries $allowedRegistries = ['docker.io', 'ghcr.io', 'quay.io']; $parser = new DockerImageParser(); $parsed = $parser->parse($image); if (!in_array($parsed['registry'], $allowedRegistries)) { throw new SecurityException('Image registry not allowed'); } return $image; } } ``` ### Network Isolation ```yaml # Docker Compose security configuration version: '3.8' services: app: image: ${APP_IMAGE} networks: - app-network security_opt: - no-new-privileges:true - apparmor:docker-default read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=100m cap_drop: - ALL cap_add: - CHOWN - SETUID - SETGID networks: app-network: driver: bridge internal: true ipam: config: - subnet: 172.20.0.0/16 ``` ## SSL/TLS Security ### Certificate Management - **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation - **Let's Encrypt** integration for free certificates - **Automatic renewal** and monitoring - **Custom certificate** upload support ### SSL Configuration ```php class SslCertificateService { public function generateCertificate(Application $application): SslCertificate { $domains = $this->validateDomains($application->getAllDomains()); $certificate = SslCertificate::create([ 'application_id' => $application->id, 'domains' => $domains, 'provider' => 'letsencrypt', 'status' => 'pending' ]); // Generate certificate using ACME protocol $acmeClient = new AcmeClient(); $certData = $acmeClient->generateCertificate($domains); $certificate->update([ 'certificate' => encrypt($certData['certificate']), 'private_key' => encrypt($certData['private_key']), 'chain' => encrypt($certData['chain']), 'expires_at' => $certData['expires_at'], 'status' => 'active' ]); return $certificate; } private function validateDomains(array $domains): array { foreach ($domains as $domain) { if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) { throw new InvalidDomainException("Invalid domain: {$domain}"); } // Check domain ownership if (!$this->verifyDomainOwnership($domain)) { throw new DomainOwnershipException("Domain ownership verification failed: {$domain}"); } } return $domains; } } ``` ## Environment Variable Security ### Secure Configuration Management ```php class EnvironmentVariable extends Model { protected $fillable = [ 'key', 'value', 'is_secret', 'application_id' ]; protected $casts = [ 'is_secret' => 'boolean', 'value' => 'encrypted' // Automatic encryption for sensitive values ]; public function setValueAttribute($value): void { // Automatically encrypt sensitive environment variables if ($this->isSensitiveKey($this->key)) { $this->attributes['value'] = encrypt($value); $this->attributes['is_secret'] = true; } else { $this->attributes['value'] = $value; } } public function getValueAttribute($value): string { if ($this->is_secret) { return decrypt($value); } return $value; } private function isSensitiveKey(string $key): bool { $sensitivePatterns = [ 'PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'API_KEY', 'DATABASE_URL', 'REDIS_URL', 'PRIVATE', 'CREDENTIAL', 'AUTH', 'CERTIFICATE', 'ENCRYPTION', 'SALT', 'HASH', 'OAUTH', 'JWT', 'BEARER', 'ACCESS', 'REFRESH' ]; foreach ($sensitivePatterns as $pattern) { if (str_contains(strtoupper($key), $pattern)) { return true; } } return false; } } ``` ## Webhook Security ### Webhook Signature Verification ```php class WebhookSecurityService { public function verifyGitHubSignature(Request $request, string $secret): bool { $signature = $request->header('X-Hub-Signature-256'); if (!$signature) { return false; } $expectedSignature = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret); return hash_equals($expectedSignature, $signature); } public function verifyGitLabSignature(Request $request, string $secret): bool { $signature = $request->header('X-Gitlab-Token'); return hash_equals($secret, $signature); } public function validateWebhookPayload(array $payload): array { // Sanitize and validate webhook payload $validator = Validator::make($payload, [ 'repository.clone_url' => 'required|url|starts_with:https://', 'ref' => 'required|string|max:255', 'head_commit.id' => 'required|string|size:40', // Git SHA 'head_commit.message' => 'required|string|max:1000' ]); if ($validator->fails()) { throw new InvalidWebhookPayloadException('Invalid webhook payload'); } return $validator->validated(); } } ``` ## Input Sanitization & Validation ### XSS Prevention ```php class SecurityMiddleware { public function handle(Request $request, Closure $next): Response { // Sanitize input data $input = $request->all(); $sanitized = $this->sanitizeInput($input); $request->merge($sanitized); return $next($request); } private function sanitizeInput(array $input): array { foreach ($input as $key => $value) { if (is_string($value)) { // Remove potentially dangerous HTML tags $input[$key] = strip_tags($value, '


'); // Escape special characters $input[$key] = htmlspecialchars($input[$key], ENT_QUOTES, 'UTF-8'); } elseif (is_array($value)) { $input[$key] = $this->sanitizeInput($value); } } return $input; } } ``` ### SQL Injection Prevention ```php // Always use parameterized queries and Eloquent ORM class ApplicationRepository { public function findByName(string $name): ?Application { // Safe: Uses parameter binding return Application::where('name', $name)->first(); } public function searchApplications(string $query): Collection { // Safe: Eloquent handles escaping return Application::where('name', 'LIKE', "%{$query}%") ->orWhere('description', 'LIKE', "%{$query}%") ->get(); } // NEVER do this - vulnerable to SQL injection // public function unsafeSearch(string $query): Collection // { // return DB::select("SELECT * FROM applications WHERE name LIKE '%{$query}%'"); // } } ``` ## Audit Logging & Monitoring ### Activity Logging ```php // Using Spatie Activity Log package class Application extends Model { use LogsActivity; protected static $logAttributes = [ 'name', 'git_repository', 'git_branch', 'fqdn' ]; protected static $logOnlyDirty = true; public function getDescriptionForEvent(string $eventName): string { return "Application {$this->name} was {$eventName}"; } } // Custom security events class SecurityEventLogger { public function logFailedLogin(string $email, string $ip): void { activity('security') ->withProperties([ 'email' => $email, 'ip' => $ip, 'user_agent' => request()->userAgent() ]) ->log('Failed login attempt'); } public function logSuspiciousActivity(User $user, string $activity): void { activity('security') ->causedBy($user) ->withProperties([ 'activity' => $activity, 'ip' => request()->ip(), 'timestamp' => now() ]) ->log('Suspicious activity detected'); } } ``` ### Security Monitoring ```php class SecurityMonitoringService { public function detectAnomalousActivity(User $user): bool { // Check for unusual login patterns $recentLogins = $user->activities() ->where('description', 'like', '%login%') ->where('created_at', '>=', now()->subHours(24)) ->get(); // Multiple failed attempts $failedAttempts = $recentLogins->where('description', 'Failed login attempt')->count(); if ($failedAttempts > 5) { $this->triggerSecurityAlert($user, 'Multiple failed login attempts'); return true; } // Login from new location $uniqueIps = $recentLogins->pluck('properties.ip')->unique(); if ($uniqueIps->count() > 3) { $this->triggerSecurityAlert($user, 'Login from multiple IP addresses'); return true; } return false; } private function triggerSecurityAlert(User $user, string $reason): void { // Send security notification $user->notify(new SecurityAlertNotification($reason)); // Log security event activity('security') ->causedBy($user) ->withProperties(['reason' => $reason]) ->log('Security alert triggered'); } } ``` ## Backup Security ### Encrypted Backups ```php class SecureBackupService { public function createEncryptedBackup(ScheduledDatabaseBackup $backup): void { $database = $backup->database; $dumpPath = $this->createDatabaseDump($database); // Encrypt backup file $encryptedPath = $this->encryptFile($dumpPath, $backup->encryption_key); // Upload to secure storage $this->uploadToSecureStorage($encryptedPath, $backup->s3Storage); // Clean up local files unlink($dumpPath); unlink($encryptedPath); } private function encryptFile(string $filePath, string $key): string { $data = file_get_contents($filePath); $encryptedData = encrypt($data, $key); $encryptedPath = $filePath . '.encrypted'; file_put_contents($encryptedPath, $encryptedData); return $encryptedPath; } } ``` ## Security Headers & CORS ### Security Headers Configuration ```php // Security headers middleware class SecurityHeadersMiddleware { public function handle(Request $request, Closure $next): Response { $response = $next($request); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('X-XSS-Protection', '1; mode=block'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); if ($request->secure()) { $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } return $response; } } ``` ### CORS Configuration ```php // CORS configuration for API endpoints return [ 'paths' => ['api/*', 'webhooks/*'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'allowed_origins' => [ 'https://app.coolify.io', 'https://*.coolify.io' ], 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => true, ]; ``` ## Security Testing ### Security Test Patterns ```php // Security-focused tests test('prevents SQL injection in search', function () { $user = User::factory()->create(); $maliciousInput = "'; DROP TABLE applications; --"; $response = $this->actingAs($user) ->getJson("/api/v1/applications?search={$maliciousInput}"); $response->assertStatus(200); // Verify applications table still exists expect(Schema::hasTable('applications'))->toBeTrue(); }); test('prevents XSS in application names', function () { $user = User::factory()->create(); $xssPayload = ''; $response = $this->actingAs($user) ->postJson('/api/v1/applications', [ 'name' => $xssPayload, 'git_repository' => 'https://github.com/user/repo.git', 'server_id' => Server::factory()->create()->id ]); $response->assertStatus(422); }); test('enforces team isolation', function () { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $team1 = Team::factory()->create(); $team2 = Team::factory()->create(); $user1->teams()->attach($team1); $user2->teams()->attach($team2); $application = Application::factory()->create(['team_id' => $team1->id]); $response = $this->actingAs($user2) ->getJson("/api/v1/applications/{$application->id}"); $response->assertStatus(403); }); ```