PR #3503
Sections
Review

fix: protect referenced secrets from deletion (#3500)

main ← feature/investigate-and-fix-615 48 files +892 −47 PR #3503 ↗

Deletion of a secret now fails with 409 when agent profiles, executor profiles, or repository bindings still reference it, and the UI shows each blocker before you can delete.

Why this change

A secret could be deleted while agent profiles, executor profiles, or repository bindings still referenced it. Later task runs then failed with a vague error and no repair path.

What it does

Architecture, end to end

Delete flows through the reference checker before storage. The UI preflights first, and the runtime surfaces a repair hint if a binding still points at a missing secret.

flowchart LR
  User[User / UI] --> API[Secrets API\nDELETE /secrets/:id]
  API --> Checker[Reference checker\nsecretReferenceChecker.list]
  Checker --> Agents[(Agent profiles)]
  Checker --> Executors[(Executor profiles)]
  Checker --> Repos[(Repositories)]
  Checker -- refs found --> Conflict[409 secret_in_use\n+ references]
  Conflict --> Dialog[Conflict dialog]
  Checker -- no refs --> Store[(Secret store)]
  Store -- forced delete --> Keep[Bindings kept for repair]
  Keep --> Runtime[Runtime Resolve\nSecretError with Re-select hint]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Backend API
Web
Runtime
Guarded delete with InUseErrorapps/backend/internal/secrets/delete.go ↗
func (s *Service) deleteChecked(ctx context.Context, id, workspaceID string, force bool) error
Click for details →

The service checks references before delete and returns 409 with the blocker list unless force is true.

Reference type and guard
type Reference struct {
  Kind string `json:"kind"`
  ID   string `json:"id,omitempty"`
  Name string `json:"name,omitempty"`
  Key  string `json:"key,omitempty"`
}

type InUseError struct {
  References []Reference `json:"references"`
}

func (e *InUseError) Error() string {
  labels := make([]string, 0, len(e.References))
  for _, ref := range e.References {
    label := strings.ReplaceAll(ref.Kind, "_", " ")
    if ref.Name != "" {
      label += fmt.Sprintf(" %q", ref.Name)
    }
    if ref.Key != "" {
      label += fmt.Sprintf(" (%s)", ref.Key)
    }
    labels = append(labels, label)
  }
  return "secret is in use by " + strings.Join(labels, ", ") + ". Remove or replace these references before deleting it."
}

func (s *Service) deleteChecked(ctx context.Context, id, workspaceID string, force bool) error {
  if force {
    return s.deleteStored(ctx, id, workspaceID)
  }
  refs, err := s.listReferences(ctx, id)
  if err != nil {
    return err
  }
  if len(refs) > 0 {
    return &InUseError{References: refs}
  }
  return s.deleteStored(ctx, id, workspaceID)
}

func (s *Service) listReferences(ctx context.Context, id string) ([]Reference, error) {
  if s.referenceChecker == nil {
    return nil, fmt.Errorf("secret reference checking is unavailable")
  }
  refs, err := s.referenceChecker(ctx, id)
  if err != nil {
    return nil, fmt.Errorf("check secret references: %w", err)
  }
  return refs, nil
}
func (c secretReferenceChecker) list(ctx context.Context, id string) ([]Reference, error)
Click for details →

The checker scans agent profiles, executor profiles, and repositories and redacts details for workspaces the caller cannot access.

Discovery and redaction
func (c secretReferenceChecker) list(ctx context.Context, id string) ([]Reference, error) {
  refs, err := c.agentReferences(ctx, id)
  if err != nil {
    return nil, err
  }
  profiles, err := c.tasks.ListAllExecutorProfiles(ctx)
  if err != nil {
    return nil, fmt.Errorf("list executor profiles: %w", err)
  }
  for _, profile := range profiles {
    refs = appendEnvironmentReferences(refs, id, "executor_profile", profile.ID, profile.Name, profile.EnvVars)
  }
  repositories, err := c.repositoryReferences(ctx, id)
  if err != nil {
    return nil, err
  }
  return append(refs, repositories...), nil
}

func (c secretReferenceChecker) agentProfileReferenceRedacted(ctx context.Context, workspaceID string, workspaceAccess map[string]error) (bool, error) {
  if workspaceID == "" {
    return false, nil
  }
  if c.authorizeWorkspace == nil {
    return false, errors.New("workspace reference authorization is unavailable")
  }
  accessErr, cached := workspaceAccess[workspaceID]
  if !cached {
    accessErr = c.authorizeWorkspace(ctx, workspaceID)
    workspaceAccess[workspaceID] = accessErr
  }
  if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
    return false, accessErr
  }
  return accessErr != nil, nil
}

