18 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| shipped | 2026-08-11 | tbd |
Copy and Move Secrets Between Scopes
Why
Moving a credential between a workspace and the user's Global set (or between workspaces) currently means reveal, copy the plaintext, create a new secret, and delete the old one. Users must handle the value outside Kandev, which is both tedious and leak-prone. Kandev should copy or move a secret across scopes as one operation, without ever showing the value.
What
- Every secret row on the Global secrets page (
/settings/general/secrets) and on a Workspace secrets page (/settings/workspace/:id/secrets) offers a Copy/Move action. - The action opens one dialog that works the same on both pages. It contains:
- a Copy / Move radio (default Copy);
- a destination picker: General plus every workspace; when the source secret is Workspace-scoped its own workspace is excluded, and when the source is Global, General is excluded (a same-scope destination is a no-op);
- an editable target name field, pre-filled with
<name> (from Global)for a Global source or<name> (from <workspace name>)for a Workspace source.
- Copy creates a new secret in the destination scope with the chosen name and the source's value. The source stays.
- Move copies the secret to the destination and then removes the source, as one atomic operation. The dialog states that the original will be removed from its current scope.
- Workspace-to-workspace copy/move is supported through the destination picker.
- The persisted default target name is locale-independent: the origin token is
the literal string
Globalfor a Global source and the raw workspace name for a Workspace source. Only the surrounding pattern (<name> (from <origin>)). The dialog default is truncated so its UTF-8 encoding is at most 100 bytes, matching the backend's byte-based name limit. - If the chosen target name already exists in the destination scope, the
operation is blocked: the name field is marked invalid (red) with an error
message. The backend enforces this atomically inside the same transaction
that creates the copy and returns
409 Conflict; the dialog also pre-checks against loaded destination names when available. - Copying or moving into the scope the secret already belongs to is rejected with a clear validation error.
- Copying or moving a secret to a workspace requires that the destination workspace exists and that the caller has access to it; a workspace source also requires access to its owning workspace. Missing, unauthorized, or nonexistent targets resolve to "not found" and never disclose whether the secret or workspace exists.
- After a successful operation the visible lists update immediately: the source list drops the secret after a Move, and the destination list shows the new secret when it is the page being viewed. A Move or Copy into General always adds the returned item to the Global store, regardless of which page started it.
- The flow never reveals or copies plaintext through the UI; the encrypted value moves between rows server-side.
API surface
HTTP
POST /api/v1/secrets/:id/copy?workspace_id=<source-workspace-id>
POST /api/v1/secrets/:id/move?workspace_id=<source-workspace-id>
```text
The `workspace_id` query parameter identifies the source secret's workspace and
is required when the source is Workspace-scoped (matching the existing scoped
CRUD routes). The request body:
```json
{
"target_scope": "global" | "workspace",
"target_workspace_id": "<id>",
"name": "<optional target name>"
}
target_workspace_idis required whentarget_scopeisworkspace.nameis optional with presence semantics: a JSON-omittednamemeans "use the source secret's name"; a presentnamemust be non-empty after trimming (an explicitly empty or whitespace-only name is a400validation error), and an explicit JSONnullis rejected as a400validation error rather than treated as omitted. The name limit is 100 UTF-8 bytes after trimming, matching the existing create/update validation.- Success returns
201 Createdwith the newSecretListItem(for Move the source is already deleted at that point). - Errors:
400invalid payload or validation (bad scope/workspace combination, same-scope destination, invalid name);404missing or unauthorized source, or missing/unauthorized destination workspace;409target name conflict (atomically detected);500sanitized internal failure (decryption, storage). Details go to server logs, never the response.
WebSocket
secrets.copy
secrets.move
```text
Payload: `{ "id": "<source>", "workspace_id": "<source-workspace>",
"target_scope": "global" | "workspace", "target_workspace_id": "<id>", "name":
"<optional>" }` (`name` follows the same presence semantics as HTTP;
`workspace_id` follows the same source-scoping rule as the HTTP query
parameter: omitted or empty selects a Global source, and a Workspace source
requires a non-empty value, resolving to not-found when missing). Success
responses return the new `SecretListItem` as the WS payload (same fields as
the HTTP response), mirroring the existing `secrets.create` convention. Error
codes: `ErrorCodeConflict` (name conflict), `ErrorCodeNotFound`
(missing/unauthorized source or destination), `ErrorCodeBadRequest`
(validation), `ErrorCodeInternalError` (unexpected failures, sanitized).
### Go service surface
```go
type CopySecretRequest struct {
Scope SecretScope `json:"target_scope"`
WorkspaceID string `json:"target_workspace_id"`
Name *string `json:"name,omitempty"` // omitted = use source name; null/empty/whitespace = validation error
}
var ErrSecretNameConflict = errors.New("a secret with this name already exists in the target scope")
var ErrWorkspaceAccessDenied = errors.New("workspace access denied or workspace not found")
var ErrSecretValidation = errors.New("secret validation failed")
Handlers classify exclusively with errors.Is against these sentinels and
ErrNotFound; they never match error text.
Because Go's standard JSON decoding maps both an omitted key and an explicit
null to a nil pointer, the request type (or the handler decode path)
implements presence-aware decoding: a custom UnmarshalJSON records whether
the name key was present, so the service rejects explicit null as a
validation error while defaulting a truly omitted key to the source name.
func (s *Service) Copy(ctx context.Context, sourceID, sourceWorkspaceID string, req *CopySecretRequest) (*SecretListItem, error) func (s *Service) Move(ctx context.Context, sourceID, sourceWorkspaceID string, req *CopySecretRequest) (*SecretListItem, error)
The database schema is unchanged and `scope`/`workspace_id` stay immutable;
the `ScopedSecretStore` interface gains the two transfer methods below. The
service authorizes and validates, then delegates to atomic store operations:
```go
// ScopedSecretStore additions. sourceWorkspaceID is the RESOLVED source's
// workspace ("" for a Global source); a Move locks it alongside the
// destination so concurrent same-source moves serialize without deadlock.
// verifyDestination runs inside the transfer transaction while the
// destination lock is held, before the conflict check and insert; returning
// an error rolls back and surfaces that error (the service maps it to
// ErrWorkspaceAccessDenied / 404).
CopyScoped(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID, targetName string, verifyDestination func(context.Context) error) (*Secret, error)
MoveScoped(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID, targetName string, verifyDestination func(context.Context) error) (*Secret, error)
CopyScoped checks the target name and inserts the copy in one transaction;
MoveScoped additionally deletes the source in the same transaction. Both
return ErrSecretNameConflict when the target name is already taken and
ErrNotFound when the source row is absent. UserVisibleStore delegates and
rejects internal (github: prefix) source IDs. The encrypted value is copied
without passing plaintext through the service.
Store invariants for the transfer operations:
- The copied row is created with the caller's
user_id(the same scoping the regular create path writes); a transfer never produces an unowned row. - The source select and the Move source delete use the same per-user visibility predicate as the rest of the store, so a user can only move rows they can see.
- The target-name conflict check compares the normalized scope (a legacy empty stored scope is Global), so a legacy Global row can never be bypassed.
- The check-then-insert is atomic on both supported dialects: SQLite starts
the transaction with
BEGIN IMMEDIATE(writer lock up front), and PostgreSQL executesBEGINthen apg_advisory_xact_lockon the target scope's lock key on that same transaction before the source select and conflict check, serializing competing writers so exactly one copy/move wins a same-target-name race under READ COMMITTED. The PostgreSQL statement never runs on SQLite. No schema or uniqueness-constraint change; pre-existing duplicate names remain allowed by the regular create path. - Lock scheme (single authoritative contract, PostgreSQL only): the secrets
package exports
WorkspaceLockKey(workspaceID string) int64, a deterministic FNV-1a 64-bit hash of thekandev-secret-transfer:prefix plus the workspace ID — stable across restarts, collision-safe for UUID inputs (a theoretical hash collision only over-serializes, never corrupts) — plus a distinct stable constantGlobalSecretTransferLockKeyfor Global targets. A transfer acquires the destination scope's key (and the source workspace's key for Move, in sorted key order) and, while holding it, runs the destination existence/authorization verification inside the transaction before the conflict check and insert. The workspace-deletion transaction's secret-cleanup step (DeleteWorkspaceSecretsTx) acquires the same workspace key at its start and holds it to commit. Interleavings are therefore closed: a transfer that inserts before a deletion commits is cleaned up by that deletion under the same lock, and a transfer that starts after the deletion commits observes the workspace gone and fails closed. On SQLite the secrets database's single writer serializes transfer and cleanup; the residual cross-database window matches the existing create path (no schema change). A deterministic race test (separate connections, channel-scheduled) proves no secret remains attached to a deleted workspace, and a concurrent same-name test with a Global target proves exactly one winner on the Global lock.
Permissions
- A Global source is visible to the caller through the existing global access
rules; a Workspace source resolves only when the caller is authorized for
that workspace (same
GetWorkspaceSecretpath as the scoped CRUD routes). - A workspace destination requires both existence and authorization. The workspace authorizer is enforced for authenticated callers; a separate existence check runs even in auth-disabled/unscoped contexts so a copy/move can never attach a secret to a phantom workspace ID.
- Missing, unauthorized, or nonexistent sources/destinations all resolve to "not found" — the error never reveals whether the secret or workspace exists.
- Backend-owned internal secret IDs (
github:prefix) are not addressable and resolve to "not found".
Failure modes
- Target name conflict: the operation fails closed with
409 Conflictand a message naming the scope and the conflicting name. The conflict check and the copy share one transaction, and competing transfers are serialized (writer lock up front on SQLite, advisory transaction lock on PostgreSQL), so concurrent requests with the same target name yield exactly one success and409for the others; nothing partial is created. - Same-scope destination: rejected with a validation error before any transfer mutation (a read-only source resolution may already have happened).
- Source missing or unauthorized:
404, nothing changes. - Destination missing or unauthorized:
404, nothing changes; existence is verified even when authorization is disabled. If the workspace-existence checker is not wired, workspace destinations fail closed (same404). - Move failure: the copy and the source deletion are one transaction. Any failure (including a crash) rolls back; the source remains intact and no copy exists. There is no intermediate dual-state or compensation window.
- Internal failure (decryption, storage, or a database error inside an
authorization/existence lookup):
500with a sanitized message; full details are logged server-side. Values never appear in error payloads. Only the known "missing or unauthorized" sentinels (secret, workspace) map to404; unknown errors are never reclassified as client mistakes. - Destination workspace fetch failure (dialog pre-check): the dialog falls
back to the backend's authoritative
409; the pre-check is best-effort only. - Workspace list failed to load (settings hydration failure): the dialog shows a localized retry/failure state and disables submit instead of presenting an empty destination picker; a Global source must never see a form with zero valid destinations.
- Workspace list loaded successfully but is empty: the dialog shows a localized "no valid destinations" state (rather than an empty picker) and keeps submit disabled; a Global source has no valid target and must not be able to submit.
- Source deleted or destination unavailable while the dialog is open: the
backend returns
404(deliberately ambiguous between a missing source and a missing/unauthorized destination, so it never discloses which). The dialog shows a localized generic failure message and stays open; rows are never removed automatically on a404, and no destination item is added. The source list refreshes on the next navigation or reload.
Scenarios
- GIVEN a Global secret named
API Key, WHEN the user opens Copy/Move on it, chooses Copy, destinationMy Workspace, and keeps the default nameAPI Key (from Global), THEN a Workspace secret namedAPI Key (from Global)with the same value appears inMy Workspaceand the Global secret remains. - GIVEN a Workspace secret in
My Workspace, WHEN the user chooses Move to General, THEN a Global secret with the chosen name exists with the same value and the Workspace secret is gone, and the Global store gains the returned item even though the operation started on the Workspace page. - GIVEN a Global secret, WHEN the user chooses Move to
My Workspace, THEN the Workspace secret is created and the Global secret is removed. - GIVEN a Workspace secret in
A, WHEN the user copies it to workspaceB, THEN a Workspace secret inBexists with the same value and the secret inAremains. - GIVEN a destination scope that already has a secret with the chosen
name, WHEN the user submits Copy or Move, THEN the operation is
blocked with
409, the name field is marked invalid with an error, and neither the destination nor the source changes. - GIVEN two concurrent copy requests with the same target name into an
empty destination scope, WHEN both submit, THEN exactly one succeeds
and the other receives
409; the destination contains one secret. - GIVEN a source secret and a destination equal to the source's own scope and workspace, WHEN the user submits, THEN a validation error is returned and nothing changes.
- GIVEN a caller without access to the destination workspace, WHEN the
caller submits a copy/move into it, THEN the operation fails with
404without disclosing the workspace's secrets. - GIVEN a destination workspace ID that does not exist and authentication
disabled, WHEN the caller submits a copy/move into it, THEN the
operation fails with
404and no secret is created. - GIVEN an internal backend-owned secret ID (
github:prefix), WHEN the caller submits a copy/move of it, THEN the operation fails with404. - GIVEN a user on the Global secrets page who copies a secret to a workspace, WHEN the copy completes, THEN the Global list is unchanged and no workspace-scoped item is added to the Global store.
- GIVEN a user on a Workspace secrets page who moves a secret to General, WHEN the move completes, THEN the workspace list no longer contains the secret and the Global store gains it.
- GIVEN a Global source named with multibyte characters, WHEN the dialog computes the default target name, THEN the default is truncated so its UTF-8 length is at most 100 bytes and the copy succeeds without a backend validation error.
Mobile design contract
- Entry point: the Copy/Move button on a secret row opens the same dialog on phone widths, from both the Global and Workspace secrets pages.
- Presentation: the dialog adapts to the phone viewport (full-height sheet/drawer behavior rather than a centered desktop box), keeps the Settings content area as the single scroll owner, respects safe areas, and restores focus on close; Escape and back navigation close it.
- Interaction: the Copy/Move radios, destination picker, and name field use touch-sized targets (at least 44px), rows stack to one column, and there is no horizontal overflow. The destination picker is operable by touch and keyboard alike.
- Accessibility: the dialog has a
DialogTitle, realLabel/htmlForassociations for the name and destination controls (never placeholder-only), a labeled Copy/Move radio group, conflict errors associated viaaria-describedby, and keyboard-operable radios (Tab/Arrow/Space). - Parity: phone users can complete the full copy and move flows, including choosing a workspace destination and correcting a conflicting target name.
Out of scope
- Bulk copy/move of multiple secrets at once.
- Automatic conflict resolution (overwrite, auto-rename); conflicts always block and require the user to change the target name.
- Copying values into literal environment variables or non-secret storage.
- Changing scope in place: scope and workspace remain immutable; copy/move is the supported way to transfer authority.
- Live propagation of the copied value to running tasks or terminals.