Skip to content

feat(secrets): secrets kept in AWS Parameter Store, moved there and back at start - #275

Merged
rami-hatoum merged 3 commits into
mainfrom
feat/secrets-kept-in-a-secret-manager
Oct 21, 2026
Merged

rami-hatoum merged 3 commits into
mainfrom
feat/secrets-kept-in-a-secret-manager

Conversation

@rami-hatoum

Copy link
Copy Markdown
Contributor

Secrets can now rest in the operator's own secret manager instead of the ledger's database. SECRETS_KEEPER=aws-parameter-store:<prefix> names AWS Systems Manager Parameter Store, and each value is then a SecureString parameter named <prefix>/<id>, kept as it is under the manager's own encryption, so whoever runs the runtime reads, rotates and tracks it there: the standard tier up to 4 KB and the advanced tier beyond, one rule written once. A row of the ledger's kept_secrets table keeps only the parameter's name and version, so a reader reads exactly the version its row names, whatever a later write left as the latest; a replacement writes the manager first and the row second, and a release deletes the parameter with every version. A parameter gone from the manager is the value gone for good; a denied identity, a throttle the client's own retries did not wait out, a timeout or a fault of the service fails the call as connection_unavailable in the manager's words and breaks nothing. With the manager as the only keeper, no SECRETS_KEY is needed and no key file is made, and a key setting given anyway stops the start in words; with the ledger as the current or the previous keeper, the custodian rules are as they were.

At start the manager is probed as the object store's bucket is, one parameter under the prefix written, read and deleted, so a prefix the identity may not write, read or delete stops the start, naming the setting, the prefix and the manager's answer. SECRETS_KEEPER_PREVIOUS moves every value in one restart, from the ledger to the manager or back: each row the previous keeper holds is revealed by it, held by the current, re-held only while its version and holding are the ones read, and released by the previous once the row took it, so a replacement made meanwhile is never written over, and the runtime's own retry-state key moves with the rest; the start line says Secrets are kept in AWS Parameter Store under <prefix> in <region>, and <count> kept by the previous keeper were moved there, and never a key or a value; a row sealed under a key neither custodian holds, or whose value the manager refuses, is left where it is and logged, and a move past the account's quota of parameters stops the start with the rows moved so far staying moved. The region comes from AWS_REGION or AWS_DEFAULT_REGION, which no connection may name any more. The keeper is loaded through @on.auto/secrets/aws only where a setting names an AWS keeper or custodian, and the package's fake answers the Parameter Store protocol for the unit tests. CI runs the KMS and Parameter Store suites against one moto server, then a runtime twice under the manager alone with no key, a connection's credential given and read back, the parameters and the rows inspected, and a move from the ledger counted. The new self-host page, Keep secrets in your secret manager, says the setting, the identity's permissions, the probe, the move, the limits and what a parameter deleted in the manager does to its connection.

rami-hatoum and others added 3 commits October 21, 2026 01:48
… its fake and the move

A value can now rest outside the ledger's database: the keeper of AWS Systems Manager Parameter
Store keeps each one as it is, a SecureString of its own, and the row keeps the name and version.

- parameterStoreKeeper({ prefix, region, proxy, credentials, endpoint?, waits? }) of
  @on.auto/secrets/aws, over @aws-sdk/client-ssm pinned to 3.1149.0, the version of the other AWS
  clients. hold(id, value) is PutParameter of <prefix>/<id>, SecureString, Overwrite, the value as
  it is, answering { keptBy: 'aws-parameter-store', held: '<name>:<version>' } with the version the
  write returned; reveal is GetParameter of that name and version with decryption; release is
  DeleteParameter of the name, which takes every version.
- The tier is one rule: a value of more than 4096 bytes of UTF-8 names the advanced tier, and a
  smaller one names none, so a new parameter takes the account's default tier and an advanced one
  keeps its tier, which the service lets no parameter leave.