func (c secretReferenceChecker) repositoryReferences(ctx context.Context, id string) ([]Reference, error) {
  workspaces, err := c.tasks.ListWorkspaces(ctx)
  if err != nil {
    return nil, fmt.Errorf("list workspaces: %w", err)
  }
  var refs []Reference
  for _, workspace := range workspaces {
    accessErr := c.authorizeWorkspace(ctx, workspace.ID)
    if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
      return nil, accessErr
    }
    repositories, err := c.tasks.ListRepositories(ctx, workspace.ID)
    if err != nil {
      return nil, fmt.Errorf("list repository references: %w", err)
    }
    for _, repository := range repositories {
      for _, binding := range repository.SecretBindings {
        if binding.SecretID != id {
          continue
        }
        ref := secrets.Reference{Kind: "repository"}
        if accessErr == nil {
          ref.ID, ref.Name, ref.Key = repository.ID, repository.Name, binding.Key
        }
        refs = append(refs, ref)
      }
    }
  }
  return refs, nil
}
func (h *Handler) httpDeleteSecret(c *gin.Context)
Click for details →

The handlers map InUseError to 409 with code secret_in_use and expose a read-only references endpoint.

Handlers
func (h *Handler) httpListSecretReferences(c *gin.Context) {
  id := c.Param("id")
  var refs []Reference
  var err error
  if workspaceID := c.Query("workspace_id"); workspaceID != "" {
    refs, err = h.service.WorkspaceSecretReferences(c.Request.Context(), id, workspaceID)
  } else {
    refs, err = h.service.References(c.Request.Context(), id)
  }
  if err != nil {
    if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
      c.JSON(http.StatusNotFound, gin.H{"error": "secret not found"})
      return
    }
    h.logger.Error("failed to list secret references", zap.String("id", id), zap.Error(err))
    c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list secret references"})
    return
  }
  if refs == nil {
    refs = []Reference{}
  }
  c.JSON(http.StatusOK, gin.H{"references": refs})
}

func (h *Handler) httpDeleteSecret(c *gin.Context) {
  id := c.Param("id")
  if err := h.deleteSecret(c, id); err != nil {
    status, _, message, details := classifyDeleteError(err)
    if status == http.StatusInternalServerError {
      h.logger.Error("failed to delete secret", zap.String("id", id), zap.Error(err))
    }
    details["error"] = message
    c.JSON(status, details)
    return
  }
  c.Status(http.StatusNoContent)
}

func classifyDeleteError(err error) (int, string, string, map[string]any) {
  var inUse *InUseError
  if errors.As(err, &inUse) {
    return http.StatusConflict, ws.ErrorCodeConflict, inUse.Error(), map[string]any{
      "code": "secret_in_use", "references": inUse.References,
    }
  }
  if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
    return http.StatusNotFound, ws.ErrorCodeNotFound, "secret not found", map[string]any{}
  }
  return http.StatusInternalServerError, ws.ErrorCodeInternalError, "failed to delete secret", map[string]any{}
}
function useSecretDeleteActions(state, requestOptions, deleteRequest)
Click for details →

The UI lists references before delete and shows a dialog with each blocker when the server returns 409.

