Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ before:
- "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak"

builds:
- id: workflow-plugin-TEMPLATE
main: ./cmd/workflow-plugin-TEMPLATE
binary: workflow-plugin-TEMPLATE
- id: workflow-plugin-audit-chain
main: ./cmd/workflow-plugin-audit-chain
binary: workflow-plugin-audit-chain
env:
- CGO_ENABLED=0
goos:
Expand All @@ -19,7 +19,7 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}} -X github.com/GoCodeAlone/workflow-plugin-TEMPLATE/internal.Version={{.Version}}
- -s -w -X main.version={{.Version}} -X github.com/GoCodeAlone/workflow-plugin-audit-chain/internal.Version={{.Version}}

archives:
- formats: [tar.gz]
Expand Down
28 changes: 19 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,51 @@
# CLAUDE.md — Workflow Plugin Template
# CLAUDE.md — workflow-plugin-audit-chain

External gRPC plugin for the GoCodeAlone/workflow engine.
External gRPC plugin for the GoCodeAlone/workflow engine providing tamper-evident
hash-chained audit logging with periodic Merkle root anchoring.

## Build & Test

```sh
go build ./...
go test ./... -v -race -count=1
GOWORK=off go build ./...
GOWORK=off go test ./... -v -race -count=1
```

## Cross-compile for deployment

```sh
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o workflow-plugin-TEMPLATE ./cmd/workflow-plugin-TEMPLATE/
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o workflow-plugin-audit-chain ./cmd/workflow-plugin-audit-chain/
```

## Regenerate proto bindings

```sh
make proto-gen
```

## Structure

- `cmd/workflow-plugin-TEMPLATE/main.go` — Plugin entry point (calls `sdk.Serve`)
- `internal/plugin.go` — Plugin manifest, module factories, step factories
- `cmd/workflow-plugin-audit-chain/main.go` — Plugin entry point (calls `sdk.Serve`)
- `internal/plugin.go` — Plugin manifest, module factories, step factories, trigger factories
- `internal/` — All module and step implementations
- `proto/audit.proto` — Proto contracts for all step input/output types
- `gen/audit.pb.go` — Generated Go bindings (committed; regenerate via `make proto-gen`)
- `plugin.json` — Capability manifest for the workflow registry
- `plugin.contracts.json` — Typed step contracts mapping step types to proto messages
- `.goreleaser.yaml` — GoReleaser v2 config for cross-platform releases
- `.github/workflows/ci.yml` — CI on push/PR (build + test)
- `.github/workflows/release.yml` — Release on v* tag push (GoReleaser)

## Adding a Module Type

1. Create `internal/module_example.go` implementing the module
2. Register in `internal/plugin.go` ModuleFactories()
2. Register in `internal/plugin.go` ModuleTypes() and CreateModule()
3. Add to `plugin.json` capabilities.moduleTypes
4. Add tests in `internal/module_example_test.go`

## Adding a Step Type

1. Create `internal/step_example.go` implementing the step
2. Register in `internal/plugin.go` StepFactories()
2. Register in `internal/plugin.go` StepTypes() and CreateStep()
3. Add to `plugin.json` capabilities.stepTypes
4. Add tests in `internal/step_example_test.go`

Expand Down
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.PHONY: proto-gen build test test-migrations vet

# Regenerate Go bindings from proto/audit.proto.
# Requires: protoc + protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest)
proto-gen:
protoc \
--proto_path=proto \
--go_out=gen \
--go_opt=paths=source_relative \
proto/audit.proto

build:
GOWORK=off go build ./...

test:
GOWORK=off go test ./... -v -race -count=1

test-migrations:
GOWORK=off go test ./chain/... -v -race -count=1 -run TestMigrations

vet:
GOWORK=off go vet ./...
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# workflow-plugin-audit-chain

