plan: 0015-kubectl-client-certificates written: 2026-10-12 revised: > 2026-10-12, first version. A runbook: a smaller model executes it item by item, in order, with no design decision of its own. Every design decision is in the `decision` field of an item. When a STOP condition is met, the executor stops, does not guess, and reports the output to the lead. Carved out of plan 0014 M3-KUBECTL-CLIENT-CERT. That item stays in plan 0014. This plan does the same user-visible result WITHOUT the plan 0014 OpenBao atproto auth plugin and WITHOUT the plan 0014 web app sign-in: the existing XRPC com.publicdomainrelay.temp.cluster.getKubeconfig returns a kubeconfig whose user is a short-lived x509 client key and certificate issued by the PKI of the cluster's own OpenBaoServer. queued: > May start now. It needs no other open plan item. Items run in the order of the `items` list. M3-PROD-READING is run by the lead only. M5-RETIRE-EXEC-PLUGIN starts only after M3-PROD-READING is done. relation_to_0014: > Plan 0014 M3-KUBECTL-CLIENT-CERT later adds what this plan leaves out: the kind ClusterClientCertificate, the web app download through a bao DPoP proof, and the front check against that kind. It builds on the issuer, the PKI role and the client certificate front of this plan; it does not replace them. Plan 0014 M3-RETIRE-EXEC-PLUGIN is the same work as M5-RETIRE-EXEC-PLUGIN here; whichever runs first closes both. source: > Read on main at c1d7026e (2026-10-12). How auth reaches the tenant k3s today: (1) kubectl -> https://kcp.socialweb.computer. Caddy ends TLS there (scripts/deploy.sh:142-152, site ${KCP_ORIGIN}) and opens a new TLS connection to kcp on 127.0.0.1:6443. A client certificate stops at Caddy. (2) kcp serves /clusters/root:::clusters through its local proxy (kcp v0.33.0, pkg/server/localproxy.go:103-189, in the Go module cache). The local proxy wraps the whole handler chain (pkg/server/config.go:672), so it runs BEFORE kcp authentication. It forwards the request with all client headers through httputil.ReverseProxy to the mount child's spec.URL, with InsecureSkipVerify and no client certificate (we do not set --mount-proxy-client-cert-file; grep finds no use). docs/ARCHITECTURE.md section 7 (line 1156) states the rule: "Bearer only through the mount". (3) spec.URL is the cluster's apiserver front (internal/apiserverfront/front.go:270-305, a TCP pipe, no TLS end) in front of k3s. The front also carries the kubelets of market nodes, so it must stay a TCP pipe. (4) The bearer today is a ServiceAccount token: the kubeconfig exec plugin (cmd/socialweb-credential, internal/execplugin) trades SOCIALWEB_DEVICE_KEY at POST /v1/credential (internal/xrpcserver/xrpcserver.go:31, 186, 452-490; internal/mint/mint.go:85-131) for a TokenRequest token of the ServiceAccount users/did- (internal/tenantclient/tenantclient.go:164-196; internal/kcpclient/names.go:62-66). (5) RBAC: deploy/tenant-grant.yaml:42-56, ClusterRoleBinding socialweb-owner binds that ServiceAccount to cluster-admin. FINDING: the exec-plugin path uses NO owners group. Its identity is the ServiceAccount user name; its groups are only system:serviceaccounts and system:serviceaccounts:users. This plan adds the group socialweb.computer:cluster-owners as a second subject of the same binding (M1-OWNERS-GROUP-RBAC). (6) The per-cluster OpenBaoServer (internal/clusterprovider/openbaoserver.go:186-212, name from VaultServerName, line 151) already has a pki mount "pki" (internal/clusterprovider/vault.go:26) whose CA IS the k3s client CA: CustodyClusterVault (vault.go:176-200) calls ImportClientCA (vault.go:116-131), which writes /server/tls/client-ca.{crt,key} to pki/config/ca. CustodyClusterVault runs for every cluster from the Harvest hook (internal/providermain/main.go:263-280). So every certificate from pki/issue is already signed by the CA that k3s trusts for client certificates. The existing roles are tenant-superuser (O system:masters, ttl 8760h) and node-client (internal/clusterprovider/vaultdeclared.go:79-83). (7) The gateway (internal/gatewaymain, internal/gateway, internal/kcpclient) and the clusterprovider run in ONE process as the same account (internal/stack/gatewaykcp/gatewaykcp.go:450-524). The gateway can read the cluster data dirs and use clusterprovider.ClusterVaultClient (openbaoserver.go:112). (8) kubeconfig.AssertClean (internal/kubeconfig/kubeconfig.go:104-121) refuses client-certificate-data and client-key-data today. docs/ARCHITECTURE.md:1235-1241 records that as a rule. This plan changes that rule for the user path on purpose (decided by the user 2026-10-12, plan 0014 revision 2). (9) k3s sets request-header authentication by default: --requestheader-client-ca-file /server/tls/request-header-ca.crt, --requestheader-allowed-names system:auth-proxy, headers X-Remote-User and X-Remote-Group, and it writes the proxy client pair /server/tls/client-auth-proxy.{crt,key}. request-header-ca is part of the identity bundle (internal/identitybundle/identitybundle.go:18-19), so every slot of a cluster trusts the same proxy client. M2-CLIENT-CERT-FRONT checks these facts live first. principle: stated: > A user reaches a tenant cluster with plain kubectl and a short-lived client certificate from that cluster's own OpenBao PKI. The certificate is checked where its TLS ends, by our own code, against the cluster's CA, the PKI CRL and the deny record. The private key is made by OpenBao, returned once in the getKubeconfig response, and stored nowhere. rules: - "The tenant identity comes only from a verified source: the iss of a checked service JWT when the certificate is issued, and the CN of a checked certificate when a request is forwarded." - "The client certificate front is the only component that turns a certificate into a tenant identity. kcp never forwards an identity header that a client wrote." - "The apiserver front stays a TCP pipe; k3s stays unchanged." - "No migration and no back-compat: the exec-plugin path keeps working until M5-RETIRE-EXEC-PLUGIN removes it." security_rules: - "KEY: the client private key is made by OpenBao (pki/issue). It exists only in the getKubeconfig response body. Never write it to kcp, to a file, to an atproto record, to a DenoPod or HostProcess argument, or to a log line. Never put it, the certificate or the kubeconfig text in an error message. The response has Cache-Control: no-store (gateway XRPC and web app)." - "KEY LOG CHECK: after every live check of M1-GETKUBECONFIG-HANDLER, M2 and M4, run `grep -rl 'PRIVATE KEY' /logs /denokcp/runs` and `grep -rl 'client-key-data' `. Any match is a STOP." - "TTL: default 8 h (28800 s). ttlSeconds below 60 or above 86400 is refused with InvalidRequest, never clamped. The PKI role kubeconfig-user has ttl 8h and max_ttl 24h as a second limit. The front refuses a certificate from that role whose NotAfter minus NotBefore is more than 24 h plus 5 minutes. EXCEPTION (user decision 2026-10-13, M2-NO-EXPIRY-CERT): `noExpiry: true` issues from the separate role kubeconfig-user-noexpiry, NotAfter = 9999-12-31T23:59:59Z (RFC 5280 no-expiry value) truncated to the cluster CA NotAfter; such a certificate carries OU socialweb.computer:no-expiry (set by the role, never by the request) and the front accepts its long lifetime only with that OU. It ends only by revocation (CRL + deny, checked every request), cluster delete (fresh CA per creation, M2-FRESH-CA-PER-CREATION), or CA expiry." - "CN: the issuer sets common_name to the verified caller DID (gateway.Caller, from atprotoauth.Verify of the service JWT iss). Never from a request field. The front takes the user name only from the CN of a certificate that chains to the cluster's client CA, and refuses it when kcpclient.AccountHash(CN) is not the account segment of the request path." - "O: exactly one organization, socialweb.computer:cluster-owners, set by the PKI role, never by the request. The front refuses any other O (this refuses tenant-superuser and node-client certificates of the same CA). The front never forwards system:masters." - "HEADERS: the front deletes Authorization, every X-Remote-* header and X-Socialweb-Client-Certificate from the incoming request before it adds its own X-Remote-User and X-Remote-Group." - "EDGE: the front believes X-Socialweb-Client-Certificate only when the TLS peer certificate is byte-equal to the edge client certificate that the front made (/tls/edge-client.crt). Any other peer that sends the header is refused with 400." - "REVOCATION: the front checks, on every request, the deny record (revocation.Admit; a Granted answer is cached at most 2 s, a refusal is never cached) and the cluster PKI CRL (cached at most 60 s; no CRL newer than 10 minutes means 503). A request ends at the certificate's NotAfter." state_rules: - "No unit tests (user, 2026-10-12). Each item is proven by a build and a live reading on the executor's own market network. The only test written is the integration test of M4-INTEGRATION-TEST." - "Delete any existing _test.go test that a change breaks, in the same commit (AGENTS.md: do not repair old tests)." - "Real names only (AGENTS.md). Commit and push only socialweb-computer-kcp on task/. The lead flips status." - "No comments in code except the ponytail: form. ASCII only." - "Task agents never touch prod. M3-PROD-READING is the lead's." ownership: repository: socialweb-computer-kcp go: "internal/xrpcserver (lexicons, no-store), internal/gateway (ttl, revocation hooks), internal/gatewaymain (wiring), internal/kcpclient (OwnersGroup), internal/kubeconfig (certificate kubeconfig), internal/openbao (CertRequest), internal/clientcert (new: issue and revoke), internal/clientcertfront (new: the client certificate front), internal/clusterprovider (PKI role, policy), internal/openbaoctl (cn_validations), api/v1alpha1 (OpenBaoPKIRole.CNValidations), internal/stack/gatewaykcp, internal/install, internal/clicfg, cmd/socialweb-computer" typescript: "socialweb-computer-k8s (kubeconfig ttl choices, no-store)" other: "deploy/tenant-grant.yaml, scripts/deploy.sh (caddy), README.md, docs/ARCHITECTURE.md, test/integration" how_to_run: worktree: "git -C /home/johnandersen777/src/publicdomainrelay-kcp/socialweb-computer-kcp fetch origin; git -C worktree add /home/johnandersen777/src/swc-worktrees/ -b task/ origin/main. Work only there." build_while_iterating: "go build ./internal//... ./cmd/socialweb-computer ; deno check for TypeScript (deploy/ts-entries.yaml). Never the full gate per change; CI runs it on push." dev_binary: "scripts/build-socialweb-computer.sh --dev --publish /var/tmp/. The binary is printed at the end (under ~/.tmp/socialweb-computer-build/dev//)." live_slot: "deno run -A /home/johnandersen777/.tmp/swc-lead/live-slot.ts acquire before dev up; beat every 30 min; release after dev down, also on failure." market_network_up: "As test-0001: sudo -n -u test-0001 dev up --instance --profile market-network --bidders 0 --with-gateway --guest-image-source file:///var/lib/socialweb-computer/guest-image --vm-host-backend systemd-user --state-dir /var/tmp/-state. Run it with run_in_background. It prints a JSON report; Ready is .network.status.phase == Ready. The gateway brings up the tenant cluster `tenant` (owner: the account in owner-sessions.json). No item of this plan needs a VM, so --bidders 0." market_network_down: "sudo -n -u test-0001 dev down --instance --state-dir /var/tmp/-state, then live-slot release." paths: gateway_dir: "/var/tmp/-state/dev//apps/dev/-gateway (test/integration/tenant.go:83-85)." owner_sessions: "/owner-sessions.json: {\"\": {\"service\", \"identifier\", \"password\"}} (internal/appctl/gatewaychild.go:25-29). Mode 0600. Never print it." cluster_data_dir: "/clusterprovider/pdr--tenant (internal/clusterprovider/name.go:39-45; test/integration/tenant.go:136-142)." cluster_tls: "/server/tls: client-ca.crt, server-ca.crt, client-admin.{crt,key}, request-header-ca.crt, client-auth-proxy.{crt,key}." front_state: "/front.json, field address (internal/clusterprovider/front.go:36; internal/apiserverfront/front.go:37-43)." vault_access: "/openbao/access.json: address, caFile, clientCert, clientKey, certMount, certRole (internal/clusterprovider/openbaoserver.go:38-66)." network_ca: "the CA bundle of the market network: `dev status --instance ` .network.status.network.caBundleRef." listeners: "`dev status --instance ` .network.status.apps[] | select(.name == \"-gateway\") | .listeners[]: xrpc (http, loopback), tls (https), and after M2 client-cert (https)." gateway_log: "/logs/core.log (JSON lines; test/integration/tenant.go:122-134 reads it)." audience: "did:web:socialweb.local in a market network (internal/stack/gatewaykcp/env.go:19, 101). Confirm it in the gateway log line \"serving\" field audiences." helper_scripts: > Write these three one-off scripts once under ~/.tmp/runbook-cert/ (Deno built-ins only, no imports, so test-0001 needs no network). Copy them and ~/.deno/bin/deno to /var/tmp/runbook-cert/ (deno mode 0755, scripts 0644). Run them as `sudo -n -u test-0001 env DENO_DIR=/var/tmp/runbook-cert/deno-dir /var/tmp/runbook-cert/deno run -A --cert /var/tmp/runbook-cert/