From e77f3989b48fe0ec0e47cbd19967f3bcd4c08b5d Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 31 Oct 2026 12:00:57 +0100 Subject: [PATCH] Prepare supervised email-connect SMTP foundation and projection Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 2 +- ...email-connect-smtp-admission-2026-10-31.md | 4 +- ...nnect-smtp-foundation-review-2026-10-31.md | 128 +++++++ ...mtp-foundation-preparation-2026-10-31.json | 111 +++++++ .../foundation.json | 84 +++++ scripts/email_connect_smtp_foundation.py | 314 ++++++++++++++++++ tests/test_email_connect_smtp_foundation.py | 262 +++++++++++++++ ...RPF-WP-0068-email-connect-platform-smtp.md | 12 +- 8 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 docs/email-connect-smtp-foundation-review-2026-10-31.md create mode 100644 docs/evidence/email-connect-smtp-foundation-preparation-2026-10-31.json create mode 100644 openbao/email-connect-platform-smtp/foundation.json create mode 100644 scripts/email_connect_smtp_foundation.py create mode 100644 tests/test_email_connect_smtp_foundation.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 0866356..a53b2c9 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -222,7 +222,7 @@ | task | RPF-WP-0067-T02 | done | — | workplans/RPF-WP-0067-netkingdom-identity-receipt-and-metadata-answers.md | | task | RPF-WP-0067-T03 | wait | — | workplans/RPF-WP-0067-netkingdom-identity-receipt-and-metadata-answers.md | | task | RPF-WP-0068-T01 | done | — | workplans/RPF-WP-0068-email-connect-platform-smtp.md | -| task | RPF-WP-0068-T02 | todo | — | workplans/RPF-WP-0068-email-connect-platform-smtp.md | +| task | RPF-WP-0068-T02 | done | — | workplans/RPF-WP-0068-email-connect-platform-smtp.md | | task | RPF-WP-0068-T03 | wait | — | workplans/RPF-WP-0068-email-connect-platform-smtp.md | | task | RPF-WP-0068-T04 | todo | — | workplans/RPF-WP-0068-email-connect-platform-smtp.md | | intake | RPF-IN-0001 | closed | — | intakes/intakes.md | diff --git a/docs/email-connect-smtp-admission-2026-10-31.md b/docs/email-connect-smtp-admission-2026-10-31.md index 1beb672..7f9de85 100644 --- a/docs/email-connect-smtp-admission-2026-10-31.md +++ b/docs/email-connect-smtp-admission-2026-10-31.md @@ -4,7 +4,9 @@ CCR-2026-0056 proposes one additional reader of the existing platform SMTP entry RPF-WP-0068 tracks preparation, foundation/binding proof, attended admission and native consumer delivery. The parent requirement is CSOC-WP-0015-T02. -**Source preparation is complete; execution is not ready.** The dedicated service +**The complete controller is now prepared** in +[the foundation packet](email-connect-smtp-foundation-review-2026-10-31.md). +Its41 SMTP tests pass. **Execution still awaits review/approval.** The dedicated service account/store/projection have only passed server dry-run. Native binding is not confirmed, the CCR is proposed, and no new policy or role has been installed. diff --git a/docs/email-connect-smtp-foundation-review-2026-10-31.md b/docs/email-connect-smtp-foundation-review-2026-10-31.md new file mode 100644 index 0000000..12f7f24 --- /dev/null +++ b/docs/email-connect-smtp-foundation-review-2026-10-31.md @@ -0,0 +1,128 @@ +# Email-connect platform SMTP foundation review + +Prepared for CCR-2026-0056 and RPF-WP-0068, supporting CSOC-WP-0015-T02. +This is a concrete review packet, not evidence of live installation. + +## Decision scope + +Admit email-connect as a third reader of the existing platform SMTP mailbox, +alongside telemetry and Authelia. Create exactly one service account, namespace +SecretStore and ExternalSecret in email-connect. The scoped Kubernetes role reads +the whole exact OpenBao KV record at +`platform/data/workloads/railiance-telemetry/smtp`; ESO projects only SMTP_PASSWORD +into the new Secret's `password` key. OpenBao does not enforce field-level access. +The role has no default policy, a maximum 15-minute lifetime and only the exact +service-account/namespace binding with audience openbao. + +This does not change the email-connect deployment or sender, create a caller, +send email, scan a mailbox, activate backup monitoring, rotate the shared password, +or grant suppression authority. Those live obligations remain in CSOC-WP-0015. +The user already selected platform-root, with Bernd as human operator, for replies +and bounces. This new reader requires its own admission review. + +The source-backed foundation proposal is: + +`sha256:f8da6c831160e1c6354ce54f936809d2d4fb48eb45eb23484dc0bcdfe0bb52f0` + +Reproduce it without credentials or network operations: + +```bash +python3 /home/worsch/railiance-platform/scripts/email_connect_smtp_foundation.py +``` + +The digest binds the controller, all three resources including server defaults, +cluster/namespace/application identities, fixed kubeconfig path and admission +digest. The latter binds the CCR's functional scope, policy and seven dependency +files. Changes to execution code or scope require a new proposal. Approval and +native binding evidence do not change functional scope. + +## Preconditions and approval boundary + +CCR-2026-0056 remains **proposed**, with native binding unconfirmed. The +[credential workflow](credential-change-approval.md) states: “Only `approved` +requests may be applied.” Its shared-reader lane requires platform review; +automatic single-reader approval does not apply. Its client workflow also states: +“The **founder** resolves the State Hub decision” and “Clients do not run +`sync-decision` on their own CCRs.” No review, approval or sync is impersonated here. + +The completed packet is ready for that platform/consumer review and founder +decision. After the owner workflow has made the exact CCR approved, the founder +can run the attended command below. The controller creates and verifies the +dedicated service account, then uses canonical confirm-binding itself before +admitting the OpenBao role. The operator need not falsely confirm an absent SA. + +## Attended command after approval + +Run in the founder's terminal; this is not an agent execution instruction. +The OpenBao relay must be available at port18200 and the fixed primary kubeconfig +must be present. `warden plan` resolves this operation to +openbao-platform-admin-login with one interactive OIDC/MFA login. + +```bash +mkdir -m 700 /home/worsch/email-connect-smtp-admission-20261031 +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 \ +warden access openbao-platform-admin-login --exec -- \ + python3 /home/worsch/railiance-platform/scripts/email_connect_smtp_foundation.py \ + --execute \ + --expect-digest sha256:f8da6c831160e1c6354ce54f936809d2d4fb48eb45eb23484dc0bcdfe0bb52f0 \ + --receipt /home/worsch/email-connect-smtp-admission-20261031/receipt.jsonl +``` + +The directory creation is intentionally exclusive. If it already exists, inspect +its contents and prior receipts before proceeding; do not remove or overwrite +evidence. Do not run the second command if the first failed. The helper separately +requires a private caller-owned directory and a new receipt path. + +The child must be silent because Warden rejects child output. It records a private +mode0600 append-only JSON Lines journal, fsynced before changes. Share only the +last complete receipt and Warden outcome; never login tokens or provider values. +The expected final child status is `foundation_admitted_projection_ready`. +Warden must also report successful session revocation and cleanup. + +## What execution verifies + +Before mutation, pinned OpenBao and Kubernetes cluster/namespace identities, +platform-admin without root, current deployment identity/generation and absence +of all three new objects and the target Secret are required. Objects are created +exclusively; none is applied over an existing object. No automatic rollback or +retry follows a partial result. + +The new service account disables automount. Its new short-lived JWT passes native +TokenReview for its UID, full service-account name and openbao audience. Only then +does canonical confirm-binding run. Guarded admission preserves the shared mailbox, +telemetry/Authelia roles and policies, and existing agent denial. It authenticates +to SMTP using certificate-verified STARTTLS without submitting mail. + +Actual workload OpenBao login must have only the dedicated policy and bounded TTL, +read its exact record, and deny metadata, siblings and mutation capabilities. +Wrong service account and wrong audience must fail. The workload token is revoked +and lookup must fail afterward. This does not claim an independently exercised +same-name/wrong-namespace fixture; exact namespace binding is checked in role +metadata and the positive namespace identity is pinned. + +Both store and projection must report Ready with exact reviewed specs; the new +Secret must name the new ExternalSecret as controller owner. Secret checks use +PartialObjectMetadata content negotiation through a private Unix-socket proxy, +with no full-object fallback. Secret values are neither requested nor journaled. +Existing application/configuration/old projection fingerprints must remain equal. + +If execution fails, retain the journal and inspect its last phase and native state. +There may be a created SA, admitted role or projection even if no final success +was written. Do not rerun blindly, revoke unrelated readers, rotate the mailbox or +roll out the application while outcome is uncertain. + +## Validation and remaining work + +41 synthetic SMTP tests pass, including12 controller cases and15 admission cases. +They exercise ordering, drift refusal, target collisions, binding refusal, partial +creation, privacy/exclusive journals, interruption, native token capability and +revocation checks, and metadata-only response enforcement/proxy cleanup. +All three resources passed a fresh native server dry-run. A read-only native +metadata query confirmed the target Secret absent. No credential was fetched by +the agent and no production resource was installed. + +Evidence: `docs/evidence/email-connect-smtp-foundation-preparation-2026-10-31.json`. +After the attended return, verify receipt and native state, record CCR verification +and rotation inventory, and hand the custody result to CSOC-WP-0015-T02. The sender +rollout, actual delivered-message authentication, inbox access, authoritative return +handling, suppression admission and retention remain separate unfinished work. diff --git a/docs/evidence/email-connect-smtp-foundation-preparation-2026-10-31.json b/docs/evidence/email-connect-smtp-foundation-preparation-2026-10-31.json new file mode 100644 index 0000000..d0ef570 --- /dev/null +++ b/docs/evidence/email-connect-smtp-foundation-preparation-2026-10-31.json @@ -0,0 +1,111 @@ +{ + "schema": "platform.email-connect-smtp-foundation-preparation.v1", + "prepared_at": "2026-10-31T10:59:04.381919+00:00", + "proposal": { + "proposal_digest": "sha256:f8da6c831160e1c6354ce54f936809d2d4fb48eb45eb23484dc0bcdfe0bb52f0", + "proposal": { + "schema": "platform.email-connect-smtp-foundation-proposal.v1", + "foundation": { + "schema": "platform.email-connect-smtp-foundation.v1", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "namespace_uid": "5678c5d7-e4f5-4a62-8f24-1c6e1d906319", + "deployment_uid": "2e98e52a-a2d1-4c52-9ac8-6efbc0a18e72", + "deployment_generation": 74, + "resources": [ + { + "apiVersion": "v1", + "automountServiceAccountToken": false, + "kind": "ServiceAccount", + "metadata": { + "name": "email-connect-platform-smtp-eso", + "namespace": "email-connect" + } + }, + { + "apiVersion": "external-secrets.io/v1", + "kind": "SecretStore", + "metadata": { + "name": "openbao-email-connect-platform-smtp", + "namespace": "email-connect" + }, + "spec": { + "provider": { + "vault": { + "auth": { + "kubernetes": { + "mountPath": "kubernetes", + "role": "email-connect-platform-smtp-eso", + "serviceAccountRef": { + "audiences": [ + "openbao" + ], + "name": "email-connect-platform-smtp-eso" + } + } + }, + "path": "platform", + "server": "http://openbao.openbao.svc:8200", + "version": "v2" + } + } + } + }, + { + "apiVersion": "external-secrets.io/v1", + "kind": "ExternalSecret", + "metadata": { + "name": "email-connect-platform-smtp", + "namespace": "email-connect" + }, + "spec": { + "data": [ + { + "remoteRef": { + "conversionStrategy": "Default", + "decodingStrategy": "None", + "key": "workloads/railiance-telemetry/smtp", + "metadataPolicy": "None", + "property": "SMTP_PASSWORD" + }, + "secretKey": "password" + } + ], + "refreshInterval": "1h", + "secretStoreRef": { + "kind": "SecretStore", + "name": "openbao-email-connect-platform-smtp" + }, + "target": { + "creationPolicy": "Owner", + "deletionPolicy": "Retain", + "name": "email-connect-platform-smtp", + "template": { + "engineVersion": "v2", + "mergePolicy": "Replace", + "type": "Opaque" + } + } + } + } + ] + }, + "admission_digest": "sha256:14a051abc35d94b9fbfbc8b47f19f9ba7bc95c779ea98fbb9083072386e72160", + "kubeconfig": "/home/worsch/.kube/config-railiance01", + "controller_sha256": "4f1355c0cd48368fa1ae4dcb0138296b3880359329a4ace35dee51783b0ff13f" + }, + "ccr_status": "proposed" + }, + "validation": { + "smtp_tests_passed": 41, + "smtp_tests_failed": 0, + "server_dry_run_resources": 3, + "native_target_secret_absent": true, + "target_metadata_only_api": true + }, + "test_source_sha256": "afdb7877637ced8a1d755bea1b609378b9b74603f3561762ed1dcb38ddd8d82c", + "approved": false, + "executed": false, + "resources_installed": false, + "email_sent": false, + "secrets_read_by_agent": false +} diff --git a/openbao/email-connect-platform-smtp/foundation.json b/openbao/email-connect-platform-smtp/foundation.json new file mode 100644 index 0000000..d5ad112 --- /dev/null +++ b/openbao/email-connect-platform-smtp/foundation.json @@ -0,0 +1,84 @@ +{ + "schema": "platform.email-connect-smtp-foundation.v1", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "namespace_uid": "5678c5d7-e4f5-4a62-8f24-1c6e1d906319", + "deployment_uid": "2e98e52a-a2d1-4c52-9ac8-6efbc0a18e72", + "deployment_generation": 74, + "resources": [ + { + "apiVersion": "v1", + "automountServiceAccountToken": false, + "kind": "ServiceAccount", + "metadata": { + "name": "email-connect-platform-smtp-eso", + "namespace": "email-connect" + } + }, + { + "apiVersion": "external-secrets.io/v1", + "kind": "SecretStore", + "metadata": { + "name": "openbao-email-connect-platform-smtp", + "namespace": "email-connect" + }, + "spec": { + "provider": { + "vault": { + "auth": { + "kubernetes": { + "mountPath": "kubernetes", + "role": "email-connect-platform-smtp-eso", + "serviceAccountRef": { + "audiences": [ + "openbao" + ], + "name": "email-connect-platform-smtp-eso" + } + } + }, + "path": "platform", + "server": "http://openbao.openbao.svc:8200", + "version": "v2" + } + } + } + }, + { + "apiVersion": "external-secrets.io/v1", + "kind": "ExternalSecret", + "metadata": { + "name": "email-connect-platform-smtp", + "namespace": "email-connect" + }, + "spec": { + "data": [ + { + "remoteRef": { + "conversionStrategy": "Default", + "decodingStrategy": "None", + "key": "workloads/railiance-telemetry/smtp", + "metadataPolicy": "None", + "property": "SMTP_PASSWORD" + }, + "secretKey": "password" + } + ], + "refreshInterval": "1h", + "secretStoreRef": { + "kind": "SecretStore", + "name": "openbao-email-connect-platform-smtp" + }, + "target": { + "creationPolicy": "Owner", + "deletionPolicy": "Retain", + "name": "email-connect-platform-smtp", + "template": { + "engineVersion": "v2", + "mergePolicy": "Replace", + "type": "Opaque" + } + } + } + } + ] +} diff --git a/scripts/email_connect_smtp_foundation.py b/scripts/email_connect_smtp_foundation.py new file mode 100644 index 0000000..2feb2f2 --- /dev/null +++ b/scripts/email_connect_smtp_foundation.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Silent attended CCR-2026-0056 foundation, admission and projection. + +Default output is an offline proposal. Never changes the application sender. +An incomplete attempt requires inspection, not an automatic retry or rollback. +""" +from __future__ import annotations + +import argparse +import hashlib +import http.client +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone + +import email_connect_smtp_delivery as admission +from state_hub_preflight_lane import command, data, bao, capabilities + +ROOT = admission.ROOT +BUNDLE = ROOT / 'openbao/email-connect-platform-smtp/foundation.json' +NS = 'email-connect' +SA = admission.LANE['service_account'] +KUBECONFIG = '/home/worsch/.kube/config-railiance01' +require = admission.require + + +def digest(value): + return 'sha256:' + hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def prepare(): + ccr, owner, owner_digest = admission.prepare() + bundle = json.loads(BUNDLE.read_text()) + proposal = dict(schema='platform.email-connect-smtp-foundation-proposal.v1', + foundation=bundle, admission_digest=owner_digest, + kubeconfig=KUBECONFIG, + controller_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()) + return ccr, proposal, digest(proposal) + + +class Native: + def __init__(self): + self.kube = ['kubectl', '--kubeconfig', KUBECONFIG] + + def get(self, kind, name, namespace=NS): + args = self.kube + ['get', kind, name, '--ignore-not-found', '-o', 'json'] + if namespace: + args += ['-n', namespace] + result = command(args) + return data(result) if result.stdout.strip() else None + + def target_metadata(self, name): + # Kubernetes content negotiation excludes Secret values at the server. + # Use a private local socket, one fixed GET path and no full-object fallback. + require(name == 'email-connect-platform-smtp', 'target_secret_scope_drift') + path = '/api/v1/namespaces/email-connect/secrets/'+name + with tempfile.TemporaryDirectory(prefix='email-connect-metadata-') as directory: + sockpath = str(Path(directory)/'api.sock') + proc = subprocess.Popen(self.kube + ['proxy', '--unix-socket='+sockpath, + '--accept-paths=^'+path+'$', '--reject-methods=^(POST|PUT|PATCH|DELETE)$'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + conn = None + try: + for _ in range(50): + if Path(sockpath).exists(): + break + require(proc.poll() is None, 'metadata_proxy_failed') + time.sleep(0.1) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(10) + try: + sock.connect(sockpath) + except BaseException: + sock.close() + raise + conn = http.client.HTTPConnection('localhost', timeout=10) + conn.sock = sock + conn.request('GET', path, headers={'Accept': 'application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1'}) + response = conn.getresponse() + if response.status == 404: + return None + require(response.status == 200, 'metadata_query_failed') + value = json.load(response) + require(value.get('kind') == 'PartialObjectMetadata' and + value.get('apiVersion') == 'meta.k8s.io/v1' and 'data' not in value, + 'metadata_only_response_required') + return value['metadata'] + finally: + if conn: + conn.close() + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill(); proc.wait(timeout=5) + + def create(self, resource): + return data(command(self.kube + ['create', '-f', '-', '-o', 'json'], payload=resource)) + + def jwt(self, service_account=SA, audience='openbao'): + return command(self.kube + ['-n', NS, 'create', 'token', service_account, + '--audience='+audience, '--duration=10m']).stdout.decode().strip() + + def token_review(self, jwt): + return data(command(self.kube + ['create', '-f', '-', '-o', 'json'], payload={ + 'apiVersion': 'authentication.k8s.io/v1', 'kind': 'TokenReview', + 'spec': {'token': jwt, 'audiences': ['openbao']}}))['status'] + + def wait(self, resource): + command(self.kube + ['-n', NS, 'wait', '--for=condition=Ready', + resource['kind']+'/'+resource['metadata']['name'], '--timeout=45s']) + + +def exact_resource(actual, wanted): + require(actual is not None, 'resource_missing') + require(actual['apiVersion'] == wanted['apiVersion'] and actual['kind'] == wanted['kind'], 'resource_drift') + require(all(actual['metadata'].get(k) == v for k, v in wanted['metadata'].items()), 'resource_identity_drift') + if wanted['kind'] == 'ServiceAccount': + require(actual.get('automountServiceAccountToken') is False + and not actual.get('secrets') and not actual.get('imagePullSecrets'), 'service_account_drift') + else: + require(actual['spec'] == wanted['spec'], 'resource_spec_drift') + require(not actual['metadata'].get('deletionTimestamp'), 'resource_deleting') + + +def protected_application(native, bundle): + deployment = native.get('deployment', 'email-connect') + require(deployment['metadata']['uid'] == bundle['deployment_uid'] + and deployment['metadata']['generation'] == bundle['deployment_generation'], 'application_changed') + values = [] + for kind, name in [('deployment', 'email-connect'), ('configmap', 'email-connect-config'), + ('externalsecret', 'email-connect-runtime')]: + obj = deployment if kind == 'deployment' else native.get(kind, name) + values.append({'uid': obj['metadata']['uid'], 'spec': obj.get('spec'), 'data': obj.get('data')}) + return digest(values) + + +def verify_login(native): + def login(sa=SA, audience='openbao', negative=False): + result = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], + payload={'role': SA, 'jwt': native.jwt(sa, audience)}, allow_failure=negative) + if negative: + if result.returncode == 0: + revoke(data(result)['auth']['client_token']) + raise admission.Refused('negative_login_succeeded') + require(b'400' in result.stderr or b'403' in result.stderr, 'negative_login_inconclusive') + return None + return data(result)['auth'] + + def revoke(token): + bao(['write', '-f', 'auth/token/revoke-self'], token=token) + check = bao(['token', 'lookup', '-format=json'], token=token, allow_failure=True) + require(check.returncode != 0 and b'403' in check.stderr, 'revocation_unconfirmed') + + auth = login() + token = auth['client_token'] + try: + require(auth['token_policies'] == [SA] and auth.get('policies', [SA]) == [SA] + and 0 < auth['lease_duration'] <= 900, 'effective_policy_or_ttl_drift') + path = admission.LANE['kv_path'] + paths = [path, path.replace('/data/', '/metadata/', 1), path+'-unrelated', + 'auth/token/create', 'auth/token/lookup-self', 'auth/token/revoke-self'] + expected = [['read'], ['deny'], ['deny'], ['deny'], ['read'], ['update']] + require(capabilities(token, paths) == dict(zip(paths, expected)), 'capability_drift') + mailbox = data(bao(['read', '-format=json', path], token=token))['data'] + admission.lane_module().validate_entry(mailbox) + finally: + revoke(token) + login('default', negative=True) + login(audience='wrong-audience', negative=True) + return dict(native_consumer_auth_verified=True, exact_read_verified=True, + mutation_sibling_metadata_denied=True, bounded_ttl=True, + other_service_account_denied=True, wrong_audience_denied=True, + workload_token_revocation_verified=True) + + +def run(proposal, receipt, checkpoint, native=None): + native = native or Native() + bundle = proposal['foundation'] + status = data(bao(['status', '-format=json'])) + require(status.get('cluster_id') == admission.OPENBAO_CLUSTER_ID and + status.get('initialized') is True and status.get('sealed') is False, 'openbao_instance_mismatch') + policies = data(bao(['token', 'lookup', '-format=json']))['data']['policies'] + require('platform-admin' in policies and 'root' not in policies, 'attended_platform_admin_required') + for name, uid in [('kube-system', bundle['cluster_uid']), (NS, bundle['namespace_uid'])]: + obj = native.get('namespace', name, namespace=None) + require(obj and obj['metadata']['uid'] == uid and not obj['metadata'].get('deletionTimestamp'), + 'cluster_or_namespace_identity_mismatch') + before = protected_application(native, bundle) + resources = bundle['resources'] + require([x['kind'] for x in resources] == ['ServiceAccount', 'SecretStore', 'ExternalSecret'], 'foundation_contract_drift') + for x in resources: + require(native.get(x['kind'], x['metadata']['name']) is None, 'target_exists_requires_inspection') + target = resources[-1]['spec']['target']['name'] + require(native.target_metadata(target) is None, 'target_secret_exists_requires_inspection') + receipt.update(phase='creating_service_account', mutation_outcome='unconfirmed') + checkpoint(receipt) + account = native.create(resources[0]) + exact_resource(account, resources[0]) + review = native.token_review(native.jwt()) + require(review.get('authenticated') is True and review.get('audiences') == ['openbao'] + and review['user']['username'] == 'system:serviceaccount:'+NS+':'+SA + and review['user']['uid'] == account['metadata']['uid'], 'native_binding_unproven') + receipt.update(phase='binding_verified', service_account_uid=account['metadata']['uid'], + native_binding_verified=True) + checkpoint(receipt) + validator = admission.module('email_connect_foundation_ccr', ROOT/'scripts/credential-change.py') + validator.confirm_binding(admission.CCR, reviewer='attended-platform-operator', + comment='CCR-2026-0056 foundation: pinned cluster and namespace, exact new service account and native audience TokenReview verified; receipt '+receipt['proposal_digest']) + ccr, _, owner_digest = admission.prepare() + admission.execution_guard(ccr, proposal['admission_digest'], owner_digest) + owner_receipt = {} + def owner_checkpoint(value): + receipt.update(phase='owner_admission', owner_admission=dict(value)) + checkpoint(receipt) + admission.admit(owner_receipt, owner_checkpoint) + receipt.update(phase='native_login_verification') + checkpoint(receipt) + receipt.update(verify_login(native)) + for wanted in resources[1:]: + receipt.update(phase='creating_'+wanted['kind'].lower()) + checkpoint(receipt) + created = native.create(wanted) + exact_resource(created, wanted) + native.wait(wanted) + live = native.get(wanted['kind'], wanted['metadata']['name']) + exact_resource(live, wanted) + require(live['metadata']['uid'] == created['metadata']['uid'] and + any(c.get('type') == 'Ready' and c.get('status') == 'True' + for c in live.get('status', {}).get('conditions', [])), 'projection_not_ready') + if wanted['kind'] == 'ExternalSecret': + require(bool(live['status'].get('refreshTime')), 'projection_refresh_unproven') + metadata = native.target_metadata(target) + require(metadata and any(o.get('uid') == live['metadata']['uid'] + and o.get('controller') is True for o in metadata.get('ownerReferences', [])), + 'projection_ownership_unproven') + receipt['projection_refresh_time'] = live['status']['refreshTime'] + require(protected_application(native, bundle) == before, 'application_changed') + receipt.update(status='foundation_admitted_projection_ready', phase='completed', + mutation_outcome='verified', projection_ready=True, application_sender_changed=False) + checkpoint(receipt) + + +def execute(ccr, proposal, actual, expected, path, runner=run): + require(actual == expected, 'reviewed_digest_required') + require(ccr['status'] == 'approved', 'approved_ccr_required') + info = path.parent.lstat() + require(path.is_absolute() and path.parent.is_dir() and not path.parent.is_symlink() + and info.st_uid == os.getuid() and not info.st_mode & 0o077, 'private_receipt_parent_required') + fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + receipt = dict(schema='platform.email-connect-smtp-foundation.v1', status='started', + phase='preflight', proposal_digest=actual, started_at=datetime.now(timezone.utc).isoformat(), + mutation_outcome='not_started', email_sent=False, application_sender_changed=False) + def checkpoint(value): + raw = (json.dumps(value, sort_keys=True)+'\n').encode() + while raw: + n = os.write(fd, raw) + if n <= 0: + raise OSError('receipt_write_failed') + raw = raw[n:] + os.fsync(fd) + try: + checkpoint(receipt) + parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(parent) + finally: + os.close(parent) + runner(proposal, receipt, checkpoint) + require(receipt['status'] == 'foundation_admitted_projection_ready', 'success_receipt_required') + result = 0 + except Exception: + receipt.update(status='failed_requires_inspection', reason='contained_foundation_failure') + result = 1 + finally: + key = 'interrupted_at' if receipt['status'] == 'started' else 'completed_at' + receipt[key] = datetime.now(timezone.utc).isoformat() + try: + checkpoint(receipt) + finally: + os.close(fd) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--expect-digest') + parser.add_argument('--receipt', type=Path) + args = parser.parse_args() + ccr, proposal, value = prepare() + if not args.execute: + print(json.dumps(dict(proposal_digest=value, proposal=proposal, ccr_status=ccr['status']), indent=2)) + return 0 + require(args.receipt is not None, 'private_receipt_required') + return execute(ccr, proposal, value, args.expect_digest, args.receipt) + + +if __name__ == '__main__': + if '--execute' in sys.argv: + with open(os.devnull, 'w') as sink: + os.dup2(sink.fileno(), 1); os.dup2(sink.fileno(), 2) + try: + code = main() + except Exception: + code = 1 + sys.exit(code) + sys.exit(main()) diff --git a/tests/test_email_connect_smtp_foundation.py b/tests/test_email_connect_smtp_foundation.py new file mode 100644 index 0000000..5fdd921 --- /dev/null +++ b/tests/test_email_connect_smtp_foundation.py @@ -0,0 +1,262 @@ +"""Exercise the attended controller against synthetic Kubernetes/Bao boundaries.""" +import copy +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +import email_connect_smtp_foundation as foundation + + +class Native: + def __init__(self, proposal): + self.bundle = proposal['foundation'] + self.created = [] + self.objects = {} + self.bad_cluster = False + self.bad_binding = False + self.target_exists = False + self.fail_kind = None + self.not_ready = False + self.drift = False + self.changed_application = False + self.bad_ownership = False + + def get(self, kind, name, namespace=foundation.NS): + if kind == 'namespace': + key = 'cluster_uid' if name == 'kube-system' else 'namespace_uid' + return {'metadata': {'uid': 'wrong' if self.bad_cluster else self.bundle[key]}} + if kind == 'deployment': + return {'metadata': {'uid': self.bundle['deployment_uid'], 'generation': 74}, + 'spec': {'unchanged': not (self.created and self.changed_application)}} + if name in ('email-connect-config', 'email-connect-runtime'): + return {'metadata': {'uid': name}, 'spec': {}, 'data': {}} + return copy.deepcopy(self.objects.get((kind, name))) + + def target_metadata(self, name): + if self.target_exists: + return {'uid': 'foreign'} + if 'ExternalSecret' in self.created: + return {'ownerReferences': [{'uid': 'wrong' if self.bad_ownership else 'uid-ExternalSecret', 'controller': True}]} + return None + + def create(self, wanted): + kind = wanted['kind'] + self.created.append(kind) + if self.fail_kind == kind: + raise RuntimeError('synthetic provider secret text') + obj = copy.deepcopy(wanted) + obj['metadata']['uid'] = 'uid-'+kind + obj['status'] = {'conditions': [{'type': 'Ready', 'status': 'False' if self.not_ready else 'True'}], + 'refreshTime': '2026-10-31T12:00:00Z'} + if self.drift and kind == 'SecretStore': + obj['spec']['provider']['vault']['path'] = 'wrong' + self.objects[(kind, wanted['metadata']['name'])] = obj + return copy.deepcopy(obj) + + def jwt(self, *args): + return 'fixture-jwt' + + def token_review(self, jwt): + return {'authenticated': not self.bad_binding, 'audiences': ['openbao'], + 'user': {'username': 'system:serviceaccount:email-connect:'+foundation.SA, 'uid': 'uid-ServiceAccount'}} + + def wait(self, resource): + pass + + +def completed(value, code=0): + return subprocess.CompletedProcess([], code, json.dumps(value).encode(), b'403 permission denied' if code else b'') + + +class FoundationTests(unittest.TestCase): + def setUp(self): + self.ccr, self.proposal, self.digest = foundation.prepare() + self.native = Native(self.proposal) + self.validator = Mock() + self.records = [] + self.owner_calls = [] + + def owner(self, args, **kwargs): + if args[0] == 'status': + return completed({'cluster_id': foundation.admission.OPENBAO_CLUSTER_ID, 'initialized': True, 'sealed': False}) + return completed({'data': {'policies': ['platform-admin']}}) + + def run_controller(self): + approved = copy.deepcopy(self.ccr) + approved['status'] = 'approved' + approved['openbao']['auth']['bound_claims_confirmed'] = True + def admit(row, checkpoint): + self.owner_calls.append('admit') + row.update(status='reader_admitted_verified'); checkpoint(row) + receipt = {'proposal_digest': self.digest, 'status': 'started'} + with patch.object(foundation, 'bao', side_effect=self.owner), \ + patch.object(foundation.admission, 'module', return_value=self.validator), \ + patch.object(foundation.admission, 'prepare', return_value=(approved, {}, self.proposal['admission_digest'])), \ + patch.object(foundation.admission, 'admit', side_effect=admit), \ + patch.object(foundation, 'verify_login', return_value={'native_consumer_auth_verified': True}): + foundation.run(self.proposal, receipt, lambda r: self.records.append(copy.deepcopy(r)), self.native) + return receipt + + def test_offline_proposal_and_live_default_refusal(self): + self.assertEqual(self.ccr['status'], 'proposed') + self.assertEqual(len(self.proposal['foundation']['resources']), 3) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp)/'receipt' + with self.assertRaises(foundation.admission.Refused): + foundation.execute(self.ccr, self.proposal, self.digest, self.digest, path) + self.assertFalse(path.exists()) + + def test_complete_controller_requires_binding_before_admission_and_ready_after(self): + result = self.run_controller() + self.assertEqual(self.native.created, ['ServiceAccount', 'SecretStore', 'ExternalSecret']) + self.validator.confirm_binding.assert_called_once() + self.assertEqual(result['status'], 'foundation_admitted_projection_ready') + self.assertFalse(result['application_sender_changed']) + phases = [r['phase'] for r in self.records] + self.assertLess(phases.index('binding_verified'), phases.index('owner_admission')) + self.assertLess(phases.index('native_login_verification'), phases.index('creating_secretstore')) + + def test_wrong_cluster_and_existing_targets_precede_mutation(self): + for mode in ('cluster', 'target', 'existing'): + with self.subTest(mode=mode): + self.native = Native(self.proposal) + self.native.bad_cluster = mode == 'cluster' + self.native.target_exists = mode == 'target' + if mode == 'existing': + self.native.objects[('SecretStore', 'openbao-email-connect-platform-smtp')] = {'foreign': True} + with self.assertRaises(foundation.admission.Refused): + self.run_controller() + self.assertFalse(self.native.created) + self.assertFalse(self.owner_calls) + + def test_bad_tokenreview_never_confirms_or_admits(self): + self.native.bad_binding = True + with self.assertRaises(foundation.admission.Refused): self.run_controller() + self.assertEqual(self.native.created, ['ServiceAccount']) + self.validator.confirm_binding.assert_not_called() + self.assertFalse(self.owner_calls) + + def test_projection_drift_not_ready_foreign_owner_and_application_changes_refuse(self): + for field in ('drift', 'not_ready', 'bad_ownership', 'changed_application'): + with self.subTest(field=field): + self.native = Native(self.proposal); setattr(self.native, field, True) + with self.assertRaises(foundation.admission.Refused): self.run_controller() + self.assertFalse(any(r.get('status') == 'foundation_admitted_projection_ready' for r in self.records)) + + def test_creation_failure_keeps_checkpoint_and_does_not_rollback_or_retry(self): + self.native.fail_kind = 'SecretStore' + with self.assertRaises(RuntimeError): self.run_controller() + self.assertEqual(self.native.created, ['ServiceAccount', 'SecretStore']) + self.assertEqual(self.records[-1]['phase'], 'creating_secretstore') + self.assertEqual(self.records[-1]['mutation_outcome'], 'unconfirmed') + + def test_journal_failure_silent_private_and_exclusive(self): + self.ccr['status'] = 'approved' + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp)/'receipt' + def fail(proposal, receipt, checkpoint): + receipt.update(phase='creating_service_account', mutation_outcome='unconfirmed') + checkpoint(receipt) + raise RuntimeError('synthetic private provider text') + self.assertEqual(foundation.execute(self.ccr, self.proposal, self.digest, self.digest, path, fail), 1) + raw = path.read_bytes() + rows = [json.loads(line) for line in raw.splitlines()] + self.assertEqual(rows[-1]['status'], 'failed_requires_inspection') + self.assertEqual(rows[-1]['phase'], 'creating_service_account') + self.assertNotIn(b'private provider', raw) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + with self.assertRaises(FileExistsError): + foundation.execute(self.ccr, self.proposal, self.digest, self.digest, path, fail) + self.assertEqual(path.read_bytes(), raw) + + def test_interruption_cannot_be_success(self): + self.ccr['status'] = 'approved' + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp)/'receipt' + def interrupt(*args): raise SystemExit(9) + with self.assertRaises(SystemExit): + foundation.execute(self.ccr, self.proposal, self.digest, self.digest, path, interrupt) + last = json.loads(path.read_text().splitlines()[-1]) + self.assertEqual(last['status'], 'started') + self.assertIn('interrupted_at', last) + + def test_native_login_scope_denials_and_all_tokens_revoked(self): + calls = [] + def bao(args, **kw): + calls.append((args, kw)) + if args[:2] == ['write', '-format=json']: + # The fake JWT identifies the requested service account/audience. + if kw['payload']['jwt'] != foundation.SA+':openbao': return completed({}, 1) + return completed({'auth': {'client_token': 'fixture', 'token_policies': [foundation.SA], 'lease_duration': 900}}) + if args[0] == 'read': + return completed({'data': {'data': dict(foundation.admission.lane_module().FIELDS, SMTP_PASSWORD='fixture'), 'metadata': {'version': 2}}}) + if args[0] == 'write': return completed({}) + return completed({}, 1) + def caps(token, paths): + return dict(zip(paths, [['read'], ['deny'], ['deny'], ['deny'], ['read'], ['update']])) + native = Mock() + native.jwt.side_effect = lambda sa=foundation.SA, audience='openbao': sa+':'+audience + with patch.object(foundation, 'bao', side_effect=bao), patch.object(foundation, 'capabilities', side_effect=caps): + result = foundation.verify_login(native) + self.assertTrue(result['other_service_account_denied']) + self.assertTrue(result['workload_token_revocation_verified']) + self.assertEqual(sum(a == ['write', '-f', 'auth/token/revoke-self'] for a, _ in calls), 1) + + def test_wrong_digest_and_cli_refusal_are_before_receipt_or_owner_calls(self): + self.ccr['status'] = 'approved' + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp)/'receipt' + with self.assertRaises(foundation.admission.Refused): + foundation.execute(self.ccr, self.proposal, self.digest, 'wrong', path) + result = subprocess.run([sys.executable, str(Path(foundation.__file__)), '--execute', + '--expect-digest', 'wrong', '--receipt', str(path)], capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((result.stdout, result.stderr), (b'', b'')) + self.assertFalse(path.exists()) + + def test_metadata_query_negotiates_partial_object_and_cleans_proxy(self): + for status, payload, refuses in [ + (404, {}, False), + (200, {'kind': 'PartialObjectMetadata', 'apiVersion': 'meta.k8s.io/v1', 'metadata': {'uid': 'fixture'}}, False), + (200, {'kind': 'Secret', 'apiVersion': 'v1', 'data': {'fixture': 'must-not-be-accepted'}}, True), + (403, {}, True), + ]: + with self.subTest(status=status, refuses=refuses): + response = io.BytesIO(json.dumps(payload).encode()); response.status = status + process = Mock(); process.poll.return_value = None + conn = Mock(); conn.getresponse.return_value = response + with patch.object(foundation.subprocess, 'Popen', return_value=process), \ + patch.object(foundation.socket, 'socket'), \ + patch.object(foundation.http.client, 'HTTPConnection', return_value=conn), \ + patch.object(foundation.time, 'sleep'): + if refuses: + with self.assertRaises(foundation.admission.Refused): + foundation.Native().target_metadata('email-connect-platform-smtp') + else: + result = foundation.Native().target_metadata('email-connect-platform-smtp') + self.assertEqual(result, None if status == 404 else {'uid': 'fixture'}) + self.assertEqual(conn.request.call_args.kwargs['headers']['Accept'], + 'application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1') + process.terminate.assert_called_once(); process.wait.assert_called_once() + conn.close.assert_called_once() + + def test_native_scope_failure_still_revokes_workload_token(self): + calls = [] + def bao(args, **kwargs): + calls.append(args) + if args[:2] == ['write', '-format=json']: + return completed({'auth': {'client_token': 'fixture', 'token_policies': ['too-broad'], 'lease_duration': 900}}) + if args[:2] == ['write', '-f']: return completed({}) + return completed({}, 1) + with patch.object(foundation, 'bao', side_effect=bao): + with self.assertRaises(foundation.admission.Refused): foundation.verify_login(Mock()) + self.assertIn(['write', '-f', 'auth/token/revoke-self'], calls) + + +if __name__ == '__main__': unittest.main() diff --git a/workplans/RPF-WP-0068-email-connect-platform-smtp.md b/workplans/RPF-WP-0068-email-connect-platform-smtp.md index 9f2f23e..6947e66 100644 --- a/workplans/RPF-WP-0068-email-connect-platform-smtp.md +++ b/workplans/RPF-WP-0068-email-connect-platform-smtp.md @@ -39,7 +39,7 @@ Evidence: docs/evidence/email-connect-smtp-admission-preparation-2026-10-31.json ```task id: RPF-WP-0068-T02 -status: todo +status: done priority: high state_hub_task_id: "de0ec1f9-cc11-593f-88c5-5a3972969f01" ``` @@ -54,6 +54,16 @@ readback after admission without printing values or reading Kubernetes Secrets. Use coulomb-social/docs/release/platform-mail-sender-2026-10-31/candidate.json. Do not deploy the application sender here or reuse an existing consumer's grant. +Preparation completed 2026-10-31: email_connect_smtp_foundation.py binds the +server-defaulted three-object bundle and pinned primary/namespace/application +identities. It performs native TokenReview before canonical binding confirmation, +guarded owner admission, positive/negative workload login with token revocation, +exclusive projection creation and Ready/ownership checks. PartialObjectMetadata +queries avoid Secret values; fresh native query confirms target absent. All41 +SMTP tests pass (12 foundation cases). No live creation/admission executed. +Native execution results belong to T03/T04, not this preparation completion. +Exact packet: docs/email-connect-smtp-foundation-review-2026-10-31.md. + ## T03 — Obtain exact admission and execute through the attended owner lane ```task