A [GoCodeAlone/workflow](https://github.com/GoCodeAlone/workflow) external plugin providing **tamper-evident hash-chained audit logging** with periodic Merkle root anchoring to external trust providers (OpenTimestamps/Bitcoin, git, Sigstore, Ethereum, AWS QLDB).

Each audit log entry is hash-chained to the previous one. Any post-hoc tampering breaks the chain and is detectable via `step.audit.verify`. Daily Merkle roots are anchored externally so integrity guarantees survive even a compromised database.

**Design spec:** `docs/plans/2026-05-02-prereq-workflow-plugin-audit-chain-design.md` in the BMW E2E fulfillment plan repo.

## Step types

| Step | Purpose |
|---|---|
| `step.audit.append` | Append a hash-chained entry to a ledger (serialised via FOR UPDATE). |
| `step.audit.verify` | Verify chain integrity over a sequence range — O(n). |
| `step.audit.merkle_root` | Build a Merkle tree over a range and return the root. |
| `step.audit.anchor` | Anchor a Merkle root to one or more configured providers. |
| `step.audit.poll_anchor_confirmation` | Poll a pending anchor for confirmation state advancement. |
| `step.audit.proof` | Return a Merkle inclusion proof + anchor records for a sequence. |
| `step.audit.public_receipt` | Generate a verifiable public receipt JSON with optional field redaction. |

## Module types

| Module | Purpose |
|---|---|
| `audit.ledger` | Declares a ledger partition (name, anchor providers, schedule). |
| `audit.anchor_provider.opentimestamps` | Anchors to Bitcoin via OpenTimestamps calendar servers (default; free). |
| `audit.anchor_provider.git` | Commits Merkle root to a git remote (fast redundancy). |
| `audit.anchor_provider.sigstore` | Anchors to Sigstore Rekor transparent log. |
| `audit.anchor_provider.ethereum` | Anchors to Ethereum L1 or L2. |
| `audit.anchor_provider.aws_qldb` | Anchors to AWS Quantum Ledger Database. |

## Trigger types

| Trigger | Purpose |
|---|---|
| `trigger.audit.entry_appended` | Fires a pipeline on each new entry appended to a ledger. |

## Quick start

```yaml
modules:
- name: my-audit-ledger
type: audit.ledger
config:
name: my-ledger
description: "Financial event audit log"
anchor_providers: [opentimestamps, git]
anchor_schedule: "0 1 * * *"
anchor_min_entries: 1

- name: my-ots
type: audit.anchor_provider.opentimestamps
config:
calendar_servers:
- "https://alice.btc.calendar.opentimestamps.org"
- "https://bob.btc.calendar.opentimestamps.org"
```

```yaml
steps:
- name: record_event
type: step.audit.append
config:
ledger: my-ledger
event_type: payment.captured
payload: '{"amount_cents":2000,"item_id":"abc123"}'
actor: stripe-webhook
```

## Build & test

```sh
GOWORK=off go build ./...
GOWORK=off go test ./... -v -race -count=1
make proto-gen # regenerate gen/audit.pb.go from proto/audit.proto
```

## License

MIT — see [LICENSE](LICENSE).
106 changes: 106 additions & 0 deletions chain/append.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package chain

import (
"context"
"database/sql"
"fmt"
)

// Appender writes hash-chained audit entries to Postgres.
// It serialises concurrent appends to the same ledger via a
// SELECT … FOR UPDATE on the audit_ledgers row, which guarantees
// gap-free monotonic sequences without application-level locking.
type Appender struct {
db *sql.DB
}

// NewAppender returns an Appender backed by db.
func NewAppender(db *sql.DB) *Appender {
return &Appender{db: db}
}

// Append opens its own transaction, appends one entry to ledger, and commits.
// metadata is stored as-is in audit_log.metadata (JSONB); pass nil if not needed.
// Returns (sequence, entryHash, error).
func (a *Appender) Append(ctx context.Context, ledger, eventType string, payload, metadata []byte, actor string) (int64, string, error) {
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("chain.Append: begin tx: %w", err)
}
seq, hash, err := a.AppendTx(ctx, tx, ledger, eventType, payload, metadata, actor)
if err != nil {
_ = tx.Rollback()
return 0, "", err
}
if err := tx.Commit(); err != nil {
return 0, "", fmt.Errorf("chain.Append: commit: %w", err)
}
return seq, hash, nil
}

// AppendTx appends one entry within the caller-supplied transaction tx.
// The caller is responsible for commit/rollback. This is the primitive used
// by BMW PR 11 Task 47 (step.bmw.audit_append_with_map) so that the audit
// entry and the business record land in a single atomic transaction.
// metadata is stored as-is in audit_log.metadata (JSONB); pass nil if not needed.
func (a *Appender) AppendTx(ctx context.Context, tx *sql.Tx, ledger, eventType string, payload, metadata []byte, actor string) (int64, string, error) {
// 0. Enforce a server-side lock timeout so a stalled holder surfaces as an
// error rather than blocking indefinitely.
if _, err := tx.ExecContext(ctx, `SET LOCAL lock_timeout = '5s'`); err != nil {
return 0, "", fmt.Errorf("chain.AppendTx: set lock_timeout: %w", err)
}

// 1. Lock the ledger row and read the current cursor.
var lastSeq int64
var lastHash string
err := tx.QueryRowContext(ctx,
`SELECT last_sequence, last_entry_hash
FROM audit_ledgers
WHERE ledger = $1
FOR UPDATE`,
ledger,
).Scan(&lastSeq, &lastHash)
if err == sql.ErrNoRows {
return 0, "", fmt.Errorf("chain.AppendTx: unknown ledger %q", ledger)
}
if err != nil {
return 0, "", fmt.Errorf("chain.AppendTx: lock ledger: %w", err)
}

// 2. Compute hashes.
payloadHash, err := PayloadHash(payload)
if err != nil {
return 0, "", fmt.Errorf("chain.AppendTx: %w", err)
}
seq := lastSeq + 1
// For the genesis entry, prevHash is empty ("").
entryHash := EntryHash(seq, ledger, eventType, payloadHash, lastHash)

// 3. Insert the audit log row.
// created_at uses DB-server NOW() to avoid application clock skew in
// multi-node deployments.
_, err = tx.ExecContext(ctx,
`INSERT INTO audit_log
(sequence, ledger, event_type, payload, payload_hash,
prev_entry_hash, entry_hash, created_at, appended_by_actor, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9)`,
seq, ledger, eventType, payload, payloadHash,
lastHash, entryHash, actor, metadata,
)
if err != nil {
return 0, "", fmt.Errorf("chain.AppendTx: insert audit_log: %w", err)
}

// 4. Advance the ledger cursor.
_, err = tx.ExecContext(ctx,
`UPDATE audit_ledgers
SET last_sequence = $2, last_entry_hash = $3
WHERE ledger = $1`,
ledger, seq, entryHash,
)
if err != nil {
return 0, "", fmt.Errorf("chain.AppendTx: update audit_ledgers: %w", err)
}

return seq, entryHash, nil
}
Loading
Loading