secrets: validate CENTRALIS_TEAM at render time, like CENTRALIS_TOKENS #144

Open
admin_emi wants to merge 1 commit from feat/centralis-team-validation into dev
2 changed files with 117 additions and 0 deletions
Showing only changes of commit 7b3ea69ef9 - Show all commits

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