Preflight hook
function useSecretDeleteActions(state, requestOptions, deleteRequest) {
  const { t } = useTranslation();
  const { toast } = useToast();
  const requestGeneration = useRef(0);
  const { deleteTarget, deleteReferences, setDeleteTarget, setDeleteReferences } = state;
  const closeDelete = () => {
    requestGeneration.current += 1;
    setDeleteTarget(null);
    setDeleteReferences(null);
  };
  const openDelete = async (secret: SecretListItem) => {
    const generation = requestGeneration.current + 1;
    requestGeneration.current = generation;
    setDeleteTarget(secret);
    setDeleteReferences(null);
    try {
      const references = await listSecretReferences(secret.id, requestOptions);
      if (requestGeneration.current !== generation) return;
      setDeleteReferences(references);
    } catch {
      if (requestGeneration.current !== generation) return;
      setDeleteTarget(null);
      setDeleteReferences(null);
      toast({ description: t("settings:secretReferencesLoadFailed"), variant: "error" });
    }
  };
  const confirmDelete = () => {
    if (!deleteTarget || deleteReferences === null || deleteReferences.length > 0) return;
    const target = deleteTarget;
    setDeleteTarget(null);
    setDeleteReferences(null);
    return deleteRequest.run(target.id).catch((error: unknown) => {
      const references = secretReferencesFromError(error);
      if (references !== null) {
        setDeleteTarget(target);
        setDeleteReferences(references);
        return;
      }
      toast({ description: secretDeleteErrorMessage(error, t), variant: "error" });
    });
  };
  return { referencesLoading: deleteTarget !== null && deleteReferences === null, openDelete, closeDelete, confirmDelete };
}
Error parsing
export function secretReferencesFromError(error: unknown): SecretReference[] | null {
  if (!(error instanceof ApiError) || error.status !== 409) return null;
  const body = error.body;
  if (!body || typeof body !== "object" || !("code" in body) || body.code !== "secret_in_use") {
    return null;
  }
  if (!("references" in body) || !Array.isArray(body.references)) return null;
  return body.references.filter(isSecretReference);
}

export function secretReferenceLabel(ref: SecretReference, t: TFunction): string {
  const name = ref.name ?? "";
  const key = ref.key ?? "";
  if (!name && !key) return t("settings:secretReferenceHidden");
  switch (ref.kind) {
    case "agent_profile":
      return t("settings:secretReferenceAgent", { name, key });
    case "executor_profile":
      return t("settings:secretReferenceExecutor", { name, key });
    case "repository":
      return t("settings:secretReferenceRepository", { name, key });
  }
}
func (e *SecretError) Error() string
Click for details →

Resolve now tells the user which key and origin failed and where to re-select the secret, without leaking the secret value.

Error message
 	return fmt.Sprintf("environment key %q from %s could not be resolved", e.Key, e.Origin)
 	return fmt.Sprintf("environment key %q from %s references an unavailable secret. Re-select the secret in the %s environment settings", e.Key, e.Origin, e.Origin)
}
Agent profile prefix
resolved, records, err := runtimeenv.Resolve(ctx, definitions, m.resolveEnvironmentDefinition)
if err != nil {
  var secretErr *runtimeenv.SecretError
  if errors.As(err, &secretErr) && secretErr.Origin == runtimeenv.OriginAgentProfile && profileInfo != nil && profileInfo.ProfileName != "" {
    err = fmt.Errorf("agent profile %q: %w", profileInfo.ProfileName, err)
  }
  return nil, fmt.Errorf("resolve task environment: %w", err)
}
func buildHTTPServer(...) (*http.Server, error)
Click for details →

The server wires the reference checker so every delete goes through the same discovery path.

Wiring
	secretsSvc := secrets.NewService(userSecretStore, log)
 	secretsSvc.SetReferenceChecker(secretReferenceChecker{
 		agents: repos.AgentSettings, tasks: repos.Task,
 		authorizeWorkspace: services.Task.AuthorizeWorkspaceAccess,
 	}.list)
	secretsSvc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error {
Read the changes as a list

Guarded delete with InUseError

apps/backend/internal/secrets/delete.go

The service checks references before delete and returns 409 with the blocker list unless force is true.

Reference type and guard
type Reference struct {
  Kind string `json:"kind"`
  ID   string `json:"id,omitempty"`
  Name string `json:"name,omitempty"`
  Key  string `json:"key,omitempty"`
}

type InUseError struct {
  References []Reference `json:"references"`
}

func (e *InUseError) Error() string {
  labels := make([]string, 0, len(e.References))
  for _, ref := range e.References {
    label := strings.ReplaceAll(ref.Kind, "_", " ")
    if ref.Name != "" {
      label += fmt.Sprintf(" %q", ref.Name)
    }
    if ref.Key != "" {
      label += fmt.Sprintf(" (%s)", ref.Key)
    }
    labels = append(labels, label)
  }
  return "secret is in use by " + strings.Join(labels, ", ") + ". Remove or replace these references before deleting it."
}

func (s *Service) deleteChecked(ctx context.Context, id, workspaceID string, force bool) error {
  if force {
    return s.deleteStored(ctx, id, workspaceID)
  }
  refs, err := s.listReferences(ctx, id)
  if err != nil {
    return err
  }
  if len(refs) > 0 {
    return &InUseError{References: refs}
  }
  return s.deleteStored(ctx, id, workspaceID)
}

func (s *Service) listReferences(ctx context.Context, id string) ([]Reference, error) {
  if s.referenceChecker == nil {
    return nil, fmt.Errorf("secret reference checking is unavailable")
  }
  refs, err := s.referenceChecker(ctx, id)
  if err != nil {
    return nil, fmt.Errorf("check secret references: %w", err)
  }
  return refs, nil
}

Cross-store reference discovery

apps/backend/internal/backendapp/secret_references.go

The checker scans agent profiles, executor profiles, and repositories and redacts details for workspaces the caller cannot access.

Discovery and redaction
func (c secretReferenceChecker) list(ctx context.Context, id string) ([]Reference, error) {
  refs, err := c.agentReferences(ctx, id)
  if err != nil {
    return nil, err
  }
  profiles, err := c.tasks.ListAllExecutorProfiles(ctx)
  if err != nil {
    return nil, fmt.Errorf("list executor profiles: %w", err)
  }
  for _, profile := range profiles {
    refs = appendEnvironmentReferences(refs, id, "executor_profile", profile.ID, profile.Name, profile.EnvVars)
  }
  repositories, err := c.repositoryReferences(ctx, id)
  if err != nil {
    return nil, err
  }
  return append(refs, repositories...), nil
}

func (c secretReferenceChecker) agentProfileReferenceRedacted(ctx context.Context, workspaceID string, workspaceAccess map[string]error) (bool, error) {
  if workspaceID == "" {
    return false, nil
  }
  if c.authorizeWorkspace == nil {
    return false, errors.New("workspace reference authorization is unavailable")
  }
  accessErr, cached := workspaceAccess[workspaceID]
  if !cached {
    accessErr = c.authorizeWorkspace(ctx, workspaceID)
    workspaceAccess[workspaceID] = accessErr
  }
  if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
    return false, accessErr
  }
  return accessErr != nil, nil
}

func (c secretReferenceChecker) repositoryReferences(ctx context.Context, id string) ([]Reference, error) {
  workspaces, err := c.tasks.ListWorkspaces(ctx)
  if err != nil {
    return nil, fmt.Errorf("list workspaces: %w", err)
  }
  var refs []Reference
  for _, workspace := range workspaces {
    accessErr := c.authorizeWorkspace(ctx, workspace.ID)
    if accessErr != nil && !errors.Is(accessErr, repoerrors.ErrWorkspaceNotFound) {
      return nil, accessErr
    }
    repositories, err := c.tasks.ListRepositories(ctx, workspace.ID)
    if err != nil {
      return nil, fmt.Errorf("list repository references: %w", err)
    }
    for _, repository := range repositories {
      for _, binding := range repository.SecretBindings {
        if binding.SecretID != id {
          continue
        }
        ref := secrets.Reference{Kind: "repository"}
        if accessErr == nil {
          ref.ID, ref.Name, ref.Key = repository.ID, repository.Name, binding.Key
        }
        refs = append(refs, ref)
      }
    }
  }
  return refs, nil
}

