Read a context's store connection from a file, not only from the environment
A store connection string carries a password, and the control plane took it
from MESH_STORE_<CONTEXT> — an environment variable, which is readable in
`docker inspect`, in the process's own /proc entry, and in whatever composed
it. Every other module in the catalogue is given secret material as a file the
mesh sealed to the machine and the host wrote.
That difference is what stopped the control plane from being an ordinary module
(novox/hq ADR 0067). A manifest can put a sealed value into a file's `content`
with ${secret:…}; it has no substitution into a container's `env` at all. So a
control-plane manifest could be written with the password in it, or without the
setting — neither honest. The fix is not to change the manifest format but to
let the control plane read what everything else reads: a file.
MESH_STORE_<CONTEXT>_FILE names one. Exactly one of the two may be set; both is
refused rather than settled by precedence, because whichever won, the other
would still read as the setting in force and the process would be writing to a
store nobody expects. Trailing whitespace is trimmed — a file written by a
person or by a filled-in placeholder ends in a newline, and a newline inside a
URL is rejected several layers from anything that could explain it. Leading
whitespace is left, being a mangled value rather than a habit.
The no-leak property is kept and extended: a file that cannot be read names its
path, never its contents, and the parse failure now names whichever source was
used because a variable name and a path are not the secret.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
+92
-14
@@ -5,13 +5,14 @@
|
||||
// makes it a rule about credentials rather than a rule about intentions.
|
||||
//
|
||||
// There is no mesh-wide connection string and no way to ask for one. A store is opened by naming
|
||||
// a context, and the settings for that context come from an environment variable named after it.
|
||||
// A control plane process that has been granted `inventory` holds MESH_STORE_INVENTORY and
|
||||
// nothing else, so reaching another context's store is not a matter of restraint — the process
|
||||
// has no address for it and no credential to present.
|
||||
// a context, and the settings for that context come from an environment variable named after it,
|
||||
// or from a file that variable's `_FILE` twin points at. A control plane process that has been
|
||||
// granted `inventory` holds MESH_STORE_INVENTORY or MESH_STORE_INVENTORY_FILE and nothing else,
|
||||
// so reaching another context's store is not a matter of restraint — the process has no address
|
||||
// for it and no credential to present.
|
||||
//
|
||||
// Which is also how the rule is *checked*: what a context can reach is visible in the
|
||||
// declaration that runs it, as the list of variables it was given.
|
||||
// declaration that runs it, as the list of variables and files it was given.
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -47,6 +48,25 @@ func Variable(context string) string {
|
||||
return "MESH_STORE_" + strings.ToUpper(context)
|
||||
}
|
||||
|
||||
// FileVariable is the environment variable naming a FILE that holds a context's connection
|
||||
// settings — the same value, kept out of the environment.
|
||||
//
|
||||
// **Because the connection string carries a password, and an environment variable is a poor place
|
||||
// to keep one.** It is readable in `docker inspect`, in the process's own /proc entry, and in
|
||||
// whatever composed it. A file is not: the mesh seals the value to the machine, the host writes it
|
||||
// with a mode of its own, and nothing in between ever holds the plaintext (novox/hq ADR 0024).
|
||||
// That is already how every other module in the catalogue is given secret material.
|
||||
//
|
||||
// It is also what lets the control plane be described by an ordinary module manifest at all
|
||||
// (novox/hq ADR 0067). A manifest can put a sealed value into a *file's* content; it cannot put one
|
||||
// into a container's environment, so a manifest for a process that reads its store connection from
|
||||
// the environment could not be written honestly — only with the password in it, or without the
|
||||
// setting at all.
|
||||
//
|
||||
// The plain variable remains, for a control plane a person starts by hand and for the bundle that
|
||||
// raises the first one.
|
||||
func FileVariable(context string) string { return Variable(context) + "_FILE" }
|
||||
|
||||
// Database is what a context's database is called.
|
||||
//
|
||||
// Named after the context, so that a person looking at a PostgreSQL server can see which
|
||||
@@ -65,23 +85,20 @@ func Open(ctx context.Context, name string) (*Store, error) {
|
||||
"name, so it must be lower-case letters and digits, starting with a letter", name)
|
||||
}
|
||||
|
||||
dsn := os.Getenv(Variable(name))
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"this process has no %s, so it was not granted the %s store. A context reaches only "+
|
||||
"the store it exclusively owns (novox/hq ADR 0008), so this is either the wrong "+
|
||||
"context or a missing grant — it is never something to work around by reusing "+
|
||||
"another context's connection", Variable(name), name)
|
||||
dsn, from, err := settingsFor(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
// Deliberately not wrapping the driver's error verbatim into a message that gets logged:
|
||||
// a malformed DSN often *is* the password, and the value is the one thing here that must
|
||||
// not be quoted back.
|
||||
// not be quoted back. Where it came from can be said, because a variable name and a path
|
||||
// are not the secret.
|
||||
return nil, fmt.Errorf(
|
||||
"the connection settings in %s could not be read; the value is not quoted here "+
|
||||
"because it carries a password", Variable(name))
|
||||
"because it carries a password", from)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
@@ -91,6 +108,67 @@ func Open(ctx context.Context, name string) (*Store, error) {
|
||||
return &Store{context: name, pool: pool}, nil
|
||||
}
|
||||
|
||||
// settingsFor is a context's connection string, and the name of where it came from.
|
||||
//
|
||||
// **Where it came from is returned; what it says never is.** A variable name and a path are safe
|
||||
// to put in a log and are exactly what somebody fixing this needs. The value is a password with a
|
||||
// URL around it, and every error below is written on the assumption that it will be logged.
|
||||
//
|
||||
// A blank variable counts as unset, here as it always has: a container given an empty string was
|
||||
// given nothing, and refusing it as *ambiguous* would turn an omission into a puzzle.
|
||||
func settingsFor(name string) (dsn, from string, err error) {
|
||||
direct := strings.TrimSpace(os.Getenv(Variable(name)))
|
||||
path := strings.TrimSpace(os.Getenv(FileVariable(name)))
|
||||
|
||||
if direct != "" && path != "" {
|
||||
// **Refused rather than settled by precedence.** The two can name different databases, and
|
||||
// whichever one won, the other would still read as the setting in force — so the process
|
||||
// would be writing to a store nobody reading its configuration expects, and every check
|
||||
// would pass. Which one is meant is not a question this can answer, and answering it wrongly
|
||||
// is worse than stopping.
|
||||
return "", "", fmt.Errorf(
|
||||
"this process has both %s and %s, and they may name different databases. Exactly one "+
|
||||
"of them says where the %s store's connection settings come from — unset whichever "+
|
||||
"is not the one you meant; picking one here would leave the other looking like the "+
|
||||
"one in force",
|
||||
Variable(name), FileVariable(name), name)
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// The path may be named; what is in it may not — and a file that could not be read has
|
||||
// disclosed nothing, so there is nothing here to withhold.
|
||||
return "", "", fmt.Errorf(
|
||||
"%s names %s as where the %s store's connection settings are, and it cannot be "+
|
||||
"read: %w", FileVariable(name), path, name, err)
|
||||
}
|
||||
// Trailing whitespace goes; leading whitespace stays. A file written by a person, or by the
|
||||
// host filling a ${secret:…} placeholder into one, ordinarily ends in a newline, and a
|
||||
// newline inside a URL is rejected several layers from anything that could explain it.
|
||||
// Nothing produces a leading space by accident, so one is a mangled value rather than a
|
||||
// formatting habit, and quietly repairing it would hide that.
|
||||
held := strings.TrimRight(string(raw), " \t\r\n")
|
||||
if held == "" {
|
||||
return "", "", fmt.Errorf(
|
||||
"%s names %s as where the %s store's connection settings are, and that file holds "+
|
||||
"nothing. Write the connection string into it, or unset %s and set %s instead",
|
||||
FileVariable(name), path, name, FileVariable(name), Variable(name))
|
||||
}
|
||||
return held, fmt.Sprintf("%s (%s)", FileVariable(name), path), nil
|
||||
}
|
||||
|
||||
if direct == "" {
|
||||
return "", "", fmt.Errorf(
|
||||
"this process has neither %s nor %s, so it was not granted the %s store. A context "+
|
||||
"reaches only the store it exclusively owns (novox/hq ADR 0008), so this is either "+
|
||||
"the wrong context or a missing grant — it is never something to work around by "+
|
||||
"reusing another context's connection",
|
||||
Variable(name), FileVariable(name), name)
|
||||
}
|
||||
return direct, Variable(name), nil
|
||||
}
|
||||
|
||||
// Context is which context this store belongs to.
|
||||
func (s *Store) Context() string { return s.context }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user