diff --git a/chain/append.go b/chain/append.go index da0cc34..4b21e71 100644 --- a/chain/append.go +++ b/chain/append.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "time" ) // Appender writes hash-chained audit entries to Postgres. @@ -21,21 +22,23 @@ func NewAppender(db *sql.DB) *Appender { // 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) { +// Returns (sequence, entryHash, dbCreatedAt, error). +// createdAt is the server-side timestamp assigned by the DB (via NOW()), not the +// application clock, so it is accurate regardless of NTP skew across nodes. +func (a *Appender) Append(ctx context.Context, ledger, eventType string, payload, metadata []byte, actor string) (int64, string, time.Time, error) { tx, err := a.db.BeginTx(ctx, nil) if err != nil { - return 0, "", fmt.Errorf("chain.Append: begin tx: %w", err) + return 0, "", time.Time{}, fmt.Errorf("chain.Append: begin tx: %w", err) } - seq, hash, err := a.AppendTx(ctx, tx, ledger, eventType, payload, metadata, actor) + seq, hash, createdAt, err := a.AppendTx(ctx, tx, ledger, eventType, payload, metadata, actor) if err != nil { _ = tx.Rollback() - return 0, "", err + return 0, "", time.Time{}, err } if err := tx.Commit(); err != nil { - return 0, "", fmt.Errorf("chain.Append: commit: %w", err) + return 0, "", time.Time{}, fmt.Errorf("chain.Append: commit: %w", err) } - return seq, hash, nil + return seq, hash, createdAt, nil } // AppendTx appends one entry within the caller-supplied transaction tx. @@ -43,11 +46,12 @@ func (a *Appender) Append(ctx context.Context, ledger, eventType string, payload // 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) { +// Returns (sequence, entryHash, dbCreatedAt, error). +func (a *Appender) AppendTx(ctx context.Context, tx *sql.Tx, ledger, eventType string, payload, metadata []byte, actor string) (int64, string, time.Time, 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) + return 0, "", time.Time{}, fmt.Errorf("chain.AppendTx: set lock_timeout: %w", err) } // 1. Lock the ledger row and read the current cursor. @@ -61,34 +65,36 @@ func (a *Appender) AppendTx(ctx context.Context, tx *sql.Tx, ledger, eventType s ledger, ).Scan(&lastSeq, &lastHash) if err == sql.ErrNoRows { - return 0, "", fmt.Errorf("chain.AppendTx: unknown ledger %q", ledger) + return 0, "", time.Time{}, fmt.Errorf("chain.AppendTx: unknown ledger %q", ledger) } if err != nil { - return 0, "", fmt.Errorf("chain.AppendTx: lock ledger: %w", err) + return 0, "", time.Time{}, 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) + return 0, "", time.Time{}, 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, + // 3. Insert the audit log row and return the DB-assigned created_at. + // RETURNING created_at ensures callers receive the server-side timestamp + // (set by NOW() inside Postgres) rather than the application clock. + var createdAt time.Time + err = tx.QueryRowContext(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)`, + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9) + RETURNING created_at`, seq, ledger, eventType, payload, payloadHash, lastHash, entryHash, actor, metadata, - ) + ).Scan(&createdAt) if err != nil { - return 0, "", fmt.Errorf("chain.AppendTx: insert audit_log: %w", err) + return 0, "", time.Time{}, fmt.Errorf("chain.AppendTx: insert audit_log: %w", err) } // 4. Advance the ledger cursor. @@ -99,8 +105,8 @@ func (a *Appender) AppendTx(ctx context.Context, tx *sql.Tx, ledger, eventType s ledger, seq, entryHash, ) if err != nil { - return 0, "", fmt.Errorf("chain.AppendTx: update audit_ledgers: %w", err) + return 0, "", time.Time{}, fmt.Errorf("chain.AppendTx: update audit_ledgers: %w", err) } - return seq, entryHash, nil + return seq, entryHash, createdAt, nil } diff --git a/chain/append_test.go b/chain/append_test.go index df531b4..030593c 100644 --- a/chain/append_test.go +++ b/chain/append_test.go @@ -149,7 +149,7 @@ func TestAppend_FirstEntry_SetsEmptyPrevHash(t *testing.T) { createLedger(t, db, "test-ledger") a := chain.NewAppender(db) - seq, hash, err := a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":1}`), nil, "actor") + seq, hash, createdAt, err := a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":1}`), nil, "actor") if err != nil { t.Fatal(err) } @@ -159,6 +159,9 @@ func TestAppend_FirstEntry_SetsEmptyPrevHash(t *testing.T) { if len(hash) != 64 { t.Errorf("expected 64-char hash, got %d: %s", len(hash), hash) } + if createdAt.IsZero() { + t.Error("Append returned zero createdAt; expected DB-assigned timestamp") + } // First entry must have empty prev_entry_hash. var prev string @@ -179,11 +182,11 @@ func TestAppend_SecondEntry_LinksPrevHash(t *testing.T) { createLedger(t, db, "test-ledger") a := chain.NewAppender(db) - _, h1, err := a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":1}`), nil, "") + _, h1, _, err := a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":1}`), nil, "") if err != nil { t.Fatal(err) } - _, _, err = a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":2}`), nil, "") + _, _, _, err = a.Append(ctx, "test-ledger", "event.x", []byte(`{"k":2}`), nil, "") if err != nil { t.Fatal(err) } @@ -206,7 +209,7 @@ func TestAppend_EntryHashMatchesChainComputation(t *testing.T) { a := chain.NewAppender(db) payload := []byte(`{"amount_cents":2000,"item_id":"abc"}`) - seq, gotHash, err := a.Append(ctx, "test-ledger", "contribution.captured", payload, nil, "stripe") + seq, gotHash, _, err := a.Append(ctx, "test-ledger", "contribution.captured", payload, nil, "stripe") if err != nil { t.Fatal(err) } @@ -227,7 +230,7 @@ func TestAppend_UnknownLedger_ReturnsError(t *testing.T) { db := setupTestDB(t) a := chain.NewAppender(db) - _, _, err := a.Append(ctx, "no-such-ledger", "event.x", []byte(`{}`), nil, "") + _, _, _, err := a.Append(ctx, "no-such-ledger", "event.x", []byte(`{}`), nil, "") if err == nil { t.Error("expected error for unknown ledger") } @@ -246,7 +249,7 @@ func TestAppendTx_ParticipatesInCallerTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - seq, _, err := a.AppendTx(ctx, tx, "test-ledger", "event.x", []byte(`{}`), nil, "actor") + seq, _, _, err := a.AppendTx(ctx, tx, "test-ledger", "event.x", []byte(`{}`), nil, "actor") if err != nil { _ = tx.Rollback() t.Fatal(err) @@ -293,7 +296,7 @@ func TestAppendTx_CommitPersistsEntry(t *testing.T) { if err != nil { t.Fatal(err) } - seq, hash, err := a.AppendTx(ctx, tx, "test-ledger", "event.x", []byte(`{"v":1}`), nil, "") + seq, hash, _, err := a.AppendTx(ctx, tx, "test-ledger", "event.x", []byte(`{"v":1}`), nil, "") if err != nil { _ = tx.Rollback() t.Fatal(err) @@ -342,7 +345,7 @@ func TestAppend_ConcurrentAppends_MonotonicSequence(t *testing.T) { go func() { defer wg.Done() for i := 0; i < entriesEach; i++ { - seq, _, err := a.Append(ctx, "concurrent-ledger", "stress.event", + seq, _, _, err := a.Append(ctx, "concurrent-ledger", "stress.event", []byte(`{"g":1}`), nil, "") mu.Lock() if err != nil { diff --git a/gen/audit.pb.go b/gen/audit.pb.go index 493f635..b1b333b 100644 --- a/gen/audit.pb.go +++ b/gen/audit.pb.go @@ -37,6 +37,9 @@ type LedgerConfig struct { AnchorMinEntries int32 `protobuf:"varint,5,opt,name=anchor_min_entries,json=anchorMinEntries,proto3" json:"anchor_min_entries,omitempty"` // payload_schema is an optional JSON Schema (bytes) for payload validation at append time. PayloadSchema []byte `protobuf:"bytes,6,opt,name=payload_schema,json=payloadSchema,proto3" json:"payload_schema,omitempty"` + // dsn is the PostgreSQL connection string for the backing store + // (e.g. "postgres://user:pass@host/db?sslmode=disable"). + Dsn string `protobuf:"bytes,7,opt,name=dsn,proto3" json:"dsn,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -113,6 +116,245 @@ func (x *LedgerConfig) GetPayloadSchema() []byte { return nil } +func (x *LedgerConfig) GetDsn() string { + if x != nil { + return x.Dsn + } + return "" +} + +// OpenTimestampsProviderConfig is the typed config for audit.anchor_provider.opentimestamps. +type OpenTimestampsProviderConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // calendar_servers lists the OTS calendar server base URLs to submit to. + // At least one is required. + CalendarServers []string `protobuf:"bytes,1,rep,name=calendar_servers,json=calendarServers,proto3" json:"calendar_servers,omitempty"` + // http_timeout_ms is the per-request HTTP timeout in milliseconds. + // 0 means use the default (30 000 ms). + HttpTimeoutMs int64 `protobuf:"varint,2,opt,name=http_timeout_ms,json=httpTimeoutMs,proto3" json:"http_timeout_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenTimestampsProviderConfig) Reset() { + *x = OpenTimestampsProviderConfig{} + mi := &file_audit_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenTimestampsProviderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenTimestampsProviderConfig) ProtoMessage() {} + +func (x *OpenTimestampsProviderConfig) ProtoReflect() protoreflect.Message { + mi := &file_audit_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenTimestampsProviderConfig.ProtoReflect.Descriptor instead. +func (*OpenTimestampsProviderConfig) Descriptor() ([]byte, []int) { + return file_audit_proto_rawDescGZIP(), []int{1} +} + +func (x *OpenTimestampsProviderConfig) GetCalendarServers() []string { + if x != nil { + return x.CalendarServers + } + return nil +} + +func (x *OpenTimestampsProviderConfig) GetHttpTimeoutMs() int64 { + if x != nil { + return x.HttpTimeoutMs + } + return 0 +} + +// GitAnchorProviderConfig is the typed config for audit.anchor_provider.git. +type GitAnchorProviderConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // remote is the git remote URL (file path, https, git+ssh, etc.). Required. + Remote string `protobuf:"bytes,1,opt,name=remote,proto3" json:"remote,omitempty"` + // branch is the branch to push anchors to. Defaults to "main". + Branch string `protobuf:"bytes,2,opt,name=branch,proto3" json:"branch,omitempty"` + // commit_template is a Go text/template string for the commit message. + // Defaults to "anchor: {{.MerkleRoot}}". + CommitTemplate string `protobuf:"bytes,3,opt,name=commit_template,json=commitTemplate,proto3" json:"commit_template,omitempty"` + // author_name is the git commit author name. Defaults to "audit-chain-bot". + AuthorName string `protobuf:"bytes,4,opt,name=author_name,json=authorName,proto3" json:"author_name,omitempty"` + // author_email is the git commit author email. Defaults to "audit-chain-bot@localhost". + AuthorEmail string `protobuf:"bytes,5,opt,name=author_email,json=authorEmail,proto3" json:"author_email,omitempty"` + // use_ssh_agent uses the system SSH agent for authentication. + UseSshAgent bool `protobuf:"varint,6,opt,name=use_ssh_agent,json=useSshAgent,proto3" json:"use_ssh_agent,omitempty"` + // ssh_key_path is the path to a PEM-encoded private key file. + SshKeyPath string `protobuf:"bytes,7,opt,name=ssh_key_path,json=sshKeyPath,proto3" json:"ssh_key_path,omitempty"` + // ssh_key_password is the passphrase for the PEM key at ssh_key_path. + SshKeyPassword string `protobuf:"bytes,8,opt,name=ssh_key_password,json=sshKeyPassword,proto3" json:"ssh_key_password,omitempty"` + // http_username provides HTTP Basic Auth credentials for HTTPS remotes. + HttpUsername string `protobuf:"bytes,9,opt,name=http_username,json=httpUsername,proto3" json:"http_username,omitempty"` + // http_password provides HTTP Basic Auth credentials (or PAT) for HTTPS remotes. + HttpPassword string `protobuf:"bytes,10,opt,name=http_password,json=httpPassword,proto3" json:"http_password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GitAnchorProviderConfig) Reset() { + *x = GitAnchorProviderConfig{} + mi := &file_audit_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GitAnchorProviderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GitAnchorProviderConfig) ProtoMessage() {} + +func (x *GitAnchorProviderConfig) ProtoReflect() protoreflect.Message { + mi := &file_audit_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GitAnchorProviderConfig.ProtoReflect.Descriptor instead. +func (*GitAnchorProviderConfig) Descriptor() ([]byte, []int) { + return file_audit_proto_rawDescGZIP(), []int{2} +} + +func (x *GitAnchorProviderConfig) GetRemote() string { + if x != nil { + return x.Remote + } + return "" +} + +func (x *GitAnchorProviderConfig) GetBranch() string { + if x != nil { + return x.Branch + } + return "" +} + +func (x *GitAnchorProviderConfig) GetCommitTemplate() string { + if x != nil { + return x.CommitTemplate + } + return "" +} + +func (x *GitAnchorProviderConfig) GetAuthorName() string { + if x != nil { + return x.AuthorName + } + return "" +} + +func (x *GitAnchorProviderConfig) GetAuthorEmail() string { + if x != nil { + return x.AuthorEmail + } + return "" +} + +func (x *GitAnchorProviderConfig) GetUseSshAgent() bool { + if x != nil { + return x.UseSshAgent + } + return false +} + +func (x *GitAnchorProviderConfig) GetSshKeyPath() string { + if x != nil { + return x.SshKeyPath + } + return "" +} + +func (x *GitAnchorProviderConfig) GetSshKeyPassword() string { + if x != nil { + return x.SshKeyPassword + } + return "" +} + +func (x *GitAnchorProviderConfig) GetHttpUsername() string { + if x != nil { + return x.HttpUsername + } + return "" +} + +func (x *GitAnchorProviderConfig) GetHttpPassword() string { + if x != nil { + return x.HttpPassword + } + return "" +} + +// SigstoreProviderConfig is the typed config for audit.anchor_provider.sigstore. +type SigstoreProviderConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // rekor_url is the base URL of the Rekor instance. + // Defaults to "https://rekor.sigstore.dev" when empty. + RekorUrl string `protobuf:"bytes,1,opt,name=rekor_url,json=rekorUrl,proto3" json:"rekor_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SigstoreProviderConfig) Reset() { + *x = SigstoreProviderConfig{} + mi := &file_audit_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SigstoreProviderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SigstoreProviderConfig) ProtoMessage() {} + +func (x *SigstoreProviderConfig) ProtoReflect() protoreflect.Message { + mi := &file_audit_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SigstoreProviderConfig.ProtoReflect.Descriptor instead. +func (*SigstoreProviderConfig) Descriptor() ([]byte, []int) { + return file_audit_proto_rawDescGZIP(), []int{3} +} + +func (x *SigstoreProviderConfig) GetRekorUrl() string { + if x != nil { + return x.RekorUrl + } + return "" +} + // AppendRequest is the input for step.audit.append. type AppendRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -132,7 +374,7 @@ type AppendRequest struct { func (x *AppendRequest) Reset() { *x = AppendRequest{} - mi := &file_audit_proto_msgTypes[1] + mi := &file_audit_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -144,7 +386,7 @@ func (x *AppendRequest) String() string { func (*AppendRequest) ProtoMessage() {} func (x *AppendRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[1] + mi := &file_audit_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -157,7 +399,7 @@ func (x *AppendRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendRequest.ProtoReflect.Descriptor instead. func (*AppendRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{1} + return file_audit_proto_rawDescGZIP(), []int{4} } func (x *AppendRequest) GetLedger() string { @@ -210,7 +452,7 @@ type AppendResponse struct { func (x *AppendResponse) Reset() { *x = AppendResponse{} - mi := &file_audit_proto_msgTypes[2] + mi := &file_audit_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -222,7 +464,7 @@ func (x *AppendResponse) String() string { func (*AppendResponse) ProtoMessage() {} func (x *AppendResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[2] + mi := &file_audit_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -235,7 +477,7 @@ func (x *AppendResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendResponse.ProtoReflect.Descriptor instead. func (*AppendResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{2} + return file_audit_proto_rawDescGZIP(), []int{5} } func (x *AppendResponse) GetSequence() int64 { @@ -274,7 +516,7 @@ type VerifyRequest struct { func (x *VerifyRequest) Reset() { *x = VerifyRequest{} - mi := &file_audit_proto_msgTypes[3] + mi := &file_audit_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -286,7 +528,7 @@ func (x *VerifyRequest) String() string { func (*VerifyRequest) ProtoMessage() {} func (x *VerifyRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[3] + mi := &file_audit_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -299,7 +541,7 @@ func (x *VerifyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyRequest.ProtoReflect.Descriptor instead. func (*VerifyRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{3} + return file_audit_proto_rawDescGZIP(), []int{6} } func (x *VerifyRequest) GetLedger() string { @@ -340,7 +582,7 @@ type VerifyResponse struct { func (x *VerifyResponse) Reset() { *x = VerifyResponse{} - mi := &file_audit_proto_msgTypes[4] + mi := &file_audit_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -352,7 +594,7 @@ func (x *VerifyResponse) String() string { func (*VerifyResponse) ProtoMessage() {} func (x *VerifyResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[4] + mi := &file_audit_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -365,7 +607,7 @@ func (x *VerifyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyResponse.ProtoReflect.Descriptor instead. func (*VerifyResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{4} + return file_audit_proto_rawDescGZIP(), []int{7} } func (x *VerifyResponse) GetValid() bool { @@ -411,7 +653,7 @@ type MerkleRootRequest struct { func (x *MerkleRootRequest) Reset() { *x = MerkleRootRequest{} - mi := &file_audit_proto_msgTypes[5] + mi := &file_audit_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -423,7 +665,7 @@ func (x *MerkleRootRequest) String() string { func (*MerkleRootRequest) ProtoMessage() {} func (x *MerkleRootRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[5] + mi := &file_audit_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -436,7 +678,7 @@ func (x *MerkleRootRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MerkleRootRequest.ProtoReflect.Descriptor instead. func (*MerkleRootRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{5} + return file_audit_proto_rawDescGZIP(), []int{8} } func (x *MerkleRootRequest) GetLedger() string { @@ -478,7 +720,7 @@ type MerkleRootResponse struct { func (x *MerkleRootResponse) Reset() { *x = MerkleRootResponse{} - mi := &file_audit_proto_msgTypes[6] + mi := &file_audit_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -490,7 +732,7 @@ func (x *MerkleRootResponse) String() string { func (*MerkleRootResponse) ProtoMessage() {} func (x *MerkleRootResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[6] + mi := &file_audit_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -503,7 +745,7 @@ func (x *MerkleRootResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MerkleRootResponse.ProtoReflect.Descriptor instead. func (*MerkleRootResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{6} + return file_audit_proto_rawDescGZIP(), []int{9} } func (x *MerkleRootResponse) GetRoot() string { @@ -551,7 +793,7 @@ type AnchorRequest struct { func (x *AnchorRequest) Reset() { *x = AnchorRequest{} - mi := &file_audit_proto_msgTypes[7] + mi := &file_audit_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -563,7 +805,7 @@ func (x *AnchorRequest) String() string { func (*AnchorRequest) ProtoMessage() {} func (x *AnchorRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[7] + mi := &file_audit_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -576,7 +818,7 @@ func (x *AnchorRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AnchorRequest.ProtoReflect.Descriptor instead. func (*AnchorRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{7} + return file_audit_proto_rawDescGZIP(), []int{10} } func (x *AnchorRequest) GetLedger() string { @@ -618,7 +860,7 @@ type AnchorResponse struct { func (x *AnchorResponse) Reset() { *x = AnchorResponse{} - mi := &file_audit_proto_msgTypes[8] + mi := &file_audit_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -630,7 +872,7 @@ func (x *AnchorResponse) String() string { func (*AnchorResponse) ProtoMessage() {} func (x *AnchorResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[8] + mi := &file_audit_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -643,7 +885,7 @@ func (x *AnchorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AnchorResponse.ProtoReflect.Descriptor instead. func (*AnchorResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{8} + return file_audit_proto_rawDescGZIP(), []int{11} } func (x *AnchorResponse) GetAnchors() []*AnchorRecord { @@ -670,7 +912,7 @@ type AnchorRecord struct { func (x *AnchorRecord) Reset() { *x = AnchorRecord{} - mi := &file_audit_proto_msgTypes[9] + mi := &file_audit_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -682,7 +924,7 @@ func (x *AnchorRecord) String() string { func (*AnchorRecord) ProtoMessage() {} func (x *AnchorRecord) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[9] + mi := &file_audit_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -695,7 +937,7 @@ func (x *AnchorRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use AnchorRecord.ProtoReflect.Descriptor instead. func (*AnchorRecord) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{9} + return file_audit_proto_rawDescGZIP(), []int{12} } func (x *AnchorRecord) GetProvider() string { @@ -738,14 +980,17 @@ type PollAnchorConfirmationRequest struct { // external_id is the provider's anchor reference stored in audit_anchors.external_id. ExternalId string `protobuf:"bytes,3,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` // proof_data is the opaque provider-specific proof bytes stored in audit_anchors.proof_data. - ProofData []byte `protobuf:"bytes,4,opt,name=proof_data,json=proofData,proto3" json:"proof_data,omitempty"` + ProofData []byte `protobuf:"bytes,4,opt,name=proof_data,json=proofData,proto3" json:"proof_data,omitempty"` + // ledger identifies which ledger's DB handle to use when updating + // audit_anchors.confirmation after a status change. + Ledger string `protobuf:"bytes,5,opt,name=ledger,proto3" json:"ledger,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PollAnchorConfirmationRequest) Reset() { *x = PollAnchorConfirmationRequest{} - mi := &file_audit_proto_msgTypes[10] + mi := &file_audit_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -757,7 +1002,7 @@ func (x *PollAnchorConfirmationRequest) String() string { func (*PollAnchorConfirmationRequest) ProtoMessage() {} func (x *PollAnchorConfirmationRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[10] + mi := &file_audit_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -770,7 +1015,7 @@ func (x *PollAnchorConfirmationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PollAnchorConfirmationRequest.ProtoReflect.Descriptor instead. func (*PollAnchorConfirmationRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{10} + return file_audit_proto_rawDescGZIP(), []int{13} } func (x *PollAnchorConfirmationRequest) GetAnchorId() string { @@ -801,6 +1046,13 @@ func (x *PollAnchorConfirmationRequest) GetProofData() []byte { return nil } +func (x *PollAnchorConfirmationRequest) GetLedger() string { + if x != nil { + return x.Ledger + } + return "" +} + // PollAnchorConfirmationResponse is the output from step.audit.poll_anchor_confirmation. // // Transient errors (calendar-server unreachable, network partition) MUST be returned @@ -829,7 +1081,7 @@ type PollAnchorConfirmationResponse struct { func (x *PollAnchorConfirmationResponse) Reset() { *x = PollAnchorConfirmationResponse{} - mi := &file_audit_proto_msgTypes[11] + mi := &file_audit_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -841,7 +1093,7 @@ func (x *PollAnchorConfirmationResponse) String() string { func (*PollAnchorConfirmationResponse) ProtoMessage() {} func (x *PollAnchorConfirmationResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[11] + mi := &file_audit_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -854,7 +1106,7 @@ func (x *PollAnchorConfirmationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PollAnchorConfirmationResponse.ProtoReflect.Descriptor instead. func (*PollAnchorConfirmationResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{11} + return file_audit_proto_rawDescGZIP(), []int{14} } func (x *PollAnchorConfirmationResponse) GetPreviousConfirmation() string { @@ -912,7 +1164,7 @@ type ProofRequest struct { func (x *ProofRequest) Reset() { *x = ProofRequest{} - mi := &file_audit_proto_msgTypes[12] + mi := &file_audit_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -924,7 +1176,7 @@ func (x *ProofRequest) String() string { func (*ProofRequest) ProtoMessage() {} func (x *ProofRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[12] + mi := &file_audit_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -937,7 +1189,7 @@ func (x *ProofRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProofRequest.ProtoReflect.Descriptor instead. func (*ProofRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{12} + return file_audit_proto_rawDescGZIP(), []int{15} } func (x *ProofRequest) GetLedger() string { @@ -971,7 +1223,7 @@ type ProofResponse struct { func (x *ProofResponse) Reset() { *x = ProofResponse{} - mi := &file_audit_proto_msgTypes[13] + mi := &file_audit_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -983,7 +1235,7 @@ func (x *ProofResponse) String() string { func (*ProofResponse) ProtoMessage() {} func (x *ProofResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[13] + mi := &file_audit_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1248,7 @@ func (x *ProofResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProofResponse.ProtoReflect.Descriptor instead. func (*ProofResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{13} + return file_audit_proto_rawDescGZIP(), []int{16} } func (x *ProofResponse) GetEntry() *Entry { @@ -1066,7 +1318,7 @@ type Entry struct { func (x *Entry) Reset() { *x = Entry{} - mi := &file_audit_proto_msgTypes[14] + mi := &file_audit_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1078,7 +1330,7 @@ func (x *Entry) String() string { func (*Entry) ProtoMessage() {} func (x *Entry) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[14] + mi := &file_audit_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1091,7 +1343,7 @@ func (x *Entry) ProtoReflect() protoreflect.Message { // Deprecated: Use Entry.ProtoReflect.Descriptor instead. func (*Entry) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{14} + return file_audit_proto_rawDescGZIP(), []int{17} } func (x *Entry) GetSequence() int64 { @@ -1174,7 +1426,7 @@ type PublicReceiptRequest struct { func (x *PublicReceiptRequest) Reset() { *x = PublicReceiptRequest{} - mi := &file_audit_proto_msgTypes[15] + mi := &file_audit_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1186,7 +1438,7 @@ func (x *PublicReceiptRequest) String() string { func (*PublicReceiptRequest) ProtoMessage() {} func (x *PublicReceiptRequest) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[15] + mi := &file_audit_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1199,7 +1451,7 @@ func (x *PublicReceiptRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PublicReceiptRequest.ProtoReflect.Descriptor instead. func (*PublicReceiptRequest) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{15} + return file_audit_proto_rawDescGZIP(), []int{18} } func (x *PublicReceiptRequest) GetLedger() string { @@ -1238,7 +1490,7 @@ type PublicReceiptResponse struct { func (x *PublicReceiptResponse) Reset() { *x = PublicReceiptResponse{} - mi := &file_audit_proto_msgTypes[16] + mi := &file_audit_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1250,7 +1502,7 @@ func (x *PublicReceiptResponse) String() string { func (*PublicReceiptResponse) ProtoMessage() {} func (x *PublicReceiptResponse) ProtoReflect() protoreflect.Message { - mi := &file_audit_proto_msgTypes[16] + mi := &file_audit_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1263,7 +1515,7 @@ func (x *PublicReceiptResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PublicReceiptResponse.ProtoReflect.Descriptor instead. func (*PublicReceiptResponse) Descriptor() ([]byte, []int) { - return file_audit_proto_rawDescGZIP(), []int{16} + return file_audit_proto_rawDescGZIP(), []int{19} } func (x *PublicReceiptResponse) GetReceiptUrl() string { @@ -1291,14 +1543,34 @@ var File_audit_proto protoreflect.FileDescriptor const file_audit_proto_rawDesc = "" + "\n" + - "\vaudit.proto\x12\x18workflow.plugin.audit.v1\"\xed\x01\n" + + "\vaudit.proto\x12\x18workflow.plugin.audit.v1\"\xff\x01\n" + "\fLedgerConfig\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12)\n" + "\x10anchor_providers\x18\x03 \x03(\tR\x0fanchorProviders\x12'\n" + "\x0fanchor_schedule\x18\x04 \x01(\tR\x0eanchorSchedule\x12,\n" + "\x12anchor_min_entries\x18\x05 \x01(\x05R\x10anchorMinEntries\x12%\n" + - "\x0epayload_schema\x18\x06 \x01(\fR\rpayloadSchema\"\x92\x01\n" + + "\x0epayload_schema\x18\x06 \x01(\fR\rpayloadSchema\x12\x10\n" + + "\x03dsn\x18\a \x01(\tR\x03dsn\"q\n" + + "\x1cOpenTimestampsProviderConfig\x12)\n" + + "\x10calendar_servers\x18\x01 \x03(\tR\x0fcalendarServers\x12&\n" + + "\x0fhttp_timeout_ms\x18\x02 \x01(\x03R\rhttpTimeoutMs\"\xf0\x02\n" + + "\x17GitAnchorProviderConfig\x12\x16\n" + + "\x06remote\x18\x01 \x01(\tR\x06remote\x12\x16\n" + + "\x06branch\x18\x02 \x01(\tR\x06branch\x12'\n" + + "\x0fcommit_template\x18\x03 \x01(\tR\x0ecommitTemplate\x12\x1f\n" + + "\vauthor_name\x18\x04 \x01(\tR\n" + + "authorName\x12!\n" + + "\fauthor_email\x18\x05 \x01(\tR\vauthorEmail\x12\"\n" + + "\ruse_ssh_agent\x18\x06 \x01(\bR\vuseSshAgent\x12 \n" + + "\fssh_key_path\x18\a \x01(\tR\n" + + "sshKeyPath\x12(\n" + + "\x10ssh_key_password\x18\b \x01(\tR\x0esshKeyPassword\x12#\n" + + "\rhttp_username\x18\t \x01(\tR\fhttpUsername\x12#\n" + + "\rhttp_password\x18\n" + + " \x01(\tR\fhttpPassword\"5\n" + + "\x16SigstoreProviderConfig\x12\x1b\n" + + "\trekor_url\x18\x01 \x01(\tR\brekorUrl\"\x92\x01\n" + "\rAppendRequest\x12\x16\n" + "\x06ledger\x18\x01 \x01(\tR\x06ledger\x12\x1d\n" + "\n" + @@ -1343,14 +1615,15 @@ const file_audit_proto_rawDesc = "" + "externalId\x12\"\n" + "\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x1f\n" + "\vanchored_at\x18\x04 \x01(\tR\n" + - "anchoredAt\"\x98\x01\n" + + "anchoredAt\"\xb0\x01\n" + "\x1dPollAnchorConfirmationRequest\x12\x1b\n" + "\tanchor_id\x18\x01 \x01(\tR\banchorId\x12\x1a\n" + "\bprovider\x18\x02 \x01(\tR\bprovider\x12\x1f\n" + "\vexternal_id\x18\x03 \x01(\tR\n" + "externalId\x12\x1d\n" + "\n" + - "proof_data\x18\x04 \x01(\fR\tproofData\"\x8e\x02\n" + + "proof_data\x18\x04 \x01(\fR\tproofData\x12\x16\n" + + "\x06ledger\x18\x05 \x01(\tR\x06ledger\"\x8e\x02\n" + "\x1ePollAnchorConfirmationResponse\x123\n" + "\x15previous_confirmation\x18\x01 \x01(\tR\x14previousConfirmation\x121\n" + "\x14current_confirmation\x18\x02 \x01(\tR\x13currentConfirmation\x12\"\n" + @@ -1404,30 +1677,33 @@ func file_audit_proto_rawDescGZIP() []byte { return file_audit_proto_rawDescData } -var file_audit_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_audit_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_audit_proto_goTypes = []any{ (*LedgerConfig)(nil), // 0: workflow.plugin.audit.v1.LedgerConfig - (*AppendRequest)(nil), // 1: workflow.plugin.audit.v1.AppendRequest - (*AppendResponse)(nil), // 2: workflow.plugin.audit.v1.AppendResponse - (*VerifyRequest)(nil), // 3: workflow.plugin.audit.v1.VerifyRequest - (*VerifyResponse)(nil), // 4: workflow.plugin.audit.v1.VerifyResponse - (*MerkleRootRequest)(nil), // 5: workflow.plugin.audit.v1.MerkleRootRequest - (*MerkleRootResponse)(nil), // 6: workflow.plugin.audit.v1.MerkleRootResponse - (*AnchorRequest)(nil), // 7: workflow.plugin.audit.v1.AnchorRequest - (*AnchorResponse)(nil), // 8: workflow.plugin.audit.v1.AnchorResponse - (*AnchorRecord)(nil), // 9: workflow.plugin.audit.v1.AnchorRecord - (*PollAnchorConfirmationRequest)(nil), // 10: workflow.plugin.audit.v1.PollAnchorConfirmationRequest - (*PollAnchorConfirmationResponse)(nil), // 11: workflow.plugin.audit.v1.PollAnchorConfirmationResponse - (*ProofRequest)(nil), // 12: workflow.plugin.audit.v1.ProofRequest - (*ProofResponse)(nil), // 13: workflow.plugin.audit.v1.ProofResponse - (*Entry)(nil), // 14: workflow.plugin.audit.v1.Entry - (*PublicReceiptRequest)(nil), // 15: workflow.plugin.audit.v1.PublicReceiptRequest - (*PublicReceiptResponse)(nil), // 16: workflow.plugin.audit.v1.PublicReceiptResponse + (*OpenTimestampsProviderConfig)(nil), // 1: workflow.plugin.audit.v1.OpenTimestampsProviderConfig + (*GitAnchorProviderConfig)(nil), // 2: workflow.plugin.audit.v1.GitAnchorProviderConfig + (*SigstoreProviderConfig)(nil), // 3: workflow.plugin.audit.v1.SigstoreProviderConfig + (*AppendRequest)(nil), // 4: workflow.plugin.audit.v1.AppendRequest + (*AppendResponse)(nil), // 5: workflow.plugin.audit.v1.AppendResponse + (*VerifyRequest)(nil), // 6: workflow.plugin.audit.v1.VerifyRequest + (*VerifyResponse)(nil), // 7: workflow.plugin.audit.v1.VerifyResponse + (*MerkleRootRequest)(nil), // 8: workflow.plugin.audit.v1.MerkleRootRequest + (*MerkleRootResponse)(nil), // 9: workflow.plugin.audit.v1.MerkleRootResponse + (*AnchorRequest)(nil), // 10: workflow.plugin.audit.v1.AnchorRequest + (*AnchorResponse)(nil), // 11: workflow.plugin.audit.v1.AnchorResponse + (*AnchorRecord)(nil), // 12: workflow.plugin.audit.v1.AnchorRecord + (*PollAnchorConfirmationRequest)(nil), // 13: workflow.plugin.audit.v1.PollAnchorConfirmationRequest + (*PollAnchorConfirmationResponse)(nil), // 14: workflow.plugin.audit.v1.PollAnchorConfirmationResponse + (*ProofRequest)(nil), // 15: workflow.plugin.audit.v1.ProofRequest + (*ProofResponse)(nil), // 16: workflow.plugin.audit.v1.ProofResponse + (*Entry)(nil), // 17: workflow.plugin.audit.v1.Entry + (*PublicReceiptRequest)(nil), // 18: workflow.plugin.audit.v1.PublicReceiptRequest + (*PublicReceiptResponse)(nil), // 19: workflow.plugin.audit.v1.PublicReceiptResponse } var file_audit_proto_depIdxs = []int32{ - 9, // 0: workflow.plugin.audit.v1.AnchorResponse.anchors:type_name -> workflow.plugin.audit.v1.AnchorRecord - 14, // 1: workflow.plugin.audit.v1.ProofResponse.entry:type_name -> workflow.plugin.audit.v1.Entry - 9, // 2: workflow.plugin.audit.v1.ProofResponse.anchors:type_name -> workflow.plugin.audit.v1.AnchorRecord + 12, // 0: workflow.plugin.audit.v1.AnchorResponse.anchors:type_name -> workflow.plugin.audit.v1.AnchorRecord + 17, // 1: workflow.plugin.audit.v1.ProofResponse.entry:type_name -> workflow.plugin.audit.v1.Entry + 12, // 2: workflow.plugin.audit.v1.ProofResponse.anchors:type_name -> workflow.plugin.audit.v1.AnchorRecord 3, // [3:3] is the sub-list for method output_type 3, // [3:3] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name @@ -1446,7 +1722,7 @@ func file_audit_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_audit_proto_rawDesc), len(file_audit_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 20, NumExtensions: 0, NumServices: 0, }, diff --git a/go.mod b/go.mod index 33335ff..864d0d0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,20 @@ module github.com/GoCodeAlone/workflow-plugin-audit-chain go 1.26.0 -require github.com/GoCodeAlone/workflow v0.20.1 +require ( + github.com/GoCodeAlone/go-plugin v1.7.0 + github.com/GoCodeAlone/workflow v0.20.1 + github.com/go-git/go-git/v5 v5.18.0 + github.com/go-openapi/runtime v0.29.4 + github.com/go-openapi/strfmt v0.26.2 + github.com/jackc/pgx/v5 v5.9.1 + github.com/sigstore/rekor v1.5.1 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) require ( cel.dev/expr v0.25.1 // indirect @@ -20,7 +33,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect - github.com/GoCodeAlone/go-plugin v1.7.0 // indirect github.com/GoCodeAlone/modular v1.13.0 // indirect github.com/GoCodeAlone/modular/modules/auth v1.15.0 // indirect github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 // indirect @@ -92,7 +104,6 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-git/v5 v5.18.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -102,9 +113,7 @@ require ( github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.29.4 // indirect github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/strfmt v0.26.2 // indirect github.com/go-openapi/swag v0.26.0 // indirect github.com/go-openapi/swag/cmdutils v0.26.0 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect @@ -147,7 +156,6 @@ require ( github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect @@ -202,13 +210,9 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect - github.com/sigstore/rekor v1.5.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect @@ -245,8 +249,6 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect diff --git a/go.sum b/go.sum index 6a623e2..b80befa 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -70,8 +72,12 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -157,6 +163,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= @@ -191,6 +199,8 @@ github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -212,10 +222,14 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -255,6 +269,8 @@ github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiN github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= @@ -267,6 +283,10 @@ github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFu github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -297,9 +317,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE= +github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -394,6 +413,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -406,6 +427,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -460,6 +483,8 @@ github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -509,8 +534,6 @@ github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUN github.com/sigstore/rekor v1.5.1 h1:Ca1egHRWRuDvXV4tZu9aXEXc3Gej9FG+HKeapV9OAMQ= github.com/sigstore/rekor v1.5.1/go.mod h1:gTLDuZuo3SyQCuZvKqwRPA79Qo/2rw39/WtLP/rZjUQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= @@ -522,8 +545,8 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -609,6 +632,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= @@ -658,6 +683,8 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -733,3 +760,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..416f0b2 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,452 @@ +// Package integration_test provides an end-to-end integration test that starts +// the audit-chain plugin as a real binary subprocess, communicates with it over +// the gRPC transport layer (via go-plugin), and verifies the full cryptographic +// audit chain scenario. +// +// The test: +// 1. Spins up an ephemeral Postgres 16 container via testcontainers. +// 2. Compiles and starts the plugin binary as a subprocess via go-plugin. +// 3. Declares an audit.ledger by sending CreateModule → InitModule → StartModule +// over gRPC — config is proto-serialised as anypb.Any(LedgerConfig). +// 4. Appends 5 audit entries via CreateStep / ExecuteStep (step.audit.append), +// each with TypedInput = anypb.Any(AppendRequest) over the gRPC wire. +// 5. Verifies chain integrity via step.audit.verify. +// 6. Computes the Merkle root over entries 1–5 via step.audit.merkle_root. +// 7. Records a mock anchor row directly, then retrieves the inclusion proof for +// entry 3 via step.audit.proof. +// 8. Cryptographically verifies the proof with chain.VerifyInclusion. +package integration_test + +import ( + "context" + "database/sql" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + goplugin "github.com/GoCodeAlone/go-plugin" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + ext "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// ── go-plugin bridge ────────────────────────────────────────────────────────── + +// testGRPCPlugin is a go-plugin Plugin implementation that dispenses +// pb.PluginServiceClient directly. ext.GRPCPlugin wraps the client in +// *ext.PluginClient which has unexported fields; this variant bypasses that +// wrapper so the test can call RPC methods directly without depending on +// unexported types. +type testGRPCPlugin struct{ goplugin.Plugin } + +func (p *testGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, _ *grpc.Server) error { return nil } + +func (p *testGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error) { + return pb.NewPluginServiceClient(c), nil +} + +// ── test infrastructure ─────────────────────────────────────────────────────── + +// startPostgres spins up an ephemeral Postgres 16 container and returns its +// connection string. +func startPostgres(t *testing.T) string { + t.Helper() + ctx := context.Background() + + pgc, err := tcpostgres.Run(ctx, "postgres:16-alpine", + tcpostgres.WithDatabase("testaudit"), + tcpostgres.WithUsername("testuser"), + tcpostgres.WithPassword("testpass"), + tcpostgres.BasicWaitStrategies(), + ) + if err != nil { + t.Fatalf("start postgres container: %v", err) + } + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(pgc); err != nil { + t.Logf("terminate container: %v", err) + } + }) + + cs, err := pgc.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("get connection string: %v", err) + } + return cs +} + +// openDB opens a sql.DB for direct SQL operations from the test process. +func openDB(t *testing.T, connStr string) *sql.DB { + t.Helper() + db, err := sql.Open("pgx", connStr) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// applyMigrations applies all four up-migration SQL files. +func applyMigrations(t *testing.T, db *sql.DB) { + t.Helper() + ctx := context.Background() + for _, f := range []string{ + "migrations/001_audit_log.sql", + "migrations/002_audit_ledgers.sql", + "migrations/003_audit_anchors.sql", + "migrations/004_indexes.sql", + } { + sqlBytes, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read migration %s: %v", f, err) + } + if _, err := db.ExecContext(ctx, string(sqlBytes)); err != nil { + t.Fatalf("apply migration %s: %v", f, err) + } + } +} + +// buildBinary compiles the plugin binary into a temp directory and returns its +// path. +func buildBinary(t *testing.T) string { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Skip("runtime.Caller unavailable") + } + // thisFile is integration_test.go at the module root. + projectRoot := filepath.Dir(thisFile) + + out := filepath.Join(t.TempDir(), "workflow-plugin-audit-chain") + cmd := exec.Command("go", "build", "-o", out, "./cmd/workflow-plugin-audit-chain/") + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), "GOWORK=off") + + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build plugin binary:\n%s\nerror: %v", output, err) + } + return out +} + +// startPlugin starts the plugin binary as a go-plugin subprocess and returns a +// pb.PluginServiceClient connected to it over gRPC. +func startPlugin(t *testing.T, binaryPath string) pb.PluginServiceClient { + t.Helper() + + client := goplugin.NewClient(&goplugin.ClientConfig{ + HandshakeConfig: ext.Handshake, + Plugins: goplugin.PluginSet{"plugin": &testGRPCPlugin{}}, + Cmd: exec.Command(binaryPath), //nolint:gosec // G204: test binary + AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}, + }) + t.Cleanup(client.Kill) + + rpcClient, err := client.Client() + if err != nil { + t.Fatalf("connect to plugin subprocess: %v", err) + } + + raw, err := rpcClient.Dispense("plugin") + if err != nil { + t.Fatalf("dispense plugin: %v", err) + } + + pbClient, ok := raw.(pb.PluginServiceClient) + if !ok { + t.Fatalf("dispensed object is not pb.PluginServiceClient (got %T)", raw) + } + return pbClient +} + +// mustNoRPCErr fatals the test if err != nil or the response error field is set. +func mustNoRPCErr(t *testing.T, label string, err error, respErr string) { + t.Helper() + if err != nil { + t.Fatalf("%s: gRPC error: %v", label, err) + } + if respErr != "" { + t.Fatalf("%s: plugin error: %s", label, respErr) + } +} + +// ── integration scenario ────────────────────────────────────────────────────── + +// TestE2E_AuditChainScenario is the canonical end-to-end integration test. +// +// All step executions go through real gRPC proto serialisation: the test +// process packs each request as anypb.Any, sends it over a TCP gRPC connection +// to the plugin subprocess, and unpacks the typed response the same way. +// +// Requires Docker (for the Postgres container) and the Go toolchain (to compile +// the plugin binary). Run with -short to skip. +func TestE2E_AuditChainScenario(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test: requires Docker and Go toolchain (run without -short)") + } + + const ledger = "e2e-ledger" + ctx := context.Background() + + // ── 1. Infrastructure ───────────────────────────────────────────────────── + connStr := startPostgres(t) + rawDB := openDB(t, connStr) + applyMigrations(t, rawDB) + + // ── 2. Build and start plugin subprocess ────────────────────────────────── + binaryPath := buildBinary(t) + pbClient := startPlugin(t, binaryPath) + + // ── 3. Declare audit.ledger via gRPC ────────────────────────────────────── + // CreateModule → InitModule → StartModule with typed LedgerConfig over gRPC. + cfgProto := &auditv1.LedgerConfig{Name: ledger, Dsn: connStr} + packedCfg, err := anypb.New(cfgProto) + if err != nil { + t.Fatalf("pack LedgerConfig: %v", err) + } + + createModResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ + Type: "audit.ledger", + Name: "e2e-module", + TypedConfig: packedCfg, + }) + mustNoRPCErr(t, "CreateModule", err, createModResp.GetError()) + modHandle := createModResp.HandleId + + initResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: modHandle}) + mustNoRPCErr(t, "InitModule", err, initResp.GetError()) + + startResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: modHandle}) + mustNoRPCErr(t, "StartModule", err, startResp.GetError()) + t.Cleanup(func() { + if resp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: modHandle}); err != nil { + t.Logf("StopModule: gRPC error: %v", err) + } else if resp.GetError() != "" { + t.Logf("StopModule: plugin error: %s", resp.GetError()) + } + }) + + // Insert the audit_ledgers cursor row (normally done by a provisioning step). + if _, err := rawDB.ExecContext(ctx, + `INSERT INTO audit_ledgers (ledger, last_sequence, last_entry_hash) + VALUES ($1, 0, '') ON CONFLICT (ledger) DO NOTHING`, ledger, + ); err != nil { + t.Fatalf("create audit_ledgers row: %v", err) + } + + // ── 4. Create step.audit.append instance ────────────────────────────────── + createAppendResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.audit.append", + Name: "e2e-append", + }) + mustNoRPCErr(t, "CreateStep(append)", err, createAppendResp.GetError()) + appendHandle := createAppendResp.HandleId + + // ── 5. Append 5 entries via gRPC ExecuteStep ────────────────────────────── + entryHashes := make([]string, 5) + for i := 1; i <= 5; i++ { + payload := fmt.Appendf(nil, `{"n":%d,"ts":%d}`, i, time.Now().UnixNano()) + + input, err := anypb.New(&auditv1.AppendRequest{ + Ledger: ledger, + EventType: "e2e.event", + Payload: payload, + Actor: "integration-test", + }) + if err != nil { + t.Fatalf("pack AppendRequest entry %d: %v", i, err) + } + + execResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: appendHandle, + TypedInput: input, + }) + mustNoRPCErr(t, fmt.Sprintf("ExecuteStep(append) entry %d", i), err, execResp.GetError()) + + var out auditv1.AppendResponse + if err := execResp.GetTypedOutput().UnmarshalTo(&out); err != nil { + t.Fatalf("unpack AppendResponse entry %d: %v", i, err) + } + if out.GetSequence() != int64(i) { + t.Errorf("entry %d: expected sequence %d, got %d", i, i, out.GetSequence()) + } + if len(out.GetEntryHash()) != 64 { + t.Errorf("entry %d: expected 64-char hash, got %d", i, len(out.GetEntryHash())) + } + if out.GetCreatedAt() == "" { + t.Errorf("entry %d: CreatedAt is empty", i) + } + entryHashes[i-1] = out.GetEntryHash() + } + + // ── 6. Verify chain integrity via step.audit.verify ─────────────────────── + createVerifyResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.audit.verify", + Name: "e2e-verify", + }) + mustNoRPCErr(t, "CreateStep(verify)", err, createVerifyResp.GetError()) + + verInput, err := anypb.New(&auditv1.VerifyRequest{ + Ledger: ledger, + StartSequence: 1, + EndSequence: 5, + }) + if err != nil { + t.Fatalf("pack VerifyRequest: %v", err) + } + verExecResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createVerifyResp.HandleId, + TypedInput: verInput, + }) + mustNoRPCErr(t, "ExecuteStep(verify)", err, verExecResp.GetError()) + + var verOut auditv1.VerifyResponse + if err := verExecResp.GetTypedOutput().UnmarshalTo(&verOut); err != nil { + t.Fatalf("unpack VerifyResponse: %v", err) + } + if !verOut.GetValid() { + t.Fatalf("chain integrity check failed at seq %d: %s", + verOut.GetFirstInvalidSequence(), verOut.GetFailureReason()) + } + if verOut.GetEntriesVerified() != 5 { + t.Errorf("expected 5 entries verified, got %d", verOut.GetEntriesVerified()) + } + + // ── 7. Compute Merkle root via step.audit.merkle_root ───────────────────── + createMRResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.audit.merkle_root", + Name: "e2e-merkle-root", + }) + mustNoRPCErr(t, "CreateStep(merkle_root)", err, createMRResp.GetError()) + + mrInput, err := anypb.New(&auditv1.MerkleRootRequest{ + Ledger: ledger, + StartSequence: 1, + EndSequence: 5, + }) + if err != nil { + t.Fatalf("pack MerkleRootRequest: %v", err) + } + mrExecResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createMRResp.HandleId, + TypedInput: mrInput, + }) + mustNoRPCErr(t, "ExecuteStep(merkle_root)", err, mrExecResp.GetError()) + + var mrOut auditv1.MerkleRootResponse + if err := mrExecResp.GetTypedOutput().UnmarshalTo(&mrOut); err != nil { + t.Fatalf("unpack MerkleRootResponse: %v", err) + } + merkleRoot := mrOut.GetRoot() + if len(merkleRoot) != 64 { + t.Fatalf("expected 64-char Merkle root, got %d: %s", len(merkleRoot), merkleRoot) + } + if mrOut.GetEntriesIncluded() != 5 { + t.Errorf("expected 5 entries included, got %d", mrOut.GetEntriesIncluded()) + } + + // Cross-check: independently compute the root from locally-collected hashes. + expectedRoot, err := chain.MerkleRoot(entryHashes) + if err != nil { + t.Fatalf("chain.MerkleRoot: %v", err) + } + if merkleRoot != expectedRoot { + t.Errorf("merkle_root handler returned %s; independently computed %s", merkleRoot, expectedRoot) + } + + // ── 8. Record a mock anchor for range 1–5 ───────────────────────────────── + // In production, step.audit.anchor submits to an external provider and writes + // this row. Here we insert directly so ProofHandler has data to work with. + if _, err := rawDB.ExecContext(ctx, ` + INSERT INTO audit_anchors + (ledger, range_start, range_end, merkle_root, provider, + external_id, proof_data, confirmation, anchored_at) + VALUES ($1, 1, 5, $2, 'e2e-mock', 'mock-ext-id-001', NULL, 'pending', NOW())`, + ledger, merkleRoot, + ); err != nil { + t.Fatalf("insert mock anchor: %v", err) + } + + // ── 9. Retrieve inclusion proof for entry 3 via step.audit.proof ────────── + createProofResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.audit.proof", + Name: "e2e-proof", + }) + mustNoRPCErr(t, "CreateStep(proof)", err, createProofResp.GetError()) + + proofInput, err := anypb.New(&auditv1.ProofRequest{ + Ledger: ledger, + Sequence: 3, + }) + if err != nil { + t.Fatalf("pack ProofRequest: %v", err) + } + proofExecResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createProofResp.HandleId, + TypedInput: proofInput, + }) + mustNoRPCErr(t, "ExecuteStep(proof)", err, proofExecResp.GetError()) + + var pOut auditv1.ProofResponse + if err := proofExecResp.GetTypedOutput().UnmarshalTo(&pOut); err != nil { + t.Fatalf("unpack ProofResponse: %v", err) + } + + // Entry 3 must be returned correctly. + if pOut.GetEntry() == nil { + t.Fatal("ProofHandler returned nil entry") + } + if seq := pOut.GetEntry().GetSequence(); seq != 3 { + t.Errorf("proof entry sequence = %d, want 3", seq) + } + if h := pOut.GetEntry().GetEntryHash(); h != entryHashes[2] { + t.Errorf("proof entry hash = %s, want %s", h, entryHashes[2]) + } + + // Merkle root matches what step.audit.merkle_root computed. + if pOut.GetMerkleRoot() != merkleRoot { + t.Errorf("proof merkle_root = %s, want %s", pOut.GetMerkleRoot(), merkleRoot) + } + + // Inclusion proof must be non-empty (entry 3 of 5 has siblings). + // Each node is a direction byte ('L'/'R') followed by 64 hex chars = 65 total. + if len(pOut.GetMerklePath()) == 0 { + t.Error("inclusion proof Merkle path is empty — expected sibling hashes for entry 3 of 5") + } + for i, node := range pOut.GetMerklePath() { + if len(node) != 65 { + t.Errorf("MerklePath[%d]: expected 65 chars (1 dir + 64 hex), got %d: %q", i, len(node), node) + } + dir := node[0] + if dir != 'L' && dir != 'R' { + t.Errorf("MerklePath[%d]: unexpected direction byte %q", i, dir) + } + } + + // Anchor record is present and references our mock provider. + if len(pOut.GetAnchors()) == 0 { + t.Error("proof has no anchors — expected the mock anchor inserted above") + } else if pOut.GetAnchors()[0].GetProvider() != "e2e-mock" { + t.Errorf("anchor provider = %q, want %q", pOut.GetAnchors()[0].GetProvider(), "e2e-mock") + } + + // ── 10. Cryptographic verification of the inclusion proof ───────────────── + // + // chain.VerifyInclusion walks the L/R-prefixed sibling path and recomputes + // the root using the same RFC 6962 hashing as chain.MerkleRoot. If the + // proof is correct, the reconstructed root must equal the anchor's root. + if !chain.VerifyInclusion(entryHashes[2], pOut.GetMerklePath(), merkleRoot) { + t.Errorf("chain.VerifyInclusion failed: inclusion proof for entry 3 does not reproduce Merkle root %s", merkleRoot) + } +} diff --git a/internal/plugin.go b/internal/plugin.go index 9cb9223..56b0f2f 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -2,9 +2,15 @@ package internal import ( + "errors" "fmt" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/emptypb" ) // Version is set at build time via -ldflags @@ -13,10 +19,17 @@ import ( // unreleased dev builds; goreleaser overrides with the real release tag. var Version = "0.0.0" -// AuditChainPlugin implements sdk.PluginProvider and the step/module/trigger -// provider interfaces for the audit-chain plugin. +// AuditChainPlugin implements sdk.PluginProvider, sdk.TypedModuleProvider, and +// sdk.TypedStepProvider. Zero map[string]any in plugin code: all module and +// step boundaries use typed proto messages (anypb.Any). type AuditChainPlugin struct{} +// Compile-time assertions. +var ( + _ sdk.TypedModuleProvider = (*AuditChainPlugin)(nil) + _ sdk.TypedStepProvider = (*AuditChainPlugin)(nil) +) + // NewPlugin returns a new plugin instance. main.go calls sdk.Serve(NewPlugin()). func NewPlugin() sdk.PluginProvider { return &AuditChainPlugin{} @@ -33,60 +46,188 @@ func (p *AuditChainPlugin) Manifest() sdk.PluginManifest { } } -// ModuleTypes returns the module type names this plugin provides. -func (p *AuditChainPlugin) ModuleTypes() []string { - return []string{ - "audit.ledger", - "audit.anchor_provider.opentimestamps", - "audit.anchor_provider.git", - "audit.anchor_provider.sigstore", - "audit.anchor_provider.ethereum", - "audit.anchor_provider.aws_qldb", - } +// ── Module provider (typed) ─────────────────────────────────────────────────── + +// knownModuleTypes is the full set of module types declared by this plugin. +// ethereum and aws_qldb are declared but deferred (not yet implemented). +var knownModuleTypes = []string{ + "audit.ledger", + "audit.anchor_provider.opentimestamps", + "audit.anchor_provider.git", + "audit.anchor_provider.sigstore", + "audit.anchor_provider.ethereum", + "audit.anchor_provider.aws_qldb", +} + +// TypedModuleTypes returns the module type names this plugin provides. +// The gRPC server prefers TypedModuleProvider over the legacy ModuleProvider. +func (p *AuditChainPlugin) TypedModuleTypes() []string { + return knownModuleTypes } -// CreateModule creates a module instance of the given type. -func (p *AuditChainPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { +// CreateTypedModule creates a module instance from a typed proto config. +// No map[string]any is used; config is unpacked directly into the target proto. +func (p *AuditChainPlugin) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { switch typeName { - case "audit.ledger", - "audit.anchor_provider.opentimestamps", - "audit.anchor_provider.git", - "audit.anchor_provider.sigstore", - "audit.anchor_provider.ethereum", + case "audit.ledger": + var cfg auditv1.LedgerConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("audit.ledger %q: unmarshal config: %w", name, err) + } + } + return modules.NewLedgerModule(name, &cfg) + + case "audit.anchor_provider.opentimestamps": + var cfg auditv1.OpenTimestampsProviderConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("audit.anchor_provider.opentimestamps %q: unmarshal config: %w", name, err) + } + } + return modules.NewOpenTimestampsProviderModule(name, &cfg) + + case "audit.anchor_provider.git": + var cfg auditv1.GitAnchorProviderConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("audit.anchor_provider.git %q: unmarshal config: %w", name, err) + } + } + return modules.NewGitAnchorProviderModule(name, &cfg) + + case "audit.anchor_provider.sigstore": + var cfg auditv1.SigstoreProviderConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("audit.anchor_provider.sigstore %q: unmarshal config: %w", name, err) + } + } + return modules.NewSigstoreProviderModule(name, &cfg) + + case "audit.anchor_provider.ethereum", "audit.anchor_provider.aws_qldb": - return nil, fmt.Errorf("audit-chain: module type %q not yet implemented", typeName) + return nil, fmt.Errorf("audit-chain: module type %q not yet implemented (deferred for pilot)", typeName) + default: return nil, fmt.Errorf("audit-chain: unknown module type %q", typeName) } } -// StepTypes returns the step type names this plugin provides. -func (p *AuditChainPlugin) StepTypes() []string { - return []string{ +// ModuleTypes satisfies the legacy sdk.ModuleProvider interface. The gRPC +// server calls TypedModuleProvider first, so this is only reached if the engine +// does not support typed modules. +func (p *AuditChainPlugin) ModuleTypes() []string { + return knownModuleTypes +} + +// CreateModule satisfies the legacy sdk.ModuleProvider interface. Returns +// "not yet implemented" for known types (encouraging upgrade to typed path) +// and "unknown module type" for unrecognised types. +func (p *AuditChainPlugin) CreateModule(typeName, _ string, _ map[string]any) (sdk.ModuleInstance, error) { + for _, known := range knownModuleTypes { + if known == typeName { + return nil, fmt.Errorf("audit-chain: module type %q not yet implemented via legacy path; engine must support TypedModuleProvider", typeName) + } + } + return nil, fmt.Errorf("audit-chain: unknown module type %q", typeName) +} + +// ── Step provider (typed) ───────────────────────────────────────────────────── + +// stepFactories is the ordered list of TypedStepFactory instances, one per +// step type. CreateTypedStep iterates them, letting each factory accept or +// decline via ErrTypedContractNotHandled. +var stepFactories = []sdk.TypedStepProvider{ + sdk.NewTypedStepFactory( "step.audit.append", + &emptypb.Empty{}, + &auditv1.AppendRequest{}, + steps.AppendHandler, + ), + sdk.NewTypedStepFactory( "step.audit.verify", + &emptypb.Empty{}, + &auditv1.VerifyRequest{}, + steps.VerifyHandler, + ), + sdk.NewTypedStepFactory( "step.audit.merkle_root", + &emptypb.Empty{}, + &auditv1.MerkleRootRequest{}, + steps.MerkleRootHandler, + ), + sdk.NewTypedStepFactory( "step.audit.anchor", + &emptypb.Empty{}, + &auditv1.AnchorRequest{}, + steps.AnchorHandler, + ), + sdk.NewTypedStepFactory( "step.audit.poll_anchor_confirmation", + &emptypb.Empty{}, + &auditv1.PollAnchorConfirmationRequest{}, + steps.PollAnchorConfirmationHandler, + ), + sdk.NewTypedStepFactory( "step.audit.proof", + &emptypb.Empty{}, + &auditv1.ProofRequest{}, + steps.ProofHandler, + ), + sdk.NewTypedStepFactory( "step.audit.public_receipt", - } + &emptypb.Empty{}, + &auditv1.PublicReceiptRequest{}, + steps.PublicReceiptHandler, + ), } -// CreateStep creates a step instance of the given type. -func (p *AuditChainPlugin) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) { - switch typeName { - case "step.audit.append", +// StepTypes returns the step type names this plugin provides. +// Satisfies the legacy sdk.StepProvider interface; TypedStepProvider is +// preferred by the gRPC server. +func (p *AuditChainPlugin) StepTypes() []string { + return []string{ + "step.audit.append", "step.audit.verify", "step.audit.merkle_root", "step.audit.anchor", "step.audit.poll_anchor_confirmation", "step.audit.proof", - "step.audit.public_receipt": - return nil, fmt.Errorf("audit-chain: step type %q not yet implemented", typeName) - default: - return nil, fmt.Errorf("audit-chain: unknown step type %q", typeName) + "step.audit.public_receipt", + } +} + +// TypedStepTypes returns the step type names served via the typed gRPC path. +func (p *AuditChainPlugin) TypedStepTypes() []string { + return p.StepTypes() +} + +// CreateTypedStep creates a typed step instance. Each factory handles exactly +// one type; the first match wins. Unknown types return "unknown step type". +func (p *AuditChainPlugin) CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) { + for _, f := range stepFactories { + inst, err := f.CreateTypedStep(typeName, name, config) + if err == nil { + return inst, nil + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + return nil, err + } + } + return nil, fmt.Errorf("audit-chain: unknown step type %q", typeName) +} + +// CreateStep satisfies the legacy sdk.StepProvider interface. The gRPC server +// prefers TypedStepProvider, so this path is only reached by engines that do +// not support typed steps. +func (p *AuditChainPlugin) CreateStep(typeName, _ string, _ map[string]any) (sdk.StepInstance, error) { + for _, s := range p.StepTypes() { + if s == typeName { + return nil, fmt.Errorf("audit-chain: step type %q not yet implemented via legacy path; engine must support TypedStepProvider", typeName) + } } + return nil, fmt.Errorf("audit-chain: unknown step type %q", typeName) } // TriggerTypes returns the trigger type names this plugin provides. diff --git a/internal/plugin_test.go b/internal/plugin_test.go index beeff08..3bf1461 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -4,8 +4,10 @@ import ( "strings" "testing" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" "github.com/GoCodeAlone/workflow-plugin-audit-chain/internal" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/anypb" ) func TestNewPlugin_ImplementsPluginProvider(t *testing.T) { @@ -189,3 +191,176 @@ func TestCreateTrigger_KnownType_ReturnsNotImplemented(t *testing.T) { t.Errorf("unexpected error message: %v", err) } } + +// ── TypedStepProvider tests (primary gRPC path) ────────────────────────────── + +func typedStepProvider(t *testing.T) sdk.TypedStepProvider { + t.Helper() + p := internal.NewPlugin() + tp, ok := p.(sdk.TypedStepProvider) + if !ok { + t.Fatal("plugin does not implement sdk.TypedStepProvider") + } + return tp +} + +// TestTypedStepTypes_Declared verifies all 7 step types are returned. +func TestTypedStepTypes_Declared(t *testing.T) { + tp := typedStepProvider(t) + types := tp.TypedStepTypes() + want := []string{ + "step.audit.append", + "step.audit.verify", + "step.audit.merkle_root", + "step.audit.anchor", + "step.audit.poll_anchor_confirmation", + "step.audit.proof", + "step.audit.public_receipt", + } + typeSet := make(map[string]bool, len(types)) + for _, typ := range types { + typeSet[typ] = true + } + for _, w := range want { + if !typeSet[w] { + t.Errorf("TypedStepTypes() missing %q", w) + } + } +} + +// TestCreateTypedStep_UnknownType_ReturnsError verifies that an unknown type +// returns an "unknown step type" error. +func TestCreateTypedStep_UnknownType_ReturnsError(t *testing.T) { + tp := typedStepProvider(t) + _, err := tp.CreateTypedStep("unknown.step.type", "x", nil) + if err == nil { + t.Fatal("expected error for unknown type, got nil") + } + if !strings.Contains(err.Error(), "unknown step type") { + t.Errorf("unexpected error message: %v", err) + } +} + +// TestCreateTypedStep_KnownType_ReturnsInstance verifies that known step types +// produce a non-nil StepInstance (nil config is valid — no step-level config). +func TestCreateTypedStep_KnownType_ReturnsInstance(t *testing.T) { + tp := typedStepProvider(t) + for _, typeName := range []string{ + "step.audit.append", + "step.audit.verify", + "step.audit.merkle_root", + "step.audit.anchor", + "step.audit.poll_anchor_confirmation", + "step.audit.proof", + "step.audit.public_receipt", + } { + inst, err := tp.CreateTypedStep(typeName, "test-"+typeName, nil) + if err != nil { + t.Errorf("%s: unexpected error: %v", typeName, err) + continue + } + if inst == nil { + t.Errorf("%s: expected non-nil StepInstance, got nil", typeName) + } + } +} + +// ── CreateTypedModule tests (primary gRPC path) ─────────────────────────────── + +func typedModuleProvider(t *testing.T) sdk.TypedModuleProvider { + t.Helper() + p := internal.NewPlugin() + tp, ok := p.(sdk.TypedModuleProvider) + if !ok { + t.Fatal("plugin does not implement sdk.TypedModuleProvider") + } + return tp +} + +// TestCreateTypedModule_LedgerConfig_ValidConfig verifies that a properly +// packed LedgerConfig returns a non-nil module. +func TestCreateTypedModule_LedgerConfig_ValidConfig(t *testing.T) { + tp := typedModuleProvider(t) + + cfg := &auditv1.LedgerConfig{ + Name: "bmw-financial", + Dsn: "postgres://u:p@localhost/db?sslmode=disable", + } + packed, err := anypb.New(cfg) + if err != nil { + t.Fatalf("anypb.New: %v", err) + } + + m, err := tp.CreateTypedModule("audit.ledger", "my-ledger", packed) + if err != nil { + t.Fatalf("CreateTypedModule: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +// TestCreateTypedModule_LedgerConfig_NilConfig surfaces validation errors +// (empty name + empty dsn) rather than panicking on a nil config. +func TestCreateTypedModule_LedgerConfig_NilConfig(t *testing.T) { + tp := typedModuleProvider(t) + + _, err := tp.CreateTypedModule("audit.ledger", "my-ledger", nil) + if err == nil { + t.Fatal("expected error for nil config, got nil") + } +} + +// TestCreateTypedModule_MismatchedAnypbType verifies that passing a config +// packed as the wrong proto type returns an unmarshal error, not a panic. +func TestCreateTypedModule_MismatchedAnypbType(t *testing.T) { + tp := typedModuleProvider(t) + + // Pack an OpenTimestampsProviderConfig but claim it is for audit.ledger. + wrongType := &auditv1.OpenTimestampsProviderConfig{ + CalendarServers: []string{"https://alice.btc.calendar.opentimestamps.org"}, + } + packed, err := anypb.New(wrongType) + if err != nil { + t.Fatalf("anypb.New: %v", err) + } + + _, err = tp.CreateTypedModule("audit.ledger", "my-ledger", packed) + if err == nil { + t.Fatal("expected unmarshal error for mismatched anypb type, got nil") + } +} + +// TestCreateTypedModule_DeferredProvider verifies ethereum and aws_qldb return +// a "not yet implemented" error rather than panicking or silently succeeding. +func TestCreateTypedModule_DeferredProvider(t *testing.T) { + tp := typedModuleProvider(t) + + for _, typeName := range []string{ + "audit.anchor_provider.ethereum", + "audit.anchor_provider.aws_qldb", + } { + _, err := tp.CreateTypedModule(typeName, "deferred", nil) + if err == nil { + t.Errorf("%s: expected error, got nil", typeName) + continue + } + if !strings.Contains(err.Error(), "not yet implemented") { + t.Errorf("%s: unexpected error message: %v", typeName, err) + } + } +} + +// TestCreateTypedModule_UnknownType verifies that a completely unknown module +// type returns an "unknown module type" error. +func TestCreateTypedModule_UnknownType(t *testing.T) { + tp := typedModuleProvider(t) + + _, err := tp.CreateTypedModule("unknown.module.type", "x", nil) + if err == nil { + t.Fatal("expected error for unknown type, got nil") + } + if !strings.Contains(err.Error(), "unknown module type") { + t.Errorf("unexpected error message: %v", err) + } +} diff --git a/migrations/004_indexes.down.sql b/migrations/004_indexes.down.sql index 5e4583e..b1c509f 100644 --- a/migrations/004_indexes.down.sql +++ b/migrations/004_indexes.down.sql @@ -1,5 +1,6 @@ -- 004_indexes.down.sql DROP INDEX IF EXISTS idx_audit_anchors_pending; +DROP INDEX IF EXISTS idx_audit_anchors_unique_anchor; DROP INDEX IF EXISTS idx_audit_anchors_ledger_range; DROP INDEX IF EXISTS idx_audit_log_event_type_created; DROP INDEX IF EXISTS idx_audit_log_ledger_seq; diff --git a/migrations/004_indexes.sql b/migrations/004_indexes.sql index d0d14ce..e127e72 100644 --- a/migrations/004_indexes.sql +++ b/migrations/004_indexes.sql @@ -8,6 +8,11 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_event_type_created CREATE INDEX IF NOT EXISTS idx_audit_anchors_ledger_range ON audit_anchors(ledger, range_start, range_end); +-- Unique anchor per (ledger, provider, range) — guards AnchorHandler idempotency +-- so retried calls with the same range do not produce duplicate anchor rows. +CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_anchors_unique_anchor + ON audit_anchors(ledger, provider, range_start, range_end); + -- Partial index for polling pending/confirmed anchors (skip finalized rows). CREATE INDEX IF NOT EXISTS idx_audit_anchors_pending ON audit_anchors(provider, confirmation) diff --git a/modules/anchor_provider.go b/modules/anchor_provider.go new file mode 100644 index 0000000..beb265e --- /dev/null +++ b/modules/anchor_provider.go @@ -0,0 +1,145 @@ +package modules + +import ( + "context" + "fmt" + "sync" + "time" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers" + gitprovider "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers/git" + otsprovider "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers/opentimestamps" + sigprovider "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers/sigstore" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── AnchorProvider registry ─────────────────────────────────────────────────── + +var ( + anchorProviderMu sync.RWMutex + anchorProviderRegistry = make(map[string]providers.AnchorProvider) +) + +// RegisterAnchorProvider registers an AnchorProvider under the given instance +// name. +func RegisterAnchorProvider(instanceName string, p providers.AnchorProvider) { + anchorProviderMu.Lock() + defer anchorProviderMu.Unlock() + anchorProviderRegistry[instanceName] = p +} + +// GetAnchorProvider looks up an AnchorProvider by instance name. +func GetAnchorProvider(instanceName string) (providers.AnchorProvider, bool) { + anchorProviderMu.RLock() + defer anchorProviderMu.RUnlock() + p, ok := anchorProviderRegistry[instanceName] + return p, ok +} + +// UnregisterAnchorProvider removes a provider from the registry (called on Stop +// and in tests). +func UnregisterAnchorProvider(instanceName string) { + anchorProviderMu.Lock() + defer anchorProviderMu.Unlock() + delete(anchorProviderRegistry, instanceName) +} + +// AnchorProviderNames returns a snapshot of all currently registered anchor +// provider names. Used by step.audit.anchor when providers = [] (all configured). +func AnchorProviderNames() []string { + anchorProviderMu.RLock() + defer anchorProviderMu.RUnlock() + names := make([]string, 0, len(anchorProviderRegistry)) + for name := range anchorProviderRegistry { + names = append(names, name) + } + return names +} + +// ── anchorProviderModule ────────────────────────────────────────────────────── + +// anchorProviderModule is the shared sdk.ModuleInstance implementation for all +// audit.anchor_provider.* types. It holds a constructed AnchorProvider and +// registers / unregisters it on lifecycle calls. +type anchorProviderModule struct { + instanceName string + provider providers.AnchorProvider +} + +// Compile-time assertion. +var _ sdk.ModuleInstance = (*anchorProviderModule)(nil) + +// Init registers the underlying AnchorProvider in the global registry. +func (m *anchorProviderModule) Init() error { + RegisterAnchorProvider(m.instanceName, m.provider) + return nil +} + +// Start is a no-op for anchor provider modules. +func (m *anchorProviderModule) Start(_ context.Context) error { return nil } + +// Stop unregisters the provider from the registry. +func (m *anchorProviderModule) Stop(_ context.Context) error { + UnregisterAnchorProvider(m.instanceName) + return nil +} + +// ── OpenTimestamps factory ──────────────────────────────────────────────────── + +// NewOpenTimestampsProviderModule creates a ModuleInstance for +// audit.anchor_provider.opentimestamps from a typed OpenTimestampsProviderConfig. +func NewOpenTimestampsProviderModule(instanceName string, cfg *auditv1.OpenTimestampsProviderConfig) (sdk.ModuleInstance, error) { + if len(cfg.GetCalendarServers()) == 0 { + return nil, fmt.Errorf("audit.anchor_provider.opentimestamps %q: at least one calendar server required", instanceName) + } + timeout := time.Duration(cfg.GetHttpTimeoutMs()) * time.Millisecond + p, err := otsprovider.NewProvider(otsprovider.Config{ + CalendarServers: cfg.GetCalendarServers(), + HTTPTimeout: timeout, + }) + if err != nil { + return nil, fmt.Errorf("audit.anchor_provider.opentimestamps %q: %w", instanceName, err) + } + return &anchorProviderModule{instanceName: instanceName, provider: p}, nil +} + +// ── Git factory ─────────────────────────────────────────────────────────────── + +// NewGitAnchorProviderModule creates a ModuleInstance for +// audit.anchor_provider.git from a typed GitAnchorProviderConfig. +func NewGitAnchorProviderModule(instanceName string, cfg *auditv1.GitAnchorProviderConfig) (sdk.ModuleInstance, error) { + if cfg.GetRemote() == "" { + return nil, fmt.Errorf("audit.anchor_provider.git %q: config.remote is required", instanceName) + } + p, err := gitprovider.NewProvider(gitprovider.Config{ + Remote: cfg.GetRemote(), + Branch: cfg.GetBranch(), + CommitTemplate: cfg.GetCommitTemplate(), + AuthorName: cfg.GetAuthorName(), + AuthorEmail: cfg.GetAuthorEmail(), + UseSSHAgent: cfg.GetUseSshAgent(), + SSHKeyPath: cfg.GetSshKeyPath(), + SSHKeyPassword: cfg.GetSshKeyPassword(), + HTTPUsername: cfg.GetHttpUsername(), + HTTPPassword: cfg.GetHttpPassword(), + }) + if err != nil { + return nil, fmt.Errorf("audit.anchor_provider.git %q: %w", instanceName, err) + } + return &anchorProviderModule{instanceName: instanceName, provider: p}, nil +} + +// ── Sigstore factory ────────────────────────────────────────────────────────── + +// NewSigstoreProviderModule creates a ModuleInstance for +// audit.anchor_provider.sigstore from a typed SigstoreProviderConfig. +func NewSigstoreProviderModule(instanceName string, cfg *auditv1.SigstoreProviderConfig) (sdk.ModuleInstance, error) { + p, err := sigprovider.NewProvider(sigprovider.Config{ + RekorURL: cfg.GetRekorUrl(), + }) + if err != nil { + return nil, fmt.Errorf("audit.anchor_provider.sigstore %q: %w", instanceName, err) + } + return &anchorProviderModule{instanceName: instanceName, provider: p}, nil +} diff --git a/modules/anchor_provider_test.go b/modules/anchor_provider_test.go new file mode 100644 index 0000000..4a35da0 --- /dev/null +++ b/modules/anchor_provider_test.go @@ -0,0 +1,188 @@ +package modules_test + +import ( + "context" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" +) + +// TestNewAnchorProviderModule_OTS verifies the OpenTimestamps module factory +// creates a valid module from a typed OpenTimestampsProviderConfig. +func TestNewAnchorProviderModule_OTS(t *testing.T) { + cfg := &auditv1.OpenTimestampsProviderConfig{ + CalendarServers: []string{"https://alice.btc.calendar.opentimestamps.org"}, + } + m, err := modules.NewOpenTimestampsProviderModule("my-ots", cfg) + if err != nil { + t.Fatalf("NewOpenTimestampsProviderModule: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +// TestNewAnchorProviderModule_OTS_RejectsEmptyCalendars verifies that +// OpenTimestamps module creation fails when no calendar servers are specified. +func TestNewAnchorProviderModule_OTS_RejectsEmptyCalendars(t *testing.T) { + cfg := &auditv1.OpenTimestampsProviderConfig{ + CalendarServers: nil, + } + _, err := modules.NewOpenTimestampsProviderModule("my-ots", cfg) + if err == nil { + t.Fatal("expected error for empty calendar servers, got nil") + } +} + +// TestNewAnchorProviderModule_Git verifies the git module factory creates a +// valid module from a typed GitAnchorProviderConfig. +func TestNewAnchorProviderModule_Git(t *testing.T) { + cfg := &auditv1.GitAnchorProviderConfig{ + Remote: "file:///tmp/test-repo", + } + m, err := modules.NewGitAnchorProviderModule("my-git", cfg) + if err != nil { + t.Fatalf("NewGitAnchorProviderModule: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +// TestNewAnchorProviderModule_Git_RejectsEmptyRemote verifies that the git +// module creation fails when no remote is configured. +func TestNewAnchorProviderModule_Git_RejectsEmptyRemote(t *testing.T) { + cfg := &auditv1.GitAnchorProviderConfig{ + Remote: "", + } + _, err := modules.NewGitAnchorProviderModule("my-git", cfg) + if err == nil { + t.Fatal("expected error for empty remote, got nil") + } +} + +// TestNewAnchorProviderModule_Sigstore verifies the Sigstore module factory +// creates a valid module from a typed SigstoreProviderConfig. +func TestNewAnchorProviderModule_Sigstore(t *testing.T) { + cfg := &auditv1.SigstoreProviderConfig{ + RekorUrl: "https://rekor.sigstore.dev", + } + m, err := modules.NewSigstoreProviderModule("my-sigstore", cfg) + if err != nil { + t.Fatalf("NewSigstoreProviderModule: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +// TestAnchorProviderModule_InitRegistersProvider verifies that Init registers +// the anchor provider in the global registry. +func TestAnchorProviderModule_OTS_InitRegistersProvider(t *testing.T) { + const name = "ots-test-instance" + modules.UnregisterAnchorProvider(name) + t.Cleanup(func() { modules.UnregisterAnchorProvider(name) }) + + cfg := &auditv1.OpenTimestampsProviderConfig{ + CalendarServers: []string{"https://alice.btc.calendar.opentimestamps.org"}, + } + m, err := modules.NewOpenTimestampsProviderModule(name, cfg) + if err != nil { + t.Fatalf("NewOpenTimestampsProviderModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + p, ok := modules.GetAnchorProvider(name) + if !ok { + t.Fatal("GetAnchorProvider returned false after Init") + } + if p == nil { + t.Fatal("GetAnchorProvider returned nil provider") + } + if p.Name() != "opentimestamps" { + t.Errorf("provider.Name() = %q, want %q", p.Name(), "opentimestamps") + } +} + +// TestAnchorProviderModule_Git_InitRegistersProvider verifies the git provider +// is registered after Init. A file:// remote is used to avoid SSH agent checks +// at construction time (no actual push occurs). +func TestAnchorProviderModule_Git_InitRegistersProvider(t *testing.T) { + const name = "git-test-instance" + modules.UnregisterAnchorProvider(name) + t.Cleanup(func() { modules.UnregisterAnchorProvider(name) }) + + cfg := &auditv1.GitAnchorProviderConfig{ + Remote: "file:///tmp/test-anchor-repo", + } + m, err := modules.NewGitAnchorProviderModule(name, cfg) + if err != nil { + t.Fatalf("NewGitAnchorProviderModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + p, ok := modules.GetAnchorProvider(name) + if !ok { + t.Fatal("GetAnchorProvider returned false after Init") + } + if p.Name() != "git" { + t.Errorf("provider.Name() = %q, want %q", p.Name(), "git") + } +} + +// TestAnchorProviderModule_Sigstore_InitRegistersProvider verifies the Sigstore +// provider is registered after Init. +func TestAnchorProviderModule_Sigstore_InitRegistersProvider(t *testing.T) { + const name = "sigstore-test-instance" + modules.UnregisterAnchorProvider(name) + t.Cleanup(func() { modules.UnregisterAnchorProvider(name) }) + + cfg := &auditv1.SigstoreProviderConfig{} + m, err := modules.NewSigstoreProviderModule(name, cfg) + if err != nil { + t.Fatalf("NewSigstoreProviderModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + p, ok := modules.GetAnchorProvider(name) + if !ok { + t.Fatal("GetAnchorProvider returned false after Init") + } + if p.Name() != "sigstore" { + t.Errorf("provider.Name() = %q, want %q", p.Name(), "sigstore") + } +} + +// TestAnchorProviderModule_StopUnregisters verifies that Stop removes the +// provider from the registry. +func TestAnchorProviderModule_StopUnregisters(t *testing.T) { + const name = "stop-ots-instance" + modules.UnregisterAnchorProvider(name) + + cfg := &auditv1.OpenTimestampsProviderConfig{ + CalendarServers: []string{"https://alice.btc.calendar.opentimestamps.org"}, + } + m, err := modules.NewOpenTimestampsProviderModule(name, cfg) + if err != nil { + t.Fatalf("NewOpenTimestampsProviderModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if err := m.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + + _, ok := modules.GetAnchorProvider(name) + if ok { + t.Fatal("GetAnchorProvider returned true after Stop; should be unregistered") + } +} diff --git a/modules/ledger.go b/modules/ledger.go new file mode 100644 index 0000000..79c6b09 --- /dev/null +++ b/modules/ledger.go @@ -0,0 +1,147 @@ +// Package modules provides workflow modular.Module implementations for the +// audit-chain plugin. Each module type wires a layer from the chain/ and +// providers/ packages into the workflow engine's dependency-injection system. +// +// The package exposes package-level registries (ledger and anchor-provider) so +// that step implementations can look up the handles they need without coupling +// to specific module instances. +package modules + +import ( + "context" + "database/sql" + "fmt" + "sync" + + _ "github.com/jackc/pgx/v5/stdlib" // register pgx driver + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── Ledger registry ─────────────────────────────────────────────────────────── + +var ( + ledgerMu sync.RWMutex + ledgerRegistry = make(map[string]*chain.Appender) +) + +// ── DB registry ─────────────────────────────────────────────────────────────── +// Steps that need raw SQL (verify, merkle_root, anchor, proof, public_receipt) +// look up the *sql.DB by the ledger partition name rather than rebuilding a +// separate connection pool. + +var ( + dbMu sync.RWMutex + dbRegistry = make(map[string]*sql.DB) +) + +// RegisterDB stores a *sql.DB under the given ledger partition name. +func RegisterDB(partitionName string, db *sql.DB) { + dbMu.Lock() + defer dbMu.Unlock() + dbRegistry[partitionName] = db +} + +// GetDB looks up a *sql.DB by ledger partition name. +func GetDB(partitionName string) (*sql.DB, bool) { + dbMu.RLock() + defer dbMu.RUnlock() + db, ok := dbRegistry[partitionName] + return db, ok +} + +// UnregisterDB removes a DB from the registry (called on Stop and in tests). +func UnregisterDB(partitionName string) { + dbMu.Lock() + defer dbMu.Unlock() + delete(dbRegistry, partitionName) +} + +// RegisterLedger registers an Appender in the global ledger registry under the +// given partition name. +func RegisterLedger(partitionName string, a *chain.Appender) { + ledgerMu.Lock() + defer ledgerMu.Unlock() + ledgerRegistry[partitionName] = a +} + +// GetLedger looks up an Appender by ledger partition name. +func GetLedger(partitionName string) (*chain.Appender, bool) { + ledgerMu.RLock() + defer ledgerMu.RUnlock() + a, ok := ledgerRegistry[partitionName] + return a, ok +} + +// UnregisterLedger removes a ledger Appender from the registry (called on Stop +// and in tests). +func UnregisterLedger(partitionName string) { + ledgerMu.Lock() + defer ledgerMu.Unlock() + delete(ledgerRegistry, partitionName) +} + +// ── LedgerModule ───────────────────────────────────────────────────────────── + +// LedgerModule implements sdk.ModuleInstance for the audit.ledger module type. +// It opens a Postgres connection, creates a chain.Appender, and registers it in +// the global ledger registry under LedgerConfig.Name (the partition key). +type LedgerModule struct { + instanceName string + config *auditv1.LedgerConfig + db *sql.DB +} + +// Compile-time assertion. +var _ sdk.ModuleInstance = (*LedgerModule)(nil) + +// NewLedgerModule creates a LedgerModule from a typed LedgerConfig proto. +// Returns an error if config.Name or config.Dsn is empty. +func NewLedgerModule(instanceName string, config *auditv1.LedgerConfig) (sdk.ModuleInstance, error) { + if config.GetName() == "" { + return nil, fmt.Errorf("audit.ledger %q: config.name is required", instanceName) + } + if config.GetDsn() == "" { + return nil, fmt.Errorf("audit.ledger %q: config.dsn is required", instanceName) + } + return &LedgerModule{ + instanceName: instanceName, + config: config, + }, nil +} + +// Init opens the Postgres connection and registers the Appender. +// sql.Open is lazy: the connection string is validated at parse time but no +// network call is made until the first query. This keeps Init fast and allows +// unit tests to run without a real Postgres instance. +// Returns an error if called more than once to prevent connection pool leaks. +func (m *LedgerModule) Init() error { + if m.db != nil { + return fmt.Errorf("audit.ledger %q: already initialized", m.instanceName) + } + db, err := sql.Open("pgx", m.config.GetDsn()) + if err != nil { + return fmt.Errorf("audit.ledger %q: open db: %w", m.instanceName, err) + } + m.db = db + RegisterLedger(m.config.GetName(), chain.NewAppender(db)) + RegisterDB(m.config.GetName(), db) + return nil +} + +// Start is a no-op for the ledger module. +func (m *LedgerModule) Start(_ context.Context) error { return nil } + +// Stop unregisters the ledger and DB, then closes the underlying DB connection. +func (m *LedgerModule) Stop(_ context.Context) error { + UnregisterLedger(m.config.GetName()) + UnregisterDB(m.config.GetName()) + if m.db != nil { + if err := m.db.Close(); err != nil { + return fmt.Errorf("audit.ledger %q: close db: %w", m.instanceName, err) + } + } + return nil +} diff --git a/modules/ledger_test.go b/modules/ledger_test.go new file mode 100644 index 0000000..212ae83 --- /dev/null +++ b/modules/ledger_test.go @@ -0,0 +1,160 @@ +package modules_test + +import ( + "context" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" +) + +// TestNewLedgerModule_RejectsEmptyName verifies that NewLedgerModule fails when +// config.Name is empty. +func TestNewLedgerModule_RejectsEmptyName(t *testing.T) { + cfg := &auditv1.LedgerConfig{ + Name: "", + Dsn: "postgres://user:pass@localhost/db?sslmode=disable", + } + _, err := modules.NewLedgerModule("test-instance", cfg) + if err == nil { + t.Fatal("expected error for empty name, got nil") + } +} + +// TestNewLedgerModule_RejectsEmptyDSN verifies that NewLedgerModule fails when +// no DSN is provided. +func TestNewLedgerModule_RejectsEmptyDSN(t *testing.T) { + cfg := &auditv1.LedgerConfig{ + Name: "test-ledger", + Dsn: "", + } + _, err := modules.NewLedgerModule("test-ledger", cfg) + if err == nil { + t.Fatal("expected error for empty DSN, got nil") + } +} + +// TestNewLedgerModule_CreatesModule verifies the factory creates a non-nil module +// and that the module's metadata is correct. +func TestNewLedgerModule_CreatesModule(t *testing.T) { + cfg := &auditv1.LedgerConfig{ + Name: "test-ledger", + Dsn: "postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable", + } + m, err := modules.NewLedgerModule("test-ledger", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +// TestLedgerModule_InitRegistersAppender verifies that Init registers the ledger +// appender in the global registry under the partition name. Since sql.Open is +// lazy (no real network call), this passes without a running Postgres instance. +func TestLedgerModule_InitRegistersAppender(t *testing.T) { + ledgerName := "init-test-ledger" + + // Ensure registry is clean before test. + modules.UnregisterLedger(ledgerName) + t.Cleanup(func() { modules.UnregisterLedger(ledgerName) }) + + cfg := &auditv1.LedgerConfig{ + Name: ledgerName, + Dsn: "postgres://user:pass@localhost:5432/db?sslmode=disable", + } + m, err := modules.NewLedgerModule("test-instance", cfg) + if err != nil { + t.Fatalf("NewLedgerModule: %v", err) + } + + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + // ProvidesServices: the ledger appender must be retrievable from the registry. + a, ok := modules.GetLedger(ledgerName) + if !ok { + t.Fatal("GetLedger returned false after Init; appender not registered") + } + if a == nil { + t.Fatal("GetLedger returned nil appender") + } +} + +// TestLedgerModule_StopUnregisters verifies that Stop removes the ledger from +// the registry. +func TestLedgerModule_StopUnregisters(t *testing.T) { + ledgerName := "stop-test-ledger" + + modules.UnregisterLedger(ledgerName) + t.Cleanup(func() { modules.UnregisterLedger(ledgerName) }) + + cfg := &auditv1.LedgerConfig{ + Name: ledgerName, + Dsn: "postgres://user:pass@localhost:5432/db?sslmode=disable", + } + m, err := modules.NewLedgerModule("test-instance", cfg) + if err != nil { + t.Fatalf("NewLedgerModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + ctx := context.Background() + if err := m.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + + _, ok := modules.GetLedger(ledgerName) + if ok { + t.Fatal("GetLedger returned true after Stop; appender should have been unregistered") + } +} + +// TestLedgerModule_InitDoubleCallReturnsError verifies that calling Init twice +// returns an error rather than leaking the first DB connection pool. +func TestLedgerModule_InitDoubleCallReturnsError(t *testing.T) { + ledgerName := "double-init-ledger" + modules.UnregisterLedger(ledgerName) + t.Cleanup(func() { modules.UnregisterLedger(ledgerName) }) + + cfg := &auditv1.LedgerConfig{ + Name: ledgerName, + Dsn: "postgres://user:pass@localhost:5432/db?sslmode=disable", + } + m, err := modules.NewLedgerModule("test-instance", cfg) + if err != nil { + t.Fatalf("NewLedgerModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("first Init: %v", err) + } + if err := m.Init(); err == nil { + t.Fatal("expected error on second Init call, got nil") + } +} + +// TestLedgerModule_StartIsNoop verifies that Start does not error. +func TestLedgerModule_StartIsNoop(t *testing.T) { + ledgerName := "start-test-ledger" + modules.UnregisterLedger(ledgerName) + t.Cleanup(func() { modules.UnregisterLedger(ledgerName) }) + + cfg := &auditv1.LedgerConfig{ + Name: ledgerName, + Dsn: "postgres://user:pass@localhost:5432/db?sslmode=disable", + } + m, err := modules.NewLedgerModule("test-instance", cfg) + if err != nil { + t.Fatalf("NewLedgerModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatalf("Start: unexpected error: %v", err) + } +} diff --git a/plugin.contracts.json b/plugin.contracts.json index 6456479..da8ca72 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -1,42 +1,84 @@ { "version": "1", - "protoPackage": "workflow.plugin.audit.v1", - "protoFile": "proto/audit.proto", "contracts": [ { + "kind": "module", + "type": "audit.ledger", + "mode": "strict_proto", + "config": "workflow.plugin.audit.v1.LedgerConfig" + }, + { + "kind": "module", + "type": "audit.anchor_provider.opentimestamps", + "mode": "strict_proto", + "config": "workflow.plugin.audit.v1.OpenTimestampsProviderConfig" + }, + { + "kind": "module", + "type": "audit.anchor_provider.git", + "mode": "strict_proto", + "config": "workflow.plugin.audit.v1.GitAnchorProviderConfig" + }, + { + "kind": "module", + "type": "audit.anchor_provider.sigstore", + "mode": "strict_proto", + "config": "workflow.plugin.audit.v1.SigstoreProviderConfig" + }, + { + "kind": "step", "type": "step.audit.append", - "inputType": "workflow.plugin.audit.v1.AppendRequest", - "outputType": "workflow.plugin.audit.v1.AppendResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.AppendRequest", + "output": "workflow.plugin.audit.v1.AppendResponse" }, { + "kind": "step", "type": "step.audit.verify", - "inputType": "workflow.plugin.audit.v1.VerifyRequest", - "outputType": "workflow.plugin.audit.v1.VerifyResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.VerifyRequest", + "output": "workflow.plugin.audit.v1.VerifyResponse" }, { + "kind": "step", "type": "step.audit.merkle_root", - "inputType": "workflow.plugin.audit.v1.MerkleRootRequest", - "outputType": "workflow.plugin.audit.v1.MerkleRootResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.MerkleRootRequest", + "output": "workflow.plugin.audit.v1.MerkleRootResponse" }, { + "kind": "step", "type": "step.audit.anchor", - "inputType": "workflow.plugin.audit.v1.AnchorRequest", - "outputType": "workflow.plugin.audit.v1.AnchorResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.AnchorRequest", + "output": "workflow.plugin.audit.v1.AnchorResponse" }, { + "kind": "step", "type": "step.audit.poll_anchor_confirmation", - "inputType": "workflow.plugin.audit.v1.PollAnchorConfirmationRequest", - "outputType": "workflow.plugin.audit.v1.PollAnchorConfirmationResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.PollAnchorConfirmationRequest", + "output": "workflow.plugin.audit.v1.PollAnchorConfirmationResponse" }, { + "kind": "step", "type": "step.audit.proof", - "inputType": "workflow.plugin.audit.v1.ProofRequest", - "outputType": "workflow.plugin.audit.v1.ProofResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.ProofRequest", + "output": "workflow.plugin.audit.v1.ProofResponse" }, { + "kind": "step", "type": "step.audit.public_receipt", - "inputType": "workflow.plugin.audit.v1.PublicReceiptRequest", - "outputType": "workflow.plugin.audit.v1.PublicReceiptResponse" + "mode": "strict_proto", + "input": "workflow.plugin.audit.v1.PublicReceiptRequest", + "output": "workflow.plugin.audit.v1.PublicReceiptResponse" + }, + { + "kind": "trigger", + "type": "trigger.audit.entry_appended", + "mode": "strict_proto", + "output": "workflow.plugin.audit.v1.Entry" } ] } diff --git a/plugin.json b/plugin.json index e1bf5ae..f001f4e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "workflow-plugin-audit-chain", "version": "0.1.0", - "description": "Tamper-evident hash-chained audit logging with periodic Merkle root anchoring (OpenTimestamps/Bitcoin, git, Sigstore, Ethereum, AWS QLDB)", + "description": "Tamper-evident hash-chained audit logging with periodic Merkle root anchoring (OpenTimestamps/Bitcoin, git, Sigstore)", "author": "GoCodeAlone", "license": "MIT", "type": "external", @@ -17,9 +17,7 @@ "audit.ledger", "audit.anchor_provider.opentimestamps", "audit.anchor_provider.git", - "audit.anchor_provider.sigstore", - "audit.anchor_provider.ethereum", - "audit.anchor_provider.aws_qldb" + "audit.anchor_provider.sigstore" ], "stepTypes": [ "step.audit.append", diff --git a/proto/audit.proto b/proto/audit.proto index 8513fa9..16759a3 100644 --- a/proto/audit.proto +++ b/proto/audit.proto @@ -18,6 +18,53 @@ message LedgerConfig { int32 anchor_min_entries = 5; // payload_schema is an optional JSON Schema (bytes) for payload validation at append time. bytes payload_schema = 6; + // dsn is the PostgreSQL connection string for the backing store + // (e.g. "postgres://user:pass@host/db?sslmode=disable"). + string dsn = 7; +} + +// ─── anchor provider module configs ────────────────────────────────────────── + +// OpenTimestampsProviderConfig is the typed config for audit.anchor_provider.opentimestamps. +message OpenTimestampsProviderConfig { + // calendar_servers lists the OTS calendar server base URLs to submit to. + // At least one is required. + repeated string calendar_servers = 1; + // http_timeout_ms is the per-request HTTP timeout in milliseconds. + // 0 means use the default (30 000 ms). + int64 http_timeout_ms = 2; +} + +// GitAnchorProviderConfig is the typed config for audit.anchor_provider.git. +message GitAnchorProviderConfig { + // remote is the git remote URL (file path, https, git+ssh, etc.). Required. + string remote = 1; + // branch is the branch to push anchors to. Defaults to "main". + string branch = 2; + // commit_template is a Go text/template string for the commit message. + // Defaults to "anchor: {{.MerkleRoot}}". + string commit_template = 3; + // author_name is the git commit author name. Defaults to "audit-chain-bot". + string author_name = 4; + // author_email is the git commit author email. Defaults to "audit-chain-bot@localhost". + string author_email = 5; + // use_ssh_agent uses the system SSH agent for authentication. + bool use_ssh_agent = 6; + // ssh_key_path is the path to a PEM-encoded private key file. + string ssh_key_path = 7; + // ssh_key_password is the passphrase for the PEM key at ssh_key_path. + string ssh_key_password = 8; + // http_username provides HTTP Basic Auth credentials for HTTPS remotes. + string http_username = 9; + // http_password provides HTTP Basic Auth credentials (or PAT) for HTTPS remotes. + string http_password = 10; +} + +// SigstoreProviderConfig is the typed config for audit.anchor_provider.sigstore. +message SigstoreProviderConfig { + // rekor_url is the base URL of the Rekor instance. + // Defaults to "https://rekor.sigstore.dev" when empty. + string rekor_url = 1; } // ─── step.audit.append ─────────────────────────────────────────────────────── @@ -141,6 +188,9 @@ message PollAnchorConfirmationRequest { string external_id = 3; // proof_data is the opaque provider-specific proof bytes stored in audit_anchors.proof_data. bytes proof_data = 4; + // ledger identifies which ledger's DB handle to use when updating + // audit_anchors.confirmation after a status change. + string ledger = 5; } // PollAnchorConfirmationResponse is the output from step.audit.poll_anchor_confirmation. diff --git a/steps/anchor.go b/steps/anchor.go new file mode 100644 index 0000000..7d3cf57 --- /dev/null +++ b/steps/anchor.go @@ -0,0 +1,131 @@ +package steps + +import ( + "context" + "fmt" + "time" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// AnchorHandler is the TypedStepHandler for step.audit.anchor. +// It computes the Merkle root over the requested sequence range, submits it +// to each configured anchor provider, and records the pending anchors in +// audit_anchors. +func AnchorHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AnchorRequest], +) (*sdk.TypedStepResult[*auditv1.AnchorResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.anchor: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.anchor: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + // Resolve the sequence range. 0 end_sequence → latest from audit_ledgers. + startSeq := input.GetStartSequence() + endSeq := input.GetEndSequence() + if endSeq == 0 { + err := db.QueryRowContext(ctx, + `SELECT last_sequence FROM audit_ledgers WHERE ledger = $1`, + input.GetLedger(), + ).Scan(&endSeq) + if err != nil { + return nil, fmt.Errorf("step.audit.anchor: resolve last_sequence for %q: %w", input.GetLedger(), err) + } + } + + // Query entry hashes in range. + rows, err := db.QueryContext(ctx, ` + SELECT entry_hash + FROM audit_log + WHERE ledger = $1 + AND sequence >= $2 + AND sequence <= $3 + ORDER BY sequence ASC`, + input.GetLedger(), startSeq, endSeq, + ) + if err != nil { + return nil, fmt.Errorf("step.audit.anchor: query entry hashes: %w", err) + } + defer rows.Close() + + var hashes []string + for rows.Next() { + var h string + if err := rows.Scan(&h); err != nil { + return nil, fmt.Errorf("step.audit.anchor: scan: %w", err) + } + hashes = append(hashes, h) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("step.audit.anchor: rows: %w", err) + } + if len(hashes) == 0 { + return nil, fmt.Errorf("step.audit.anchor: no entries in ledger %q sequence range [%d, %d]", + input.GetLedger(), startSeq, endSeq) + } + + merkleRoot, err := chain.MerkleRoot(hashes) + if err != nil { + return nil, fmt.Errorf("step.audit.anchor: %w", err) + } + + // Determine which providers to use. + providerNames := input.GetProviders() + if len(providerNames) == 0 { + // Empty = all configured anchor providers in the registry (snapshot names). + providerNames = modules.AnchorProviderNames() + } + + var anchorRecords []*auditv1.AnchorRecord + for _, name := range providerNames { + p, ok := modules.GetAnchorProvider(name) + if !ok { + return nil, fmt.Errorf("step.audit.anchor: anchor provider %q not registered", name) + } + + a, err := p.Anchor(ctx, providers.MerkleRoot{Hex: merkleRoot}) + if err != nil { + return nil, fmt.Errorf("step.audit.anchor: provider %q: %w", name, err) + } + + // Persist to audit_anchors. ON CONFLICT DO NOTHING makes this idempotent: + // a retried anchor step for the same (ledger, provider, range) silently + // skips the INSERT rather than failing with a unique-constraint violation. + if _, err := db.ExecContext(ctx, ` + INSERT INTO audit_anchors + (ledger, range_start, range_end, merkle_root, provider, external_id, proof_data, confirmation, anchored_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (ledger, provider, range_start, range_end) DO NOTHING`, + input.GetLedger(), startSeq, endSeq, merkleRoot, + a.ProviderName, a.ExternalID, a.ProofData, string(a.Confirmation), + a.AnchoredAt.UTC(), + ); err != nil { + return nil, fmt.Errorf("step.audit.anchor: insert audit_anchors for provider %q: %w", name, err) + } + + anchorRecords = append(anchorRecords, &auditv1.AnchorRecord{ + Provider: a.ProviderName, + ExternalId: a.ExternalID, + Confirmation: string(a.Confirmation), + AnchoredAt: a.AnchoredAt.UTC().Format(time.RFC3339), + }) + } + + return &sdk.TypedStepResult[*auditv1.AnchorResponse]{ + Output: &auditv1.AnchorResponse{ + Anchors: anchorRecords, + }, + }, nil +} diff --git a/steps/anchor_test.go b/steps/anchor_test.go new file mode 100644 index 0000000..733e762 --- /dev/null +++ b/steps/anchor_test.go @@ -0,0 +1,47 @@ +package steps_test + +import ( + "context" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestAnchorHandler_EmptyLedger(t *testing.T) { + _, err := steps.AnchorHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AnchorRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.AnchorRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestAnchorHandler_DBNotRegistered(t *testing.T) { + const ledger = "anchor-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.AnchorHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AnchorRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.AnchorRequest{ + Ledger: ledger, + StartSequence: 1, + EndSequence: 10, + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} diff --git a/steps/append.go b/steps/append.go new file mode 100644 index 0000000..ddb218e --- /dev/null +++ b/steps/append.go @@ -0,0 +1,60 @@ +// Package steps implements the seven audit-chain step types as typed proto +// handlers. All handler functions satisfy sdk.TypedStepHandler and are wired +// into sdk.TypedStepFactory instances in internal/plugin.go. +// +// Zero map[string]any: every handler receives a typed *auditv1.* input and +// returns a typed *auditv1.* output via sdk.TypedStepResult. +package steps + +import ( + "context" + "fmt" + "time" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// AppendHandler is the TypedStepHandler for step.audit.append. +// It appends one hash-chained entry to the named ledger and returns the +// assigned sequence number, entry hash, and server-side timestamp. +func AppendHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AppendRequest], +) (*sdk.TypedStepResult[*auditv1.AppendResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.append: ledger is required") + } + if input.GetEventType() == "" { + return nil, fmt.Errorf("step.audit.append: event_type is required") + } + + appender, ok := modules.GetLedger(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.append: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + seq, entryHash, createdAt, err := appender.Append( + ctx, + input.GetLedger(), + input.GetEventType(), + input.GetPayload(), + input.GetMetadata(), + input.GetActor(), + ) + if err != nil { + return nil, fmt.Errorf("step.audit.append: %w", err) + } + + return &sdk.TypedStepResult[*auditv1.AppendResponse]{ + Output: &auditv1.AppendResponse{ + Sequence: seq, + EntryHash: entryHash, + CreatedAt: createdAt.UTC().Format(time.RFC3339), + }, + }, nil +} diff --git a/steps/append_test.go b/steps/append_test.go new file mode 100644 index 0000000..f287b6a --- /dev/null +++ b/steps/append_test.go @@ -0,0 +1,65 @@ +package steps_test + +import ( + "context" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestAppendHandler_EmptyLedger(t *testing.T) { + _, err := steps.AppendHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AppendRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.AppendRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestAppendHandler_LedgerNotRegistered(t *testing.T) { + const ledger = "append-test-unregistered" + modules.UnregisterLedger(ledger) + t.Cleanup(func() { modules.UnregisterLedger(ledger) }) + + _, err := steps.AppendHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AppendRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.AppendRequest{ + Ledger: ledger, + EventType: "test.event", + Payload: []byte(`{"k":"v"}`), + }, + }) + if err == nil { + t.Fatal("expected error for unregistered ledger, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} + +func TestAppendHandler_EmptyEventType(t *testing.T) { + const ledger = "append-test-empty-event-type" + modules.UnregisterLedger(ledger) + t.Cleanup(func() { modules.UnregisterLedger(ledger) }) + + _, err := steps.AppendHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.AppendRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.AppendRequest{ + Ledger: ledger, + EventType: "", + Payload: []byte(`{"k":"v"}`), + }, + }) + if err == nil { + t.Fatal("expected error for empty event_type, got nil") + } +} diff --git a/steps/merkle_root.go b/steps/merkle_root.go new file mode 100644 index 0000000..b566c7f --- /dev/null +++ b/steps/merkle_root.go @@ -0,0 +1,89 @@ +package steps + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// MerkleRootHandler is the TypedStepHandler for step.audit.merkle_root. +// It reads the entry hashes for the requested sequence range, builds a binary +// Merkle tree using RFC 6962 leaf/node hashing, and returns the root. +func MerkleRootHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.MerkleRootRequest], +) (*sdk.TypedStepResult[*auditv1.MerkleRootResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.merkle_root: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.merkle_root: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + rows, err := db.QueryContext(ctx, ` + SELECT sequence, entry_hash + FROM audit_log + WHERE ledger = $1 + AND sequence >= $2 + AND ($3 = 0 OR sequence <= $3) + ORDER BY sequence ASC`, + input.GetLedger(), + input.GetStartSequence(), + input.GetEndSequence(), + ) + if err != nil { + return nil, fmt.Errorf("step.audit.merkle_root: query: %w", err) + } + defer rows.Close() + + var ( + hashes []string + startSeq int64 + endSeq int64 + first = true + ) + for rows.Next() { + var seq int64 + var entryHash string + if err := rows.Scan(&seq, &entryHash); err != nil { + return nil, fmt.Errorf("step.audit.merkle_root: scan: %w", err) + } + if first { + startSeq = seq + first = false + } + endSeq = seq + hashes = append(hashes, entryHash) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("step.audit.merkle_root: rows: %w", err) + } + + if len(hashes) == 0 { + return nil, fmt.Errorf("step.audit.merkle_root: no entries found in ledger %q for sequence range [%d, %d]", + input.GetLedger(), input.GetStartSequence(), input.GetEndSequence()) + } + + root, err := chain.MerkleRoot(hashes) + if err != nil { + return nil, fmt.Errorf("step.audit.merkle_root: %w", err) + } + + return &sdk.TypedStepResult[*auditv1.MerkleRootResponse]{ + Output: &auditv1.MerkleRootResponse{ + Root: root, + EntriesIncluded: int64(len(hashes)), + StartSequence: startSeq, + EndSequence: endSeq, + }, + }, nil +} diff --git a/steps/merkle_root_test.go b/steps/merkle_root_test.go new file mode 100644 index 0000000..e289c8d --- /dev/null +++ b/steps/merkle_root_test.go @@ -0,0 +1,47 @@ +package steps_test + +import ( + "context" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestMerkleRootHandler_EmptyLedger(t *testing.T) { + _, err := steps.MerkleRootHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.MerkleRootRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.MerkleRootRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestMerkleRootHandler_DBNotRegistered(t *testing.T) { + const ledger = "merkle-root-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.MerkleRootHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.MerkleRootRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.MerkleRootRequest{ + Ledger: ledger, + StartSequence: 1, + EndSequence: 10, + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} diff --git a/steps/poll_anchor_confirmation.go b/steps/poll_anchor_confirmation.go new file mode 100644 index 0000000..0039692 --- /dev/null +++ b/steps/poll_anchor_confirmation.go @@ -0,0 +1,120 @@ +package steps + +import ( + "context" + "fmt" + "strconv" + "time" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// PollAnchorConfirmationHandler is the TypedStepHandler for +// step.audit.poll_anchor_confirmation. +// +// Swallow-transient-errors contract (§ 3.5c): +// - Transient errors (network, 5xx, calendar unreachable) → successful response +// with swallowed = true, error_message set, confirmation unchanged. +// - Hard errors (invalid proof, 4xx semantic rejection) → gRPC error (returned +// as a non-nil error from this handler). +func PollAnchorConfirmationHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest], +) (*sdk.TypedStepResult[*auditv1.PollAnchorConfirmationResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + p, ok := modules.GetAnchorProvider(input.GetProvider()) + if !ok { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: anchor provider %q not registered", input.GetProvider()) + } + + // Parse anchor_id (stored as string to be pipeline-friendly; DB primary key is BIGSERIAL). + anchorID, err := strconv.ParseInt(input.GetAnchorId(), 10, 64) + if err != nil { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: invalid anchor_id %q: %w", input.GetAnchorId(), err) + } + + // Read the current confirmation from the DB. + var prevConfirmation string + if err := db.QueryRowContext(ctx, + `SELECT confirmation FROM audit_anchors WHERE id = $1`, + anchorID, + ).Scan(&prevConfirmation); err != nil { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: read anchor %d: %w", anchorID, err) + } + + anchor := providers.Anchor{ + ProviderName: input.GetProvider(), + ExternalID: input.GetExternalId(), + ProofData: input.GetProofData(), + Confirmation: providers.ConfirmationLevel(prevConfirmation), + } + + v, err := p.Verify(ctx, anchor) + if err != nil { + // Hard error — abort step. + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: verify: %w", err) + } + + updatedAt := v.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + + // Transient error swallowed by the provider — return success without updating DB. + if v.Swallowed { + return &sdk.TypedStepResult[*auditv1.PollAnchorConfirmationResponse]{ + Output: &auditv1.PollAnchorConfirmationResponse{ + PreviousConfirmation: prevConfirmation, + CurrentConfirmation: prevConfirmation, + Transitioned: false, + UpdatedAt: updatedAt.UTC().Format(time.RFC3339), + Swallowed: true, + ErrorMessage: v.ErrorMessage, + }, + }, nil + } + + // Forward-only ordering guard: prevent downgrade (finalized→confirmed, + // confirmed→pending). A provider returning a lower confirmation level must + // not overwrite a more advanced state in the DB. + confirmationOrder := map[string]int{"pending": 0, "confirmed": 1, "finalized": 2} + currentConfirmation := string(v.Confirmation) + transitioned := confirmationOrder[currentConfirmation] > confirmationOrder[prevConfirmation] + + if transitioned { + _, err = db.ExecContext(ctx, ` + UPDATE audit_anchors + SET confirmation = $1, + confirmed_at = CASE WHEN $1 = 'confirmed' THEN NOW() ELSE confirmed_at END, + finalized_at = CASE WHEN $1 = 'finalized' THEN NOW() ELSE finalized_at END + WHERE id = $2`, + currentConfirmation, anchorID, + ) + if err != nil { + return nil, fmt.Errorf("step.audit.poll_anchor_confirmation: update anchor %d: %w", anchorID, err) + } + } + + return &sdk.TypedStepResult[*auditv1.PollAnchorConfirmationResponse]{ + Output: &auditv1.PollAnchorConfirmationResponse{ + PreviousConfirmation: prevConfirmation, + CurrentConfirmation: currentConfirmation, + Transitioned: transitioned, + UpdatedAt: updatedAt.UTC().Format(time.RFC3339), + }, + }, nil +} diff --git a/steps/poll_anchor_confirmation_test.go b/steps/poll_anchor_confirmation_test.go new file mode 100644 index 0000000..e5058fd --- /dev/null +++ b/steps/poll_anchor_confirmation_test.go @@ -0,0 +1,188 @@ +package steps_test + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + _ "github.com/jackc/pgx/v5/stdlib" // register pgx driver for sql.Open in tests + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestPollAnchorConfirmationHandler_EmptyLedger(t *testing.T) { + _, err := steps.PollAnchorConfirmationHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PollAnchorConfirmationRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestPollAnchorConfirmationHandler_DBNotRegistered(t *testing.T) { + const ledger = "poll-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.PollAnchorConfirmationHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PollAnchorConfirmationRequest{ + Ledger: ledger, + AnchorId: "42", + Provider: "opentimestamps", + ExternalId: "abc123", + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} + +// TestPollAnchorConfirmationHandler_TransientError_Swallowed verifies that when +// the anchor provider returns Swallowed=true (transient network error), the +// handler returns success with swallowed=true and no gRPC error. +// This is the load-bearing contract from § 3.5c. +func TestPollAnchorConfirmationHandler_TransientError_Swallowed(t *testing.T) { + const ( + ledger = "poll-test-transient" + provider = "poll-test-transient-provider" + ) + + // Register a fake DB: returns confirmation="pending" for any SELECT. + db := openFakeDB(t) + modules.RegisterDB(ledger, db) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + // Mock provider returns Swallowed=true (calendar server unreachable). + mock := &mockAnchorProvider{ + providerName: provider, + verifyResult: providers.Verification{ + Provider: provider, + Confirmation: providers.ConfirmationPending, + Swallowed: true, + ErrorMessage: "calendar server timeout: dial tcp: connection refused", + }, + } + modules.RegisterAnchorProvider(provider, mock) + t.Cleanup(func() { modules.UnregisterAnchorProvider(provider) }) + + result, err := steps.PollAnchorConfirmationHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PollAnchorConfirmationRequest{ + Ledger: ledger, + AnchorId: "1", + Provider: provider, + ExternalId: "abc123", + }, + }) + + if err != nil { + t.Fatalf("expected no gRPC error for transient swallowed error, got: %v", err) + } + if result == nil || result.Output == nil { + t.Fatal("expected non-nil result") + } + out := result.Output + if !out.GetSwallowed() { + t.Error("swallowed should be true for transient error") + } + if out.GetTransitioned() { + t.Error("transitioned should be false when error is swallowed") + } + if out.GetErrorMessage() == "" { + t.Error("error_message should be populated when swallowed=true") + } + if out.GetCurrentConfirmation() != out.GetPreviousConfirmation() { + t.Errorf("current_confirmation should equal previous when swallowed: prev=%q cur=%q", + out.GetPreviousConfirmation(), out.GetCurrentConfirmation()) + } +} + +// TestPollAnchorConfirmationHandler_HardError_PropagatesGRPC verifies that when +// the anchor provider returns a non-nil error (hard error: invalid proof, 4xx), +// the handler propagates it as a gRPC error (non-nil return error). +func TestPollAnchorConfirmationHandler_HardError_PropagatesGRPC(t *testing.T) { + const ( + ledger = "poll-test-hard-error" + provider = "poll-test-hard-error-provider" + ) + + db := openFakeDB(t) + modules.RegisterDB(ledger, db) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + mock := &mockAnchorProvider{ + providerName: provider, + verifyErr: errors.New("4xx: proof rejected by provider — invalid merkle path"), + } + modules.RegisterAnchorProvider(provider, mock) + t.Cleanup(func() { modules.UnregisterAnchorProvider(provider) }) + + _, err := steps.PollAnchorConfirmationHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PollAnchorConfirmationRequest{ + Ledger: ledger, + AnchorId: "1", + Provider: provider, + ExternalId: "abc123", + }, + }) + + if err == nil { + t.Fatal("expected gRPC error for hard provider error, got nil") + } + if !strings.Contains(err.Error(), "4xx") { + t.Errorf("error should propagate the provider's rejection message, got: %v", err) + } +} + +func TestPollAnchorConfirmationHandler_ProviderNotRegistered(t *testing.T) { + const ( + ledger = "poll-test-prov-unregistered" + provider = "poll-test-missing-provider" + ) + + // Register a lazy DB (sql.Open is lazy — no real connection until first query). + // The pgx driver is registered via the blank import above. + db, err := sql.Open("pgx", "postgres://u:p@localhost:5432/db?sslmode=disable") + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + modules.RegisterDB(ledger, db) + t.Cleanup(func() { + modules.UnregisterDB(ledger) + _ = db.Close() + }) + + modules.UnregisterAnchorProvider(provider) + t.Cleanup(func() { modules.UnregisterAnchorProvider(provider) }) + + _, err = steps.PollAnchorConfirmationHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PollAnchorConfirmationRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PollAnchorConfirmationRequest{ + Ledger: ledger, + AnchorId: "99", + Provider: provider, + ExternalId: "xyz", + }, + }) + if err == nil { + t.Fatal("expected error for unregistered provider, got nil") + } + if !strings.Contains(err.Error(), provider) { + t.Errorf("error should mention provider name %q, got: %v", provider, err) + } +} diff --git a/steps/proof.go b/steps/proof.go new file mode 100644 index 0000000..a793b05 --- /dev/null +++ b/steps/proof.go @@ -0,0 +1,193 @@ +package steps + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// ProofHandler is the TypedStepHandler for step.audit.proof. +// It fetches the entry at the requested sequence, finds all anchors covering +// that sequence, and builds a Merkle inclusion proof for the first covering +// anchor range. +func ProofHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.ProofRequest], +) (*sdk.TypedStepResult[*auditv1.ProofResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.proof: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.proof: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + entry, err := fetchEntry(ctx, db, input.GetLedger(), input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.proof: %w", err) + } + + anchors, err := fetchAnchorsForSequence(ctx, db, input.GetLedger(), input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.proof: %w", err) + } + + var merklePath []string + var merkleRoot string + if len(anchors) > 0 { + a := anchors[0] + merkleRoot = a.merkleRootHex + + hashes, idx, err := queryEntryHashesWithIndex(ctx, db, input.GetLedger(), a.rangeStart, a.rangeEnd, input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.proof: %w", err) + } + if idx >= 0 { + merklePath, err = chain.InclusionProof(hashes, idx) + if err != nil { + return nil, fmt.Errorf("step.audit.proof: InclusionProof: %w", err) + } + } + } + + anchorRecords := make([]*auditv1.AnchorRecord, 0, len(anchors)) + for _, a := range anchors { + anchorRecords = append(anchorRecords, a.record) + } + + return &sdk.TypedStepResult[*auditv1.ProofResponse]{ + Output: &auditv1.ProofResponse{ + Entry: entry, + MerklePath: merklePath, + MerkleRoot: merkleRoot, + Anchors: anchorRecords, + }, + }, nil +} + +// ── shared query helpers ────────────────────────────────────────────────────── + +// fetchEntry queries a single audit_log row and converts it to *auditv1.Entry. +func fetchEntry(ctx context.Context, db *sql.DB, ledger string, sequence int64) (*auditv1.Entry, error) { + var ( + seq int64 + eventType, payloadHash, prevEntryHash, entryHash, actor string + payload, metadata []byte + createdAt time.Time + ) + err := db.QueryRowContext(ctx, ` + SELECT sequence, event_type, payload, payload_hash, + prev_entry_hash, entry_hash, created_at, appended_by_actor, metadata + FROM audit_log + WHERE ledger = $1 AND sequence = $2`, + ledger, sequence, + ).Scan(&seq, &eventType, &payload, &payloadHash, + &prevEntryHash, &entryHash, &createdAt, &actor, &metadata) + if err != nil { + return nil, fmt.Errorf("fetch entry seq=%d ledger=%q: %w", sequence, ledger, err) + } + return &auditv1.Entry{ + Sequence: seq, + Ledger: ledger, + EventType: eventType, + Payload: payload, + EntryHash: entryHash, + PrevEntryHash: prevEntryHash, + CreatedAt: createdAt.UTC().Format(time.RFC3339), + Actor: actor, + Metadata: metadata, + }, nil +} + +// anchorRow holds data from audit_anchors for internal use. +type anchorRow struct { + rangeStart, rangeEnd int64 + merkleRootHex string + record *auditv1.AnchorRecord +} + +// fetchAnchorsForSequence returns all audit_anchors rows whose range covers seq. +func fetchAnchorsForSequence(ctx context.Context, db *sql.DB, ledger string, seq int64) ([]anchorRow, error) { + rows, err := db.QueryContext(ctx, ` + SELECT range_start, range_end, merkle_root, provider, external_id, confirmation, anchored_at + FROM audit_anchors + WHERE ledger = $1 + AND range_start <= $2 + AND range_end >= $2 + ORDER BY id ASC`, + ledger, seq, + ) + if err != nil { + return nil, fmt.Errorf("query anchors for seq=%d: %w", seq, err) + } + defer rows.Close() + + var result []anchorRow + for rows.Next() { + var ( + rangeStart, rangeEnd int64 + merkleRoot, prov, extID, conf string + anchoredAt time.Time + ) + if err := rows.Scan(&rangeStart, &rangeEnd, &merkleRoot, &prov, &extID, &conf, &anchoredAt); err != nil { + return nil, fmt.Errorf("scan anchor: %w", err) + } + result = append(result, anchorRow{ + rangeStart: rangeStart, + rangeEnd: rangeEnd, + merkleRootHex: merkleRoot, + record: &auditv1.AnchorRecord{ + Provider: prov, + ExternalId: extID, + Confirmation: conf, + AnchoredAt: anchoredAt.UTC().Format(time.RFC3339), + }, + }) + } + return result, rows.Err() +} + +// queryEntryHashesWithIndex queries entry hashes for [rangeStart, rangeEnd] +// and returns the slice of hashes plus the index of targetSeq (-1 if absent). +func queryEntryHashesWithIndex(ctx context.Context, db *sql.DB, ledger string, rangeStart, rangeEnd, targetSeq int64) ([]string, int, error) { + rows, err := db.QueryContext(ctx, ` + SELECT sequence, entry_hash + FROM audit_log + WHERE ledger = $1 + AND sequence >= $2 + AND sequence <= $3 + ORDER BY sequence ASC`, + ledger, rangeStart, rangeEnd, + ) + if err != nil { + return nil, -1, fmt.Errorf("query entry hashes [%d,%d]: %w", rangeStart, rangeEnd, err) + } + defer rows.Close() + + var hashes []string + idx := -1 + i := 0 + for rows.Next() { + var seq int64 + var h string + if err := rows.Scan(&seq, &h); err != nil { + return nil, -1, fmt.Errorf("scan entry hash: %w", err) + } + if seq == targetSeq { + idx = i + } + hashes = append(hashes, h) + i++ + } + return hashes, idx, rows.Err() +} diff --git a/steps/proof_test.go b/steps/proof_test.go new file mode 100644 index 0000000..6aa18fb --- /dev/null +++ b/steps/proof_test.go @@ -0,0 +1,46 @@ +package steps_test + +import ( + "context" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestProofHandler_EmptyLedger(t *testing.T) { + _, err := steps.ProofHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.ProofRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.ProofRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestProofHandler_DBNotRegistered(t *testing.T) { + const ledger = "proof-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.ProofHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.ProofRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.ProofRequest{ + Ledger: ledger, + Sequence: 5, + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} diff --git a/steps/public_receipt.go b/steps/public_receipt.go new file mode 100644 index 0000000..19b972a --- /dev/null +++ b/steps/public_receipt.go @@ -0,0 +1,211 @@ +package steps + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// ── receipt document types (no map[string]any) ──────────────────────────────── + +// receiptDocument is the top-level object serialised into receipt_json. +type receiptDocument struct { + Entry receiptEntry `json:"entry"` + MerkleProof receiptMerkleProof `json:"merkle_proof"` + Anchors []receiptAnchor `json:"anchors"` + PseudonymMap map[string]string `json:"pseudonym_map,omitempty"` + GeneratedAt string `json:"generated_at"` +} + +// receiptEntry holds the audit log entry fields included in the receipt. +type receiptEntry struct { + Sequence int64 `json:"sequence"` + Ledger string `json:"ledger"` + EventType string `json:"event_type"` + EntryHash string `json:"entry_hash"` + PrevEntryHash string `json:"prev_entry_hash"` + Payload json.RawMessage `json:"payload"` + CreatedAt string `json:"created_at"` +} + +// receiptMerkleProof holds the Merkle root and inclusion path. +type receiptMerkleProof struct { + MerkleRoot string `json:"merkle_root"` + MerklePath []string `json:"merkle_path"` +} + +// receiptAnchor holds a single anchor record for the receipt document. +type receiptAnchor struct { + Provider string `json:"provider"` + ExternalID string `json:"external_id"` + Confirmation string `json:"confirmation"` + AnchoredAt string `json:"anchored_at"` +} + +// ── handler ─────────────────────────────────────────────────────────────────── + +// PublicReceiptHandler is the TypedStepHandler for step.audit.public_receipt. +// It builds a self-contained verifiable receipt JSON that includes the audit +// entry, its Merkle inclusion proof, all covering anchor records, and an +// optional pseudonymisation map for redacted payload fields. +func PublicReceiptHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PublicReceiptRequest], +) (*sdk.TypedStepResult[*auditv1.PublicReceiptResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.public_receipt: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.public_receipt: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + entry, err := fetchEntry(ctx, db, input.GetLedger(), input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: %w", err) + } + + anchors, err := fetchAnchorsForSequence(ctx, db, input.GetLedger(), input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: %w", err) + } + + var merklePath []string + var merkleRoot string + if len(anchors) > 0 { + a := anchors[0] + merkleRoot = a.merkleRootHex + + hashes, idx, err := queryEntryHashesWithIndex(ctx, db, input.GetLedger(), a.rangeStart, a.rangeEnd, input.GetSequence()) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: %w", err) + } + if idx >= 0 { + merklePath, err = chain.InclusionProof(hashes, idx) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: InclusionProof: %w", err) + } + } + } + if merklePath == nil { + merklePath = []string{} + } + + // Apply payload redactions if requested. + redactedPayload, pseudonymMap, err := ApplyRedactions(entry.GetPayload(), input.GetRedactFields()) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: redact: %w", err) + } + + // Convert anchors to the receipt document format. + anchorsForDoc := make([]receiptAnchor, 0, len(anchors)) + for _, a := range anchors { + anchorsForDoc = append(anchorsForDoc, receiptAnchor{ + Provider: a.record.GetProvider(), + ExternalID: a.record.GetExternalId(), + Confirmation: a.record.GetConfirmation(), + AnchoredAt: a.record.GetAnchoredAt(), + }) + } + + // Marshal the receipt document using typed structs (no map[string]any). + receiptDoc := receiptDocument{ + Entry: receiptEntry{ + Sequence: entry.GetSequence(), + Ledger: entry.GetLedger(), + EventType: entry.GetEventType(), + EntryHash: entry.GetEntryHash(), + PrevEntryHash: entry.GetPrevEntryHash(), + Payload: json.RawMessage(redactedPayload), + CreatedAt: entry.GetCreatedAt(), + }, + MerkleProof: receiptMerkleProof{ + MerkleRoot: merkleRoot, + MerklePath: merklePath, + }, + Anchors: anchorsForDoc, + PseudonymMap: pseudonymMap, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + } + + receiptBytes, err := json.Marshal(receiptDoc) + if err != nil { + return nil, fmt.Errorf("step.audit.public_receipt: marshal receipt: %w", err) + } + receiptJSON := string(receiptBytes) + + h := sha256.Sum256(receiptBytes) + receiptHash := hex.EncodeToString(h[:]) + + return &sdk.TypedStepResult[*auditv1.PublicReceiptResponse]{ + Output: &auditv1.PublicReceiptResponse{ + ReceiptJson: receiptJSON, + ReceiptHash: receiptHash, + // ReceiptUrl is empty until a serving layer is wired; hash + JSON + // are sufficient for offline verification. + }, + }, nil +} + +// ── redaction ───────────────────────────────────────────────────────────────── + +// ApplyRedactions replaces the listed top-level JSON keys in payload with +// stable per-receipt pseudonyms of the form "contributor_N", where N increments +// once per unique original value within this receipt's scope (duplicate original +// values receive the same pseudonym). The field is REPLACED (not deleted) so +// the payload structure is preserved. +// Returns the modified payload bytes, a field→pseudonym mapping, and any error. +// Exported so it can be tested independently. +func ApplyRedactions(payload []byte, redactFields []string) ([]byte, map[string]string, error) { + if len(redactFields) == 0 || len(payload) == 0 { + return payload, nil, nil + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(payload, &obj); err != nil { + return nil, nil, fmt.Errorf("unmarshal payload: %w", err) + } + + pseudonymMap := make(map[string]string, len(redactFields)) + // valueToCounter maps the raw JSON representation of a value to its assigned + // pseudonym so that duplicate originals get the same contributor label. + valueToCounter := make(map[string]string) + counter := 1 + + for _, field := range redactFields { + raw, exists := obj[field] + if !exists { + continue + } + // Deduplicate: identical raw JSON values share one pseudonym in this receipt. + key := string(raw) + pseudonym, seen := valueToCounter[key] + if !seen { + pseudonym = "contributor_" + strconv.Itoa(counter) + valueToCounter[key] = pseudonym + counter++ + } + pseudonymMap[field] = pseudonym + // Replace (not delete) the field value with the pseudonym string. + obj[field] = json.RawMessage(strconv.Quote(pseudonym)) + } + + redacted, err := json.Marshal(obj) + if err != nil { + return nil, nil, fmt.Errorf("marshal redacted payload: %w", err) + } + return redacted, pseudonymMap, nil +} diff --git a/steps/public_receipt_test.go b/steps/public_receipt_test.go new file mode 100644 index 0000000..ed035dc --- /dev/null +++ b/steps/public_receipt_test.go @@ -0,0 +1,136 @@ +package steps_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestPublicReceiptHandler_EmptyLedger(t *testing.T) { + _, err := steps.PublicReceiptHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PublicReceiptRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PublicReceiptRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestPublicReceiptHandler_DBNotRegistered(t *testing.T) { + const ledger = "receipt-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.PublicReceiptHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.PublicReceiptRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.PublicReceiptRequest{ + Ledger: ledger, + Sequence: 5, + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +} + +// ── ApplyRedactions tests ───────────────────────────────────────────────────── + +// TestApplyRedactions_TwoDistinctFields verifies that two fields with different +// original values are replaced with contributor_1 and contributor_2 respectively, +// and the pseudonym map records both mappings. +func TestApplyRedactions_TwoDistinctFields(t *testing.T) { + payload := []byte(`{"user_id":"alice","partner_id":"bob","amount":100}`) + + redacted, pseudonymMap, err := steps.ApplyRedactions(payload, []string{"user_id", "partner_id"}) + if err != nil { + t.Fatalf("ApplyRedactions: %v", err) + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(redacted, &obj); err != nil { + t.Fatalf("unmarshal redacted payload: %v", err) + } + + // user_id → "contributor_1" (first unique value) + var userID string + if err := json.Unmarshal(obj["user_id"], &userID); err != nil { + t.Fatalf("unmarshal user_id: %v", err) + } + if userID != "contributor_1" { + t.Errorf("user_id: got %q, want %q", userID, "contributor_1") + } + + // partner_id → "contributor_2" (second unique value) + var partnerID string + if err := json.Unmarshal(obj["partner_id"], &partnerID); err != nil { + t.Fatalf("unmarshal partner_id: %v", err) + } + if partnerID != "contributor_2" { + t.Errorf("partner_id: got %q, want %q", partnerID, "contributor_2") + } + + // amount is unchanged + var amount float64 + if err := json.Unmarshal(obj["amount"], &amount); err != nil { + t.Fatalf("unmarshal amount: %v", err) + } + if amount != 100 { + t.Errorf("amount should be unchanged: got %v", amount) + } + + // pseudonym_map contains both mappings + if pseudonymMap["user_id"] != "contributor_1" { + t.Errorf("pseudonymMap[user_id]: got %q, want %q", pseudonymMap["user_id"], "contributor_1") + } + if pseudonymMap["partner_id"] != "contributor_2" { + t.Errorf("pseudonymMap[partner_id]: got %q, want %q", pseudonymMap["partner_id"], "contributor_2") + } +} + +// TestApplyRedactions_DuplicateOriginalValue verifies that two fields sharing the +// same original value receive the same contributor pseudonym. +func TestApplyRedactions_DuplicateOriginalValue(t *testing.T) { + payload := []byte(`{"first_name":"alice","display_name":"alice","amount":100}`) + + _, pseudonymMap, err := steps.ApplyRedactions(payload, []string{"first_name", "display_name"}) + if err != nil { + t.Fatalf("ApplyRedactions: %v", err) + } + if pseudonymMap["first_name"] != "contributor_1" { + t.Errorf("first_name: got %q, want contributor_1", pseudonymMap["first_name"]) + } + // Same original value "alice" → same pseudonym + if pseudonymMap["display_name"] != "contributor_1" { + t.Errorf("display_name: got %q, want contributor_1 (duplicate of first_name)", pseudonymMap["display_name"]) + } +} + +// TestApplyRedactions_NoRedactFields verifies that an empty redact list returns +// the original payload unchanged with a nil pseudonym map. +func TestApplyRedactions_NoRedactFields(t *testing.T) { + payload := []byte(`{"user_id":"alice"}`) + + redacted, pseudonymMap, err := steps.ApplyRedactions(payload, nil) + if err != nil { + t.Fatalf("ApplyRedactions: %v", err) + } + if string(redacted) != string(payload) { + t.Errorf("no-op redaction modified payload: got %s", redacted) + } + if len(pseudonymMap) != 0 { + t.Errorf("no-op redaction returned non-empty pseudonym map: %v", pseudonymMap) + } +} diff --git a/steps/testutil_test.go b/steps/testutil_test.go new file mode 100644 index 0000000..1a0c2b8 --- /dev/null +++ b/steps/testutil_test.go @@ -0,0 +1,130 @@ +package steps_test + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "io" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/providers" +) + +// ── mock AnchorProvider ─────────────────────────────────────────────────────── + +// mockAnchorProvider lets tests control the result returned by Verify. +type mockAnchorProvider struct { + providerName string + verifyResult providers.Verification + verifyErr error +} + +func (m *mockAnchorProvider) Name() string { return m.providerName } + +func (m *mockAnchorProvider) Anchor(_ context.Context, _ providers.MerkleRoot) (providers.Anchor, error) { + return providers.Anchor{}, nil +} + +func (m *mockAnchorProvider) Verify(_ context.Context, _ providers.Anchor) (providers.Verification, error) { + return m.verifyResult, m.verifyErr +} + +func (m *mockAnchorProvider) Cost(_ int) providers.Cost { + return providers.Cost{} +} + +// ── fake SQL driver ─────────────────────────────────────────────────────────── +// +// A minimal database/sql/driver implementation that returns a single row +// containing "pending" for any SELECT query. Used to get past the +// `SELECT confirmation FROM audit_anchors WHERE id = $1` read in +// PollAnchorConfirmationHandler without a real Postgres instance. + +var fakeDriverCount int64 + +// openFakeDB returns a *sql.DB backed by a fake driver that returns +// confirmation="pending" for any SELECT and succeeds for any DML. +// The underlying connection is closed via t.Cleanup. +func openFakeDB(t *testing.T) *sql.DB { + t.Helper() + n := atomic.AddInt64(&fakeDriverCount, 1) + name := fmt.Sprintf("audit-chain-fake-sql-%d", n) + sql.Register(name, &fakeSQLDriver{}) + db, err := sql.Open(name, "fake://test") + if err != nil { + t.Fatalf("openFakeDB sql.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// fakeSQLDriver opens fakeSQLConn connections. +type fakeSQLDriver struct{} + +func (d *fakeSQLDriver) Open(_ string) (driver.Conn, error) { + return &fakeSQLConn{}, nil +} + +// fakeSQLConn implements driver.Conn and driver.QueryerContext. +type fakeSQLConn struct{} + +func (c *fakeSQLConn) Prepare(_ string) (driver.Stmt, error) { + return &fakeSQLStmt{}, nil +} + +func (c *fakeSQLConn) Close() error { return nil } + +func (c *fakeSQLConn) Begin() (driver.Tx, error) { + return &fakeSQLTx{}, nil +} + +// QueryContext is the driver.QueryerContext fast path — bypasses Prepare. +func (c *fakeSQLConn) QueryContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { + return &fakeSQLRows{}, nil +} + +// ExecContext is the driver.ExecerContext fast path for INSERT/UPDATE/DELETE. +func (c *fakeSQLConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) { + return driver.RowsAffected(1), nil +} + +// fakeSQLTx is a no-op transaction. +type fakeSQLTx struct{} + +func (t *fakeSQLTx) Commit() error { return nil } +func (t *fakeSQLTx) Rollback() error { return nil } + +// fakeSQLStmt is a no-op prepared statement (fallback when QueryerContext is unused). +type fakeSQLStmt struct{} + +func (s *fakeSQLStmt) Close() error { return nil } +func (s *fakeSQLStmt) NumInput() int { return -1 } +func (s *fakeSQLStmt) Exec(_ []driver.Value) (driver.Result, error) { return driver.RowsAffected(1), nil } +func (s *fakeSQLStmt) Query(_ []driver.Value) (driver.Rows, error) { return &fakeSQLRows{}, nil } + +// fakeSQLRows returns one row: confirmation = "pending". +// Any scan destination beyond index 0 receives a zero time.Time value so that +// multi-column scans do not panic. +type fakeSQLRows struct { + done bool +} + +func (r *fakeSQLRows) Columns() []string { return []string{"confirmation"} } +func (r *fakeSQLRows) Close() error { return nil } + +func (r *fakeSQLRows) Next(dest []driver.Value) error { + if r.done { + return io.EOF + } + r.done = true + if len(dest) > 0 { + dest[0] = "pending" + } + for i := 1; i < len(dest); i++ { + dest[i] = time.Time{} // zero value for additional columns + } + return nil +} diff --git a/steps/verify.go b/steps/verify.go new file mode 100644 index 0000000..8ddde52 --- /dev/null +++ b/steps/verify.go @@ -0,0 +1,102 @@ +package steps + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/workflow-plugin-audit-chain/chain" + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// VerifyHandler is the TypedStepHandler for step.audit.verify. +// It scans the audit_log table in the requested sequence range and +// re-derives each entry hash, checking both hash integrity and chain linkage. +func VerifyHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *auditv1.VerifyRequest], +) (*sdk.TypedStepResult[*auditv1.VerifyResponse], error) { + input := req.Input + + if input.GetLedger() == "" { + return nil, fmt.Errorf("step.audit.verify: ledger is required") + } + + db, ok := modules.GetDB(input.GetLedger()) + if !ok { + return nil, fmt.Errorf("step.audit.verify: ledger %q not registered; ensure the audit.ledger module is initialised", input.GetLedger()) + } + + rows, err := db.QueryContext(ctx, ` + SELECT sequence, event_type, payload_hash, prev_entry_hash, entry_hash + FROM audit_log + WHERE ledger = $1 + AND sequence >= $2 + AND ($3 = 0 OR sequence <= $3) + ORDER BY sequence ASC`, + input.GetLedger(), + input.GetStartSequence(), + input.GetEndSequence(), + ) + if err != nil { + return nil, fmt.Errorf("step.audit.verify: query: %w", err) + } + defer rows.Close() + + var ( + verified int64 + prevHash string + firstSeq int64 = -1 + ) + for rows.Next() { + var seq int64 + var eventType, payloadHash, prevEntryHash, entryHash string + if err := rows.Scan(&seq, &eventType, &payloadHash, &prevEntryHash, &entryHash); err != nil { + return nil, fmt.Errorf("step.audit.verify: scan: %w", err) + } + + // Check chain linkage: prev_entry_hash must match the previous entry's hash + // (or be empty for the genesis entry at sequence 1). + if verified > 0 && prevEntryHash != prevHash { + return &sdk.TypedStepResult[*auditv1.VerifyResponse]{ + Output: &auditv1.VerifyResponse{ + Valid: false, + FirstInvalidSequence: seq, + FailureReason: fmt.Sprintf("chain link broken at sequence %d: prev_entry_hash mismatch", seq), + EntriesVerified: verified, + }, + }, nil + } + + // Re-derive the entry hash and compare. + expected := chain.EntryHash(seq, input.GetLedger(), eventType, payloadHash, prevEntryHash) + if expected != entryHash { + return &sdk.TypedStepResult[*auditv1.VerifyResponse]{ + Output: &auditv1.VerifyResponse{ + Valid: false, + FirstInvalidSequence: seq, + FailureReason: fmt.Sprintf("entry hash mismatch at sequence %d: stored=%s derived=%s", seq, entryHash, expected), + EntriesVerified: verified, + }, + }, nil + } + + if firstSeq < 0 { + firstSeq = seq + } + prevHash = entryHash + verified++ + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("step.audit.verify: rows: %w", err) + } + + return &sdk.TypedStepResult[*auditv1.VerifyResponse]{ + Output: &auditv1.VerifyResponse{ + Valid: true, + EntriesVerified: verified, + }, + }, nil +} diff --git a/steps/verify_test.go b/steps/verify_test.go new file mode 100644 index 0000000..83a5e58 --- /dev/null +++ b/steps/verify_test.go @@ -0,0 +1,47 @@ +package steps_test + +import ( + "context" + "strings" + "testing" + + auditv1 "github.com/GoCodeAlone/workflow-plugin-audit-chain/gen" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/modules" + "github.com/GoCodeAlone/workflow-plugin-audit-chain/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestVerifyHandler_EmptyLedger(t *testing.T) { + _, err := steps.VerifyHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.VerifyRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.VerifyRequest{Ledger: ""}, + }) + if err == nil { + t.Fatal("expected error for empty ledger, got nil") + } + if !strings.Contains(err.Error(), "ledger") { + t.Errorf("error should mention ledger field, got: %v", err) + } +} + +func TestVerifyHandler_DBNotRegistered(t *testing.T) { + const ledger = "verify-test-unregistered" + modules.UnregisterDB(ledger) + t.Cleanup(func() { modules.UnregisterDB(ledger) }) + + _, err := steps.VerifyHandler(context.Background(), sdk.TypedStepRequest[*emptypb.Empty, *auditv1.VerifyRequest]{ + Config: &emptypb.Empty{}, + Input: &auditv1.VerifyRequest{ + Ledger: ledger, + StartSequence: 1, + EndSequence: 10, + }, + }) + if err == nil { + t.Fatal("expected error for unregistered DB, got nil") + } + if !strings.Contains(err.Error(), ledger) { + t.Errorf("error should mention ledger name %q, got: %v", ledger, err) + } +}