feat(abi,auth): first-class uuid[] DbValue + trusted identity headers (WZ-016)

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 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 00:43:13 +08:00
parent 05ded99d32
commit 314a25353d
8 changed files with 476 additions and 69 deletions

View File

@ -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;

View File

@ -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,
},

91
auth/trustedheaders.go Normal file
View File

@ -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)))
})
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)

View File

@ -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) ---
{

View File

@ -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) {