diff --git a/Containerfile b/Containerfile index caa95c8..aeda084 100644 --- a/Containerfile +++ b/Containerfile @@ -20,5 +20,5 @@ RUN rm -rf /usr/local/bin/pip* /usr/local/lib/python3.12/site-packages/pip* \ /opt/venv/lib/python3.12/site-packages/setuptools* /opt/venv/lib/python3.12/site-packages/pkg_resources WORKDIR /app USER 10001:10001 -EXPOSE 8080 +EXPOSE 8080 8081 ENTRYPOINT ["informed-decision-container"] diff --git a/SCOPE.md b/SCOPE.md index c4cb528..ee9516f 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -308,3 +308,15 @@ implementation, not a deployed intake listener. Attribute contracts, forwarding, workload custody, private listener composition and live acceptance are tracked as blocked tasks in INFD-WP-0005, with acceptance requirements in `docs/intake-dependency-requirements.md`. + +## Initial lifecycle production candidate — 2026-10-04 + +The private workload listener is now composed and tested with CA-verified +Kubernetes TokenReview, rotating projections and a shared single-writer runtime. +Initial audience is net-kingdom-admins humans plus the exact State Hub service +account. Attribute review now verifies native requester/human-control and receipt +pins before policy evaluation. FLEX-WP-0033 supplies separately pinned lifecycle +and v12 review policy candidates. Published immutable artifacts, security scans, +server dry-runs and recovery steps are retained in `deploy/lifecycle/README.md`. +These changes are not deployed. Requester custody, production State Hub forwarding +and native acceptance remain live tasks in INFD-WP-0005/0007 and RPF-WP-0051. diff --git a/deploy/README.md b/deploy/README.md index 2e169d0..9685243 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -20,8 +20,8 @@ Engine, installs the hashed `requirements.lock`, then installs the built wheel without dependency resolution. The image contains no pip or build installer. Its user/group is 10001; runtime deployments must use a read-only root filesystem and drop capabilities. Rebuild the wheel before building the image. Refresh -dependency pins deliberately with `uv pip compile pyproject.toml --python-version -3.12 --generate-hashes --output-file requirements.lock`, then repeat checks and +dependency pins deliberately with `uv lock` followed by `uv export --no-dev --no-emit-project --format +requirements-txt --output-file requirements.lock`, then repeat checks and the image scan. A successful local build is not a published registry digest. The dated evidence pins the exact wheel/image/runtime-file hashes and scanner. diff --git a/deploy/lifecycle/README.md b/deploy/lifecycle/README.md new file mode 100644 index 0000000..bad3f91 --- /dev/null +++ b/deploy/lifecycle/README.md @@ -0,0 +1,96 @@ +# INFD-WP-0007 production candidate + +Prepared 2026-10-04 for the founder-selected initial audience: net-kingdom-admins +humans and system:serviceaccount:state-hub:state-hub. **Not deployed.** + +| Artifact | Immutable reference | +| --- | --- | +| Service | forgejo.coulomb.social/coulomb/informed-decision@sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d | +| Access Engine | forgejo.coulomb.social/coulomb/flex-auth@sha256:f6ed8af69af766d7e9498465a23c292d91185b79352065736a2132216aac1d37 | +| Lifecycle package | informed-decision.lifecycle v1, sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827 | +| Human review package | informed-decision.compact-sitting v12, sha256:c5387f7d198dc39ab5967e0c218ce05cc779693565c8875e3035e45ec002a2db | + +`admission.candidate.json` renders `service.candidate.json` using +`tools/render_deployment.py`. `registries.candidate.json` projects the three +sibling JSON registries. The policy YAML is Helm-rendered from Access Engine's +FLEX-WP-0033 candidate values; it is a review artifact, not a second deployment +owner. Existing Helm releases must be updated through their owning workflow. +All 25 proposed objects passed Kubernetes server dry-run against railiance01. +No Secret is included. Dry-run does not test reachability, credential delivery, +policy caller identity, backup restoration or a human disposition. + +## Proposed live changes + +- Replace the service's image and immutable runtime ConfigMap, retaining its + public Service/Ingress, single writer, Recreate strategy and existing PVC. +- Enable `/decisions` and lifecycle audit delivery; keep private API on 8081. +- Add the exact State Hub peer, API-server egress, CA/token projection and only + create-tokenreviews RBAC for informed-decision/review. +- Mount the proposed reloadable registries and Platform's exact read-only + requester Secret projection, without subPath. +- Add a separate lifecycle PDP and change the sitting PDP to the v12 package. + Review retains all v11 exact mandates and 15-minute KeyCape MFA; lifecycle + authoring uses a 12-hour MFA window. Agents cannot accept. +- Initial intake permits decision-record / net-kingdom-admins, 50 new attempts + per UTC day, with the existing platform-root recipient. Other admins may + collaborate; this does not silently make them the bound recipient. + +The policy chart retains the owner's existing port-only API-server egress +(443/6443). The consumer's new API-server egress is address-specific. CARING is +advisory and consumer fact attribution is unsigned, as in the existing reviewed +contract; neither is presented as a newly enforced control. + +## Pre-cutover gates + +1. RPF-WP-0051 / CCR-2026-0032: complete the already prepared requester delivery + under its separate live go, with exact identity/path negative proofs and + mounted-file refresh/rotation evidence. Only the audit ExternalSecret was + present in the live namespace at inspection. +2. INFD-WP-0005-T08: State Hub owner supplies production forwarding and separate + lifecycle/intake audience token projections. Its current dev fixture is not + an activated forwarder. Confirm notification receiver reachability; keep + resolution write-back disabled until its owner admission. +3. Refresh live image/config pins and unresolved-entry counts; complete the + backup/restore steps below; obtain explicit approval of this concrete rollout. + INFD-WP-0007-T07 and CCR-2026-0032 require this distinct go. + +## Cutover and recovery procedure + +Before migrating, close new review traffic through the owning ingress workflow, +let active requests finish, and inspect the current schema-2 writer read-only. +The observed baseline was 24 confirmed submissions, 158 delivered outbox rows, +zero unresolved entries; these counts are not a future maintenance-window gate. +Retain the exact current Deployment, Service, runtime ConfigMap, Helm values and +all related policy pins. Do not include credentials in the evidence packet. + +Use the existing running image's installed `informed-decision-admin backup` to +create a unique SQLite consistent snapshot in a private directory. Copy the +backup to independently protected custody, record its digest, and verify a +restore on a separate private volume before cutover. Close the old writer before +starting the schema-5 image. Local synthetic restore proof is retained in the +evidence directory; it does not replace this production data restoration check. + +Deploy admitted owner policy/custody prerequisites, then the service candidate. +Verify `/healthz`, `/readyz`, pending/blocked counts, actual audience isolation, +wrong-caller refusals and public/private route separation. Exercise two new +records and a revision without changing image/config/package pins. Only a human +may finish review. Correlate the native Approval Engine entry and independent +Audit Core receipts; restart and retrieve the same immutable evidence. + +If validation fails, stop the candidate and retain its database plus all new +receipts. **Never start the old schema-2 image on the schema-5 database.** Rollback +requires the verified pre-cutover copy on a separate volume and reconciliation +of every post-cutover external effect before resuming writes. An uncertain +submission stays unresolved; do not retry or discard it to enable rollback. +The prior image is in ../sitting-admission.json and remains a reference, not an +instruction to downgrade the live database. + +## Validation evidence + +`docs/evidence/2026-10-04-lifecycle-release/` retains container checks, bounded +scan summaries and policy validation. Service: 662 passed, 1 skipped with owner +components; 13 container checks passed, including restart and backup/restore. +Both artifact scans contain zero HIGH/CRITICAL findings. Actual evaluator tests +cover first-audience collaboration across revisions and verify unchanged package +pins; authentication custody in those tests is synthetic. Native activation and +broader browser packet/binding profiles remain explicit workplan tasks. diff --git a/deploy/lifecycle/admission.candidate.json b/deploy/lifecycle/admission.candidate.json new file mode 100644 index 0000000..350a7de --- /dev/null +++ b/deploy/lifecycle/admission.candidate.json @@ -0,0 +1,52 @@ +{ + "image": "forgejo.coulomb.social/coulomb/informed-decision@sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d", + "policy": { + "origin": "http://flex-auth-informed-decision-sitting.flex-auth.svc.cluster.local:8080", + "package": "informed-decision.compact-sitting", + "version": "v12", + "package_digest": "sha256:c5387f7d198dc39ab5967e0c218ce05cc779693565c8875e3035e45ec002a2db", + "pod_name": "flex-auth-informed-decision-sitting" + }, + "keycape_egress_ips": [ + "92.205.62.239" + ], + "storage_class": "local-path", + "ordinary_approval_ids": [ + "2ce76d7f-01c3-4b63-8476-8d1230679769", + "c2af4bcf-15b4-4305-aac1-acc7e4dcb099", + "dc22666a-d5d7-46bc-9490-ebe9fe09dc38" + ], + "lifecycle_policy": { + "package": "informed-decision.lifecycle", + "version": "v1", + "package_digest": "sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827", + "origin": "http://flex-auth-informed-decision-lifecycle.flex-auth.svc.cluster.local:8080", + "pod_name": "flex-auth-informed-decision-lifecycle" + }, + "lifecycle_delivery": true, + "intake_attributes": true, + "intake_delivery": { + "hub_origin": "http://state-hub.state-hub.svc.cluster.local:8000" + }, + "workload_api": { + "registries_configmap": "informed-decision-workload-registry", + "peers": [ + { + "namespace": "state-hub", + "app": "state-hub" + } + ], + "api_egress": [ + { + "ip": "10.43.0.1", + "port": 443 + }, + { + "ip": "92.205.62.239", + "port": 6443 + } + ], + "intake": true, + "lifecycle": true + } +} diff --git a/deploy/lifecycle/intake-submitters.json b/deploy/lifecycle/intake-submitters.json new file mode 100644 index 0000000..5972773 --- /dev/null +++ b/deploy/lifecycle/intake-submitters.json @@ -0,0 +1,12 @@ +{ + "system:serviceaccount:state-hub:state-hub": { + "submitter_id": "state-hub", + "memo_classes": [ + "decision-record" + ], + "review_groups": [ + "net-kingdom-admins" + ], + "daily_limit": 50 + } +} diff --git a/deploy/lifecycle/lifecycle-callers.json b/deploy/lifecycle/lifecycle-callers.json new file mode 100644 index 0000000..ccde839 --- /dev/null +++ b/deploy/lifecycle/lifecycle-callers.json @@ -0,0 +1,5 @@ +{ + "system:serviceaccount:state-hub:state-hub": { + "tenant": "tenant:platform" + } +} diff --git a/deploy/lifecycle/policy.candidate.yaml b/deploy/lifecycle/policy.candidate.yaml new file mode 100644 index 0000000..193e6b5 --- /dev/null +++ b/deploy/lifecycle/policy.candidate.yaml @@ -0,0 +1,189 @@ +--- +# Source: flex-auth/templates/networkpolicy.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: flex-auth-informed-decision-lifecycle + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + policyTypes: + - Ingress + - Egress + egress: + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: informed-decision + podSelector: + matchLabels: + app.kubernetes.io/name: informed-decision + ports: + - port: 8080 + protocol: TCP +--- +# Source: flex-auth/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: flex-auth-informed-decision-lifecycle + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +automountServiceAccountToken: false +--- +# Source: flex-auth/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: flex-auth-informed-decision-lifecycle-tokenreviewer + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +--- +# Source: flex-auth/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: flex-auth-informed-decision-lifecycle-tokenreviewer + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: flex-auth-informed-decision-lifecycle-tokenreviewer +subjects: + - kind: ServiceAccount + name: flex-auth-informed-decision-lifecycle + namespace: flex-auth +--- +# Source: flex-auth/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: flex-auth-informed-decision-lifecycle + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +spec: + selector: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + ports: + - name: http + port: 8080 + targetPort: http +--- +# Source: flex-auth/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: flex-auth-informed-decision-lifecycle + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + template: + metadata: + labels: + app.kubernetes.io/name: flex-auth-informed-decision-lifecycle + app.kubernetes.io/instance: flex-auth-informed-decision-lifecycle + app.kubernetes.io/part-of: flex-auth + spec: + automountServiceAccountToken: false + serviceAccountName: flex-auth-informed-decision-lifecycle + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: flex-auth + image: forgejo.coulomb.social/coulomb/flex-auth@sha256:f6ed8af69af766d7e9498465a23c292d91185b79352065736a2132216aac1d37 + imagePullPolicy: IfNotPresent + args: + - serve + - --addr + - 0.0.0.0:8080 + - --registry + - /opt/flex-auth/examples/informed-decision-lifecycle/registry.json + - --policy + - /opt/flex-auth/examples/informed-decision-lifecycle/policy.md + - --caller-auth-mode + - "enforce" + - --caller-kubernetes-url + - "https://10.43.0.1" + - --caller-binding + - "informed-decision=system:serviceaccount:informed-decision:review" + ports: + - name: http + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 20 + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 5 + resources: + limits: + cpu: 300m + memory: 192Mi + requests: + cpu: 5m + memory: 32Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + volumeMounts: + - mountPath: /var/run/secrets/flex-auth-reviewer + name: flex-auth-reviewer + readOnly: true + volumes: + - name: flex-auth-reviewer + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: + expirationSeconds: 3600 + path: token + - configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: ca.crt diff --git a/deploy/lifecycle/registries.candidate.json b/deploy/lifecycle/registries.candidate.json new file mode 100644 index 0000000..60ad67b --- /dev/null +++ b/deploy/lifecycle/registries.candidate.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "informed-decision-workload-registry", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "proposal; INFD-WP-0007 concrete rollout approval required" + } + }, + "data": { + "lifecycle-callers.json": "{\n \"system:serviceaccount:state-hub:state-hub\": {\n \"tenant\": \"tenant:platform\"\n }\n}\n", + "intake-submitters.json": "{\n \"system:serviceaccount:state-hub:state-hub\": {\n \"submitter_id\": \"state-hub\",\n \"memo_classes\": [\n \"decision-record\"\n ],\n \"review_groups\": [\n \"net-kingdom-admins\"\n ],\n \"daily_limit\": 50\n }\n}\n", + "review-groups.json": "{\n \"net-kingdom-admins\": {\n \"subject\": \"uid=platform-root,ou=people,dc=netkingdom,dc=local\",\n \"display_name\": \"Platform reviewer\"\n }\n}\n" + } +} diff --git a/deploy/lifecycle/review-groups.json b/deploy/lifecycle/review-groups.json new file mode 100644 index 0000000..23d57ff --- /dev/null +++ b/deploy/lifecycle/review-groups.json @@ -0,0 +1,6 @@ +{ + "net-kingdom-admins": { + "subject": "uid=platform-root,ou=people,dc=netkingdom,dc=local", + "display_name": "Platform reviewer" + } +} diff --git a/deploy/lifecycle/review-policy.candidate.yaml b/deploy/lifecycle/review-policy.candidate.yaml new file mode 100644 index 0000000..324795a --- /dev/null +++ b/deploy/lifecycle/review-policy.candidate.yaml @@ -0,0 +1,189 @@ +--- +# Source: flex-auth/templates/networkpolicy.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: flex-auth-informed-decision-sitting + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + policyTypes: + - Ingress + - Egress + egress: + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: informed-decision + podSelector: + matchLabels: + app.kubernetes.io/name: informed-decision + ports: + - port: 8080 + protocol: TCP +--- +# Source: flex-auth/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: flex-auth-informed-decision-sitting + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +automountServiceAccountToken: false +--- +# Source: flex-auth/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: flex-auth-informed-decision-sitting-tokenreviewer + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +--- +# Source: flex-auth/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: flex-auth-informed-decision-sitting-tokenreviewer + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: flex-auth-informed-decision-sitting-tokenreviewer +subjects: + - kind: ServiceAccount + name: flex-auth-informed-decision-sitting + namespace: flex-auth +--- +# Source: flex-auth/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: flex-auth-informed-decision-sitting + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +spec: + selector: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + ports: + - name: http + port: 8080 + targetPort: http +--- +# Source: flex-auth/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: flex-auth-informed-decision-sitting + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + template: + metadata: + labels: + app.kubernetes.io/name: flex-auth-informed-decision-sitting + app.kubernetes.io/instance: flex-auth-informed-decision-sitting + app.kubernetes.io/part-of: flex-auth + spec: + automountServiceAccountToken: false + serviceAccountName: flex-auth-informed-decision-sitting + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: flex-auth + image: forgejo.coulomb.social/coulomb/flex-auth@sha256:f6ed8af69af766d7e9498465a23c292d91185b79352065736a2132216aac1d37 + imagePullPolicy: IfNotPresent + args: + - serve + - --addr + - 0.0.0.0:8080 + - --registry + - /opt/flex-auth/examples/informed-decision-lifecycle-review/registry.json + - --policy + - /opt/flex-auth/examples/informed-decision-lifecycle-review/policy.md + - --caller-auth-mode + - "enforce" + - --caller-kubernetes-url + - "https://10.43.0.1" + - --caller-binding + - "informed-decision=system:serviceaccount:informed-decision:review" + ports: + - name: http + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 20 + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 5 + resources: + limits: + cpu: 300m + memory: 192Mi + requests: + cpu: 5m + memory: 32Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + volumeMounts: + - mountPath: /var/run/secrets/flex-auth-reviewer + name: flex-auth-reviewer + readOnly: true + volumes: + - name: flex-auth-reviewer + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: + expirationSeconds: 3600 + path: token + - configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: ca.crt diff --git a/deploy/lifecycle/service.candidate.json b/deploy/lifecycle/service.candidate.json new file mode 100644 index 0000000..fd07dfa --- /dev/null +++ b/deploy/lifecycle/service.candidate.json @@ -0,0 +1,800 @@ +{ + "apiVersion": "v1", + "kind": "List", + "items": [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": "review", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "automountServiceAccountToken": false + }, + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "name": "informed-decision-data", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "storageClassName": "local-path", + "resources": { + "requests": { + "storage": "1Gi" + } + } + } + }, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "informed-decision-runtime-92d01402eb05", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "immutable": true, + "data": { + "runtime.json": "{\"approval_origin\":\"http://approval-engine.approval-engine.svc.cluster.local:8080\",\"audit\":{\"origin\":\"http://audit-core.audit-core.svc.cluster.local:8080\",\"sender_token_file\":\"/var/run/secrets/informed-decision/audit/token\"},\"evidence_db\":\"/data/private/review.sqlite\",\"intake_attributes\":true,\"intake_delivery\":{\"hub_origin\":\"http://state-hub.state-hub.svc.cluster.local:8000\"},\"lifecycle_delivery\":true,\"lifecycle_policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-lifecycle.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.lifecycle\",\"package_digest\":\"sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827\",\"version\":\"v1\"},\"ordinary_approval_ids\":[\"2ce76d7f-01c3-4b63-8476-8d1230679769\",\"c2af4bcf-15b4-4305-aac1-acc7e4dcb099\",\"dc22666a-d5d7-46bc-9490-ebe9fe09dc38\"],\"policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-sitting.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.compact-sitting\",\"package_digest\":\"sha256:c5387f7d198dc39ab5967e0c218ce05cc779693565c8875e3035e45ec002a2db\",\"version\":\"v12\"},\"schema\":\"informed-decision.review-runtime.v1\",\"workload_api\":{\"intake\":{\"groups_file\":\"/workload-registry/review-groups.json\",\"secret_file\":\"/var/run/secrets/informed-decision/requester/client-secret\",\"submitters_file\":\"/workload-registry/intake-submitters.json\"},\"lifecycle_callers_file\":\"/workload-registry/lifecycle-callers.json\",\"token_review\":{\"ca_file\":\"/var/run/secrets/informed-decision/kubernetes/ca.crt\",\"origin\":\"https://kubernetes.default.svc:443\",\"token_file\":\"/var/run/secrets/informed-decision/kubernetes/token\"}}}" + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "informed-decision", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "spec": { + "replicas": 1, + "strategy": { + "type": "Recreate" + }, + "progressDeadlineSeconds": 600, + "selector": { + "matchLabels": { + "app.kubernetes.io/name": "informed-decision" + } + }, + "template": { + "metadata": { + "labels": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + } + }, + "spec": { + "automountServiceAccountToken": false, + "serviceAccountName": "review", + "terminationGracePeriodSeconds": 45, + "securityContext": { + "runAsNonRoot": true, + "runAsUser": 10001, + "runAsGroup": 10001, + "fsGroup": 10001, + "fsGroupChangePolicy": "OnRootMismatch", + "seccompProfile": { + "type": "RuntimeDefault" + } + }, + "containers": [ + { + "name": "informed-decision", + "image": "forgejo.coulomb.social/coulomb/informed-decision@sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d", + "securityContext": { + "allowPrivilegeEscalation": false, + "readOnlyRootFilesystem": true, + "capabilities": { + "drop": [ + "ALL" + ] + } + }, + "env": [ + { + "name": "INFD_KEYCAPE_ISSUER", + "value": "https://kc.coulomb.social" + }, + { + "name": "INFD_CONTAINER_CONFIG", + "value": "/configuration/runtime.json" + } + ], + "ports": [ + { + "name": "http", + "containerPort": 8080 + }, + { + "name": "workload", + "containerPort": 8081 + } + ], + "startupProbe": { + "httpGet": { + "path": "/healthz", + "port": "http" + }, + "periodSeconds": 10, + "timeoutSeconds": 2, + "failureThreshold": 12 + }, + "readinessProbe": { + "httpGet": { + "path": "/healthz", + "port": "http" + }, + "periodSeconds": 10, + "timeoutSeconds": 2 + }, + "livenessProbe": { + "httpGet": { + "path": "/healthz", + "port": "http" + }, + "periodSeconds": 10, + "timeoutSeconds": 2 + }, + "resources": { + "requests": { + "cpu": "5m", + "memory": "64Mi" + }, + "limits": { + "cpu": "500m", + "memory": "256Mi" + } + }, + "volumeMounts": [ + { + "name": "data", + "mountPath": "/data" + }, + { + "name": "runtime", + "mountPath": "/run/informed-decision" + }, + { + "name": "tmp", + "mountPath": "/tmp" + }, + { + "name": "configuration", + "mountPath": "/configuration", + "readOnly": true + }, + { + "name": "caller", + "mountPath": "/var/run/secrets/informed-decision/policy", + "readOnly": true + }, + { + "name": "audit", + "mountPath": "/var/run/secrets/informed-decision/audit", + "readOnly": true + }, + { + "name": "kubernetes-api", + "mountPath": "/var/run/secrets/informed-decision/kubernetes", + "readOnly": true + }, + { + "name": "workload-registry", + "mountPath": "/workload-registry", + "readOnly": true + }, + { + "name": "sitting-requester", + "mountPath": "/var/run/secrets/informed-decision/requester", + "readOnly": true + } + ] + } + ], + "volumes": [ + { + "name": "data", + "persistentVolumeClaim": { + "claimName": "informed-decision-data" + } + }, + { + "name": "runtime", + "emptyDir": { + "medium": "Memory", + "sizeLimit": "16Mi" + } + }, + { + "name": "tmp", + "emptyDir": { + "medium": "Memory", + "sizeLimit": "16Mi" + } + }, + { + "name": "configuration", + "configMap": { + "name": "informed-decision-runtime-92d01402eb05", + "defaultMode": 292 + } + }, + { + "name": "caller", + "projected": { + "defaultMode": 288, + "sources": [ + { + "serviceAccountToken": { + "path": "token", + "audience": "flex-auth", + "expirationSeconds": 3600 + } + } + ] + } + }, + { + "name": "audit", + "secret": { + "secretName": "informed-decision-audit", + "defaultMode": 288, + "items": [ + { + "key": "token", + "path": "token" + } + ] + } + }, + { + "name": "kubernetes-api", + "projected": { + "defaultMode": 288, + "sources": [ + { + "serviceAccountToken": { + "path": "token", + "expirationSeconds": 600 + } + }, + { + "configMap": { + "name": "kube-root-ca.crt", + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ] + } + } + ] + } + }, + { + "name": "workload-registry", + "configMap": { + "name": "informed-decision-workload-registry", + "defaultMode": 292 + } + }, + { + "name": "sitting-requester", + "secret": { + "secretName": "informed-decision-sitting-requester", + "defaultMode": 288, + "optional": false, + "items": [ + { + "key": "client-secret", + "path": "client-secret" + } + ] + } + } + ] + } + } + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "informed-decision", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + }, + "ports": [ + { + "name": "http", + "port": 80, + "targetPort": "http", + "protocol": "TCP" + } + ] + } + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "informed-decision-review", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "spec": { + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + } + }, + "policyTypes": [ + "Ingress", + "Egress" + ], + "ingress": [ + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "kube-system" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "traefik" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8080 + } + ] + }, + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "state-hub" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "state-hub" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8081 + } + ] + } + ], + "egress": [ + { + "to": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "kube-system" + } + }, + "podSelector": { + "matchLabels": { + "k8s-app": "kube-dns" + } + } + } + ], + "ports": [ + { + "protocol": "UDP", + "port": 53 + }, + { + "protocol": "TCP", + "port": 53 + } + ] + }, + { + "to": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "approval-engine" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "approval-engine" + } + } + }, + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "audit-core" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "audit-core" + } + } + }, + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "flex-auth" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "flex-auth-informed-decision-sitting" + } + } + }, + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "flex-auth" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "flex-auth-informed-decision-lifecycle" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8080 + } + ] + }, + { + "to": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "kube-system" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "traefik" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8443 + } + ] + }, + { + "to": [ + { + "ipBlock": { + "cidr": "92.205.62.239/32" + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 443 + } + ] + }, + { + "to": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "state-hub" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "state-hub" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8000 + } + ] + }, + { + "to": [ + { + "ipBlock": { + "cidr": "10.43.0.1/32" + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 443 + } + ] + }, + { + "to": [ + { + "ipBlock": { + "cidr": "92.205.62.239/32" + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 6443 + } + ] + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "informed-decision-workload", + "namespace": "informed-decision", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + }, + "ports": [ + { + "name": "workload", + "port": 8081, + "targetPort": "workload", + "protocol": "TCP" + } + ] + } + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": { + "name": "informed-decision-tokenreview", + "annotations": { + "informed-decision.coulomb.social/admission": "proposal; INFD-WP-0007/INFD-WP-0005 owner review required" + } + }, + "rules": [ + { + "apiGroups": [ + "authentication.k8s.io" + ], + "resources": [ + "tokenreviews" + ], + "verbs": [ + "create" + ] + } + ] + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": { + "name": "informed-decision-tokenreview", + "annotations": { + "informed-decision.coulomb.social/admission": "proposal; INFD-WP-0007/INFD-WP-0005 owner review required" + } + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "informed-decision-tokenreview" + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": "review", + "namespace": "informed-decision" + } + ] + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "informed-decision-review-ingress", + "namespace": "approval-engine", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08", + "informed-decision.coulomb.social/owner-review": "APPROVAL-WP-0002-T01" + } + }, + "spec": { + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "approval-engine" + } + }, + "policyTypes": [ + "Ingress" + ], + "ingress": [ + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "informed-decision" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8080 + } + ] + } + ] + } + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "informed-decision-review-ingress", + "namespace": "flex-auth", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08", + "informed-decision.coulomb.social/owner-review": "INFD-WP-0001-T08 policy-owner return" + } + }, + "spec": { + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "flex-auth-informed-decision-sitting" + } + }, + "policyTypes": [ + "Ingress" + ], + "ingress": [ + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "informed-decision" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8080 + } + ] + } + ] + } + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "informed-decision-lifecycle-ingress", + "namespace": "flex-auth", + "annotations": { + "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08", + "informed-decision.coulomb.social/owner-review": "INFD-WP-0007 lifecycle policy-owner return" + } + }, + "spec": { + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "flex-auth-informed-decision-lifecycle" + } + }, + "policyTypes": [ + "Ingress" + ], + "ingress": [ + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "informed-decision" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "informed-decision", + "app.kubernetes.io/component": "review" + } + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8080 + } + ] + } + ] + } + } + ] +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/container.json b/docs/evidence/2026-10-04-lifecycle-release/container.json new file mode 100644 index 0000000..d3272b7 --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/container.json @@ -0,0 +1,32 @@ +{ + "status": "passed", + "image_id": "sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d", + "repo_digests": [ + "informed-decision@sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d" + ], + "checks_passed": 13, + "checks": [ + "installed container entrypoint serves with projected configuration", + "non-root read-only runtime has no external network or published port", + "private config/database modes enforced; package installer absent", + "configured review refuses anonymous access and reports audit-unready status", + "private listener refuses anonymous lifecycle access and exposes no browser routes", + "public listener exposes no private workload route", + "a second writer cannot acquire the active evidence volume", + "synthetic uncertain intent and undelivered evidence remain explicit", + "consistent backup created while serving; overwrite refused", + "SIGTERM closes the serving writer without forced kill", + "restart preserves immutable content and unresolved submission without retry", + "separate restored volume preserves exact content and unresolved state", + "incomplete owner configuration exits before serving" + ], + "fixture_only": true, + "external_network": "none", + "content_snapshot_sha256": "7bcbffb5652f7784de477241c4fcc464f8f5fa26288c44876db87beec964a904", + "native_identity_policy_audit_proven": false, + "published": false, + "deployed": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "cleanup_complete": true +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/lifecycle-policy-validation.json b/docs/evidence/2026-10-04-lifecycle-release/lifecycle-policy-validation.json new file mode 100644 index 0000000..fe1c1f4 --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/lifecycle-policy-validation.json @@ -0,0 +1,88 @@ +{ + "valid": true, + "caring_findings": [ + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed canonical roles", + "fields": [ + "caring.canonical_roles" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed organization relations", + "fields": [ + "caring.organization_relations" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed scopes", + "fields": [ + "caring.scopes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed planes", + "fields": [ + "caring.planes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed capabilities", + "fields": [ + "caring.capabilities" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed exposure modes", + "fields": [ + "caring.exposure_modes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed conditions", + "fields": [ + "caring.conditions" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed restrictions", + "fields": [ + "caring.restrictions" + ] + } + ], + "tests": [ + { + "name": "test_empty_denied", + "passed": true + } + ], + "fixtures": [ + { + "id": "unknown-request-denied", + "passed": true, + "expected": { + "effect": "deny" + }, + "actual": { + "effect": "deny", + "reason": "lifecycle_scope_or_identity_refused" + } + } + ] +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/manifest.json b/docs/evidence/2026-10-04-lifecycle-release/manifest.json new file mode 100644 index 0000000..f402f69 --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/manifest.json @@ -0,0 +1,19 @@ +{ + "date": "2026-10-04", + "status": "prepared-not-deployed", + "server_dry_run": { + "cluster": "railiance01", + "objects": 25, + "passed": true + }, + "sha256": { + "deploy/lifecycle/admission.candidate.json": "e3054aebb2338146b912121198eba0232973574b9f045710abc802e524fe43f6", + "deploy/lifecycle/intake-submitters.json": "58ba5ef87ecfd40bc7f0eaf2beb5e50f0c1f0e47315e79dbfa9e06411a17eba9", + "deploy/lifecycle/lifecycle-callers.json": "0b66382a64bdf81ee75d14a70ba3b19290084922a1e0bc8a873af0f039dc7041", + "deploy/lifecycle/policy.candidate.yaml": "e08fcb494d8d29f45b5fbce949246ce2de28e73a56efd2064f42dbfee8f3d11f", + "deploy/lifecycle/registries.candidate.json": "9bad6db5fbc2178d31b04120016a41830c3552fbb9e2517b718f9bbe87cf649e", + "deploy/lifecycle/review-groups.json": "1d61d2b82230981130936651a7fc4230e06ecaa51416a8a1be2bb2eb6ec4c353", + "deploy/lifecycle/review-policy.candidate.yaml": "6c49a9d520a553778935555351c70b3823b72dd0f1ed3a5383d5a4fc0e5e7998", + "deploy/lifecycle/service.candidate.json": "bf36ecb0160bf893a60fe059a3baf27a63668e2bb7b9f17c9582d6de8e842545" + } +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/policy-scan.json b/docs/evidence/2026-10-04-lifecycle-release/policy-scan.json new file mode 100644 index 0000000..6462967 --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/policy-scan.json @@ -0,0 +1,17 @@ +{ + "scanner": "aquasec/trivy@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969", + "scanner_version": "0.74.0", + "scanned_at": "2026-10-04T19:35:45.454792771Z", + "artifact": "flex-auth:wp0007-candidate", + "image_id": "sha256:f6ed8af69af766d7e9498465a23c292d91185b79352065736a2132216aac1d37", + "severities": [ + "HIGH", + "CRITICAL" + ], + "vulnerabilities": 0, + "full_report_sha256": "e41d19c961f0e0d3a4051362c6769edc3069b4286612239861818d403c16ee6a", + "warnings": [ + "vendor severity selection" + ], + "scope": "candidate artifact scan; not a general security attestation" +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/review-policy-validation.json b/docs/evidence/2026-10-04-lifecycle-release/review-policy-validation.json new file mode 100644 index 0000000..dca2cad --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/review-policy-validation.json @@ -0,0 +1,232 @@ +{ + "valid": true, + "caring_findings": [ + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed canonical roles", + "fields": [ + "caring.canonical_roles" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed organization relations", + "fields": [ + "caring.organization_relations" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed scopes", + "fields": [ + "caring.scopes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed planes", + "fields": [ + "caring.planes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed capabilities", + "fields": [ + "caring.capabilities" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed exposure modes", + "fields": [ + "caring.exposure_modes" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed conditions", + "fields": [ + "caring.conditions" + ] + }, + { + "code": "CARING-POLICY-MISSING-DIMENSION", + "severity": "warning", + "message": "policy package should declare governed restrictions", + "fields": [ + "caring.restrictions" + ] + } + ], + "tests": [ + { + "name": "test_allow_does_not_prove_the_pin", + "passed": true + }, + { + "name": "test_approval_id_malformed", + "passed": true + }, + { + "name": "test_attribute_acts_are_eligible_only_when_sets_admit", + "passed": true + }, + { + "name": "test_bad_memo_id_refused", + "passed": true + }, + { + "name": "test_digest_mismatch", + "passed": true + }, + { + "name": "test_exact_pin_mismatch_is_not_rescued", + "passed": true + }, + { + "name": "test_exact_record_ignores_attribute_context", + "passed": true + }, + { + "name": "test_future_mfa_is_identity_refused", + "passed": true + }, + { + "name": "test_human_control_false", + "passed": true + }, + { + "name": "test_initial_release_allows_only_decision_record_class", + "passed": true + }, + { + "name": "test_initial_release_sets", + "passed": true + }, + { + "name": "test_list_does_not_use_attribute_admission", + "passed": true + }, + { + "name": "test_listable_is_exact_union", + "passed": true + }, + { + "name": "test_memo_review_group_is_not_hardcoded", + "passed": true + }, + { + "name": "test_memo_version_refused", + "passed": true + }, + { + "name": "test_requester_mismatch", + "passed": true + }, + { + "name": "test_review_group_must_be_verified", + "passed": true + }, + { + "name": "test_service_subject_is_identity_refused", + "passed": true + }, + { + "name": "test_six_exact_metered_memos_allow_human_review", + "passed": true + }, + { + "name": "test_stale_mfa_on_attribute_branch", + "passed": true + }, + { + "name": "test_t03_records_not_in_act_scope", + "passed": true + }, + { + "name": "test_tool_proof_exact_record_allowed", + "passed": true + }, + { + "name": "test_tool_proof_without_mfa_denied", + "passed": true + }, + { + "name": "test_tool_proof_wrong_digest_denied", + "passed": true + }, + { + "name": "test_tool_proof_wrong_native_id_denied", + "passed": true + }, + { + "name": "test_unknown_class", + "passed": true + }, + { + "name": "test_unknown_request_denied", + "passed": true + }, + { + "name": "test_unknown_submitter", + "passed": true + } + ], + "fixtures": [ + { + "id": "unknown-request-denied", + "passed": true, + "expected": { + "effect": "deny" + }, + "actual": { + "effect": "deny", + "reason": "compact_sitting_scope_or_identity_refused" + } + }, + { + "id": "attribute-non-human-refused", + "passed": true, + "expected": { + "effect": "deny", + "reason": "identity_refused" + }, + "actual": { + "effect": "deny", + "reason": "identity_refused" + } + }, + { + "id": "attribute-stale-mfa", + "passed": true, + "expected": { + "effect": "deny", + "reason": "stale_mfa" + }, + "actual": { + "effect": "deny", + "reason": "stale_mfa" + } + }, + { + "id": "attribute-future-mfa", + "passed": true, + "expected": { + "effect": "deny", + "reason": "identity_refused" + }, + "actual": { + "effect": "deny", + "reason": "identity_refused" + } + } + ] +} diff --git a/docs/evidence/2026-10-04-lifecycle-release/service-scan.json b/docs/evidence/2026-10-04-lifecycle-release/service-scan.json new file mode 100644 index 0000000..132f947 --- /dev/null +++ b/docs/evidence/2026-10-04-lifecycle-release/service-scan.json @@ -0,0 +1,17 @@ +{ + "scanner": "aquasec/trivy@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969", + "scanner_version": "0.74.0", + "scanned_at": "2026-10-04T19:30:26.955785485Z", + "artifact": "informed-decision:wp0007-candidate", + "image_id": "sha256:2768bd1da3e651cc0be2d0f1ae6eea1af4eb057b31ef63ebb03b004c2ed76c3d", + "severities": [ + "HIGH", + "CRITICAL" + ], + "vulnerabilities": 0, + "full_report_sha256": "86543b4d8850008ada40e10f99f3b26c0564a77cab5932f61115e574e21a2ee4", + "warnings": [ + "Alpine 3.24 absent from scanner EOL list" + ], + "scope": "candidate artifact scan; not a general security attestation" +} diff --git a/docs/specs/LifecycleProductionRuntime.md b/docs/specs/LifecycleProductionRuntime.md new file mode 100644 index 0000000..059133d --- /dev/null +++ b/docs/specs/LifecycleProductionRuntime.md @@ -0,0 +1,83 @@ +# Initial production runtime candidate + +INFD-WP-0007 / INFD-WP-0005. Founder selected net-kingdom-admins humans and +system:serviceaccount:state-hub:state-hub on 2026-10-04. This is a preparation +contract; deployment still requires the concrete rollout approval. + +## Identity and listeners + +The same process/store serves browser routes on 8080 and private workload routes +on 8081. Only the public listener is behind decisions.coulomb.social. A separate +ClusterIP and exact State Hub namespace AND app-label ingress expose 8081. +`/v1/decisions` uses a projected token with audience informed-decision-lifecycle; +`/v1/memos` uses a different token with audience informed-decision-intake. Neither +route accepts a browser cookie. Browser authoring retains KeyCape sessions, +CSRF and origin checking. The private listener exposes no browser routes. + +Every workload request performs Kubernetes TokenReview over CA- and +hostname-verified HTTPS with a freshly read rotating reviewer credential. The +review service needs only create on tokenreviews, not token creation or Secret +read. The API-server audience is used for its reviewer token. Workload UID and +subject come from TokenReview; tenant comes from the reloadable exact-account +registry. JWT expiry is read only after TokenReview verifies that same token. +Callers expire within 30 seconds or token expiry, whichever is earlier. + +The registry is identity registration, not an action permit. Every operation +still requires a fresh external PDP decision. The lifecycle PDP has a distinct +package, version and digest from human review. Only humans can bind via the +existing protected review flow. Access Engine keeps the authorization decision; +Approval Engine keeps authoritative approval state. + +## Initial proposal data + +`deploy/lifecycle/lifecycle-callers.json` names the exact State Hub account and +platform tenant. The intake registry permits only decision-record class and +net-kingdom-admins, with a proposed cap of 50 new attempts per UTC day. Replays +do not spend a second attempt; uncertain creation never automatically reposts. +The cap and routing are configuration, not per-decision deployments. + +The review-group recipient is the existing platform-root KeyCape subject found +in previously bound review records. This is the initial single recipient; group +membership alone does not turn every admin into that bound principal. Expanding +to multiple human recipients needs a reviewed binding contract. The default +legacy registries remain empty; only the reviewed release ConfigMap mounts the +initial proposal. Directory group verification remains independent of this map. + +Registry volumes use no subPath and reload on each request. Requester custody is +read-only at `/var/run/secrets/informed-decision/requester/client-secret`, matching +CCR-2026-0032. No secret value is copied into release files or SQLite. Kubernetes +Secret objects are not emitted by the renderer. + +## Evidence and compatibility + +Runtime options enable lifecycle audit delivery, intake audit/notification +delivery and verified intake metadata for human review. The latter reloads the +live Approval Engine object, checks its binding pin, requester informed-decision +and human_control=true before the PDP call. Store guards recheck retained intake +metadata at the mutation boundary. A valid-looking UUID/digest alone is not proof. + +State Hub forwarding currently exists as a dev fixture, not a production worker. +The selected identity still needs projected lifecycle/intake tokens and an owner +runtime integration. State Hub notifications/write-back require admitted receiver +routes; `INFD_HUB_WRITEBACK` stays off in this candidate. Pending rows remain +explicit. These are live INFD-WP-0005 dependencies, not claimed release evidence. + +The image migrates schema 2 to schema 5 on open. Existing records, presentations, +dispositions, submissions and outbox evidence must survive. Rollback to the old +image against the migrated database is forbidden: stop the new writer, retain all +new evidence, and restore a verified pre-cutover copy on a separate private volume +only under the reviewed recovery plan. An in-flight/unresolved human submission +must be reconciled, never retried or discarded to make rollback convenient. + +## Remaining live gates + +- CCR-2026-0032 requester projection: current namespace has only the audit + ExternalSecret; requester custody, native identity/path refusals and rotation + evidence remain with RPF-WP-0051. +- FLEX-WP-0033: publish scanned policy artifact; approve exact policy/RBAC/network + objects and native caller/subject allow/refusal tests. +- INFD-WP-0005: production State Hub forwarding/receiver and token projections. +- INFD-WP-0007-T06/T07: backup/restore, exact rollout approval, native human review + and independent audit receipts across two decisions and a revision with unchanged + release/configuration pins. Browser packet authoring and broader binding profiles + remain on T05; the supported review handoff profile is documented separately. diff --git a/docs/specs/LifecycleServiceContract.md b/docs/specs/LifecycleServiceContract.md index b4a9063..00ddea5 100644 --- a/docs/specs/LifecycleServiceContract.md +++ b/docs/specs/LifecycleServiceContract.md @@ -102,3 +102,9 @@ HTTP schemas with canonical URLs and request limits; service admission and publication audience contracts; lifecycle audit receiver admission and readiness accounting. Test fixtures exercise the real PolicyClient against synthetic policy transport. They are not evidence of a deployed lifecycle policy grant. + + +Production candidate record facts also include `revision_by`, the immutable actor +of the requested revision. This lets its editor retain access to an unpublished +successor after refining a published decision. Original authorship remains +`registered_by`; both facts participate in the same transaction/read race guard. diff --git a/informed_decision/http_transport.py b/informed_decision/http_transport.py index 3a37518..cd612af 100644 --- a/informed_decision/http_transport.py +++ b/informed_decision/http_transport.py @@ -7,7 +7,7 @@ import ipaddress from http.client import HTTPException from urllib.error import HTTPError, URLError from urllib.parse import urlsplit -from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener +from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener class TransportError(Exception): @@ -41,9 +41,9 @@ class _NoRedirect(HTTPRedirectHandler): class JSONTransport: """No redirects or ambient proxy credentials; five-second request timeout.""" - def __init__(self, *, allow_internal_http=False) -> None: + def __init__(self, *, allow_internal_http=False, ssl_context=None) -> None: self.allow_internal_http = allow_internal_http - self._opener = build_opener(ProxyHandler({}), _NoRedirect()) + self._opener = build_opener(ProxyHandler({}), _NoRedirect(), HTTPSHandler(context=ssl_context)) def request(self, method: str, url: str, *, headers=None, body=None) -> tuple[int, dict]: parsed = urlsplit(url) diff --git a/informed_decision/lifecycle.py b/informed_decision/lifecycle.py index 25b0999..e01f529 100644 --- a/informed_decision/lifecycle.py +++ b/informed_decision/lifecycle.py @@ -215,7 +215,11 @@ class LifecycleRepository: published = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? " "AND version=? AND kind='published' ORDER BY sequence DESC LIMIT 1", (tenant, memo_id, version if version is not None else row["current_version"])).fetchone() + revision = db.execute("SELECT actor FROM lifecycle_events WHERE tenant=? AND memo_id=? " + "AND version=? AND kind IN ('registered','revised') ORDER BY sequence DESC LIMIT 1", + (tenant, memo_id, version if version is not None else row["current_version"])).fetchone() return {"registered": True, "registered_by": json.loads(row["actor"]), + "revision_by": json.loads(revision["actor"]) if revision else None, "current_version": row["current_version"], "published_audience": json.loads(published["body"])["audience"] if published else None} diff --git a/informed_decision/policy.py b/informed_decision/policy.py index d95fec2..7f93f45 100644 --- a/informed_decision/policy.py +++ b/informed_decision/policy.py @@ -57,9 +57,12 @@ def submitted_digest(request): return "sha256:" + hashlib.sha256(encoded.encode()).hexdigest() -def build_request(session: HumanSession, memo, action, policy_version): +def build_request(session: HumanSession, memo, action, policy_version, *, intake_context=None): if action not in ACTIONS: raise ValueError("unsupported review action") + extra = {} if intake_context is None else dict(intake_context) + if extra and set(extra) != {"review_group", "memo_class", "intake_submitter", "approval_requester", "approval_human_control"}: + raise ValueError("exact intake policy context required") return {"id": str(uuid.uuid4()), "tenant": "tenant:platform", "subject": {"id": session.subject, "type": "human", "tenant": session.tenant.value, "attributes": {"tenant_source": session.tenant.route.value, @@ -70,7 +73,7 @@ def build_request(session: HumanSession, memo, action, policy_version): "resource": {"id": "memo:" + memo.id, "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, "context": {"memo_version": memo.version, "approval_id": memo.approval_id, - "approval_binding_digest": memo.approval_binding_digest}, + "approval_binding_digest": memo.approval_binding_digest, **extra}, "policy_version": policy_version} diff --git a/informed_decision/review.py b/informed_decision/review.py index ac15bca..f2d855f 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -53,9 +53,10 @@ class OverviewRow: class ReviewController: - def __init__(self, store, policy, approval_factory, *, clock=time.time): + def __init__(self, store, policy, approval_factory, *, clock=time.time, intake_attributes=False): self.store, self.policy, self.approval_factory = store, policy, approval_factory self.clock = clock + self.intake_attributes = intake_attributes def _session(self, session): if not isinstance(session, HumanSession) or session.expires_at <= self.clock(): @@ -80,7 +81,13 @@ class ReviewController: def _authorize(self, session, memo, action): self._memo(session, memo) - request = build_request(session, memo, action, self.policy.version) + context = self.store.intake_context(memo) if self.intake_attributes and action != 'list' else None + if context is not None: + current = self._approval(session, memo) + if (current['binding'].get('actor') != context['approval_requester'] + or current['binding'].get('human_control') is not True): + raise ReviewError(409, 'binding_changed') + request = build_request(session, memo, action, self.policy.version, intake_context=context) observation = self.policy.check(request) policy_id = self.store.observe_policy(observation) try: diff --git a/informed_decision/runtime.py b/informed_decision/runtime.py index e179287..f065c42 100644 --- a/informed_decision/runtime.py +++ b/informed_decision/runtime.py @@ -119,6 +119,7 @@ class Runtime: controller: ReviewController pump: AuditPump lifecycle: object | None = None + workload: object | None = None @classmethod def from_file(cls, filename): @@ -129,7 +130,7 @@ class Runtime: or info.st_size > 16384): raise ValueError("runtime configuration must be an owned private 0600 file") data = json.loads(path.read_text()) - if ((set(data) - {"ordinary_approval_ids", "intake_delivery", "lifecycle_policy", "lifecycle_delivery"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"} + if ((set(data) - {"ordinary_approval_ids", "intake_delivery", "lifecycle_policy", "lifecycle_delivery", "workload_api", "intake_attributes"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"} or data["schema"] != "informed-decision.review-runtime.v1" or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"} or set(data["audit"]) != {"origin", "sender_token_file"}): @@ -137,6 +138,9 @@ class Runtime: lifecycle_delivery = data.get("lifecycle_delivery", False) if type(lifecycle_delivery) is not bool: raise ValueError("invalid lifecycle delivery setting") + intake_attributes = data.get('intake_attributes', False) + if type(intake_attributes) is not bool: + raise ValueError('invalid intake attributes setting') ordinary = ordinary_review_ids(data.get("ordinary_approval_ids", [])) if not Path(data["evidence_db"]).is_absolute(): raise ValueError("absolute evidence database path required") @@ -168,7 +172,8 @@ class Runtime: package_digest=configuration["package_digest"], allow_internal_http=True) store = Store(data["evidence_db"]) controller = ReviewController(store, client, lambda session: ApprovalHTTPClient( - approval_origin, session, allow_internal_http=True, ordinary_approval_ids=tuple(ordinary))) + approval_origin, session, allow_internal_http=True, ordinary_approval_ids=tuple(ordinary)), + intake_attributes=intake_attributes) worker = None if hub: from .intake_delivery import IntakeDeliveryWorker @@ -179,5 +184,9 @@ class Runtime: from .lifecycle import LifecycleRepository from .lifecycle_service import LifecycleService lifecycle = LifecycleService(LifecycleRepository(store), lifecycle_policy) + workload = None + if 'workload_api' in data: + from .workload import compose_workload + workload = compose_workload(data['workload_api'], store, lifecycle, approval_origin, token_file) return cls(controller, AuditPump(store, sink, intake_worker=worker, - lifecycle_delivery=lifecycle_delivery), lifecycle) + lifecycle_delivery=lifecycle_delivery), lifecycle, workload) diff --git a/informed_decision/store.py b/informed_decision/store.py index f6f3d1e..3df961c 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -259,12 +259,36 @@ class Store: if row is None or row["outcome"] != "allow" or row["usable_until"] <= time.time(): raise Conflict("a current recorded policy observation is required") request = json.loads(row["request"]) + expected = {"memo_version": memo.version, "approval_id": memo.approval_id, + "approval_binding_digest": memo.approval_binding_digest} + if set(request['context']) - expected.keys(): + context = self._intake_context(db, memo) + if context is None: + raise Conflict("intake policy observation has no ready receipt") + expected.update(context) if (request["action"] != action or request["subject"]["id"] != subject or request["resource"]["id"] != "memo:" + memo.id - or request["context"] != {"memo_version": memo.version, "approval_id": memo.approval_id, - "approval_binding_digest": memo.approval_binding_digest}): + or request["context"] != expected): raise Conflict("policy observation does not name this act") + @staticmethod + def _intake_context(db, memo): + row = db.execute("SELECT submitter,metadata,receipt FROM intake_ledger WHERE memo_id=? AND state='ready'", + (memo.id,)).fetchone() + if row is None: + return None + receipt, metadata = json.loads(row['receipt']), json.loads(row['metadata']) + if (receipt['approval_id'] != memo.approval_id + or receipt['approval_binding_digest'] != memo.approval_binding_digest): + raise Conflict('intake receipt does not match memo') + return {'review_group': metadata['review_group'], 'memo_class': metadata['memo_class'], + 'intake_submitter': row['submitter'], 'approval_requester': 'informed-decision', + 'approval_human_control': True} + + def intake_context(self, memo): + with self._connection() as db: + return self._intake_context(db, memo) + def _presentation(self, db, presentation_id): row = db.execute("SELECT body FROM presentations WHERE id=?", (presentation_id,)).fetchone() if row is None: diff --git a/informed_decision/web.py b/informed_decision/web.py index 2e9158c..e13d8b3 100644 --- a/informed_decision/web.py +++ b/informed_decision/web.py @@ -263,7 +263,25 @@ def main(): readiness=runtime.pump.ready if runtime else lambda: False, lifecycle=runtime.lifecycle if runtime else None) try: - serve(app, host=host, port=8080, threads=4) + if runtime and runtime.workload: + serve_with_workload(app, runtime.workload, host=host) + else: + serve(app, host=host, port=8080, threads=4) finally: if runtime: runtime.pump.stop() + + +def serve_with_workload(public, private, *, host, public_port=8080, private_port=8081): + """Two sockets, one writer/process; the private app has no browser routes.""" + from waitress import create_server + servers, socket_map = [], {} + try: + servers.append(create_server(public, host=host, port=public_port, threads=4, map=socket_map)) + servers.append(create_server(private, host=host, port=private_port, threads=4, map=socket_map, + max_request_body_size=1024 * 1024)) + servers[0].run() + finally: + for server in servers: + server.close() + server.task_dispatcher.shutdown() diff --git a/informed_decision/workload.py b/informed_decision/workload.py new file mode 100644 index 0000000..ef08bf7 --- /dev/null +++ b/informed_decision/workload.py @@ -0,0 +1,133 @@ +"""Private workload composition. Kubernetes verifies identity; PDP grants actions.""" +import json +import math +from pathlib import Path +import re +import ssl +import time + +import jwt + +from .disposition import Actor, ActorKind +from .http_transport import JSONTransport, https_origin +from .intake_service import IntakeError +from .lifecycle import Attribution +from .lifecycle_http import LifecycleAPI +from .lifecycle_service import Caller +from .records import dumps + +LIFECYCLE_AUDIENCE = 'informed-decision-lifecycle' +SA = re.compile(r'system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+') + + +def registry(path): + with Path(path).open('rb') as handle: + raw = handle.read(65537) + if len(raw) > 65536: + raise ValueError('registry too large') + result = json.loads(raw) + if (not isinstance(result, dict) or any(not SA.fullmatch(name) or + not isinstance(row, dict) or set(row) != {'tenant'} or row['tenant'] != 'tenant:platform' + for name, row in result.items())): + raise ValueError('invalid workload registry') + return result + + +class KubernetesTokenReview: + def __init__(self, origin, ca_file, token_provider, *, transport_factory=JSONTransport): + self.origin = https_origin(origin) + self.ca_file = Path(ca_file) + if not self.ca_file.is_absolute(): + raise ValueError('absolute Kubernetes CA path required') + self.token_provider = token_provider + self.transport_factory = transport_factory + + def __call__(self, body): + try: + # Reopen projected CA/caller custody; require TLS peer and hostname validation. + context = ssl.create_default_context(cafile=str(self.ca_file)) + transport = self.transport_factory(ssl_context=context) + status, result = transport.request('POST', self.origin + '/apis/authentication.k8s.io/v1/tokenreviews', + headers={'Authorization': 'Bearer ' + self.token_provider(), 'Content-Type': 'application/json'}, + body=dumps(body).encode()) + if status not in (200, 201): + raise ValueError() + return result + except Exception: + raise IntakeError(503, 'workload_identity_unavailable') from None + + +class LifecycleWorkloadIdentity: + def __init__(self, review, callers_file, *, clock=time.time): + self.review, self.callers_file, self.clock = review, Path(callers_file), clock + if not self.callers_file.is_absolute(): + raise ValueError('absolute workload registry path required') + + def __call__(self, token): + if not isinstance(token, str) or not 1 <= len(token) <= 16384: + raise ValueError('invalid workload token') + result = self.review({'apiVersion': 'authentication.k8s.io/v1', 'kind': 'TokenReview', + 'spec': {'token': token, 'audiences': [LIFECYCLE_AUDIENCE]}}) + status = result['status'] + user = status['user'] + if (status.get('authenticated') is not True or status.get('error') + or LIFECYCLE_AUDIENCE not in status.get('audiences', []) + or not isinstance(user.get('username'), str) or not SA.fullmatch(user['username']) + or not isinstance(user.get('uid'), str) or not user['uid']): + raise ValueError('workload authentication refused') + admitted = registry(self.callers_file).get(user['username']) + if admitted is None: + raise ValueError('workload not admitted') + # The API server has verified this Kubernetes JWT. Read only its expiry; + # subject/UID come from TokenReview and tenant from reviewed registration. + claims = jwt.decode(token, options={'verify_signature': False}) + expiry = claims.get('exp') + if type(expiry) is not int or not math.isfinite(expiry) or expiry <= self.clock(): + raise ValueError('workload token expired') + return Caller(Attribution(admitted['tenant'], Actor(user['username'], ActorKind.SERVICE)), + min(expiry, self.clock() + 30), dumps({ + 'principal_type_source': 'authentication-derived', + 'tenant_source': 'registration-supplied', + 'workload_uid': user['uid'], 'authentication_audience': LIFECYCLE_AUDIENCE})) + + +class PrivateWorkloadApp: + def __init__(self, *, intake=None, lifecycle=None): + self.intake, self.lifecycle = intake, lifecycle + + def __call__(self, environ, start_response): + path = environ.get('PATH_INFO', '') + if path == '/v1/memos' and self.intake is not None: + return self.intake(environ, start_response) + if (path == '/v1/decisions' or path.startswith('/v1/decisions/')) and self.lifecycle is not None: + return self.lifecycle(environ, start_response) + raw = b'{"error":"route_not_found"}' + start_response('404 Not Found', [('Content-Type', 'application/json'), ('Cache-Control', 'no-store'), + ('Content-Length', str(len(raw)))]) + return [raw] + + +def compose_workload(data, store, lifecycle, approval_origin, token_reader): + if (not isinstance(data, dict) or set(data) - {'token_review', 'intake', 'lifecycle_callers_file'} + or 'token_review' not in data or not ({'intake', 'lifecycle_callers_file'} & data.keys())): + raise ValueError('invalid private workload configuration') + review = data['token_review'] + if not isinstance(review, dict) or set(review) != {'origin', 'ca_file', 'token_file'}: + raise ValueError('exact TokenReview configuration required') + verifier = KubernetesTokenReview(review['origin'], review['ca_file'], token_reader(review['token_file'])) + intake, api = None, None + if 'intake' in data: + from .requester import compose_intake + configuration = data['intake'] + if not isinstance(configuration, dict) or set(configuration) != {'secret_file', 'submitters_file', 'groups_file'}: + raise ValueError('invalid private intake configuration') + if any(not isinstance(p, str) or not Path(p).is_absolute() for p in configuration.values()): + raise ValueError('absolute intake projection paths required') + intake = compose_intake(store, verifier, issuer='https://kc.coulomb.social', + secret_file=configuration['secret_file'], approval_origin=approval_origin, + submitters_path=configuration['submitters_file'], groups_path=configuration['groups_file']) + if 'lifecycle_callers_file' in data: + if lifecycle is None: + raise ValueError('private lifecycle API requires a separately pinned policy') + api = LifecycleAPI(lifecycle, LifecycleWorkloadIdentity(verifier, data['lifecycle_callers_file'])) + return PrivateWorkloadApp(intake=intake, lifecycle=api) diff --git a/pyproject.toml b/pyproject.toml index 0585608..be3d1b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "informed-decision" version = "0.2.0" description = "Presentation and binding surface for decisions — the Decision Memo and its evidence." requires-python = ">=3.11" -dependencies = ["PyJWT[crypto]>=2.10,<3", "waitress>=3,<4"] +dependencies = ["PyJWT[crypto]>=2.14,<3", "waitress>=3,<4"] [project.scripts] informed-decision-web = "informed_decision.web:main" diff --git a/requirements.lock b/requirements.lock index 9f836f1..ad4aefb 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,15 +1,13 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --python-version 3.12 --generate-hashes --output-file requirements.lock -cffi==2.1.1 \ +# uv export --no-dev --no-emit-project --format requirements-txt --output-file requirements.lock +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ - --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ - --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ @@ -25,9 +23,7 @@ cffi==2.1.1 \ --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ - --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ - --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ @@ -38,8 +34,6 @@ cffi==2.1.1 \ --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ - --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ - --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ @@ -50,20 +44,16 @@ cffi==2.1.1 \ --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ - --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ - --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ - --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ - --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ @@ -74,13 +64,11 @@ cffi==2.1.1 \ --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ - --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ - --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ @@ -101,7 +89,6 @@ cffi==2.1.1 \ --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 - # via cryptography cryptography==50.0.1 \ --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ @@ -149,16 +136,12 @@ cryptography==50.0.1 \ --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef - # via pyjwt -pycparser==3.0 \ +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 - # via cffi -pyjwt==2.13.0 \ - --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ - --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 - # via informed-decision (pyproject.toml) +pyjwt==2.15.1 \ + --hash=sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193 \ + --hash=sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8 waitress==3.0.2 \ --hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \ --hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e - # via informed-decision (pyproject.toml) diff --git a/tests/review_components.py b/tests/review_components.py index 5e6c3ee..bf61059 100644 --- a/tests/review_components.py +++ b/tests/review_components.py @@ -26,7 +26,7 @@ FIXTURE = Path(__file__).parent / 'fixtures/review-policy' @contextmanager -def flex_component(binary, directory, signing_key): +def flex_component(binary, directory, signing_key, *, fixture=FIXTURE): tokens={'synthetic-workload-token':'system:serviceaccount:informed-decision:review', 'synthetic-wrong-caller':'system:serviceaccount:other:other'} calls=[] @@ -55,7 +55,7 @@ def flex_component(binary, directory, signing_key): listener.bind(('127.0.0.1',0));port=listener.getsockname()[1] origin=f'http://127.0.0.1:{port}' process=subprocess.Popen([str(binary),'serve','--addr',f'127.0.0.1:{port}', - '--registry',str(FIXTURE/'registry.json'),'--policy',str(FIXTURE/'policy.md'), + '--registry',str(fixture/'registry.json'),'--policy',str(fixture/'policy.md'), '--caller-auth-mode','enforce','--caller-binding','informed-decision=system:serviceaccount:informed-decision:review', '--caller-kubernetes-url',f'http://127.0.0.1:{server.server_port}', '--caller-reviewer-token-file',str(reviewer),'--caller-ca-file',str(ca)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE) @@ -69,7 +69,7 @@ def flex_component(binary, directory, signing_key): except TransportError:pass time.sleep(.05) else:raise RuntimeError('fixture evaluator did not start') - pin=json.loads((FIXTURE/'pin.json').read_text());pin.pop('fixture_only') + pin=json.loads((fixture/'pin.json').read_text());pin.pop('fixture_only', None) client=PolicyClient(origin,lambda:'synthetic-workload-token',allow_internal_http=True,**pin) yield client,calls finally: diff --git a/tests/test_dependency_pins.py b/tests/test_dependency_pins.py new file mode 100644 index 0000000..d875f78 --- /dev/null +++ b/tests/test_dependency_pins.py @@ -0,0 +1,15 @@ +"""Prevent a tested dependency and its shipped container pin from diverging.""" +from pathlib import Path +import re +import tomllib + + +def test_container_dependencies_match_project_lock(): + root = Path(__file__).resolve().parents[1] + lock = tomllib.loads((root/'uv.lock').read_text()) + locked = {p['name'].lower(): p['version'] for p in lock['package']} + requirements = dict(re.findall(r'^([a-zA-Z0-9_-]+)==([^ ;\\\n]+)', + (root/'requirements.lock').read_text(), re.MULTILINE)) + assert requirements + for name, version in requirements.items(): + assert locked[name.lower()] == version, name diff --git a/tests/test_deployment_candidate.py b/tests/test_deployment_candidate.py index af3a68a..a9ba575 100644 --- a/tests/test_deployment_candidate.py +++ b/tests/test_deployment_candidate.py @@ -97,3 +97,53 @@ def test_optional_workspace_requires_distinct_pins_and_exact_policy_network_peer data['lifecycle_policy']['package'] = data['policy']['package'] with pytest.raises(ValueError): module.render(data) + + +def workload_inputs(): + data = inputs() + data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle', 'pod_name': 'flex-auth-lifecycle'} + data['lifecycle_delivery'] = True + data['intake_attributes'] = True + data['intake_delivery'] = {'hub_origin': 'http://state-hub.state-hub.svc.cluster.local:8000'} + data['workload_api'] = {'intake': True, 'lifecycle': True, + 'registries_configmap': 'informed-decision-workload-registry', + 'peers': [{'namespace': 'state-hub', 'app': 'state-hub'}], + 'api_egress': [{'ip': '10.43.0.1', 'port': 443}, {'ip': '92.205.62.239', 'port': 6443}]} + return data + + +def test_private_workload_candidate_has_exact_rbac_peers_and_projection(): + objects = module.render(workload_inputs())['items'] + services = [o for o in objects if o['kind'] == 'Service'] + assert len(services) == 2 + assert services[0]['spec']['ports'][0]['port'] == 80 + assert services[1]['spec']['ports'][0]['port'] == 8081 + assert all(o['kind'] not in ('Secret', 'Ingress') for o in objects) + role = next(o for o in objects if o['kind'] == 'ClusterRole') + assert role['rules'] == [{'apiGroups':['authentication.k8s.io'],'resources':['tokenreviews'],'verbs':['create']}] + binding = next(o for o in objects if o['kind'] == 'ClusterRoleBinding') + assert binding['subjects'] == [{'kind':'ServiceAccount','name':'review','namespace':'informed-decision'}] + policy = next(o for o in objects if o['kind']=='NetworkPolicy' and o['metadata']['name']=='informed-decision-review') + rule = policy['spec']['ingress'][-1] + assert rule['ports'] == [{'protocol':'TCP','port':8081}] + assert rule['from'][0]['namespaceSelector']['matchLabels'] == {'kubernetes.io/metadata.name':'state-hub'} + pod = next(o for o in objects if o['kind']=='Deployment')['spec']['template']['spec'] + registry = next(m for m in pod['containers'][0]['volumeMounts'] if m['name']=='workload-registry') + assert registry['readOnly'] and 'subPath' not in registry + requester = next(v for v in pod['volumes'] if v['name']=='sitting-requester') + assert requester['secret']['secretName'] == 'informed-decision-sitting-requester' + assert requester['secret']['defaultMode'] == 0o440 + config = json.loads(next(o for o in objects if o['kind']=='ConfigMap')['data']['runtime.json']) + assert config['workload_api']['token_review']['origin'] == 'https://kubernetes.default.svc:443' + + +@pytest.mark.parametrize('fault', ['wildcard','missing_delivery','no_policy','broad_api','false_type','empty_peers']) +def test_private_workload_candidate_refuses_incomplete_or_broad_configuration(fault): + data = workload_inputs() + if fault == 'wildcard': data['workload_api']['peers'][0]['app']='*' + if fault == 'missing_delivery': del data['intake_delivery'] + if fault == 'no_policy': del data['lifecycle_policy'] + if fault == 'broad_api': data['workload_api']['api_egress'][0]['ip']='10.0.0.0/8' + if fault == 'false_type': data['workload_api']['intake']='true' + if fault == 'empty_peers': data['workload_api']['peers']=[] + with pytest.raises(ValueError): module.render(data) diff --git a/tests/test_lifecycle_policy_component.py b/tests/test_lifecycle_policy_component.py new file mode 100644 index 0000000..6cc5ae0 --- /dev/null +++ b/tests/test_lifecycle_policy_component.py @@ -0,0 +1,56 @@ +"""Real candidate Access Engine policy; authentication custody remains synthetic.""" +from dataclasses import replace +import os +from pathlib import Path +import time + +import pytest + +from informed_decision.disposition import Actor, ActorKind +from informed_decision.lifecycle import Attribution, LifecycleRepository +from informed_decision.lifecycle_service import Caller, LifecycleError, LifecycleService +from informed_decision.records import dumps +from informed_decision.store import Store +from review_components import flex_component +from test_browser_auth import signing_key +from test_lifecycle import workspace +from test_policy_client import human + + +def test_initial_audience_collaborates_across_revisions_with_real_pdp(workspace, tmp_path, signing_key): + binary = os.environ.get('INFD_FLEX_AUTH_BINARY') + source = os.environ.get('INFD_FLEX_AUTH_SOURCE') + if not binary or not source: + pytest.skip('set INFD_FLEX_AUTH_BINARY and INFD_FLEX_AUTH_SOURCE for candidate owner policy') + fixture = Path(source) / 'examples/informed-decision-lifecycle' + pins = (fixture / 'pin.json').read_bytes() + with flex_component(binary, tmp_path, signing_key, fixture=fixture) as (pdp, calls): + repo, memo = workspace + app = LifecycleService(repo, pdp) + hub = Caller(Attribution('tenant:platform', Actor('system:serviceaccount:state-hub:state-hub', ActorKind.SERVICE)), + time.time() + 600, dumps({'principal_type_source': 'authentication-derived', + 'tenant_source': 'registration-supplied', 'authentication_audience': 'informed-decision-lifecycle', + 'workload_uid': 'synthetic-verified-uid'})) + person = Caller.from_human_session(human()) + app.register(hub, memo, key='first') + with pytest.raises(LifecycleError): + app.retrieve(person, memo.id) + app.publish(hub, memo.id, 1, audience={'groups': ['net-kingdom-admins'], 'subjects': []}, key='publish') + app.annotate(person, memo.id, 1, kind='comment', body={'text': 'Consider the alternative'}, key='comment') + app.revise(person, memo.next_version(brief='Alternative assessed'), expected_version=1, summary='Refined', key='refine') + # The successor is unpublished; its editor and original author retain access. + assert app.retrieve(person, memo.id).version == 2 + assert app.retrieve(hub, memo.id).version == 2 + other = Caller.from_human_session(replace(human(), subject='another-admin')) + with pytest.raises(LifecycleError): + app.retrieve(other, memo.id) + app.publish(person, memo.id, 2, audience={'groups': ['net-kingdom-admins'], 'subjects': []}, key='republish') + assert app.retrieve(other, memo.id).version == 2 + app.register(hub, replace(memo, id='second-decision'), key='second') + reopened = LifecycleService(LifecycleRepository(Store(repo.store.path)), pdp) + assert reopened.retrieve(person, memo.id).version == 2 + foreign = replace(hub, attribution=Attribution('tenant:platform', Actor('system:serviceaccount:other:other', ActorKind.SERVICE))) + with pytest.raises(LifecycleError): + reopened.retrieve(foreign, memo.id) + assert calls and all(c['known'] and c['audiences'] == ['flex-auth'] for c in calls) + assert (fixture / 'pin.json').read_bytes() == pins diff --git a/tests/test_lifecycle_review.py b/tests/test_lifecycle_review.py index d04c619..e0d0812 100644 --- a/tests/test_lifecycle_review.py +++ b/tests/test_lifecycle_review.py @@ -186,8 +186,11 @@ def test_actual_engine_human_entry_and_independent_audit_link_to_source_revision status, loaded = request(intake, receipt['envelope']) assert status == 201, loaded controller = ReviewController(app.repository.store, policy(), - lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport)) + lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport), intake_attributes=True) page = controller.open(human, receipt['review_memo_id']) + context = controller.policy.transport.calls[-1][3]['context'] + assert context['intake_submitter'] == 'state-hub' + assert context['approval_requester'] == 'informed-decision' and context['approval_human_control'] is True controller.acknowledge(human, page.presentation.id, memo.required_ack_ids) controller.act(human, page.presentation.id, Verb.ACCEPT, operation_id='human-accept') result = app.outcomes(actor, memo.id, 2) @@ -278,3 +281,41 @@ def test_schema_four_upgrade_preserves_evidence_and_fences_old_readers(handoff): assert app.events(actor, memo.id) == before receipt = prepare(handoff) assert prepare(handoff) == receipt + + +def test_attribute_review_checks_live_requester_before_policy(handoff): + from informed_decision.review import ReviewController, ReviewError + from test_policy_client import human + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + assert request(intake, receipt['envelope'])[0] == 201 + stored = app.repository.store.memo(receipt['review_memo_id']) + session = replace(human(), subject=stored.binding.principal.id) + class Engine: + def get_approval(self, approval_id): + return {'binding': {'digest': stored.approval_binding_digest, 'actor': 'wrong-requester', 'human_control': True}} + controller = ReviewController(app.repository.store, policy(), lambda session: Engine(), intake_attributes=True) + with pytest.raises(ReviewError, match='binding_changed'): + controller.open(session, stored.id) + assert controller.policy.transport.calls == [] + + +def test_intake_policy_guard_rechecks_retained_metadata(handoff): + from informed_decision.policy import build_request + from test_policy_client import human + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + assert request(intake, receipt['envelope'])[0] == 201 + store = app.repository.store + review = store.memo(receipt['review_memo_id']) + session = replace(human(), subject=review.binding.principal.id) + request_body = build_request(session, review, 'read', 'v1', intake_context=store.intake_context(review)) + observation = policy().check(request_body) + policy_id = store.observe_policy(observation) + with store._transaction() as db: + row = db.execute('SELECT metadata FROM intake_ledger WHERE memo_id=?', (review.id,)).fetchone() + metadata = json.loads(row['metadata']); metadata['review_group'] = 'changed' + db.execute('UPDATE intake_ledger SET metadata=? WHERE memo_id=?', (json.dumps(metadata), review.id)) + with pytest.raises(Conflict, match='policy observation'): + store.present(review.id, principal_sub=session.subject, tenant=session.tenant, + principal_type=session.principal_type, policy_id=policy_id) diff --git a/tests/test_workload_runtime.py b/tests/test_workload_runtime.py new file mode 100644 index 0000000..1f2fc63 --- /dev/null +++ b/tests/test_workload_runtime.py @@ -0,0 +1,191 @@ +import io +import json +import ssl +import time + +import jwt +import pytest + +from informed_decision.disposition import ActorKind +from informed_decision.intake_service import IntakeError, AUDIENCE +from informed_decision.workload import KubernetesTokenReview, LifecycleWorkloadIdentity, LIFECYCLE_AUDIENCE, PrivateWorkloadApp +from informed_decision.runtime import Runtime +from test_review_runtime import config + +SA = 'system:serviceaccount:state-hub:state-hub' + + +def verified(body): + return {'status': {'authenticated': True, 'audiences': body['spec']['audiences'], + 'user': {'username': SA, 'uid': 'actual-sa-uid'}}} + + +def token(exp=None): + return jwt.encode({'exp': int(time.time())+300 if exp is None else exp}, 'synthetic-test-key-with-at-least-thirty-two-bytes', algorithm='HS256') + + +def test_workload_identity_uses_verified_subject_and_separate_audience(tmp_path): + path = tmp_path/'callers.json' + path.write_text(json.dumps({SA: {'tenant': 'tenant:platform'}})) + requests = [] + def review(body): + requests.append(body) + return verified(body) + identity = LifecycleWorkloadIdentity(review, path) + caller = identity(token()) + assert requests[0]['spec']['audiences'] == [LIFECYCLE_AUDIENCE] + assert caller.attribution.actor.sub == SA and caller.attribution.actor.kind is ActorKind.SERVICE + assert caller.expires_at <= time.time()+30 + assert json.loads(caller.attributes_json)['workload_uid'] == 'actual-sa-uid' + assert 'token' not in caller.attributes_json + path.write_text('{}') + with pytest.raises(ValueError): + identity(token()) + + +@pytest.mark.parametrize('fault', ['wrong_audience', 'unauthenticated', 'human', 'missing_uid', 'expired', 'no_expiry', 'tenant']) +def test_workload_identity_fails_closed(tmp_path, fault): + path = tmp_path/'callers.json' + path.write_text(json.dumps({SA: {'tenant': 'tenant:other' if fault == 'tenant' else 'tenant:platform'}})) + def review(body): + result = verified(body) + if fault == 'wrong_audience': result['status']['audiences'] = [AUDIENCE] + if fault == 'unauthenticated': result['status']['authenticated'] = False + if fault == 'human': result['status']['user']['username'] = 'human' + if fault == 'missing_uid': result['status']['user']['uid'] = '' + return result + value = token(int(time.time())-1) if fault == 'expired' else token() + if fault == 'no_expiry': value = jwt.encode({}, 'synthetic-test-key-with-at-least-thirty-two-bytes', algorithm='HS256') + with pytest.raises(ValueError): + LifecycleWorkloadIdentity(review, path)(value) + + +def test_tokenreview_uses_owned_ca_rotating_caller_and_bounded_transport(tmp_path, monkeypatch): + seen = [] + sentinel = ssl.create_default_context() + def context(*, cafile): + assert cafile == str(tmp_path/'ca.crt') + return sentinel + monkeypatch.setattr(ssl, 'create_default_context', context) + current = ['synthetic-caller-one'] + class Transport: + def __init__(self, **kwargs): + assert kwargs['ssl_context'] is sentinel + def request(self, method, url, **kwargs): + seen.append((method, url, kwargs)) + return 201, verified(json.loads(kwargs['body'])) + review = KubernetesTokenReview('https://kubernetes.default.svc:443', tmp_path/'ca.crt', + lambda: current[0], transport_factory=Transport) + body = {'spec': {'token': 'synthetic-submitter', 'audiences': [AUDIENCE]}} + assert review(body)['status']['authenticated'] + current[0] = 'synthetic-caller-two' + review(body) + assert seen[0][2]['headers']['Authorization'] != seen[1][2]['headers']['Authorization'] + assert seen[0][1] == 'https://kubernetes.default.svc:443/apis/authentication.k8s.io/v1/tokenreviews' + def fail(**kwargs): raise OSError('private-path-or-response') + monkeypatch.setattr(ssl, 'create_default_context', fail) + with pytest.raises(IntakeError, match='workload_identity_unavailable') as error: + review(body) + assert 'private-path' not in str(error.value) + + +def test_explicit_runtime_configuration_does_not_read_custody_or_start_listener(tmp_path): + path, data = config(tmp_path) + data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle'} + data['workload_api'] = {'token_review': {'origin': 'https://kubernetes.default.svc:443', + 'ca_file': str(tmp_path/'absent-ca'), 'token_file': str(tmp_path/'absent-token')}, + 'lifecycle_callers_file': str(tmp_path/'absent-callers'), + 'intake': {'secret_file': str(tmp_path/'absent-secret'), 'submitters_file': str(tmp_path/'absent-submitters'), + 'groups_file': str(tmp_path/'absent-groups')}} + data['intake_attributes'] = True + path.write_text(json.dumps(data)) + runtime = Runtime.from_file(path) + assert isinstance(runtime.workload, PrivateWorkloadApp) + assert runtime.workload.intake.store is runtime.controller.store + assert runtime.workload.lifecycle.service is runtime.lifecycle + assert runtime.controller.intake_attributes + del data['lifecycle_policy'] + path.write_text(json.dumps(data)) + with pytest.raises(ValueError): Runtime.from_file(path) + + +def test_private_router_does_not_expose_browser_or_unknown_routes(): + calls = [] + def app(environ, start): + calls.append(environ['PATH_INFO']); start('200 OK', []); return [b'ok'] + router = PrivateWorkloadApp(intake=app, lifecycle=app) + for path in ('/v1/memos', '/v1/decisions', '/v1/decisions/example/events'): + assert b''.join(router({'PATH_INFO': path}, lambda s, h: None)) == b'ok' + for path in ('/', '/auth/start', '/review', '/v1/decisionsevil'): + statuses = [] + router({'PATH_INFO': path}, lambda s, h: statuses.append(s)) + assert statuses == ['404 Not Found'] + assert len(calls) == 3 + + +def test_two_listener_startup_failure_closes_first_socket_and_dispatcher(monkeypatch): + import waitress + from informed_decision.web import serve_with_workload + calls = [] + class Server: + def close(self): calls.append('close') + def shutdown(self): calls.append('shutdown') + @property + def task_dispatcher(self): return self + def create(app, **kwargs): + calls.append(kwargs) + if kwargs['port'] == 8081: raise OSError('occupied') + return Server() + monkeypatch.setattr(waitress, 'create_server', create) + with pytest.raises(OSError): serve_with_workload(object(), object(), host='127.0.0.1') + assert calls[0]['map'] is calls[1]['map'] + assert calls[-2:] == ['close', 'shutdown'] + + +def test_real_tls_tokenreview_requires_ca_and_hostname(tmp_path): + from datetime import datetime, timedelta, timezone + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import ipaddress + import threading + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.now(timezone.utc) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'synthetic-api')]) + certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)) + .not_valid_after(now+timedelta(hours=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False) + .sign(key, hashes.SHA256())) + ca, private_key = tmp_path/'ca.pem', tmp_path/'key.pem' + ca.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + private_key.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption())) + received = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_POST(self): + received.append(self.path) + assert self.headers['Authorization'] == 'Bearer synthetic-reviewer' + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + raw = json.dumps(verified(body)).encode() + self.send_response(201); self.end_headers(); self.wfile.write(raw) + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls.load_cert_chain(ca, private_key) + server.socket = tls.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + body = {'spec': {'token': 'synthetic-caller', 'audiences': [LIFECYCLE_AUDIENCE]}} + try: + origin = f'https://127.0.0.1:{server.server_port}' + review = KubernetesTokenReview(origin, ca, lambda: 'synthetic-reviewer') + assert review(body)['status']['authenticated'] + wrong_host = KubernetesTokenReview(origin.replace('127.0.0.1', 'localhost'), ca, lambda: 'synthetic-reviewer') + with pytest.raises(IntakeError): wrong_host(body) + untrusted = KubernetesTokenReview(origin, '/etc/ssl/certs/ca-certificates.crt', lambda: 'synthetic-reviewer') + with pytest.raises(IntakeError): untrusted(body) + assert len(received) == 1 + finally: + server.shutdown(); server.server_close(); thread.join(timeout=5) diff --git a/tools/render_deployment.py b/tools/render_deployment.py index 5adff65..bff1dd3 100644 --- a/tools/render_deployment.py +++ b/tools/render_deployment.py @@ -24,8 +24,65 @@ def validate_policy(policy): raise ValueError('exact policy package/version/digest required') +def validate_workload(value): + if (not isinstance(value,dict) or set(value)!={'registries_configmap','peers','api_egress','intake','lifecycle'} + or any(type(value[k]) is not bool for k in ('intake','lifecycle')) + or not (value['intake'] or value['lifecycle']) + or not re.fullmatch(r'[a-z0-9][a-z0-9.-]{0,62}',value['registries_configmap'])): + raise ValueError('exact workload listener configuration required') + for key in ('peers','api_egress'): + if not isinstance(value[key],list) or not 1<=len(value[key])<=16: + raise ValueError('bounded exact workload peers required') + for item in value['peers']: + if (not isinstance(item,dict) or set(item)!={'namespace','app'} + or any(not re.fullmatch(r'[a-z0-9][a-z0-9.-]{0,62}',x) for x in item.values())): + raise ValueError('exact workload namespace and pod label required') + for item in value['api_egress']: + if not isinstance(item,dict) or set(item)!={'ip','port'} or type(item['port']) is not int or item['port'] not in (443,6443): + raise ValueError('exact Kubernetes API endpoint required') + address=ipaddress.ip_address(item['ip']) + if address.is_loopback or address.is_unspecified or address.is_multicast: + raise ValueError('routable API address required') + + +def add_workload_objects(objects,pod,value,obj,readonly,peer): + container=pod['containers'][0] + container['ports'].append({'name':'workload','containerPort':8081}) + container['volumeMounts'].extend([ + readonly('kubernetes-api','/var/run/secrets/informed-decision/kubernetes'), + readonly('workload-registry','/workload-registry')]) + pod['volumes'].extend([ + {'name':'kubernetes-api','projected':{'defaultMode':0o440,'sources':[ + {'serviceAccountToken':{'path':'token','expirationSeconds':600}}, + {'configMap':{'name':'kube-root-ca.crt','items':[{'key':'ca.crt','path':'ca.crt'}]}}]}}, + {'name':'workload-registry','configMap':{'name':value['registries_configmap'],'defaultMode':0o444}}]) + if value['intake']: + container['volumeMounts'].append(readonly('sitting-requester','/var/run/secrets/informed-decision/requester')) + pod['volumes'].append({'name':'sitting-requester','secret':{ + 'secretName':'informed-decision-sitting-requester','defaultMode':0o440,'optional':False, + 'items':[{'key':'client-secret','path':'client-secret'}]}}) + objects.append(obj('v1','Service',NAME+'-workload',spec={'type':'ClusterIP', + 'selector':{**LABEL,'app.kubernetes.io/component':'review'}, + 'ports':[{'name':'workload','port':8081,'targetPort':'workload','protocol':'TCP'}]})) + objects[5]['spec']['ingress'].append({'from':[peer(p['namespace'],p['app']) for p in value['peers']], + 'ports':[{'protocol':'TCP','port':8081}]}) + for endpoint in value['api_egress']: + address=ipaddress.ip_address(endpoint['ip']) + objects[5]['spec']['egress'].append({'to':[{'ipBlock':{'cidr':str(address)+'/'+str(address.max_prefixlen)}}], + 'ports':[{'protocol':'TCP','port':endpoint['port']}]}) + role=obj('rbac.authorization.k8s.io/v1','ClusterRole',NAME+'-tokenreview',rules=[ + {'apiGroups':['authentication.k8s.io'],'resources':['tokenreviews'],'verbs':['create']}]) + binding=obj('rbac.authorization.k8s.io/v1','ClusterRoleBinding',NAME+'-tokenreview', + roleRef={'apiGroup':'rbac.authorization.k8s.io','kind':'ClusterRole','name':NAME+'-tokenreview'}, + subjects=[{'kind':'ServiceAccount','name':'review','namespace':NAME}]) + for item in (role,binding): + item['metadata'].pop('namespace') + item['metadata']['annotations']['informed-decision.coulomb.social/admission']='proposal; INFD-WP-0007/INFD-WP-0005 owner review required' + objects.append(item) + + def render(inputs): - if not isinstance(inputs,dict) or (set(inputs)-{'ordinary_approval_ids','lifecycle_policy','lifecycle_delivery'})!={'image','policy','keycape_egress_ips','storage_class'}: + if not isinstance(inputs,dict) or (set(inputs)-{'ordinary_approval_ids','lifecycle_policy','lifecycle_delivery','workload_api','intake_attributes','intake_delivery'})!={'image','policy','keycape_egress_ips','storage_class'}: raise ValueError('exact image, policy, KeyCape egress IPs and storage class inputs required') if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/informed-decision@sha256:[0-9a-f]{64}',inputs['image']): raise ValueError('immutable Informed Decision registry digest required') @@ -67,6 +124,32 @@ def render(inputs): raise ValueError('lifecycle delivery must be boolean') config['lifecycle_delivery']=inputs['lifecycle_delivery'] + if 'intake_attributes' in inputs: + if type(inputs['intake_attributes']) is not bool: + raise ValueError('intake attributes must be boolean') + config['intake_attributes']=inputs['intake_attributes'] + if 'intake_delivery' in inputs: + if inputs['intake_delivery'] != {'hub_origin':'http://state-hub.state-hub.svc.cluster.local:8000'}: + raise ValueError('exact State Hub delivery origin required') + config['intake_delivery']=inputs['intake_delivery'] + if 'workload_api' in inputs: + workload=inputs['workload_api'] + validate_workload(workload) + if workload['lifecycle'] and (not lifecycle or inputs.get('lifecycle_delivery') is not True): + raise ValueError('workload lifecycle needs policy and evidence delivery') + if workload['intake'] and (inputs.get('intake_attributes') is not True or 'intake_delivery' not in inputs): + raise ValueError('private intake needs attribute and delivery composition') + config['workload_api']={'token_review':{'origin':'https://kubernetes.default.svc:443', + 'ca_file':'/var/run/secrets/informed-decision/kubernetes/ca.crt', + 'token_file':'/var/run/secrets/informed-decision/kubernetes/token'}} + if workload['lifecycle']: + config['workload_api']['lifecycle_callers_file']='/workload-registry/lifecycle-callers.json' + if workload['intake']: + config['workload_api']['intake']={ + 'secret_file':'/var/run/secrets/informed-decision/requester/client-secret', + 'submitters_file':'/workload-registry/intake-submitters.json', + 'groups_file':'/workload-registry/review-groups.json'} + raw=json.dumps(config,sort_keys=True,separators=(',',':')) config_name=NAME+'-runtime-'+hashlib.sha256(raw.encode()).hexdigest()[:12] def obj(api,kind,name,**values): @@ -124,6 +207,11 @@ def render(inputs): # its exact peer for CNI enforcement after Service DNAT. {'to':[peer('kube-system','traefik')],'ports':[{'protocol':'TCP','port':8443}]}, {'to':[{'ipBlock':{'cidr':cidr}} for cidr in cidrs],'ports':[{'protocol':'TCP','port':443}]}]})] + if 'intake_delivery' in inputs: + objects[5]['spec']['egress'].append({'to':[peer('state-hub','state-hub')], + 'ports':[{'protocol':'TCP','port':8000}]}) + if 'workload_api' in inputs: + add_workload_objects(objects, pod, inputs['workload_api'], obj, readonly, peer) # Counterparty proposals travel in the review packet. Neither the existing # Approval Engine namespace gate nor other Flex callers admits this pod. # Audit Core already owns its prepared exact informed-decision ingress. diff --git a/tools/smoke_container.py b/tools/smoke_container.py index 3a8d2fc..53d3979 100644 --- a/tools/smoke_container.py +++ b/tools/smoke_container.py @@ -20,6 +20,7 @@ def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--image',required=True) parser.add_argument('--receipt',type=Path,required=True) + parser.add_argument('--workload',action='store_true',help='exercise the separate private workload listener') args=parser.parse_args() prefix='infd-proof-'+uuid.uuid4().hex[:12] volumes=[];containers=[];checks=[] @@ -89,6 +90,17 @@ assert s.submission(d.id)['state']=='unresolved' 'package':'container.fixture','version':'v1','package_digest':'sha256:'+'a'*64, 'caller_token_file':'/configuration/caller-token'}, 'audit':{'origin':'http://127.0.0.1:18084','sender_token_file':'/configuration/audit-token'}} + if args.workload: + config['lifecycle_policy']={**config['policy'],'package':'container.lifecycle.fixture'} + config['lifecycle_delivery']=True + config['workload_api']={'token_review':{'origin':'https://127.0.0.1:18443', + 'ca_file':'/configuration/absent-ca','token_file':'/configuration/caller-token'}, + 'lifecycle_callers_file':'/configuration/lifecycle-callers.json', + 'intake':{'secret_file':'/configuration/absent-requester', + 'submitters_file':'/configuration/intake-submitters.json', + 'groups_file':'/configuration/review-groups.json'}} + for name in ('lifecycle-callers.json','intake-submitters.json','review-groups.json'): + (fixture/name).write_text('{}') (fixture/'runtime.json').write_text(json.dumps(config)) for file in fixture.iterdir():file.chmod(0o444) data=volume('data');backup=volume('backup') @@ -102,6 +114,10 @@ assert s.submission(d.id)['state']=='unresolved' checks.append('private config/database modes enforced; package installer absent') command(first,"import urllib.request,urllib.error\nfor path,status in [('/readyz',503),('/review?memo_id=missing',401)]:\n try: urllib.request.urlopen('http://127.0.0.1:8080'+path)\n except urllib.error.HTTPError as e: assert e.code==status\n else: raise AssertionError(path)") checks.append('configured review refuses anonymous access and reports audit-unready status') + if args.workload: + command(first,"import urllib.request,urllib.error\nfor port,path,status in [(8081,'/v1/decisions',401),(8081,'/v1/memos',405),(8081,'/review',404),(8080,'/v1/decisions',404)]:\n try: urllib.request.urlopen('http://127.0.0.1:'+str(port)+path)\n except urllib.error.HTTPError as e: assert e.code==status\n else: raise AssertionError(path)") + checks.append('private listener refuses anonymous lifecycle access and exposes no browser routes') + checks.append('public listener exposes no private workload route') command(first,"from informed_decision.container import single_writer\ntry:\n with single_writer('/data/private'): pass\nexcept ValueError: pass\nelse: raise AssertionError('second writer admitted')") checks.append('a second writer cannot acquire the active evidence volume') command(first,seed) diff --git a/uv.lock b/uv.lock index e0df483..df1dc70 100644 --- a/uv.lock +++ b/uv.lock @@ -181,7 +181,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6,<7" }, { name = "waitress", specifier = ">=3,<4" }, @@ -234,11 +234,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177 } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896 }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860 }, ] [package.optional-dependencies] diff --git a/workplans/INFD-WP-0005-frictionless-decision-intake.md b/workplans/INFD-WP-0005-frictionless-decision-intake.md index b1c68c6..d5d1af2 100644 --- a/workplans/INFD-WP-0005-frictionless-decision-intake.md +++ b/workplans/INFD-WP-0005-frictionless-decision-intake.md @@ -113,7 +113,7 @@ status: wait priority: high blocked_on: message-from:railiance-platform needs_human: true -blocking_reason: "Founder-approved ESO binding/mount accepted for source integration; private listener and CA-verified TokenReview runtime composition, immutable image/config, native custody proof and separate live go remain" +blocking_reason: "Founder-approved ESO binding/mount accepted for source integration; private listener and CA-verified TokenReview runtime composed and tested; native custody proof and separate live go remain" state_hub_task_id: "3b0f536c-e8ee-5493-b663-2f60f5f429ad" decision_id: "0d6792b6-59e4-4fa0-887a-c4e51ea8e131" ``` @@ -130,7 +130,7 @@ id: INFD-WP-0005-T06 status: wait priority: high blocked_on: message-from:access-engine -blocking_reason: "Attribute admission branch and generated submitter/class sets are access-engine's change (msg 306bcdd2); deploy needs founder go" +blocking_reason: "Consumer implemented; FLEX-WP-0033 prepares v12 State Hub / decision-record admission; native verification and concrete rollout approval remain" state_hub_task_id: "c03016da-a795-5c5c-b86e-0ce9afc67be6" ``` @@ -167,7 +167,7 @@ id: INFD-WP-0005-T08 status: wait priority: medium blocked_on: message-from:state-hub -blocking_reason: "state-hub must choose the actual SA and supply reviewed grant/runtime forwarding (msg e30dea31), alongside T05/T06" +blocking_reason: "Founder selected system:serviceaccount:state-hub:state-hub; production token projection and runtime forwarding remain, alongside T05/T06" state_hub_task_id: "ce2b36b4-ef82-53b7-aee7-e0063855370b" ``` @@ -259,3 +259,15 @@ private listener, CA-verified TokenReview transport, ESO TokenRequest check, immutable owner image/config and native rotation/revocation proof remain. T05 stays wait and the wider human decision stays open for the concrete live packet. No live action, credential retrieval or issuer mutation occurred. + + +## Production runtime preparation — 2026-10-04 + +Private intake/lifecycle listeners, CA-verified TokenReview, rotating requester +projection composition and attribute-admission consumer checks are implemented. +Founder selected net-kingdom-admins humans and the State Hub workload. Initial +registries are review candidates under deploy/lifecycle; the empty legacy +registries and live runtime remain unchanged. T05/T06 remain waiting on native +custody and policy deployment; T08 still needs the State Hub production forwarder, +not merely its dev fixture. See docs/specs/LifecycleProductionRuntime.md and +FLEX-WP-0033 for exact boundaries. diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index 02df8a1..67d318f 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -249,3 +249,28 @@ Validation for this continuation: full cross-repository suite **634 passed, 5 skipped**; `git diff --check` passed. Schema-4 upgrade preserves existing records and marks schema 5 to fence older readers. Implementation decision logged as State Hub `ccb2e110-6032-4259-a402-989415a8d9fe` (not a rollout approval). + + +## First production audience and runtime candidate — 2026-10-04 + +Founder selected net-kingdom-admins humans and the State Hub workload. Added the +private 8081 composition with fresh CA-verified TokenReview and exact workload +registration, shared-store public/private serving and guarded attribute review. +A revision's editor now remains authorized to its unpublished successor through +immutable revision_by facts. Actual Access Engine HTTP integration proves this +with the separately pinned lifecycle policy (FLEX-WP-0033). + +Published immutable service/policy candidates and retained the reviewable packet +at deploy/lifecycle/README.md. Both scans have zero HIGH/CRITICAL findings after +fixing service JWT pins and inherited Access Engine Go/OPA/telemetry dependencies. +All 25 Kubernetes objects passed server dry-run; 662 service/component tests pass +(1 skipped), as do 13 installed-container checks. Access Engine full regression +and candidate package validations pass. Tests are not native custody proof. + +No live change or human binding was performed. T02–T06 remain in progress for +native contracts, State Hub production forwarding, browser packet/binding +profiles and complete live acceptance. T07 still requires concrete rollout go. +CCR-2026-0032 / RPF-WP-0051 owns requester delivery; INFD-WP-0005-T08 owns State +Hub forwarding/token projections. The exact chosen identity is no longer an open +question. Rollback must retain new evidence and must not run a schema-2 reader +on the migrated schema-5 database.