secrets: validate CENTRALIS_TEAM at render time, like CENTRALIS_TOKENS #144
2 changed files with 117 additions and 0 deletions
|
|
@ -468,6 +468,60 @@ if raw:
|
||||||
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
|
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
|
||||||
% (len(reg), ", ".join(sorted(reg))))
|
% (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))
|
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
|
||||||
VERIFY
|
VERIFY
|
||||||
) || rc=1
|
) || rc=1
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,69 @@ if raw:
|
||||||
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
|
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
|
||||||
% (len(reg), ", ".join(sorted(reg))))
|
% (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))
|
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
|
||||||
VERIFY
|
VERIFY
|
||||||
) || rc=1
|
) || rc=1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue