789 lines
22 KiB
Plaintext
789 lines
22 KiB
Plaintext
---
|
|
description:
|
|
globs:
|
|
alwaysApply: false
|
|
---
|
|
# 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
|
|
|
|
### 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, '<p><br><strong><em>');
|
|
|
|
// 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 = '<script>alert("xss")</script>';
|
|
|
|
$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);
|
|
});
|
|
```
|