2024-07-02 14:12:04 +00:00
< ? php
namespace App\Http\Controllers\Api ;
use App\Actions\Service\RestartService ;
use App\Actions\Service\StartService ;
use App\Actions\Service\StopService ;
use App\Http\Controllers\Controller ;
use App\Jobs\DeleteResourceJob ;
use App\Models\EnvironmentVariable ;
2026-03-23 14:15:02 +00:00
use App\Models\LocalFileVolume ;
use App\Models\LocalPersistentVolume ;
2024-07-02 14:12:04 +00:00
use App\Models\Project ;
use App\Models\Server ;
use App\Models\Service ;
2026-03-26 11:17:39 +00:00
use App\Support\ValidationPatterns ;
2026-05-11 09:53:22 +00:00
use Illuminate\Database\Eloquent\Model ;
2026-03-23 14:15:02 +00:00
use Illuminate\Http\JsonResponse ;
2024-07-02 14:12:04 +00:00
use Illuminate\Http\Request ;
2026-04-30 09:49:15 +00:00
use Illuminate\Support\Collection ;
2026-01-11 21:19:09 +00:00
use Illuminate\Support\Facades\Validator ;
2024-07-09 11:30:13 +00:00
use OpenApi\Attributes as OA ;
2025-03-19 08:22:34 +00:00
use Symfony\Component\Yaml\Yaml ;
2024-07-02 14:12:04 +00:00
class ServicesController extends Controller
{
2026-03-29 14:02:05 +00:00
use Concerns\HandlesTagsApi ;
protected function findTaggableResource ( string $uuid , int | string $teamId ) : mixed
{
return Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $uuid ) -> first ();
}
protected function tagResourceNotFoundMessage () : string
{
return 'Service not found.' ;
}
2026-07-02 13:50:43 +00:00
private function exposeFileStorageContentIfAllowed ( LocalFileVolume | LocalPersistentVolume $storage ) : LocalFileVolume | LocalPersistentVolume
{
if ( request () -> attributes -> get ( 'can_read_sensitive' , false ) === true ) {
$storage -> makeVisible ([ 'content' ]);
}
return $storage ;
}
2024-07-02 14:12:04 +00:00
private function removeSensitiveData ( $service )
{
2026-07-02 13:50:43 +00:00
if ( $service instanceof Collection ) {
return $service -> map ( fn ( Service $item ) => $this -> removeSensitiveData ( $item ));
}
2024-07-04 11:45:06 +00:00
$service -> makeHidden ([
'id' ,
2024-12-17 09:38:32 +00:00
'resourceable' ,
'resourceable_id' ,
'resourceable_type' ,
2024-07-04 11:45:06 +00:00
]);
2026-04-30 09:28:06 +00:00
if ( request () -> attributes -> get ( 'can_read_sensitive' , false ) === true ) {
$service -> makeVisible ([
2024-12-09 10:10:35 +00:00
'docker_compose_raw' ,
'docker_compose' ,
2024-12-12 12:20:13 +00:00
'value' ,
'real_value' ,
2024-12-09 10:10:35 +00:00
]);
2026-04-30 09:49:15 +00:00
$this -> exposeNestedServerSecrets ( $service );
2024-07-02 14:12:04 +00:00
}
2026-02-27 10:41:01 +00:00
if ( $service -> is_shown_once ? ? false ) {
$service -> makeHidden ([ 'value' , 'real_value' ]);
2024-07-02 14:12:04 +00:00
}
return serializeApiResponse ( $service );
}
2026-04-30 09:49:15 +00:00
/**
* Expose sensitive fields on eager - loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability .
* Handles both single models and Eloquent Collections ( the listing endpoint
* passes a Collection of Services per project to removeSensitiveData ()) .
*/
2026-05-11 09:53:22 +00:00
private function exposeNestedServerSecrets ( Model | Collection $model ) : void
2026-04-30 09:49:15 +00:00
{
2026-05-11 09:53:22 +00:00
if ( $model instanceof Collection ) {
2026-04-30 09:49:15 +00:00
foreach ( $model as $item ) {
$this -> exposeNestedServerSecrets ( $item );
}
return ;
}
$server = $model -> destination ? -> server ? ? $model -> server ? ? null ;
if ( ! $server ) {
return ;
}
$server -> makeVisible ([
'logdrain_axiom_api_key' ,
'logdrain_newrelic_license_key' ,
]);
$settings = $server -> settings ? ? null ;
if ( $settings ) {
$settings -> makeVisible ([
'sentinel_token' ,
'sentinel_custom_url' ,
'logdrain_newrelic_license_key' ,
'logdrain_axiom_api_key' ,
'logdrain_custom_config' ,
'logdrain_custom_config_parser' ,
]);
}
}
2026-01-13 19:04:44 +00:00
private function applyServiceUrls ( Service $service , array $urlsArray , string $teamId , bool $forceDomainOverride = false ) : ? array
2026-01-11 21:19:09 +00:00
{
$errors = [];
2026-01-13 18:25:58 +00:00
$conflicts = [];
2026-01-11 21:19:09 +00:00
2026-01-13 19:04:44 +00:00
$urls = collect ( $urlsArray ) -> flatMap ( function ( $item ) {
$urlValue = data_get ( $item , 'url' );
if ( blank ( $urlValue )) {
return [];
}
return str ( $urlValue ) -> replaceStart ( ',' , '' ) -> replaceEnd ( ',' , '' ) -> trim () -> explode ( ',' ) -> map ( fn ( $url ) => trim ( $url )) -> filter ();
});
2026-07-02 14:52:07 +00:00
$errors = ValidationPatterns :: validateApplicationDomains ( $urls -> implode ( ',' ));
$urls = collect ( ValidationPatterns :: applicationDomainList (
ValidationPatterns :: normalizeApplicationDomains ( $urls -> implode ( ',' ))
));
2026-01-13 19:04:44 +00:00
$duplicates = $urls -> duplicates () -> unique () -> values ();
if ( $duplicates -> isNotEmpty () && ! $forceDomainOverride ) {
2026-01-14 13:42:35 +00:00
$errors [] = 'The current request contains conflicting URLs across containers: ' . implode ( ', ' , $duplicates -> toArray ()) . '. Use force_domain_override=true to proceed.' ;
2026-01-13 19:04:44 +00:00
}
if ( count ( $errors ) > 0 ) {
return [ 'errors' => $errors ];
}
collect ( $urlsArray ) -> each ( function ( $item ) use ( $service , $teamId , $forceDomainOverride , & $errors , & $conflicts ) {
$name = data_get ( $item , 'name' );
$containerUrls = data_get ( $item , 'url' );
2026-01-11 21:19:09 +00:00
if ( blank ( $name )) {
$errors [] = 'Service container name is required to apply URLs.' ;
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
$application = $service -> applications () -> where ( 'name' , $name ) -> first ();
if ( ! $application ) {
$errors [] = " Service container with ' { $name } ' not found. " ;
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
2026-01-13 19:04:44 +00:00
if ( filled ( $containerUrls )) {
2026-07-02 14:52:07 +00:00
$containerUrls = ValidationPatterns :: normalizeApplicationDomains ( $containerUrls );
$containerUrlCollection = collect ( ValidationPatterns :: applicationDomainList ( $containerUrls ));
2026-01-13 18:25:58 +00:00
2026-07-02 14:52:07 +00:00
$result = checkIfDomainIsAlreadyUsedViaAPI ( $containerUrlCollection , $teamId , $application -> uuid );
2026-01-13 18:25:58 +00:00
if ( isset ( $result [ 'error' ])) {
$errors [] = $result [ 'error' ];
2026-01-13 19:04:44 +00:00
return ;
2026-01-13 18:25:58 +00:00
}
if ( $result [ 'hasConflicts' ] && ! $forceDomainOverride ) {
$conflicts = array_merge ( $conflicts , $result [ 'conflicts' ]);
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
} else {
2026-01-13 19:04:44 +00:00
$containerUrls = null ;
2026-01-11 21:19:09 +00:00
}
2026-01-13 19:04:44 +00:00
$application -> fqdn = $containerUrls ;
2026-01-11 21:19:09 +00:00
$application -> save ();
2026-01-13 19:04:44 +00:00
});
2026-01-11 21:19:09 +00:00
if ( ! empty ( $errors )) {
return [ 'errors' => $errors ];
}
2026-01-13 18:25:58 +00:00
if ( ! empty ( $conflicts )) {
return [
'conflicts' => $conflicts ,
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ,
];
}
2026-01-11 21:19:09 +00:00
return null ;
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'List' ,
description : 'List all services.' ,
path : '/services' ,
2024-09-04 08:09:10 +00:00
operationId : 'list-services' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
responses : [
new OA\Response (
response : 200 ,
description : 'Get all services' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/Service' )
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function services ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$projects = Project :: where ( 'team_id' , $teamId ) -> get ();
2024-07-02 14:12:04 +00:00
$services = collect ();
2026-05-11 09:53:22 +00:00
$serviceRelations = $request -> attributes -> get ( 'can_read_sensitive' , false ) === true
? [ 'destination.server.settings' ]
: [];
2024-07-02 14:12:04 +00:00
foreach ( $projects as $project ) {
2026-05-11 09:53:22 +00:00
$services -> push ( $project -> services () -> with ( $serviceRelations ) -> get ());
2024-07-02 14:12:04 +00:00
}
foreach ( $services as $service ) {
$service = $this -> removeSensitiveData ( $service );
}
2024-07-04 11:45:06 +00:00
return response () -> json ( $services -> flatten ());
2024-07-02 14:12:04 +00:00
}
2024-07-09 11:30:13 +00:00
#[OA\Post(
2025-03-21 10:31:17 +00:00
summary : 'Create service' ,
description : 'Create a one-click / custom service' ,
path : '/services' ,
operationId : 'create-service' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
requestBody : new OA\RequestBody (
required : true ,
content : new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
2025-04-03 14:02:59 +00:00
required : [ 'server_uuid' , 'project_uuid' , 'environment_name' , 'environment_uuid' ],
2024-07-09 11:30:13 +00:00
properties : [
2026-01-10 21:29:11 +00:00
'type' => [ 'description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)' , 'type' => 'string' ],
2024-07-09 11:59:54 +00:00
'name' => [ 'type' => 'string' , 'maxLength' => 255 , 'description' => 'Name of the service.' ],
'description' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'Description of the service.' ],
'project_uuid' => [ 'type' => 'string' , 'description' => 'Project UUID.' ],
2024-12-17 12:42:16 +00:00
'environment_name' => [ 'type' => 'string' , 'description' => 'Environment name. You need to provide at least one of environment_name or environment_uuid.' ],
'environment_uuid' => [ 'type' => 'string' , 'description' => 'Environment UUID. You need to provide at least one of environment_name or environment_uuid.' ],
2024-07-09 11:59:54 +00:00
'server_uuid' => [ 'type' => 'string' , 'description' => 'Server UUID.' ],
'destination_uuid' => [ 'type' => 'string' , 'description' => 'Destination UUID. Required if server has multiple destinations.' ],
'instant_deploy' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Start the service immediately after creation.' ],
2026-01-11 17:26:11 +00:00
'docker_compose_raw' => [ 'type' => 'string' , 'description' => 'The base64 encoded Docker Compose content.' ],
2026-01-11 21:19:09 +00:00
'urls' => [
'type' => 'array' ,
'description' => 'Array of URLs to be applied to containers of a service.' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name as defined in docker-compose.' ],
2026-03-18 07:23:24 +00:00
'url' => [ 'type' => 'string' , 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").' ],
2026-01-11 21:19:09 +00:00
],
),
],
2026-01-13 18:25:58 +00:00
'force_domain_override' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Force domain override even if conflicts are detected.' ],
2026-03-13 12:32:58 +00:00
'is_container_label_escape_enabled' => [ 'type' => 'boolean' , 'default' => true , 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' ],
2026-03-29 14:02:05 +00:00
'tags' => [ 'type' => 'array' , 'items' => new OA\Items ( type : 'string' ), 'description' => 'Tags to assign to the service.' ],
2024-07-09 11:30:13 +00:00
],
),
),
),
responses : [
new OA\Response (
response : 201 ,
2025-03-19 08:22:34 +00:00
description : 'Service created successfully.' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
2024-07-09 11:59:54 +00:00
'uuid' => [ 'type' => 'string' , 'description' => 'Service UUID.' ],
'domains' => [ 'type' => 'array' , 'items' => [ 'type' => 'string' ], 'description' => 'Service domains.' ],
2024-07-09 11:30:13 +00:00
]
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
2026-01-13 18:25:58 +00:00
new OA\Response (
response : 409 ,
description : 'Domain conflicts detected.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ],
'warning' => [ 'type' => 'string' , 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ],
'conflicts' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'domain' => [ 'type' => 'string' , 'example' => 'example.com' ],
'resource_name' => [ 'type' => 'string' , 'example' => 'My Application' ],
'resource_uuid' => [ 'type' => 'string' , 'nullable' => true , 'example' => 'abc123-def456' ],
'resource_type' => [ 'type' => 'string' , 'enum' => [ 'application' , 'service' , 'instance' ], 'example' => 'application' ],
'message' => [ 'type' => 'string' , 'example' => 'Domain example.com is already in use by application \'My Application\'' ],
]
),
],
]
)
),
]
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-07-09 11:30:13 +00:00
]
)]
2025-03-21 10:31:17 +00:00
public function create_service ( Request $request )
2024-07-02 14:12:04 +00:00
{
2026-03-29 14:02:05 +00:00
$allowedFields = [ 'type' , 'name' , 'description' , 'project_uuid' , 'environment_name' , 'environment_uuid' , 'server_uuid' , 'destination_uuid' , 'instant_deploy' , 'docker_compose_raw' , 'urls' , 'force_domain_override' , 'is_container_label_escape_enabled' , 'tags' ];
2024-07-02 14:12:04 +00:00
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'create' , Service :: class );
2024-07-02 14:12:04 +00:00
$return = validateIncomingRequest ( $request );
2026-03-25 22:44:37 +00:00
if ( $return instanceof JsonResponse ) {
2024-07-02 14:12:04 +00:00
return $return ;
}
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-03-21 10:31:17 +00:00
'type' => 'string|required_without:docker_compose_raw' ,
'docker_compose_raw' => 'string|required_without:type' ,
2024-07-02 14:12:04 +00:00
'project_uuid' => 'string|required' ,
2024-12-17 12:42:16 +00:00
'environment_name' => 'string|nullable' ,
'environment_uuid' => 'string|nullable' ,
2024-07-02 14:12:04 +00:00
'server_uuid' => 'string|required' ,
2025-04-03 14:02:59 +00:00
'destination_uuid' => 'string|nullable' ,
2024-07-02 14:12:04 +00:00
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-03-13 12:32:58 +00:00
'is_container_label_escape_enabled' => 'boolean' ,
2026-03-29 14:02:05 +00:00
'tags' => 'array|nullable' ,
'tags.*' => 'string|min:2' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2024-07-02 14:12:04 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
2025-01-07 14:31:43 +00:00
if ( $validator -> fails () || ! empty ( $extraFields )) {
2024-07-02 14:12:04 +00:00
$errors = $validator -> errors ();
2025-01-07 14:31:43 +00:00
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
2024-07-02 14:12:04 +00:00
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
2026-01-10 21:29:11 +00:00
2026-07-07 11:56:33 +00:00
$return = $this -> validateTagsParameter ( $request );
if ( $return instanceof JsonResponse ) {
return $return ;
}
2026-01-10 21:29:11 +00:00
if ( filled ( $request -> type ) && filled ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.' ,
], 422 );
}
2024-12-17 12:42:16 +00:00
$environmentUuid = $request -> environment_uuid ;
$environmentName = $request -> environment_name ;
if ( blank ( $environmentUuid ) && blank ( $environmentName )) {
return response () -> json ([ 'message' => 'You need to provide at least one of environment_name or environment_uuid.' ], 422 );
}
2024-07-02 14:12:04 +00:00
$serverUuid = $request -> server_uuid ;
$instantDeploy = $request -> instant_deploy ? ? false ;
if ( $request -> is_public && ! $request -> public_port ) {
$request -> offsetSet ( 'is_public' , false );
}
$project = Project :: whereTeamId ( $teamId ) -> whereUuid ( $request -> project_uuid ) -> first ();
if ( ! $project ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Project not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2024-12-17 12:42:16 +00:00
$environment = $project -> environments () -> where ( 'name' , $environmentName ) -> first ();
if ( ! $environment ) {
$environment = $project -> environments () -> where ( 'uuid' , $environmentUuid ) -> first ();
}
2024-07-02 14:12:04 +00:00
if ( ! $environment ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Environment not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
$server = Server :: whereTeamId ( $teamId ) -> whereUuid ( $serverUuid ) -> first ();
if ( ! $server ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2026-07-16 19:35:23 +00:00
if ( ! $server -> canHostResources ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'server_uuid' => [ 'The specified server is configured as a build server and cannot host resources.' ]],
], 422 );
}
2024-07-02 14:12:04 +00:00
$destinations = $server -> destinations ();
if ( $destinations -> count () == 0 ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server has no destinations.' ], 400 );
2024-07-02 14:12:04 +00:00
}
if ( $destinations -> count () > 1 && ! $request -> has ( 'destination_uuid' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server has multiple destinations and you do not set destination_uuid.' ], 400 );
2024-07-02 14:12:04 +00:00
}
$destination = $destinations -> first ();
2026-02-27 02:15:18 +00:00
if ( $destinations -> count () > 1 && $request -> has ( 'destination_uuid' )) {
$destination = $destinations -> where ( 'uuid' , $request -> destination_uuid ) -> first ();
if ( ! $destination ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.' ,
],
], 422 );
}
}
2024-07-02 14:12:04 +00:00
$services = get_service_templates ();
$serviceKeys = $services -> keys ();
if ( $serviceKeys -> contains ( $request -> type )) {
$oneClickServiceName = $request -> type ;
$oneClickService = data_get ( $services , " $oneClickServiceName .compose " );
$oneClickDotEnvs = data_get ( $services , " $oneClickServiceName .envs " , null );
if ( $oneClickDotEnvs ) {
$oneClickDotEnvs = str ( base64_decode ( $oneClickDotEnvs )) -> split ( '/\r\n|\r|\n/' ) -> filter ( function ( $value ) {
2025-01-07 14:31:43 +00:00
return ! empty ( $value );
2024-07-02 14:12:04 +00:00
});
}
if ( $oneClickService ) {
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
$dockerComposeRaw = base64_decode ( $oneClickService );
// Validate for command injection BEFORE creating service
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-10-15 20:07:39 +00:00
$servicePayload = [
2024-07-02 14:12:04 +00:00
'name' => " $oneClickServiceName - " . str () -> random ( 10 ),
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
'docker_compose_raw' => $dockerComposeRaw ,
2024-07-02 14:12:04 +00:00
'environment_id' => $environment -> id ,
'service_type' => $oneClickServiceName ,
'server_id' => $server -> id ,
'destination_id' => $destination -> id ,
'destination_type' => $destination -> getMorphClass (),
];
2025-11-28 09:29:08 +00:00
if ( in_array ( $oneClickServiceName , NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK )) {
2025-10-15 20:07:39 +00:00
data_set ( $servicePayload , 'connect_to_docker_network' , true );
2024-07-02 14:12:04 +00:00
}
2026-07-16 19:35:23 +00:00
$service = new Service ( $servicePayload );
$service -> save ();
2026-01-13 16:26:51 +00:00
$service -> name = $request -> name ? ? " $oneClickServiceName - " . $service -> uuid ;
$service -> description = $request -> description ;
2026-03-13 12:32:58 +00:00
if ( $request -> has ( 'is_container_label_escape_enabled' )) {
$service -> is_container_label_escape_enabled = $request -> boolean ( 'is_container_label_escape_enabled' );
}
2024-07-02 14:12:04 +00:00
$service -> save ();
if ( $oneClickDotEnvs ? -> count () > 0 ) {
$oneClickDotEnvs -> each ( function ( $value ) use ( $service ) {
$key = str () -> before ( $value , '=' );
$value = str ( str () -> after ( $value , '=' ));
$generatedValue = $value ;
if ( $value -> contains ( 'SERVICE_' )) {
$command = $value -> after ( 'SERVICE_' ) -> beforeLast ( '_' );
$generatedValue = generateEnvValue ( $command -> value (), $service );
}
2025-01-07 14:31:43 +00:00
EnvironmentVariable :: create ([
2024-07-02 14:12:04 +00:00
'key' => $key ,
'value' => $generatedValue ,
2024-12-17 09:38:32 +00:00
'resourceable_id' => $service -> id ,
'resourceable_type' => $service -> getMorphClass (),
2024-07-02 14:12:04 +00:00
'is_preview' => false ,
]);
});
}
$service -> parse ( isNew : true );
2025-11-28 15:33:27 +00:00
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites ( $service );
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
$service -> delete ();
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2026-01-11 21:19:09 +00:00
}
}
2026-03-29 14:02:05 +00:00
if ( $request -> has ( 'tags' )) {
$this -> attachTagsToResource ( $service , $request -> tags , $teamId );
}
2024-07-02 14:12:04 +00:00
if ( $instantDeploy ) {
StartService :: dispatch ( $service );
}
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.created' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
'service_type' => $oneClickServiceName ? ? null ,
'instant_deploy' => ( bool ) $instantDeploy ,
]);
2024-07-02 14:12:04 +00:00
return response () -> json ([
2024-07-04 11:45:06 +00:00
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
]) -> setStatusCode ( 201 );
2024-07-02 14:12:04 +00:00
}
2025-03-21 10:31:17 +00:00
return response () -> json ([ 'message' => 'Service not found.' , 'valid_service_types' => $serviceKeys ], 404 );
} elseif ( filled ( $request -> docker_compose_raw )) {
2026-03-29 14:02:05 +00:00
$allowedFields = [ 'name' , 'description' , 'project_uuid' , 'environment_name' , 'environment_uuid' , 'server_uuid' , 'destination_uuid' , 'instant_deploy' , 'docker_compose_raw' , 'connect_to_docker_network' , 'urls' , 'force_domain_override' , 'is_container_label_escape_enabled' , 'tags' ];
2025-08-17 17:45:12 +00:00
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-08-17 17:45:12 +00:00
'project_uuid' => 'string|required' ,
'environment_name' => 'string|nullable' ,
'environment_uuid' => 'string|nullable' ,
'server_uuid' => 'string|required' ,
'destination_uuid' => 'string' ,
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
'connect_to_docker_network' => 'boolean' ,
'docker_compose_raw' => 'string|required' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-03-13 12:32:58 +00:00
'is_container_label_escape_enabled' => 'boolean' ,
2026-07-07 11:56:33 +00:00
'tags' => 'array|nullable' ,
'tags.*' => 'string|min:2' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2025-08-17 17:45:12 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
$environmentUuid = $request -> environment_uuid ;
$environmentName = $request -> environment_name ;
if ( blank ( $environmentUuid ) && blank ( $environmentName )) {
return response () -> json ([ 'message' => 'You need to provide at least one of environment_name or environment_uuid.' ], 422 );
}
$serverUuid = $request -> server_uuid ;
$projectUuid = $request -> project_uuid ;
$project = Project :: whereTeamId ( $teamId ) -> whereUuid ( $projectUuid ) -> first ();
if ( ! $project ) {
return response () -> json ([ 'message' => 'Project not found.' ], 404 );
}
$environment = $project -> environments () -> where ( 'name' , $environmentName ) -> first ();
if ( ! $environment ) {
$environment = $project -> environments () -> where ( 'uuid' , $environmentUuid ) -> first ();
}
if ( ! $environment ) {
return response () -> json ([ 'message' => 'Environment not found.' ], 404 );
}
$server = Server :: whereTeamId ( $teamId ) -> whereUuid ( $serverUuid ) -> first ();
if ( ! $server ) {
return response () -> json ([ 'message' => 'Server not found.' ], 404 );
}
2026-07-16 19:35:23 +00:00
if ( ! $server -> canHostResources ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'server_uuid' => [ 'The specified server is configured as a build server and cannot host resources.' ]],
], 422 );
}
2025-08-17 17:45:12 +00:00
$destinations = $server -> destinations ();
if ( $destinations -> count () == 0 ) {
return response () -> json ([ 'message' => 'Server has no destinations.' ], 400 );
}
if ( $destinations -> count () > 1 && ! $request -> has ( 'destination_uuid' )) {
return response () -> json ([ 'message' => 'Server has multiple destinations and you do not set destination_uuid.' ], 400 );
}
$destination = $destinations -> first ();
2026-02-27 02:15:18 +00:00
if ( $destinations -> count () > 1 && $request -> has ( 'destination_uuid' )) {
$destination = $destinations -> where ( 'uuid' , $request -> destination_uuid ) -> first ();
if ( ! $destination ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.' ,
],
], 422 );
}
}
2025-08-17 17:45:12 +00:00
if ( ! isBase64Encoded ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerComposeRaw = base64_decode ( $request -> docker_compose_raw );
2026-01-13 15:53:11 +00:00
if ( mb_detect_encoding ( $dockerComposeRaw , 'UTF-8' , true ) === false ) {
2025-08-17 17:45:12 +00:00
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerCompose = base64_decode ( $request -> docker_compose_raw );
$dockerComposeRaw = Yaml :: dump ( Yaml :: parse ( $dockerCompose ), 10 , 2 , Yaml :: DUMP_MULTI_LINE_LITERAL_BLOCK );
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
// Validate for command injection BEFORE saving to database
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-08-17 17:45:12 +00:00
$connectToDockerNetwork = $request -> connect_to_docker_network ? ? false ;
$instantDeploy = $request -> instant_deploy ? ? false ;
2024-07-02 14:12:04 +00:00
2025-03-21 10:31:17 +00:00
$service = new Service ;
2025-08-17 17:45:12 +00:00
$service -> name = $request -> name ? ? 'service-' . str () -> random ( 10 );
$service -> description = $request -> description ;
$service -> docker_compose_raw = $dockerComposeRaw ;
$service -> environment_id = $environment -> id ;
$service -> server_id = $server -> id ;
$service -> destination_id = $destination -> id ;
$service -> destination_type = $destination -> getMorphClass ();
$service -> connect_to_docker_network = $connectToDockerNetwork ;
2026-03-13 12:32:58 +00:00
if ( $request -> has ( 'is_container_label_escape_enabled' )) {
$service -> is_container_label_escape_enabled = $request -> boolean ( 'is_container_label_escape_enabled' );
}
2025-08-17 17:45:12 +00:00
$service -> save ();
$service -> parse ( isNew : true );
2025-03-14 14:26:48 +00:00
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
$service -> delete ();
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2025-08-17 17:45:12 +00:00
}
2026-01-11 21:19:09 +00:00
}
2025-08-17 17:45:12 +00:00
2026-03-29 14:02:05 +00:00
if ( $request -> has ( 'tags' )) {
$this -> attachTagsToResource ( $service , $request -> tags , $teamId );
}
2026-01-11 21:19:09 +00:00
if ( $instantDeploy ) {
StartService :: dispatch ( $service );
}
2025-08-17 17:45:12 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.created' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
'service_type' => 'docker_compose' ,
'instant_deploy' => ( bool ) $instantDeploy ,
]);
2025-08-17 17:45:12 +00:00
return response () -> json ([
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
2025-08-17 17:45:12 +00:00
]) -> setStatusCode ( 201 );
2026-01-10 21:29:11 +00:00
} elseif ( filled ( $request -> type )) {
return response () -> json ([
'message' => 'Invalid service type.' ,
'valid_service_types' => $serviceKeys ,
], 404 );
2025-03-14 14:26:48 +00:00
}
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'Get' ,
description : 'Get service by UUID.' ,
path : '/services/{uuid}' ,
2024-09-04 08:09:10 +00:00
operationId : 'get-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter ( name : 'uuid' , in : 'path' , required : true , description : 'Service UUID' , schema : new OA\Schema ( type : 'string' )),
],
responses : [
new OA\Response (
response : 200 ,
2024-09-09 16:38:40 +00:00
description : 'Get a service by UUID.' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
ref : '#/components/schemas/Service'
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function service_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
if ( ! $request -> uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'view' , $service );
2026-05-11 09:53:22 +00:00
$serviceRelations = [ 'applications' , 'databases' ];
if ( $request -> attributes -> get ( 'can_read_sensitive' , false ) === true ) {
$serviceRelations [] = 'destination.server.settings' ;
}
$service = $service -> load ( $serviceRelations );
2024-08-07 07:20:55 +00:00
2024-07-03 11:13:38 +00:00
return response () -> json ( $this -> removeSensitiveData ( $service ));
2024-07-02 14:12:04 +00:00
}
2025-07-30 02:40:02 +00:00
#[OA\Get(
summary : 'Get service logs.' ,
2026-07-06 21:58:12 +00:00
description : 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.' ,
2025-07-30 15:29:27 +00:00
path : '/services/{uuid}/logs' ,
2025-07-30 02:40:02 +00:00
operationId : 'get-service-logs-by-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
format : 'uuid' ,
)
),
new OA\Parameter (
2025-07-30 15:29:27 +00:00
name : 'sub_service_name' ,
in : 'query' ,
2026-07-06 21:58:12 +00:00
description : 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.' ,
2025-07-30 02:40:02 +00:00
required : true ,
2026-07-06 21:58:12 +00:00
schema : new OA\Schema ( type : 'string' , example : 'appwrite-console' ),
2025-07-30 02:40:02 +00:00
),
new OA\Parameter (
name : 'lines' ,
in : 'query' ,
description : 'Number of lines to show from the end of the logs.' ,
required : false ,
schema : new OA\Schema (
type : 'integer' ,
format : 'int32' ,
default : 100 ,
)
),
2025-07-31 01:32:11 +00:00
new OA\Parameter (
name : 'show_timestamps' ,
in : 'query' ,
description : 'Show timestamps in the logs.' ,
required : false ,
schema : new OA\Schema ( type : 'boolean' , default : false ),
),
2025-07-30 02:40:02 +00:00
],
responses : [
new OA\Response (
response : 200 ,
description : 'Get service logs by UUID.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'logs' => [ 'type' => 'string' ],
]
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function logs_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
}
2025-07-30 15:29:27 +00:00
$subServiceName = $request -> query -> get ( 'sub_service_name' );
if ( ! $subServiceName ) {
return response () -> json ([ 'message' => 'Sub service name is required.' ], 400 );
}
2025-07-30 02:40:02 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-07-30 17:41:17 +00:00
$name = " { $subServiceName } - { $service -> uuid } " ;
$containers = getCurrentServiceSubContainerStatus ( $service -> destination -> server , $service -> id , $name );
2025-07-30 15:29:27 +00:00
$container = $containers -> first ();
2025-07-30 02:40:02 +00:00
if ( ! $container ) {
return response () -> json ([ 'message' => 'Container not found.' ], 404 );
}
$status = getContainerStatus ( $service -> destination -> server , $container [ 'Names' ]);
if ( $status !== 'running' ) {
return response () -> json ([
'message' => 'Container is not running.' ,
], 400 );
}
2026-07-06 21:58:12 +00:00
$lines = normalizeLogLines ( $request -> query ( 'lines' ));
$showTimestamps = parseLogTimestampFlag ( $request -> query ( 'show_timestamps' ));
2025-07-31 01:32:11 +00:00
$logs = getContainerLogs ( $service -> destination -> server , $container [ 'ID' ], $lines , $showTimestamps );
2025-07-30 02:40:02 +00:00
return response () -> json ([
'logs' => $logs ,
]);
}
2024-07-09 11:30:13 +00:00
#[OA\Delete(
summary : 'Delete' ,
description : 'Delete service by UUID.' ,
path : '/services/{uuid}' ,
2024-09-04 08:09:10 +00:00
operationId : 'delete-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter ( name : 'uuid' , in : 'path' , required : true , description : 'Service UUID' , schema : new OA\Schema ( type : 'string' )),
2024-10-01 07:02:16 +00:00
new OA\Parameter ( name : 'delete_configurations' , in : 'query' , required : false , description : 'Delete configurations.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'delete_volumes' , in : 'query' , required : false , description : 'Delete volumes.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'docker_cleanup' , in : 'query' , required : false , description : 'Run docker cleanup.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'delete_connected_networks' , in : 'query' , required : false , description : 'Delete connected networks.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
2024-07-09 11:30:13 +00:00
],
responses : [
new OA\Response (
response : 200 ,
2024-09-09 16:38:40 +00:00
description : 'Delete a service by UUID' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service deletion request queued.' ],
],
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function delete_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
if ( ! $request -> uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2024-10-01 07:02:16 +00:00
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'delete' , $service );
2024-10-01 07:02:16 +00:00
DeleteResourceJob :: dispatch (
resource : $service ,
2025-10-26 15:25:44 +00:00
deleteVolumes : $request -> boolean ( 'delete_volumes' , true ),
deleteConnectedNetworks : $request -> boolean ( 'delete_connected_networks' , true ),
deleteConfigurations : $request -> boolean ( 'delete_configurations' , true ),
dockerCleanup : $request -> boolean ( 'docker_cleanup' , true )
2024-10-01 07:02:16 +00:00
);
2024-07-02 14:12:04 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.deleted' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
]);
2024-07-02 14:12:04 +00:00
return response () -> json ([
'message' => 'Service deletion request queued.' ,
]);
}
2025-03-20 06:28:28 +00:00
#[OA\Patch(
summary : 'Update' ,
description : 'Update service by UUID.' ,
path : '/services/{uuid}' ,
operationId : 'update-service-by-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
2025-04-09 16:52:12 +00:00
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
2025-03-20 06:28:28 +00:00
requestBody : new OA\RequestBody (
description : 'Service updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name.' ],
'description' => [ 'type' => 'string' , 'description' => 'The service description.' ],
'instant_deploy' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the service should be deployed instantly.' ],
'connect_to_docker_network' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Connect the service to the predefined docker network.' ],
2026-01-11 17:26:11 +00:00
'docker_compose_raw' => [ 'type' => 'string' , 'description' => 'The base64 encoded Docker Compose content.' ],
2026-01-11 21:19:09 +00:00
'urls' => [
'type' => 'array' ,
'description' => 'Array of URLs to be applied to containers of a service.' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name as defined in docker-compose.' ],
2026-03-18 07:23:24 +00:00
'url' => [ 'type' => 'string' , 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").' ],
2026-01-11 21:19:09 +00:00
],
),
],
2026-01-13 18:25:58 +00:00
'force_domain_override' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Force domain override even if conflicts are detected.' ],
2026-03-13 12:32:58 +00:00
'is_container_label_escape_enabled' => [ 'type' => 'boolean' , 'default' => true , 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' ],
2025-03-20 06:28:28 +00:00
],
)
),
]
),
responses : [
new OA\Response (
response : 200 ,
description : 'Service updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'uuid' => [ 'type' => 'string' , 'description' => 'Service UUID.' ],
'domains' => [ 'type' => 'array' , 'items' => [ 'type' => 'string' ], 'description' => 'Service domains.' ],
]
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
2026-01-13 18:25:58 +00:00
new OA\Response (
response : 409 ,
description : 'Domain conflicts detected.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ],
'warning' => [ 'type' => 'string' , 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ],
'conflicts' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'domain' => [ 'type' => 'string' , 'example' => 'example.com' ],
'resource_name' => [ 'type' => 'string' , 'example' => 'My Application' ],
'resource_uuid' => [ 'type' => 'string' , 'nullable' => true , 'example' => 'abc123-def456' ],
'resource_type' => [ 'type' => 'string' , 'enum' => [ 'application' , 'service' , 'instance' ], 'example' => 'application' ],
'message' => [ 'type' => 'string' , 'example' => 'Domain example.com is already in use by application \'My Application\'' ],
]
),
],
]
)
),
]
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2025-03-20 06:28:28 +00:00
]
)]
public function update_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$return = validateIncomingRequest ( $request );
2026-03-25 22:44:37 +00:00
if ( $return instanceof JsonResponse ) {
2025-03-20 06:28:28 +00:00
return $return ;
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'update' , $service );
2026-03-13 12:32:58 +00:00
$allowedFields = [ 'name' , 'description' , 'instant_deploy' , 'docker_compose_raw' , 'connect_to_docker_network' , 'urls' , 'force_domain_override' , 'is_container_label_escape_enabled' ];
2025-03-20 06:28:28 +00:00
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-03-20 06:28:28 +00:00
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
'connect_to_docker_network' => 'boolean' ,
2025-07-30 19:59:35 +00:00
'docker_compose_raw' => 'string|nullable' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-03-13 12:32:58 +00:00
'is_container_label_escape_enabled' => 'boolean' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2025-03-20 06:28:28 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
2025-07-30 19:59:35 +00:00
if ( $request -> has ( 'docker_compose_raw' )) {
if ( ! isBase64Encoded ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerComposeRaw = base64_decode ( $request -> docker_compose_raw );
2026-01-13 15:53:11 +00:00
if ( mb_detect_encoding ( $dockerComposeRaw , 'UTF-8' , true ) === false ) {
2025-07-30 19:59:35 +00:00
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerCompose = base64_decode ( $request -> docker_compose_raw );
$dockerComposeRaw = Yaml :: dump ( Yaml :: parse ( $dockerCompose ), 10 , 2 , Yaml :: DUMP_MULTI_LINE_LITERAL_BLOCK );
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
// Validate for command injection BEFORE saving to database
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-07-30 19:59:35 +00:00
$service -> docker_compose_raw = $dockerComposeRaw ;
2025-03-20 06:28:28 +00:00
}
2025-08-17 17:45:12 +00:00
if ( $request -> has ( 'name' )) {
$service -> name = $request -> name ;
}
if ( $request -> has ( 'description' )) {
$service -> description = $request -> description ;
}
if ( $request -> has ( 'connect_to_docker_network' )) {
$service -> connect_to_docker_network = $request -> connect_to_docker_network ;
}
2026-03-13 12:32:58 +00:00
if ( $request -> has ( 'is_container_label_escape_enabled' )) {
$service -> is_container_label_escape_enabled = $request -> boolean ( 'is_container_label_escape_enabled' );
}
2025-03-20 06:28:28 +00:00
$service -> save ();
2025-08-17 17:45:12 +00:00
2025-03-20 06:28:28 +00:00
$service -> parse ();
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2025-03-20 06:28:28 +00:00
}
2026-01-11 21:19:09 +00:00
}
2025-03-20 06:28:28 +00:00
2026-01-11 21:19:09 +00:00
if ( $request -> instant_deploy ) {
StartService :: dispatch ( $service );
}
2025-03-20 06:28:28 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.updated' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
'changed_fields' => array_values ( array_intersect ( $allowedFields , array_keys ( $request -> all ()))),
]);
2025-03-20 06:28:28 +00:00
2025-08-17 17:45:12 +00:00
return response () -> json ([
2025-03-20 06:28:28 +00:00
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
2025-08-17 17:45:12 +00:00
]) -> setStatusCode ( 200 );
2025-03-20 06:28:28 +00:00
}
2024-09-05 20:54:20 +00:00
#[OA\Get(
summary : 'List Envs' ,
description : 'List all envs by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'list-envs-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'All environment variables by service UUID.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/EnvironmentVariable' )
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function envs ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-06 08:48:47 +00:00
$envs = $service -> environment_variables -> map ( function ( $env ) {
2024-09-05 20:54:20 +00:00
$env -> makeHidden ([
'application_id' ,
'standalone_clickhouse_id' ,
'standalone_dragonfly_id' ,
'standalone_keydb_id' ,
'standalone_mariadb_id' ,
'standalone_mongodb_id' ,
'standalone_mysql_id' ,
'standalone_postgresql_id' ,
'standalone_redis_id' ,
]);
2024-10-31 17:20:11 +00:00
return $this -> removeSensitiveData ( $env );
2024-09-05 20:54:20 +00:00
});
2024-09-06 08:48:47 +00:00
return response () -> json ( $envs );
2024-09-05 20:54:20 +00:00
}
#[OA\Patch(
summary : 'Update Env' ,
description : 'Update env by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'update-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Env updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'key' , 'value' ],
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
),
],
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variable updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
2026-02-09 13:48:16 +00:00
ref : '#/components/schemas/EnvironmentVariable'
2024-09-05 20:54:20 +00:00
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function update_env_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2026-03-19 20:56:58 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> route ( 'uuid' )) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2026-06-25 15:33:41 +00:00
if ( $request -> has ( 'key' )) {
$request -> merge ([ 'key' => ValidationPatterns :: normalizeEnvironmentVariableKey (( string ) $request -> key )]);
}
2024-09-05 20:54:20 +00:00
$validator = customApiValidator ( $request -> all (), [
2026-06-25 15:33:41 +00:00
'key' => ValidationPatterns :: environmentVariableKeyRules (),
2024-09-05 20:54:20 +00:00
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
2025-12-10 14:43:16 +00:00
'comment' => 'string|nullable|max:256' ,
2024-09-05 20:54:20 +00:00
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $request -> key ) -> trim () -> replace ( ' ' , '_' ) -> value ;
$env = $service -> environment_variables () -> where ( 'key' , $key ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $env ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Environment variable not found.' ], 404 );
}
2025-12-10 14:43:16 +00:00
$env -> value = $request -> value ;
if ( $request -> has ( 'is_literal' )) {
$env -> is_literal = $request -> is_literal ;
}
if ( $request -> has ( 'is_multiline' )) {
$env -> is_multiline = $request -> is_multiline ;
}
if ( $request -> has ( 'is_shown_once' )) {
$env -> is_shown_once = $request -> is_shown_once ;
}
if ( $request -> has ( 'comment' )) {
$env -> comment = $request -> comment ;
}
2024-09-05 20:54:20 +00:00
$env -> save ();
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.env_updated' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'env_uuid' => $env -> uuid ,
'env_key' => $env -> key ,
]);
2024-09-05 20:54:20 +00:00
return response () -> json ( $this -> removeSensitiveData ( $env )) -> setStatusCode ( 201 );
}
#[OA\Patch(
summary : 'Update Envs (Bulk)' ,
description : 'Update multiple envs by service UUID.' ,
path : '/services/{uuid}/envs/bulk' ,
operationId : 'update-envs-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Bulk envs updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'data' ],
properties : [
'data' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
],
],
),
),
],
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variables updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
2026-02-09 13:48:16 +00:00
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/EnvironmentVariable' )
2024-09-05 20:54:20 +00:00
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function create_bulk_envs ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2026-03-19 20:56:58 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> route ( 'uuid' )) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-05 20:54:20 +00:00
$bulk_data = $request -> get ( 'data' );
2024-09-06 08:48:47 +00:00
if ( ! $bulk_data ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Bulk data is required.' ], 400 );
}
$updatedEnvs = collect ();
foreach ( $bulk_data as $item ) {
2026-06-25 15:33:41 +00:00
if ( array_key_exists ( 'key' , $item )) {
$item [ 'key' ] = ValidationPatterns :: normalizeEnvironmentVariableKey (( string ) $item [ 'key' ]);
}
2024-09-05 20:54:20 +00:00
$validator = customApiValidator ( $item , [
2026-06-25 15:33:41 +00:00
'key' => ValidationPatterns :: environmentVariableKeyRules (),
2024-09-05 20:54:20 +00:00
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
2026-03-19 21:17:55 +00:00
'comment' => 'string|nullable|max:256' ,
2024-09-05 20:54:20 +00:00
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $item [ 'key' ]) -> trim () -> replace ( ' ' , '_' ) -> value ;
2024-09-05 20:54:20 +00:00
$env = $service -> environment_variables () -> updateOrCreate (
2024-12-17 09:38:32 +00:00
[ 'key' => $key ],
2024-09-05 20:54:20 +00:00
$item
);
$updatedEnvs -> push ( $this -> removeSensitiveData ( $env ));
}
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.env_bulk_upserted' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'env_count' => $updatedEnvs -> count (),
]);
2024-09-05 20:54:20 +00:00
return response () -> json ( $updatedEnvs ) -> setStatusCode ( 201 );
}
#[OA\Post(
summary : 'Create Env' ,
description : 'Create env by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'create-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
required : true ,
description : 'Env created.' ,
content : new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
),
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variable created.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'uuid' => [ 'type' => 'string' , 'example' => 'nc0k04gk8g0cgsk440g0koko' ],
]
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function create_env ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2026-03-19 20:56:58 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> route ( 'uuid' )) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2026-06-25 15:33:41 +00:00
if ( $request -> has ( 'key' )) {
$request -> merge ([ 'key' => ValidationPatterns :: normalizeEnvironmentVariableKey (( string ) $request -> key )]);
}
2024-09-05 20:54:20 +00:00
$validator = customApiValidator ( $request -> all (), [
2026-06-25 15:33:41 +00:00
'key' => ValidationPatterns :: environmentVariableKeyRules (),
2024-09-05 20:54:20 +00:00
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
2025-12-10 14:43:16 +00:00
'comment' => 'string|nullable|max:256' ,
2024-09-05 20:54:20 +00:00
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $request -> key ) -> trim () -> replace ( ' ' , '_' ) -> value ;
$existingEnv = $service -> environment_variables () -> where ( 'key' , $key ) -> first ();
2024-09-05 20:54:20 +00:00
if ( $existingEnv ) {
return response () -> json ([
'message' => 'Environment variable already exists. Use PATCH request to update it.' ,
], 409 );
}
2025-12-10 14:43:16 +00:00
$env = $service -> environment_variables () -> create ([
'key' => $key ,
'value' => $request -> value ,
'is_literal' => $request -> is_literal ? ? false ,
'is_multiline' => $request -> is_multiline ? ? false ,
'is_shown_once' => $request -> is_shown_once ? ? false ,
'comment' => $request -> comment ? ? null ,
]);
2024-09-05 20:54:20 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.env_created' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'env_uuid' => $env -> uuid ,
'env_key' => $env -> key ,
]);
2024-09-05 20:54:20 +00:00
return response () -> json ( $this -> removeSensitiveData ( $env )) -> setStatusCode ( 201 );
}
#[OA\Delete(
summary : 'Delete Env' ,
description : 'Delete env by UUID.' ,
path : '/services/{uuid}/envs/{env_uuid}' ,
operationId : 'delete-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
new OA\Parameter (
name : 'env_uuid' ,
in : 'path' ,
description : 'UUID of the environment variable.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Environment variable deleted.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Environment variable deleted.' ],
]
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function delete_env_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2026-03-19 20:56:58 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> route ( 'uuid' )) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2026-03-19 20:56:58 +00:00
$env = EnvironmentVariable :: where ( 'uuid' , $request -> route ( 'env_uuid' ))
2024-12-17 09:38:32 +00:00
-> where ( 'resourceable_type' , Service :: class )
-> where ( 'resourceable_id' , $service -> id )
2024-09-05 20:54:20 +00:00
-> first ();
2024-09-06 08:48:47 +00:00
if ( ! $env ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Environment variable not found.' ], 404 );
}
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
$envKey = $env -> key ;
$envUuid = $env -> uuid ;
2024-09-05 20:54:20 +00:00
$env -> forceDelete ();
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.env_deleted' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'env_uuid' => $envUuid ,
'env_key' => $envKey ,
]);
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Environment variable deleted.' ]);
}
2026-03-13 15:32:00 +00:00
#[OA\Post(
summary : 'Move' ,
description : 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.' ,
path : '/services/{uuid}/move' ,
operationId : 'move-service-by-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Target environment to move the service to.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'environment_uuid' => [ 'type' => 'string' , 'description' => 'UUID of the target environment.' ],
],
required : [ 'environment_uuid' ],
)
),
]
),
responses : [
new OA\Response (
response : 200 ,
description : 'Service moved successfully.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service moved successfully.' ],
'uuid' => [ 'type' => 'string' ],
'project_uuid' => [ 'type' => 'string' ],
'environment_uuid' => [ 'type' => 'string' ],
]
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
]
)]
2026-07-15 10:29:20 +00:00
public function move_by_uuid ( Request $request ) : JsonResponse
2026-03-13 15:32:00 +00:00
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
$this -> authorize ( 'update' , $service );
return moveResourceToEnvironment ( $request , $service , 'Service' , $teamId );
}
2026-07-19 09:42:04 +00:00
#[OA\Post(
2024-07-09 11:30:13 +00:00
summary : 'Start' ,
2026-07-19 09:42:04 +00:00
description : 'Start service.' ,
2024-07-09 11:30:13 +00:00
path : '/services/{uuid}/start' ,
2024-09-04 08:09:10 +00:00
operationId : 'start-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Start service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service starting request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_deploy ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'deploy' , $service );
2024-12-13 11:03:05 +00:00
if ( str ( $service -> status ) -> contains ( 'running' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service is already running.' ], 400 );
2024-07-02 14:12:04 +00:00
}
StartService :: dispatch ( $service );
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.deployed' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
]);
2024-07-02 14:12:04 +00:00
return response () -> json (
[
'message' => 'Service starting request queued.' ,
],
200
);
}
2026-07-19 09:42:04 +00:00
#[OA\Post(
2024-07-09 11:30:13 +00:00
summary : 'Stop' ,
2026-07-19 09:42:04 +00:00
description : 'Stop service.' ,
2024-07-09 11:30:13 +00:00
path : '/services/{uuid}/stop' ,
2024-09-04 08:09:10 +00:00
operationId : 'stop-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
2026-01-01 12:03:13 +00:00
new OA\Parameter (
name : 'docker_cleanup' ,
in : 'query' ,
description : 'Perform docker cleanup (prune networks, volumes, etc.).' ,
schema : new OA\Schema (
type : 'boolean' ,
default : true ,
)
),
2024-07-09 11:30:13 +00:00
],
responses : [
new OA\Response (
response : 200 ,
description : 'Stop service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service stopping request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_stop ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'stop' , $service );
2024-12-13 11:03:05 +00:00
if ( str ( $service -> status ) -> contains ( 'stopped' ) || str ( $service -> status ) -> contains ( 'exited' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service is already stopped.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2026-01-01 12:03:13 +00:00
$dockerCleanup = $request -> boolean ( 'docker_cleanup' , true );
StopService :: dispatch ( $service , false , $dockerCleanup );
2024-07-02 14:12:04 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.stopped' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
'docker_cleanup' => $dockerCleanup ,
]);
2024-07-02 14:12:04 +00:00
return response () -> json (
[
'message' => 'Service stopping request queued.' ,
],
200
);
}
2026-07-19 09:42:04 +00:00
#[OA\Post(
2024-07-09 11:30:13 +00:00
summary : 'Restart' ,
2026-07-19 09:42:04 +00:00
description : 'Restart service.' ,
2024-07-09 11:30:13 +00:00
path : '/services/{uuid}/restart' ,
2024-09-04 08:09:10 +00:00
operationId : 'restart-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
2025-05-27 13:03:17 +00:00
new OA\Parameter (
name : 'latest' ,
in : 'query' ,
description : 'Pull latest images.' ,
schema : new OA\Schema (
type : 'boolean' ,
default : false ,
)
),
2024-07-09 11:30:13 +00:00
],
responses : [
new OA\Response (
response : 200 ,
description : 'Restart service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service restaring request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_restart ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'deploy' , $service );
2025-05-27 13:03:17 +00:00
$pullLatest = $request -> boolean ( 'latest' );
RestartService :: dispatch ( $service , $pullLatest );
2024-07-02 14:12:04 +00:00
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.restarted' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'service_name' => $service -> name ,
'pull_latest' => $pullLatest ,
]);
2024-07-02 14:12:04 +00:00
return response () -> json (
[
'message' => 'Service restarting request queued.' ,
],
200
);
}
2026-03-23 14:15:02 +00:00
#[OA\Get(
summary : 'List Storages' ,
description : 'List all persistent storages and file storages by service UUID.' ,
path : '/services/{uuid}/storages' ,
operationId : 'list-storages-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'All storages by service UUID.' ,
content : new OA\JsonContent (
properties : [
new OA\Property ( property : 'persistent_storages' , type : 'array' , items : new OA\Items ( type : 'object' )),
new OA\Property ( property : 'file_storages' , type : 'array' , items : new OA\Items ( type : 'object' )),
],
),
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function storages ( Request $request ) : JsonResponse
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([
'message' => 'Service not found.' ,
], 404 );
}
$this -> authorize ( 'view' , $service );
$persistentStorages = collect ();
$fileStorages = collect ();
foreach ( $service -> applications as $app ) {
$persistentStorages = $persistentStorages -> merge (
$app -> persistentStorages -> map ( fn ( $s ) => $s -> setAttribute ( 'resource_uuid' , $app -> uuid ) -> setAttribute ( 'resource_type' , 'application' ))
);
$fileStorages = $fileStorages -> merge (
$app -> fileStorages -> map ( fn ( $s ) => $s -> setAttribute ( 'resource_uuid' , $app -> uuid ) -> setAttribute ( 'resource_type' , 'application' ))
);
}
foreach ( $service -> databases as $db ) {
$persistentStorages = $persistentStorages -> merge (
$db -> persistentStorages -> map ( fn ( $s ) => $s -> setAttribute ( 'resource_uuid' , $db -> uuid ) -> setAttribute ( 'resource_type' , 'database' ))
);
$fileStorages = $fileStorages -> merge (
$db -> fileStorages -> map ( fn ( $s ) => $s -> setAttribute ( 'resource_uuid' , $db -> uuid ) -> setAttribute ( 'resource_type' , 'database' ))
);
}
2026-07-02 13:50:43 +00:00
$fileStorages -> each ( fn ( LocalFileVolume $storage ) => $this -> exposeFileStorageContentIfAllowed ( $storage ));
2026-03-23 14:15:02 +00:00
return response () -> json ([
'persistent_storages' => $persistentStorages -> sortBy ( 'id' ) -> values (),
'file_storages' => $fileStorages -> sortBy ( 'id' ) -> values (),
]);
}
#[OA\Post(
summary : 'Create Storage' ,
description : 'Create a persistent storage or file storage for a service sub-resource.' ,
path : '/services/{uuid}/storages' ,
operationId : 'create-storage-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
],
requestBody : new OA\RequestBody (
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'type' , 'mount_path' , 'resource_uuid' ],
properties : [
'type' => [ 'type' => 'string' , 'enum' => [ 'persistent' , 'file' ], 'description' => 'The type of storage.' ],
'resource_uuid' => [ 'type' => 'string' , 'description' => 'UUID of the service application or database sub-resource.' ],
'name' => [ 'type' => 'string' , 'description' => 'Volume name (persistent only, required for persistent).' ],
'mount_path' => [ 'type' => 'string' , 'description' => 'The container mount path.' ],
'host_path' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'The host path (persistent only, optional).' ],
'content' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'File content (file only, optional).' ],
'is_directory' => [ 'type' => 'boolean' , 'description' => 'Whether this is a directory mount (file only, default false).' ],
'fs_path' => [ 'type' => 'string' , 'description' => 'Host directory path (required when is_directory is true).' ],
],
additionalProperties : false ,
),
),
],
),
responses : [
new OA\Response (
response : 201 ,
description : 'Storage created.' ,
content : new OA\JsonContent ( type : 'object' ),
),
new OA\Response ( response : 401 , ref : '#/components/responses/401' ),
new OA\Response ( response : 400 , ref : '#/components/responses/400' ),
new OA\Response ( response : 404 , ref : '#/components/responses/404' ),
new OA\Response ( response : 422 , ref : '#/components/responses/422' ),
]
)]
public function create_storage ( Request $request ) : JsonResponse
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$return = validateIncomingRequest ( $request );
if ( $return instanceof JsonResponse ) {
return $return ;
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
$this -> authorize ( 'update' , $service );
$validator = customApiValidator ( $request -> all (), [
'type' => 'required|string|in:persistent,file' ,
'resource_uuid' => 'required|string' ,
2026-03-26 11:17:39 +00:00
'name' => [ 'string' , 'regex:' . ValidationPatterns :: VOLUME_NAME_PATTERN ],
2026-03-23 14:15:02 +00:00
'mount_path' => 'required|string' ,
2026-04-20 09:27:10 +00:00
'host_path' => [ 'string' , 'nullable' , 'regex:' . ValidationPatterns :: DIRECTORY_PATH_PATTERN ],
2026-03-23 14:15:02 +00:00
'content' => 'string|nullable' ,
'is_directory' => 'boolean' ,
2026-07-02 12:37:39 +00:00
'is_host_file' => 'boolean' ,
2026-03-23 14:15:02 +00:00
'fs_path' => 'string' ,
]);
2026-07-02 12:37:39 +00:00
$allAllowedFields = [ 'type' , 'resource_uuid' , 'name' , 'mount_path' , 'host_path' , 'content' , 'is_directory' , 'is_host_file' , 'fs_path' ];
2026-03-23 14:15:02 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allAllowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
$subResource = $service -> applications () -> where ( 'uuid' , $request -> resource_uuid ) -> first ();
if ( ! $subResource ) {
$subResource = $service -> databases () -> where ( 'uuid' , $request -> resource_uuid ) -> first ();
}
if ( ! $subResource ) {
return response () -> json ([ 'message' => 'Service resource not found.' ], 404 );
}
if ( $request -> type === 'persistent' ) {
if ( ! $request -> name ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'name' => 'The name field is required for persistent storages.' ],
], 422 );
}
2026-07-02 12:37:39 +00:00
$typeSpecificInvalidFields = array_intersect ([ 'content' , 'is_directory' , 'is_host_file' , 'fs_path' ], array_keys ( $request -> all ()));
2026-03-23 14:15:02 +00:00
if ( ! empty ( $typeSpecificInvalidFields )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => collect ( $typeSpecificInvalidFields )
-> mapWithKeys ( fn ( $field ) => [ $field => " Field ' { $field } ' is not valid for type 'persistent'. " ]),
], 422 );
}
$storage = LocalPersistentVolume :: create ([
'name' => $subResource -> uuid . '-' . $request -> name ,
'mount_path' => $request -> mount_path ,
'host_path' => $request -> host_path ,
'resource_id' => $subResource -> id ,
'resource_type' => $subResource -> getMorphClass (),
]);
return response () -> json ( $storage , 201 );
}
// File storage
$typeSpecificInvalidFields = array_intersect ([ 'name' , 'host_path' ], array_keys ( $request -> all ()));
if ( ! empty ( $typeSpecificInvalidFields )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => collect ( $typeSpecificInvalidFields )
-> mapWithKeys ( fn ( $field ) => [ $field => " Field ' { $field } ' is not valid for type 'file'. " ]),
], 422 );
}
$isDirectory = $request -> boolean ( 'is_directory' , false );
2026-07-02 12:37:39 +00:00
$isHostFile = $request -> boolean ( 'is_host_file' , false );
if ( $isDirectory && $isHostFile ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'is_host_file' => 'Host file mounts cannot also be directory mounts.' ],
], 422 );
}
2026-03-23 14:15:02 +00:00
if ( $isDirectory ) {
if ( ! $request -> fs_path ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'fs_path' => 'The fs_path field is required for directory mounts.' ],
], 422 );
}
$fsPath = str ( $request -> fs_path ) -> trim () -> start ( '/' ) -> value ();
$mountPath = str ( $request -> mount_path ) -> trim () -> start ( '/' ) -> value ();
validateShellSafePath ( $fsPath , 'storage source path' );
validateShellSafePath ( $mountPath , 'storage destination path' );
$storage = LocalFileVolume :: create ([
'fs_path' => $fsPath ,
'mount_path' => $mountPath ,
'is_directory' => true ,
'resource_id' => $subResource -> id ,
'resource_type' => get_class ( $subResource ),
]);
2026-07-02 12:37:39 +00:00
} elseif ( $isHostFile ) {
if ( ! $request -> fs_path ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'fs_path' => 'The fs_path field is required for host file mounts.' ],
], 422 );
}
2026-03-25 22:44:37 +00:00
2026-07-02 12:37:39 +00:00
if ( $request -> filled ( 'content' )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'content' => 'Content is not valid for host file mounts.' ],
], 422 );
}
2026-03-25 22:44:37 +00:00
2026-07-02 12:37:39 +00:00
try {
$fsPath = validateHostFileMountPath ( $request -> fs_path , 'host file source path' );
$mountPath = validateFileMountPath ( $request -> mount_path , 'host file destination path' );
} catch ( \Throwable $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'mount_path' => $e -> getMessage ()],
], 422 );
}
$storage = LocalFileVolume :: create ([
'fs_path' => $fsPath ,
'mount_path' => $mountPath ,
'content' => null ,
'is_directory' => false ,
'is_host_file' => true ,
'resource_id' => $subResource -> id ,
'resource_type' => get_class ( $subResource ),
]);
} else {
try {
$mountPath = validateFileMountPath ( $request -> mount_path , 'file storage path' );
$fsPath = confineFileMountPath ( service_configuration_dir () . '/' . $service -> uuid , $mountPath , 'file storage path' );
} catch ( \Throwable $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'mount_path' => $e -> getMessage ()],
], 422 );
}
2026-03-23 14:15:02 +00:00
$storage = LocalFileVolume :: create ([
'fs_path' => $fsPath ,
'mount_path' => $mountPath ,
'content' => $request -> content ,
'is_directory' => false ,
'resource_id' => $subResource -> id ,
'resource_type' => get_class ( $subResource ),
]);
}
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.storage_created' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'storage_uuid' => $storage -> uuid ? ? null ,
'storage_id' => $storage -> id ,
'storage_type' => $request -> type ,
'mount_path' => $storage -> mount_path ,
]);
2026-07-02 13:50:43 +00:00
return response () -> json ( $this -> exposeFileStorageContentIfAllowed ( $storage ), 201 );
2026-03-23 14:15:02 +00:00
}
#[OA\Patch(
summary : 'Update Storage' ,
description : 'Update a persistent storage or file storage by service UUID.' ,
path : '/services/{uuid}/storages' ,
operationId : 'update-storage-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'type' ],
properties : [
'uuid' => [ 'type' => 'string' , 'description' => 'The UUID of the storage (preferred).' ],
'id' => [ 'type' => 'integer' , 'description' => 'The ID of the storage (deprecated, use uuid instead).' ],
'type' => [ 'type' => 'string' , 'enum' => [ 'persistent' , 'file' ], 'description' => 'The type of storage: persistent or file.' ],
'is_preview_suffix_enabled' => [ 'type' => 'boolean' , 'description' => 'Whether to add -pr-N suffix for preview deployments.' ],
'name' => [ 'type' => 'string' , 'description' => 'The volume name (persistent only, not allowed for read-only storages).' ],
'mount_path' => [ 'type' => 'string' , 'description' => 'The container mount path (not allowed for read-only storages).' ],
'host_path' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'The host path (persistent only, not allowed for read-only storages).' ],
'content' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'The file content (file only, not allowed for read-only storages).' ],
],
additionalProperties : false ,
),
),
],
),
responses : [
new OA\Response (
response : 200 ,
description : 'Storage updated.' ,
content : new OA\JsonContent ( type : 'object' ),
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
]
)]
public function update_storage ( Request $request ) : JsonResponse
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$return = validateIncomingRequest ( $request );
if ( $return instanceof JsonResponse ) {
return $return ;
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> route ( 'uuid' )) -> first ();
if ( ! $service ) {
return response () -> json ([
'message' => 'Service not found.' ,
], 404 );
}
$this -> authorize ( 'update' , $service );
$validator = customApiValidator ( $request -> all (), [
'uuid' => 'string' ,
'id' => 'integer' ,
'type' => 'required|string|in:persistent,file' ,
'is_preview_suffix_enabled' => 'boolean' ,
2026-03-26 11:17:39 +00:00
'name' => [ 'string' , 'regex:' . ValidationPatterns :: VOLUME_NAME_PATTERN ],
2026-03-23 14:15:02 +00:00
'mount_path' => 'string' ,
2026-04-20 09:27:10 +00:00
'host_path' => [ 'string' , 'nullable' , 'regex:' . ValidationPatterns :: DIRECTORY_PATH_PATTERN ],
2026-03-23 14:15:02 +00:00
'content' => 'string|nullable' ,
]);
$allAllowedFields = [ 'uuid' , 'id' , 'type' , 'is_preview_suffix_enabled' , 'name' , 'mount_path' , 'host_path' , 'content' ];
$extraFields = array_diff ( array_keys ( $request -> all ()), $allAllowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
$storageUuid = $request -> input ( 'uuid' );
$storageId = $request -> input ( 'id' );
if ( ! $storageUuid && ! $storageId ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [ 'uuid' => 'Either uuid or id is required.' ],
], 422 );
}
$lookupField = $storageUuid ? 'uuid' : 'id' ;
$lookupValue = $storageUuid ? ? $storageId ;
$storage = null ;
if ( $request -> type === 'persistent' ) {
foreach ( $service -> applications as $app ) {
$storage = $app -> persistentStorages -> where ( $lookupField , $lookupValue ) -> first ();
if ( $storage ) {
break ;
}
}
if ( ! $storage ) {
foreach ( $service -> databases as $db ) {
$storage = $db -> persistentStorages -> where ( $lookupField , $lookupValue ) -> first ();
if ( $storage ) {
break ;
}
}
}
} else {
foreach ( $service -> applications as $app ) {
$storage = $app -> fileStorages -> where ( $lookupField , $lookupValue ) -> first ();
if ( $storage ) {
break ;
}
}
if ( ! $storage ) {
foreach ( $service -> databases as $db ) {
$storage = $db -> fileStorages -> where ( $lookupField , $lookupValue ) -> first ();
if ( $storage ) {
break ;
}
}
}
}
if ( ! $storage ) {
return response () -> json ([
'message' => 'Storage not found.' ,
], 404 );
}
$isReadOnly = $storage -> shouldBeReadOnlyInUI ();
$editableOnlyFields = [ 'name' , 'mount_path' , 'host_path' , 'content' ];
$requestedEditableFields = array_intersect ( $editableOnlyFields , array_keys ( $request -> all ()));
if ( $isReadOnly && ! empty ( $requestedEditableFields )) {
return response () -> json ([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.' ,
'read_only_fields' => array_values ( $requestedEditableFields ),
], 422 );
}
// Reject fields that don't apply to the given storage type
if ( ! $isReadOnly ) {
$typeSpecificInvalidFields = $request -> type === 'persistent'
? array_intersect ([ 'content' ], array_keys ( $request -> all ()))
: array_intersect ([ 'name' , 'host_path' ], array_keys ( $request -> all ()));
if ( ! empty ( $typeSpecificInvalidFields )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => collect ( $typeSpecificInvalidFields )
-> mapWithKeys ( fn ( $field ) => [ $field => " Field ' { $field } ' is not valid for type ' { $request -> type } '. " ]),
], 422 );
}
}
// Always allowed
if ( $request -> has ( 'is_preview_suffix_enabled' )) {
$storage -> is_preview_suffix_enabled = $request -> is_preview_suffix_enabled ;
}
// Only for editable storages
if ( ! $isReadOnly ) {
if ( $request -> type === 'persistent' ) {
if ( $request -> has ( 'name' )) {
$storage -> name = $request -> name ;
}
if ( $request -> has ( 'mount_path' )) {
$storage -> mount_path = $request -> mount_path ;
}
if ( $request -> has ( 'host_path' )) {
$storage -> host_path = $request -> host_path ;
}
} else {
if ( $request -> has ( 'mount_path' )) {
$storage -> mount_path = $request -> mount_path ;
}
if ( $request -> has ( 'content' )) {
$storage -> content = $request -> content ;
}
}
}
$storage -> save ();
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.storage_updated' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'storage_uuid' => $storage -> uuid ? ? null ,
'storage_id' => $storage -> id ,
'storage_type' => $request -> type ,
'mount_path' => $storage -> mount_path ? ? null ,
]);
2026-07-02 13:50:43 +00:00
return response () -> json ( $this -> exposeFileStorageContentIfAllowed ( $storage ));
2026-03-23 14:15:02 +00:00
}
#[OA\Delete(
summary : 'Delete Storage' ,
description : 'Delete a persistent storage or file storage by service UUID.' ,
path : '/services/{uuid}/storages/{storage_uuid}' ,
operationId : 'delete-storage-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
new OA\Parameter (
name : 'storage_uuid' ,
in : 'path' ,
description : 'UUID of the storage.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
],
responses : [
new OA\Response ( response : 200 , description : 'Storage deleted.' , content : new OA\JsonContent (
properties : [ new OA\Property ( property : 'message' , type : 'string' )],
)),
new OA\Response ( response : 401 , ref : '#/components/responses/401' ),
new OA\Response ( response : 400 , ref : '#/components/responses/400' ),
new OA\Response ( response : 404 , ref : '#/components/responses/404' ),
new OA\Response ( response : 422 , ref : '#/components/responses/422' ),
]
)]
public function delete_storage ( Request $request ) : JsonResponse
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
$this -> authorize ( 'update' , $service );
$storageUuid = $request -> route ( 'storage_uuid' );
$storage = null ;
foreach ( $service -> applications as $app ) {
$storage = $app -> persistentStorages -> where ( 'uuid' , $storageUuid ) -> first ();
if ( $storage ) {
break ;
}
}
if ( ! $storage ) {
foreach ( $service -> databases as $db ) {
$storage = $db -> persistentStorages -> where ( 'uuid' , $storageUuid ) -> first ();
if ( $storage ) {
break ;
}
}
}
if ( ! $storage ) {
foreach ( $service -> applications as $app ) {
$storage = $app -> fileStorages -> where ( 'uuid' , $storageUuid ) -> first ();
if ( $storage ) {
break ;
}
}
}
if ( ! $storage ) {
foreach ( $service -> databases as $db ) {
$storage = $db -> fileStorages -> where ( 'uuid' , $storageUuid ) -> first ();
if ( $storage ) {
break ;
}
}
}
if ( ! $storage ) {
return response () -> json ([ 'message' => 'Storage not found.' ], 404 );
}
if ( $storage -> shouldBeReadOnlyInUI ()) {
return response () -> json ([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.' ,
], 422 );
}
2026-07-19 21:20:46 +00:00
$storage -> abortIfScheduledBackupsExist ();
2026-07-16 19:44:48 +00:00
2026-03-23 14:15:02 +00:00
if ( $storage instanceof LocalFileVolume ) {
$storage -> deleteStorageOnServer ();
}
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent' ;
$storageMountPath = $storage -> mount_path ? ? null ;
2026-03-23 14:15:02 +00:00
$storage -> delete ();
feat(observability): add structured audit log channel for API and webhook events
Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.
Instrumented:
- API mutation endpoints (create / update / delete / start / stop / restart) across
applications, services, databases (incl. backups, env vars, storage), servers,
projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).
The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.
Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:50:37 +00:00
auditLog ( 'api.service.storage_deleted' , [
'team_id' => $teamId ,
'service_uuid' => $service -> uuid ,
'storage_uuid' => $storageUuid ,
'storage_type' => $storageType ,
'mount_path' => $storageMountPath ,
]);
2026-03-23 14:15:02 +00:00
return response () -> json ([ 'message' => 'Storage deleted.' ]);
}
2026-03-29 14:02:05 +00:00
#[OA\Get(
summary : 'List Tags' ,
description : 'List tags for a service by UUID.' ,
path : '/services/{uuid}/tags' ,
operationId : 'list-tags-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'List of tags.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/Tag' )
)
),
]
),
new OA\Response ( response : 401 , ref : '#/components/responses/401' ),
new OA\Response ( response : 400 , ref : '#/components/responses/400' ),
new OA\Response ( response : 404 , ref : '#/components/responses/404' ),
]
)]
public function tags ( Request $request ) : JsonResponse
{
return $this -> listTags ( $request );
}
#[OA\Post(
summary : 'Create Tag' ,
description : 'Add tag(s) to a service by UUID.' ,
path : '/services/{uuid}/tags' ,
operationId : 'create-tag-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
],
requestBody : new OA\RequestBody (
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'tag_name' => [ 'type' => 'string' , 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.' ],
'tag_names' => [
'type' => 'array' ,
'items' => new OA\Items ( type : 'string' ),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' ,
],
],
)
),
]
),
responses : [
new OA\Response (
response : 201 ,
description : 'Tags added successfully.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/Tag' )
)
),
]
),
new OA\Response ( response : 401 , ref : '#/components/responses/401' ),
new OA\Response ( response : 400 , ref : '#/components/responses/400' ),
new OA\Response ( response : 404 , ref : '#/components/responses/404' ),
new OA\Response ( response : 422 , ref : '#/components/responses/422' ),
]
)]
public function create_tag ( Request $request ) : JsonResponse
{
return $this -> createTag ( $request );
}
#[OA\Delete(
summary : 'Delete Tag' ,
description : 'Remove a tag from a service by UUID.' ,
path : '/services/{uuid}/tags/{tag_uuid}' ,
operationId : 'delete-tag-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
new OA\Parameter (
name : 'tag_uuid' ,
in : 'path' ,
description : 'UUID of the tag.' ,
required : true ,
schema : new OA\Schema ( type : 'string' )
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Tag removed.' ,
),
new OA\Response ( response : 401 , ref : '#/components/responses/401' ),
new OA\Response ( response : 400 , ref : '#/components/responses/400' ),
new OA\Response ( response : 404 , ref : '#/components/responses/404' ),
]
)]
public function delete_tag ( Request $request ) : JsonResponse
{
return $this -> deleteTag ( $request );
}
2024-07-02 14:12:04 +00:00
}