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:
parent
05ded99d32
commit
314a25353d
@ -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;
|
||||
|
||||
208
abi/v1/db.pb.go
208
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,
|
||||
},
|
||||
|
||||
91
auth/trustedheaders.go
Normal file
91
auth/trustedheaders.go
Normal 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)))
|
||||
})
|
||||
}
|
||||
82
auth/trustedheaders_test.go
Normal file
82
auth/trustedheaders_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) ---
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user