Prepare Playwright consumer credentials and identity activation handoff

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13690-09e0-7cd1-964b-ec83cdbcd348
This commit is contained in:
codex 2026-10-13 21:21:58 +02:00
parent 5ea8b2dcd3
commit 6323db59ba
10 changed files with 652 additions and 3 deletions

View file

@ -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

View file

@ -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"
}

View file

@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBeDCCAR2gAwIBAgIBADAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhrM3Mtc2Vy
dmVyLWNhQDE3NzMxMDM2MjAwHhcNMjYwMzEwMDA0NzAwWhcNMzYwMzA3MDA0NzAw
WjAjMSEwHwYDVQQDDBhrM3Mtc2VydmVyLWNhQDE3NzMxMDM2MjAwWTATBgcqhkjO
PQIBBggqhkjOPQMBBwNCAAT44CzyUUGic6xYSLOApGq8A61OcoP/v+nns47p3F1z
M89iyNSZqtIHRpg0BxiMtTcdwXKart6aPIC8/8PfpVyAo0IwQDAOBgNVHQ8BAf8E
BAMCAqQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUG/vhGaKEp4uD2KaSPDTU
249sq2EwCgYIKoZIzj0EAwIDSQAwRgIhALUc7a0LcGfMiAI+auFTByXlL4hTH0Cr
DxsOiRhV9D0vAiEAjTVKYP/KFPe68cAL8S76mLKdKhd564XiVAeISh5NkG4=
-----END CERTIFICATE-----

View file

@ -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"
}
]
}

View file

@ -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

View file

@ -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:<sub>` or
`keycape:service:<sub>`. 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.

View file

@ -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())

View file

@ -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()

View file

@ -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;

View file

@ -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.