VYPR
High severity8.8GHSA Advisory· Published May 23, 2026· Updated May 23, 2026

Arcane: Missing admin authorization on global variables endpoint

CVE-2026-47125

Description

Summary

The PUT /api/environments/{id}/templates/variables endpoint, which writes the system-wide .env.global file used for variable substitution in every project's compose file, is missing an admin authorization check. Any authenticated non-admin user can call this endpoint with their bearer token or API key and overwrite the global environment variables that are merged into every project deployment. By overriding values like REGISTRY, IMAGE, DATABASE_URL, or SECRET_KEY that other users reference via ${VAR} in compose files, an attacker can redirect image pulls to attacker-controlled registries (supply-chain RCE on the Docker host), exfiltrate database credentials, or disrupt all projects.

Details

The endpoint is registered at backend/internal/huma/handlers/templates.go:374:

huma.Register(api, huma.Operation{
    OperationID: "updateGlobalVariables",
    Method:      "PUT",
    Path:        "/environments/{id}/templates/variables",
    ...
    Security: []map[string][]string{
        {"BearerAuth": {}},
        {"ApiKeyAuth": {}},
    },
}, h.UpdateGlobalVariables)

The handler at backend/internal/huma/handlers/templates.go:889 performs no role check:

func (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *UpdateGlobalVariablesInput) (*UpdateGlobalVariablesOutput, error) {
    if h.templateService == nil {
        return nil, huma.Error500InternalServerError("service not available")
    }

    if input.EnvironmentID != "0" {
        return h.updateGlobalVariablesForRemoteEnvironmentInternal(ctx, input)
    }

    if err := h.templateService.UpdateGlobalVariables(ctx, input.Body.Variables); err != nil {
        return nil, huma.Error500InternalServerError((&common.GlobalVariablesUpdateError{Err: err}).Error())
    }
    ...
}

This is anomalous compared to every other admin-sensitive handler in the codebase, all of which begin with if err := checkAdmin(ctx); err != nil { return nil, err } (see users.go, events.go, swarm.go, settings.go, apikeys.go, environments.go, notifications.go, container_registries.go, git_repositories.go, system.go). The helper exists at backend/internal/huma/handlers/helpers.go:12 but is never invoked from templates.go.

The auth middleware at backend/internal/huma/middleware/auth.go:192-254 only validates that *some* authenticated user is present (Bearer JWT, API key, or environment access token); it does not enforce roles. Role enforcement is the responsibility of each handler.

That this endpoint is intended to be admin-only is evidenced by the UI customization search at backend/internal/huma/handlers/customize.go:82-91 and :106-114, which explicitly hides the variables and registries categories from non-admin users:

if !humamw.IsAdminFromContext(ctx) {
    filtered := []category.Category{}
    for _, cat := range results.Results {
        if cat.ID != "registries" && cat.ID != "variables" {
            filtered = append(filtered, cat)
        }
    }
    results.Results = filtered
    ...
}

The corresponding container_registries.go handlers all enforce admin via checkAdmin() (e.g. container_registries.go:273,329,360,387,442); the equivalent enforcement for the global-variables write was forgotten.

The service layer at backend/internal/services/template_service.go:1107 writes attacker-supplied keys/values to /.env.global:

func (s *TemplateService) UpdateGlobalVariables(ctx context.Context, vars []env.Variable) error {
    envPath, err := s.getGlobalVariablesPath(ctx)
    ...
    for _, v := range vars {
        if strings.TrimSpace(v.Key) == "" { continue }
        key := strings.TrimSpace(v.Key)
        value := strings.TrimSpace(v.Value)
        if strings.ContainsAny(value, " \t\n\r#") {
            value = fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
        }
        _, _ = fmt.Fprintf(&builder, "%s=%s\n", key, value)
    }
    if err := projects.WriteFileWithPerm(envPath, builder.String(), common.FilePerm); err != nil { ... }
}

That file is then loaded for every project at deploy time via backend/pkg/projects/env.go:65-82 (EnvLoader.LoadEnvironmentloadAndMergeGlobalEnv):

if strings.TrimSpace(l.projectsDir) != "" {
    globalEnvPath := filepath.Join(l.projectsDir, GlobalEnvFileName)
    if err := l.loadAndMergeGlobalEnv(ctx, globalEnvPath, envMap, injectionVars); err != nil ...
}

loadAndMergeGlobalEnv (env.go:94-125) populates both envMap (used by compose-go for ${VAR} substitution in compose files) and injectionVars (auto-injected into containers). The result: a single non-admin write to the global variables endpoint changes the resolved compose state of every project on the host.

Additionally, the key field is only strings.TrimSpace'd (template_service.go:1128); embedded newlines inside the key are not removed, so a key like "FOO\nINJECTED" will write two lines into .env.global, allowing arbitrary key injection and overwrite of variables an attacker did not include in their request body.

Impact

  • Cross-project supply-chain RCE on the Docker host. Compose files commonly reference ${REGISTRY}/${IMAGE}:${TAG}. By pointing REGISTRY (or IMAGE) at an attacker-controlled registry, the next deploy of any affected project pulls and runs attacker code with whatever privileges Arcane gives that container (commonly Docker socket access, host volume mounts, etc.).
  • Credential theft from other users' projects. Variables like DATABASE_URL, SMTP_HOST, WEBHOOK_URL, S3_ENDPOINT can be redirected to attacker-controlled servers; the next deploy will hand the new connection strings to applications that then submit credentials/data to the attacker.
  • Cross-tenant integrity and availability. A single non-admin user can corrupt .env.global to break every project on the instance.
  • Bypass of intended privilege boundary. The UI explicitly hides the variables and registries surfaces from non-admins, indicating these are admin-only controls; this finding closes the gap between the documented privilege model and the API enforcement.

The privilege delta is significant: the project clearly distinguishes admin from non-admin users (separate roles, admin-only UI categories, checkAdmin() enforced on dozens of other endpoints), yet this endpoint grants a non-admin the ability to execute attacker-controlled images on the host on behalf of every other tenant.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Missing admin authorization on PUT /api/environments/{id}/templates/variables allows any authenticated user to overwrite global environment variables, leading to supply-chain RCE or data exfiltration.

Vulnerability

The PUT /api/environments/{id}/templates/variables endpoint in the Arcane application (backend/internal/huma/handlers/templates.go:374) lacks an admin authorization check. Unlike other admin-sensitive handlers that call checkAdmin(ctx), the UpdateGlobalVariables handler (line 889) performs no role verification. Any authenticated user with a valid bearer token or API key can call this endpoint and overwrite the system-wide .env.global file, which is used for variable substitution in every project's compose file. Affected versions are those prior to the fix (not yet specified in references). [1][2]

Exploitation

An attacker needs only a valid non-admin user account (or API key) on the Arcane instance. The attacker sends a PUT request to /environments/{id}/templates/variables with a JSON body containing arbitrary variable names and values. The endpoint accepts the request without checking if the user has admin privileges. The attacker can override variables such as REGISTRY, IMAGE, DATABASE_URL, or SECRET_KEY that are referenced via ${VAR} in other users' compose files. [1][2]

Impact

Successful exploitation allows an attacker to redirect image pulls to attacker-controlled registries, leading to supply-chain remote code execution on the Docker host. Alternatively, the attacker can exfiltrate database credentials or disrupt all projects by injecting malicious environment variables. The impact is high, affecting confidentiality, integrity, and availability of the entire deployment. [1][2]

Mitigation

As of the publication date (2026-05-23), no fixed version has been released. The advisory recommends adding an admin role check to the handler, similar to other endpoints. Users should monitor the project's repository for a patch. No workaround is available other than restricting API access to trusted users. [1][2]

AI Insight generated on May 23, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
3185f750a5ea

refactor: create admin role middleware for each endpoint (#2593)

https://github.com/getarcaneapp/arcaneKyle MendellMay 13, 2026Fixed in 1.19.2via llm-release-walk
32 files changed · +521 36
  • backend/api/handlers/apikeys.go+5 0 modified
    @@ -90,6 +90,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ListApiKeys)
     
     	huma.Register(api, huma.Operation{
    @@ -103,6 +104,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateApiKey)
     
     	huma.Register(api, huma.Operation{
    @@ -116,6 +118,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetApiKey)
     
     	huma.Register(api, huma.Operation{
    @@ -129,6 +132,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateApiKey)
     
     	huma.Register(api, huma.Operation{
    @@ -142,6 +146,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteApiKey)
     }
     
    
  • backend/api/handlers/build_workspaces.go+13 0 modified
    @@ -7,6 +7,7 @@ import (
     	"path"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
     	"github.com/getarcaneapp/arcane/types/base"
    @@ -77,6 +78,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks
     				},
     			},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UploadFile)
     
     	huma.Register(api, huma.Operation{
    @@ -87,6 +89,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks
     		Description: "Create a directory under the builds workspace root",
     		Tags:        []string{"Builds"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateDirectory)
     
     	huma.Register(api, huma.Operation{
    @@ -97,6 +100,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks
     		Description: "Delete a file or directory under the builds workspace root",
     		Tags:        []string{"Builds"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteFile)
     }
     
    @@ -194,6 +198,9 @@ func (h *BuildWorkspaceHandler) DownloadFile(ctx context.Context, input *Downloa
     }
     
     func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.service == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -220,6 +227,9 @@ func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBui
     }
     
     func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *CreateBuildDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.service == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -233,6 +243,9 @@ func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *Crea
     }
     
     func (h *BuildWorkspaceHandler) DeleteFile(ctx context.Context, input *DeleteBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.service == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/container_registries.go+6 0 modified
    @@ -6,6 +6,7 @@ import (
     	"strings"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
    @@ -132,6 +133,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -145,6 +147,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.SyncRegistries)
     
     	huma.Register(api, huma.Operation{
    @@ -184,6 +187,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -197,6 +201,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -210,6 +215,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TestRegistry)
     }
     
    
  • backend/api/handlers/containers.go+28 0 modified
    @@ -168,6 +168,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Summary:     "Create container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -186,6 +187,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Summary:     "Start container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.StartContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -195,6 +197,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Summary:     "Stop container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.StopContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -204,6 +207,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Summary:     "Restart container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RestartContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -214,6 +218,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Description: "Pull latest image and recreate container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RedeployContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -223,6 +228,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Summary:     "Delete container",
     		Tags:        []string{"Containers"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteContainer)
     
     	huma.Register(api, huma.Operation{
    @@ -233,6 +239,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d
     		Description: "Enable or disable auto-update for a specific container",
     		Tags:        []string{"Containers", "Updater"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.SetAutoUpdate)
     }
     
    @@ -529,6 +536,9 @@ func buildNetworkingConfig(body containertypes.Create) *network.NetworkingConfig
     }
     
     func (h *ContainerHandler) CreateContainer(ctx context.Context, input *CreateContainerInput) (*CreateContainerOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -595,6 +605,9 @@ func (h *ContainerHandler) GetContainer(ctx context.Context, input *GetContainer
     }
     
     func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -617,6 +630,9 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA
     }
     
     func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -639,6 +655,9 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc
     }
     
     func (h *ContainerHandler) RestartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -661,6 +680,9 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe
     }
     
     func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *ContainerActionInput) (*GetContainerOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -699,6 +721,9 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain
     }
     
     func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteContainerInput) (*DeleteContainerOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.containerService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -721,6 +746,9 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon
     }
     
     func (h *ContainerHandler) SetAutoUpdate(ctx context.Context, input *SetAutoUpdateInput) (*SetAutoUpdateOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.settingsService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/environments.go+10 0 modified
    @@ -231,6 +231,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateEnvironment)
     
     	huma.Register(api, huma.Operation{
    @@ -257,6 +258,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateEnvironment)
     
     	huma.Register(api, huma.Operation{
    @@ -270,6 +272,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteEnvironment)
     
     	huma.Register(api, huma.Operation{
    @@ -283,6 +286,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TestConnection)
     
     	huma.Register(api, huma.Operation{
    @@ -309,6 +313,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PairAgent)
     
     	huma.Register(api, huma.Operation{
    @@ -322,6 +327,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.SyncEnvironment)
     
     	huma.Register(api, huma.Operation{
    @@ -346,6 +352,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetDeploymentSnippets)
     
     	huma.Register(api, huma.Operation{
    @@ -359,6 +366,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DownloadEnvironmentMTLSBundle)
     
     	huma.Register(api, huma.Operation{
    @@ -372,6 +380,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DownloadEnvironmentMTLSFile)
     
     	huma.Register(api, huma.Operation{
    @@ -398,6 +407,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DownloadEdgeMTLSCA)
     }
     
    
  • backend/api/handlers/events.go+5 0 modified
    @@ -4,6 +4,7 @@ import (
     	"context"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
     	"github.com/getarcaneapp/arcane/types/base"
    @@ -95,6 +96,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ListEvents)
     
     	huma.Register(api, huma.Operation{
    @@ -108,6 +110,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateEvent)
     
     	huma.Register(api, huma.Operation{
    @@ -121,6 +124,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteEvent)
     
     	huma.Register(api, huma.Operation{
    @@ -134,6 +138,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetEventsByEnvironment)
     }
     
    
  • backend/api/handlers/gitops_syncs.go+20 0 modified
    @@ -149,6 +149,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateSync)
     
     	huma.Register(api, huma.Operation{
    @@ -162,6 +163,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ImportSyncs)
     
     	huma.Register(api, huma.Operation{
    @@ -188,6 +190,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateSync)
     
     	huma.Register(api, huma.Operation{
    @@ -201,6 +204,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteSync)
     
     	huma.Register(api, huma.Operation{
    @@ -214,6 +218,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PerformSync)
     
     	huma.Register(api, huma.Operation{
    @@ -278,6 +283,9 @@ func (h *GitOpsSyncHandler) ListSyncs(ctx context.Context, input *ListGitOpsSync
     
     // CreateSync creates a new GitOps sync.
     func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsSyncInput) (*CreateGitOpsSyncOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.syncService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -308,6 +316,9 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS
     
     // ImportSyncs imports multiple GitOps syncs.
     func (h *GitOpsSyncHandler) ImportSyncs(ctx context.Context, input *ImportGitOpsSyncsInput) (*ImportGitOpsSyncsOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.syncService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -357,6 +368,9 @@ func (h *GitOpsSyncHandler) GetSync(ctx context.Context, input *GetGitOpsSyncInp
     
     // UpdateSync updates an existing GitOps sync.
     func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsSyncInput) (*UpdateGitOpsSyncOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.syncService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -387,6 +401,9 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS
     
     // DeleteSync deletes a GitOps sync by ID.
     func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsSyncInput) (*DeleteGitOpsSyncOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.syncService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -413,6 +430,9 @@ func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsS
     
     // PerformSync manually triggers a sync operation.
     func (h *GitOpsSyncHandler) PerformSync(ctx context.Context, input *PerformSyncInput) (*PerformSyncOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.syncService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/git_repositories.go+5 0 modified
    @@ -141,6 +141,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateRepository)
     
     	huma.Register(api, huma.Operation{
    @@ -167,6 +168,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateRepository)
     
     	huma.Register(api, huma.Operation{
    @@ -180,6 +182,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteRepository)
     
     	huma.Register(api, huma.Operation{
    @@ -193,6 +196,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TestRepository)
     
     	huma.Register(api, huma.Operation{
    @@ -232,6 +236,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.SyncRepositories)
     }
     
    
  • backend/api/handlers/images.go+20 0 modified
    @@ -208,6 +208,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RemoveImage)
     
     	huma.Register(api, huma.Operation{
    @@ -221,6 +222,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PullImage)
     
     	huma.Register(api, huma.Operation{
    @@ -234,6 +236,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.BuildImage)
     
     	huma.Register(api, huma.Operation{
    @@ -273,6 +276,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PruneImages)
     
     	huma.Register(api, huma.Operation{
    @@ -303,6 +307,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i
     				},
     			},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UploadImage)
     }
     
    @@ -384,6 +389,9 @@ func (h *ImageHandler) GetImage(ctx context.Context, input *GetImageInput) (*Get
     
     // RemoveImage removes a Docker image.
     func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput) (*RemoveImageOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.imageService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -409,6 +417,9 @@ func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput)
     
     // PullImage pulls a Docker image with streaming progress.
     func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*huma.StreamResponse, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.imageService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -445,6 +456,9 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h
     
     // BuildImage builds a Docker image with streaming progress.
     func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) (*huma.StreamResponse, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.buildService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -548,6 +562,9 @@ func (h *ImageHandler) GetImageBuild(ctx context.Context, input *GetImageBuildIn
     
     // PruneImages removes unused Docker images.
     func (h *ImageHandler) PruneImages(ctx context.Context, input *PruneImagesInput) (*PruneImagesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.imageService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -670,6 +687,9 @@ func (h *ImageHandler) GetImageUsageCounts(ctx context.Context, input *GetImageU
     
     // UploadImage uploads a Docker image from a tar archive.
     func (h *ImageHandler) UploadImage(ctx context.Context, input *UploadImageInput) (*UploadImageOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.imageService == nil || h.settingsService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/job_schedules.go+9 0 modified
    @@ -5,6 +5,7 @@ import (
     	"net/http"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
     	"github.com/getarcaneapp/arcane/types/base"
     	"github.com/getarcaneapp/arcane/types/jobschedule"
    @@ -74,6 +75,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.Update)
     
     	huma.Register(api, huma.Operation{
    @@ -100,6 +102,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RunJob)
     }
     
    @@ -133,6 +136,9 @@ func (h *JobSchedulesHandler) ListJobs(ctx context.Context, input *ListJobsInput
     }
     
     func (h *JobSchedulesHandler) RunJob(ctx context.Context, input *RunJobInput) (*RunJobOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.jobService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -182,6 +188,9 @@ func (h *JobSchedulesHandler) Get(ctx context.Context, input *GetJobSchedulesInp
     }
     
     func (h *JobSchedulesHandler) Update(ctx context.Context, input *UpdateJobSchedulesInput) (*UpdateJobSchedulesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.jobService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/networks.go+12 0 modified
    @@ -163,6 +163,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS
     		Summary:     "Create network",
     		Tags:        []string{"Networks"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateNetwork)
     
     	huma.Register(api, huma.Operation{
    @@ -190,6 +191,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS
     		Summary:     "Delete network",
     		Tags:        []string{"Networks"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteNetwork)
     
     	huma.Register(api, huma.Operation{
    @@ -199,6 +201,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS
     		Summary:     "Prune networks",
     		Tags:        []string{"Networks"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PruneNetworks)
     }
     
    @@ -263,6 +266,9 @@ func (h *NetworkHandler) GetNetworkCounts(ctx context.Context, input *GetNetwork
     }
     
     func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetworkInput) (*CreateNetworkOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	user, exists := humamw.GetCurrentUserFromContext(ctx)
     	if !exists {
     		return nil, huma.Error401Unauthorized("not authenticated")
    @@ -392,6 +398,9 @@ func (h *NetworkHandler) GetNetworkTopology(ctx context.Context, input *GetNetwo
     }
     
     func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetworkInput) (*DeleteNetworkOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	user, exists := humamw.GetCurrentUserFromContext(ctx)
     	if !exists {
     		return nil, huma.Error401Unauthorized("not authenticated")
    @@ -410,6 +419,9 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork
     }
     
     func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworksInput) (*PruneNetworksOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	report, err := h.networkService.PruneNetworks(ctx)
     	if err != nil {
     		return nil, huma.Error500InternalServerError((&common.NetworkPruneError{Err: err}).Error())
    
  • backend/api/handlers/notifications.go+9 0 modified
    @@ -7,6 +7,7 @@ import (
     	"strings"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/config"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
    @@ -140,6 +141,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Get all notification settings",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetAllNotificationSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -149,6 +151,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Get notification settings by provider",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetNotificationSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -158,6 +161,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Create or update notification settings",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateOrUpdateNotificationSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -167,6 +171,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Delete notification settings",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteNotificationSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -176,6 +181,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Test notification",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TestNotification)
     
     	huma.Register(api, huma.Operation{
    @@ -185,6 +191,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Get Apprise settings",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetAppriseSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -194,6 +201,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Create or update Apprise settings",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateOrUpdateAppriseSettings)
     
     	huma.Register(api, huma.Operation{
    @@ -203,6 +211,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS
     		Summary:     "Test Apprise notification",
     		Tags:        []string{"Notifications"},
     		Security:    []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TestAppriseNotification)
     
     	huma.Register(api, huma.Operation{
    
  • backend/api/handlers/projects.go+48 0 modified
    @@ -243,6 +243,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeployProject)
     
     	huma.Register(api, huma.Operation{
    @@ -256,6 +257,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DownProject)
     
     	huma.Register(api, huma.Operation{
    @@ -269,6 +271,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateProject)
     
     	huma.Register(api, huma.Operation{
    @@ -308,6 +311,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RedeployProject)
     
     	huma.Register(api, huma.Operation{
    @@ -321,6 +325,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DestroyProject)
     
     	huma.Register(api, huma.Operation{
    @@ -334,6 +339,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateProject)
     
     	huma.Register(api, huma.Operation{
    @@ -347,6 +353,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateProjectInclude)
     
     	huma.Register(api, huma.Operation{
    @@ -360,6 +367,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RestartProject)
     
     	huma.Register(api, huma.Operation{
    @@ -373,6 +381,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ArchiveProject)
     
     	huma.Register(api, huma.Operation{
    @@ -386,6 +395,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UnarchiveProject)
     
     	huma.Register(api, huma.Operation{
    @@ -399,6 +409,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PullProjectImages)
     
     	huma.Register(api, huma.Operation{
    @@ -412,6 +423,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.BuildProjectImages)
     }
     
    @@ -500,6 +512,9 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP
     
     // DeployProject deploys a Docker Compose project.
     func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProjectInput) (*huma.StreamResponse, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -546,6 +561,9 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject
     
     // DownProject brings down a Docker Compose project.
     func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInput) (*DownProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -575,6 +593,9 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu
     
     // CreateProject creates a new Docker Compose project.
     func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProjectInput) (*CreateProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -673,6 +694,9 @@ func (h *ProjectHandler) GetProjectFile(ctx context.Context, input *GetProjectFi
     
     // RedeployProject redeploys a Docker Compose project.
     func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployProjectInput) (*RedeployProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -706,6 +730,9 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro
     
     // DestroyProject destroys a Docker Compose project.
     func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProjectInput) (*DestroyProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -745,6 +772,9 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje
     
     // UpdateProject updates a Docker Compose project.
     func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProjectInput) (*UpdateProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -777,6 +807,9 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject
     
     // UpdateProjectInclude updates an include file within a project.
     func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *UpdateProjectIncludeInput) (*UpdateProjectIncludeOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -809,6 +842,9 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update
     
     // RestartProject restarts all containers in a project.
     func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProjectInput) (*RestartProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -841,6 +877,9 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje
     }
     
     func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProjectInput) (*ArchiveProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -871,6 +910,9 @@ func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProje
     }
     
     func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveProjectInput) (*UnarchiveProjectOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -898,6 +940,9 @@ func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveP
     
     // PullProjectImages pulls all images for a project with streaming progress.
     func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProjectImagesInput) (*huma.StreamResponse, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -943,6 +988,9 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje
     
     // BuildProjectImages builds compose services with build directives.
     func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildProjectInput) (*huma.StreamResponse, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.projectService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/remenv_handlers_test.go+8 1 modified
    @@ -9,6 +9,7 @@ import (
     	"time"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamiddleware "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/database"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
    @@ -21,6 +22,12 @@ import (
     	"gorm.io/gorm"
     )
     
    +// adminTestContextInternal returns a context with the admin flag set, suitable for
    +// unit-testing handlers that call checkAdminInternal directly.
    +func adminTestContextInternal() context.Context {
    +	return context.WithValue(context.Background(), humamiddleware.ContextKeyUserIsAdmin, true)
    +}
    +
     func setupRemoteHandlerEnvironmentServiceInternal(t *testing.T, server *httptest.Server) *services.EnvironmentService {
     	t.Helper()
     
    @@ -135,7 +142,7 @@ func TestTemplateHandler_GetGlobalVariables_RemoteSuccess(t *testing.T) {
     		environmentService: setupRemoteHandlerEnvironmentServiceInternal(t, server),
     	}
     
    -	output, err := handler.GetGlobalVariables(context.Background(), &GetGlobalVariablesInput{EnvironmentID: "env-remote"})
    +	output, err := handler.GetGlobalVariables(adminTestContextInternal(), &GetGlobalVariablesInput{EnvironmentID: "env-remote"})
     	require.NoError(t, err)
     	require.Equal(t, expected, output.Body)
     }
    
  • backend/api/handlers/settings.go+3 0 modified
    @@ -152,6 +152,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateSettings)
     
     	// Top-level settings endpoints (not environment-scoped)
    @@ -166,6 +167,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.Search)
     
     	huma.Register(api, huma.Operation{
    @@ -179,6 +181,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetCategories)
     }
     
    
  • backend/api/handlers/swarm.go+28 28 modified
    @@ -521,57 +521,57 @@ func RegisterSwarm(api huma.API, swarmSvc *services.SwarmService, environmentSvc
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/services", Summary: "List swarm services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServices)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-service", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Get swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetService)
    -	huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.CreateService)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateService)
    -	huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeleteService)
    +	huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateService)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateService)
    +	huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteService)
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-service-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}/tasks", Summary: "List tasks for a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServiceTasks)
    -	huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.RollbackService)
    -	huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ScaleService)
    +	huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RollbackService)
    +	huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.ScaleService)
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-nodes", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes", Summary: "List swarm nodes", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodes)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-node", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Get swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNode)
    -	huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNodeAgentDeployment)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateNode)
    -	huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeleteNode)
    -	huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.PromoteNode)
    -	huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DemoteNode)
    +	huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetNodeAgentDeployment)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateNode)
    +	huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteNode)
    +	huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.PromoteNode)
    +	huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DemoteNode)
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-node-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}/tasks", Summary: "List tasks for a swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodeTasks)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-node-identity", Method: http.MethodGet, Path: "/swarm/node-identity", Summary: "Get local swarm node identity", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNodeIdentity)
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/tasks", Summary: "List swarm tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListTasks)
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-stacks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks", Summary: "List swarm stacks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStacks)
    -	huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeployStack)
    +	huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeployStack)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-stack", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Get swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetStack)
    -	huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetStackSource)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateStackSource)
    -	huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeleteStack)
    +	huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetStackSource)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateStackSource)
    +	huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteStack)
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/services", Summary: "List swarm stack services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackServices)
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/tasks", Summary: "List swarm stack tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackTasks)
     	huma.Register(api, huma.Operation{OperationID: "render-swarm-stack-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks/config/render", Summary: "Render/validate swarm stack config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.RenderStackConfig)
     
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-status", Method: http.MethodGet, Path: "/environments/{id}/swarm/status", Summary: "Get swarm status", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmStatus)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-info", Method: http.MethodGet, Path: "/environments/{id}/swarm/info", Summary: "Get swarm info", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmInfo)
    -	huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.InitSwarm)
    -	huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.JoinSwarm)
    -	huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.LeaveSwarm)
    -	huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UnlockSwarm)
    -	huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetUnlockKey)
    -	huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetJoinTokens)
    -	huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.RotateJoinTokens)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateSwarmSpec)
    +	huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.InitSwarm)
    +	huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.JoinSwarm)
    +	huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.LeaveSwarm)
    +	huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UnlockSwarm)
    +	huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetUnlockKey)
    +	huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetJoinTokens)
    +	huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RotateJoinTokens)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSwarmSpec)
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-configs", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs", Summary: "List swarm configs", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListConfigs)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-config", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Get swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetConfig)
    -	huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.CreateConfig)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateConfig)
    -	huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeleteConfig)
    +	huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateConfig)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateConfig)
    +	huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteConfig)
     
     	huma.Register(api, huma.Operation{OperationID: "list-swarm-secrets", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets", Summary: "List swarm secrets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListSecrets)
     	huma.Register(api, huma.Operation{OperationID: "get-swarm-secret", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Get swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSecret)
    -	huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.CreateSecret)
    -	huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.UpdateSecret)
    -	huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.DeleteSecret)
    +	huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateSecret)
    +	huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSecret)
    +	huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteSecret)
     }
     
     // ListServices lists swarm services for an environment and returns a paginated response.
    
  • backend/api/handlers/system.go+6 0 modified
    @@ -160,6 +160,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PruneAll)
     
     	huma.Register(api, huma.Operation{
    @@ -173,6 +174,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.StartAllContainers)
     
     	huma.Register(api, huma.Operation{
    @@ -186,6 +188,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.StartAllStoppedContainers)
     
     	huma.Register(api, huma.Operation{
    @@ -199,6 +202,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.StopAllContainers)
     
     	huma.Register(api, huma.Operation{
    @@ -225,6 +229,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CheckUpgradeAvailable)
     
     	huma.Register(api, huma.Operation{
    @@ -239,6 +244,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.TriggerUpgrade)
     }
     
    
  • backend/api/handlers/templates.go+28 0 modified
    @@ -6,6 +6,7 @@ import (
     	"net/http"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
    @@ -190,6 +191,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.FetchRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -331,6 +333,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -344,6 +347,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -357,6 +361,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteRegistry)
     
     	huma.Register(api, huma.Operation{
    @@ -370,6 +375,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetGlobalVariables)
     
     	huma.Register(api, huma.Operation{
    @@ -383,6 +389,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateGlobalVariables)
     }
     
    @@ -729,6 +736,9 @@ func (h *TemplateHandler) GetRegistries(ctx context.Context, _ *GetTemplateRegis
     
     // CreateRegistry creates a new template registry.
     func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTemplateRegistryInput) (*CreateTemplateRegistryOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -758,6 +768,9 @@ func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTempl
     
     // UpdateRegistry updates a template registry.
     func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTemplateRegistryInput) (*UpdateTemplateRegistryOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -791,6 +804,9 @@ func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTempl
     
     // DeleteRegistry deletes a template registry.
     func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTemplateRegistryInput) (*DeleteTemplateRegistryOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -818,6 +834,9 @@ func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTempl
     
     // FetchRegistry fetches templates from a remote registry URL.
     func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplateRegistryInput) (*FetchTemplateRegistryOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -846,6 +865,9 @@ func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplat
     
     // GetGlobalVariables returns global template variables.
     func (h *TemplateHandler) GetGlobalVariables(ctx context.Context, input *GetGlobalVariablesInput) (*GetGlobalVariablesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -882,6 +904,9 @@ func (h *TemplateHandler) getGlobalVariablesForRemoteEnvironmentInternal(ctx con
     
     // UpdateGlobalVariables updates global template variables.
     func (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *UpdateGlobalVariablesInput) (*UpdateGlobalVariablesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.templateService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -891,6 +916,9 @@ func (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *Upda
     	}
     
     	if err := h.templateService.UpdateGlobalVariables(ctx, input.Body.Variables); err != nil {
    +		if common.IsInvalidEnvKeyError(err) {
    +			return nil, huma.Error400BadRequest(err.Error())
    +		}
     		return nil, huma.Error500InternalServerError((&common.GlobalVariablesUpdateError{Err: err}).Error())
     	}
     
    
  • backend/api/handlers/updater.go+3 0 modified
    @@ -5,6 +5,7 @@ import (
     	"net/http"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
    @@ -71,6 +72,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RunUpdater)
     
     	huma.Register(api, huma.Operation{
    @@ -110,6 +112,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateContainer)
     }
     
    
  • backend/api/handlers/users.go+6 0 modified
    @@ -7,6 +7,7 @@ import (
     	"time"
     
     	"github.com/danielgtaylor/huma/v2"
    +	humamw "github.com/getarcaneapp/arcane/backend/api/middleware"
     	"github.com/getarcaneapp/arcane/backend/internal/common"
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/internal/services"
    @@ -96,6 +97,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ListUsers)
     
     	huma.Register(api, huma.Operation{
    @@ -109,6 +111,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateUser)
     
     	huma.Register(api, huma.Operation{
    @@ -122,6 +125,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.GetUser)
     
     	huma.Register(api, huma.Operation{
    @@ -135,6 +139,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateUser)
     
     	huma.Register(api, huma.Operation{
    @@ -148,6 +153,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteUser)
     }
     
    
  • backend/api/handlers/volumes.go+44 0 modified
    @@ -350,6 +350,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateVolume)
     
     	huma.Register(api, huma.Operation{
    @@ -363,6 +364,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RemoveVolume)
     
     	huma.Register(api, huma.Operation{
    @@ -376,6 +378,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.PruneVolumes)
     
     	huma.Register(api, huma.Operation{
    @@ -469,6 +472,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     				},
     			},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UploadFile)
     
     	huma.Register(api, huma.Operation{
    @@ -481,6 +485,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateDirectory)
     
     	huma.Register(api, huma.Operation{
    @@ -493,6 +498,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteFile)
     
     	// --- Volume Backup Endpoints ---
    @@ -519,6 +525,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateBackup)
     
     	huma.Register(api, huma.Operation{
    @@ -531,6 +538,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RestoreBackup)
     
     	huma.Register(api, huma.Operation{
    @@ -543,6 +551,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.RestoreBackupFiles)
     
     	huma.Register(api, huma.Operation{
    @@ -555,6 +564,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteBackup)
     
     	huma.Register(api, huma.Operation{
    @@ -620,6 +630,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService,
     				},
     			},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UploadAndRestore)
     }
     
    @@ -703,6 +714,9 @@ func (h *VolumeHandler) GetVolume(ctx context.Context, input *GetVolumeInput) (*
     
     // CreateVolume creates a new Docker volume.
     func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInput) (*CreateVolumeOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -734,6 +748,9 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp
     
     // RemoveVolume removes a Docker volume.
     func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInput) (*RemoveVolumeOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -759,6 +776,9 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp
     
     // PruneVolumes removes all unused Docker volumes.
     func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInput) (*PruneVolumesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -914,6 +934,9 @@ func (h *VolumeHandler) DownloadFile(ctx context.Context, input *DownloadFileInp
     }
     
     func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -941,6 +964,9 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput)
     }
     
     func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -956,6 +982,9 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect
     }
     
     func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) (*base.ApiResponse[base.MessageResponse], error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -1024,6 +1053,9 @@ func (h *VolumeHandler) ListBackups(ctx context.Context, input *ListBackupsInput
     }
     
     func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInput) (*CreateBackupOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -1045,6 +1077,9 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp
     }
     
     func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupInput) (*RestoreBackupOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -1066,6 +1101,9 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI
     }
     
     func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBackupFilesInput) (*RestoreBackupFilesOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -1132,6 +1170,9 @@ func (h *VolumeHandler) ListBackupFiles(ctx context.Context, input *ListBackupFi
     }
     
     func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInput) (*DeleteBackupOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -1173,6 +1214,9 @@ func (h *VolumeHandler) DownloadBackup(ctx context.Context, input *DownloadBacku
     }
     
     func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRestoreInput) (*UploadAndRestoreOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.volumeService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/volumes_upload_test.go+2 2 modified
    @@ -17,7 +17,7 @@ import (
     func TestUploadFileReturnsBadRequestWhenNoFileProvided(t *testing.T) {
     	h := &VolumeHandler{volumeService: &services.VolumeService{}}
     
    -	_, err := h.UploadFile(context.Background(), &UploadFileInput{
    +	_, err := h.UploadFile(adminTestContextInternal(), &UploadFileInput{
     		EnvironmentID: "0",
     		VolumeName:    "vol-1",
     		Path:          "/",
    @@ -34,7 +34,7 @@ func TestUploadFileReturnsBadRequestWhenNoFileProvided(t *testing.T) {
     func TestUploadAndRestoreReturnsBadRequestWhenNoFileProvided(t *testing.T) {
     	h := &VolumeHandler{volumeService: &services.VolumeService{}}
     
    -	ctx := context.WithValue(context.Background(), humamw.ContextKeyCurrentUser, &models.User{BaseModel: models.BaseModel{ID: "u-1"}})
    +	ctx := context.WithValue(adminTestContextInternal(), humamw.ContextKeyCurrentUser, &models.User{BaseModel: models.BaseModel{ID: "u-1"}})
     
     	_, err := h.UploadAndRestore(ctx, &UploadAndRestoreInput{
     		EnvironmentID: "0",
    
  • backend/api/handlers/vulnerabilities.go+12 0 modified
    @@ -137,6 +137,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.ScanImage)
     
     	huma.Register(api, huma.Operation{
    @@ -254,6 +255,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.IgnoreVulnerability)
     
     	huma.Register(api, huma.Operation{
    @@ -267,6 +269,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UnignoreVulnerability)
     
     	huma.Register(api, huma.Operation{
    @@ -285,6 +288,9 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab
     
     // ScanImage initiates a vulnerability scan for an image.
     func (h *VulnerabilityHandler) ScanImage(ctx context.Context, input *ScanImageInput) (*ScanImageOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.vulnerabilityService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -542,6 +548,9 @@ type IgnoreVulnerabilityOutput struct {
     
     // IgnoreVulnerability creates an ignore record for a vulnerability.
     func (h *VulnerabilityHandler) IgnoreVulnerability(ctx context.Context, input *IgnoreVulnerabilityInput) (*IgnoreVulnerabilityOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.vulnerabilityService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -591,6 +600,9 @@ type UnignoreVulnerabilityOutput struct {
     
     // UnignoreVulnerability removes an ignore record for a vulnerability.
     func (h *VulnerabilityHandler) UnignoreVulnerability(ctx context.Context, input *UnignoreVulnerabilityInput) (*UnignoreVulnerabilityOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.vulnerabilityService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/handlers/webhooks.go+12 0 modified
    @@ -83,6 +83,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.CreateWebhook)
     
     	huma.Register(api, huma.Operation{
    @@ -96,6 +97,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.UpdateWebhook)
     
     	huma.Register(api, huma.Operation{
    @@ -109,6 +111,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) {
     			{"BearerAuth": {}},
     			{"ApiKeyAuth": {}},
     		},
    +		Middlewares: humamw.RequireAdmin(api),
     	}, h.DeleteWebhook)
     }
     
    @@ -133,6 +136,9 @@ func (h *WebhookHandler) ListWebhooks(ctx context.Context, input *ListWebhooksIn
     
     // CreateWebhook creates a new webhook and returns the raw token (shown once only).
     func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhookInput) (*CreateWebhookOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.webhookService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -185,6 +191,9 @@ func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhook
     
     // UpdateWebhook updates a webhook's enabled state.
     func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhookInput) (*UpdateWebhookOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.webhookService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    @@ -213,6 +222,9 @@ func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhook
     
     // DeleteWebhook removes a webhook.
     func (h *WebhookHandler) DeleteWebhook(ctx context.Context, input *DeleteWebhookInput) (*DeleteWebhookOutput, error) {
    +	if err := checkAdminInternal(ctx); err != nil {
    +		return nil, err
    +	}
     	if h.webhookService == nil {
     		return nil, huma.Error500InternalServerError("service not available")
     	}
    
  • backend/api/middleware/role.go+24 0 added
    @@ -0,0 +1,24 @@
    +package middleware
    +
    +import (
    +	"log/slog"
    +	"net/http"
    +
    +	"github.com/danielgtaylor/huma/v2"
    +)
    +
    +// RequireAdmin returns a per-operation Huma middleware slice that returns 403
    +// to non-admin callers. Attach via Operation.Middlewares:
    +//
    +//	huma.Register(api, huma.Operation{..., Middlewares: middleware.RequireAdmin(api)}, h.Handler)
    +func RequireAdmin(api huma.API) huma.Middlewares {
    +	return huma.Middlewares{func(ctx huma.Context, next func(huma.Context)) {
    +		if !IsAdminFromContext(ctx.Context()) {
    +			if err := huma.WriteErr(api, ctx, http.StatusForbidden, "admin access required"); err != nil {
    +				slog.WarnContext(ctx.Context(), "failed to write 403 response", "error", err)
    +			}
    +			return
    +		}
    +		next(ctx)
    +	}}
    +}
    
  • backend/api/middleware/role_test.go+76 0 added
    @@ -0,0 +1,76 @@
    +package middleware
    +
    +import (
    +	"context"
    +	"net/http"
    +	"net/http/httptest"
    +	"testing"
    +
    +	"github.com/danielgtaylor/huma/v2"
    +	"github.com/danielgtaylor/huma/v2/adapters/humaecho"
    +	"github.com/labstack/echo/v4"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestRequireAdmin_RejectsNonAdmin(t *testing.T) {
    +	router := echo.New()
    +	api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0"))
    +
    +	api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
    +		next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, false)))
    +	})
    +
    +	huma.Register(api, huma.Operation{
    +		OperationID: "guarded",
    +		Method:      http.MethodGet,
    +		Path:        "/guarded",
    +		Middlewares: RequireAdmin(api),
    +	}, func(_ context.Context, _ *struct{}) (*struct{}, error) {
    +		t.Fatal("handler must not run for non-admin")
    +		return nil, nil
    +	})
    +
    +	req := httptest.NewRequest(http.MethodGet, "/api/guarded", nil)
    +	rec := httptest.NewRecorder()
    +	router.ServeHTTP(rec, req)
    +
    +	require.Equal(t, http.StatusForbidden, rec.Code)
    +	require.Contains(t, rec.Body.String(), "admin access required")
    +}
    +
    +func TestRequireAdmin_AllowsAdmin(t *testing.T) {
    +	router := echo.New()
    +	api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0"))
    +
    +	api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
    +		next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, true)))
    +	})
    +
    +	type guardedAdminOutput struct {
    +		Body struct {
    +			Success bool `json:"success"`
    +		}
    +	}
    +
    +	handlerRan := false
    +	huma.Register(api, huma.Operation{
    +		OperationID: "guarded-admin",
    +		Method:      http.MethodGet,
    +		Path:        "/guarded-admin",
    +		Middlewares: RequireAdmin(api),
    +	}, func(_ context.Context, _ *struct{}) (*guardedAdminOutput, error) {
    +		handlerRan = true
    +		return &guardedAdminOutput{
    +			Body: struct {
    +				Success bool `json:"success"`
    +			}{Success: true},
    +		}, nil
    +	})
    +
    +	req := httptest.NewRequest(http.MethodGet, "/api/guarded-admin", nil)
    +	rec := httptest.NewRecorder()
    +	router.ServeHTTP(rec, req)
    +
    +	require.Equal(t, http.StatusOK, rec.Code)
    +	require.True(t, handlerRan)
    +}
    
  • backend/internal/common/errors.go+12 0 modified
    @@ -1165,6 +1165,18 @@ func (e *GlobalVariablesUpdateError) Error() string {
     	return fmt.Sprintf("Failed to update global variables: %v", e.Err)
     }
     
    +type InvalidEnvKeyError struct {
    +	Key string
    +}
    +
    +func (e *InvalidEnvKeyError) Error() string {
    +	return fmt.Sprintf("Invalid env key %q (must match [A-Za-z_][A-Za-z0-9_]*)", e.Key)
    +}
    +
    +func IsInvalidEnvKeyError(err error) bool {
    +	return isErrorTypeInternal[*InvalidEnvKeyError](err)
    +}
    +
     type UpdaterRunError struct {
     	Err error
     }
    
  • backend/internal/services/template_service.go+9 0 modified
    @@ -11,6 +11,7 @@ import (
     	"net/http"
     	"os"
     	"path/filepath"
    +	"regexp"
     	"strings"
     	"sync"
     	"time"
    @@ -237,6 +238,11 @@ func (s *TemplateService) GetAllTemplatesPaginated(ctx context.Context, params p
     
     var ErrTemplateNotFound = errors.New("template not found")
     
    +// envKeyPattern is the POSIX env-name shape used to validate global variable
    +// keys before they are persisted to .env.global. Keys that do not match are
    +// rejected with a common.InvalidEnvKeyError.
    +var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
    +
     func (s *TemplateService) GetTemplate(ctx context.Context, id string) (*models.ComposeTemplate, error) {
     	if err := s.syncFilesystemTemplatesInternal(ctx); err != nil {
     		slog.WarnContext(ctx, "failed to sync filesystem templates", "error", err)
    @@ -1128,6 +1134,9 @@ func (s *TemplateService) UpdateGlobalVariables(ctx context.Context, vars []env.
     		}
     
     		key := strings.TrimSpace(v.Key)
    +		if !envKeyPattern.MatchString(key) {
    +			return &common.InvalidEnvKeyError{Key: v.Key}
    +		}
     		value := strings.TrimSpace(v.Value)
     
     		if strings.ContainsAny(value, " \t\n\r#") {
    
  • backend/internal/services/template_service_test.go+23 0 modified
    @@ -22,6 +22,7 @@ import (
     	"github.com/getarcaneapp/arcane/backend/internal/models"
     	"github.com/getarcaneapp/arcane/backend/pkg/pagination"
     	httputils "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx"
    +	envtypes "github.com/getarcaneapp/arcane/types/env"
     	tmpl "github.com/getarcaneapp/arcane/types/template"
     )
     
    @@ -407,3 +408,25 @@ services:
     	require.NotNil(t, stored.Metadata.IconURL)
     	require.Equal(t, "https://cdn.example/local.png", *stored.Metadata.IconURL)
     }
    +
    +func TestUpdateGlobalVariables_RejectsNewlineInjectionKey(t *testing.T) {
    +	projectsDir := t.TempDir()
    +
    +	db, err := gorm.Open(glsqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
    +	require.NoError(t, err)
    +	require.NoError(t, db.AutoMigrate(&models.SettingVariable{}))
    +	dbWrap := &database.DB{DB: db}
    +	settingsSvc, err := NewSettingsService(context.Background(), dbWrap)
    +	require.NoError(t, err)
    +	require.NoError(t, settingsSvc.UpdateSetting(context.Background(), "projectsDirectory", projectsDir))
    +
    +	service := &TemplateService{db: dbWrap, settingsService: settingsSvc}
    +
    +	err = service.UpdateGlobalVariables(context.Background(), []envtypes.Variable{
    +		{Key: "BENIGN\nINJECTED", Value: "x"},
    +	})
    +	require.True(t, common.IsInvalidEnvKeyError(err), "expected InvalidEnvKeyError, got %v", err)
    +
    +	_, statErr := os.Stat(filepath.Join(projectsDir, ".env.global"))
    +	require.True(t, os.IsNotExist(statErr), ".env.global must not be written on validation failure")
    +}
    
  • frontend/src/lib/services/api-service.ts+5 0 modified
    @@ -279,6 +279,11 @@ class APIClient {
     					}
     				}
     
    +				if (errorResponse.status === 403 && typeof window !== 'undefined') {
    +					const reason = extractServerMessage(parsed) ?? 'You do not have permission to perform this action.';
    +					toast.error('Access denied', { description: reason });
    +				}
    +
     				throw new APIError(extractServerMessage(parsed, true) ?? error.message, {
     					cause: error,
     					config: requestConfig,
    
  • frontend/src/lib/utils/api.util.ts+6 2 modified
    @@ -1,4 +1,5 @@
     import type { Result } from './try-catch';
    +import { APIError } from '$lib/services/api-service';
     import { toast } from 'svelte-sonner';
     
     function extractServerMessage(data: any): string | undefined {
    @@ -67,9 +68,12 @@ export async function handleApiResultWithCallbacks<T>({
     		setLoadingState(true);
     
     		if (result.error) {
    -			const dockerMsg = extractDockerErrorMessage(result.error);
     			console.error(`API Error: ${message}:`, result.error);
    -			toast.error(message, { description: dockerMsg });
    +			// 403s are surfaced by the global "Access denied" toast in the API
    +			// service interceptor; skip the per-action toast to avoid double-toasting.
    +			if (!(result.error instanceof APIError) || result.error.status !== 403) {
    +				toast.error(message, { description: extractDockerErrorMessage(result.error) });
    +			}
     			await Promise.resolve(onError(result.error));
     		} else {
     			await Promise.resolve(onSuccess(result.data as T));
    
  • go.work.sum+24 3 modified
    @@ -27,6 +27,7 @@ cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsL
     cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
     code.cloudfoundry.org/clock v1.1.0 h1:XLzC6W3Ah/Y7ht1rmZ6+QfPdt1iGWEAAtIZXgiaj57c=
     code.cloudfoundry.org/clock v1.1.0/go.mod h1:yA3fxddT9RINQL2XHS7PS+OXxKCGhfrZmlNUCIM6AKo=
    +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
     filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
     filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
     github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
    @@ -253,13 +254,15 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
     github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
     github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
     github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
    +github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
     github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
     github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
     github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
     github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054 h1:uH66TXeswKn5PW5zdZ39xEwfS9an067BirqA+P4QaLI=
     github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
     github.com/checkpoint-restore/checkpointctl v1.4.0 h1:3kRns56TArwiyHOMakaumUgSZZlB1hZBkjVgR6IeZ3E=
     github.com/checkpoint-restore/checkpointctl v1.4.0/go.mod h1:ynQ52zQBazgcTZuxpwTFzRinIcAf0haDTC1X1LA/FKA=
    +github.com/checkpoint-restore/checkpointctl v1.5.0/go.mod h1:y5HRs1ZWQUZGyEuthlTHmTJN9PUMOjlaH6JvVaNq9kE=
     github.com/checkpoint-restore/go-criu/v7 v7.2.0 h1:qGiWA4App1gGlEfIJ68WR9jbezV9J7yZdjzglezcqKo=
     github.com/checkpoint-restore/go-criu/v7 v7.2.0/go.mod h1:u0LCWLg0w4yqqu14aXhiB4YD3a1qd8EcCEg7vda5dwo=
     github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
    @@ -320,14 +323,17 @@ github.com/containerd/fuse-overlayfs-snapshotter/v2 v2.1.7 h1:pD+wZQE81lBgjiQRaD
     github.com/containerd/fuse-overlayfs-snapshotter/v2 v2.1.7/go.mod h1:gzkTvoDZmX6hLuNn6qQ8s3wkTJ+Va9SbT76v07GzVK0=
     github.com/containerd/go-cni v1.1.13 h1:eFSGOKlhoYNxpJ51KRIMHZNlg5UgocXEIEBGkY7Hnis=
     github.com/containerd/go-cni v1.1.13/go.mod h1:nTieub0XDRmvCZ9VI/SBG6PyqT95N4FIhxsauF1vSBI=
    +github.com/containerd/go-dmverity v0.1.0/go.mod h1:vYevYgfeVF244IpQCKshXLvyKoRHwoVrFG0HV6GrUrs=
     github.com/containerd/go-runc v1.1.0 h1:OX4f+/i2y5sUT7LhmcJH7GYrjjhHa1QI4e8yO0gGleA=
     github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U=
     github.com/containerd/imgcrypt/v2 v2.0.1 h1:gQcmeCKA97fAl0wlpq0itSY/PagFBsn4/mlKUy6kOio=
     github.com/containerd/imgcrypt/v2 v2.0.1/go.mod h1:/qIJL8nxzdzMA2n5iYyyuIY36KfoVQWmgTWdfVtyebM=
    +github.com/containerd/imgcrypt/v2 v2.0.2/go.mod h1:8r4JW1b83jkDhaioOUZ7idxIYp+Wn1k4E4KXwy2oSNI=
     github.com/containerd/nri v0.10.0 h1:bt2NzfvlY6OJE0i+fB5WVeGQEycxY7iFVQpEbh7J3Go=
     github.com/containerd/nri v0.10.0/go.mod h1:5VyvLa/4uL8FjyO8nis1UjbCutXDpngil17KvBSL6BU=
     github.com/containerd/nri v0.11.0 h1:26mcQwNG58AZn0YkOrlJQ0yxQVmyZooflnVWJTqQrqQ=
     github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI=
    +github.com/containerd/nri v0.12.0/go.mod h1:TGAfPLH4a+qwbv0PxsefPiR+PobYecDj2aXMtz7GQcg=
     github.com/containerd/otelttrpc v0.1.0 h1:UOX68eVTE8H/T45JveIg+I22Ev2aFj4qPITCmXsskjw=
     github.com/containerd/otelttrpc v0.1.0/go.mod h1:XhoA2VvaGPW1clB2ULwrBZfXVuEWuyOd2NUD1IM0yTg=
     github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
    @@ -421,6 +427,7 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD
     github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
     github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
     github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
    +github.com/erofs/go-erofs v0.3.0/go.mod h1:XkSeN9MHszGd4+3gcEjadJLYHCQpWzJ7/8yznzMuzJs=
     github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
     github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
     github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
    @@ -606,8 +613,6 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
     github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
     github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
     github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
    -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
    -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
     github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
     github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
     github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
    @@ -620,6 +625,7 @@ github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:
     github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
     github.com/intel/goresctrl v0.10.0 h1:Q94PBhLtQM/aVxVTKzB1j/ooMuV+9XleTN1fq2OGsSo=
     github.com/intel/goresctrl v0.10.0/go.mod h1:1S8GDqL46GuKb525bxNhIEEkhf4rhVcbSf9DuKhp7mw=
    +github.com/intel/goresctrl v0.12.0/go.mod h1:5GWtmPY4BWl/a9rU8apGED9Xul5b5WoLtg/qOWaghWU=
     github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
     github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
     github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
    @@ -717,6 +723,7 @@ github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2T
     github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk=
     github.com/letsencrypt/boulder v0.20251110.0 h1:J8MnKICeilO91dyQ2n5eBbab24neHzUpYMUIOdOtbjc=
     github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8=
    +github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA=
     github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
     github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
     github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
    @@ -833,6 +840,7 @@ github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 h1:
     github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2/go.mod h1:MXdPzqAA8pHC58USHqNCSjyLnRQ6D+NjbpP+02Z1U/0=
     github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY=
     github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw=
    +github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg=
     github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
     github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
     github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
    @@ -938,11 +946,13 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB
     github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
     github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
     github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
    +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
     github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug=
     github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
     github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
     github.com/tink-crypto/tink-go/v2 v2.5.0 h1:B8KLF6AofxdBIE4UJIaFbmoj5/1ehEtt7/MmzfI4Zpw=
     github.com/tink-crypto/tink-go/v2 v2.5.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
    +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
     github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
     github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
     github.com/tonistiigi/go-actions-cache v0.0.0-20250626083717-378c5ed1ddd9 h1:GWuTlpuUQBaK6u0R3HwE+eWaQ2aXwHgo8CaXgqtDQZU=
    @@ -1257,7 +1267,6 @@ golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s
     golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
     golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
     golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
    -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
     golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
     golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
     golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
    @@ -1350,26 +1359,37 @@ k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
     k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
     k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
     k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
    +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
     k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
     k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
     k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
     k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
    +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
     k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
     k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
     k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
     k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
    +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
    +k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk=
     k8s.io/cri-api v0.34.1 h1:n2bU++FqqJq0CNjP/5pkOs0nIx7aNpb1Xa053TecQkM=
     k8s.io/cri-api v0.34.1/go.mod h1:4qVUjidMg7/Z9YGZpqIDygbkPWkg3mkS1PvOx/kpHTE=
    +k8s.io/cri-api v0.36.0/go.mod h1:1gMX7udEAiRCWGS4uxscdbxq6vufwhZt38Ri+XH6P00=
    +k8s.io/cri-client v0.36.0/go.mod h1:sMNSZqkBxzc/8IqPQyVg+QaKnntLn6bnP5xjOQ9OX6U=
    +k8s.io/cri-streaming v0.36.0/go.mod h1:AGYm+qv2gm7CTj9Gotc6CxPy7xEvyJGVvc3RhWNK5NQ=
     k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
     k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
    +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
     k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
     k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
     k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
     k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
    +k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
    +k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
     k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
     k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
     k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
     k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
    +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
     kernel.org/pub/linux/libs/security/libcap/cap v1.2.76 h1:mrdLPj8ujM6eIKGtd1PkkuCIodpFFDM42Cfm0YODkIM=
     kernel.org/pub/linux/libs/security/libcap/cap v1.2.76/go.mod h1:7V2BQeHnVAQwhCnCPJ977giCeGDiywVewWF+8vkpPlc=
     kernel.org/pub/linux/libs/security/libcap/cap v1.2.77 h1:iQtQTjFUOcTT19fI8sTCzYXsjeVs56et3D8AbKS2Uks=
    @@ -1410,6 +1430,7 @@ sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
     sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
     sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
     sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
    +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
     sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
     sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
     tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
    

Vulnerability mechanics

Root cause

"Missing admin authorization check in the global variables update handler allows any authenticated user to overwrite the system-wide `.env.global` file."

Attack vector

An authenticated non-admin user sends a PUT request to `/api/environments/{id}/templates/variables` with a valid Bearer JWT or API key. The auth middleware validates the user exists but does not enforce roles, and the handler performs no admin check [patch_id=1637256]. The attacker supplies arbitrary key-value pairs that are written to the system-wide `.env.global` file, which is merged into every project's compose environment at deploy time. By overriding variables like `REGISTRY`, `IMAGE`, or `DATABASE_URL` that other users reference via `${VAR}`, the attacker can redirect image pulls to attacker-controlled registries or exfiltrate credentials across all projects.

Affected code

The handler `UpdateGlobalVariables` at `backend/internal/huma/handlers/templates.go:889` lacks the `checkAdmin()` call that every other admin-sensitive handler in the codebase includes. The service layer at `backend/internal/services/template_service.go:1107` writes attacker-supplied key-value pairs to `

What the fix does

The patch adds `if err := checkAdmin(ctx); err != nil { return nil, err }` at the top of `UpdateGlobalVariables`, matching the pattern used by every other admin-sensitive handler in the codebase. This ensures that only users with the admin role can modify the global environment variables that affect all projects. The fix closes the gap between the UI (which hides the variables category from non-admins) and the API enforcement.

Preconditions

  • authAttacker must have a valid authenticated session (Bearer JWT or API key) for any non-admin user on the Arcane instance.
  • networkThe endpoint must be reachable over the network; no special network position is required beyond normal API access.
  • configNo special configuration is needed; the endpoint is enabled by default and the service layer writes to the default global env path.

Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.