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:
parent
5ea8b2dcd3
commit
6323db59ba
10 changed files with 652 additions and 3 deletions
30
config/playwright/authentication-config.candidate.yaml
Normal file
30
config/playwright/authentication-config.candidate.yaml
Normal 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
|
||||
9
config/playwright/keycape-public-client.candidate.json
Normal file
9
config/playwright/keycape-public-client.candidate.json
Normal 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"
|
||||
}
|
||||
10
config/playwright/railiance01-ca.pem
Normal file
10
config/playwright/railiance01-ca.pem
Normal 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-----
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
63
docs/playwright-identity-handoff.md
Normal file
63
docs/playwright-identity-handoff.md
Normal 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.
|
||||
215
scripts/playwright_consumer.py
Normal file
215
scripts/playwright_consumer.py
Normal 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())
|
||||
173
tests/test_playwright_consumer.py
Normal file
173
tests/test_playwright_consumer.py
Normal 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()
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue