feat(config): expand environment variables in pgdog.toml and users.toml - #1493
feat(config): expand environment variables in pgdog.toml and users.toml#1493ChrisRx wants to merge 2 commits into
Conversation
Configuration files can now reference the process environment, so secrets
and per-environment values no longer have to be baked into the files on
disk:
```toml
[admin]
password = "${PGDOG_ADMIN_PASSWORD}"
[general]
shutdown_timeout = ${PGDOG_SHUTDOWN_TIMEOUT:-60000}
```
`$VAR` and `${VAR}` are substituted from the environment, `${VAR:-value}`
supplies a fallback, and `$$` is a literal `$`.
Lookups are lenient: a reference to a variable that isn't set is left in
the document verbatim rather than failing the load. `users.toml` is the
file most likely to contain a stray `$` — a password like `sup$rsecret`
keeps working instead of turning into a startup failure or, worse, a
silently truncated credential. The one behaviour change to be aware of is
that a literal `$$` in an existing value now collapses to a single `$`;
that is unavoidable once any escape exists.
Expansion runs on the document source before it is parsed, so a variable
is interpolated as TOML rather than as a string. `${PASSWORD}` in value
position still needs its surrounding quotes, and a value containing `"`
or a newline will change how the rest of the document parses. This is
what allows bare `shutdown_timeout = ${VAR}` to work, and it is
documented on `expand`.
Implementation notes:
- New `pgdog-config::expand` module. `expand()` is infallible and returns
`Cow::Borrowed` when there is nothing to substitute, so the common case
costs no allocation.
- `FromToml::from_toml` replaces bare `toml::from_str` at the three sites
that parse config text read from disk: both branches of
`ConfigAndUsers::load` and `bootstrap_logger`. Every other
`toml::from_str` in the tree parses a test literal, where expansion is
unwanted, and is untouched.
- The trait carries a blanket impl over `DeserializeOwned`, so no
per-type boilerplate is needed. `from_toml`, not `from_str`, to avoid
colliding with the crate's many `std::str::FromStr` impls.
- `Error::config` now receives the expanded text, so the line numbers it
reports stay correct when a variable's value contains a newline.
- `ConfigAndUsers` keeps `config_text`/`users_text` as the raw,
unexpanded source. Resolved secrets must not be written back to disk
when the config is reloaded or backed up.
Adds a dependency on `shellexpand`.
The expand environment variable feature for configuration files previously used shellexpand to expand references before being parsed as toml. shellexpand supports expanding references that include just a $ so it is being replaced here with a simple scanner over the toml input string that only allows bracketed variable references. The replacement expand function works with the former fallback syntax. Another big bonus for this change is that requiring brackets means that the new function can also ensure that there is a closing bracket before performing a substitution.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| // Anything else is literal text: emit through the `${` and rescan right | ||
| // after it, so a stray `${` in one value can't swallow a real reference | ||
| // later in the document. | ||
| let reference = body.find('}').and_then(|end| { |
There was a problem hiding this comment.
I think all characters are allowed in a Postgres password, e.g., $, { and }, so this is a valid password which will be expanded to an empty string:
${hello}
Curious if you have any thoughts. Maybe we should only expand settings that are entirely covered by an env var, e.g.:
password = "${PASSWORD}" # setting value starts with `${` and ends with `}`That would require us to perform shellexpand on each value after deserialization (or write a custom serializer).
Just thinking out loud, let me know what you think.
There was a problem hiding this comment.
In the case with ${hello} it would need to be set as the password value and also be set in the environment, so unless it has an environment variable for hello= it will keep it as the original string, ultimately leaving as ${hello}.
I was definitely concerned with using shellexpand, I could imagine a situation where generated passwords or especially some longer tokens could easily contain something where it would match shorter commonly set environment variables, like CC, where a randomly generated value like ....$CC..... would get expanded to the value of CC with something like gcc. But I feel a lot better with the new non-shellexpand approach being that it requires:
- The environment variable must still be set in the process environment
- It must be a sequence of
${followed by a} - The variable name itself can only contain contain characters
[a-zA-Z0-9_]and cannot start with_or a digit (I based it off of POSIX standard, but including lowercase letters)
I don't know how to go about calculating a probability on it myself, but it seems like it would be practically impossible given the confluence of things that would need to happen coupled with password generators usually don't create passwords that include { or } and tokens like JWTs are usually base64 encoded which would exclude that as well.
I really appreciate the discussion with this btw, I think this kind of thing IME is not something you can think too much about for sure!
Configuration files can now reference the process environment, so secrets and per-environment values no longer have to be baked into the files on disk:
Only the braced form is a reference:
${VAR}is substituted from the environment,${VAR:-value}supplies a fallback, and$${VAR}is a literal${VAR}. A bare$VARis left alone.Lookups are lenient, so a reference to a variable that isn't set is left in the document verbatim rather than failing the load. Between that and ignoring the unbraced form, existing values keep working even in
users.tomlwhere it is most likely to contain a stray$in passwords. This means passwords likesup$rsecretorp$$w0rdpasses through untouched instead of turning into a startup failure or a silently truncated credential.Expansion runs on the document source before it is parsed, so a variable is interpolated as TOML rather than as a string.
${PASSWORD}in value position still needs its surrounding quotes, and a value containing"or a newline will change how the rest of the document parses. This is what allows bareshutdown_timeout = ${VAR}to work, and it is documented onexpand.Implementation notes:
pgdog-config::expandmodule.expand()is infallible and returnsCow::Borrowedwhen there is nothing to substitute, so the common case costs no allocation.FromToml::from_tomlreplaces baretoml::from_strat the three sites that parse config text read from disk: both branches ofConfigAndUsers::loadandbootstrap_logger. Every othertoml::from_strin the tree parses a test literal, where expansion is unwanted, and is untouched.DeserializeOwned, so no per-type boilerplate is needed.from_toml, notfrom_str, to avoid colliding with the crate's manystd::str::FromStrimpls.Error::confignow receives the expanded text, so the line numbers it reports stay correct when a variable's value contains a newline.ConfigAndUserskeepsconfig_text/users_textas the raw, unexpanded source. Resolved secrets must not be written back to disk when the config is reloaded or backed up.Fixes #1479