HTTP and WS conflict handling

apps/backend/internal/secrets/handlers.go

The handlers map InUseError to 409 with code secret_in_use and expose a read-only references endpoint.

Handlers
func (h *Handler) httpListSecretReferences(c *gin.Context) {
  id := c.Param("id")
  var refs []Reference
  var err error
  if workspaceID := c.Query("workspace_id"); workspaceID != "" {
    refs, err = h.service.WorkspaceSecretReferences(c.Request.Context(), id, workspaceID)
  } else {
    refs, err = h.service.References(c.Request.Context(), id)
  }
  if err != nil {
    if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
      c.JSON(http.StatusNotFound, gin.H{"error": "secret not found"})
      return
    }
    h.logger.Error("failed to list secret references", zap.String("id", id), zap.Error(err))
    c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list secret references"})
    return
  }
  if refs == nil {
    refs = []Reference{}
  }
  c.JSON(http.StatusOK, gin.H{"references": refs})
}

func (h *Handler) httpDeleteSecret(c *gin.Context) {
  id := c.Param("id")
  if err := h.deleteSecret(c, id); err != nil {
    status, _, message, details := classifyDeleteError(err)
    if status == http.StatusInternalServerError {
      h.logger.Error("failed to delete secret", zap.String("id", id), zap.Error(err))
    }
    details["error"] = message
    c.JSON(status, details)
    return
  }
  c.Status(http.StatusNoContent)
}

func classifyDeleteError(err error) (int, string, string, map[string]any) {
  var inUse *InUseError
  if errors.As(err, &inUse) {
    return http.StatusConflict, ws.ErrorCodeConflict, inUse.Error(), map[string]any{
      "code": "secret_in_use", "references": inUse.References,
    }
  }
  if errors.Is(err, ErrNotFound) || errors.Is(err, ErrWorkspaceAccessDenied) {
    return http.StatusNotFound, ws.ErrorCodeNotFound, "secret not found", map[string]any{}
  }
  return http.StatusInternalServerError, ws.ErrorCodeInternalError, "failed to delete secret", map[string]any{}
}

Preflight and conflict dialog in UI

apps/web/components/settings/secrets-settings.tsx

The UI lists references before delete and shows a dialog with each blocker when the server returns 409.

Preflight hook
function useSecretDeleteActions(state, requestOptions, deleteRequest) {
  const { t } = useTranslation();
  const { toast } = useToast();
  const requestGeneration = useRef(0);
  const { deleteTarget, deleteReferences, setDeleteTarget, setDeleteReferences } = state;
  const closeDelete = () => {
    requestGeneration.current += 1;
    setDeleteTarget(null);
    setDeleteReferences(null);
  };
  const openDelete = async (secret: SecretListItem) => {
    const generation = requestGeneration.current + 1;
    requestGeneration.current = generation;
    setDeleteTarget(secret);
    setDeleteReferences(null);
    try {
      const references = await listSecretReferences(secret.id, requestOptions);
      if (requestGeneration.current !== generation) return;
      setDeleteReferences(references);
    } catch {
      if (requestGeneration.current !== generation) return;
      setDeleteTarget(null);
      setDeleteReferences(null);
      toast({ description: t("settings:secretReferencesLoadFailed"), variant: "error" });
    }
  };
  const confirmDelete = () => {
    if (!deleteTarget || deleteReferences === null || deleteReferences.length > 0) return;
    const target = deleteTarget;
    setDeleteTarget(null);
    setDeleteReferences(null);
    return deleteRequest.run(target.id).catch((error: unknown) => {
      const references = secretReferencesFromError(error);
      if (references !== null) {
        setDeleteTarget(target);
        setDeleteReferences(references);
        return;
      }
      toast({ description: secretDeleteErrorMessage(error, t), variant: "error" });
    });
  };
  return { referencesLoading: deleteTarget !== null && deleteReferences === null, openDelete, closeDelete, confirmDelete };
}
Error parsing
export function secretReferencesFromError(error: unknown): SecretReference[] | null {
  if (!(error instanceof ApiError) || error.status !== 409) return null;
  const body = error.body;
  if (!body || typeof body !== "object" || !("code" in body) || body.code !== "secret_in_use") {
    return null;
  }
  if (!("references" in body) || !Array.isArray(body.references)) return null;
  return body.references.filter(isSecretReference);
}