- The client retries in the SDK's adaptive mode, which slows its requests once the service
  throttles them, unless AWS_RETRY_MODE or the profile names another; AWS_MAX_ATTEMPTS is read as
  the SDK reads it.
- ParameterNotFound and ParameterVersionNotFound are a value gone for good: reveal answers nothing,
  and the store's read answers nothing, as for a row that is gone.
- A value the service refuses to keep is SecretRefused in words that lead with the parameter, "AWS
  Parameter Store refused to keep the parameter <name>: <the service's words>", since the probe's
  value is a UUID: over_the_keepers_quota for ParameterLimitExceeded, the account's, and
  refused_by_the_keeper for PoliciesLimitExceededException, ParameterMaxVersionLimitExceeded and a
  ValidationException of a write, such as a value that holds a reference in double braces. The hold
  of the keeper port answers SecretRefused or KeepingFailure, so keep and replace do.
- Any other failure, a denied identity, a throttle the SDK's retries did not wait out, a timeout, a
  port that cannot be reached or a fault of the service, is keeper_unavailable in the service's
  words, the account of every ARN left out.
- awsWaits, 5 seconds to connect and the store's 10 for an answer, and the words without the
  account are written once in src/aws/aws-requests.ts for both AWS adapters; kmsWaits and KmsWaits
  become awsWaits and AwsWaits.
- The store takes keepers, { current, previous? }, and custodians only where a keeper seals: it
  keeps and replaces with the current keeper, reads with the keeper a row names, a row of a keeper
  neither current nor previous being keeper_unavailable in words that name it, and forgets a row
  whose release fails or whose keeper is unknown, logging where its value is left.
  sealedSecretStoreOn(rows, custodians) is the store whose one keeper seals under the custodians;
  the server's store and the stores the tests of the connections and tool servers use are it.
- moved() moves what the previous keeper holds, 100 rows at a time after the last id of the batch
  before: each value revealed, held by the current keeper under the row's id, re-held while the
  row's version and holding are the ones read, and released by the previous keeper once the row
  took it. A replacement made meanwhile is left as it is and nothing of it is released; a row whose
  value is gone is left where it is. A row sealed under a key neither custodian holds is left where
  it is and logged once for that key's kid, a value the current keeper refuses is left where it is
  and logged with the secret's id, and the count skips both. A refusal over the keeper's quota
  would meet every later row, so it fails the move, the rows moved before it moved. heldBy of the
  rows port takes that id, in memory, on SQLite and on PostgreSQL.
- keeperProbed(keeper) holds one value under the id probe-<uuidv7>, which no secret's id can be,
  reads it back and releases it, within the store's bound.
- serveFakeParameterStore() of @on.auto/secrets/testing answers PutParameter, GetParameter and
  DeleteParameter of the SSM JSON protocol under X-Amz-Target through one switch, keeps the
  versions and the tier of each parameter, answers a name:version selector, refuses a version or a
  name it does not have, a standard-tier value over 4 KB, any value over 8 KB, a value with a
  reference in double braces and a move of an advanced parameter to the standard tier, is told a
  name is gone, the next writes throttled or refused under an error's name, how many parameters it
  keeps, or an operation denied, stalling or faulting, and records each call without its value,
  with the retry mode its user agent names. The fakes of KMS and Parameter Store and a keeper in
  memory, with the TestKeepers it is made by, live in src/fakes, which imports nothing from
  src/testing.
- keeperBehaviour runs the store over any keeper: a value kept as it is and no value in the row, a
  replacement with the earlier version still read, a forget that leaves no version, the refusals
  before the keeper is asked, and the move from the ledger and back past a hundred rows, a
  replacement made meanwhile either way, a hundred values gone, rows under a key not given and
  moved once it is, a third keeper's rows and a previous keeper that cannot reveal. storeBehaviour
  runs it over a keeper in memory, so the ledger runs it over SQLite and PostgreSQL.
- Tests of the keeper over the fake: the call it sends, the tier at 4 KB exactly and one byte over
  in ASCII and in UTF-8, an earlier version read after a later write, a release of every version, a
  parameter and a version gone, a write throttled once and retried in the adaptive mode, the
  standard mode where AWS_RETRY_MODE names it, writes still throttled, each value the service
  refuses, the probe stopped at a parameter it refuses, a move past the parameters the account
  keeps stopped and later resumed, a write, a read and a delete denied without the account or a
  credential, and a service that stalls, faults or cannot be reached. A suite against a real server
  runs when SECRETS_TEST_AWS_URL names one, with a write it throttles on its way there.
- The README of the package and the ledger's README say the keeper, the move and the fakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…R names it, probed and moved

An operator can now keep no secret in the runtime's database: SECRETS_KEEPER names a prefix in AWS
Systems Manager Parameter Store, and a runtime with no other keeper needs no key at all.

- SECRETS_KEEPER is ledger by default, or aws-parameter-store:<prefix>, the prefix an absolute
  parameter path of at most 14 segments of letters, digits, '.', '-' and '_', the first beginning
  with neither aws nor ssm, and no slash at its end; anything else stops the start in words. Its
  region is AWS_REGION's, or AWS_DEFAULT_REGION's where that is not set, kept with the name of the
  variable it came from, and a keeper with neither stops the start naming SECRETS_KEEPER and
  AWS_REGION.
- SECRETS_KEEPER_PREVIOUS is read only beside SECRETS_KEEPER, and names the other of the two.
- A custodian is read exactly where the ledger keeper is current or previous: with Parameter Store
  alone a key setting stops the start in words that say no keeper would use it, and no key file is
  made, beside a SQLite ledger or a PostgreSQL one. The rule that a ledger in PostgreSQL needs a
  key keeps its words while the ledger keeps secrets.
- secretsVariables lists SECRETS_KEEPER and SECRETS_KEEPER_PREVIOUS, and the variables the runtime
  reads for itself AWS_DEFAULT_REGION, so no connection may name them.
- The keeper is made through import('@on.auto/secrets/aws'), the one loader of the AWS adapters, so
  the SSM client is loaded where a keeper in Parameter Store or a key in KMS is named, with the KMS
  client, and a runtime that names neither loads no AWS client.
- At start the server probes a current keeper in Parameter Store with one parameter,
  <prefix>/probe-<uuidv7>, written, read and deleted, a probe that fails stopping the start with
  the setting, the prefix and the words of the service; then moves what the previous keeper keeps,
  a move that fails stopping the start with SECRETS_KEEPER_PREVIOUS; then rotates, and logs
  "Secrets are kept in AWS Parameter Store under <prefix> in <region>, and <count> kept by the
  previous keeper were moved there", or the ledger's line as it was, with that count after it where
  a previous keeper is named.
- The key the runtime keeps for itself moves with the rest. Under the manager each start logs "The
  runtime read the key it keeps for retry_state in AWS Parameter Store under <prefix> in <region>",
  its own_key_id the id that names the key's parameter, and a start that finds the parameter gone
  stops with "... is no longer in AWS Parameter Store under <prefix> in <region>, the region
  AWS_REGION names, so the server does not start", as after a change of region or account. Its
  other failures name the manager, its prefix and its region.
- The composition of the secret store, its custodians, keepers, start and own keys, moves from
  src/database to src/secrets.
- Tests: the settings and each refusal; the custodians and keepers each setting makes and what they
  load; no AWS client loaded for a key in hand, and the SSM client for a keeper in Parameter Store;
  the probe and a prefix the identity may not write, read or delete, or a manager that stalls; the
  move into the manager and back with its counts, one that cannot read, and one past the parameters
  the account keeps, stopped with the secrets moved before it moved; a server started twice under
  the manager alone with no key and no key file, its own key kept there; that key moved from the
  ledger; its line and its id at start, a start stopped once its parameter is deleted, and a start
  in another region stopped at it after a probe that passes, serving nothing and writing nothing
  there but the probe, and started again once the region is set back; and no value, key or
  credential in a log line.
