Bind release browser qualification to the narrow CI identity

Use a dedicated Auth Keys write client restricted to tag:ci-deploy, never the broader demo registration. Reject missing configuration and validate the actual running Self tag and DNS before Serve. Keep exact-source, denial-stop, no-replay and cleanup contracts, with connected offline registration and Serve controls.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 04:57:17 +01:00
parent a847b05e3e
commit 2fb4bf9eee
6 changed files with 281 additions and 16 deletions
+22
View File
@@ -371,6 +371,28 @@ non-mutating wrapper that verifies the current committed stable target. All
stable demo writes require an exact stable tag and activation marker and run
from `release-convergence.yml` under the global customer-promotion lease.
## Published-RC browser qualification registration
`qualify-browser-update-release.yml` must **not** reuse the demo binding above.
It uses the separate repository secrets **TS_CI_DEPLOY_OAUTH_CLIENT_ID** and
**TS_CI_DEPLOY_OAUTH_SECRET** for a dedicated business-tailnet OAuth client with
only **Auth Keys write**, restricted to **tag:ci-deploy**. These names must not
alias/copy the `tag:infra` demo client or a full-access administrative client.
The Tailnet/credential owner verifies that client scope and tag restriction when
configuring the pair; source inspection and the node's observed tag do not prove
OAuth scope. No secret value belongs in source, logs or receipts.
The workflow fails before joining when either dedicated secret is unavailable;
there is no demo-credential, auth-key or caller-input fallback. It checks the
actual running node's **Self.Tags == ["tag:ci-deploy"]** and its own business
DNS name before starting Serve. Only its own `127.0.0.1:7655` is served over
HTTPS, with no peer selection, SSH, routes or private demo/production target.
This journey needs no expansion of CI access to private services. Cleanup resets
that new Serve route even if route setup fails after the successful join; the
pinned Tailscale action and fresh hosted VM own node teardown. A configured
binding, source tests or a joined node is not native update/recovery acceptance.
## Helm CI
**File**: `helm-ci.yml`
@@ -112,21 +112,33 @@ jobs:
npx --no-install playwright install --with-deps chromium
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends jq curl openssh-client
- name: Register a fresh ephemeral project CI node
- name: Require the dedicated narrow CI registration binding
env:
CI_CLIENT_ID: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
CI_CLIENT_SECRET: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
run: |
set -euo pipefail
if [[ -z "$CI_CLIENT_ID" || -z "$CI_CLIENT_SECRET" ]]; then
echo 'Dedicated tag:ci-deploy OAuth binding is not configured; no demo credential fallback is permitted.' >&2
exit 2
fi
- name: Register a fresh ephemeral narrow CI node
id: tailnet
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
oauth-client-id: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
tags: tag:ci-deploy
version: '1.94.2'
- name: Serve only this disposable install
id: serve
run: |
set -euo pipefail
# Never enumerate peer/private targets or use a supplied hostname.
origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)')
# Validate the actual Self tag before Serve. Never select a peer or
# fall back to the demo client's broader tag:infra identity.
origin=$(tailscale status --json | python3 scripts/release_browser_tailnet.py)
tailscale serve --bg --https=443 http://127.0.0.1:7655
echo "origin=$origin" >> "$GITHUB_OUTPUT"
python3 scripts/write_github_output.py origin "$origin"
- name: Exercise native browser update and documented recovery
env:
FROM: ${{ steps.pair.outputs.from }}
@@ -142,7 +154,7 @@ jobs:
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
- name: Close ephemeral Serve route
if: always() && steps.serve.outcome == 'success'
if: always() && steps.tailnet.outcome == 'success'
run: tailscale serve reset
- name: Retain bounded identity and terminal receipts, never credentials
if: always()
@@ -6441,10 +6441,20 @@ collision-safe writer. Each installed binary hash must match its
signed archive, rather than just a reported version. No rebuilt main binary,
frozen-product insertion, candidate re-cut or stable promotion is performed.
A pinned Tailscale action registers an ephemeral project CI node; Serve exposes
only its own loopback Pulse install through its derived `tawny-powan.ts.net`
HTTPS identity. No caller-selected endpoint, peer or previous stopped target is
used. Registration alone receives the existing CI OAuth credential; acceptance
A pinned Tailscale action registers an ephemeral project CI node using only
`tag:ci-deploy` and the dedicated `TS_CI_DEPLOY_OAUTH_CLIENT_ID` /
`TS_CI_DEPLOY_OAUTH_SECRET` binding. Its OAuth client has only Auth Keys write,
restricted to that tag; it must not alias the `tag:infra` demo or full-access
administrative identity. The credential owner verifies that configured scope;
source and Self observations are not proof of OAuth/ACL authority. Missing
dedicated registration configuration stops before join, with no credential,
auth-key or caller-input fallback. `release_browser_tailnet.py` rejects anything
except a running, online Self node with exactly that one tag and its own valid
business DNS name before Serve or origin output. No peers or raw status enter
receipts. Serve exposes only its own loopback Pulse install through that derived
`tawny-powan.ts.net` HTTPS identity; no private-service ACL expansion is needed.
No caller-selected endpoint, peer or previous stopped target is used. Only the
fixed prerequisite/registration steps receive the dedicated OAuth pair; acceptance
commands receive a scrubbed environment with no provider, production or
publishing credential. State is synthetic, disabled resources and throwaway
admin/API authentication, with the existing telemetry opt-out.
@@ -6473,7 +6483,8 @@ original auto-update/timer intent after forward update and documented CLI
then checks the restored baseline. This is CLI recovery plus browser readback,
**not** the separate history-based UI rollback action. Cleanup stops the
throwaway service/timer, records its result and fails the process on failure;
the workflow resets the new Serve route and its hosted VM is disposed of.
the workflow resets the new Serve route after any successful join, including
partial route setup failure, and its hosted VM is disposed of.
Bounded control, signed packet, installer/binary, phase, browser, cleanup and
redacted settings receipts are retained for seven days. Successful screenshots
@@ -6488,6 +6499,11 @@ Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
release/asset/binary identity rejection, worker-mode exclusion, scrubbed command
environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py`
executes the recovery-access stop before another browser process can start.
`scripts/tests/test_release_browser_tailnet.py` executes the actual prerequisite
and Serve shell with fixed offline node observations: absent dedicated
configuration cannot borrow the demo credential, broad/extra/missing tags and
foreign/unsafe Self identities cannot start Serve or write an origin. Its binding
controls reject broad demo secret reuse and caller-selected registration inputs.
`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real
observer against offline Chromium success/recovery, anonymous auth probes,
restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Validate only the joined CI node's identity before exposing its loopback UI.
Reads local `tailscale status --json`; makes no network request and never emits
peer inventory, raw status or credentials. OAuth/ACL scope is configured by the
Tailnet owner, not inferred from a DNS name or a tag observation.
"""
import json
import re
import sys
MAX_STATUS_BYTES = 1024 * 1024
ERROR = "Release browser requires a running Self node with only tag:ci-deploy and a business-tailnet DNS name"
def self_origin(status):
if not isinstance(status, dict) or status.get("BackendState") != "Running":
raise ValueError(ERROR)
node = status.get("Self")
if (not isinstance(node, dict) or node.get("Online") is not True
or node.get("Tags") != ["tag:ci-deploy"]):
raise ValueError(ERROR)
name = node.get("DNSName")
if not isinstance(name, str):
raise ValueError(ERROR)
name = name.removesuffix(".")
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.tawny-powan\.ts\.net", name):
raise ValueError(ERROR)
return "https://" + name
def main():
try:
raw = sys.stdin.buffer.read(MAX_STATUS_BYTES + 1)
if len(raw) > MAX_STATUS_BYTES:
raise ValueError(ERROR)
origin = self_origin(json.loads(raw))
except (ValueError, UnicodeError):
print(ERROR, file=sys.stderr)
return 2
print(origin)
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -3
View File
@@ -138,10 +138,13 @@ class BrowserPacketTest(unittest.TestCase):
function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0]
script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n'
result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin',
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'},
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret',
'TS_CI_DEPLOY_OAUTH_CLIENT_ID': 'synthetic-narrow-id',
'TS_CI_DEPLOY_OAUTH_SECRET': 'synthetic-narrow-secret'},
capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotIn('synthetic-secret', result.stdout)
self.assertNotIn('synthetic-narrow', result.stdout)
self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout)
self.assertIn('EXTRA=fixture', result.stdout)
@@ -152,11 +155,12 @@ class BrowserPacketTest(unittest.TestCase):
self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text())
self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source)
self.assertIn('runs-on: ubuntu-24.04', source)
self.assertIn('"Self"]["DNSName"]', source)
self.assertIn('tailscale status --json | python3 scripts/release_browser_tailnet.py', source)
self.assertIn('ssh-keygen -Y verify', source)
acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0]
self.assertIn('sudo env -i', acceptance)
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'TS_CI_DEPLOY_OAUTH_CLIENT_ID',
'TS_CI_DEPLOY_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
self.assertNotIn(name, acceptance)
artifact = source.split('- name: Retain bounded', 1)[1]
for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'):
@@ -0,0 +1,164 @@
"""Offline controls for the actual browser workflow's narrow registration route."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[2]
HELPER = ROOT / "scripts/release_browser_tailnet.py"
WORKFLOW = ROOT / ".github/workflows/qualify-browser-update-release.yml"
SPEC = importlib.util.spec_from_file_location("release_browser_tailnet", HELPER)
tailnet = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(tailnet)
def status():
return {"BackendState": "Running", "Self": {"Online": True,
"Tags": ["tag:ci-deploy"], "DNSName": "ci-browser.tawny-powan.ts.net."},
"Peer": {"private": {"DNSName": "private-peer", "private_data": "not-for-receipts"}}}
def workflow_step(name):
step = WORKFLOW.read_text().split(" - name: " + name + "\n", 1)[1].split(" - name:", 1)[0]
return textwrap.dedent(step.split(" run: |\n", 1)[1])
def assert_registration_binding(case, source):
# Closed, literal action inputs: no demo fallback or caller-selected tag/key.
action = source.split("uses: tailscale/github-action@", 1)[1].split(" - name:", 1)[0]
expected = {
"oauth-client-id": "${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}",
"oauth-secret": "${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}",
"tags": "tag:ci-deploy",
"version": "'1.94.2'",
}
actual = dict(line.strip().split(": ", 1) for line in action.split(" with:\n", 1)[1].splitlines() if line.strip())
case.assertEqual(expected, actual)
for broad in ("secrets.TS_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_SECRET", "tags: tag:infra"):
case.assertNotIn(broad, source)
class SelfIdentityTest(unittest.TestCase):
def test_reads_only_the_running_narrow_self_identity(self):
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(status()))
observed = status()
observed["Peer"] = "untrusted and deliberately not a peer inventory"
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(observed))
observed["Self"]["DNSName"] = "a.tawny-powan.ts.net"
self.assertEqual("https://a.tawny-powan.ts.net", tailnet.self_origin(observed))
def test_rejects_broad_extra_missing_and_malformed_tags(self):
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], [], None,
"tag:ci-deploy", ["tag:ci-deploy", "tag:ci-deploy"], ["tag:production"]):
with self.subTest(tags=tags), self.assertRaisesRegex(ValueError, "only tag:ci-deploy"):
observed = status()
observed["Self"]["Tags"] = tags
tailnet.self_origin(observed)
def test_rejects_non_self_wrong_tailnet_and_unsafe_dns(self):
for name in (None, 1, "private-peer", "ci.other.ts.net", "https://ci.tawny-powan.ts.net",
"ci.tawny-powan.ts.net\nsecret", "ci.tawny-powan.ts.net..",
"-ci.tawny-powan.ts.net", "ci-.tawny-powan.ts.net",
"a" * 64 + ".tawny-powan.ts.net"):
with self.subTest(name=name), self.assertRaises(ValueError):
observed = status()
observed["Self"]["DNSName"] = name
tailnet.self_origin(observed)
for observed in (None, [], {}, status() | {"Self": []}, status() | {"BackendState": "NeedsLogin"}):
with self.subTest(observed=observed), self.assertRaises(ValueError):
tailnet.self_origin(observed)
for online in (False, None, 1, "true"):
with self.subTest(online=online), self.assertRaises(ValueError):
observed = status()
observed["Self"]["Online"] = online
tailnet.self_origin(observed)
def test_cli_errors_never_return_raw_status_or_peers(self):
invalid = status()
invalid["Self"]["Tags"] = ["tag:infra"]
for raw in (json.dumps(invalid).encode(), b"private diagnostic is not JSON", b"\xff",
b" " * (tailnet.MAX_STATUS_BYTES + 1)):
with self.subTest(bytes=len(raw)):
result = subprocess.run(["python3", HELPER], input=raw, capture_output=True)
self.assertEqual(2, result.returncode)
self.assertEqual(b"", result.stdout)
self.assertEqual((tailnet.ERROR + "\n").encode(), result.stderr)
class WorkflowBindingTest(unittest.TestCase):
def test_only_dedicated_tag_scoped_oauth_binding_is_declared(self):
source = WORKFLOW.read_text()
assert_registration_binding(self, source)
self.assertIn("id: tailnet\n", source)
self.assertIn("if: always() && steps.tailnet.outcome == 'success'", source)
self.assertNotIn("steps.serve.outcome == 'success'", source)
def test_rejects_demo_credentials_and_broad_or_caller_selected_registration(self):
source = WORKFLOW.read_text()
for before, after in (
("secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_CLIENT_ID"),
("secrets.TS_CI_DEPLOY_OAUTH_SECRET", "secrets.TS_OAUTH_SECRET"),
("tags: tag:ci-deploy", "tags: tag:infra"),
("tags: tag:ci-deploy", "tags: tag:ci-deploy,tag:infra"),
("tags: tag:ci-deploy", "tags: ${{ inputs.tags }}"),
("tags: tag:ci-deploy", "authkey: ${{ secrets.TS_OAUTH_SECRET }}"),
):
with self.subTest(after=after), self.assertRaises(AssertionError):
assert_registration_binding(self, source.replace(before, after))
def test_missing_narrow_binding_stops_even_when_demo_credentials_exist(self):
script = workflow_step("Require the dedicated narrow CI registration binding")
for client_id, secret in (("", ""), ("present", ""), ("", "present"), ("present", "present")):
env = {"PATH": os.environ["PATH"], "CI_CLIENT_ID": client_id, "CI_CLIENT_SECRET": secret,
"TS_OAUTH_CLIENT_ID": "broad-id-not-to-be-used", "TS_OAUTH_SECRET": "broad-secret-not-to-be-used"}
result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True)
self.assertEqual(0 if client_id and secret else 2, result.returncode)
self.assertEqual("", result.stdout)
self.assertNotIn("broad-id-not-to-be-used", result.stderr)
self.assertNotIn("broad-secret-not-to-be-used", result.stderr)
def test_actual_serve_step_stops_before_serve_and_output_on_wrong_identity(self):
script = workflow_step("Serve only this disposable install")
fixtures = [status()]
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], []):
observed = status()
observed["Self"]["Tags"] = tags
fixtures.append(observed)
observed = status()
observed["Self"]["DNSName"] = "private-peer.other.ts.net"
fixtures.append(observed)
fixtures.append({})
for i, observed in enumerate(fixtures):
with self.subTest(fixture=i), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "status.json").write_text(json.dumps(observed))
fake = work / "tailscale"
fake.write_text('#!/bin/bash\nset -eu\n'
'if [[ "$*" == "status --json" ]]; then cat "$FIXTURE/status.json"; '
'else printf "%s\\n" "$*" >> "$FIXTURE/serve-calls"; fi\n')
fake.chmod(0o700)
env = {"PATH": str(work) + ":" + os.environ["PATH"], "FIXTURE": tmp,
"GITHUB_OUTPUT": str(work / "output")}
result = subprocess.run(["bash", "-c", script], cwd=ROOT, env=env, capture_output=True, text=True)
self.assertEqual(0 if i == 0 else 2, result.returncode, result.stderr)
self.assertEqual("", result.stdout)
self.assertNotIn("private-peer", result.stderr)
self.assertNotIn("not-for-receipts", result.stderr)
if i == 0:
self.assertEqual("serve --bg --https=443 http://127.0.0.1:7655\n", (work / "serve-calls").read_text())
header, value, delimiter = (work / "output").read_text().splitlines()
self.assertEqual("origin<<" + delimiter, header)
self.assertTrue(delimiter.startswith("pulse_output_"))
self.assertEqual("https://ci-browser.tawny-powan.ts.net", value)
else:
self.assertFalse((work / "serve-calls").exists())
self.assertFalse((work / "output").exists())
if __name__ == "__main__":
unittest.main()