From 314a25353d0974289b4bd61da78e45a437224139 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sat, 4 Jul 2026 00:43:13 +0800 Subject: [PATCH] feat(abi,auth): first-class uuid[] DbValue + trusted identity headers (WZ-016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shared wasm-boundary fixes surfaced by the messenger port (WZ-013): 1. uuid[] DbValue variant. bnwasm had no uuid-array bind/scan, so every ANY($1::uuid[]) query broke in the guest ("unsupported argument type []uuid.UUID") and messenger worked around it with a ::text[]::uuid[] cast. Adds a dedicated DbValue.uuid_array_value (abiv1.UuidArray) — distinct from text[] so the host binds a native uuid[] param (queries keep ::uuid[]) and scans a uuid[] column straight into []uuid.UUID. Guest toDbValue marshals []uuid.UUID; naturalValue/assign parse the canonical strings back into []uuid.UUID (nil→NULL, empty stays empty). Pinned by the uuid_array entry in the shared DbValueFixtures contract (round-trip + driver-value tests green). 2. Trusted identity headers (auth/trustedheaders.go). Context does not cross the ABI, so guests cannot see the host's verified principal. The SECURE contract: the host runs its RBAC guard against the signature-verified JWT, strips any client-supplied copy of the X-Bn-Verified-* headers, and sets them itself from auth.Get{Public,}UserFromContext; the guest reconstructs context via auth.TrustedHeaderMiddleware and trusts ONLY those headers. Guests MUST NOT decode a client cookie/Bearer token for identity — that is a privilege-escalation bug (a verified public user forging an admin JWT the guest would honour on a RolePublic method). Documented in docs/wasm-abi.md, replacing the ambiguous "auth context reaches the guest via HttpRequest headers" line that invited the insecure decode. Co-Authored-By: Claude Fable 5 --- abi/proto/v1/db.proto | 11 ++ abi/v1/db.pb.go | 208 +++++++++++++------- auth/trustedheaders.go | 91 +++++++++ auth/trustedheaders_test.go | 82 ++++++++ docs/wasm-abi.md | 48 ++++- plugin/wasmguest/bnwasm/dbvalue.go | 41 ++++ plugin/wasmguest/bnwasm/dbvalue_fixtures.go | 20 ++ plugin/wasmguest/bnwasm/dbvalue_test.go | 44 +++++ 8 files changed, 476 insertions(+), 69 deletions(-) create mode 100644 auth/trustedheaders.go create mode 100644 auth/trustedheaders_test.go diff --git a/abi/proto/v1/db.proto b/abi/proto/v1/db.proto index c764d94..3d44223 100644 --- a/abi/proto/v1/db.proto +++ b/abi/proto/v1/db.proto @@ -33,6 +33,11 @@ message DbValue { string numeric_value = 10; // text[] array. TextArray text_array_value = 11; + // uuid[] array (each element canonical string form). Distinct from + // text_array so the host binds a native uuid[] parameter (letting a query + // keep `ANY($1::uuid[])` with no text[]-cast workaround) and scans a uuid[] + // column straight into []uuid.UUID. + UuidArray uuid_array_value = 12; } } @@ -41,6 +46,12 @@ message TextArray { repeated string values = 1; } +// UuidArray is a Postgres uuid[] value; each element is a canonical UUID +// string (matching DbValue.uuid_value's single-UUID encoding). +message UuidArray { + repeated string values = 1; +} + // DbRow is one result row; values align with DbRowsResponse.columns. message DbRow { repeated DbValue values = 1; diff --git a/abi/v1/db.pb.go b/abi/v1/db.pb.go index 273b53d..1f7846c 100644 --- a/abi/v1/db.pb.go +++ b/abi/v1/db.pb.go @@ -46,6 +46,7 @@ type DbValue struct { // *DbValue_JsonbValue // *DbValue_NumericValue // *DbValue_TextArrayValue + // *DbValue_UuidArrayValue Kind isDbValue_Kind `protobuf_oneof:"kind"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -187,6 +188,15 @@ func (x *DbValue) GetTextArrayValue() *TextArray { return nil } +func (x *DbValue) GetUuidArrayValue() *UuidArray { + if x != nil { + if x, ok := x.Kind.(*DbValue_UuidArrayValue); ok { + return x.UuidArrayValue + } + } + return nil +} + type isDbValue_Kind interface { isDbValue_Kind() } @@ -240,6 +250,14 @@ type DbValue_TextArrayValue struct { TextArrayValue *TextArray `protobuf:"bytes,11,opt,name=text_array_value,json=textArrayValue,proto3,oneof"` } +type DbValue_UuidArrayValue struct { + // uuid[] array (each element canonical string form). Distinct from + // text_array so the host binds a native uuid[] parameter (letting a query + // keep `ANY($1::uuid[])` with no text[]-cast workaround) and scans a uuid[] + // column straight into []uuid.UUID. + UuidArrayValue *UuidArray `protobuf:"bytes,12,opt,name=uuid_array_value,json=uuidArrayValue,proto3,oneof"` +} + func (*DbValue_Null) isDbValue_Kind() {} func (*DbValue_BoolValue) isDbValue_Kind() {} @@ -262,6 +280,8 @@ func (*DbValue_NumericValue) isDbValue_Kind() {} func (*DbValue_TextArrayValue) isDbValue_Kind() {} +func (*DbValue_UuidArrayValue) isDbValue_Kind() {} + // TextArray is a Postgres text[] value. type TextArray struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -307,6 +327,52 @@ func (x *TextArray) GetValues() []string { return nil } +// UuidArray is a Postgres uuid[] value; each element is a canonical UUID +// string (matching DbValue.uuid_value's single-UUID encoding). +type UuidArray struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UuidArray) Reset() { + *x = UuidArray{} + mi := &file_v1_db_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UuidArray) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UuidArray) ProtoMessage() {} + +func (x *UuidArray) ProtoReflect() protoreflect.Message { + mi := &file_v1_db_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 UuidArray.ProtoReflect.Descriptor instead. +func (*UuidArray) Descriptor() ([]byte, []int) { + return file_v1_db_proto_rawDescGZIP(), []int{2} +} + +func (x *UuidArray) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + // DbRow is one result row; values align with DbRowsResponse.columns. type DbRow struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -317,7 +383,7 @@ type DbRow struct { func (x *DbRow) Reset() { *x = DbRow{} - mi := &file_v1_db_proto_msgTypes[2] + mi := &file_v1_db_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -329,7 +395,7 @@ func (x *DbRow) String() string { func (*DbRow) ProtoMessage() {} func (x *DbRow) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[2] + mi := &file_v1_db_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -342,7 +408,7 @@ func (x *DbRow) ProtoReflect() protoreflect.Message { // Deprecated: Use DbRow.ProtoReflect.Descriptor instead. func (*DbRow) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{2} + return file_v1_db_proto_rawDescGZIP(), []int{3} } func (x *DbRow) GetValues() []*DbValue { @@ -364,7 +430,7 @@ type DbError struct { func (x *DbError) Reset() { *x = DbError{} - mi := &file_v1_db_proto_msgTypes[3] + mi := &file_v1_db_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -376,7 +442,7 @@ func (x *DbError) String() string { func (*DbError) ProtoMessage() {} func (x *DbError) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[3] + mi := &file_v1_db_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -389,7 +455,7 @@ func (x *DbError) ProtoReflect() protoreflect.Message { // Deprecated: Use DbError.ProtoReflect.Descriptor instead. func (*DbError) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{3} + return file_v1_db_proto_rawDescGZIP(), []int{4} } func (x *DbError) GetCode() string { @@ -420,7 +486,7 @@ type DbQueryRequest struct { func (x *DbQueryRequest) Reset() { *x = DbQueryRequest{} - mi := &file_v1_db_proto_msgTypes[4] + mi := &file_v1_db_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -432,7 +498,7 @@ func (x *DbQueryRequest) String() string { func (*DbQueryRequest) ProtoMessage() {} func (x *DbQueryRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[4] + mi := &file_v1_db_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -445,7 +511,7 @@ func (x *DbQueryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DbQueryRequest.ProtoReflect.Descriptor instead. func (*DbQueryRequest) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{4} + return file_v1_db_proto_rawDescGZIP(), []int{5} } func (x *DbQueryRequest) GetSql() string { @@ -481,7 +547,7 @@ type DbRowsResponse struct { func (x *DbRowsResponse) Reset() { *x = DbRowsResponse{} - mi := &file_v1_db_proto_msgTypes[5] + mi := &file_v1_db_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -493,7 +559,7 @@ func (x *DbRowsResponse) String() string { func (*DbRowsResponse) ProtoMessage() {} func (x *DbRowsResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[5] + mi := &file_v1_db_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -506,7 +572,7 @@ func (x *DbRowsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DbRowsResponse.ProtoReflect.Descriptor instead. func (*DbRowsResponse) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{5} + return file_v1_db_proto_rawDescGZIP(), []int{6} } func (x *DbRowsResponse) GetColumns() []string { @@ -543,7 +609,7 @@ type DbExecRequest struct { func (x *DbExecRequest) Reset() { *x = DbExecRequest{} - mi := &file_v1_db_proto_msgTypes[6] + mi := &file_v1_db_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -555,7 +621,7 @@ func (x *DbExecRequest) String() string { func (*DbExecRequest) ProtoMessage() {} func (x *DbExecRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[6] + mi := &file_v1_db_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -568,7 +634,7 @@ func (x *DbExecRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DbExecRequest.ProtoReflect.Descriptor instead. func (*DbExecRequest) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{6} + return file_v1_db_proto_rawDescGZIP(), []int{7} } func (x *DbExecRequest) GetSql() string { @@ -602,7 +668,7 @@ type DbExecResponse struct { func (x *DbExecResponse) Reset() { *x = DbExecResponse{} - mi := &file_v1_db_proto_msgTypes[7] + mi := &file_v1_db_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -614,7 +680,7 @@ func (x *DbExecResponse) String() string { func (*DbExecResponse) ProtoMessage() {} func (x *DbExecResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[7] + mi := &file_v1_db_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -627,7 +693,7 @@ func (x *DbExecResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DbExecResponse.ProtoReflect.Descriptor instead. func (*DbExecResponse) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{7} + return file_v1_db_proto_rawDescGZIP(), []int{8} } func (x *DbExecResponse) GetRowsAffected() int64 { @@ -653,7 +719,7 @@ type DbTxBeginRequest struct { func (x *DbTxBeginRequest) Reset() { *x = DbTxBeginRequest{} - mi := &file_v1_db_proto_msgTypes[8] + mi := &file_v1_db_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -665,7 +731,7 @@ func (x *DbTxBeginRequest) String() string { func (*DbTxBeginRequest) ProtoMessage() {} func (x *DbTxBeginRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[8] + mi := &file_v1_db_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -678,7 +744,7 @@ func (x *DbTxBeginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxBeginRequest.ProtoReflect.Descriptor instead. func (*DbTxBeginRequest) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{8} + return file_v1_db_proto_rawDescGZIP(), []int{9} } type DbTxBeginResponse struct { @@ -692,7 +758,7 @@ type DbTxBeginResponse struct { func (x *DbTxBeginResponse) Reset() { *x = DbTxBeginResponse{} - mi := &file_v1_db_proto_msgTypes[9] + mi := &file_v1_db_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -704,7 +770,7 @@ func (x *DbTxBeginResponse) String() string { func (*DbTxBeginResponse) ProtoMessage() {} func (x *DbTxBeginResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[9] + mi := &file_v1_db_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -717,7 +783,7 @@ func (x *DbTxBeginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxBeginResponse.ProtoReflect.Descriptor instead. func (*DbTxBeginResponse) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{9} + return file_v1_db_proto_rawDescGZIP(), []int{10} } func (x *DbTxBeginResponse) GetTxHandle() uint64 { @@ -743,7 +809,7 @@ type DbTxCommitRequest struct { func (x *DbTxCommitRequest) Reset() { *x = DbTxCommitRequest{} - mi := &file_v1_db_proto_msgTypes[10] + mi := &file_v1_db_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -755,7 +821,7 @@ func (x *DbTxCommitRequest) String() string { func (*DbTxCommitRequest) ProtoMessage() {} func (x *DbTxCommitRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[10] + mi := &file_v1_db_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -768,7 +834,7 @@ func (x *DbTxCommitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxCommitRequest.ProtoReflect.Descriptor instead. func (*DbTxCommitRequest) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{10} + return file_v1_db_proto_rawDescGZIP(), []int{11} } func (x *DbTxCommitRequest) GetTxHandle() uint64 { @@ -787,7 +853,7 @@ type DbTxCommitResponse struct { func (x *DbTxCommitResponse) Reset() { *x = DbTxCommitResponse{} - mi := &file_v1_db_proto_msgTypes[11] + mi := &file_v1_db_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +865,7 @@ func (x *DbTxCommitResponse) String() string { func (*DbTxCommitResponse) ProtoMessage() {} func (x *DbTxCommitResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[11] + mi := &file_v1_db_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +878,7 @@ func (x *DbTxCommitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxCommitResponse.ProtoReflect.Descriptor instead. func (*DbTxCommitResponse) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{11} + return file_v1_db_proto_rawDescGZIP(), []int{12} } func (x *DbTxCommitResponse) GetError() *DbError { @@ -831,7 +897,7 @@ type DbTxRollbackRequest struct { func (x *DbTxRollbackRequest) Reset() { *x = DbTxRollbackRequest{} - mi := &file_v1_db_proto_msgTypes[12] + mi := &file_v1_db_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -843,7 +909,7 @@ func (x *DbTxRollbackRequest) String() string { func (*DbTxRollbackRequest) ProtoMessage() {} func (x *DbTxRollbackRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[12] + mi := &file_v1_db_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -856,7 +922,7 @@ func (x *DbTxRollbackRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxRollbackRequest.ProtoReflect.Descriptor instead. func (*DbTxRollbackRequest) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{12} + return file_v1_db_proto_rawDescGZIP(), []int{13} } func (x *DbTxRollbackRequest) GetTxHandle() uint64 { @@ -875,7 +941,7 @@ type DbTxRollbackResponse struct { func (x *DbTxRollbackResponse) Reset() { *x = DbTxRollbackResponse{} - mi := &file_v1_db_proto_msgTypes[13] + mi := &file_v1_db_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -887,7 +953,7 @@ func (x *DbTxRollbackResponse) String() string { func (*DbTxRollbackResponse) ProtoMessage() {} func (x *DbTxRollbackResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_db_proto_msgTypes[13] + mi := &file_v1_db_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -900,7 +966,7 @@ func (x *DbTxRollbackResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DbTxRollbackResponse.ProtoReflect.Descriptor instead. func (*DbTxRollbackResponse) Descriptor() ([]byte, []int) { - return file_v1_db_proto_rawDescGZIP(), []int{13} + return file_v1_db_proto_rawDescGZIP(), []int{14} } func (x *DbTxRollbackResponse) GetError() *DbError { @@ -914,7 +980,7 @@ var File_v1_db_proto protoreflect.FileDescriptor const file_v1_db_proto_rawDesc = "" + "\n" + - "\vv1/db.proto\x12\x06abi.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcb\x03\n" + + "\vv1/db.proto\x12\x06abi.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8a\x04\n" + "\aDbValue\x12\x14\n" + "\x04null\x18\x01 \x01(\bH\x00R\x04null\x12\x1f\n" + "\n" + @@ -932,9 +998,12 @@ const file_v1_db_proto_rawDesc = "" + "jsonbValue\x12%\n" + "\rnumeric_value\x18\n" + " \x01(\tH\x00R\fnumericValue\x12=\n" + - "\x10text_array_value\x18\v \x01(\v2\x11.abi.v1.TextArrayH\x00R\x0etextArrayValueB\x06\n" + + "\x10text_array_value\x18\v \x01(\v2\x11.abi.v1.TextArrayH\x00R\x0etextArrayValue\x12=\n" + + "\x10uuid_array_value\x18\f \x01(\v2\x11.abi.v1.UuidArrayH\x00R\x0euuidArrayValueB\x06\n" + "\x04kind\"#\n" + "\tTextArray\x12\x16\n" + + "\x06values\x18\x01 \x03(\tR\x06values\"#\n" + + "\tUuidArray\x12\x16\n" + "\x06values\x18\x01 \x03(\tR\x06values\"0\n" + "\x05DbRow\x12'\n" + "\x06values\x18\x01 \x03(\v2\x0f.abi.v1.DbValueR\x06values\"7\n" + @@ -981,41 +1050,43 @@ func file_v1_db_proto_rawDescGZIP() []byte { return file_v1_db_proto_rawDescData } -var file_v1_db_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_v1_db_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_v1_db_proto_goTypes = []any{ (*DbValue)(nil), // 0: abi.v1.DbValue (*TextArray)(nil), // 1: abi.v1.TextArray - (*DbRow)(nil), // 2: abi.v1.DbRow - (*DbError)(nil), // 3: abi.v1.DbError - (*DbQueryRequest)(nil), // 4: abi.v1.DbQueryRequest - (*DbRowsResponse)(nil), // 5: abi.v1.DbRowsResponse - (*DbExecRequest)(nil), // 6: abi.v1.DbExecRequest - (*DbExecResponse)(nil), // 7: abi.v1.DbExecResponse - (*DbTxBeginRequest)(nil), // 8: abi.v1.DbTxBeginRequest - (*DbTxBeginResponse)(nil), // 9: abi.v1.DbTxBeginResponse - (*DbTxCommitRequest)(nil), // 10: abi.v1.DbTxCommitRequest - (*DbTxCommitResponse)(nil), // 11: abi.v1.DbTxCommitResponse - (*DbTxRollbackRequest)(nil), // 12: abi.v1.DbTxRollbackRequest - (*DbTxRollbackResponse)(nil), // 13: abi.v1.DbTxRollbackResponse - (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*UuidArray)(nil), // 2: abi.v1.UuidArray + (*DbRow)(nil), // 3: abi.v1.DbRow + (*DbError)(nil), // 4: abi.v1.DbError + (*DbQueryRequest)(nil), // 5: abi.v1.DbQueryRequest + (*DbRowsResponse)(nil), // 6: abi.v1.DbRowsResponse + (*DbExecRequest)(nil), // 7: abi.v1.DbExecRequest + (*DbExecResponse)(nil), // 8: abi.v1.DbExecResponse + (*DbTxBeginRequest)(nil), // 9: abi.v1.DbTxBeginRequest + (*DbTxBeginResponse)(nil), // 10: abi.v1.DbTxBeginResponse + (*DbTxCommitRequest)(nil), // 11: abi.v1.DbTxCommitRequest + (*DbTxCommitResponse)(nil), // 12: abi.v1.DbTxCommitResponse + (*DbTxRollbackRequest)(nil), // 13: abi.v1.DbTxRollbackRequest + (*DbTxRollbackResponse)(nil), // 14: abi.v1.DbTxRollbackResponse + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp } var file_v1_db_proto_depIdxs = []int32{ - 14, // 0: abi.v1.DbValue.timestamp_value:type_name -> google.protobuf.Timestamp + 15, // 0: abi.v1.DbValue.timestamp_value:type_name -> google.protobuf.Timestamp 1, // 1: abi.v1.DbValue.text_array_value:type_name -> abi.v1.TextArray - 0, // 2: abi.v1.DbRow.values:type_name -> abi.v1.DbValue - 0, // 3: abi.v1.DbQueryRequest.args:type_name -> abi.v1.DbValue - 2, // 4: abi.v1.DbRowsResponse.rows:type_name -> abi.v1.DbRow - 3, // 5: abi.v1.DbRowsResponse.error:type_name -> abi.v1.DbError - 0, // 6: abi.v1.DbExecRequest.args:type_name -> abi.v1.DbValue - 3, // 7: abi.v1.DbExecResponse.error:type_name -> abi.v1.DbError - 3, // 8: abi.v1.DbTxBeginResponse.error:type_name -> abi.v1.DbError - 3, // 9: abi.v1.DbTxCommitResponse.error:type_name -> abi.v1.DbError - 3, // 10: abi.v1.DbTxRollbackResponse.error:type_name -> abi.v1.DbError - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 2, // 2: abi.v1.DbValue.uuid_array_value:type_name -> abi.v1.UuidArray + 0, // 3: abi.v1.DbRow.values:type_name -> abi.v1.DbValue + 0, // 4: abi.v1.DbQueryRequest.args:type_name -> abi.v1.DbValue + 3, // 5: abi.v1.DbRowsResponse.rows:type_name -> abi.v1.DbRow + 4, // 6: abi.v1.DbRowsResponse.error:type_name -> abi.v1.DbError + 0, // 7: abi.v1.DbExecRequest.args:type_name -> abi.v1.DbValue + 4, // 8: abi.v1.DbExecResponse.error:type_name -> abi.v1.DbError + 4, // 9: abi.v1.DbTxBeginResponse.error:type_name -> abi.v1.DbError + 4, // 10: abi.v1.DbTxCommitResponse.error:type_name -> abi.v1.DbError + 4, // 11: abi.v1.DbTxRollbackResponse.error:type_name -> abi.v1.DbError + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_v1_db_proto_init() } @@ -1035,6 +1106,7 @@ func file_v1_db_proto_init() { (*DbValue_JsonbValue)(nil), (*DbValue_NumericValue)(nil), (*DbValue_TextArrayValue)(nil), + (*DbValue_UuidArrayValue)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1042,7 +1114,7 @@ func file_v1_db_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_db_proto_rawDesc), len(file_v1_db_proto_rawDesc)), NumEnums: 0, - NumMessages: 14, + NumMessages: 15, NumExtensions: 0, NumServices: 0, }, diff --git a/auth/trustedheaders.go b/auth/trustedheaders.go new file mode 100644 index 0000000..5ce34b6 --- /dev/null +++ b/auth/trustedheaders.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +// Trusted identity headers — the wasm plugin auth contract. +// +// Context values do NOT cross the wasm ABI, so a guest cannot see the auth +// context the host's middleware built. The SECURE way to convey the caller's +// identity to a guest is these headers, populated HOST-SIDE from the +// signature-verified principal after the host has run its RBAC guard +// (rbac.Authorize against the verified JWT). The host STRIPS any client-supplied +// copy of these headers before setting them, so a guest may trust them +// unconditionally. +// +// A guest MUST NOT decode a client-supplied cookie or Authorization/Bearer +// token to establish identity — those are attacker-controlled across the +// boundary (the host forwards the raw request), and trusting them is a +// privilege-escalation bug. Read identity ONLY from these headers (via +// TrustedHeaderMiddleware / ContextFromTrustedHeaders). See +// core/docs/wasm-abi.md ("Trusted identity headers"). +// +// Values are the canonical MIME header form so a host map-set and a guest +// http.Header.Get agree without re-canonicalization surprises. +const ( + // Admin principal (auth.Claims). + HeaderVerifiedUserID = "X-Bn-Verified-User-Id" + HeaderVerifiedRole = "X-Bn-Verified-Role" + HeaderVerifiedEmail = "X-Bn-Verified-Email" + + // Public/community principal (auth.PublicClaims). + HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id" + HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username" + HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email" +) + +// AllTrustedHeaders lists every trusted identity header, canonical form. The +// host deletes each of these from the inbound request before injecting its own +// verified values, so a client cannot forge one. +func AllTrustedHeaders() []string { + return []string{ + HeaderVerifiedUserID, + HeaderVerifiedRole, + HeaderVerifiedEmail, + HeaderVerifiedPublicUserID, + HeaderVerifiedPublicUsername, + HeaderVerifiedPublicEmail, + } +} + +// ContextFromTrustedHeaders rebuilds the request's auth context from the host's +// verified identity headers. Trust model: see the const block above — these +// headers are host-controlled, so no token parsing or signature check happens +// here (the guest has no signing secret by design). A malformed user-id header +// is ignored rather than trusted. +func ContextFromTrustedHeaders(ctx context.Context, h http.Header) context.Context { + if s := h.Get(HeaderVerifiedUserID); s != "" { + if id, err := uuid.Parse(s); err == nil { + ctx = WithUser(ctx, &Claims{ + UserID: id, + Email: h.Get(HeaderVerifiedEmail), + Role: h.Get(HeaderVerifiedRole), + }) + } + } + if s := h.Get(HeaderVerifiedPublicUserID); s != "" { + if id, err := uuid.Parse(s); err == nil { + ctx = WithPublicUser(ctx, &PublicClaims{ + UserID: id, + Email: h.Get(HeaderVerifiedPublicEmail), + Username: h.Get(HeaderVerifiedPublicUsername), + }) + } + } + return ctx +} + +// TrustedHeaderMiddleware wraps a guest HTTP handler so downstream RPCs can read +// auth.GetUserFromContext / GetPublicUserFromContext. Plugins whose Connect RPCs +// resolve the caller from context MUST mount this around their handler (context +// does not cross the ABI otherwise). This is the ONLY sanctioned way for a guest +// to learn the caller's identity. +func TrustedHeaderMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(ContextFromTrustedHeaders(r.Context(), r.Header))) + }) +} diff --git a/auth/trustedheaders_test.go b/auth/trustedheaders_test.go new file mode 100644 index 0000000..27a09c5 --- /dev/null +++ b/auth/trustedheaders_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" +) + +func TestContextFromTrustedHeaders(t *testing.T) { + adminID := uuid.New() + pubID := uuid.New() + + h := http.Header{} + h.Set(HeaderVerifiedUserID, adminID.String()) + h.Set(HeaderVerifiedRole, "superadmin") + h.Set(HeaderVerifiedEmail, "admin@example.com") + h.Set(HeaderVerifiedPublicUserID, pubID.String()) + h.Set(HeaderVerifiedPublicUsername, "ninja") + h.Set(HeaderVerifiedPublicEmail, "ninja@example.com") + + ctx := ContextFromTrustedHeaders(context.Background(), h) + + c, ok := GetUserFromContext(ctx) + if !ok { + t.Fatal("admin claims missing") + } + if c.UserID != adminID || c.Role != "superadmin" || c.Email != "admin@example.com" { + t.Fatalf("admin claims mismatch: %+v", c) + } + p, ok := GetPublicUserFromContext(ctx) + if !ok { + t.Fatal("public claims missing") + } + if p.UserID != pubID || p.Username != "ninja" || p.Email != "ninja@example.com" { + t.Fatalf("public claims mismatch: %+v", p) + } +} + +// TestContextFromTrustedHeaders_NoneWhenAbsent proves an anonymous request (no +// trusted headers) yields no principals — nothing is fabricated. +func TestContextFromTrustedHeaders_NoneWhenAbsent(t *testing.T) { + ctx := ContextFromTrustedHeaders(context.Background(), http.Header{}) + if _, ok := GetUserFromContext(ctx); ok { + t.Fatal("unexpected admin claims for anonymous request") + } + if _, ok := GetPublicUserFromContext(ctx); ok { + t.Fatal("unexpected public claims for anonymous request") + } +} + +// TestContextFromTrustedHeaders_BadUUIDIgnored proves a malformed id header is +// ignored rather than trusted (no panic, no principal). +func TestContextFromTrustedHeaders_BadUUIDIgnored(t *testing.T) { + h := http.Header{} + h.Set(HeaderVerifiedUserID, "not-a-uuid") + h.Set(HeaderVerifiedRole, "superadmin") + ctx := ContextFromTrustedHeaders(context.Background(), h) + if _, ok := GetUserFromContext(ctx); ok { + t.Fatal("malformed user-id header must not yield a principal") + } +} + +// TestTrustedHeaderMiddleware proves the middleware installs the principal into +// the downstream handler's request context, keyed by the canonical header form. +func TestTrustedHeaderMiddleware(t *testing.T) { + adminID := uuid.New() + var got *Claims + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = GetUserFromContext(r.Context()) + }) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + // lower-case on the way in — net/http canonicalizes, middleware must still see it. + req.Header.Set("x-bn-verified-user-id", adminID.String()) + req.Header.Set("x-bn-verified-role", "admin") + TrustedHeaderMiddleware(next).ServeHTTP(httptest.NewRecorder(), req) + if got == nil || got.UserID != adminID || got.Role != "admin" { + t.Fatalf("middleware did not install principal: %+v", got) + } +} diff --git a/docs/wasm-abi.md b/docs/wasm-abi.md index 97eece8..7e79e47 100644 --- a/docs/wasm-abi.md +++ b/docs/wasm-abi.md @@ -298,7 +298,7 @@ No silent gaps: each member is either a guest stub or served host-side. | `EmbeddingService` | **stub** — `caps/embeddings.go` | | `RAGService` | **stub** — `caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` | | `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role | -| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. Auth context reaches the guest via `HttpRequest` headers (host-side interceptors already ran). | +| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. The caller's **verified** identity reaches the guest via **trusted identity headers** (see "Trusted identity headers" below) — never by decoding a client token. | | `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` | | `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub | @@ -339,6 +339,14 @@ zero value / best-effort on transport failure. a whole can still be SQL NULL (nil `[]string` → `DbValue_Null`); only a NULL *inside* the array is unrepresentable. The host executor must honor this same limit (encode a NULL element as `""` or reject it), not invent a sentinel. + **`uuid[]` (`DbValue.uuid_array_value`, `abiv1.UuidArray`):** a first-class + variant distinct from `text[]`, so a query keeps native `ANY($1::uuid[])` + (no `::text[]::uuid[]` cast workaround) and a `uuid[]` column scans straight + into `[]uuid.UUID`. Each element is a canonical UUID string (like the scalar + `uuid_value`); nil `[]uuid.UUID` → `DbValue_Null`, empty stays a non-NULL + empty `uuid[]`. The host binds a native `[]uuid.UUID` parameter and reads a + `UUIDArrayOID` column back into this variant. Pinned by the `uuid_array` + entry in `bnwasm.DbValueFixtures`. - `Interceptors` (`connect.Option`) → host-side only; RBAC merges from `manifest.rbac_method_roles`. - `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`. @@ -348,6 +356,44 @@ zero value / best-effort on transport failure. `manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH` callback inversion. +## Trusted identity headers + +Context values do **not** cross the ABI, so a guest cannot see the +`auth.Claims` / `auth.PublicClaims` the host's middleware built. A guest that +needs the caller's identity (its Connect RPCs call +`auth.GetUserFromContext` / `GetPublicUserFromContext`) reads it from +**host-set trusted headers**, and **only** from those. + +**Trust model (host-enforced, guest-trusting):** + +1. Upstream CMS auth middleware verifies the JWT signature and populates + `r.Context()` with the principal. The wasm mount's RBAC guard then runs + `rbac.Authorize` against that **verified** principal (deny-by-default, + live-user validator) *before* the request is forwarded. +2. When forwarding over `HOOK_HANDLE_HTTP`, the host **strips any + client-supplied copy** of the trusted headers from the request and **sets + them itself** from the verified context principal. A guest therefore trusts + them unconditionally — they are not attacker-controllable. +3. The guest reconstructs its context with `auth.TrustedHeaderMiddleware` + (wrap it around your `HTTPHandler`) or `auth.ContextFromTrustedHeaders`. + No token parsing, no signature check — the guest holds no signing secret by + design. + +The headers (`core/auth/trustedheaders.go`, canonical MIME form): + +| Header | Source | +|---|---| +| `X-Bn-Verified-User-Id` / `X-Bn-Verified-Role` / `X-Bn-Verified-Email` | admin `auth.Claims` | +| `X-Bn-Verified-Public-User-Id` / `X-Bn-Verified-Public-Username` / `X-Bn-Verified-Public-Email` | public `auth.PublicClaims` | + +> **SECURITY — do NOT decode a client token in the guest.** The host forwards +> the raw request, so its `Cookie` / `Authorization` headers are +> attacker-controlled across the boundary. Decoding an unsigned `access_token` +> cookie (or Bearer JWT) as identity in the guest is a **privilege-escalation +> bug** (a verified public user can forge an admin JWT the guest would then +> honour on a `RolePublic` method). Identity comes from the trusted headers +> above and nowhere else. + ## Error semantics `AbiError{code, message}` travels in `InvokeResponse.error` and diff --git a/plugin/wasmguest/bnwasm/dbvalue.go b/plugin/wasmguest/bnwasm/dbvalue.go index 52529a3..29c627f 100644 --- a/plugin/wasmguest/bnwasm/dbvalue.go +++ b/plugin/wasmguest/bnwasm/dbvalue.go @@ -70,6 +70,19 @@ func toDbValue(a any) (*abiv1.DbValue, error) { return nullValue(), nil } return &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: v}}}, nil + case []uuid.UUID: + // uuid[] gets its own variant so the host binds a native uuid[] + // parameter (queries keep `ANY($1::uuid[])` — no text[]-cast + // workaround). nil → NULL / empty stays a non-NULL empty uuid[], + // mirroring the []string convention. + if v == nil { + return nullValue(), nil + } + strs := make([]string, len(v)) + for i, id := range v { + strs[i] = id.String() + } + return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: strs}}}, nil case string: return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: v}}, nil case bool: @@ -129,6 +142,7 @@ func nullValue() *abiv1.DbValue { // jsonb → ([]byte, false) // numeric → (string, false) // lossless decimal string // text_array → ([]string, false) +// uuid_array → ([]string, false) // canonical forms; assign parses into []uuid.UUID func naturalValue(dv *abiv1.DbValue) (any, bool) { switch k := dv.GetKind().(type) { case *abiv1.DbValue_Null: @@ -153,11 +167,20 @@ func naturalValue(dv *abiv1.DbValue) (any, bool) { return k.NumericValue, false case *abiv1.DbValue_TextArrayValue: return append([]string(nil), k.TextArrayValue.GetValues()...), false + case *abiv1.DbValue_UuidArrayValue: + // Raw canonical strings, mirroring the single-uuid arm returning a + // string: assign() parses them into a []uuid.UUID destination (with + // error propagation), the same way uuid.UUID.Scan parses the scalar. + return append([]string(nil), k.UuidArrayValue.GetValues()...), false default: return nil, true } } +// uuidSliceType is the reflect.Type of []uuid.UUID, the canonical scan +// destination sqlc emits for a uuid[] column (and COALESCE(...::uuid[]) arg). +var uuidSliceType = reflect.TypeOf([]uuid.UUID(nil)) + // scanValue assigns a DbValue into a destination pointer with pgx-flavored Scan // semantics: a nil dest skips the column; a sql.Scanner destination is fed the // natural value (including nil for NULL); pointer-to-pointer destinations model @@ -204,6 +227,24 @@ func scanValue(dv *abiv1.DbValue, dest any) error { func assign(dst reflect.Value, nv any) error { src := reflect.ValueOf(nv) + // uuid[] natural value ([]string of canonical UUIDs) → []uuid.UUID: parse + // each element (mirrors uuid.UUID.Scan for the scalar). reflect can neither + // assign nor convert []string to []uuid.UUID, so this must be explicit. + if dst.Type() == uuidSliceType { + if strs, ok := nv.([]string); ok { + out := make([]uuid.UUID, len(strs)) + for i, s := range strs { + id, err := uuid.Parse(s) + if err != nil { + return fmt.Errorf("bnwasm: cannot scan %q into uuid at index %d: %w", s, i, err) + } + out[i] = id + } + dst.Set(reflect.ValueOf(out)) + return nil + } + } + // Direct assignability (string→string, time.Time→time.Time, []string→[]string). if src.Type().AssignableTo(dst.Type()) { dst.Set(src) diff --git a/plugin/wasmguest/bnwasm/dbvalue_fixtures.go b/plugin/wasmguest/bnwasm/dbvalue_fixtures.go index 1105e94..f06fb6f 100644 --- a/plugin/wasmguest/bnwasm/dbvalue_fixtures.go +++ b/plugin/wasmguest/bnwasm/dbvalue_fixtures.go @@ -16,6 +16,14 @@ var FixtureTime = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC) // FixtureUUID is the fixed UUID used by the shared DbValue fixtures. var FixtureUUID = uuid.MustParse("11111111-2222-3333-4444-555555555555") +// FixtureUUIDs are the fixed, ORDERED UUIDs used by the uuid_array fixture; the +// round-trip must preserve both their values and their order. +var FixtureUUIDs = []uuid.UUID{ + uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001"), + uuid.MustParse("bbbbbbbb-0000-0000-0000-000000000002"), + uuid.MustParse("cccccccc-0000-0000-0000-000000000003"), +} + // DbValueFixture is one entry in the canonical DbValue↔Go mapping table. type DbValueFixture struct { // Name identifies the DbValue variant. @@ -140,6 +148,18 @@ var DbValueFixtures = []DbValueFixture{ Want: []string{"a", "b"}, DriverValue: `{"a","b"}`, }, + { + // uuid[]: three ordered UUIDs bound as a native uuid[] parameter and + // scanned back into []uuid.UUID. Ordering is part of the contract — the + // host mirror (dbexec.go) must preserve element order. + Name: "uuid_array", + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: []string{ + FixtureUUIDs[0].String(), FixtureUUIDs[1].String(), FixtureUUIDs[2].String(), + }}}}, + NewDest: func() any { return new([]uuid.UUID) }, + Want: FixtureUUIDs, + DriverValue: `{"aaaaaaaa-0000-0000-0000-000000000001","bbbbbbbb-0000-0000-0000-000000000002","cccccccc-0000-0000-0000-000000000003"}`, + }, // --- encoding edge cases (extra entries beyond one-per-variant) --- { diff --git a/plugin/wasmguest/bnwasm/dbvalue_test.go b/plugin/wasmguest/bnwasm/dbvalue_test.go index 57939ce..accfbd7 100644 --- a/plugin/wasmguest/bnwasm/dbvalue_test.go +++ b/plugin/wasmguest/bnwasm/dbvalue_test.go @@ -6,6 +6,7 @@ import ( "testing" abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + "github.com/google/uuid" ) // TestToDbValueTextArrayNilVsEmpty pins the guest-side arg encoding for []string: @@ -50,6 +51,49 @@ func TestToDbValueTextArrayNilVsEmpty(t *testing.T) { }) } +// TestToDbValueUuidArray pins the guest-side arg encoding for []uuid.UUID: a nil +// slice marshals to SQL NULL, an empty-but-non-nil slice stays a non-NULL empty +// uuid[], and values marshal to canonical strings in order. The host binds this +// as a native uuid[] parameter (no text[]-cast workaround). +func TestToDbValueUuidArray(t *testing.T) { + t.Run("nil", func(t *testing.T) { + dv, err := toDbValue([]uuid.UUID(nil)) + if err != nil { + t.Fatal(err) + } + if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok { + t.Fatalf("nil []uuid.UUID: want DbValue_Null, got %T", dv.GetKind()) + } + }) + t.Run("empty", func(t *testing.T) { + dv, err := toDbValue([]uuid.UUID{}) + if err != nil { + t.Fatal(err) + } + ua, ok := dv.GetKind().(*abiv1.DbValue_UuidArrayValue) + if !ok { + t.Fatalf("empty []uuid.UUID: want DbValue_UuidArrayValue, got %T", dv.GetKind()) + } + if got := ua.UuidArrayValue.GetValues(); len(got) != 0 { + t.Fatalf("empty []uuid.UUID: want zero-length uuid[], got %#v", got) + } + }) + t.Run("values", func(t *testing.T) { + dv, err := toDbValue(FixtureUUIDs) + if err != nil { + t.Fatal(err) + } + ua, ok := dv.GetKind().(*abiv1.DbValue_UuidArrayValue) + if !ok { + t.Fatalf("want DbValue_UuidArrayValue, got %T", dv.GetKind()) + } + want := []string{FixtureUUIDs[0].String(), FixtureUUIDs[1].String(), FixtureUUIDs[2].String()} + if got := ua.UuidArrayValue.GetValues(); !reflect.DeepEqual(got, want) { + t.Fatalf("want %v, got %#v", want, got) + } + }) +} + // TestToDbValueNilConvention locks the nil→NULL convention across the reference // types so []string stays consistent with []byte and json.RawMessage. func TestToDbValueNilConvention(t *testing.T) {