diff --git a/Makefile b/Makefile index c0d83dc..ce14bd2 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ generate/examples: go generate ./examples/pingpong go generate ./examples/oneof go generate ./examples/swagger + go generate ./examples/rangerguard .PHONY: run/example/server run/example/server: install generate/examples diff --git a/examples/oneof/oneof.ranger.go b/examples/oneof/oneof.ranger.go index fc12e0a..7ca3d91 100644 --- a/examples/oneof/oneof.ranger.go +++ b/examples/oneof/oneof.ranger.go @@ -30,7 +30,7 @@ type OneOfClient struct { prefix string } -func NewOneOfClient(addr string, client ranger.HTTPClient, opts ...ranger.ClientPlugin) (*OneOfClient, error) { +func NewOneOfClient(addr string, client ranger.HTTPClient, plugins ...ranger.ClientPlugin) (*OneOfClient, error) { base, err := url.Parse(ranger.SanitizeUrl(addr)) if err != nil { return nil, err @@ -45,7 +45,7 @@ func NewOneOfClient(addr string, client ranger.HTTPClient, opts ...ranger.Client httpclient: client, prefix: base.ResolveReference(u).String(), } - serviceClient.AddPlugins(opts...) + serviceClient.AddPlugins(plugins...) return serviceClient, nil } func (c *OneOfClient) Echo(ctx context.Context, in *OneOfRequest) (*OneOfReply, error) { diff --git a/examples/pingpong/pingpong.ranger.go b/examples/pingpong/pingpong.ranger.go index be1a3c8..a0052c5 100644 --- a/examples/pingpong/pingpong.ranger.go +++ b/examples/pingpong/pingpong.ranger.go @@ -31,7 +31,7 @@ type PingPongClient struct { prefix string } -func NewPingPongClient(addr string, client ranger.HTTPClient, opts ...ranger.ClientPlugin) (*PingPongClient, error) { +func NewPingPongClient(addr string, client ranger.HTTPClient, plugins ...ranger.ClientPlugin) (*PingPongClient, error) { base, err := url.Parse(ranger.SanitizeUrl(addr)) if err != nil { return nil, err @@ -46,7 +46,7 @@ func NewPingPongClient(addr string, client ranger.HTTPClient, opts ...ranger.Cli httpclient: client, prefix: base.ResolveReference(u).String(), } - serviceClient.AddPlugins(opts...) + serviceClient.AddPlugins(plugins...) return serviceClient, nil } func (c *PingPongClient) Ping(ctx context.Context, in *PingRequest) (*PongReply, error) { diff --git a/examples/rangerguard/client/client.go b/examples/rangerguard/client/client.go new file mode 100644 index 0000000..822c861 --- /dev/null +++ b/examples/rangerguard/client/client.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "crypto/tls" + "net/http" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + pb "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication/cert" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/crypto" +) + +func main() { + // inefficient, just for testing + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + tlsconfig := &tls.Config{ + InsecureSkipVerify: true, + } + + tr := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, + TLSClientConfig: tlsconfig, + } + + // key for signing the requests + privateKey, err := crypto.PrivateKeyFromFile("../server/private-key.p8") + if err != nil { + log.Error().Err(err).Msg("could not read private key") + } + + plugin, err := cert.NewRangerPlugin(cert.ClientConfig{ + PrivateKey: privateKey, + Issuer: "ranger_guard", + Subject: "ranger_guard_client", + Kid: "1", + }) + if err != nil { + log.Error().Err(err).Msg("could not create signer plugin") + } + + log.Info().Msgf("start proto cLient") + protoClient, err := pb.NewHelloWorldClient("https://localhost:8443/hello/", &http.Client{Transport: tr}, plugin) + if err != nil { + log.Error().Err(err).Msg("could not create hello world client") + } + + data := &pb.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + if err == nil { + log.Info().Msgf("Response %s", protoResp.Text) // prints "Hello World" + } else { + log.Error().Err(err).Msg("Could not get the response") + } +} diff --git a/examples/rangerguard/hello.go b/examples/rangerguard/hello.go new file mode 100644 index 0000000..1272851 --- /dev/null +++ b/examples/rangerguard/hello.go @@ -0,0 +1,6 @@ +package rangerguard + +// To regenerate the protocol buffer output for this package, run +// go generate + +//go:generate protoc --go_out=. --go_opt=paths=source_relative --rangerrpc_out=. hello.proto diff --git a/examples/rangerguard/hello.pb.go b/examples/rangerguard/hello.pb.go new file mode 100644 index 0000000..3fd626c --- /dev/null +++ b/examples/rangerguard/hello.pb.go @@ -0,0 +1,347 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.5 +// source: hello.proto + +package rangerguard + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HelloReq struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Subject string `protobuf:"bytes,1,opt,name=subject,proto3" json:"subject,omitempty"` +} + +func (x *HelloReq) Reset() { + *x = HelloReq{} + if protoimpl.UnsafeEnabled { + mi := &file_hello_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HelloReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloReq) ProtoMessage() {} + +func (x *HelloReq) ProtoReflect() protoreflect.Message { + mi := &file_hello_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloReq.ProtoReflect.Descriptor instead. +func (*HelloReq) Descriptor() ([]byte, []int) { + return file_hello_proto_rawDescGZIP(), []int{0} +} + +func (x *HelloReq) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +type HelloResp struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *HelloResp) Reset() { + *x = HelloResp{} + if protoimpl.UnsafeEnabled { + mi := &file_hello_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HelloResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloResp) ProtoMessage() {} + +func (x *HelloResp) ProtoReflect() protoreflect.Message { + mi := &file_hello_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloResp.ProtoReflect.Descriptor instead. +func (*HelloResp) Descriptor() ([]byte, []int) { + return file_hello_proto_rawDescGZIP(), []int{1} +} + +func (x *HelloResp) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type Empty struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *Empty) Reset() { + *x = Empty{} + if protoimpl.UnsafeEnabled { + mi := &file_hello_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_hello_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_hello_proto_rawDescGZIP(), []int{2} +} + +type Tags struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Tags map[string]string `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *Tags) Reset() { + *x = Tags{} + if protoimpl.UnsafeEnabled { + mi := &file_hello_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Tags) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Tags) ProtoMessage() {} + +func (x *Tags) ProtoReflect() protoreflect.Message { + mi := &file_hello_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Tags.ProtoReflect.Descriptor instead. +func (*Tags) Descriptor() ([]byte, []int) { + return file_hello_proto_rawDescGZIP(), []int{3} +} + +func (x *Tags) GetTags() map[string]string { + if x != nil { + return x.Tags + } + return nil +} + +var File_hello_proto protoreflect.FileDescriptor + +var file_hello_proto_rawDesc = []byte{ + 0x0a, 0x0b, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x69, + 0x6f, 0x2e, 0x6d, 0x6f, 0x6e, 0x64, 0x6f, 0x6f, 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, + 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, + 0x6c, 0x64, 0x22, 0x24, 0x0a, 0x08, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x52, 0x65, 0x71, 0x12, 0x18, + 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x1f, 0x0a, 0x09, 0x48, 0x65, 0x6c, 0x6c, + 0x6f, 0x52, 0x65, 0x73, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x88, 0x01, 0x0a, 0x04, 0x54, 0x61, 0x67, 0x73, 0x12, 0x47, 0x0a, 0x04, 0x74, + 0x61, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x69, 0x6f, 0x2e, 0x6d, + 0x6f, 0x6e, 0x64, 0x6f, 0x6f, 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, 0x65, 0x78, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2e, + 0x54, 0x61, 0x67, 0x73, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, + 0x74, 0x61, 0x67, 0x73, 0x1a, 0x37, 0x0a, 0x09, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, 0xd3, 0x01, + 0x0a, 0x0a, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x12, 0x66, 0x0a, 0x05, + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x2d, 0x2e, 0x69, 0x6f, 0x2e, 0x6d, 0x6f, 0x6e, 0x64, 0x6f, + 0x6f, 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2e, 0x48, 0x65, 0x6c, 0x6c, + 0x6f, 0x52, 0x65, 0x71, 0x1a, 0x2e, 0x2e, 0x69, 0x6f, 0x2e, 0x6d, 0x6f, 0x6e, 0x64, 0x6f, 0x6f, + 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, + 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2e, 0x48, 0x65, 0x6c, 0x6c, 0x6f, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x5d, 0x0a, 0x04, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2a, 0x2e, 0x69, + 0x6f, 0x2e, 0x6d, 0x6f, 0x6e, 0x64, 0x6f, 0x6f, 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, + 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, + 0x6c, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x29, 0x2e, 0x69, 0x6f, 0x2e, 0x6d, 0x6f, + 0x6e, 0x64, 0x6f, 0x6f, 0x2e, 0x66, 0x61, 0x6c, 0x63, 0x6f, 0x6e, 0x2e, 0x65, 0x78, 0x61, 0x6d, + 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2e, 0x54, + 0x61, 0x67, 0x73, 0x42, 0x2f, 0x5a, 0x2d, 0x67, 0x6f, 0x2e, 0x6d, 0x6f, 0x6e, 0x64, 0x6f, 0x6f, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x72, 0x2d, 0x72, 0x70, 0x63, 0x2f, + 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x2f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x72, 0x67, + 0x75, 0x61, 0x72, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_hello_proto_rawDescOnce sync.Once + file_hello_proto_rawDescData = file_hello_proto_rawDesc +) + +func file_hello_proto_rawDescGZIP() []byte { + file_hello_proto_rawDescOnce.Do(func() { + file_hello_proto_rawDescData = protoimpl.X.CompressGZIP(file_hello_proto_rawDescData) + }) + return file_hello_proto_rawDescData +} + +var file_hello_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_hello_proto_goTypes = []interface{}{ + (*HelloReq)(nil), // 0: io.mondoo.falcon.example.helloworld.HelloReq + (*HelloResp)(nil), // 1: io.mondoo.falcon.example.helloworld.HelloResp + (*Empty)(nil), // 2: io.mondoo.falcon.example.helloworld.Empty + (*Tags)(nil), // 3: io.mondoo.falcon.example.helloworld.Tags + nil, // 4: io.mondoo.falcon.example.helloworld.Tags.TagsEntry +} +var file_hello_proto_depIdxs = []int32{ + 4, // 0: io.mondoo.falcon.example.helloworld.Tags.tags:type_name -> io.mondoo.falcon.example.helloworld.Tags.TagsEntry + 0, // 1: io.mondoo.falcon.example.helloworld.HelloWorld.Hello:input_type -> io.mondoo.falcon.example.helloworld.HelloReq + 2, // 2: io.mondoo.falcon.example.helloworld.HelloWorld.Info:input_type -> io.mondoo.falcon.example.helloworld.Empty + 1, // 3: io.mondoo.falcon.example.helloworld.HelloWorld.Hello:output_type -> io.mondoo.falcon.example.helloworld.HelloResp + 3, // 4: io.mondoo.falcon.example.helloworld.HelloWorld.Info:output_type -> io.mondoo.falcon.example.helloworld.Tags + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_hello_proto_init() } +func file_hello_proto_init() { + if File_hello_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_hello_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HelloReq); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hello_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HelloResp); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hello_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Empty); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hello_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Tags); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_hello_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_hello_proto_goTypes, + DependencyIndexes: file_hello_proto_depIdxs, + MessageInfos: file_hello_proto_msgTypes, + }.Build() + File_hello_proto = out.File + file_hello_proto_rawDesc = nil + file_hello_proto_goTypes = nil + file_hello_proto_depIdxs = nil +} diff --git a/examples/rangerguard/hello.proto b/examples/rangerguard/hello.proto new file mode 100644 index 0000000..27c1cbb --- /dev/null +++ b/examples/rangerguard/hello.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package io.mondoo.falcon.example.helloworld; +option go_package = "go.mondoo.com/ranger-rpc/examples/rangerguard"; + +service HelloWorld { + rpc Hello(HelloReq) returns (HelloResp); + rpc Info(Empty) returns (Tags); +} + +message HelloReq { + string subject = 1; +} + +message HelloResp { + string text = 1; +} + +message Empty {} + +message Tags { + map tags = 1; +} \ No newline at end of file diff --git a/examples/rangerguard/hello.ranger.go b/examples/rangerguard/hello.ranger.go new file mode 100644 index 0000000..d0ae8a8 --- /dev/null +++ b/examples/rangerguard/hello.ranger.go @@ -0,0 +1,144 @@ +// Code generated by protoc-gen-rangerrpc version DO NOT EDIT. +// source: hello.proto + +package rangerguard + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + ranger "go.mondoo.com/ranger-rpc" + "go.mondoo.com/ranger-rpc/metadata" + jsonpb "google.golang.org/protobuf/encoding/protojson" + pb "google.golang.org/protobuf/proto" +) + +// service interface definition + +type HelloWorld interface { + Hello(context.Context, *HelloReq) (*HelloResp, error) + Info(context.Context, *Empty) (*Tags, error) +} + +// client implementation + +type HelloWorldClient struct { + ranger.Client + httpclient ranger.HTTPClient + prefix string +} + +func NewHelloWorldClient(addr string, client ranger.HTTPClient, plugins ...ranger.ClientPlugin) (*HelloWorldClient, error) { + base, err := url.Parse(ranger.SanitizeUrl(addr)) + if err != nil { + return nil, err + } + + u, err := url.Parse("./HelloWorld") + if err != nil { + return nil, err + } + + serviceClient := &HelloWorldClient{ + httpclient: client, + prefix: base.ResolveReference(u).String(), + } + serviceClient.AddPlugins(plugins...) + return serviceClient, nil +} +func (c *HelloWorldClient) Hello(ctx context.Context, in *HelloReq) (*HelloResp, error) { + out := new(HelloResp) + err := c.DoClientRequest(ctx, c.httpclient, strings.Join([]string{c.prefix, "/Hello"}, ""), in, out) + return out, err +} +func (c *HelloWorldClient) Info(ctx context.Context, in *Empty) (*Tags, error) { + out := new(Tags) + err := c.DoClientRequest(ctx, c.httpclient, strings.Join([]string{c.prefix, "/Info"}, ""), in, out) + return out, err +} + +// server implementation + +type HelloWorldServerOption func(s *HelloWorldServer) + +func WithUnknownFieldsForHelloWorldServer() HelloWorldServerOption { + return func(s *HelloWorldServer) { + s.allowUnknownFields = true + } +} + +func NewHelloWorldServer(handler HelloWorld, opts ...HelloWorldServerOption) http.Handler { + srv := &HelloWorldServer{ + handler: handler, + } + + for i := range opts { + opts[i](srv) + } + + service := ranger.Service{ + Name: "HelloWorld", + Methods: map[string]ranger.Method{ + "Hello": srv.Hello, + "Info": srv.Info, + }, + } + return ranger.NewRPCServer(&service) +} + +type HelloWorldServer struct { + handler HelloWorld + allowUnknownFields bool +} + +func (p *HelloWorldServer) Hello(ctx context.Context, reqBytes *[]byte) (pb.Message, error) { + var req HelloReq + var err error + + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, errors.New("could not access header") + } + + switch md.First("Content-Type") { + case "application/protobuf", "application/octet-stream", "application/grpc+proto": + err = pb.Unmarshal(*reqBytes, &req) + default: + // handle case of empty object + if len(*reqBytes) > 0 { + err = jsonpb.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(*reqBytes, &req) + } + } + + if err != nil { + return nil, err + } + return p.handler.Hello(ctx, &req) +} +func (p *HelloWorldServer) Info(ctx context.Context, reqBytes *[]byte) (pb.Message, error) { + var req Empty + var err error + + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, errors.New("could not access header") + } + + switch md.First("Content-Type") { + case "application/protobuf", "application/octet-stream", "application/grpc+proto": + err = pb.Unmarshal(*reqBytes, &req) + default: + // handle case of empty object + if len(*reqBytes) > 0 { + err = jsonpb.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(*reqBytes, &req) + } + } + + if err != nil { + return nil, err + } + return p.handler.Info(ctx, &req) +} diff --git a/examples/rangerguard/keysstore.go b/examples/rangerguard/keysstore.go new file mode 100644 index 0000000..d15b24b --- /dev/null +++ b/examples/rangerguard/keysstore.go @@ -0,0 +1,36 @@ +package rangerguard + +import ( + "crypto/x509" + "errors" + + "go.mondoo.com/ranger-rpc/plugins/rangerguard/crypto" +) + +type Keystore struct { + entries map[string]*x509.Certificate +} + +func NewKeystore() *Keystore { + ks := &Keystore{} + ks.entries = make(map[string]*x509.Certificate) + return ks +} + +func (ks *Keystore) Get(key string) (*x509.Certificate, error) { + e, ok := ks.entries[key] + if !ok { + return nil, errors.New("could not find key") + } + return e, nil +} + +func (ks *Keystore) Load(file string) error { + // key id 1 + cert, err := crypto.CertificateFromFile(file) + if err != nil { + return err + } + ks.entries["1"] = cert + return nil +} diff --git a/examples/rangerguard/server/cert.pem b/examples/rangerguard/server/cert.pem new file mode 100644 index 0000000..0c8f00c --- /dev/null +++ b/examples/rangerguard/server/cert.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBejCCAQECCQC+pPgm3zOMjzAKBggqhkjOPQQDAjAnMQswCQYDVQQGEwJVUzEL +MAkGA1UECAwCQ0ExCzAJBgNVBAcMAlNGMB4XDTIyMDkyMjIzMTEyNFoXDTIzMDkx +NzIzMTEyNFowJzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJT +RjB2MBAGByqGSM49AgEGBSuBBAAiA2IABHTkGG+sJSSi01pFPHcs1VU/oS6cnAuZ ++nA4ljtpSCL9tjS6ZPYXKEpR844Mg3gRQnoFQSxFZT99CtYvi8Dr1dFUqRZELE/n +HF7xm42enEIASLD9Nt9oQ2Hs7nqU6/XA5DAKBggqhkjOPQQDAgNnADBkAjBhBT6n +WRxtZSEYR4QLRPIdrTQKB/PM2AXXh8oqaF23KWJYWgLLjI1dkl6zqd4xzSMCMDxd +dZp3kQ5YLZXgxgx/rrlfzeyi8zovBB4wXZKGwjMFO8yH5q3m3bE9xwAWKOH3GA== +-----END CERTIFICATE----- diff --git a/examples/rangerguard/server/private-key.p8 b/examples/rangerguard/server/private-key.p8 new file mode 100644 index 0000000..2e53d2a --- /dev/null +++ b/examples/rangerguard/server/private-key.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDALA8nxuVtc2x+99NiM +pLIcdLvGlrQ5xX5Ufqy6MzZISVHRrRxH7RDxAVT221tqtOShZANiAAR05BhvrCUk +otNaRTx3LNVVP6EunJwLmfpwOJY7aUgi/bY0umT2FyhKUfOODIN4EUJ6BUEsRWU/ +fQrWL4vA69XRVKkWRCxP5xxe8ZuNnpxCAEiw/TbfaENh7O56lOv1wOQ= +-----END PRIVATE KEY----- diff --git a/examples/rangerguard/server/private-key.pem b/examples/rangerguard/server/private-key.pem new file mode 100644 index 0000000..2f7479e --- /dev/null +++ b/examples/rangerguard/server/private-key.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDALA8nxuVtc2x+99NiMpLIcdLvGlrQ5xX5Ufqy6MzZISVHRrRxH7RDx +AVT221tqtOSgBwYFK4EEACKhZANiAAR05BhvrCUkotNaRTx3LNVVP6EunJwLmfpw +OJY7aUgi/bY0umT2FyhKUfOODIN4EUJ6BUEsRWU/fQrWL4vA69XRVKkWRCxP5xxe +8ZuNnpxCAEiw/TbfaENh7O56lOv1wOQ= +-----END EC PRIVATE KEY----- diff --git a/examples/rangerguard/server/public-key.pem b/examples/rangerguard/server/public-key.pem new file mode 100644 index 0000000..a21a7de --- /dev/null +++ b/examples/rangerguard/server/public-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEdOQYb6wlJKLTWkU8dyzVVT+hLpycC5n6 +cDiWO2lIIv22NLpk9hcoSlHzjgyDeBFCegVBLEVlP30K1i+LwOvV0VSpFkQsT+cc +XvGbjZ6cQgBIsP0232hDYezuepTr9cDk +-----END PUBLIC KEY----- diff --git a/examples/rangerguard/server/server.go b/examples/rangerguard/server/server.go new file mode 100644 index 0000000..d6150fa --- /dev/null +++ b/examples/rangerguard/server/server.go @@ -0,0 +1,72 @@ +package main + +import ( + "crypto/tls" + "net/http" + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/cert" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" +) + +func main() { + serve() +} + +func serve() { + ks := helloworld.NewKeystore() + err := ks.Load("./cert.pem") + if err != nil { + log.Fatal().Err(err).Msg("Cannot not load public key") + } + + // inefficient, just for testing + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // https server + cfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + } + + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you an http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + ca := cert.New(cert.Config{ + KeyStore: ks, + }) + authenticators := []authentication.Authenticator{ca} + authorizors := []authorization.Authorizor{always.Allow()} + + srv := &http.Server{ + Addr: ":8443", + // use a wrapper handler to verify authentication + Handler: rangerguard.New(rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, mux), + TLSConfig: cfg, + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0), + } + + log.Info().Msgf("start server on %s", srv.Addr) + err = srv.ListenAndServeTLS("./cert.pem", "./private-key.pem") + log.Fatal(). + Err(err). + Str("service", "http"). + Msg("cannot start service") +} diff --git a/examples/rangerguard/service.go b/examples/rangerguard/service.go new file mode 100644 index 0000000..68d97e7 --- /dev/null +++ b/examples/rangerguard/service.go @@ -0,0 +1,32 @@ +package rangerguard + +import ( + "context" + "strings" + + "github.com/rs/zerolog/log" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" +) + +type HelloWorldServerImpl struct{} + +func (s *HelloWorldServerImpl) Hello(ctx context.Context, req *HelloReq) (*HelloResp, error) { + return &HelloResp{Text: "Hello " + req.Subject}, nil +} + +func (s *HelloWorldServerImpl) Info(ctx context.Context, req *Empty) (*Tags, error) { + tags := make(map[string]string) + + val, ok := rangerguard.UserFromContext(ctx) + if !ok { + log.Error().Msg("could not extract falcon:user from context") + } else { + tags["issuer"] = val.GetIssuer() + tags["subject"] = val.GetSubject() + tags["name"] = val.GetName() + tags["email"] = val.GetEmail() + tags["groups"] = strings.Join(val.GetGroups(), ",") + } + + return &Tags{Tags: tags}, nil +} diff --git a/go.mod b/go.mod index ae36a21..868feb5 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,11 @@ require ( github.com/lyft/protoc-gen-star v0.6.1 github.com/rs/zerolog v1.27.0 github.com/stretchr/testify v1.8.0 + go.opentelemetry.io/otel v1.10.0 + golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 google.golang.org/genproto v0.0.0-20220715211116-798f69b842b9 google.golang.org/protobuf v1.28.0 + gopkg.in/square/go-jose.v2 v2.6.0 moul.io/http2curl v1.0.0 ) @@ -19,6 +22,8 @@ require ( github.com/cockroachdb/redact v1.1.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/getsentry/sentry-go v0.13.0 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.21.1 // indirect @@ -34,6 +39,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/spf13/afero v1.9.0 // indirect + go.opentelemetry.io/otel/trace v1.10.0 // indirect golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 46c5880..efc4f40 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,11 @@ github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= @@ -381,6 +386,10 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= +go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= +go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= +go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -394,6 +403,8 @@ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY= +golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -723,6 +734,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/plugins/authentication/authenticator.go b/plugins/authentication/authenticator.go new file mode 100644 index 0000000..18ca082 --- /dev/null +++ b/plugins/authentication/authenticator.go @@ -0,0 +1,12 @@ +package authentication + +import ( + "net/http" + + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" +) + +type Authenticator interface { + Name() string + Verify(req *http.Request) (user.User, bool, error) +} diff --git a/plugins/authentication/cert/cert_authenticator.go b/plugins/authentication/cert/cert_authenticator.go new file mode 100644 index 0000000..dc4a2fc --- /dev/null +++ b/plugins/authentication/cert/cert_authenticator.go @@ -0,0 +1,155 @@ +package cert + +import ( + "bytes" + "crypto/subtle" + "crypto/x509" + "fmt" + "io" + "net/http" + "time" + + "github.com/cockroachdb/errors" + "github.com/rs/zerolog/log" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/crypto" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/header" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + jwt "gopkg.in/square/go-jose.v2/jwt" +) + +type KeyStore interface { + Get(keyid string) (*x509.Certificate, error) +} + +type Config struct { + KeyStore KeyStore + Expected jwt.Expected +} + +func New(cfg Config) *certificateAuthenticator { + return &certificateAuthenticator{ + ks: cfg.KeyStore, + verifier: &CertJWTVerifier{ + Ks: cfg.KeyStore, + Expected: cfg.Expected, + }, + } +} + +type certificateAuthenticator struct { + ks KeyStore + verifier *CertJWTVerifier +} + +func (ca *certificateAuthenticator) Name() string { + return "Certificate Authenticator" +} + +func (ca *certificateAuthenticator) Verify(r *http.Request) (user.User, bool, error) { + verifier := ca.verifier + payload := ca.readBody(r) + hash := crypto.HashPayload(payload) + + bearerToken, err := header.ExtractToken(r) + if err != nil { + return nil, false, err + } + + // abort checking if the bearer token is empty + if len(bearerToken) == 0 { + return nil, false, nil + } + + var c user.Claims + err = verifier.Validate(bearerToken, &c) + if err != nil { + // log.Debug().Err(err).Str("component", "guard").Msg("validate token") + return nil, false, err + } + + // hash from jwt claim + // NOTE: at this point we make content signing optional to experiment with the public api + // Unfortunately there is no easy standard that it implemented in all API tooling + identical := true + var chash string + if c.HasClaim("chash") { + err = c.UnmarshalClaim("chash", &chash) + if err != nil { + log.Debug().Err(err).Str("component", "guard").Msg("could not extract content hash") + return nil, false, err + } + + // hash from serialized content + shash := fmt.Sprintf("%x", hash) + + identical = subtle.ConstantTimeCompare([]byte(shash), []byte(chash)) == 1 + if !identical { + log.Debug().Str("component", "guard").Str("hash generated", shash).Str("hash encoded", chash).Msg("hash do not match") + } + } + + ui, err := user.ParseClaims(&c) + if err != nil { + return nil, false, err + } + + return ui, identical, nil +} + +func (ca *certificateAuthenticator) readBody(request *http.Request) []byte { + if request.Body == nil { + return []byte{} + } + payload, _ := io.ReadAll(request.Body) + request.Body = io.NopCloser(bytes.NewReader(payload)) + return payload +} + +type CertJWTVerifier struct { + Ks KeyStore + Expected jwt.Expected +} + +func (v *CertJWTVerifier) Validate(rawToken string, dest *user.Claims) error { + tok, err := jwt.ParseSigned(rawToken) + if err != nil { + return err + } + + // determine the public key to verify a bearer token + if len(tok.Headers) != 1 { + return errors.New("multiple headers not supported") + } + + certificate, err := v.Ks.Get(tok.Headers[0].KeyID) + if err != nil { + return errors.Wrap(err, "invalid keyid in jwt") + } + + // verify the signing algo + if len(tok.Headers) == 0 || tok.Headers[0].Algorithm != "ES384" { + return errors.New("unsupported signature algorithm token, only ES384 is supported") + } + + // check that the token is valid + out := jwt.Claims{} + pk, err := crypto.PublicKeyFromCert(certificate) + if err != nil { + return err + } + if err := tok.Claims(pk, &out); err != nil { + return err + } + + err = out.Validate(v.Expected.WithTime(time.Now())) + if err != nil { + return err + } + + // parse claims into user struct + if err := tok.Claims(pk, dest); err != nil { + return err + } + + return nil +} diff --git a/plugins/authentication/cert/cert_authenticator_test.go b/plugins/authentication/cert/cert_authenticator_test.go new file mode 100644 index 0000000..1645bc7 --- /dev/null +++ b/plugins/authentication/cert/cert_authenticator_test.go @@ -0,0 +1,117 @@ +package cert_test + +import ( + "context" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/cert" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/crypto" +) + +func createCertHelloworldServer() (*httptest.Server, *helloworld.Keystore) { + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you an http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + ks := helloworld.NewKeystore() + // init auth middleware + ca := cert.New(cert.Config{ + KeyStore: ks, + }) + authenticators := []authentication.Authenticator{ca} + authorizors := []authorization.Authorizor{always.Allow()} + + // Start a local HTTP server + server := httptest.NewServer(rangerguard.New( + rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, + mux)) + return server, ks +} + +func TestGuardCertAuthentication(t *testing.T) { + server, ks := createCertHelloworldServer() + err := ks.Load("../../../examples/rangerguard/server/cert.pem") + require.NoError(t, err) + // Close the server when test finishes + defer server.Close() + + // key for signing the requests + privateKey, err := crypto.PrivateKeyFromFile("../../../examples/rangerguard/server/private-key.p8") + require.NoError(t, err) + + plugin, err := cert.NewRangerPlugin(cert.ClientConfig{ + PrivateKey: privateKey, + Issuer: "testissuer", + Subject: "testsubject", + Kid: "1", + }) + require.NoError(t, err) + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", ranger.DefaultHttpClient(), plugin) + require.NoError(t, err) + + // check that the client is authenticated + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") + + // check that guard detects the user + tagResp, err := protoClient.Info(context.Background(), &helloworld.Empty{}) + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "testsubject", tagResp.Tags["subject"], "get expected user subject") + assert.Equal(t, "testissuer", tagResp.Tags["issuer"], "get expected issuer") + assert.Equal(t, "", tagResp.Tags["name"], "get expected user name") + assert.Equal(t, "", tagResp.Tags["email"], "get expected user email") + assert.Equal(t, "", tagResp.Tags["groups"], "get expected user email") +} + +func TestDenyGuardCertAuthentication(t *testing.T) { + server, ks := createCertHelloworldServer() + err := ks.Load("../../../examples/rangerguard/server/cert.pem") + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", ranger.DefaultHttpClient()) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + _, err = protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "service returns with error") +} + +// implement fake keystore +type fakeKeyStore struct{} + +func (ks *fakeKeyStore) Get(key string) (*x509.Certificate, error) { + cert, err := crypto.CertificateFromFile("../../example/server/cert.pem") + if err != nil { + return nil, err + } + + if key == "1" { + return cert, nil + } + + return nil, errors.New("invalid kid") +} diff --git a/plugins/authentication/cert/cert_client_plugin.go b/plugins/authentication/cert/cert_client_plugin.go new file mode 100644 index 0000000..1f3d137 --- /dev/null +++ b/plugins/authentication/cert/cert_client_plugin.go @@ -0,0 +1,94 @@ +package cert + +import ( + "crypto/ecdsa" + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog/log" + "go.mondoo.com/ranger-rpc" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/crypto" + jose "gopkg.in/square/go-jose.v2" + jwt "gopkg.in/square/go-jose.v2/jwt" +) + +type ClientConfig struct { + Subject string + Issuer string + Kid string + PrivateKey *ecdsa.PrivateKey + DisableContentHashing bool + NoExpiration bool +} + +func NewRangerPlugin(cfg ClientConfig) (ranger.ClientPlugin, error) { + return &certAuthenticationClientPlugin{cfg: cfg}, nil +} + +type certAuthenticationClientPlugin struct { + cfg ClientConfig +} + +func (cap *certAuthenticationClientPlugin) GetName() string { + return "Ranger Guard Signer Plugin" +} + +func (cap *certAuthenticationClientPlugin) GetHeader(serialzed []byte) http.Header { + header := make(http.Header) + + // generate jwt security header + signature := crypto.HashPayload(serialzed) + + bearer, err := cap.Sign(signature) + if err != nil { + log.Error().Err(err).Str("component", "guard").Msg("could not generate bearer token") + return header + } + + header.Set("Authorization", fmt.Sprintf("Bearer %s", bearer)) + return header +} + +// Sign generates JWT bearer token +func (cap *certAuthenticationClientPlugin) Sign(signature []byte) (string, error) { + var shash string + + if !cap.cfg.DisableContentHashing { + shash = fmt.Sprintf("%x", signature) + } + issuedAt := time.Now() + + cl := jwt.Claims{ + Subject: cap.cfg.Subject, + Issuer: cap.cfg.Issuer, + IssuedAt: jwt.NewNumericDate(issuedAt), + NotBefore: jwt.NewNumericDate(issuedAt), + } + + if !cap.cfg.NoExpiration { + // valid for 60 seconds + cl.Expiry = jwt.NewNumericDate(issuedAt.Add(time.Duration(60) * time.Second)) + } + + customClaims := struct { + ContentHash string `json:"chash,omitempty"` + }{ + shash, + } + + sig, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.ES384, + Key: cap.cfg.PrivateKey, + }, (&jose.SignerOptions{}).WithHeader("kid", cap.cfg.Kid).WithType("JWT")) + if err != nil { + return "", err + } + + bearer, err := jwt.Signed(sig).Claims(cl).Claims(customClaims).CompactSerialize() + if err != nil { + return "", err + } + + return bearer, nil +} diff --git a/plugins/authentication/defaultuser/default.go b/plugins/authentication/defaultuser/default.go new file mode 100644 index 0000000..f26d5fe --- /dev/null +++ b/plugins/authentication/defaultuser/default.go @@ -0,0 +1,51 @@ +package defaultuser + +import ( + "net/http" + "regexp" + "strings" + + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" +) + +func Anonymous() *defaultUserAuthenticator { + u := &user.UserInfo{ + Name: "anonymous", + Subject: "anonymous", + Issuer: "system/anonymous", + Groups: []string{"anonymous"}, + } + return New(u) +} + +// passes request with a default user, if no other authentication is set +func New(defaultUser user.User) *defaultUserAuthenticator { + return &defaultUserAuthenticator{ + defaultUser: &user.UserInfo{ + Name: defaultUser.GetName(), + Subject: defaultUser.GetSubject(), + Issuer: defaultUser.GetIssuer(), + Email: strings.ToLower(defaultUser.GetEmail()), + Groups: defaultUser.GetGroups(), + }, + } +} + +type defaultUserAuthenticator struct { + defaultUser user.User + allowed []*regexp.Regexp +} + +func (aa *defaultUserAuthenticator) Name() string { + return "Default User Authenticator" +} + +func (aa *defaultUserAuthenticator) Verify(req *http.Request) (user.User, bool, error) { + // check that the authorization header is not set + headerVal := req.Header.Get("Authorization") + if len(headerVal) > 0 { + return nil, false, nil + } + + return aa.defaultUser, true, nil +} diff --git a/plugins/authentication/defaultuser/default_test.go b/plugins/authentication/defaultuser/default_test.go new file mode 100644 index 0000000..63eec68 --- /dev/null +++ b/plugins/authentication/defaultuser/default_test.go @@ -0,0 +1,69 @@ +package defaultuser_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/defaultuser" + "go.mondoo.com/ranger-rpc/plugins/authentication/statictoken" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" +) + +func createGuardServer(authenticators []authentication.Authenticator, authorizors []authorization.Authorizor) (*httptest.Server, error) { + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you an http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + // Start a local HTTP server + server := httptest.NewServer(rangerguard.New( + rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, + mux)) + return server, nil +} + +func TestGuardWithAnonymousAuth(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.Anonymous()}, []authorization.Authorizor{always.Allow()}) + if err != nil { + t.Fatal(err) + } + + // Close the server when test finishes + defer server.Close() + + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") +} + +// Default user only works if the authorization header is empty +func TestGuardWithAnonymousAuthWithInvalidCreds(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.Anonymous()}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}, statictoken.NewRangerPlugin("abcdefg")) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + _, err = protoClient.Hello(context.Background(), data) + assert.Equal(t, "rpc error: code = Unauthenticated desc = request permission unauthenticated", err.Error(), "service returns without error") +} diff --git a/plugins/authentication/statictoken/statictoken.go b/plugins/authentication/statictoken/statictoken.go new file mode 100644 index 0000000..7f91481 --- /dev/null +++ b/plugins/authentication/statictoken/statictoken.go @@ -0,0 +1,118 @@ +package statictoken + +import ( + "net/http" + "strings" + + "github.com/rs/zerolog/log" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/header" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + "golang.org/x/crypto/bcrypt" +) + +var defaultBcryptCost = 14 + +var defaultUser = &user.UserInfo{ + Subject: "statictoken-authenticated", + Name: "Statictoken Authenticated User", + Issuer: "system/statictoken", +} + +type StaticTokenOption func(c *staticTokenAuthenticator) + +func WithUser(subject string, name string, email string) StaticTokenOption { + return func(a *staticTokenAuthenticator) { + a.user = &user.UserInfo{ + Subject: subject, + Name: name, + Email: strings.ToLower(email), + Issuer: "system/statictoken", + } + } +} + +type StaticTokenComparer interface { + CheckPasswordHash(password, hash string) bool +} + +type bcryptComparer struct{} + +func (bc *bcryptComparer) CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func WithComparer(comparer StaticTokenComparer) StaticTokenOption { + return func(a *staticTokenAuthenticator) { + a.comparer = comparer + } +} + +func WithBcryptCost(bcryptCost int) StaticTokenOption { + if bcryptCost < 9 || bcryptCost > 32 { + log.Warn().Msgf("Invalid bcrypt cost %d, setting to default %d", bcryptCost, defaultBcryptCost) + bcryptCost = defaultBcryptCost + } + return func(a *staticTokenAuthenticator) { + a.bcryptCost = bcryptCost + } +} + +func New(token string, opts ...StaticTokenOption) *staticTokenAuthenticator { + ta := &staticTokenAuthenticator{ + bcryptCost: defaultBcryptCost, + comparer: &bcryptComparer{}, + } + + // apply opts + for i := range opts { + opts[i](ta) + } + + hash, err := hashPassword(token, ta.bcryptCost) + if err != nil { + log.Error().Err(err).Msg("could not hash static token") + } + ta.hashedToken = hash + + // fallback to default user + if ta.user == nil { + ta.user = defaultUser + } + + return ta +} + +type staticTokenAuthenticator struct { + user *user.UserInfo + comparer StaticTokenComparer + hashedToken string + bcryptCost int +} + +func (sa *staticTokenAuthenticator) Name() string { + return "Static Token Authenticator" +} + +func (sa *staticTokenAuthenticator) Verify(req *http.Request) (user.User, bool, error) { + bearerToken, err := header.ExtractToken(req) + if err != nil { + return nil, false, err + } + + if bearerToken == "" { + // Don't compare a token if it has not been supplied + return nil, false, nil + } + + if sa.comparer.CheckPasswordHash(bearerToken, sa.hashedToken) { + return sa.user, true, nil + } + + return nil, false, nil +} + +func hashPassword(password string, cost int) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + return string(bytes), err +} diff --git a/plugins/authentication/statictoken/statictoken_client_plugin.go b/plugins/authentication/statictoken/statictoken_client_plugin.go new file mode 100644 index 0000000..029cb60 --- /dev/null +++ b/plugins/authentication/statictoken/statictoken_client_plugin.go @@ -0,0 +1,26 @@ +package statictoken + +import ( + "fmt" + "net/http" + + "go.mondoo.com/ranger-rpc" +) + +func NewRangerPlugin(token string) ranger.ClientPlugin { + return &statictokenClientPlugin{token: token} +} + +type statictokenClientPlugin struct { + token string +} + +func (scp *statictokenClientPlugin) GetName() string { + return "Ranger Guard Static Token Plugin" +} + +func (scp *statictokenClientPlugin) GetHeader(serialzed []byte) http.Header { + header := make(http.Header) + header.Set("Authorization", fmt.Sprintf("Bearer %s", scp.token)) + return header +} diff --git a/plugins/authentication/statictoken/statictoken_test.go b/plugins/authentication/statictoken/statictoken_test.go new file mode 100644 index 0000000..c880cde --- /dev/null +++ b/plugins/authentication/statictoken/statictoken_test.go @@ -0,0 +1,97 @@ +package statictoken_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/statictoken" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" +) + +func createGuardServer(authenticators []authentication.Authenticator, authorizors []authorization.Authorizor) (*httptest.Server, error) { + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you a http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + // Start a local HTTP server + server := httptest.NewServer(rangerguard.New( + rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, + mux)) + return server, nil +} + +func TestGuardWithStaticTokenAuth(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{statictoken.New("abcdefg", statictoken.WithUser("johndoe", "John Doe", "john@example.com"))}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}, statictoken.NewRangerPlugin("abcdefg")) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") +} + +type fakeComparer struct { + wasCalled bool +} + +func (fc *fakeComparer) CheckPasswordHash(password, hash string) bool { + fc.wasCalled = true + return true +} + +func TestGuardWithMissingToken(t *testing.T) { + fc := &fakeComparer{} + server, err := createGuardServer([]authentication.Authenticator{statictoken.New("abcdefg", statictoken.WithUser("johndoe", "John Doe", "john@example.com"), statictoken.WithComparer(fc))}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}, statictoken.NewRangerPlugin("")) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + _, err = protoClient.Hello(context.Background(), data) + + assert.NotNil(t, err, "service returns error") + // verify that the bcrypt compare call was not run + assert.Equal(t, fc.wasCalled, false) +} + +func TestGuardWithFakeComparer(t *testing.T) { + fc := &fakeComparer{} + server, err := createGuardServer([]authentication.Authenticator{statictoken.New("abcdefg", statictoken.WithUser("johndoe", "John Doe", "john@example.com"), statictoken.WithComparer(fc))}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}, statictoken.NewRangerPlugin("abcdefg")) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") + assert.Equal(t, fc.wasCalled, true) // make sure that our comparer was called +} diff --git a/plugins/authorization/always/allow.go b/plugins/authorization/always/allow.go new file mode 100644 index 0000000..4a633f6 --- /dev/null +++ b/plugins/authorization/always/allow.go @@ -0,0 +1,19 @@ +package always + +import ( + "go.mondoo.com/ranger-rpc/plugins/authorization" +) + +func Allow() *allowAuthorizor { + return &allowAuthorizor{} +} + +type allowAuthorizor struct{} + +func (da *allowAuthorizor) Name() string { + return "Allow Authorizer" +} + +func (da *allowAuthorizor) Authorize(a authorization.AuthorizationFacts) (authorized authorization.Decision, reason string, err error) { + return authorization.DecisionAllow, "allow authorizer allows this requests", nil +} diff --git a/plugins/authorization/always/allow_test.go b/plugins/authorization/always/allow_test.go new file mode 100644 index 0000000..84933ae --- /dev/null +++ b/plugins/authorization/always/allow_test.go @@ -0,0 +1,75 @@ +package always_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc/codes" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/defaultuser" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + "go.mondoo.com/ranger-rpc/status" +) + +func createGuardServer(authenticators []authentication.Authenticator, authorizors []authorization.Authorizor) (*httptest.Server, error) { + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you an http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + // Start a local HTTP server + server := httptest.NewServer(rangerguard.New( + rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, + mux)) + return server, nil +} + +func TestGuardWithNoAuthorization(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.New(user.Anonymous)}, []authorization.Authorizor{}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "non-ok http request") + + s, _ := status.FromError(err) + assert.Equal(t, codes.PermissionDenied, s.Code()) + + assert.Equal(t, "", protoResp.Text, "get expected service response") +} + +func TestGuardWithAllwaysAllow(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.New(user.Anonymous)}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") +} diff --git a/plugins/authorization/always/deny.go b/plugins/authorization/always/deny.go new file mode 100644 index 0000000..33ccabb --- /dev/null +++ b/plugins/authorization/always/deny.go @@ -0,0 +1,19 @@ +package always + +import ( + "go.mondoo.com/ranger-rpc/plugins/authorization" +) + +func Deny() *denyAuthorizor { + return &denyAuthorizor{} +} + +type denyAuthorizor struct{} + +func (da *denyAuthorizor) Name() string { + return "Deny Authorizer" +} + +func (da *denyAuthorizor) Authorize(a authorization.AuthorizationFacts) (authorized authorization.Decision, reason string, err error) { + return authorization.DecisionDeny, "deny authorizer denies this requests", nil +} diff --git a/plugins/authorization/always/deny_test.go b/plugins/authorization/always/deny_test.go new file mode 100644 index 0000000..3c5dacf --- /dev/null +++ b/plugins/authorization/always/deny_test.go @@ -0,0 +1,38 @@ +package always_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc/codes" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/defaultuser" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + "go.mondoo.com/ranger-rpc/status" +) + +func TestDenyWithAuthnAndAuthz(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.New(user.Anonymous)}, []authorization.Authorizor{always.Deny()}) + require.NoError(t, err) + + // close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "service returns with permission error") + + s, _ := status.FromError(err) + assert.Equal(t, codes.PermissionDenied, s.Code()) + assert.Equal(t, "", protoResp.Text, "get expected service response") +} diff --git a/plugins/authorization/authorizor.go b/plugins/authorization/authorizor.go new file mode 100644 index 0000000..c5a96ba --- /dev/null +++ b/plugins/authorization/authorizor.go @@ -0,0 +1,79 @@ +package authorization + +import ( + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" +) + +type Authorizor interface { + Name() string + // The default behavior for Authorize is to deny access + Authorize(a AuthorizationFacts) (authorized Decision, reason string, err error) +} + +// This AuthorizationFacts interface provides all the facts that the authorization engine can use to +// determine if a user has access or not +type AuthorizationFacts interface { + GetUser() user.User + + // GetAction returns the action associated with API requests e.g get, create, update, patch, delete, list + GetAction() string + + // The kind of object, that is affected by the request + GetResource() string + + // GetAPIGroup returns the api group + GetAPIGroup() string + + // GetAPIVersion returns the api version + GetAPIVersion() string + + // GetPath returns the request path + GetPath() string +} + +// Decision is the response from an Authorizor +type Decision int + +const ( + // DecisionAllow means that an Authorizor decided that the user is allowed to use the API. + DecisionAllow Decision = iota + + // DecisionDeny means that an Authorizor decided to deny the request + DecisionDeny + + // DecisionAbstention means that an Authorizor is not voting at all + DecisionAbstention +) + +type AttributesRecord struct { + User user.User + Action string + Resource string + APIGroup string + APIVersion string + Path string +} + +func (ar *AttributesRecord) GetUser() user.User { + return ar.User +} + +func (ar *AttributesRecord) GetAction() string { + return ar.Action +} + +func (ar *AttributesRecord) GetResource() string { + return ar.Resource +} + +func (ar *AttributesRecord) GetAPIGroup() string { + return ar.APIGroup +} + +func (ar *AttributesRecord) GetAPIVersion() string { + return ar.APIVersion +} + +func (ar *AttributesRecord) GetPath() string { + return ar.Path +} diff --git a/plugins/rangerguard/README.md b/plugins/rangerguard/README.md new file mode 100644 index 0000000..ad31cd9 --- /dev/null +++ b/plugins/rangerguard/README.md @@ -0,0 +1,126 @@ +# Ranger Guard Middleware + +Both humans and agents can be authenticated and authorized for API access. When a request reaches the endpoint, it goes through several stages. The following diagram illustrates the flow: + + ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + ranger guard │ + │ +┌─────────────┐ │ +│ │ │ ┌─────────────┐ ┌─────────────┐ +│ Human │───┬───▶│ authn 1 │ ┌───▶│ authz 1 │ │ +│ │ │ │ └─────────────┘ │ └─────────────┘ ┌─────────────┐ +└─────────────┘ │ │ │ │ │ │ │ + │ │ │ │ │ ┌──▶│ Service │ +┌─────────────┐ │ ▼ │ ▼ ││ │ │ +│ │ │ │ ┌─────────────┐ │ ┌─────────────┐ │ └─────────────┘ +│ Agent │───┘ │ authn 2 │───┘ │ authz 2 │───┘│ +│ │ │ └─────────────┘ └─────────────┘ +└─────────────┘ │ + │ + │ + └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +Ranger Guard allows the specification of multiple authentication and authorization modules. *Authentication* modules are called in sequence until one of authentication middleware succeeds. If the incoming request cannot be authenticated, it is rejected with 401 HTTP status code. Otherwise the user is authenticated and its identifier is made available to subsequent authorization steps. If no authentication middleware is configured, the request is just denied. + +Until now, we authenticated the user, the incoming request must still be *authorized*. If no authorization middleware is defined, all request are denied. If multiple authorization modules are configured, Ranger Guard checks each one, and if any module authorizes the request, then the request can proceed. If all of the modules deny the request, then the request is denied with 403 HTTP status code. + +## Authenticators + +Ranger Guard uses Ranger's plugin mechanism to register its authentication modules. The aim of the authentication modules is to: + +1. ensure the entity is the right one +2. ensure nobody tampered with the data in transit + +This allows the server to cover 3 use cases: + +1. the server is able to verify that only valid clients are talking to its api +2. ranger-generated client signs the request payload +3. ranger-generated client use using cert pinning and verifies https cert to ensure it is the correct server + +Out-of-the-box the following authentication method are available: + + * Certificate Authenticator + * Static Token Authenticator + + +``` + ┌───────────┐ + │Google/Okta│ + ┌─────────────────▶│ /Keycloak │ + │ └───────────┘ +┌───────────────┐ ┌─────────────┐ +│ UI │ Auth Header │ │ +│ │────────────────▶│OIDC Verifier│─────┐ ┌───────────┐ +│ │ │ │ │ │ │ +└───────────────┘ └─────────────┘ │ │ │ + ├────▶│ API │ +┌───────────────┐ ┌─────────────┐ │ │ │ +│ Client + Cert │ │ │ │ │ │ +│ │────────────────▶│Cert Verifier│─────┘ └───────────┘ +│ │ │ │ +└───────────────┘ Auth Header + └─────────────┘ + Signed Messages │ ┌───────────┐ + └─────────────────▶│ AMS │ + └───────────┘ +``` + +## Guard Example + +``` +# start the server +$ cd examples/rangerguard/server +$ go run main.go + +# start the client +$ cd examples/rangerguard/client +$ go run client.go +``` + +## FAQ + +### How can the client trust the server? + +The current pattern only authenticates the client to the server but not the server to the client. Clients should use certificate pinning and verify that the https certificate of the server is valid. + +### Generate certificates + +This based on [How to Generate & Use Private Keys using OpenSSL's Command Line Tool](https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b). Ranger Guard recommends the use of elliptic curves: + +```bash +# list available curves +openssl ecparam -list_curves + +# generate a private key +openssl ecparam -name secp384r1 -genkey -noout -out private-key.pem + +# convert to PKCS8 private key +openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key.p8 + +# generate corresponding public key +openssl ec -in private-key.pem -pubout -out public-key.pem + +# create a self-signed certificate +openssl req -new -x509 -key private-key.pem -out cert.pem -days 360 +``` + +To generate rsa certs: + +``` +#!/bin/bash +openssl genpkey -algorithm RSA \ + -pkeyopt rsa_keygen_bits:3072 \ + -pkeyopt rsa_keygen_pubexp:65537 | \ + openssl pkcs8 -topk8 -nocrypt -outform pem > rsa/rsa-3072-private-key.p8 + +openssl pkey -pubout -inform pem -outform pem \ + -in rsa/rsa-3072-private-key.p8 \ + -out rsa/rsa-3072-public-key.pem +``` + +## References + +- The auth method is inspired by Amazon [best-practices](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) +- Go [HTTPS configuration](https://gist.github.com/denji/12b3a568f092ab951456) +- Generate a [private/public key](https://rietta.com/blog/2012/01/27/openssl-generating-rsa-key-from-command/) +- [Golang & Cryptography. RSA asymmetric algorithm](https://medium.com/@raul_11817/golang-cryptography-rsa-asymmetric-algorithm-e91363a2f7b3) +- handle [unencrypted private/public key](https://help.globalscape.com/help/secureserver3/Generating_an_unencrypted_private_key_and_self-signed_public_certificate.htm) \ No newline at end of file diff --git a/plugins/rangerguard/context.go b/plugins/rangerguard/context.go new file mode 100644 index 0000000..ad44b33 --- /dev/null +++ b/plugins/rangerguard/context.go @@ -0,0 +1,18 @@ +package rangerguard + +import ( + "context" + + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" +) + +type guardUserIdentifier struct{} + +func UserFromContext(ctx context.Context) (u user.User, ok bool) { + u, ok = ctx.Value(guardUserIdentifier{}).(user.User) + return +} + +func NewUserContext(ctx context.Context, u user.User) context.Context { + return context.WithValue(ctx, guardUserIdentifier{}, u) +} diff --git a/plugins/rangerguard/crypto/cert.go b/plugins/rangerguard/crypto/cert.go new file mode 100644 index 0000000..90bc887 --- /dev/null +++ b/plugins/rangerguard/crypto/cert.go @@ -0,0 +1,70 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "os" +) + +// .p8 errors file. +var ( + ErrAuthKeyNotPem = errors.New("AuthKey must be a valid .p8 PEM file") + ErrAuthKeyNotECDSA = errors.New("AuthKey must be of type ecdsa.PrivateKey") + ErrAuthKeyNil = errors.New("AuthKey was nil") +) + +func PublicKeyFromCert(cert *x509.Certificate) (*ecdsa.PublicKey, error) { + key := cert.PublicKey + switch pk := key.(type) { + case *ecdsa.PublicKey: + return pk, nil + default: + return nil, ErrAuthKeyNotECDSA + } +} + +func CertificateFromFile(filename string) (*x509.Certificate, error) { + bytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + return CertificateFromBytes(bytes) +} + +func CertificateFromBytes(bytes []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(bytes) + if block == nil { + return nil, ErrAuthKeyNotPem + } + + return x509.ParseCertificate(block.Bytes) +} + +func PrivateKeyFromFile(filename string) (*ecdsa.PrivateKey, error) { + bytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + return PrivateKeyFromBytes(bytes) +} + +// AuthKeyFromBytes loads a .p8 certificate from an in memory byte array and +// returns an *ecdsa.PrivateKey. +func PrivateKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(bytes) + if block == nil { + return nil, ErrAuthKeyNotPem + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + switch pk := key.(type) { + case *ecdsa.PrivateKey: + return pk, nil + default: + return nil, ErrAuthKeyNotECDSA + } +} diff --git a/plugins/rangerguard/crypto/cert_test.go b/plugins/rangerguard/crypto/cert_test.go new file mode 100644 index 0000000..be9f388 --- /dev/null +++ b/plugins/rangerguard/crypto/cert_test.go @@ -0,0 +1,15 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeystore(t *testing.T) { + assert := assert.New(t) + + privateKey, err := PrivateKeyFromFile("../../../examples/rangerguard/server/private-key.p8") + assert.Equal(nil, err, "key could be loaded") + assert.NotEqual(nil, privateKey, "key should be loaded") +} diff --git a/plugins/rangerguard/crypto/payload.go b/plugins/rangerguard/crypto/payload.go new file mode 100644 index 0000000..c9aef87 --- /dev/null +++ b/plugins/rangerguard/crypto/payload.go @@ -0,0 +1,38 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "math/big" +) + +func HashPayload(message []byte) []byte { + pssh := sha256.New() + pssh.Write(message) + return pssh.Sum(nil) +} + +func SignMessage(priv *ecdsa.PrivateKey, message []byte) (*big.Int, *big.Int, error) { + hashed := HashPayload(message) + r, s, err := ecdsa.Sign( + rand.Reader, + priv, + hashed, + ) + if err != nil { + return nil, nil, err + } + + return r, s, nil +} + +func VerifyMessage(pub *ecdsa.PublicKey, message []byte, r, s *big.Int) bool { + hashed := HashPayload(message) + return ecdsa.Verify( + pub, + hashed, + r, + s, + ) +} diff --git a/plugins/rangerguard/crypto/payload_test.go b/plugins/rangerguard/crypto/payload_test.go new file mode 100644 index 0000000..61b31d8 --- /dev/null +++ b/plugins/rangerguard/crypto/payload_test.go @@ -0,0 +1,26 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEcdsaSignMessage(t *testing.T) { + message := []byte("the code must be like a piece of music") + + privKey, err := PrivateKeyFromFile("../../../examples/rangerguard/server/private-key.p8") + assert.Equal(t, nil, err, "key could be loaded") + + r, s, err := SignMessage(privKey, message) + assert.Equal(t, nil, err, "they should be equal") + + certificate, err := CertificateFromFile("../../../examples/rangerguard/server/cert.pem") + assert.Equal(t, nil, err, "key could be loaded") + + pk, err := PublicKeyFromCert(certificate) + require.NoError(t, err) + valid := VerifyMessage(pk, message, r, s) + assert.Equal(t, true, valid, "signature is valud") +} diff --git a/plugins/rangerguard/header/bearer.go b/plugins/rangerguard/header/bearer.go new file mode 100644 index 0000000..332b61f --- /dev/null +++ b/plugins/rangerguard/header/bearer.go @@ -0,0 +1,43 @@ +package header + +import ( + "net/http" + "net/url" + "strings" + + "github.com/rs/zerolog/log" +) + +// ExtractTokenFromBearer extracts the access token +func ExtractTokenFromBearer(token string) string { + if len(token) > 6 && strings.ToUpper(token[0:7]) == "BEARER " { + return token[7:] + } + if strings.ToUpper(token) == "BEARER" { + return "" + } + return token +} + +func ExtractToken(req *http.Request) (string, error) { + // let's try to read the bearer token from the authorization header + token := ExtractTokenFromBearer(req.Header.Get("Authorization")) + + // fallback to query-param + // http://self-issued.info/docs/draft-ietf-oauth-v2-bearer.html#query-param + if len(token) == 0 { + u, err := url.Parse(req.RequestURI) + if err != nil { + log.Warn().Err(err).Msg("could not extract bearer token from url query parameter") + return "", nil + } + + m, err := url.ParseQuery(u.RawQuery) + if err != nil { + return "", nil + } + token = m.Get("access_token") + } + + return token, nil +} diff --git a/plugins/rangerguard/header/bearer_test.go b/plugins/rangerguard/header/bearer_test.go new file mode 100644 index 0000000..b744975 --- /dev/null +++ b/plugins/rangerguard/header/bearer_test.go @@ -0,0 +1,46 @@ +package header_test + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/header" +) + +func TestExtractTokenFromBearer(t *testing.T) { + for name, tc := range map[string]struct { + bearer string + want string + }{ + "success": { + bearer: "Bearer token", + want: "token", + }, + "empty bearer": { + bearer: "Bearer", + want: "", + }, + "empty bearer with space": { + bearer: "Bearer ", + want: "", + }, + } { + t.Run(name, func(t *testing.T) { + if got := header.ExtractTokenFromBearer(tc.bearer); got != tc.want { + t.Errorf("ExtractTokenFromBearer() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestExtractToken(t *testing.T) { + t.Run("with garbage from uri", func(t *testing.T) { + req := httptest.NewRequest("POST", "localhost:80", strings.NewReader("test")) + req.RequestURI = "/;'!@#$%^&*(_+{}[]\\|" + token, err := header.ExtractToken(req) + require.NoError(t, err) + require.Empty(t, token) + }) +} diff --git a/plugins/rangerguard/mux.go b/plugins/rangerguard/mux.go new file mode 100644 index 0000000..1e4af8f --- /dev/null +++ b/plugins/rangerguard/mux.go @@ -0,0 +1,205 @@ +package rangerguard + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/rs/zerolog/log" + "go.mondoo.com/ranger-rpc" + "go.mondoo.com/ranger-rpc/codes" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + "go.mondoo.com/ranger-rpc/status" + "go.opentelemetry.io/otel" +) + +var tracer = otel.Tracer("go.mondoo.com/ranger-rpc/plugins/rangerguard") + +var ( + AUTHENTICATION_DENIED_ERROR = status.Error(codes.Unauthenticated, "request permission unauthenticated") + PERMISSION_DENIED_ERROR = status.Error(codes.PermissionDenied, "request permission denied") +) + +type Options struct { + Authenticators []authentication.Authenticator + Authorizors []authorization.Authorizor + Hooks []Hook +} + +func New(opts Options, next http.Handler) *guardMux { + if opts.Authenticators == nil || len(opts.Authenticators) == 0 { + log.Warn().Str("component", "guard").Msg("no authenticator set, access will be denied") + } + if opts.Authorizors == nil || len(opts.Authorizors) == 0 { + log.Warn().Str("component", "guard").Msg("no authorizer set, access will be denied") + } + return &guardMux{ + authenticators: opts.Authenticators, + authorizors: opts.Authorizors, + hooks: opts.Hooks, + next: next, + middlewares: []http.HandlerFunc{}, + } +} + +type Hook interface { + Name() string + // Run is called a call has a validated identity + // it can optionally return a new Context that will be attached to the incoming request + Run(context.Context, user.User, *http.Request) (context.Context, error) +} + +// guardMux secures a go http mux handler +// if no authenticator or authorizor is defined, the request will be denied +// use the AllowAuthenticator and AllowAuthorizor middleware to allow access to specific routes +type guardMux struct { + next http.Handler + authenticators []authentication.Authenticator + authorizors []authorization.Authorizor + hooks []Hook + // Slice of middlewares to be called after a match is found + middlewares []http.HandlerFunc +} + +// Use appends a http.HandlerFunc to the chain. Those functions can be used to intercept or modify requests/responses, +// and are executed in the order that they are added +func (r *guardMux) Use(mwf ...http.HandlerFunc) { + for _, fn := range mwf { + r.middlewares = append(r.middlewares, fn) + } +} + +func (gm guardMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + guardCtx, span := tracer.Start(r.Context(), "ranger.guard.ServeHTTP") + defer span.End() + // iterate over middle ware + for i := len(gm.middlewares) - 1; i >= 0; i-- { + fn := gm.middlewares[i] + fn(w, r) + } + + // handle authentication calls + user, authenticated, authnReq := gm.authenticate(r) + if !authenticated || user == nil { + log.Info().Str("component", "guard").Str("client", r.RemoteAddr).Str("uri", r.RequestURI).Msg("Unauthenticated") + ranger.HttpError(w, authnReq, AUTHENTICATION_DENIED_ERROR) + span.End() + return + } + + // tell hooks about the user + for i := range gm.hooks { + _, span := tracer.Start(guardCtx, "ranger.guard.ServeHTTP/hook/"+gm.hooks[i].Name()) + newCtx, err := gm.hooks[i].Run(authnReq.Context(), user, r) + span.End() + if err != nil { + log.Error().Err(err).Str("hook", gm.hooks[i].Name()).Msg("could not authenticate because ranger guard hook returned an error") + ranger.HttpError(w, authnReq, AUTHENTICATION_DENIED_ERROR) + span.End() + return + } + // assign new context + if newCtx != nil { + authnReq = authnReq.WithContext(newCtx) + } + } + + authorized, authzReq := gm.authorize(authnReq, user) + if !authorized { + log.Info().Str("component", "guard").Str("client", r.RemoteAddr).Str("uri", r.RequestURI).Msg("Unauthorized") + ranger.HttpError(w, authzReq, PERMISSION_DENIED_ERROR) + span.End() + return + } + + span.End() + gm.next.ServeHTTP(w, authzReq) +} + +func (gm guardMux) authenticate(r *http.Request) (user.User, bool, *http.Request) { + // log := logger.FromContext(r.Context()) + _, span := tracer.Start(r.Context(), "ranger.guard.authenticate") + defer span.End() + + // iterate over the authenticators, once one is passing, we go forward + errMsgs := []string{} + for i := range gm.authenticators { + authenticator := gm.authenticators[i] + user, valid, err := authenticator.Verify(r) + if err != nil { + errMsgs = append(errMsgs, err.Error()) + continue + } + if valid { + log.Debug().Str("component", "guard"). + Str("authenticator", authenticator.Name()). + Str("client", r.RemoteAddr). + Str("subject", user.GetSubject()). + Str("issuer", user.GetIssuer()). + Str("email", user.GetEmail()). + Str("uri", r.RequestURI). + Msg("request authenticated") + + // set user into request context + ctx := NewUserContext(r.Context(), user) + req := r.WithContext(ctx) + + return user, true, req + } + } + + log.Debug(). + Err(errors.New(strings.Join(errMsgs, ", "))). + Str("component", "guard"). + Str("client", r.RemoteAddr). + Str("uri", r.RequestURI). + Msg("request not authenticated") + return nil, false, r +} + +func (gm guardMux) authorize(r *http.Request, user user.User) (bool, *http.Request) { + // log := logger.FromContext(r.Context()) + spanCtx, span := tracer.Start(r.Context(), "ranger.guard.authorize") + defer span.End() + + af := &authorization.AttributesRecord{ + User: user, + Action: r.Method, + Path: r.URL.Path, + } + + for i := range gm.authorizors { + authorizor := gm.authorizors[i] + _, authSpan := tracer.Start(spanCtx, "ranger.guard.authorize/hook/"+authorizor.Name()) + decision, _, err := authorizor.Authorize(af) + authSpan.End() + if err != nil { + continue + } + + if decision == authorization.DecisionAllow { + log.Debug().Str("component", "guard"). + Str("authorizor", authorizor.Name()). + Str("client", r.RemoteAddr). + Str("uri", r.RequestURI). + Str("subject", user.GetSubject()). + Str("issuer", user.GetIssuer()). + Str("email", user.GetEmail()). + Msg("request authorized") + return true, r + } + } + + log.Debug(). + Str("component", "guard"). + Str("client", r.RemoteAddr). + Str("subject", user.GetSubject()). + Str("issuer", user.GetIssuer()). + Str("email", user.GetEmail()). + Str("uri", r.RequestURI). + Msg("request not authorized") + return false, r +} diff --git a/plugins/rangerguard/mux_test.go b/plugins/rangerguard/mux_test.go new file mode 100644 index 0000000..f34515b --- /dev/null +++ b/plugins/rangerguard/mux_test.go @@ -0,0 +1,137 @@ +package rangerguard_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc/codes" + helloworld "go.mondoo.com/ranger-rpc/examples/rangerguard" + "go.mondoo.com/ranger-rpc/plugins/authentication" + "go.mondoo.com/ranger-rpc/plugins/authentication/defaultuser" + "go.mondoo.com/ranger-rpc/plugins/authorization" + "go.mondoo.com/ranger-rpc/plugins/authorization/always" + "go.mondoo.com/ranger-rpc/plugins/rangerguard" + "go.mondoo.com/ranger-rpc/status" +) + +func createGuardServer(authenticators []authentication.Authenticator, authorizors []authorization.Authorizor) (*httptest.Server, error) { + helloHandler := helloworld.NewHelloWorldServer(&helloworld.HelloWorldServerImpl{}) + // You can use any mux you like - NewHelloWorldServer gives you an http.Handler. + mux := http.NewServeMux() + mux.Handle("/hello/", http.StripPrefix("/hello", helloHandler)) + + // Start a local HTTP server + server := httptest.NewServer(rangerguard.New(rangerguard.Options{ + Authenticators: authenticators, + Authorizors: authorizors, + }, mux)) + return server, nil +} + +func TestGuardWithNoNilAuth(t *testing.T) { + server, err := createGuardServer(nil, nil) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "non-ok http request") + + s, _ := status.FromError(err) + assert.Equal(t, codes.Unauthenticated, s.Code(), "has correct error code") + + assert.Equal(t, "", protoResp.Text, "get expected service response") +} + +func TestGuardWithNoAuth(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{}, []authorization.Authorizor{}) + if err != nil { + t.Fatal(err) + } + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "non-ok http request") + + s, _ := status.FromError(err) + assert.Equal(t, codes.Unauthenticated, s.Code(), "has correct error code") + + assert.Equal(t, "", protoResp.Text, "get expected service response") +} + +func TestGuardWithNoAuthentication(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.NotNil(t, err, "non-ok http request") + + s, _ := status.FromError(err) + assert.Equal(t, codes.Unauthenticated, s.Code(), "has correct error code") + assert.Equal(t, "", protoResp.Text, "get expected service response") +} + +func TestGuardWithNoAuthorization(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.Anonymous()}, []authorization.Authorizor{}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + assert.NotNil(t, err, "non-ok http request") + + s, _ := status.FromError(err) + assert.Equal(t, codes.PermissionDenied, s.Code()) + + assert.Equal(t, "", protoResp.Text, "get expected service response") +} + +func TestGuardWithAuthnAndAuthz(t *testing.T) { + server, err := createGuardServer([]authentication.Authenticator{defaultuser.Anonymous()}, []authorization.Authorizor{always.Allow()}) + require.NoError(t, err) + + // Close the server when test finishes + defer server.Close() + + // do client request with signed jwt token + protoClient, err := helloworld.NewHelloWorldClient(server.URL+"/hello/", &http.Client{}) + require.NoError(t, err) + + data := &helloworld.HelloReq{Subject: "World"} + protoResp, err := protoClient.Hello(context.Background(), data) + + assert.Nil(t, err, "service returns without error") + assert.Equal(t, "Hello World", protoResp.Text, "get expected service response") +} diff --git a/plugins/rangerguard/user/claim.go b/plugins/rangerguard/user/claim.go new file mode 100644 index 0000000..a805941 --- /dev/null +++ b/plugins/rangerguard/user/claim.go @@ -0,0 +1,102 @@ +package user + +import ( + "encoding/json" + "strings" + + "github.com/cockroachdb/errors" +) + +// default claim names +const ( + ClaimIssuer = "iss" + ClaimSubject = "sub" + ClaimEmail = "email" + ClaimEmailVerified = "email_verified" + ClaimName = "name" + ClaimGivenName = "given_name" + ClaimFamilyName = "family_name" + ClaimGroups = "groups" + ClaimPicture = "picture" +) + +type Claims map[string]json.RawMessage + +func (c Claims) UnmarshalClaim(id string, v interface{}) error { + val, ok := c[id] + if !ok { + return errors.New("claim " + id + " not present") + } + return json.Unmarshal([]byte(val), v) +} + +func (c Claims) HasClaim(id string) bool { + if _, ok := c[id]; !ok { + return false + } + return true +} + +// ParseClaims extracts basic information from the claims +// standard claims are defined in https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +// we follow the required fields from google https://developers.google.com/identity/protocols/OpenIDConnect#server-flow +// required claims: iss, sub +// optional claims: name, email, email_verified +func ParseClaims(c *Claims) (User, error) { + user := UserInfo{} + + // the default claim is "sub" + if c.HasClaim(ClaimIssuer) { + var issuer string + if err := c.UnmarshalClaim(ClaimIssuer, &issuer); err != nil { + return nil, err + } + user.Issuer = issuer + } + + if c.HasClaim(ClaimSubject) { + var subject string + if err := c.UnmarshalClaim(ClaimSubject, &subject); err != nil { + return nil, err + } + user.Subject = subject + } + + // if a human, we may have a name field + if c.HasClaim(ClaimName) { + var name string + if err := c.UnmarshalClaim(ClaimName, &name); err != nil { + return nil, err + } + user.Name = name + } + + // If the email claim is used, email_verified claim needs to be present + // that is not true for all implementations, lets follow kubernetes approach + // https://github.com/kubernetes/kubernetes/issues/59496 + if c.HasClaim(ClaimEmail) { + var email string + if err := c.UnmarshalClaim(ClaimEmail, &email); err != nil { + return nil, err + } + user.Email = strings.ToLower(email) + } + + if c.HasClaim(ClaimEmailVerified) { + var emailVerified bool + if err := c.UnmarshalClaim(ClaimEmailVerified, &emailVerified); err != nil { + return nil, errors.Wrap(err, "guuard oidc> could not parse 'email_verified' claim") + } + } + + // if the claims include groups, we parse them too + if c.HasClaim(ClaimGroups) { + var groups []string + if err := c.UnmarshalClaim(ClaimGroups, &groups); err != nil { + return nil, err + } + user.Groups = groups + } + + return &user, nil +} diff --git a/plugins/rangerguard/user/claim_test.go b/plugins/rangerguard/user/claim_test.go new file mode 100644 index 0000000..7b858cc --- /dev/null +++ b/plugins/rangerguard/user/claim_test.go @@ -0,0 +1,39 @@ +package user_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mondoo.com/ranger-rpc/plugins/rangerguard/user" + "gopkg.in/square/go-jose.v2/jwt" +) + +func TestClaimParser(t *testing.T) { + // create a real jwt claims object + cl := jwt.Claims{ + Subject: "subject", + Issuer: "issuer", + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + // valid for 60 seconds + Expiry: jwt.NewNumericDate(time.Now().Add(time.Duration(60) * time.Second)), + } + + // marshal claims to json as done in JWT + data, err := json.Marshal(cl) + require.NoError(t, err) + + // unmarshal json to our claims object + c := user.Claims{} + err = json.Unmarshal(data, &c) + require.NoError(t, err) + + identity, err := user.ParseClaims(&c) + require.NoError(t, err) + + assert.Equal(t, "subject", identity.GetSubject()) + assert.Equal(t, "issuer", identity.GetIssuer()) +} diff --git a/plugins/rangerguard/user/static.go b/plugins/rangerguard/user/static.go new file mode 100644 index 0000000..7b97c61 --- /dev/null +++ b/plugins/rangerguard/user/static.go @@ -0,0 +1,57 @@ +package user + +var Anonymous = &UserInfo{ + Subject: "anonymous", + Name: "anonymous", + Issuer: "system/anonymous", +} + +var SystemAdmin = &UserInfo{ + Subject: "admin", + Issuer: "system/admin", + Name: "system-admin", +} + +// DefaultInfo provides a simple user information exchange object +type UserInfo struct { + Mrn string + Issuer string + Subject string + Name string + Email string + Groups []string + SignInProvider string + Labels map[string]string +} + +func (i *UserInfo) GetIssuer() string { + return i.Issuer +} + +func (i *UserInfo) GetSubject() string { + return i.Subject +} + +func (i *UserInfo) GetName() string { + return i.Name +} + +func (i *UserInfo) GetEmail() string { + return i.Email +} + +func (i *UserInfo) GetGroups() []string { + return i.Groups +} + +func (i *UserInfo) GetSignInProvider() string { + return i.SignInProvider +} + +func (i *UserInfo) GetLabels() map[string]string { + return i.Labels +} + +func (i *UserInfo) GetMrn() string { + return i.Mrn +} diff --git a/plugins/rangerguard/user/userinfo.go b/plugins/rangerguard/user/userinfo.go new file mode 100644 index 0000000..4afa44e --- /dev/null +++ b/plugins/rangerguard/user/userinfo.go @@ -0,0 +1,28 @@ +package user + +// User describes the authenticated user +type User interface { + // GetIssuer returns the issuer of the subject id + GetIssuer() string + + // GetSubject returns a unique user id, it is expected to stay stable + GetSubject() string + + // GetName returns a human-readable name of the user + GetName() string + + // GetEmail returns the users email, only if available and verified + GetEmail() string + + // GetGroups returns the names of the groups the user is a member of + GetGroups() []string + + // GetSignInProvider returns the sign-in provider that authenticated the user + GetSignInProvider() string + + // GetLabels returns customer information for the user + GetLabels() map[string]string + + // GetMrn returns the mrn set in the claims, or an empty string if non is found + GetMrn() string +}