diff --git a/infra/deploy/render-secrets-openbao.sh b/infra/deploy/render-secrets-openbao.sh index 518f999..a3d277e 100644 --- a/infra/deploy/render-secrets-openbao.sh +++ b/infra/deploy/render-secrets-openbao.sh @@ -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; . ` byte-for-byte\n" % len(want)) VERIFY ) || rc=1 diff --git a/infra/deploy/render-secrets.sh b/infra/deploy/render-secrets.sh index ea7de00..b74e38c 100755 --- a/infra/deploy/render-secrets.sh +++ b/infra/deploy/render-secrets.sh @@ -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; . ` byte-for-byte\n" % len(want)) VERIFY ) || rc=1