From 2e50c1235c19b4a53d0797a86072f3c7c47d79f2 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 28 Jun 2026 15:20:07 +0000 Subject: [PATCH] chrome-service: grant emo shared browser access (noVNC + homelab browser CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viktor asked to give emo access to the cluster's headed Chrome so he can fill in forms and get past anti-bot / captcha pages. emo was deliberately locked out of chrome-service (noVNC Authentik allowlist was Viktor-only + his power-user RBAC has no pods/portforward). Viktor's explicit decision: SHARE his existing browser rather than stand up an isolated per-user instance, accepting that emo can therefore reach Viktor's warmed logged-in sessions (CDP has no per-context auth, so the single shared persistent profile is reachable by anyone who can drive the browser). emo's CLI use is hands-off (his agent can run it unattended). - authentik: add emo (emil.barzin / emil.barzin@gmail.com) to CHROME_ALLOWED so the admin-services-restriction policy admits him to chrome.viktorbarzin.me (noVNC). Reverses the prior Viktor-only lock; comment updated to record why. - chrome-service/rbac.tf (new): emo-browser ServiceAccount + long-lived token (dashboard-sa.tf pattern), a chrome-service-portforward Role granting pods/portforward, and a cluster read-only binding (oidc-power-user-readonly) so the SA can resolve the Service and emo's normal read access doesn't regress. - t3-provision-users.sh: install_browser_kubeconfig installs a dual-context kubeconfig for any user with a -browser SA — SA token as the default context (non-interactive, works headless), personal OIDC retained as the oidc@homelab named context. emo's OIDC-only kubeconfig can't authenticate the headless agent session that homelab browser needs. - docs/architecture/chrome-service.md: document the shared-browser multi-user access model, the session-exposure trade-off, and how to grant/revoke a user. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/chrome-service.md | 36 +++++++ scripts/t3-provision-users.sh | 74 +++++++++++++++ .../authentik/admin-services-restriction.tf | 17 ++-- stacks/chrome-service/rbac.tf | 95 +++++++++++++++++++ 4 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 stacks/chrome-service/rbac.tf diff --git a/docs/architecture/chrome-service.md b/docs/architecture/chrome-service.md index 37cb6edc..118c0895 100644 --- a/docs/architecture/chrome-service.md +++ b/docs/architecture/chrome-service.md @@ -293,6 +293,42 @@ Key facts: byte-identical copy of `files/stealth.js`, guarded by a drift test — so the CLI's stealth never diverges from the in-cluster callers'. +## Multi-user access (sharing the browser) + +There is ONE chrome-service browser with ONE persistent profile, warmed with +**Viktor's** logged-in sessions. CDP has no per-context auth, so anyone who can +drive the browser — over the noVNC view OR the CDP/`homelab browser` path — can +reach the persistent profile (`browser.contexts[0]`) and therefore Viktor's +sessions. Access is gated accordingly, per user. + +**Decision (2026-06-28):** emo (`emil.barzin` / `emil.barzin@gmail.com`) SHARES +Viktor's browser for form-filling + captcha solving, rather than getting an +isolated instance. The session-exposure trade-off above was explicitly accepted. + +Two independent grants make up "browser access" for a user: + +1. **noVNC (interactive view, `chrome.viktorbarzin.me`)** — gated by the Authentik + `admin-services-restriction` policy: the `CHROME_ALLOWED` set + (`stacks/authentik/admin-services-restriction.tf`) matches the user's Authentik + username OR email. Add the user there. No kubeconfig/RBAC needed. +2. **CLI (`homelab browser`, CDP over port-forward)** — needs `pods/portforward` + in `chrome-service` PLUS a non-interactive credential (a normal devvm user's + kubeconfig is interactive-OIDC-only and can't authenticate a headless agent + session). Provided by a per-user **ServiceAccount** with a long-lived token + (`stacks/chrome-service/rbac.tf`, e.g. `emo-browser`): `pods/portforward` in + this namespace + cluster read-only (`oidc-power-user-readonly`, so it can also + resolve the Service and doesn't regress the user's normal read). The devvm + provisioner (`scripts/t3-provision-users.sh` → `install_browser_kubeconfig`) + reads that token and installs it as the user's DEFAULT kubeconfig context + (`-browser@homelab`), keeping their personal OIDC login as the + `oidc@homelab` named context. The SA's existence is the source of truth for who + gets the CLI — the provisioner no-ops for users without a `-browser` SA. + +**To grant another user:** add them to `CHROME_ALLOWED` (noVNC) and/or add a +`-browser` SA + bindings mirroring `emo-browser` in `rbac.tf` (CLI), then run +the provisioner. To revoke: remove from `CHROME_ALLOWED` and delete the SA (rotate +a token by deleting its `-browser-token` Secret). + ## Limits + risks - **Anti-bot vs stealth arms race** — when an upstream beats us (DRM diff --git a/scripts/t3-provision-users.sh b/scripts/t3-provision-users.sh index 9cbc6c1e..1714596a 100644 --- a/scripts/t3-provision-users.sh +++ b/scripts/t3-provision-users.sh @@ -240,6 +240,79 @@ EOF log "wrote OIDC kubeconfig -> $user:~/.kube/config" } +# Hands-off chrome-service browser credential. For a user who has a +# `-browser` ServiceAccount in the chrome-service namespace (created in +# stacks/chrome-service/rbac.tf), install a DUAL-CONTEXT kubeconfig whose DEFAULT +# context authenticates with that SA's long-lived token — so `homelab browser` +# (which shells out to `kubectl port-forward -n chrome-service`) works +# non-interactively, even from a headless agent session (the user's interactive +# OIDC login can't authenticate a headless kubectl). The user's personal OIDC +# identity is retained as the `oidc@homelab` named context +# (`kubectl --context oidc@homelab`). TF (the SA's existence) is the source of +# truth for WHO gets this — there is no roster flag. Idempotent (cmp-guarded; SA +# tokens are stable) + best-effort (cluster/secret unreachable -> WARN, never aborts). +install_browser_kubeconfig() { + local user="$1" home kc sa secret token server ca tmp + home="$(getent passwd "$user" | cut -d: -f6)" + [[ -z "$home" ]] && return 0 + sa="${user}-browser" + secret="${sa}-token" + [[ -r "$ADMIN_KUBECONFIG" ]] || return 0 + # Gate: only users with a chrome-service browser SA (TF-driven). Best-effort read. + KUBECONFIG="$ADMIN_KUBECONFIG" kubectl --request-timeout=10s -n chrome-service get serviceaccount "$sa" >/dev/null 2>&1 || return 0 + token="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl --request-timeout=10s -n chrome-service get secret "$secret" -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true)" + [[ -n "$token" ]] || { log "WARN: browser SA token not ready for $user (secret chrome-service/$secret) — skipped"; return 0; } + server="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}')" + ca="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')" + [[ -n "$server" && -n "$ca" ]] || { log "WARN: could not read cluster server/CA -> skip browser kubeconfig for $user"; return 0; } + kc="$home/.kube/config" + tmp="$(mktemp)" + cat > "$tmp" </dev/null; then rm -f "$tmp"; return 0; fi # already current -> no churn + if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] dual-context (SA default + OIDC) browser kubeconfig -> $user:$kc"; rm -f "$tmp"; return 0; fi + install -d -o "$user" -g "$user" -m 0700 "$home/.kube" + install -o "$user" -g "$user" -m 0600 "$tmp" "$kc" || { log "WARN: failed to write browser kubeconfig for $user"; rm -f "$tmp"; return 0; } + rm -f "$tmp" + log "wrote dual-context browser kubeconfig (SA default + OIDC) -> $user:~/.kube/config" + return 0 +} + # Idempotently set KEY=VALUE in a t3-serve env file, PRESERVING other lines — so writing # T3_PORT never clobbers an injected CLAUDE_CODE_OAUTH_TOKEN, and vice-versa. Mode 0600. env_set() { @@ -594,6 +667,7 @@ while IFS=$'\t' read -r os_user tier shell groups_csv code_layout repos_csv; do refresh_user_clone "$os_user" code fi install_user_kubeconfig "$os_user" + install_browser_kubeconfig "$os_user" # hands-off chrome-service CLI cred (no-op unless the user has a browser SA) deploy_user_launcher "$os_user" # keep ~/start-claude.sh current (skel only seeds new accounts) fi refresh_codex_mirror "$os_user" # all tiers — mirror of the managed claudeMd diff --git a/stacks/authentik/admin-services-restriction.tf b/stacks/authentik/admin-services-restriction.tf index 806dd417..293c78b5 100644 --- a/stacks/authentik/admin-services-restriction.tf +++ b/stacks/authentik/admin-services-restriction.tf @@ -49,14 +49,15 @@ resource "authentik_policy_expression" "admin_services_restriction" { host = request.context.get("host", "") - # chrome-service noVNC (chrome.viktorbarzin.me) exposes Viktor's LIVE - # logged-in browser sessions, so lock it to Viktor's own accounts ONLY. - # "Home Server Admins" is NOT sufficient — emo (emil.barzin@gmail.com) is a - # member. akadmin kept as break-glass. The homelab-browser CDP path is - # already RBAC-gated (emo = oidc-power-user-readonly, no pods/portforward), - # so this closes the only remaining, human, noVNC path. Match username OR - # email so neither attribute alone can lock Viktor out. - CHROME_ALLOWED = {"akadmin", "akadmin@viktorbarzin.me", "vbarzin@gmail.com"} + # chrome-service noVNC (chrome.viktorbarzin.me) exposes LIVE logged-in browser + # sessions from the SHARED persistent profile. Originally Viktor-only. + # 2026-06-28 (Viktor's explicit decision): emo SHARES Viktor's browser, so emo + # (emil.barzin / emil.barzin@gmail.com) is allowed in for noVNC form-filling + + # captcha solving. Trade-off accepted: emo can therefore reach Viktor's warmed + # sessions (the CLI half is the emo-browser ServiceAccount in + # stacks/chrome-service/rbac.tf). akadmin kept as break-glass. Match username OR + # email so neither attribute alone can lock anyone out. + CHROME_ALLOWED = {"akadmin", "akadmin@viktorbarzin.me", "vbarzin@gmail.com", "emil.barzin", "emil.barzin@gmail.com"} if host == "chrome.viktorbarzin.me": return request.user.username in CHROME_ALLOWED or request.user.email in CHROME_ALLOWED diff --git a/stacks/chrome-service/rbac.tf b/stacks/chrome-service/rbac.tf new file mode 100644 index 00000000..f0043f1a --- /dev/null +++ b/stacks/chrome-service/rbac.tf @@ -0,0 +1,95 @@ +# emo's hands-off "homelab browser" credential + chrome-service port-forward RBAC. +# +# Access decision (2026-06-28, Viktor's explicit call): emo SHARES Viktor's single +# chrome-service browser rather than getting an isolated instance. The noVNC half of +# that grant is the Authentik allowlist in +# stacks/authentik/admin-services-restriction.tf (CHROME_ALLOWED); THIS file is the +# CLI half — it lets emo's `homelab browser` reach the headed Chrome over CDP. +# +# `homelab browser` shells out to `kubectl port-forward -n chrome-service svc/chrome-service` +# (cli/browser.go). emo's normal kubeconfig is interactive-OIDC-only (kubelogin) and +# can't authenticate a headless agent session, and his power-user tier has no +# pods/portforward. So we mint a dedicated ServiceAccount with a long-lived token +# (the dashboard-sa.tf pattern) that the devvm provisioner installs as emo's DEFAULT +# kubeconfig context (scripts/t3-provision-users.sh install_browser_kubeconfig); his +# personal OIDC login stays available as the `oidc@homelab` named context. +# +# TRADE-OFF (accepted): CDP access == full control of the shared browser, including +# the persistent profile (browser.contexts[0]) where Viktor's warmed logins live. +# CDP has no per-context auth, so this SA can reach Viktor's sessions. That is inherent +# to sharing one browser (the isolated per-user instance was declined). +# See docs/architecture/chrome-service.md "Multi-user access". + +resource "kubernetes_service_account" "emo_browser" { + metadata { + name = "emo-browser" + namespace = kubernetes_namespace.chrome_service.metadata[0].name + } +} + +# Long-lived (non-expiring) token for the SA — the devvm provisioner reads this and +# writes it into emo's kubeconfig. Same pattern as stacks/rbac/.../dashboard-sa.tf. +resource "kubernetes_secret" "emo_browser_token" { + metadata { + name = "emo-browser-token" + namespace = kubernetes_namespace.chrome_service.metadata[0].name + annotations = { + "kubernetes.io/service-account.name" = kubernetes_service_account.emo_browser.metadata[0].name + } + } + type = "kubernetes.io/service-account-token" + wait_for_service_account_token = true +} + +# The ONLY verb emo's SA lacks for `kubectl port-forward svc/chrome-service`: the +# port-forward subresource. (get/list of pods + services + endpoints comes from the +# cluster-read binding below.) Namespace-scoped to chrome-service. +resource "kubernetes_role" "browser_portforward" { + metadata { + name = "chrome-service-portforward" + namespace = kubernetes_namespace.chrome_service.metadata[0].name + } + rule { + api_groups = [""] + resources = ["pods/portforward"] + verbs = ["create"] + } +} + +resource "kubernetes_role_binding" "emo_browser_portforward" { + metadata { + name = "emo-browser-portforward" + namespace = kubernetes_namespace.chrome_service.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.browser_portforward.metadata[0].name + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.emo_browser.metadata[0].name + namespace = kubernetes_namespace.chrome_service.metadata[0].name + } +} + +# Cluster-wide read-only (NO secrets), mirroring emo's power-user OIDC access, bound +# to the SA. Needed because the SA becomes emo's DEFAULT kubectl context, so without +# this his everyday `kubectl get ...` would regress — AND port-forward itself needs +# get/list on services + pods + endpoints (all covered by oidc-power-user-readonly). +# That ClusterRole is defined in stacks/rbac (modules/rbac/main.tf); referenced by name. +resource "kubernetes_cluster_role_binding" "emo_browser_readonly" { + metadata { + name = "emo-browser-readonly" + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "oidc-power-user-readonly" + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.emo_browser.metadata[0].name + namespace = kubernetes_namespace.chrome_service.metadata[0].name + } +}