- A page, Keep secrets in your secret manager, under Deploy in the navigation, with the setting and
  what a change of region or account does, the parameters, the identity's permissions, the probe,
  the key the runtime keeps for itself, the move and back, with the settings removed only once
  nothing is left in the ledger and what to do where a warning says something is, what the operator
  sees and may delete there, which never includes the parameter of that key, and the limits: the
  time a move takes, the SDK's adaptive retry and its two variables, a throttled move resumed by
  the next start, the parameters an account keeps, past which a move stops, and the values the
  service refuses, with how a pasted credential, a consent and a renewal answer them. Its examples
  and quoted words are checked by tests. The configuration reference, the security page's secrets
  at rest, the production, PostgreSQL and connections pages and the overview point at it, the
  READMEs follow, and .env.example says a ledger in PostgreSQL needs no key where SECRETS_KEEPER
  names Parameter Store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and the runtime under it

The keeper of AWS Parameter Store is tried against a server of the Parameter Store API on every
pull request, beside the custodian of a key in KMS, and so is a runtime that keeps no secret in its
database.

- The job kms becomes secrets-aws: one moto server, motoserver/moto 5.2.4 pinned by its digest, on
  the port 19760, answers KMS and Parameter Store, and ci-ok needs the job by its new name.
- One variable, SECRETS_TEST_AWS_URL, replaces SECRETS_TEST_KMS_URL in turbo, in the job and in
  the KMS suite, which the keeper's suite takes too; the job checks that both suites ran, that
  vitest's summary lines name nothing skipped, and that the credential is in no line of the output.
- A step starts the runtime twice under SECRETS_KEEPER=aws-parameter-store:/auto-runtime/secrets
  with no SECRETS_KEY, on the port 19762, keeps the pasted value of a connection through the HTTP
  API and reads its ending back through it, shows two parameters under the prefix, the value once
  among them, two rows of the ledger kept by the keeper that hold no value, wrapped key or kid, and
  no key file, then removes the connection and shows its parameter gone.
- It then starts a runtime under the ledger and a key, and once more with Parameter Store as the
  keeper and the ledger as the previous one, and checks that the key it keeps for itself was
  moved and counted. Each start line is checked, with the prefix and the region, and so is the
  line of the key the runtime keeps for itself at both starts under the manager alone; no value,
  key, credential or value read back from moto reaches the log, and a step that fails ends the
  runtime it started and shows each log.
- scripts/test-groups.test.ts follows the job's new name, and the secrets README says what CI runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rami-hatoum
rami-hatoum merged commit eae2d70 into main Oct 21, 2026
20 checks passed
@rami-hatoum
rami-hatoum deleted the feat/secrets-kept-in-a-secret-manager branch October 21, 2026 07:48
rami-hatoum pushed a commit that referenced this pull request Oct 21, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.16.0](v0.15.0...v0.16.0)
(2026-10-21)


### Features

* **api:** an agent asked to connect a service knows the runtime does it
([#277](#277))
([d4e9d1f](d4e9d1f))
* **secrets:** secrets kept in AWS Parameter Store, moved there and back
at start ([#275](#275))
([eae2d70](eae2d70))
* **secrets:** the secrets key in AWS KMS; a store that cannot answer
now fails the call
([#271](#271))
([374c1b2](374c1b2))
* **server:** the agent's relay reaches its runtime again within
seconds, on the protocol's means
([#278](#278))
([92da8a1](92da8a1))


### Bug Fixes

* **server:** a ledger of another version says where it is, what to do
and where to ask
([#273](#273))
([168a756](168a756))
* **tool-servers:** a path argument that holds a slash is sent encoded
as one segment, not refused
([#276](#276))
([9942eda](9942eda))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: beonautobot[bot] <298957281+beonautobot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant