diff --git a/CHANGELOG.md b/CHANGELOG.md index 9638671506..52071a7d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/Enums/InvitationMethod.php b/app/Enums/InvitationMethod.php new file mode 100644 index 0000000000..6ac758a3d1 --- /dev/null +++ b/app/Enums/InvitationMethod.php @@ -0,0 +1,11 @@ + config()->string('app.name'), + 'workspace' => function (): ?array { + $id = session('workspace'); + + return is_string($id) ? Workspace::find($id)?->only('id', 'name') : null; + }, ]; } } diff --git a/app/Http/Middleware/SetWorkspace.php b/app/Http/Middleware/SetWorkspace.php new file mode 100644 index 0000000000..06f0748c6f --- /dev/null +++ b/app/Http/Middleware/SetWorkspace.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/app/Models/CustomRole.php b/app/Models/CustomRole.php new file mode 100644 index 0000000000..ccf21e78d3 --- /dev/null +++ b/app/Models/CustomRole.php @@ -0,0 +1,53 @@ + $permissions + * @property-read CarbonInterface $created_at + * @property-read CarbonInterface $updated_at + */ +#[ScopedBy([WorkspaceScope::class])] +final class CustomRole extends Model +{ + use HasUlids; + + /** + * @return BelongsTo + */ + 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', + ]; + } +} diff --git a/app/Models/Scopes/WorkspaceScope.php b/app/Models/Scopes/WorkspaceScope.php new file mode 100644 index 0000000000..e8962c1882 --- /dev/null +++ b/app/Models/Scopes/WorkspaceScope.php @@ -0,0 +1,21 @@ +where($model->qualifyColumn('workspace_id'), $workspaceId); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 5c8e2620d9..e8fd5caa05 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; /** @@ -33,6 +35,22 @@ final class User extends Authenticatable use HasFactory; use HasUlids; + /** + * @return BelongsToMany + */ + public function workspaces(): BelongsToMany + { + return $this->belongsToMany(Workspace::class, 'workspace_members'); + } + + /** + * @return HasMany + */ + public function memberships(): HasMany + { + return $this->hasMany(WorkspaceMember::class); + } + protected function casts(): array { return [ diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php new file mode 100644 index 0000000000..4cab5e0e5b --- /dev/null +++ b/app/Models/Workspace.php @@ -0,0 +1,64 @@ + + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'workspace_members'); + } + + /** + * @return HasMany + */ + public function customRoles(): HasMany + { + return $this->hasMany(CustomRole::class); + } + + /** + * @return HasMany + */ + 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', + ]; + } +} diff --git a/app/Models/WorkspaceInvitation.php b/app/Models/WorkspaceInvitation.php new file mode 100644 index 0000000000..a3728af935 --- /dev/null +++ b/app/Models/WorkspaceInvitation.php @@ -0,0 +1,62 @@ + + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function customRole(): BelongsTo + { + return $this->belongsTo(CustomRole::class); + } + + protected function casts(): array + { + return [ + 'id' => 'string', + 'workspace_id' => 'string', + 'email' => 'string', + 'role' => UserRole::class, + 'custom_role_id' => 'string', + 'via' => InvitationMethod::class, + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/WorkspaceMember.php b/app/Models/WorkspaceMember.php new file mode 100644 index 0000000000..9edcdcf819 --- /dev/null +++ b/app/Models/WorkspaceMember.php @@ -0,0 +1,65 @@ + + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function customRole(): BelongsTo + { + return $this->belongsTo(CustomRole::class); + } + + protected function casts(): array + { + return [ + 'id' => 'string', + 'workspace_id' => 'string', + 'user_id' => 'string', + 'role' => UserRole::class, + 'custom_role_id' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 781820d106..97ea30555b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetWorkspace; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -16,5 +17,9 @@ $middleware->web(append: [ HandleInertiaRequests::class, ]); + + $middleware->alias([ + 'workspace' => SetWorkspace::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void {})->create(); diff --git a/database/migrations/2026_03_29_212354_create_workspaces_table.php b/database/migrations/2026_03_29_212354_create_workspaces_table.php new file mode 100644 index 0000000000..508e7af171 --- /dev/null +++ b/database/migrations/2026_03_29_212354_create_workspaces_table.php @@ -0,0 +1,23 @@ +ulid('id')->primary(); + $table->string('name'); + $table->string('description')->nullable(); + $table->smallInteger('concurrent_builds'); + $table->smallInteger('default_deployment_timeout'); + $table->boolean('is_2fa_required'); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2026_03_29_212414_create_custom_roles_table.php b/database/migrations/2026_03_29_212414_create_custom_roles_table.php new file mode 100644 index 0000000000..2750816ee9 --- /dev/null +++ b/database/migrations/2026_03_29_212414_create_custom_roles_table.php @@ -0,0 +1,25 @@ +ulid('id')->primary(); + $table->foreignUlid('workspace_id')->index()->constrained(); + $table->string('name'); + $table->string('identifier'); + $table->string('description')->nullable(); + $table->jsonb('permissions'); + $table->timestamps(); + + $table->unique(['workspace_id', 'identifier']); + }); + } +}; diff --git a/database/migrations/2026_03_29_212434_create_workspace_members_table.php b/database/migrations/2026_03_29_212434_create_workspace_members_table.php new file mode 100644 index 0000000000..35adaa6bea --- /dev/null +++ b/database/migrations/2026_03_29_212434_create_workspace_members_table.php @@ -0,0 +1,24 @@ +ulid('id')->primary(); + $table->foreignUlid('workspace_id')->index()->constrained(); + $table->foreignUlid('user_id')->index()->constrained(); + $table->string('role', 50); + $table->foreignUlid('custom_role_id')->nullable()->index()->constrained(); + $table->timestamps(); + + $table->unique(['workspace_id', 'user_id']); + }); + } +}; diff --git a/database/migrations/2026_03_29_212455_create_workspace_invitations_table.php b/database/migrations/2026_03_29_212455_create_workspace_invitations_table.php new file mode 100644 index 0000000000..1f4187ffc0 --- /dev/null +++ b/database/migrations/2026_03_29_212455_create_workspace_invitations_table.php @@ -0,0 +1,26 @@ +ulid('id')->primary(); + $table->foreignUlid('workspace_id')->index()->constrained(); + $table->string('email')->index(); + $table->string('role', 50); + $table->foreignUlid('custom_role_id')->nullable()->index()->constrained(); + $table->string('via', 50); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->unique(['workspace_id', 'email']); + }); + } +}; diff --git a/resources/js/app.ts b/resources/js/app.ts index 901c1bc3dc..5d642fbb1d 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,7 +1,19 @@ import "../css/app.css"; -import { createInertiaApp } from "@inertiajs/svelte"; +import { createInertiaApp, http, page } from "@inertiajs/svelte"; import Layout from "./layouts/Layout.svelte"; createInertiaApp({ layout: () => Layout, }).catch(console.error); + +// Auto-append workspace query parameter to every Inertia request. +http.onRequest((config) => { + const url = new URL(config.url, window.location.origin); + + if (!url.searchParams.has("workspace") && page.props.workspace?.id) { + url.searchParams.set("workspace", page.props.workspace.id); + config.url = url.pathname + url.search; + } + + return config; +}); diff --git a/resources/js/types/inertia.d.ts b/resources/js/types/inertia.d.ts index 492304e543..81147809be 100644 --- a/resources/js/types/inertia.d.ts +++ b/resources/js/types/inertia.d.ts @@ -4,6 +4,10 @@ declare module "@inertiajs/core" { export interface InertiaConfig { sharedPageProps: { appName: string; + workspace: { + id: string; + name: string; + } | null; }; // flashDataType: { // toast?: { type: "success" | "error"; message: string };