export function secretReferenceLabel(ref: SecretReference, t: TFunction): string {
  const name = ref.name ?? "";
  const key = ref.key ?? "";
  if (!name && !key) return t("settings:secretReferenceHidden");
  switch (ref.kind) {
    case "agent_profile":
      return t("settings:secretReferenceAgent", { name, key });
    case "executor_profile":
      return t("settings:secretReferenceExecutor", { name, key });
    case "repository":
      return t("settings:secretReferenceRepository", { name, key });
  }
}

Repair hint on missing secret

apps/backend/internal/agent/runtime/environment/environment.go

Resolve now tells the user which key and origin failed and where to re-select the secret, without leaking the secret value.

Error message
 	return fmt.Sprintf("environment key %q from %s could not be resolved", e.Key, e.Origin)
 	return fmt.Sprintf("environment key %q from %s references an unavailable secret. Re-select the secret in the %s environment settings", e.Key, e.Origin, e.Origin)
}
Agent profile prefix
resolved, records, err := runtimeenv.Resolve(ctx, definitions, m.resolveEnvironmentDefinition)
if err != nil {
  var secretErr *runtimeenv.SecretError
  if errors.As(err, &secretErr) && secretErr.Origin == runtimeenv.OriginAgentProfile && profileInfo != nil && profileInfo.ProfileName != "" {
    err = fmt.Errorf("agent profile %q: %w", profileInfo.ProfileName, err)
  }
  return nil, fmt.Errorf("resolve task environment: %w", err)
}

Wiring the checker at startup

apps/backend/internal/backendapp/main.go

The server wires the reference checker so every delete goes through the same discovery path.

Wiring
	secretsSvc := secrets.NewService(userSecretStore, log)
 	secretsSvc.SetReferenceChecker(secretReferenceChecker{
 		agents: repos.AgentSettings, tasks: repos.Task,
 		authorizeWorkspace: services.Task.AuthorizeWorkspaceAccess,
 	}.list)
	secretsSvc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error {

Data and storage

References carry only kind, name, and key. Hidden workspaces expose kind only, and no value or secret ID ever leaves the server.

FieldTypeNotes
kindstringagent_profile, executor_profile, or repository
idstringresource id, omitted when redacted
namestringprofile or repository name, omitted when redacted
keystringenvironment key that binds the secret
codestringsecret_in_use on 409 conflict

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Delete now fails closed when the checker is unavailable, which blocks deletes until storage recovers.
  • Redaction hides workspace details but still blocks deletion, so hidden references cannot be bypassed.
  • Forced delete keeps bindings intact, so a later re-select can repair without data loss.

Trade-offs and review notes

Where to look first

  1. Verify secret_references.go redacts agent and repository details when AuthorizeWorkspaceAccess returns not-found.
  2. Check that deleteChecked fails closed when referenceChecker is nil and that force=true bypasses only the check.
  3. Confirm handlers return 409 with code secret_in_use and that the UI preflight and 409 fallback both open the dialog.