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
|
group alone would not establish Kubernetes access. No administrator kubeconfig
|
||||||
is distributed and no broad `system:authenticated` binding is installed.
|
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
|
```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:
|
In another terminal, from the existing rapp-playwright checkout:
|
||||||
|
|
||||||
```sh
|
```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
|
artifact expiry. Current targets are the bundled fixture and public OnCo home
|
||||||
page; additional target admission remains separate from group membership.
|
page; additional target admission remains separate from group membership.
|
||||||
|
|
||||||
|
## Existing operator evidence
|
||||||
|
|
||||||
Platform can recheck the native permission boundary without changing grants:
|
Platform can recheck the native permission boundary without changing grants:
|
||||||
|
|
||||||
```sh
|
```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-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-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-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
|
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;
|
`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.
|
consumer identity/group mapping and the remaining package-owner acceptance.
|
||||||
|
|
||||||
Group decision mirrored to State Hub d2426028-0b04-4ea2-b199-d7f9e19e38e6.
|
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