semaphoreui/semaphore · error

cannot modify secret in read-only storage

Error message

cannot modify secret in read-only storage

What it means

ErrReadOnlyStorage is a sentinel error indicating a secret write (serialize/create/rekey) was attempted while the access-key encryption service is in read-only mode (e.g. a remote/vault storage that does not permit modification, or a read-only operational mode). Callers should test with errors.Is; writes to existing secret material are simply not permitted in this mode.

Solutions

  1. Switch the project's secret storage to a writable storage backend (or flip the storage's read-only flag).
  2. Use errors.Is(err, ErrReadOnlyStorage) to detect this case and skip/park write operations instead of retrying.
  3. If storage should be writable, fix the storage configuration (credentials, mode) and retry the operation.

Example fix

// before
err := svc.SerializeSecret(&key) // fails with read-only storage
// after
if err := svc.SerializeSecret(&key); errors.Is(err, server.ErrReadOnlyStorage) {
    log.Warn("storage read-only; skipping secret write")
    return nil
}
Defensive patterns

Strategy: try-catch

Validate before calling

if storage.ReadOnly {
    return fmt.Errorf("storage %d is read-only; cannot write secrets", storage.ID)
}

Type guard

func writable(s SecretStorage) bool { return !s.ReadOnly }

Try / catch

if err := svc.SerializeSecret(&key); err != nil {
    if errors.Is(err, server.ErrReadOnlyStorage) {
        log.Warn("read-only storage; secret not written")
        return nil
    }
    return err
}

Prevention

When it happens

Trigger: SerializeSecret (via getDeserializer with readonly=true) or Create on the encryption service when the resolved secret storage is read-only; the service wraps it with common_errors.NewUserError(ErrReadOnlyStorage).

Common situations: Pointing a project's secret storage at a vault/storage configured read-only; creating access keys while the backend storage is in migration or replica mode; tests exercising read-only behavior.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/7e38d0158b68aa0e. Report an issue: GitHub.

Appendix: source

Thrown at services/server/access_key_encryption_svc.go:18

package server

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/semaphoreui/semaphore/db"
	"github.com/semaphoreui/semaphore/pkg/common_errors"
	"github.com/semaphoreui/semaphore/pkg/tz"
	pro "github.com/semaphoreui/semaphore/pro/services/server"
)

const RekeyBatchSize = 100

var ErrReadOnlyStorage = errors.New("cannot modify secret in read-only storage")

// ErrAccessKeyExpired is returned when a key with ExpireAt in the past is
// deserialized. Expired secrets must never be usable.
var ErrAccessKeyExpired = errors.New("access key expired")

type AccessKeyEncryptionService interface {
	SerializeSecret(key *db.AccessKey) error
	DeserializeSecret(key *db.AccessKey) error
	FillEnvironmentSecrets(env *db.Environment, deserializeSecret bool) error
	DeleteSecret(key *db.AccessKey) error
	RekeyAccessKeys(oldKey string) (err error)

	// Task survey secrets: task-bound, expiring access keys
	// (owner AccessKeyTaskSecret). See task_secret_svc.go.
	CreateTaskSurveySecrets(projectID int, taskID int, secrets string, expireAt time.Time) error
	GetTaskSurveySecrets(projectID int, taskID int) (string, error)
	DeleteTaskSurveySecrets(projectID int, taskID int) error
}

View on GitHub (pinned to 1774ccb71a)