secrets: validate CENTRALIS_TEAM at render time, like CENTRALIS_TOKENS
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s

CENTRALIS_TOKENS has a parse-and-prove check in both renderers because it is
JSON, full of double quotes, living in a file bash expands, and because
Centralis fails closed on it. CENTRALIS_TEAM is the same kind of value with
none of the same guard.

It is the allowlist for Google sign-in. Centralis' buildOAuth() requires a
Google client AND at least one team member, so an empty or malformed team does
not fail loudly -- it turns OAuth off and leaves the server bearer-token-only,
which looks exactly like never having configured it.

The sharper failure is a partial one. parseTeam() (centralis src/config.ts)
SKIPS an unparseable entry with a console.error and carries on, so an address
with a typo'd domain drops exactly one person, behind a log line on a container
start nobody watches. Entries parseTeam would skip are therefore refused here,
where a human is reading the output.

Accepts all three shapes parseTeam accepts -- email->role, email->object, and
an array of objects -- and matches its case-insensitive duplicate detection.
Rejects invalid JSON, a non-object/array, an entry without an @, a duplicate
address, and an empty team. Prints member addresses and roles on success, the
same argument the adjacent CENTRALIS_TOKENS line makes for printing subjects:
seeing the list is how an operator notices somebody is missing.

Added to BOTH renderers with identical logic. The parity argument this
migration rests on is that the two backends produce the same file; a validation
present on only one side means one backend can write a file the other would
have refused.

Verified end to end against the live vault with verify-centralis-render.sh:
9 keys, 2 subjects, VERDICT PASS, compose model digests identical.
This commit is contained in:
Emi Griffith 2026-08-01 08:43:21 -07:00 committed by admin_emi
parent 7b14b9062c
commit 7b3ea69ef9
2 changed files with 117 additions and 0 deletions

View file

@ -468,6 +468,60 @@ if raw:
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
% (len(reg), ", ".join(sorted(reg))))
# CENTRALIS_TEAM: same hazard, same shape of guard. Kept in step with the SOPS
# renderer deliberately — the parity argument this whole migration rests on is that
# the two backends produce the same file, and a validation that exists on only one
# side means one backend can write a file the other would have refused.
#
# It is the Google sign-in allowlist, and buildOAuth() needs a Google client AND at
# least one member, so a malformed team turns OAuth off rather than failing. Worse,
# centralis' parseTeam() SKIPS a bad entry and carries on, so a typo'd domain drops
# exactly one person behind a log line nobody reads.
raw_team = got.get("CENTRALIS_TEAM", "")
if raw_team:
try:
team = json.loads(raw_team)
except ValueError as exc:
sys.stderr.write("!! CENTRALIS_TEAM is not valid JSON after sourcing: %s\n" % exc)
sys.exit(1)
# The three shapes centralis' parseTeam accepts, in src/config.ts.
rows = []
if isinstance(team, dict):
for email, value in team.items():
role = value if isinstance(value, str) else (value or {}).get("role") or "member"
rows.append((email, role))
elif isinstance(team, list):
for row in team:
row = row or {}
rows.append((str(row.get("email", "")), row.get("role") or "member"))
else:
sys.stderr.write("!! CENTRALIS_TEAM must be a JSON object or array\n")
sys.exit(1)
bad_team = []
seen_emails = set()
for email, role in rows:
key = str(email).strip().lower()
if key == "" or "@" not in key:
bad_team.append("%r is not an email address — centralis would skip it" % email)
elif key in seen_emails:
bad_team.append("%s appears twice — centralis would ignore the second" % key)
else:
seen_emails.add(key)
if bad_team:
sys.stderr.write("!! CENTRALIS_TEAM has entries centralis would silently drop:\n")
for b in bad_team:
sys.stderr.write("!! %s\n" % b)
sys.exit(1)
if not seen_emails:
sys.stderr.write("!! CENTRALIS_TEAM is empty — that disables Google sign-in entirely\n")
sys.exit(1)
sys.stderr.write(" CENTRALIS_TEAM parses: %d member(s) — %s\n"
% (len(rows), ", ".join("%s=%s" % (e.strip().lower(), r)
for e, r in sorted(rows))))
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
VERIFY
) || rc=1

View file

@ -467,6 +467,69 @@ if raw:
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
% (len(reg), ", ".join(sorted(reg))))
# CENTRALIS_TEAM is the same hazard as CENTRALIS_TOKENS and had none of the same
# guard: JSON, full of double quotes, in a file bash expands. It is the allowlist
# for Google sign-in, and centralis' buildOAuth() requires a Google client AND at
# least one team member — so an empty or malformed team does not fail loudly, it
# turns OAuth OFF and leaves the server bearer-token-only. That looks identical to
# never having configured it.
#
# Worse than the tokens case: centralis' parseTeam() SKIPS a bad entry with a
# console.error and carries on. An address with a typo'd domain does not break
# anything visibly; it silently drops exactly one person, and the log line saying
# so scrolls past on a container start nobody watches. So entries parseTeam would
# skip are refused HERE, where a human is reading the output.
raw_team = got.get("CENTRALIS_TEAM", "")
if raw_team:
try:
team = json.loads(raw_team)
except ValueError as exc:
sys.stderr.write("!! CENTRALIS_TEAM is not valid JSON after sourcing: %s\n" % exc)
sys.exit(1)
# The three shapes centralis' parseTeam accepts, in src/config.ts:
# {"emi@x.com": "owner"} email -> role
# {"jin@x.com": {"subject": ..., "role": ...}} email -> object
# [{"email": ..., "subject": ..., "role": ...}] array of objects
rows = []
if isinstance(team, dict):
for email, value in team.items():
role = value if isinstance(value, str) else (value or {}).get("role") or "member"
rows.append((email, role))
elif isinstance(team, list):
for row in team:
row = row or {}
rows.append((str(row.get("email", "")), row.get("role") or "member"))
else:
sys.stderr.write("!! CENTRALIS_TEAM must be a JSON object or array\n")
sys.exit(1)
bad_team = []
seen_emails = set()
for email, role in rows:
key = str(email).strip().lower()
if key == "" or "@" not in key:
bad_team.append("%r is not an email address — centralis would skip it" % email)
elif key in seen_emails:
bad_team.append("%s appears twice — centralis would ignore the second" % key)
else:
seen_emails.add(key)
if bad_team:
sys.stderr.write("!! CENTRALIS_TEAM has entries centralis would silently drop:\n")
for b in bad_team:
sys.stderr.write("!! %s\n" % b)
sys.exit(1)
if not seen_emails:
sys.stderr.write("!! CENTRALIS_TEAM is empty — that disables Google sign-in entirely\n")
sys.exit(1)
# Addresses, not credentials, and the same argument as the tokens line above:
# seeing the list is how an operator notices a person is missing or a domain
# is misspelled, which is the whole failure this check exists to catch.
sys.stderr.write(" CENTRALIS_TEAM parses: %d member(s) — %s\n"
% (len(rows), ", ".join("%s=%s" % (e.strip().lower(), r)
for e, r in sorted(rows))))
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
VERIFY
) || rc=1