Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.

Most breaking changes are automatically handled by the upgrade migration. See the [v5 Upgrade Guide](docs) for details.

- Most description fields are now limited to 255 characters, as descriptions should rarely need more
- Database primary keys unified from two columns (numeric `id` + CUIDv2 `uuid`) into a single ULID `id` column

### Security
Expand All @@ -21,10 +22,21 @@ Most breaking changes are automatically handled by the upgrade migration. See th
- Complete redesign of the Coolify User Interface and User Experience
- Separate notification email in addition to the user's account email
- "Remember me" option on login, keeping users authenticated for 14 days (without it, sessions expire after 24 hours of inactivity)
- **Workspaces**
- Workspaces replacing v4 teams (clearer name, fully self-contained and isolated from each other)
- Option to require 2FA workspace-wide
- Concurrent builds limit and default deployment timeout to workspace settings
- Workspace global scope and middleware (~114 lines) replacing ~1,970 manual team checks, written in 12 different ways (~2,800 lines) across ~400 files
- Support for multiple workspaces open simultaneously in different browser tabs (via query parameter, avoiding a long and ugly path segment)
- Automatic workspace selection via URL parameter > cookie > session > auto-select (single workspace)
- **Permission System**
- `UserRole` enum defining all predefined roles in one place, replacing scattered role-check methods (`isAdmin()`, `isMember()`, etc.) with a single enum-based approach supporting granular permissions and adding new permissions to existing roles without a database migration
- `Permission` enum with granular permissions per feature (e.g. `WorkspaceRead`, `WorkspaceCreate`, `WorkspaceUpdate`, `WorkspaceDestroy`), defined as string-backed enums so changes are code-only, not database migrations
- Custom roles with configurable permissions assignable to workspace members
- **v4 to v5 upgrade migration**
- Coolify v4 database as `old_pgsql` connection
-
- Worker Servers that replace build servers with servers that can also run jobs (Horizon workers) in addition to building Docker images
- Worker servers replacing build servers, capable of running Horizon workers (queue job workers) in addition to building Docker images

### Changed

Expand Down Expand Up @@ -58,8 +70,8 @@ Most breaking changes are automatically handled by the upgrade migration. See th

- `laravel.log` file growing indefinitely and consuming excessive disk space
- Failed jobs being logged into the database (Horizon already handles this) causing excessive disk usage in some cases
- Maximum concurrent builds setting not being respected when set to more than 4 on v4.x because only 4 Horizon workers are available by default
-
- Maximum concurrent builds setting not respected when set above 4 in v4, as only 4 Horizon workers were available by default
- Concurrent builds setting incorrectly scoped per-server instead of workspace-wide

### Removed

Expand All @@ -70,6 +82,7 @@ Most breaking changes are automatically handled by the upgrade migration. See th

- All database migrations for a cleaner, more consistent and stable database schema (no more `down()` methods, no more defaults in the DB...)
- All database models for improved maintainability, consistency and database interoperability (SQLite and Postgres)
- Many hardcoded or static strings stored in DB columns into automatically cast PHP string-backed enums
- Hardcoded queue strings replaced with a `ProcessingQueue` enum
- `config/constants.php` to `config/coolify.php` for all Coolify-specific settings
- Environment variable naming to be shorter and more consistent
Expand Down
11 changes: 11 additions & 0 deletions app/Enums/InvitationMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum InvitationMethod: string
{
case Email = 'email';
case Link = 'link';
}
13 changes: 13 additions & 0 deletions app/Enums/Permission.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum Permission: string
{
case WorkspaceCreate = 'workspaceCreate';
case WorkspaceRead = 'workspaceRead';
case WorkspaceUpdate = 'workspaceUpdate';
case WorkspaceDelete = 'workspaceDelete';
}
11 changes: 11 additions & 0 deletions app/Enums/UserRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum UserRole: string
{
case Admin = 'admin';
case Custom = 'custom';
}
6 changes: 6 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Middleware;

use App\Models\Workspace;
use Illuminate\Http\Request;
use Inertia\Middleware;

Expand Down Expand Up @@ -50,6 +51,11 @@ public function share(Request $request): array
return [
...parent::share($request),
'appName' => config()->string('app.name'),
'workspace' => function (): ?array {
$id = session('workspace');

return is_string($id) ? Workspace::find($id)?->only('id', 'name') : null;
},
];
}
}
83 changes: 83 additions & 0 deletions app/Http/Middleware/SetWorkspace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

use function is_string;

final class SetWorkspace
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
/** @var User $user */
$user = $request->user();

if ($request->query->has('workspace')) {
$workspaceId = $request->query->getString('workspace');

// Abort if the workspace ID was tampered with, does not exist, or the user is not a member.
abort_unless($this->isMember($user, $workspaceId), 404);

$this->persist($workspaceId);

return $next($request);
}

return $this->resolveWorkspace($user, $request);
}

private function resolveWorkspace(User $user, Request $request): Response
{
$candidate = $request->cookie('workspace');

if (is_string($candidate) && $this->isMember($user, $candidate)) {
return $this->redirectWithWorkspace($request, $candidate);
}

$candidate = session('workspace');

if (is_string($candidate) && $this->isMember($user, $candidate)) {
return $this->redirectWithWorkspace($request, $candidate);
}

$count = $user->workspaces()->count();

if ($count === 1) {
return $this->redirectWithWorkspace($request, $user->workspaces()->sole()->id);
}

// TODO: Redirect to workspace selector.
// if ($count > 1) {
// return redirect('/');
// }

// TODO: Redirect to workspace creation.
return redirect('/');
}

private function redirectWithWorkspace(Request $request, string $workspaceId): Response
{
return redirect()->to($request->fullUrlWithQuery(['workspace' => $workspaceId]));
}

private function isMember(User $user, string $workspaceId): bool
{
return $user->workspaces()->whereKey($workspaceId)->exists();
}

private function persist(string $workspaceId): void
{
session(['workspace' => $workspaceId]);
context(['workspace' => $workspaceId]);
cookie()->queue('workspace', $workspaceId, 30 * 24 * 60);
}
}
53 changes: 53 additions & 0 deletions app/Models/CustomRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace App\Models;

use App\Enums\Permission;
use App\Models\Scopes\WorkspaceScope;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;

/**
* @property-read string $id
* @property-read string $workspace_id
* @property-read string $name
* @property-read string $identifier
* @property-read string|null $description
* @property-read Collection<int, Permission> $permissions
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
*/
#[ScopedBy([WorkspaceScope::class])]
final class CustomRole extends Model
{
use HasUlids;

/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}

protected function casts(): array
{
return [
'id' => 'string',
'workspace_id' => 'string',
'name' => 'string',
'identifier' => 'string',
'description' => 'string',
'permissions' => AsEnumCollection::of(Permission::class),
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}
21 changes: 21 additions & 0 deletions app/Models/Scopes/WorkspaceScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

final class WorkspaceScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$workspaceId = session('workspace')
?? context('workspace') // context fallback is needed for queued jobs where session is unavailable
?? '';

$builder->where($model->qualifyColumn('workspace_id'), $workspaceId);
}
}
18 changes: 18 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;

/**
Expand All @@ -33,6 +35,22 @@ final class User extends Authenticatable
use HasFactory;
use HasUlids;

/**
* @return BelongsToMany<Workspace, $this>
*/
public function workspaces(): BelongsToMany
{
return $this->belongsToMany(Workspace::class, 'workspace_members');
}

/**
* @return HasMany<WorkspaceMember, $this>
*/
public function memberships(): HasMany
{
return $this->hasMany(WorkspaceMember::class);
}

protected function casts(): array
{
return [
Expand Down
64 changes: 64 additions & 0 deletions app/Models/Workspace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
* @property-read string $id
* @property-read string $name
* @property-read string|null $description
* @property-read int $concurrent_builds
* @property-read int $default_deployment_timeout
* @property-read bool $is_2fa_required
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
*/
final class Workspace extends Model
{
use HasUlids;

/**
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'workspace_members');
}

/**
* @return HasMany<CustomRole, $this>
*/
public function customRoles(): HasMany
{
return $this->hasMany(CustomRole::class);
}

/**
* @return HasMany<WorkspaceMember, $this>
*/
public function members(): HasMany
{
return $this->hasMany(WorkspaceMember::class);
}

protected function casts(): array
{
return [
'id' => 'string',
'name' => 'string',
'description' => 'string',
'concurrent_builds' => 'integer',
'default_deployment_timeout' => 'integer',
'is_2fa_required' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}
Loading