API to migrate a secret to a secret store¶
The URL of the associated launchpad blueprint:
https://blueprints.launchpad.net/barbican/+spec/admin-migrate-secret-store
Provide a REST operation that re-encrypts an existing, active secret
onto a named secret store and keeps the same secret UUID. Consumers,
ACLs, container membership, creator_id, and user metadata are not
modified. The secrets row is never deleted.
This is independent of the in-review custom-UUID / disaster-recovery spec (Gerrit change 997056). That spec restores a deleted UUID. This spec moves live key material from one backend to another.
Problem Description¶
Use case (Deployer / operator)¶
A user has secrets stored on backend A (for example PKCS11 /
store_crypto + p11_crypto) and needs them on backend B (for
example KMIP) without changing the secret UUID. Volume and image
encryption, Castellan clients, and container secret_refs all persist
that UUID. Recreating the secret with a new id is not acceptable.
Today it is not possible to change the backend used to store an existing secret’s payload. Delete-and-recreate is the only workaround, and it is the wrong tool:
DELETE soft-deletes the secrets row and children, and
plugin.delete_secretmay destroy the KMIP/Vault object.ACLs are hard-deleted.
Re-POSTing consumers stamps the migrator’s
project_id, not Cinder/Glance’s, so laterremove_consumerfails.Re-POSTing the same payload generates a new UUID. After DELETE, the original UUID permanently 404s unless restored from soft-delete.
Preferred-store only affects new
POST /v1/secrets. It does not move existing secrets.
Use case (Self-service to preferred store)¶
GET /v1/secret-stores is allowed for role:reader. Every project
member can list every configured backend, including customer-specific
HSMs. A user must not be able to park material on an arbitrary backend
they discover from that list.
A common case: a secret was created while store AAAA was the project preferred store; the admin later changes preferred to BBBB. The secret owner should be able to move their secret onto BBBB without a cloud-operator ticket. They must not be able to migrate to CCCC, another store they saw listed but that is not their project’s preferred store.
Setting a project preferred store is already role:admin (this
policy predates the Domain Manager persona). A future self-service
model could let Domain Managers set preferred backends for projects in
their domain; this spec’s policy hooks should not block that.
Actors¶
Cloud / project admin: migrates a secret onto any configured secret store (or scripts a list). Does not need to know which store the secret is on today. If it is already there, the call is a success no-op. May use a system-scoped token to avoid assigning
adminon every project that holds secrets to migrate.Secret owner / member: under the default policy, may migrate a secret they can access onto the project’s current preferred secret store only. A request to any other store returns 403.
Domain manager (optional deployer policy): custom policy may allow migrate for secrets in their domain (see Policy).
External consumer (Cinder, Glance, Castellan): unchanged. The UUID, ACLs, and consumer registrations stay in place.
Proposed Change¶
Add a microversioned operation on the secret resource that takes a target secret-store identifier, decrypts the payload with the plugin already recorded on the secret, stores it on the named store, then removes the old plugin object only if it is a different object.
The secrets row stays. Identity (UUID, ACLs, consumers, container
joins, user metadata, creator_id) is not rewritten.
Scope of this spec¶
This spec covers:
REST API for per-secret migration to a named
secret_storesrowReturning the current secret-store id on secret metadata GET, computed from the same plugin identity retrieve already uses
Returning
secret_store_idonGET /v1/secret-stores(list, get-by-id, preferred, and global-default) so clients can migrate without parsingsecret_store_refPolicy (
secret:migrate_secretstore; admin to any store; members to project preferred only under default)Atomicity and plugin-delete rules so the secret is not lost and a newly stored KMIP/Vault object is not immediately destroyed
Idempotency when the secret is already on the target store
Microversion, errors, tests, and documentation
Out of scope:
Custom UUID / tombstone recovery (change 997056)
Making secret payloads generally mutable (no user-facing PUT/PATCH of ciphertext)
Bulk “migrate this whole project” API (an admin client can loop)
Persisting
secret_store_idorbackend_idon the secret (see Data model impact). A stored UUID would dangle across reinstalls until multiple instances of the same driver are first class (bug #2164266, bug #2164268).PATCH of secret-store placement (clients must not drive migrate via metadata update)
Changing who may list secret stores
Orders, CAs, transport keys
Barbican-to-Barbican copy across deployments
REST API¶
The operation is a PUT on the secret, not on /v1/secret-stores.
The destination store UUID is in the URL; the body is empty.
PUT /v1/secrets/{secret-id}/secret-store/{secret-store-id}
OpenStack-API-Version: key-manager 1.3
Method: PUT
Request body: none (empty)
Success: 204 No Content (secret href unchanged)
Microversion: next after current max (1.2 → 1.3). Requests without the microversion get 404 on this path. The same microversion adds computed secretstore fields on secret metadata GET (below), and adds
secret_store_idon secret-store catalogue GET responses.
URL parameters:
secret-id— existing secret UUID.secret-store-id— UUID of the destinationsecret_storesrow.
Normal response codes: 204
Error response codes:
400— multiple secret stores not enabled;secret-store-idis not a UUID; secret has no stored payload (metadata-only); nothing to migrate401/403— unauthenticated / policy deny; or target store is not the project preferred store and caller is not permitted to migrate to arbitrary backends404— secret not found in scope, or secret store id not found (including a UUID-shaped id that does not exist)503/ plugin errors — decrypt or store failed; secret must remain readable from the original backend
Secret metadata GET (same microversion)¶
GET /v1/secrets/{secret-id} (JSON metadata) and secret list
responses include the store the payload currently lives on, when
that store can be resolved. This is not a stored column. It is
computed the same way payload retrieve already finds a plugin:
match generate_fullname_for(plugin) (and the crypto plugin for
store_crypto) to secret_store_metadata['plugin_name'] /
kek_data.plugin_name, then map that identity onto the unique
secret_stores row (Newton UNIQUE(store_plugin,
crypto_plugin)).
New fields, only when the request asks for microversion 1.3 or
newer (same gating as consumers in 1.1):
{
"secret_ref": "https://…/v1/secrets/{secret-id}",
"secret_store_id": "93869b0f-60eb-4830-adb9-e2f7154a080b",
"secret_store_ref":
"https://…/v1/secret-stores/93869b0f-60eb-4830-adb9-e2f7154a080b"
}
secret_store_id/secret_store_ref— the livesecret_storesrow for this secret right now. After migrate, GET shows T. After a DB rebuild that re-syncs the same plugin pair, GET shows the new catalogue UUID, not a stale id.Always include both fields on microversion ≥ 1.3 (clients can rely on the keys being present). Values are JSON
nullwhen multiple secret stores are not enabled, or when the secret has no stored payload (metadata-only).Return HTTP 500 when multiple backends are enabled, the secret has a live payload, and no
secret_storesrow matches the plugin identity (or more than one row matches). That is a server misconfiguration / removed backend, not a client error.Do not add these fields to payload GET (
/v1/secrets/{id}/payload).Do not accept them on POST/PUT of the secret. Placement changes only via
PUT …/secret-store/{secret-store-id}.
This is derived at read time from existing plugin metadata, not a
new attribute on the secrets row. Unlike optional
transport_key_id, the keys are always present on ≥ 1.3 so
clients do not need “field missing vs null” branching.
Secret-store catalogue responses (same microversion)¶
Today GET /v1/secret-stores (and get-by-id / preferred /
global-default) return secret_store_ref but deliberately omit
the store UUID, so clients must parse the href before they can call
migrate. Microversion 1.3 also returns secret_store_id on
those responses:
{
"secret_store_id": "93869b0f-60eb-4830-adb9-e2f7154a080b",
"secret_store_ref":
"https://…/v1/secret-stores/93869b0f-60eb-4830-adb9-e2f7154a080b",
"name": "Software Only Crypto",
"secret_store_plugin": "store_crypto",
"crypto_plugin": "simple_crypto",
"global_default": true,
"status": "ACTIVE"
}
Requests without microversion 1.3 keep the previous response shape
(secret_store_ref only). The field name is secret_store_id
(Barbican catalogue naming), not a bare id, matching the
computed fields on secret metadata GET.
Policy¶
New rule: secret:migrate_secretstore.
Default check:
{
"secret:migrate_secretstore": "role:admin or (role:member and secret_store_id:%(target.preferred_secret_store_id)s)"
}
with scope_types=['project', 'domain', 'system']. role:admin may
target any configured store (including with a system-scoped token).
role:member may migrate only when the URL’s secret-store-id
matches the project’s current preferred store; a request to any other
store returns 403 after policy evaluation.
Unlike secretstore_preferred:post, this rule is not limited to
project scope only. secretstore_preferred:post rejects domain-
and system-scoped tokens because POST …/preferred has no request
body and must take the project id from the Keystone token. Migrate
identifies the secret (and thus its project) from the URL, so
system-scoped operators can migrate secrets across projects without
holding admin on every project.
Deployers may replace the default with a stricter rule (for example
admin-only) or extend it (for example Domain Managers — see below).
There is no tighter built-in policy than admin-only; tightening is
done by overriding the rule in policy.yaml.
Target dict / request context — include at least:
project_idanddomain_idfor the secret’s projectsecret_store_id(destination from the URL)preferred_secret_store_idfor the secret’s projectsource_secret_store_idwhen resolvable from plugin metadata
This enables deployer policies such as:
"secret:migrate_secretstore": "role:admin or (role:manager and domain_id:%(target.secret.domain_id)s)"
which would let Domain Managers migrate secrets in their domain.
Do not gate this on secret:decrypt or secrets:post alone.
Decrypt is too broad (ACL readers); post is create, not mutate.
Algorithm¶
For secret S and target store T (from URL):
Authorise (
secret:migrate_secretstore). Load S (active only; 404 if deleted). Reject metadata-only secrets with 400. Load T fromsecret_stores(404 if missing). If multiple secret stores are disabled, return 400.If the caller is
role:member(notrole:admin) and T is not the project’s preferred store, return 403.If S is already on T’s plugin, return 204. Resolve T to a store/crypto plugin instance and compare
generate_fullname_for(plugin)(and crypto plugin identity forstore_crypto) to theplugin_namealready on S. Do not persist a store id on the secret.Decrypt the payload using the plugin already on S (
get_plugin_retrieve_delete/ existing GET-payload path). Failure: abort, no writes.Snapshot
old_meta(plugin name + plugin-specific object ids) before store.Store the plaintext on the plugin for T. This is not the current
store_secret()helper: that helper refuses secrets that already have data. Implementation needs a dedicated “store onto thissecret_storesrow” path (resolve T →store_plugin/crypto_plugin, thenstore_secreton that plugin).Persist new
secret_store_metadata/encrypted_data(and any plugin metadata) so subsequent GET uses T. Keep this in the same DB transaction as retiring the old datum rows (soft-delete old encrypted datum / store metadata).If store or DB commit fails: delete the new plugin object if one was created; leave
old_metaas the live pointer. The secret stays available on the original backend.After commit:
plugin.delete_secret(old_meta)only ifold_metanames a different plugin object thannew_meta. Skip ifold_metais missing, or if it is the same object.store_crypto.delete_secretis a no-op; that is fine.If step 9 fails: log at ERROR/WARNING. The secret is already available on T. Leaking an orphan KMIP object is better than deleting the only copy.
Do not call plugin.delete_secret on the secrets row. Do not
DELETE the secret. Do not change consumers, ACLs, or containers.
Same-plugin trap¶
A naive “store, then delete using the old plugin” on the same
KMIP/Vault plugin stores a new object and then destroys it, because
delete_secret uses whatever metadata it is given. Always compare
old vs new object identity. PKCS11 via store_crypto hides this
today (delete is a no-op); KMIP and Vault will not.
Idempotency and concurrency¶
Already on T: 204, no decrypt required if T’s resolved plugin fullname matches the secret’s existing
plugin_name(and crypto plugin forstore_crypto).Two concurrent migrates of S: at most one should rewrite metadata; the other should 204 or 409. Implementation can use a row lock on the secret.
Secret DELETE during migrate: migrate must 404/409; DELETE must not observe a half-rewritten metadata set.
Containers and consumers¶
Migrate one secret. An RSA container is three secrets (private,
public, optional passphrase); migrate each id separately. Consumer
registrations stay; Cinder/Glance keep their original project_id.
Alternatives¶
DELETE then POST with caller-supplied UUID (change 997056). Rejected for live secrets. Destroys identity, ACLs, and consumer ownership. Correct for DR of a tombstone, not for migration.
User-facing PUT of arbitrary payload to change backend. Rejected. Makes secrets mutable for anyone who can update payload. Migrate to the project preferred store via this subresource is in scope (see Policy).
PUT
/v1/secret-stores/{ss-id}/secrets/{secret-id}. Rejected as the primary spelling. Semantically this mutates the secret, not the store catalogue. A store-centric bulk API can be a later addition.
- 3a. PUT
/v1/secrets/{id}/secret-storewith store id in the body. Rejected. Prefer
PUT …/secret-store/{secret-store-id}with an empty body so the destination is unambiguous in the URL.
PATCH ``secret_store_id`` on the secret to change placement. Rejected. A writable field on every secret would let clients drive placement without policy checks. Migrate stays a dedicated subresource. Read-only computed fields on GET are in scope (see REST API) so operators and clients can see where a secret lives without a stored foreign key.
Persist ``secret_store_id`` (or ``backend_id``) on the secret for idempotency or GET. Rejected for this spec.
secret_stores.idis a random UUID4 assigned at first INSERT of a(store_plugin, crypto_plugin)row, not a stable plugin or HSM identity. Reinstall, or delete-and-re-add a backend frombarbican.conf, yields a new UUID. A column on the secret would then dangle. That only becomes a reasonable stored key once multiple instances of the same driver are first class (bug #2164266, bug #2164268). Until then, compute the catalogue UUID on GET from plugin identity. The backend-registry spec may later persist a stablebackend_idon metadata; migrate’s no-op check can switch to that in a small follow-up.Server-side “migrate all secrets in this project”. Deferred. Timeouts, partial failure, and progress reporting need a job API. An admin script calling this endpoint per secret is enough for the first iteration.
Data model impact¶
None. No new tables, columns, or Alembic migrations. GET does not
write secret_store_id onto the secret.
The migrate no-op check and GET store fields both use existing plugin
metadata only (see Algorithm). secret_stores.store_plugin is a
Stevedore/config name; plugin_name on the secret is a Python class
path. The comparison is after resolving a catalogue row to an
instance, not string equality of those two columns.
This is sufficient while UNIQUE(store_plugin, crypto_plugin)
holds (Newton): two stores cannot share the same plugin pair, so
plugin identity is a 1:1 stand-in for the store row. GET therefore
returns at most one secret_store_id. If several rows ever share
a plugin pair, GET returns HTTP 500 rather than guess (that layout
is out of scope until bugs 2164266 / 2164268).
The backend-registry spec (later) will persist a stable
backend_id on secret metadata so multiple instances of the same
driver can be distinguished. When that lands, migrate should switch
the no-op check to backend_id (a small follow-up, not a field
added here and discarded). Operators should expect a reconfig when
moving to the registry layout.
Security impact¶
Touches secret payload in API-worker memory for the duration of the call, same as GET payload + POST secret. Must not log plaintext.
New way to cause Barbican to write to a named backend. Default policy limits members to the project preferred store; admins may target any configured backend.
Does not weaken cross-project isolation: the secret must already be in the caller’s authorised scope.
Encryption changes with the destination plugin. PKCS#11 via
store_cryptouses pKEK/MKEK; KMIP stores cleartext in the appliance; SimpleCrypto uses Fernet in the database. Migrate re-wraps through the API worker; it does not preserve the old on-disk/on-HSM representation.Resource exhaustion: one decrypt + one store per call. No fan-out. Bulk migrate is client-side.
Audit: log project, actor, secret id, source plugin identity, target
secret_storeid at INFO (WARNING if old-object delete fails after commit).GET metadata (1.3) tells any
secret:getcaller which catalogue store currently holds the payload. That is no stronger thanGET /v1/secret-stores(alreadyrole:reader) plus knowing the secret exists. It does not allow choosing a backend.
Notifications impact¶
None required. A future CADF notification for “secret re-wrapped” is optional and can be a follow-up.
Python and command line client impact¶
Follow-up, not this spec:
openstacksdk: method on
Secret(for examplemigrate_secret_store); parsesecret_store_id/secret_store_refon secret GET.OSC: admin command wrapping migrate; show the new GET fields.
python-barbicanclient is deprecated for new work; do not add the primary client there.
Other end user impact¶
Members who can secret:get see which catalogue secretstore
currently holds the payload (microversion ≥ 1.3). Under default
policy they may migrate only to the project preferred store.
Operators can GET before/after migrate instead of inferring from
plugin names.
Performance impact¶
One decrypt and one store per migrated secret. PKCS11 unwrap + KMIP create is HSM/network bound.
One extra read of
secret_storesand current plugin metadata on migrate, and the same cheap lookup on secret metadata GET when microversion ≥ 1.3 (thesecret_storestable is small).No extra work on payload GET. POST create is unchanged.
Other deployer impact¶
Requires
enable_multiple_secret_stores = Trueand a populatedsecret_storestable. Migrate when the flag is False returns 400. Longer term, consider deprecatingenable_multiple_secret_stores = Falseand phasing out single-store-only deployments (out of scope for this spec).No new config flags required. Optional later: max payload size already enforced by existing limits.
Rolling upgrade: older API workers do not expose the route. Microversion 1.3 must be served only by new workers.
After migrate, operators may decommission backend A only once they have migrated every secret that still decrypts with A’s plugin.
Follow-up (not this spec): a
barbican-managesubcommand summarising how many secrets use each backend would help migration planning.
Developer impact¶
plugin.resources.store_secretcannot be reused as-is (_secret_already_has_stored_data). Add a sibling helper, for examplerewrap_secret(secret_model, project_model, secret_store).SecretStorePluginManager.get_plugin_storeselects preferred / default / plugin class name, notsecret_stores.id. Need a resolver from store row → plugin instance (includingstore_crypto+ named crypto plugin). Reuse it for: store onto T, “already on T”, and computed GET fields.Plugin authors: no interface change if
store_secret/get_secret/delete_secretalready work. The comparison of old vs new metadata is in the resource layer.
Worked example (PKCS11 → KMIP)¶
GET /v1/secret-stores (admin notes KMIP store id)
GET /v1/secrets/{secret-uuid} (secret_store_id is PKCS11)
PUT /v1/secrets/{secret-uuid}/secret-store/{kmip-ss-id}
GET /v1/secrets/{secret-uuid} (secret_store_id is now KMIP)
GET /v1/secrets/{secret-uuid}/payload (still works; same UUID)
GET /v1/secrets/{secret-uuid}/consumers
(Cinder registration unchanged)
Internal sequence:
decrypt via p11_crypto (ciphertext in DB, MKEK on HSM)
|
v
KMIP store_secret → new KMIP UUID in secret_store_metadata
|
v
commit DB (new metadata live, old encrypted_data soft-deleted)
|
v
delete_secret(old_meta) → store_crypto no-op; leftover ciphertext
already soft-deleted
Implementation¶
Assignee(s)¶
- Primary assignee:
alee
- Other contributors:
None yet.
Work Items¶
Spec review and barbican implementation.
Microversion 1.3 and API-guide / microversion history.
Policy
secret:migrate_secretstoreand target dict fields.Resolver:
secret_storesrow → store/crypto plugin instance; use it to store onto T, decide “already on T” viaplugin_name, and compute GETsecret_store_id.rewrap_secrethelper with old-vs-new plugin-delete guard.Controller
PUT /v1/secrets/{id}/secret-store/{ss-id}.Secret metadata GET/list: always add computed
secret_store_idandsecret_store_reffor microversion ≥ 1.3 (nullwhen N/A; HTTP 500 when a live payload is unresolvable). No schema change.Secret-store catalogue GET/list/preferred/global-default: return
secret_store_idalongsidesecret_store_reffor microversion ≥ 1.3 (omit the id on older microversions).Unit tests: already-on-target; decrypt fail; store fail rolls back; KMIP-style metadata;
store_cryptono-op delete; member 403 to non-preferred store; member 204 to preferred; admin to any store; GET returns store id matching T after migrate; GET returnsnullstore fields for metadata-only and when multiple backends are disabled; GET omits fields on microversion 1.2; GET returns 500 when a live payload cannot be resolved; metadata-only migrate returns 400; secret-stores list includessecret_store_idon 1.3 and omits it on older microversions.Functional tests with two configured stores.
Release note.
Follow-up: SDK/OSC. Follow-up (with backend registry): switch no-op check to
backend_id. Follow-up:barbican-managebackend usage summary.
Dependencies¶
Does not depend on custom UUID spec 997056.
Does not depend on the backend-registry / admin-backend-API drafts. Those land after this spec. Migrate compares plugin identity until registry stores
backend_idon secrets; a small follow-up then switches the no-op check. Operators reconfigure when moving to the registry layout.
Testing¶
Unit: algorithm steps 2–10, policy, validation, concurrent migrate.
Functional: two stores (simple-crypto + second plugin, or two
store_cryptoconfigs where feasible); migrate; GET payload; GET metadatasecret_store_idmatches destination; consumers still listed; ACL still enforced; repeat PUT is 204.Negative: member 403 to non-preferred store; member 204 to preferred; admin to arbitrary store; bad store id 404; metadata-only migrate 400 and GET returns
nullstore fields; multiple backends disabled migrate 400 and GET returnsnullstore fields; unresolved live payload GET 500; microversion 1.2 does not expose the migrate route and does not include store fields on GET.Gate: no extra hardware required for a simple-crypto pair if the second store can be a second crypto plugin. KMIP job remains optional extra coverage.
Documentation Impact¶
API reference: new subresource and errors; secret GET/list fields
secret_store_idandsecret_store_ref.Admin guide: operator procedure (list stores, GET secret to see current store,
PUT …/secret-store/{ss-id}per secret, then decommission old backend). Warn that GET payload during migrate is briefly served from the old backend until commit. Document that the id is computed and is not durable across asecret_storesre-sync.doc/source/api/microversion_history.rst— 1.3.Release note.
References¶
Launchpad blueprint: https://blueprints.launchpad.net/barbican/+spec/admin-migrate-secret-store
Custom secret UUID / DR (in review): https://review.opendev.org/c/openstack/barbican-specs/+/997056
Multiple Secret Backend Support (Newton): https://specs.openstack.org/openstack/barbican-specs/specs/newton/multiple-secret-backend.html
Multiple instances of the same secret-store driver (deferred): https://bugs.launchpad.net/barbican/+bug/2164266
Admin API for backend configuration (deferred): https://bugs.launchpad.net/barbican/+bug/2164268
Secret stores policy (reader can list stores; admin sets preferred):
barbican/common/policies/secretstores.pystore_crypto.delete_secretis a no-op:barbican/plugin/store_crypto.pyReviewathon notes (2026-08-21, team): live secret migrate is a PUT on the secret; recovery stays on POST+id.