diff --git a/UPSTREAM.json b/UPSTREAM.json index e8640ed..9ba37bf 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -13,7 +13,7 @@ "assets/interlock-two-roles.svg": "9fd03b2654226824b733617d1069ae831739141e2ee0ec6b67d4c4749689751e", "broker/broker.go": "c4250c3f2afce78e060b31fc165cc1d37fb181cf3fce07392b90ea98c2b548f0", "broker/broker_test.go": "5ccf4f6aad1ae846ce405f92fc6c75728e4adec49eaedcdd45f76e6b91dc0a6b", - "broker/envelope.go": "a1f6e2333c45f5f15ce1b1d58cd29589cbc35abcdb0f93ea01c56060f7dcafcf", + "broker/envelope.go": "53a5203c9650511083481d8ca1db6e6424b5be92e9ec7d90347ca0723c958447", "broker/generality_test.go": "14b83f8dd8e25562cd5d099e7d0b9285d15d2874cddd42f3901303c0553ee596", "clients/.gitignore": "e44a4a6d3c3287bc82212abbac01e1992247c3cb2b3e5bff8ecfd7ee73788bb5", "clients/gen/main.go": "2a11e7de9fe7eb1ed8a19435ee5421923a399bc32834da94c0497b5d59728016", @@ -68,17 +68,19 @@ "examples/exclusive-publish/policy.go": "d18df2ada6684bc1815d19b4918b7ba8b7b584d7fd75e9fc7f8c0562299cd94c", "examples/generated-file-protection/policy.go": "0a9a5b33990adb770c74382bbab7e618582fd8c2ce6632deba5ca9cc652696ee", "examples/release-manifest/policy.go": "d2f005addc2842f0ec235feb64315776aa936aa2e49bb82325867b3c9bcb691c", + "examples/release-manifest/publish_test.go": "25bad210cd30e1d0e2ef4f6328856d18919a5fcda3bfd6d72ce2d770ffeb4868", "examples/repository-policy/policy.go": "8c7fe12c487c1369d5ce8ae24e647e050557cd2bf29c56d4010b782603debbd1", "go.mod": "a9a846b064eac2e330c18198dae19044e438bff31dcb26d928cdb2f9b5cae3e1", "install.ps1": "ada3f94562569929446dd6ca200f993854fa10081785e3ad1a09db285dacca6a", "install.sh": "7d62cccc9b35280490c3332279743050957ab7e648b4b115c86211ba8995ab4e", "interlock.go": "de4ff7ee8dd15ab12d0bea387b66ad6e7abfbb66553839ecc0befc32ec8fb606", "interlock_test.go": "06b56780f7ae90fe12b055bf426aab8b87ed4fe174698a49d729bdc6484299e2", - "ir/ir.go": "5cf039998f609e9f13f770bc518ba9127389320c9f5a71dd8ae3e5240762e718", + "ir/ir.go": "20fe77a927c8f89ac90b5a806579854934ff5d3977a014c236d30377860ec310", "ir/ir_test.go": "64a962436ee5cab261093f8cb455ad92075683a7ef58997dac1e022e09d1a46a", - "proof/proof.go": "9aec2cb135b09467445d3e46880913d65ca3076860d168f5fc98bb1aa5590948", + "proof/proof.go": "22a3baeee05bfb1e9f2be549a07b873ddc51d36037711ffef31e77ee161c24fb", "proof/proof_test.go": "8151c10b3efc95e8eb66841ce979093a35263adaf0b6f337b1fe5a3184769123", "protocol/protocol.go": "1d9cc6ba1d59635e9c88ab9a20058491c78d0024e5e87fd25ec87628271c728e", + "publish/publish.go": "2434a5c00da06f97a27aae77f6b9ca8a006027e37123eac627032cce0a7f3434", "receipt/receipt.go": "8a21b9054b599965221e08fe158887759473d2f41d28dd3df42a1ffe1c55cb6e", "receipt/receipt_test.go": "a215a205205573bb0fbf6d121aa761bc28510346b92e8bb72dd85ca90b6fa65c", "scaffold/demo.go": "d496e57f31a17165a540f704704ae341e66641139795263bee2dcfb9ca1de7c7", @@ -93,7 +95,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "0584cd08a481c5d9cf49829a8e73158dd2410802", + "commit": "9ec0e3c0251ee23dd771b8a42d0f9f7b34d4d4d7", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/broker/envelope.go b/broker/envelope.go index e2aaf3c..c070f37 100644 --- a/broker/envelope.go +++ b/broker/envelope.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/operatorstack/interlock/ir" ) @@ -38,6 +39,73 @@ type upstreamEnvelope struct { ArtifactSHA256 string `json:"artifact_sha256"` } +// UpstreamEvidence is the tenant-owned data a caller supplies to write an +// upstream evidence envelope. The tenant owns the meaning of Schema and Status; +// Interlock never interprets either. Deliberately absent is the artifact hash: +// WriteUpstreamEnvelope computes it from the staged bytes themselves, so the +// tagged-vs-bare-hex footgun is unrepresentable — a caller cannot supply a hash +// at all, let alone the wrong format. +type UpstreamEvidence struct { + Schema string + RunID string + Status string +} + +// WriteUpstreamEnvelope writes the durable upstream evidence envelope that +// readUpstreamEnvelope re-reads. It binds the envelope to the exact staged bytes +// by computing ir.HashBytes(staged) internally (never a caller-supplied hash), +// and emits byte-identical output to what the reader decodes: the four +// upstreamEnvelope fields in struct order via json.Marshal plus a trailing +// newline. Co-locating the writer with the reader is why they can never drift. +func WriteUpstreamEnvelope(path string, ev UpstreamEvidence, staged []byte) error { + data, err := json.Marshal(upstreamEnvelope{ + Schema: ev.Schema, + RunID: ev.RunID, + Status: ev.Status, + ArtifactSHA256: ir.HashBytes(staged), + }) + if err != nil { + return fmt.Errorf("interlock/broker: marshal upstream envelope: %w", err) + } + if err := WriteFileAtomic(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("interlock/broker: write upstream envelope: %w", err) + } + return nil +} + +// WriteFileAtomic writes data to a same-directory temp file (fsync'd, chmod'd) +// and renames it into place, so a reader never observes a partial file. This is a +// generic durable-write utility — NOT the broker's protected effect, which is the +// policy-gated atomic publish in Publish. It is exported so the publishing façade +// can persist evidence through the same implementation rather than duplicating it. +func WriteFileAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if _, err = file.Write(data); err != nil { + file.Close() + return err + } + if err = file.Sync(); err != nil { + file.Close() + return err + } + if err = file.Chmod(mode); err != nil { + file.Close() + return err + } + if err = file.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + // readUpstreamEnvelope reads the envelope at path, verifies it correlates to the // expected run and is hash-bound to the expected artifact bytes, and returns the // envelope plus its own content hash (in ir.HashBytes tagged format) as an diff --git a/examples/release-manifest/publish_test.go b/examples/release-manifest/publish_test.go new file mode 100644 index 0000000..b8b7f05 --- /dev/null +++ b/examples/release-manifest/publish_test.go @@ -0,0 +1,119 @@ +package main + +// Slice 2 of the interface-optimization exercise: a SECOND, non-DeltaWire tenant +// publishes through the exact same interlock/publish façade DeltaWire uses. The +// only tenant-specific inputs are this tenant's own policy, schema, status, actor, +// and resource — there is no DeltaWire branch anywhere in the façade or broker. +// This proves the M3 generality claim at the ergonomic layer, not just the core. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/interlock/publish" +) + +// releaseManifestPolicy compiles this example's builder to canonical IR and loads +// it through the same exported loader a tenant would use. +func releaseManifestPolicy(t *testing.T) publish.Policy { + t.Helper() + ir, err := Build().Emit() + if err != nil { + t.Fatalf("emit policy: %v", err) + } + p, err := publish.LoadPolicy(ir) + if err != nil { + t.Fatalf("load policy: %v", err) + } + return p +} + +// The release bot publishes an attested manifest through the shared façade, using +// its own schema and status — no DeltaWire assumptions. +func TestReleaseManifestPublishesViaFacade(t *testing.T) { + policy := releaseManifestPolicy(t) + dir := t.TempDir() + + const content = `{"version":"1.2.3"}` + staged := filepath.Join(dir, "staging", "release-manifest.json") + if err := os.MkdirAll(filepath.Dir(staged), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(staged, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "dist", "release-manifest.json") + evidenceDir := filepath.Join(dir, "evidence") + + res, err := publish.Publish(publish.Request{ + Policy: policy, + RunID: "rel-run", + RequestID: "rel-1", + Actor: "release-bot", + ResourceURI: "repo://dist/release-manifest.json", + Kind: publish.KindFile, + StagedPath: staged, + TargetPath: target, + // Target must not exist yet. + Upstream: []publish.Evidence{{ + Schema: ReleaseAttestationSchema, + Status: "approved", + }}, + EvidenceDir: evidenceDir, + }) + if err != nil { + t.Fatalf("publish: %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read published: %v", err) + } + if string(got) != content { + t.Fatalf("published bytes = %q, want %q", got, content) + } + if res.PublishedHash != publish.HashBytes([]byte(content)) { + t.Fatalf("published hash %q != content hash", res.PublishedHash) + } +} + +// A foreign receipt schema must fail closed even through the ergonomic façade: +// schema is policy data, and the façade never interprets or privileges it. +func TestReleaseManifestForeignSchemaFailsClosed(t *testing.T) { + policy := releaseManifestPolicy(t) + dir := t.TempDir() + + const content = `{"version":"1.2.3"}` + staged := filepath.Join(dir, "staging", "release-manifest.json") + if err := os.MkdirAll(filepath.Dir(staged), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(staged, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "dist", "release-manifest.json") + + _, err := publish.Publish(publish.Request{ + Policy: policy, + RunID: "rel-run", + RequestID: "rel-2", + Actor: "release-bot", + ResourceURI: "repo://dist/release-manifest.json", + Kind: publish.KindFile, + StagedPath: staged, + TargetPath: target, + Upstream: []publish.Evidence{{ + // DeltaWire's schema — foreign to this tenant's policy. + Schema: "deltawire.supervision.receipt.v1", + Status: "released", + }}, + EvidenceDir: filepath.Join(dir, "evidence"), + }) + if err == nil { + t.Fatal("publish accepted a foreign receipt schema") + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("fail-closed publish still created the target") + } +} diff --git a/ir/ir.go b/ir/ir.go index 85770ed..78771ee 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -241,3 +241,19 @@ func HashBytes(b []byte) string { sum := sha256.Sum256(b) return "sha256:" + hex.EncodeToString(sum[:]) } + +// LoadPolicy decodes canonical policy bytes and verifies the protocol tag. It +// does not re-canonicalize: policy identity is still established by Policy.Hash +// at decide time, so round-tripping already-canonical bytes changes no canonical +// output. This is the exported loader every tenant needs; without it each one +// hand-rolls the same json.Unmarshal + protocol check. +func LoadPolicy(b []byte) (Policy, error) { + var p Policy + if err := json.Unmarshal(b, &p); err != nil { + return Policy{}, fmt.Errorf("interlock/ir: decode policy: %w", err) + } + if p.Protocol != Protocol { + return Policy{}, fmt.Errorf("interlock/ir: policy protocol %q != %q", p.Protocol, Protocol) + } + return p, nil +} diff --git a/proof/proof.go b/proof/proof.go index 70cb911..4f4ec98 100644 --- a/proof/proof.go +++ b/proof/proof.go @@ -300,15 +300,20 @@ func brokerPublish(policy ir.Policy, req broker.PublishRequest) (broker.Result, return broker.Publish(policy, req, receipt.NewChain(req.RunID)) } -// writeEnvelope writes a 4-field upstream evidence envelope (the exact shape -// broker/envelope.go re-reads) and returns its path. artifactHash must already -// be in ir.HashBytes tagged form ("sha256:"+hex). -func writeEnvelope(dir, name, schema, runID, status, artifactHash string) (string, error) { - body := fmt.Sprintf( - `{"schema":%q,"run_id":%q,"status":%q,"artifact_sha256":%q}`, - schema, runID, status, artifactHash, - ) - return writeFile(dir, name, body) +// writeEnvelope writes an upstream evidence envelope via Interlock's own exported +// writer, which hash-binds it to the staged bytes, and returns its path. Using +// broker.WriteUpstreamEnvelope (rather than hand-formatting the JSON) means the +// proofs exercise the same writer tenants use and cannot drift from the reader. +func writeEnvelope(dir, name, schema, runID, status string, staged []byte) (string, error) { + path := filepath.Join(dir, name) + if err := broker.WriteUpstreamEnvelope(path, broker.UpstreamEvidence{ + Schema: schema, + RunID: runID, + Status: status, + }, staged); err != nil { + return "", err + } + return path, nil } // --- Proof 6: broker publishes byte-exact staged content ------------------ @@ -326,7 +331,7 @@ func proofBrokerByteExact() (string, error) { return "", err } env, err := writeEnvelope(dir, "envelope.json", - "deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes([]byte(content))) + "deltawire.supervision.receipt.v1", "run1", "released", []byte(content)) if err != nil { return "", err } @@ -365,7 +370,7 @@ func proofStaleTargetFailsClosed() (string, error) { return "", err } env, err := writeEnvelope(dir, "envelope.json", - "deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes([]byte(content))) + "deltawire.supervision.receipt.v1", "run1", "released", []byte(content)) if err != nil { return "", err } @@ -401,7 +406,7 @@ func proofCrossRunEvidenceFailsClosed() (string, error) { // Envelope hash-bound to the staged bytes but correlated to a DIFFERENT run — // the shape of a copied receipt reused across runs. env, err := writeEnvelope(dir, "envelope.json", - "deltawire.supervision.receipt.v1", "other-run", "released", ir.HashBytes([]byte(content))) + "deltawire.supervision.receipt.v1", "other-run", "released", []byte(content)) if err != nil { return "", err } @@ -475,7 +480,7 @@ func proofSecondTenantSameBroker() (string, error) { // This tenant's own schema/status, hash-bound to its own artifact. env, err := writeEnvelope(dir, "attestation.json", - "release.attestation.v1", "rel-run", "approved", ir.HashBytes([]byte(content))) + "release.attestation.v1", "rel-run", "approved", []byte(content)) if err != nil { return "", err } @@ -494,7 +499,7 @@ func proofSecondTenantSameBroker() (string, error) { // A foreign receipt schema must fail closed even when hash-bound and // run-correlated: schema is policy data, not broker-privileged. badEnv, err := writeEnvelope(dir, "foreign.json", - "deltawire.supervision.receipt.v1", "rel-run", "released", ir.HashBytes([]byte(content))) + "deltawire.supervision.receipt.v1", "rel-run", "released", []byte(content)) if err != nil { return "", err } diff --git a/publish/publish.go b/publish/publish.go new file mode 100644 index 0000000..6b618e3 --- /dev/null +++ b/publish/publish.go @@ -0,0 +1,194 @@ +// Package publish is Interlock's high-level publishing façade: the smallest +// interface a tenant needs to publish a verified staged candidate to a protected +// target through the broker. It collapses the glue every tenant would otherwise +// re-implement — writing the upstream evidence envelope, assembling the many-field +// broker.PublishRequest, creating and retaining the receipt chain, and persisting +// the decision evidence — around the ONE authoritative broker.Publish call. +// +// This is a leaf package by design: it does NOT pull in the authoring toolchain +// (compiler/spec) that the root interlock package carries, so a tenant can import +// publishing alone. A tenant that imports interlock/publish need not import +// interlock/broker, interlock/ir, or interlock/receipt directly (the loader, +// resource kinds, and policy type are re-exported below). +// +// Authority boundary — what this façade MUST NOT do. The honest guarantee lives +// in the broker: it hashes the real staged bytes, re-reads each upstream envelope +// from disk taking schema+status FROM THE FILE, verifies target prior-state, and +// fails closed. This façade may construct, resolve, compute expected state, +// serialize the envelope, and retain the receipt chain. It never calls the engine, +// never reads or writes the target path, never invents evidence, and never +// interprets what a tenant's upstream status means — status flows through +// uninterpreted into the envelope the broker re-reads. +package publish + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/receipt" +) + +// Re-exports so a tenant imports interlock/publish alone. These are the low-level +// stable symbols a publish caller still legitimately names. +type ( + // Policy is an alias for ir.Policy — the compiled decision table. + Policy = ir.Policy + // ResourceKind is an alias for ir.ResourceKind (file, tree, …). + ResourceKind = ir.ResourceKind +) + +// KindFile re-exports ir.KindFile, the common publish resource kind. +const KindFile = ir.KindFile + +// LoadPolicy re-exports ir.LoadPolicy: decode canonical policy bytes + verify the +// protocol tag. +func LoadPolicy(b []byte) (Policy, error) { return ir.LoadPolicy(b) } + +// HashBytes re-exports ir.HashBytes: the tagged content hash ("sha256:"+hex) +// Interlock uses, so a tenant can verify published bytes without importing +// interlock/ir. +func HashBytes(b []byte) string { return ir.HashBytes(b) } + +// Evidence is one durable upstream receipt a tenant attaches to a publish. The +// tenant owns the meaning of Schema and Status; Interlock never interprets +// either. No hash field: the façade binds each envelope to the staged bytes. +type Evidence struct { + Schema string + Status string +} + +// Request is one logical publication. It carries the same fields the broker +// ultimately needs, but the tenant supplies them once, by name, instead of +// assembling broker.PublishRequest, writing envelopes, and managing a chain. +type Request struct { + Policy Policy + RunID string + RequestID string + Actor string + ResourceURI string + Kind ResourceKind + StagedPath string + TargetPath string + + // ExpectedTargetHash is the prior hash the target must currently have. Empty + // means the target must NOT exist — mirroring the broker, so there is no + // implicit overwrite. Use TargetHashOf to adopt the current target state. + ExpectedTargetHash string + + Upstream []Evidence + + // EvidenceDir is where the upstream envelope(s), broker decision result, and + // retained receipt chain are persisted as durable run evidence. + EvidenceDir string +} + +// Result wraps the broker's decision result and the retained receipt chain. +type Result struct { + broker.Result + Chain *receipt.Chain +} + +// upstreamEnvelopeName is the on-disk name of the single upstream evidence +// envelope. Multiple upstream receipts are indexed to keep names stable. +const upstreamEnvelopeName = "interlock-upstream-envelope.json" + +// Publish performs one publication through the broker. It reads the staged bytes +// once, writes an upstream evidence envelope per Evidence (status flows through +// uninterpreted), assembles the broker request, creates and retains a receipt +// chain, and calls the one authoritative broker.Publish. On any outcome that +// produced a decision it persists the decision result and chain as run evidence; +// unlike a hand-rolled integration it surfaces — rather than swallows — an +// evidence-persistence failure, since an unaudited effect must not report success. +func Publish(req Request) (Result, error) { + staged, err := os.ReadFile(req.StagedPath) + if err != nil { + return Result{}, fmt.Errorf("interlock/publish: read staged candidate: %w", err) + } + + upstream := make([]broker.UpstreamReceipt, 0, len(req.Upstream)) + for i, ev := range req.Upstream { + name := upstreamEnvelopeName + if i > 0 { + name = fmt.Sprintf("interlock-upstream-envelope.%d.json", i) + } + path := filepath.Join(req.EvidenceDir, name) + if err := broker.WriteUpstreamEnvelope(path, broker.UpstreamEvidence{ + Schema: ev.Schema, + RunID: req.RunID, + Status: ev.Status, + }, staged); err != nil { + return Result{}, err + } + upstream = append(upstream, broker.UpstreamReceipt{Path: path}) + } + + chain := receipt.NewChain(req.RunID) + res, pubErr := broker.Publish(req.Policy, broker.PublishRequest{ + RunID: req.RunID, + RequestID: req.RequestID, + Actor: req.Actor, + ResourceURI: req.ResourceURI, + Kind: req.Kind, + StagedPath: req.StagedPath, + TargetPath: req.TargetPath, + ExpectedTargetHash: req.ExpectedTargetHash, + Upstream: upstream, + }, chain) + + // Persist evidence whenever the engine ran (a receipt was appended) — this + // covers both allow and deny; pre-decision fail-closed faults append nothing + // and leave no misleading partial evidence, matching prior behavior. + var persistErr error + if req.EvidenceDir != "" && len(chain.Receipts) > 0 { + persistErr = persistEvidence(req.EvidenceDir, res, chain) + } + + result := Result{Result: res, Chain: chain} + if pubErr != nil { + // The broker outcome is primary: return it verbatim so the caller sees the + // exact fail-closed reason (denied/required/fault/stale/missing-envelope). + return result, pubErr + } + if persistErr != nil { + return result, fmt.Errorf("interlock/publish: publish succeeded but persisting evidence failed: %w", persistErr) + } + return result, nil +} + +// persistEvidence writes the broker decision result and the retained receipt +// chain as durable run evidence, atomically. +func persistEvidence(dir string, res broker.Result, chain *receipt.Chain) error { + resultData, err := json.MarshalIndent(res, "", " ") + if err != nil { + return fmt.Errorf("interlock/publish: marshal broker result: %w", err) + } + if err := broker.WriteFileAtomic(filepath.Join(dir, "interlock-broker-receipt.json"), append(resultData, '\n'), 0o600); err != nil { + return err + } + chainData, err := json.MarshalIndent(chain, "", " ") + if err != nil { + return fmt.Errorf("interlock/publish: marshal receipt chain: %w", err) + } + if err := broker.WriteFileAtomic(filepath.Join(dir, "interlock-receipt-chain.json"), append(chainData, '\n'), 0o600); err != nil { + return err + } + return nil +} + +// TargetHashOf returns the current target's content hash in Interlock's tagged +// format, or "" if the target does not exist yet — the value to pass as +// Request.ExpectedTargetHash to adopt the current state (or require absence). +func TargetHashOf(path string) (string, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("interlock/publish: read target: %w", err) + } + return ir.HashBytes(b), nil +}