mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
feat(api): add instance email settings endpoints
Add root-team-authorized API access for SMTP and Resend settings with validation, sensitive-field controls, auditing, and coverage.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class InstanceEmailSettingsController extends Controller
|
||||
{
|
||||
private const FIELDS = [
|
||||
'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host',
|
||||
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
|
||||
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
|
||||
];
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get instance email settings',
|
||||
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.',
|
||||
path: '/settings/email', operationId: 'get-instance-email-settings',
|
||||
security: [['bearerAuth' => []]], tags: ['Settings'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Instance email settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
]
|
||||
)]
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
$settings = InstanceSettings::get();
|
||||
$this->authorizeRootTeam('view', $settings);
|
||||
|
||||
return response()->json($this->serialize($settings));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update instance email settings',
|
||||
description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.',
|
||||
path: '/settings/email', operationId: 'update-instance-email-settings',
|
||||
security: [['bearerAuth' => []]], tags: ['Settings'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated instance email settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$settings = InstanceSettings::get();
|
||||
$this->authorizeRootTeam('update', $settings);
|
||||
|
||||
$validator = customApiValidator($request->json()->all(), [
|
||||
'smtp_enabled' => 'sometimes|boolean',
|
||||
'smtp_from_address' => 'sometimes|nullable|email',
|
||||
'smtp_from_name' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_host' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
|
||||
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
|
||||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_password' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$settings->fill($validator->validated());
|
||||
$settings->save();
|
||||
|
||||
auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]);
|
||||
|
||||
return response()->json($this->serialize($settings->refresh()));
|
||||
}
|
||||
|
||||
private function authorizeRootTeam(string $ability, InstanceSettings $settings): void
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.');
|
||||
$this->authorize($ability, $settings);
|
||||
}
|
||||
|
||||
private function serialize(InstanceSettings $settings): array
|
||||
{
|
||||
exposeSensitiveFields($settings);
|
||||
|
||||
return Arr::only($settings->toArray(), self::FIELDS);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Api\DigitalOceanController;
|
||||
use App\Http\Controllers\Api\GithubController;
|
||||
use App\Http\Controllers\Api\GitlabController;
|
||||
use App\Http\Controllers\Api\HetznerController;
|
||||
use App\Http\Controllers\Api\InstanceEmailSettingsController;
|
||||
use App\Http\Controllers\Api\NotificationsController;
|
||||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
@@ -83,6 +84,8 @@ Route::group([
|
||||
Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']);
|
||||
Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']);
|
||||
Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']);
|
||||
Route::get('/settings/email', [InstanceEmailSettingsController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/settings/email', [InstanceEmailSettingsController::class, 'update'])->middleware(['api.ability:write:sensitive']);
|
||||
Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']);
|
||||
Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']);
|
||||
Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0]);
|
||||
});
|
||||
|
||||
function instanceEmailToken(User $user, Team $team, string $role, array $abilities): string
|
||||
{
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return $user->createToken('instance-email-test', $abilities)->plainTextToken;
|
||||
}
|
||||
|
||||
function instanceEmailHeaders(string $token): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
test('root team owners can get instance email settings', function () {
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'smtp_enabled' => true,
|
||||
'smtp_ehlo_domain' => 'coolify.example.com',
|
||||
]);
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['read']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->getJson('/api/v1/settings/email')
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('smtp_enabled', true)
|
||||
->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com')
|
||||
->assertJsonMissingPath('smtp_password');
|
||||
});
|
||||
|
||||
test('root team admins can update instance email settings', function () {
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['write:sensitive']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->patchJson('/api/v1/settings/email', [
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'alerts@example.com',
|
||||
'smtp_from_name' => 'Coolify',
|
||||
'smtp_host' => 'smtp.example.com',
|
||||
'smtp_port' => 587,
|
||||
'smtp_encryption' => 'starttls',
|
||||
'smtp_username' => 'coolify',
|
||||
'smtp_password' => 'secret',
|
||||
'smtp_timeout' => 10,
|
||||
'smtp_ehlo_domain' => 'coolify.example.com',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com');
|
||||
|
||||
$settings = InstanceSettings::findOrFail(0);
|
||||
expect($settings->smtp_enabled)->toBeTrue()
|
||||
->and($settings->smtp_host)->toBe('smtp.example.com')
|
||||
->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com');
|
||||
});
|
||||
|
||||
test('instance email settings reject non-root teams', function () {
|
||||
$team = Team::factory()->create();
|
||||
$token = instanceEmailToken(User::factory()->create(), $team, 'owner', ['read', 'write']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->getJson('/api/v1/settings/email')
|
||||
->assertForbidden();
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->patchJson('/api/v1/settings/email', ['smtp_enabled' => true])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('instance email settings reject root team members', function () {
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'member', ['read']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->getJson('/api/v1/settings/email')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('instance email settings validate the smtp ehlo domain', function () {
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write:sensitive']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->patchJson('/api/v1/settings/email', ['smtp_ehlo_domain' => 'not a hostname'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('smtp_ehlo_domain');
|
||||
});
|
||||
|
||||
test('updating instance email settings requires write sensitive', function () {
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->patchJson('/api/v1/settings/email', ['smtp_enabled' => true])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('root admins cannot use a token issued for another team', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($user->id, ['role' => 'owner']);
|
||||
$team = Team::factory()->create();
|
||||
$token = instanceEmailToken($user, $team, 'owner', ['read', 'write:sensitive']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->getJson('/api/v1/settings/email')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('read sensitive exposes instance email secrets to root team admins', function () {
|
||||
InstanceSettings::findOrFail(0)->update(['smtp_password' => 'secret']);
|
||||
$token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['read', 'read:sensitive']);
|
||||
|
||||
$this->withHeaders(instanceEmailHeaders($token))
|
||||
->getJson('/api/v1/settings/email')
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('smtp_password', 'secret');
|
||||
});
|
||||
Reference in New Issue
Block a user