diff --git a/config/playwright/authentication-config.candidate.yaml b/config/playwright/authentication-config.candidate.yaml new file mode 100644 index 0000000..58742a3 --- /dev/null +++ b/config/playwright/authentication-config.candidate.yaml @@ -0,0 +1,30 @@ +# Owner-review candidate only. Not a kubectl resource and not activated. +# Cluster owner must merge with existing authenticators, validate natively and +# preserve existing admin/service-account authentication and rollback access. +apiVersion: apiserver.config.k8s.io/v1 +kind: AuthenticationConfiguration +jwt: + - issuer: + url: https://kc.coulomb.social + audiences: + - playwright-kubernetes + audienceMatchPolicy: MatchAny + claimValidationRules: + - expression: "claims.aud == 'playwright-kubernetes' || claims.aud == ['playwright-kubernetes']" + message: dedicated Playwright audience required + - expression: "has(claims.scope) && 'playwright:connect' in claims.scope.split(' ')" + message: Playwright access scope required + - expression: "claims.principal_type == 'human' || (claims.principal_type == 'service' && has(claims.roles) && 'coding-agent' in claims.roles)" + message: identified human or registered coding agent required + - expression: "claims.sub != '' && claims.exp > claims.iat && claims.exp - claims.iat <= 3600" + message: nonempty subject and bounded token lifetime required + claimMappings: + username: + expression: "'keycape:' + claims.principal_type + ':' + claims.sub" + groups: + expression: "['playwright-consumers']" + userValidationRules: + - expression: "!user.username.startsWith('system:')" + message: reserved Kubernetes identity prefix refused + - expression: "user.groups == ['playwright-consumers']" + message: only the Playwright consumer group may be mapped diff --git a/config/playwright/keycape-public-client.candidate.json b/config/playwright/keycape-public-client.candidate.json new file mode 100644 index 0000000..85bb789 --- /dev/null +++ b/config/playwright/keycape-public-client.candidate.json @@ -0,0 +1,9 @@ +{ + "clientId": "playwright-cli", + "audience": "playwright-kubernetes", + "displayName": "Playwright platform consumer CLI", + "redirectUris": ["http://127.0.0.1:18764/callback"], + "allowedScopes": ["openid", "profile", "groups", "playwright:connect"], + "grantTypes": ["authorization_code"], + "clientType": "public" +} diff --git a/config/playwright/railiance01-ca.pem b/config/playwright/railiance01-ca.pem new file mode 100644 index 0000000..11eadf5 --- /dev/null +++ b/config/playwright/railiance01-ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBeDCCAR2gAwIBAgIBADAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhrM3Mtc2Vy +dmVyLWNhQDE3NzMxMDM2MjAwHhcNMjYwMzEwMDA0NzAwWhcNMzYwMzA3MDA0NzAw +WjAjMSEwHwYDVQQDDBhrM3Mtc2VydmVyLWNhQDE3NzMxMDM2MjAwWTATBgcqhkjO +PQIBBggqhkjOPQMBBwNCAAT44CzyUUGic6xYSLOApGq8A61OcoP/v+nns47p3F1z +M89iyNSZqtIHRpg0BxiMtTcdwXKart6aPIC8/8PfpVyAo0IwQDAOBgNVHQ8BAf8E +BAMCAqQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUG/vhGaKEp4uD2KaSPDTU +249sq2EwCgYIKoZIzj0EAwIDSQAwRgIhALUc7a0LcGfMiAI+auFTByXlL4hTH0Cr +DxsOiRhV9D0vAiEAjTVKYP/KFPe68cAL8S76mLKdKhd564XiVAeISh5NkG4= +-----END CERTIFICATE----- diff --git a/docs/evidence/2026-10-13-playwright-consumer-preparation.json b/docs/evidence/2026-10-13-playwright-consumer-preparation.json new file mode 100644 index 0000000..d757b80 --- /dev/null +++ b/docs/evidence/2026-10-13-playwright-consumer-preparation.json @@ -0,0 +1,56 @@ +{ + "recorded_at": "2026-10-13T19:21:02.085796+00:00", + "workplan": "RPF-WP-0056", + "status": "consumer-source-prepared-activation-pending", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "public_ca_source": "playwright/kube-root-ca.crt on railiance01, read over existing authenticated SSH", + "pilot_ready_replicas": 1, + "tests": { + "command": "PLAYWRIGHT_TEST_TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q tests/test_playwright_consumer.py", + "passed": 9, + "exit_code": 0, + "notes": "Temporary directory override required because /tmp is a Git worktree on this workstation. Tokens are synthetic. Installed kubectl parsed generated configuration." + }, + "configuration_syntax": "YAML and JSON parsed; not Kubernetes-native validation", + "owner_replies_received": false, + "live_consumer_authentication_proven": false, + "impersonation_used_for_consumer_proof": false, + "remaining": [ + "native JWT/CEL validation and cluster activation", + "public PKCE and dedicated per-agent client registrations", + "governed coding-agent credential delivery", + "supported per-caller API transport", + "real human and coding-agent identity, authorization and browser trials", + "provider owner availability acceptance" + ], + "sources": [ + { + "path": "scripts/playwright_consumer.py", + "sha256": "76e2ee82ed1d232c6c5f5ade5a43a2bb5217de83ecbdb8a7f4d5ff8d03cad0ac" + }, + { + "path": "tests/test_playwright_consumer.py", + "sha256": "6f323661d1af3d980504154de4049e7c1398848c9039794c529b516977d26dd4" + }, + { + "path": "config/playwright/railiance01-ca.pem", + "sha256": "794d3badd7c3743fe66c3671e7f152277f2ac9adddbc5cebe534802432861bd7" + }, + { + "path": "config/playwright/authentication-config.candidate.yaml", + "sha256": "074c7e99c687f4105d70551c9a071b577d45372bc333eb1749dce81eb029d104" + }, + { + "path": "config/playwright/keycape-public-client.candidate.json", + "sha256": "df079d45bc146adf65a7a46acc0ee1145fbc863ef8b468a99785a95699f35b5e" + }, + { + "path": "docs/playwright-consumer-access.md", + "sha256": "cf16e48cb6cd8bfb5f99164e692c2da6fda0e38c897346a267d10103787f873c" + }, + { + "path": "docs/playwright-identity-handoff.md", + "sha256": "5f98ad3b5f377749dbb3e5e07204d1c40f657837161c17720c3da92bf7684cd3" + } + ] +} diff --git a/docs/playwright-consumer-access.md b/docs/playwright-consumer-access.md index 3e8f0a9..4334588 100644 --- a/docs/playwright-consumer-access.md +++ b/docs/playwright-consumer-access.md @@ -10,12 +10,72 @@ identities into that Kubernetes group is still open in RPF-WP-0056-T03; a KeyCap group alone would not establish Kubernetes access. No administrator kubeconfig is distributed and no broad `system:authenticated` binding is installed. -Once the caller has the supported group identity and API route: +## Prepared consumer setup — activation pending + +The helper and commands below are ready for owner integration. They are not a +claim that the registrations, JWT authenticator or general consumer transport +are active. The [owner handoff](playwright-identity-handoff.md) lists the remaining +changes and native acceptance checks. No further membership decision is needed. + +After owner activation, use a private directory outside any Git worktree and a +new token output path. `keycape` is the identity owner's native CLI: ```sh -kubectl -n playwright port-forward deployment/playwright 18081:8080 --address 127.0.0.1 +install -d -m 700 "$HOME/.local/state/playwright-consumer" +keycape login --issuer https://kc.coulomb.social \ + --client-id playwright-cli --audience playwright-kubernetes \ + --scope 'openid profile groups playwright:connect' \ + --redirect-uri http://127.0.0.1:18764/callback \ + --out "$HOME/.local/state/playwright-consumer/token.json" ``` +Registered coding agents instead use `keycape service-token` with their own +Playwright client ID, audience `playwright-kubernetes`, scope `playwright:connect`, +and `--secret-env` naming the variable populated by their governed custody route. +Client registration and delivery are still pending; existing OpenBao/approval +client credentials must not be reused. Tokens expire; authenticate again into a +new private file and generate a new kubeconfig for renewal. There is no refresh +token, secret provisioning or unattended human login in this helper. + +From this repository, create the isolated consumer configuration: + +```sh +python3 scripts/playwright_consumer.py setup \ + --token-file "$HOME/.local/state/playwright-consumer/token.json" \ + --out "$HOME/.local/state/playwright-consumer/kubeconfig.json" +``` + +Setup embeds only the public CA, exec helper and token file path. It refuses +overwrites. The helper checks destination and CA before reading a mode-0600 +token, then supplies a Kubernetes ExecCredential through kubectl's pipe. Local +claim checks are misuse protection; Kubernetes must verify the JWT signature. +The public CA was read from `playwright/kube-root-ca.crt` on the pinned cluster +UID `a553c742-0115-43d4-99a4-a5ca56fe0786`; its SHA-256 is +`794d3badd7c3743fe66c3671e7f152277f2ac9adddbc5cebe534802432861bd7`. +CA rotation requires a reviewed source update. + +The owner-supported API transport must expose the TLS API on +`https://127.0.0.1:16444`. The existing local ops-bridge tunnel is +`k3s-api-railiance01`; its configured actor is workstation-specific and must not +be shared with other users or agents. Each caller needs their own supported +transport identity. With that route active, verify and forward: + +```sh +python3 scripts/playwright_consumer.py verify \ + --kubeconfig "$HOME/.local/state/playwright-consumer/kubeconfig.json" +kubectl --kubeconfig "$HOME/.local/state/playwright-consumer/kubeconfig.json" \ + --context playwright -n playwright port-forward deployment/playwright \ + 18081:8080 --address 127.0.0.1 +``` + +Verification requires the unmodified generated kubeconfig, reads the actual API +identity using SelfSubjectReview, and checks nine allowed/denied permissions. +It rejects administrator identities and added impersonation settings. Its +value-safe JSON result can be retained as an identity receipt. Run it separately +for a human and a coding agent; then run each browser trial through that caller's +forward. Existing port-forwards must be stopped separately during revocation; +token expiry or client disablement does not prove an established stream closed. + In another terminal, from the existing rapp-playwright checkout: ```sh @@ -28,6 +88,8 @@ run capability. Download retained receipts/screenshots before the one-hour artifact expiry. Current targets are the bundled fixture and public OnCo home page; additional target admission remains separate from group membership. +## Existing operator evidence + Platform can recheck the native permission boundary without changing grants: ```sh diff --git a/docs/playwright-identity-handoff.md b/docs/playwright-identity-handoff.md new file mode 100644 index 0000000..8964c4c --- /dev/null +++ b/docs/playwright-identity-handoff.md @@ -0,0 +1,63 @@ +# Playwright consumer identity activation handoff + +Status: source prepared; owner activation and real human/agent evidence pending. +The user already admitted all identified platform humans and coding agents. +This handoff implements ADR-0006 without inventing registration acceptance, +distributing admin kubeconfigs or broadening other service credentials. + +| Owner | Concrete change | Required return | +| --- | --- | --- | +| railiance-cluster | Review and integrate `config/playwright/authentication-config.candidate.yaml` into the K3s API-server configuration | Native config validation, rollout/recovery receipt, preserved existing authentication, real allowed/denied requests | +| key-cape / net-kingdom | Review and register `config/playwright/keycape-public-client.candidate.json`; dedicated per-agent confidential registrations | Exact claim/expiry contract, issuer rollout, registration and custody references without values | +| ops-bridge / cluster access owner | Supported per-caller TLS API route on local port 16444 | Human and agent transport instructions using their own identities | +| railiance-platform | Run `scripts/playwright_consumer.py verify` and provider trials for both identity kinds | API-returned identity, permission negatives, responsive/keyboard trial receipts | +| rapp-playwright | Review native consumer receipts and update managed availability | Accepted RAPP-PLAYWRIGHT-WP-0004-T01/T03 and declaration/registry evidence | + +Coordination requests sent October 13: cluster +`f643ce05-b71d-4492-83a3-ae142d0d3535`, KeyCape +`60b22d25-a573-4957-aaae-3a66f5713466`, NetKingdom +`94a0096c-f34c-463c-b336-b13cf629b968`. No reply had arrived when preparing +this source. Owner source/activation receipts remain necessary. + +The candidate uses issuer `https://kc.coulomb.social`, dedicated audience +`playwright-kubernetes`, access scope `playwright:connect`, and a constant mapped +group `playwright-consumers`. Subjects become `keycape:human:` or +`keycape:service:`. KeyCape represents registered coding agents as service +principals with the `coding-agent` role. Other services fail this mapping; token +groups cannot inject Kubernetes privileges. Human tenants are not rewritten or +restricted to `tenant:platform`. Dedicated registrations determine who can obtain +this audience and scope. + +The public client uses exact PKCE callback `http://127.0.0.1:18764/callback`. +Native KeyCape login verifies issuer/JWKS, scope, audience, state and nonce. +Use its access token: ID tokens have client audience and no access scope. +Human lifetime inherits issuer settings; confirm it is at most one hour before +activation. Do not add per-client `tokenLifetime` to the public client: KeyCape +supports that override only for client-credentials registrations. + +Each coding agent needs a distinct new client registration with the dedicated +audience/scope, unique service subject, `coding-agent` role, its actual registered +tenant and preferably 15-minute lifetime. Its new secretRef must have an existing +governed custody/delivery contract before activation. No existing OpenBao client +secret or audience is reused. The browser-RP registration helper's accepted +HTTPS/standard-scope type does not cover this loopback/custom-scope client; its +case-acceptance requirement cannot be satisfied by fabricating a receipt. + +Cluster integration must preserve unrelated authenticators and the SSH/admin +recovery route. `AuthenticationConfiguration` is an API-server file, not a +resource accepted by `kubectl apply`. Review existing K3s flags and config +fragments, natively validate CEL against the deployed Kubernetes version, +establish rollback before activation, and coordinate any required single-node +API-server restart. The source candidate has not been compiled by Kubernetes +or applied. Source: [Kubernetes 1.35 authentication configuration and exec +plugins](https://v1-35.docs.kubernetes.io/docs/reference/access-authn-authz/authentication/). + +Before availability is published, prove wrong audience, missing scope, expired +token, tampered signature, ordinary service and nonmember refusals at the native +API. The helper's synthetic tests do not establish those server-side properties. +Prove positive human and coding-agent access via SelfSubjectReview, permission +checks and browser trials, with no `--as` impersonation. Client disablement stops +issuance; issued JWTs remain valid until expiry, and existing port-forwards need +explicit termination. Keep the provider availability tasks open until those +receipts are accepted. Existing operator-driven pilot/restart evidence remains +valid with its documented limits. diff --git a/scripts/playwright_consumer.py b/scripts/playwright_consumer.py new file mode 100644 index 0000000..0927e7a --- /dev/null +++ b/scripts/playwright_consumer.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Build an isolated consumer kubeconfig and deliver KeyCape tokens to kubectl. + +Token claim checks here prevent accidental credential misuse. Signature verification +and authoritative group mapping belong to the Kubernetes JWT authenticator. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import time +from datetime import datetime, timezone + +ROOT = Path(__file__).resolve().parents[1] +SERVER = "https://127.0.0.1:16444" +CA_SHA256 = "794d3badd7c3743fe66c3671e7f152277f2ac9adddbc5cebe534802432861bd7" +ISSUER = "https://kc.coulomb.social" +AUDIENCE = "playwright-kubernetes" +SCOPE = "playwright:connect" +API = "client.authentication.k8s.io/v1" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def private_path(value: str) -> Path: + path = Path(os.path.abspath(os.path.expanduser(value))) + require(path.resolve() == path, "symlink paths are refused") + require(not any((p / ".git").exists() for p in path.parents), + "consumer files must be outside Git worktrees") + parent = path.parent.stat() + require(parent.st_uid == os.getuid() and stat.S_IMODE(parent.st_mode) == 0o700, + "parent directory must be owned by the caller with mode 0700") + return path + + +def read_token(value: str) -> tuple[str, int]: + path = private_path(value) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd) as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() + and stat.S_IMODE(info.st_mode) == 0o600 and info.st_nlink == 1, + "token must be a caller-owned mode-0600 regular file with one link") + require(info.st_size <= 65536, "token response is too large") + response = json.loads(stream.read(65537)) + require(isinstance(response, dict), "invalid token response") + token = response.get("access_token") + require(response.get("token_type", "").lower() == "bearer" + and isinstance(token, str) and len(token.split(".")) == 3, + "expected a KeyCape bearer access token") + part = token.split(".")[1] + claims = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))) + require(isinstance(claims, dict), "invalid token claims") + require(claims.get("iss") == ISSUER, "token issuer mismatch") + aud = claims.get("aud") + require(aud == AUDIENCE or aud == [AUDIENCE], "token audience mismatch") + require(isinstance(claims.get("scope"), str) + and SCOPE in claims["scope"].split(), "token scope mismatch") + principal = claims.get("principal_type") + roles = claims.get("roles", []) + require(principal == "human" or (principal == "service" + and isinstance(roles, list) and "coding-agent" in roles), + "token is not a human or registered coding agent") + require(isinstance(claims.get("sub"), str) and bool(claims["sub"]), + "token subject is missing") + expires = claims.get("exp") + issued = claims.get("iat") + now = time.time() + require(type(expires) is int and type(issued) is int + and issued <= now + 30 and 0 < expires - issued <= 3600 and expires > now + 30, + "token expired or has invalid validity times; authenticate again") + return token, expires + + +def credential(token_file: str) -> dict: + # Check kubectl's destination BEFORE opening the credential file. + info = json.loads(os.environ.get("KUBERNETES_EXEC_INFO", "{}")) + require(info.get("apiVersion") == API and info.get("kind") == "ExecCredential", + "invoke through the generated kubectl configuration") + cluster = info.get("spec", {}).get("cluster", {}) + require(cluster.get("server") == SERVER and not cluster.get("proxy-url") + and not cluster.get("tls-server-name") + and not cluster.get("insecure-skip-tls-verify", False), + "kubectl destination mismatch") + ca = base64.b64decode(cluster.get("certificate-authority-data", ""), validate=True) + require(hashlib.sha256(ca).hexdigest() == CA_SHA256, "kubectl CA mismatch") + require(not sys.stdout.isatty(), "credential output must be consumed by kubectl") + token, expires = read_token(token_file) + return {"apiVersion": API, "kind": "ExecCredential", "status": { + "token": token, + "expirationTimestamp": datetime.fromtimestamp(expires, timezone.utc) + .isoformat().replace("+00:00", "Z"), + }} + + +def configuration(token_file: str) -> dict: + token_path = private_path(token_file) + ca = (ROOT / "config/playwright/railiance01-ca.pem").read_bytes() + require(hashlib.sha256(ca).hexdigest() == CA_SHA256, "public CA source changed") + return { + "apiVersion": "v1", "kind": "Config", "current-context": "playwright", + "clusters": [{"name": "railiance01-playwright", "cluster": { + "server": SERVER, "certificate-authority-data": base64.b64encode(ca).decode(), + }}], + "contexts": [{"name": "playwright", "context": { + "cluster": "railiance01-playwright", "user": "playwright-consumer", + "namespace": "playwright", + }}], + "users": [{"name": "playwright-consumer", "user": {"exec": { + "apiVersion": API, "command": sys.executable, + "args": [str(Path(__file__).resolve()), "credential", "--token-file", str(token_path)], + "interactiveMode": "Never", "provideClusterInfo": True, + }}}], + } + + +def setup(token_file: str, output: str) -> None: + config = configuration(token_file) + target = private_path(output) + require(private_path(token_file) != target, "token and kubeconfig paths must differ") + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(config, stream, indent=2) + stream.write("\n") + print("Created isolated consumer kubeconfig; authentication and API transport are required.") + + +def verify(kubeconfig: str) -> dict: + path = private_path(kubeconfig) + config = json.loads(path.read_text()) + token_file = config["users"][0]["user"]["exec"]["args"][-1] + require(config == configuration(token_file), + "verification requires the unmodified generated consumer configuration") + + def post(endpoint: str, payload: dict) -> dict: + result = subprocess.run( + ["kubectl", "--kubeconfig", str(path), "--context", "playwright", + "--request-timeout=15s", "create", "--raw", endpoint, "-f", "-"], + input=json.dumps(payload), text=True, capture_output=True, timeout=25, + ) + require(result.returncode == 0, "consumer API request failed; check login and API transport") + return json.loads(result.stdout) + + identity = post("/apis/authentication.k8s.io/v1/selfsubjectreviews", { + "apiVersion": "authentication.k8s.io/v1", "kind": "SelfSubjectReview", + })["status"]["userInfo"] + username = identity["username"] + require(username.startswith(("keycape:human:", "keycape:service:")) + and set(identity.get("groups", [])) == {"playwright-consumers", "system:authenticated"}, + "API returned an unexpected consumer identity or groups") + checks = [ + ("pods-list", "playwright", "", "pods", "", "list", "", True), + ("deployment-get", "playwright", "apps", "deployments", "", "get", "playwright", True), + ("forward", "playwright", "", "pods", "portforward", "create", "", True), + ("secrets-denied", "playwright", "", "secrets", "", "list", "", False), + ("exec-denied", "playwright", "", "pods", "exec", "create", "", False), + ("write-denied", "playwright", "apps", "deployments", "", "patch", "playwright", False), + ("other-namespace-denied", "default", "", "pods", "", "list", "", False), + ("other-deployment-denied", "playwright", "apps", "deployments", "", "get", "other", False), + ("impersonation-denied", "", "", "users", "", "impersonate", "", False), + ] + for name, namespace, group, resource, subresource, verb, resource_name, expected in checks: + result = post("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", { + "apiVersion": "authorization.k8s.io/v1", "kind": "SelfSubjectAccessReview", + "spec": {"resourceAttributes": { + "namespace": namespace, "group": group, "resource": resource, + "subresource": subresource, "verb": verb, "name": resource_name, + }}, + }) + status = result["status"] + require(not status.get("evaluationError") and status.get("allowed") is expected, + "consumer permission check failed: " + name) + return {"identity": username, "groups": sorted(identity["groups"]), + "impersonated": False, "checks_passed": [c[0] for c in checks]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subs = parser.add_subparsers(dest="action", required=True) + config = subs.add_parser("setup", help="create a new isolated kubeconfig") + config.add_argument("--token-file", required=True) + config.add_argument("--out", required=True) + auth = subs.add_parser("credential", help="kubectl exec plugin; never run directly") + auth.add_argument("--token-file", required=True) + check = subs.add_parser("verify", help="prove current identity and namespace permissions") + check.add_argument("--kubeconfig", required=True) + args = parser.parse_args() + try: + if args.action == "setup": + setup(args.token_file, args.out) + elif args.action == "credential": + print(json.dumps(credential(args.token_file))) + else: + print(json.dumps(verify(args.kubeconfig), indent=2)) + except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError, OverflowError, + subprocess.TimeoutExpired): + # Parse/provider errors may contain secret input; never echo the exception. + print("Playwright consumer command failed. Check private file permissions, token validity, " + "the pinned API route and owner activation status.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_playwright_consumer.py b/tests/test_playwright_consumer.py new file mode 100644 index 0000000..a157b71 --- /dev/null +++ b/tests/test_playwright_consumer.py @@ -0,0 +1,173 @@ +"""Credential delivery and evidence checks; synthetic tokens are never login proof.""" +import base64 +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/playwright_consumer.py" +spec = importlib.util.spec_from_file_location("playwright_consumer", SCRIPT) +consumer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(consumer) + + +class ConsumerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=os.environ.get("PLAYWRIGHT_TEST_TMPDIR", "/tmp")) + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.token = self.directory / "token.json" + self.config = self.directory / "kubeconfig.json" + self.claims = dict(iss=consumer.ISSUER, aud=consumer.AUDIENCE, + sub="synthetic", principal_type="human", scope=consumer.SCOPE, + iat=int(time.time()), exp=int(time.time()) + 900) + self.cluster = consumer.configuration(str(self.token))["clusters"][0]["cluster"] + + def write_token(self): + part = base64.urlsafe_b64encode(json.dumps(self.claims).encode()).decode().rstrip("=") + value = "synthetic." + part + ".not-a-real-signature" + self.token.write_text(json.dumps(dict(access_token=value, token_type="Bearer"))) + self.token.chmod(0o600) + return value + + def invoke(self, cluster=None): + env = dict(os.environ, KUBERNETES_EXEC_INFO=json.dumps({ + "apiVersion": consumer.API, "kind": "ExecCredential", + "spec": {"cluster": self.cluster if cluster is None else cluster}, + })) + return subprocess.run([sys.executable, str(SCRIPT), "credential", "--token-file", + str(self.token)], env=env, capture_output=True, text=True) + + def test_exec_delivers_only_expected_credential_and_expiry(self): + token = self.write_token() + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr) + value = json.loads(result.stdout) + self.assertEqual(value["status"]["token"], token) + self.assertTrue(value["status"]["expirationTimestamp"].endswith("Z")) + self.assertEqual(result.stderr, "") + + def test_registered_coding_agent_supported(self): + self.claims.update(principal_type="service", roles=["coding-agent"]) + self.write_token() + self.assertEqual(self.invoke().returncode, 0) + + def test_wrong_destination_refused_before_token_read(self): + variants = [dict(server="https://example.org"), {"proxy-url": "http://example.org"}, + {"insecure-skip-tls-verify": True}, {"tls-server-name": "other"}, + {"certificate-authority-data": "ZmFrZQ=="}] + for change in variants: + with self.subTest(change=change), patch.object(consumer, "read_token") as read: + info = dict(apiVersion=consumer.API, kind="ExecCredential", + spec={"cluster": dict(self.cluster, **change)}) + with patch.dict(os.environ, KUBERNETES_EXEC_INFO=json.dumps(info)): + with self.assertRaises(ValueError): + consumer.credential(str(self.token)) + read.assert_not_called() + + def test_wrong_claims_fail_without_leaking_token(self): + original = self.claims.copy() + variants = [{"iss": "https://other.example"}, {"aud": "openbao"}, + {"aud": [consumer.AUDIENCE, "openbao"]}, {"scope": "openid"}, + {"principal_type": "service", "roles": []}, {"sub": ""}, + {"exp": int(time.time()) - 5}, {"exp": True}, + {"iat": int(time.time()) + 100}, {"exp": int(time.time()) + 7200}] + for change in variants: + with self.subTest(change=change): + self.claims = dict(original, **change) + token = self.write_token() + result = self.invoke() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + self.assertNotIn(token, result.stderr) + + def test_file_permissions_symlinks_and_hardlinks_refused(self): + self.write_token() + self.token.chmod(0o644) + self.assertNotEqual(self.invoke().returncode, 0) + self.token.chmod(0o600) + other = self.directory / "other.json" + os.link(self.token, other) + self.assertNotEqual(self.invoke().returncode, 0) + self.token.unlink() + self.token.symlink_to(other) + self.assertNotEqual(self.invoke().returncode, 0) + + def test_nonprivate_parent_and_git_worktree_refused(self): + self.directory.chmod(0o755) + with self.assertRaises(ValueError): + consumer.configuration(str(self.token)) + self.directory.chmod(0o700) + (self.directory / ".git").write_text("gitdir: synthetic") + with self.assertRaises(ValueError): + consumer.configuration(str(self.token)) + + def test_setup_no_credentials_no_overwrite_and_kubectl_parses(self): + consumer.setup(str(self.token), str(self.config)) + config = json.loads(self.config.read_text()) + self.assertEqual(self.config.stat().st_mode & 0o777, 0o600) + self.assertEqual(set(config["users"][0]["user"]), {"exec"}) + with self.assertRaises(FileExistsError): + consumer.setup(str(self.token), str(self.config)) + result = subprocess.run(["kubectl", "--kubeconfig", str(self.config), + "config", "view", "-o", "json"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["current-context"], "playwright") + + def test_verify_rejects_impersonation_configuration_without_api_call(self): + config = consumer.configuration(str(self.token)) + config["users"][0]["user"]["as"] = "keycape:human:fake" + self.config.write_text(json.dumps(config)) + with patch.object(consumer.subprocess, "run") as run: + with self.assertRaises(ValueError): + consumer.verify(str(self.config)) + run.assert_not_called() + + def test_verify_requires_real_identity_and_all_permission_results(self): + self.config.write_text(json.dumps(consumer.configuration(str(self.token)))) + calls = [] + + def api(argv, **kwargs): + calls.append(argv) + body = json.loads(kwargs["input"]) + if body["kind"] == "SelfSubjectReview": + data = {"status": {"userInfo": {"username": "keycape:human:synthetic", + "groups": ["system:authenticated", "playwright-consumers"]}}} + else: + data = {"status": {"allowed": len(calls) <= 4}} + return subprocess.CompletedProcess(argv, 0, json.dumps(data), "") + + with patch.object(consumer.subprocess, "run", side_effect=api): + receipt = consumer.verify(str(self.config)) + self.assertEqual(len(receipt["checks_passed"]), 9) + self.assertFalse(receipt["impersonated"]) + self.assertTrue(all("--kubeconfig" in argv and "--as" not in argv for argv in calls)) + + with patch.object(consumer.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 0, json.dumps({"status": {"userInfo": {"username": "system:admin", + "groups": ["system:masters"]}}}), "")): + with self.assertRaises(ValueError): + consumer.verify(str(self.config)) + + calls.clear() + + def excessive_permission(argv, **kwargs): + result = api(argv, **kwargs) + if len(calls) > 1: + result.stdout = json.dumps({"status": {"allowed": True}}) + return result + + with patch.object(consumer.subprocess, "run", side_effect=excessive_permission): + with self.assertRaisesRegex(ValueError, "secrets-denied"): + consumer.verify(str(self.config)) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/README.md b/workplans/README.md index 407962f..a5f0596 100644 --- a/workplans/README.md +++ b/workplans/README.md @@ -16,7 +16,7 @@ Completed plans retain their IDs and managed UUIDs, including archived plans. | [RPF-WP-0048](RPF-WP-0048-activity-core-gitops-adoption.md) (blocked) | T02 | Activity-core promotion evidence | | [RPF-WP-0053](RPF-WP-0053-coulomb-paired-backup-admission.md) (proposed) | T01, T02, T03, T04, T05 | Paired archive execution/custody, retrieval/recovery and monitoring admission | | [RPF-WP-0055](RPF-WP-0055-keycape-client-secret-rotation-residual.md) (backlog) | T01 | Unpromoted rotation residual; do not implement without demand/risk promotion | -| [RPF-WP-0056](RPF-WP-0056-playwright-consumer-admission.md) (blocked) | T03 | Dedicated Playwright consumer group, managed pilot and authenticated identity route | +| [RPF-WP-0056](RPF-WP-0056-playwright-consumer-admission.md) (blocked) | T03 | Pilot and consumer setup implemented; identity registration, API authentication/transport and real consumer proof pending | October 13 review corrected WP-0038-T04's stale `todo` state to the documented `wait`. RPF-IN-0012 remains an open independent execution/wrapped-delivery review; diff --git a/workplans/RPF-WP-0056-playwright-consumer-admission.md b/workplans/RPF-WP-0056-playwright-consumer-admission.md index 04e770c..f500886 100644 --- a/workplans/RPF-WP-0056-playwright-consumer-admission.md +++ b/workplans/RPF-WP-0056-playwright-consumer-admission.md @@ -125,3 +125,34 @@ cross-version rollback or abrupt-interruption proof. T03 remains open for actual consumer identity/group mapping and the remaining package-owner acceptance. Group decision mirrored to State Hub d2426028-0b04-4ea2-b199-d7f9e19e38e6. + +## Prepare consumer login delivery and owner activation inputs + +```task +id: RPF-WP-0056-T05 +status: done +priority: high +``` + +User authorized continuing with identity-owner implementation. Added +scripts/playwright_consumer.py: isolated kubeconfig setup, public-CA/destination +pinning before credential delivery, private KeyCape token-file consumption and +actual SelfSubjectReview plus nine allowed/denied access checks. Verification +refuses modified/impersonating configurations and administrator identities. +Nine focused tests pass, including misuse, file permission, wrong-destination, +expired/wrong-audience token and excessive-permission refusals; installed kubectl +parses the generated configuration. These synthetic tests do not prove native +JWT authentication or live identity access. + +config/playwright/ contains the public cluster CA and review candidates for the +native JWT authenticator and public PKCE client. docs/playwright-consumer-access.md +now provides login/setup/verify/forward/trial commands; the exact owner activation +contract is docs/playwright-identity-handoff.md. Existing workstation API tunnel +uses its own SSH actor and is not a general per-consumer transport entitlement. +Dedicated coding-agent registration and governed secret delivery remain pending. + +T03 stays wait: cluster/KeyCape/NetKingdom activation replies have not arrived, +the authenticator candidate has not been natively validated or activated, and +neither a real human nor agent consumer receipt exists. Do not update provider +managed availability to ready from this preparation. No new membership approval +is required. Evidence: docs/evidence/2026-10-13-playwright-consumer-preparation.json.