diff --git a/api/v1/project_member.pb.go b/api/v1/project_member.pb.go index 1611dd7..556b3b3 100644 --- a/api/v1/project_member.pb.go +++ b/api/v1/project_member.pb.go @@ -23,10 +23,12 @@ const ( // ProjectMember is the database model type ProjectMember struct { - state protoimpl.MessageState `protogen:"open.v1"` - Meta *Meta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` - ProjectId string `protobuf:"bytes,2,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` - TenantId string `protobuf:"bytes,4,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Meta *Meta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + ProjectId string `protobuf:"bytes,2,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + TenantId string `protobuf:"bytes,4,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + // Namespace introduces the possibility to associate memberships for different applications that use the masterdata-api as a backend. + Namespace string `protobuf:"bytes,5,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -82,6 +84,13 @@ func (x *ProjectMember) GetTenantId() string { return "" } +func (x *ProjectMember) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type ProjectMemberCreateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProjectMember *ProjectMember `protobuf:"bytes,1,opt,name=project_member,json=projectMember,proto3" json:"project_member,omitempty"` @@ -263,6 +272,7 @@ type ProjectMemberFindRequest struct { ProjectId *string `protobuf:"bytes,1,opt,name=project_id,json=projectId,proto3,oneof" json:"project_id,omitempty"` TenantId *string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3,oneof" json:"tenant_id,omitempty"` Annotations map[string]string `protobuf:"bytes,6,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Namespace string `protobuf:"bytes,7,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -318,6 +328,13 @@ func (x *ProjectMemberFindRequest) GetAnnotations() map[string]string { return nil } +func (x *ProjectMemberFindRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type ProjectMemberResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ProjectMember *ProjectMember `protobuf:"bytes,1,opt,name=project_member,json=projectMember,proto3" json:"project_member,omitempty"` @@ -410,12 +427,13 @@ var File_v1_project_member_proto protoreflect.FileDescriptor const file_v1_project_member_proto_rawDesc = "" + "\n" + - "\x17v1/project_member.proto\x12\x02v1\x1a\rv1/meta.proto\"i\n" + + "\x17v1/project_member.proto\x12\x02v1\x1a\rv1/meta.proto\"\x87\x01\n" + "\rProjectMember\x12\x1c\n" + "\x04meta\x18\x01 \x01(\v2\b.v1.MetaR\x04meta\x12\x1d\n" + "\n" + "project_id\x18\x02 \x01(\tR\tprojectId\x12\x1b\n" + - "\ttenant_id\x18\x04 \x01(\tR\btenantId\"V\n" + + "\ttenant_id\x18\x04 \x01(\tR\btenantId\x12\x1c\n" + + "\tnamespace\x18\x05 \x01(\tR\tnamespace\"V\n" + "\x1aProjectMemberCreateRequest\x128\n" + "\x0eproject_member\x18\x01 \x01(\v2\x11.v1.ProjectMemberR\rprojectMember\"V\n" + "\x1aProjectMemberUpdateRequest\x128\n" + @@ -423,12 +441,13 @@ const file_v1_project_member_proto_rawDesc = "" + "\x1aProjectMemberDeleteRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\")\n" + "\x17ProjectMemberGetRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x8e\x02\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xac\x02\n" + "\x18ProjectMemberFindRequest\x12\"\n" + "\n" + "project_id\x18\x01 \x01(\tH\x00R\tprojectId\x88\x01\x01\x12 \n" + "\ttenant_id\x18\x02 \x01(\tH\x01R\btenantId\x88\x01\x01\x12O\n" + - "\vannotations\x18\x06 \x03(\v2-.v1.ProjectMemberFindRequest.AnnotationsEntryR\vannotations\x1a>\n" + + "\vannotations\x18\x06 \x03(\v2-.v1.ProjectMemberFindRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + + "\tnamespace\x18\a \x01(\tR\tnamespace\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\r\n" + diff --git a/api/v1/tenant.pb.go b/api/v1/tenant.pb.go index 4b0c73e..c3907f3 100644 --- a/api/v1/tenant.pb.go +++ b/api/v1/tenant.pb.go @@ -27,6 +27,7 @@ type FindParticipatingProjectsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` IncludeInherited *bool `protobuf:"varint,2,opt,name=include_inherited,json=includeInherited,proto3,oneof" json:"include_inherited,omitempty"` + Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -75,10 +76,18 @@ func (x *FindParticipatingProjectsRequest) GetIncludeInherited() bool { return false } +func (x *FindParticipatingProjectsRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type FindParticipatingTenantsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` IncludeInherited *bool `protobuf:"varint,2,opt,name=include_inherited,json=includeInherited,proto3,oneof" json:"include_inherited,omitempty"` + Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -127,10 +136,18 @@ func (x *FindParticipatingTenantsRequest) GetIncludeInherited() bool { return false } +func (x *FindParticipatingTenantsRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type ListTenantMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` IncludeInherited *bool `protobuf:"varint,2,opt,name=include_inherited,json=includeInherited,proto3,oneof" json:"include_inherited,omitempty"` + Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -179,6 +196,13 @@ func (x *ListTenantMembersRequest) GetIncludeInherited() bool { return false } +func (x *ListTenantMembersRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type ListTenantMembersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Tenants []*TenantWithMembershipAnnotations `protobuf:"bytes,1,rep,name=tenants,proto3" json:"tenants,omitempty"` @@ -951,18 +975,21 @@ var File_v1_tenant_proto protoreflect.FileDescriptor const file_v1_tenant_proto_rawDesc = "" + "\n" + - "\x0fv1/tenant.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x0fv1/common.proto\x1a\fv1/iam.proto\x1a\rv1/meta.proto\x1a\x10v1/project.proto\x1a\x0ev1/quota.proto\"\x87\x01\n" + + "\x0fv1/tenant.proto\x12\x02v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x0fv1/common.proto\x1a\fv1/iam.proto\x1a\rv1/meta.proto\x1a\x10v1/project.proto\x1a\x0ev1/quota.proto\"\xa5\x01\n" + " FindParticipatingProjectsRequest\x12\x1b\n" + "\ttenant_id\x18\x01 \x01(\tR\btenantId\x120\n" + - "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01B\x14\n" + - "\x12_include_inherited\"\x86\x01\n" + + "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01\x12\x1c\n" + + "\tnamespace\x18\x03 \x01(\tR\tnamespaceB\x14\n" + + "\x12_include_inherited\"\xa4\x01\n" + "\x1fFindParticipatingTenantsRequest\x12\x1b\n" + "\ttenant_id\x18\x01 \x01(\tR\btenantId\x120\n" + - "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01B\x14\n" + - "\x12_include_inherited\"\x7f\n" + + "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01\x12\x1c\n" + + "\tnamespace\x18\x03 \x01(\tR\tnamespaceB\x14\n" + + "\x12_include_inherited\"\x9d\x01\n" + "\x18ListTenantMembersRequest\x12\x1b\n" + "\ttenant_id\x18\x01 \x01(\tR\btenantId\x120\n" + - "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01B\x14\n" + + "\x11include_inherited\x18\x02 \x01(\bH\x00R\x10includeInherited\x88\x01\x01\x12\x1c\n" + + "\tnamespace\x18\x03 \x01(\tR\tnamespaceB\x14\n" + "\x12_include_inherited\"Z\n" + "\x19ListTenantMembersResponse\x12=\n" + "\atenants\x18\x01 \x03(\v2#.v1.TenantWithMembershipAnnotationsR\atenants\"e\n" + diff --git a/api/v1/tenant_member.pb.go b/api/v1/tenant_member.pb.go index fb27b51..bc6e8e7 100644 --- a/api/v1/tenant_member.pb.go +++ b/api/v1/tenant_member.pb.go @@ -28,7 +28,9 @@ type TenantMember struct { // TenantId is the id of the parent tenant TenantId string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` // MemberId is the id of the member tenant - MemberId string `protobuf:"bytes,3,opt,name=member_id,json=memberId,proto3" json:"member_id,omitempty"` + MemberId string `protobuf:"bytes,3,opt,name=member_id,json=memberId,proto3" json:"member_id,omitempty"` + // Namespace introduces the possibility to associate memberships for different applications that use the masterdata-api as a backend. + Namespace string `protobuf:"bytes,4,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -84,6 +86,13 @@ func (x *TenantMember) GetMemberId() string { return "" } +func (x *TenantMember) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type TenantMemberCreateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TenantMember *TenantMember `protobuf:"bytes,1,opt,name=tenant_member,json=tenantMember,proto3" json:"tenant_member,omitempty"` @@ -265,6 +274,7 @@ type TenantMemberFindRequest struct { TenantId *string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3,oneof" json:"tenant_id,omitempty"` MemberId *string `protobuf:"bytes,2,opt,name=member_id,json=memberId,proto3,oneof" json:"member_id,omitempty"` Annotations map[string]string `protobuf:"bytes,6,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Namespace string `protobuf:"bytes,7,opt,name=namespace,proto3" json:"namespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -320,6 +330,13 @@ func (x *TenantMemberFindRequest) GetAnnotations() map[string]string { return nil } +func (x *TenantMemberFindRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + type TenantMemberResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TenantMember *TenantMember `protobuf:"bytes,1,opt,name=tenant_member,json=tenantMember,proto3" json:"tenant_member,omitempty"` @@ -412,11 +429,12 @@ var File_v1_tenant_member_proto protoreflect.FileDescriptor const file_v1_tenant_member_proto_rawDesc = "" + "\n" + - "\x16v1/tenant_member.proto\x12\x02v1\x1a\rv1/meta.proto\"f\n" + + "\x16v1/tenant_member.proto\x12\x02v1\x1a\rv1/meta.proto\"\x84\x01\n" + "\fTenantMember\x12\x1c\n" + "\x04meta\x18\x01 \x01(\v2\b.v1.MetaR\x04meta\x12\x1b\n" + "\ttenant_id\x18\x02 \x01(\tR\btenantId\x12\x1b\n" + - "\tmember_id\x18\x03 \x01(\tR\bmemberId\"R\n" + + "\tmember_id\x18\x03 \x01(\tR\bmemberId\x12\x1c\n" + + "\tnamespace\x18\x04 \x01(\tR\tnamespace\"R\n" + "\x19TenantMemberCreateRequest\x125\n" + "\rtenant_member\x18\x01 \x01(\v2\x10.v1.TenantMemberR\ftenantMember\"R\n" + "\x19TenantMemberUpdateRequest\x125\n" + @@ -424,11 +442,12 @@ const file_v1_tenant_member_proto_rawDesc = "" + "\x19TenantMemberDeleteRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"(\n" + "\x16TenantMemberGetRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x89\x02\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xa7\x02\n" + "\x17TenantMemberFindRequest\x12 \n" + "\ttenant_id\x18\x01 \x01(\tH\x00R\btenantId\x88\x01\x01\x12 \n" + "\tmember_id\x18\x02 \x01(\tH\x01R\bmemberId\x88\x01\x01\x12N\n" + - "\vannotations\x18\x06 \x03(\v2,.v1.TenantMemberFindRequest.AnnotationsEntryR\vannotations\x1a>\n" + + "\vannotations\x18\x06 \x03(\v2,.v1.TenantMemberFindRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + + "\tnamespace\x18\a \x01(\tR\tnamespace\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\f\n" + diff --git a/client/main.go b/client/main.go index 20f252d..b7cd5bb 100644 --- a/client/main.go +++ b/client/main.go @@ -29,9 +29,17 @@ func main() { hmacKey = auth.HmacDefaultKey } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - c, err := client.NewClient(ctx, "localhost", 50051, "certs/client.pem", "certs/client-key.pem", "certs/ca.pem", hmacKey, true, logger) + c, err := client.NewClient(&client.Config{ + Logger: logger, + Hostname: "localhost", + Port: 50051, + CertFile: "certs/client.pem", + KeyFile: "certs/client-key.pem", + CaFile: "certs/ca.pem", + Insecure: true, + HmacKey: hmacKey, + Namespace: "test", + }) if err != nil { logger.Error(err.Error()) panic(err) diff --git a/pkg/client/client.go b/pkg/client/client.go index ea2a933..5353082 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -4,16 +4,16 @@ import ( "context" "crypto/tls" "crypto/x509" - "errors" "fmt" "log/slog" "os" - "github.com/metal-stack/masterdata-api/pkg/auth" "google.golang.org/grpc" "google.golang.org/grpc/credentials" + grpcinsecure "google.golang.org/grpc/credentials/insecure" v1 "github.com/metal-stack/masterdata-api/api/v1" + "github.com/metal-stack/masterdata-api/pkg/auth" ) // Client defines the client API @@ -33,17 +33,59 @@ type GRPCClient struct { hmacKey string } +type Config struct { + Logger *slog.Logger + + Hostname string + Port uint + + CertFile string + KeyFile string + CaFile string + Insecure bool + + HmacKey string + + // Namespace if set adds this namespace to namespaced requests such that it does not need to be passed all the time + Namespace string +} + +func (c *Config) validate() error { + if c == nil { + return fmt.Errorf("config must not be nil") + } + + if c.Hostname == "" { + return fmt.Errorf("hostname must be specified") + } + + if c.KeyFile != "" || c.CertFile != "" { + if c.KeyFile == "" || c.CertFile == "" { + return fmt.Errorf("either both key and cert file must be specified or none of them") + } + } + + return nil +} + // NewClient creates a new client for the services for the given address, with the certificate and hmac. -func NewClient(ctx context.Context, hostname string, port int, certFile string, keyFile string, caFile string, hmacKey string, insecure bool, logger *slog.Logger) (Client, error) { +func NewClient(config *Config) (Client, error) { + if err := config.validate(); err != nil { + return nil, err + } - address := fmt.Sprintf("%s:%d", hostname, port) + if config.Logger == nil { + config.Logger = slog.Default() + } + + address := fmt.Sprintf("%s:%d", config.Hostname, config.Port) certPool, err := x509.SystemCertPool() if err != nil { return nil, fmt.Errorf("failed to load system credentials: %w", err) } - if caFile != "" { + if caFile := config.CaFile; caFile != "" { ca, err := os.ReadFile(caFile) if err != nil { return nil, fmt.Errorf("could not read ca certificate: %w", err) @@ -55,56 +97,110 @@ func NewClient(ctx context.Context, hostname string, port int, certFile string, } } - clientCertificate, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, fmt.Errorf("could not load client key pair: %w", err) - } + var ( + certificates []tls.Certificate + opts []grpc.DialOption + ) - creds := credentials.NewTLS(&tls.Config{ - ServerName: hostname, - Certificates: []tls.Certificate{clientCertificate}, - RootCAs: certPool, - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: insecure, // nolint:gosec - }) + if config.CertFile != "" && config.KeyFile != "" { + clientCertificate, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile) + if err != nil { + return nil, fmt.Errorf("could not load client key pair: %w", err) + } - if hmacKey == "" { - return nil, errors.New("no hmac-key specified") + certificates = append(certificates, clientCertificate) + + creds := credentials.NewTLS(&tls.Config{ + ServerName: config.Hostname, + Certificates: certificates, + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: config.Insecure, // nolint:gosec + }) + + opts = append(opts, + // oauth.NewOauthAccess requires the configuration of transport + // credentials. + grpc.WithTransportCredentials(creds), + ) + } else { + opts = append(opts, + grpc.WithTransportCredentials(grpcinsecure.NewCredentials()), + ) } client := GRPCClient{ - log: logger, - hmacKey: hmacKey, + log: config.Logger, } - // Set up the credentials for the connection. - perRPCHMACAuthenticator, err := auth.NewHMACAuther(hmacKey, auth.EditUser) - if err != nil { - return nil, fmt.Errorf("failed to create hmac-authenticator: %w", err) + if config.HmacKey != "" { + client.hmacKey = config.HmacKey + + // Set up the credentials for the connection. + perRPCHMACAuthenticator, err := auth.NewHMACAuther(config.HmacKey, auth.EditUser) + if err != nil { + return nil, fmt.Errorf("failed to create hmac-authenticator: %w", err) + } + + opts = append(opts, + // In addition to the following grpc.DialOption, callers may also use + // the grpc.CallOption grpc.PerRPCCredentials with the RPC invocation + // itself. + // See: https://godoc.org/google.golang.org/grpc#PerRPCCredentials + grpc.WithPerRPCCredentials(perRPCHMACAuthenticator)) } - opts := []grpc.DialOption{ - // In addition to the following grpc.DialOption, callers may also use - // the grpc.CallOption grpc.PerRPCCredentials with the RPC invocation - // itself. - // See: https://godoc.org/google.golang.org/grpc#PerRPCCredentials - grpc.WithPerRPCCredentials(perRPCHMACAuthenticator), - // oauth.NewOauthAccess requires the configuration of transport - // credentials. - grpc.WithTransportCredentials(creds), - - // grpc.WithInsecure(), + if config.Namespace != "" { + opts = append(opts, NamespaceInterceptor(config.Namespace)) } + // Set up a connection to the server. conn, err := grpc.NewClient(address, opts...) if err != nil { return nil, err } + client.conn = conn return client, nil } +func NamespaceInterceptor(namespace string) grpc.DialOption { + return grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch r := req.(type) { + case *v1.TenantMemberCreateRequest: + if r.TenantMember.Namespace == "" { + r.TenantMember.Namespace = namespace + } + case *v1.ProjectMemberCreateRequest: + if r.ProjectMember.Namespace == "" { + r.ProjectMember.Namespace = namespace + } + case *v1.TenantMemberFindRequest: + if r.Namespace == "" { + r.Namespace = namespace + } + case *v1.ProjectMemberFindRequest: + if r.Namespace == "" { + r.Namespace = namespace + } + case *v1.FindParticipatingProjectsRequest: + if r.Namespace == "" { + r.Namespace = namespace + } + case *v1.FindParticipatingTenantsRequest: + if r.Namespace == "" { + r.Namespace = namespace + } + case *v1.ListTenantMembersRequest: + if r.Namespace == "" { + r.Namespace = namespace + } + } + return invoker(ctx, method, req, reply, cc, opts...) + }) +} + // Close the underlying connection func (c GRPCClient) Close() error { return c.conn.Close() diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..4eece9c --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,211 @@ +package client + +import ( + "context" + "log/slog" + "net" + "strconv" + "testing" + + v1 "github.com/metal-stack/masterdata-api/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +func Test_Client(t *testing.T) { + const ( + namespace = "a" + ) + + var ( + log = slog.Default() + grpcServer = grpc.NewServer() + projectMemberServer = &projectMemberServer{} + tenantMemberServer = &tenantMemberServer{} + ) + + v1.RegisterProjectMemberServiceServer(grpcServer, projectMemberServer) + v1.RegisterTenantMemberServiceServer(grpcServer, tenantMemberServer) + + reflection.Register(grpcServer) + + lis, err := net.Listen("tcp", "") + require.NoError(t, err) + + go func() { + err = grpcServer.Serve(lis) + require.NoError(t, err) + }() + defer func() { + grpcServer.Stop() + }() + + _, portString, err := net.SplitHostPort(lis.Addr().String()) + require.NoError(t, err) + + port, err := strconv.Atoi(portString) + require.NoError(t, err) + + client, err := NewClient(&Config{ + Hostname: "localhost", + Port: uint(port), + Insecure: true, + Logger: log, + Namespace: namespace, + }) + require.NoError(t, err) + + t.Run("check namespace interceptor sets missing namespace", func(t *testing.T) { + t.Run("project member", func(t *testing.T) { + projectMemberServer.create = func(ctx context.Context, pmcr *v1.ProjectMemberCreateRequest) (*v1.ProjectMemberResponse, error) { + assert.Equal(t, "project-a", pmcr.ProjectMember.ProjectId) + assert.Equal(t, "tenant-a", pmcr.ProjectMember.TenantId) + assert.Equal(t, namespace, pmcr.ProjectMember.Namespace) + return &v1.ProjectMemberResponse{}, nil + } + projectMemberServer.find = func(ctx context.Context, pmfr *v1.ProjectMemberFindRequest) (*v1.ProjectMemberListResponse, error) { + assert.Equal(t, namespace, pmfr.Namespace) + return &v1.ProjectMemberListResponse{}, nil + } + + _, err = client.ProjectMember().Create(t.Context(), &v1.ProjectMemberCreateRequest{ + ProjectMember: &v1.ProjectMember{ + ProjectId: "project-a", + TenantId: "tenant-a", + }, + }) + require.NoError(t, err) + + _, err = client.ProjectMember().Find(t.Context(), &v1.ProjectMemberFindRequest{}) + require.NoError(t, err) + }) + + t.Run("tenant member", func(t *testing.T) { + tenantMemberServer.create = func(ctx context.Context, tmcr *v1.TenantMemberCreateRequest) (*v1.TenantMemberResponse, error) { + assert.Equal(t, "tenant-a", tmcr.TenantMember.TenantId) + assert.Equal(t, namespace, tmcr.TenantMember.Namespace) + return &v1.TenantMemberResponse{}, nil + } + tenantMemberServer.find = func(ctx context.Context, tmfr *v1.TenantMemberFindRequest) (*v1.TenantMemberListResponse, error) { + assert.Equal(t, namespace, tmfr.Namespace) + return &v1.TenantMemberListResponse{}, nil + } + + _, err = client.TenantMember().Create(t.Context(), &v1.TenantMemberCreateRequest{ + TenantMember: &v1.TenantMember{ + TenantId: "tenant-a", + }, + }) + require.NoError(t, err) + + _, err = client.TenantMember().Find(t.Context(), &v1.TenantMemberFindRequest{}) + require.NoError(t, err) + }) + }) + + t.Run("check explicit namespace can be set anyway", func(t *testing.T) { + t.Run("project member", func(t *testing.T) { + projectMemberServer.create = func(ctx context.Context, pmcr *v1.ProjectMemberCreateRequest) (*v1.ProjectMemberResponse, error) { + assert.Equal(t, "project-a", pmcr.ProjectMember.ProjectId) + assert.Equal(t, "tenant-a", pmcr.ProjectMember.TenantId) + assert.Equal(t, "b", pmcr.ProjectMember.Namespace) + return &v1.ProjectMemberResponse{}, nil + } + projectMemberServer.find = func(ctx context.Context, pmfr *v1.ProjectMemberFindRequest) (*v1.ProjectMemberListResponse, error) { + assert.Equal(t, "b", pmfr.Namespace) + return &v1.ProjectMemberListResponse{}, nil + } + + _, err = client.ProjectMember().Create(t.Context(), &v1.ProjectMemberCreateRequest{ + ProjectMember: &v1.ProjectMember{ + ProjectId: "project-a", + TenantId: "tenant-a", + Namespace: "b", + }, + }) + require.NoError(t, err) + + _, err = client.ProjectMember().Find(t.Context(), &v1.ProjectMemberFindRequest{ + Namespace: "b", + }) + require.NoError(t, err) + }) + + t.Run("tenant member", func(t *testing.T) { + tenantMemberServer.create = func(ctx context.Context, tmcr *v1.TenantMemberCreateRequest) (*v1.TenantMemberResponse, error) { + assert.Equal(t, "tenant-a", tmcr.TenantMember.TenantId) + assert.Equal(t, "b", tmcr.TenantMember.Namespace) + return &v1.TenantMemberResponse{}, nil + } + tenantMemberServer.find = func(ctx context.Context, tmfr *v1.TenantMemberFindRequest) (*v1.TenantMemberListResponse, error) { + assert.Equal(t, "b", tmfr.Namespace) + return &v1.TenantMemberListResponse{}, nil + } + + _, err = client.TenantMember().Create(t.Context(), &v1.TenantMemberCreateRequest{ + TenantMember: &v1.TenantMember{ + TenantId: "tenant-a", + Namespace: "b", + }, + }) + require.NoError(t, err) + + _, err = client.TenantMember().Find(t.Context(), &v1.TenantMemberFindRequest{ + Namespace: "b", + }) + require.NoError(t, err) + }) + }) +} + +type projectMemberServer struct { + create func(context.Context, *v1.ProjectMemberCreateRequest) (*v1.ProjectMemberResponse, error) + find func(context.Context, *v1.ProjectMemberFindRequest) (*v1.ProjectMemberListResponse, error) +} + +func (t *projectMemberServer) Create(ctx context.Context, r *v1.ProjectMemberCreateRequest) (*v1.ProjectMemberResponse, error) { + return t.create(ctx, r) +} + +func (t *projectMemberServer) Delete(context.Context, *v1.ProjectMemberDeleteRequest) (*v1.ProjectMemberResponse, error) { + panic("unimplemented") +} + +func (t *projectMemberServer) Find(ctx context.Context, r *v1.ProjectMemberFindRequest) (*v1.ProjectMemberListResponse, error) { + return t.find(ctx, r) +} + +func (t *projectMemberServer) Get(context.Context, *v1.ProjectMemberGetRequest) (*v1.ProjectMemberResponse, error) { + panic("unimplemented") +} + +func (t *projectMemberServer) Update(context.Context, *v1.ProjectMemberUpdateRequest) (*v1.ProjectMemberResponse, error) { + panic("unimplemented") +} + +type tenantMemberServer struct { + create func(context.Context, *v1.TenantMemberCreateRequest) (*v1.TenantMemberResponse, error) + find func(context.Context, *v1.TenantMemberFindRequest) (*v1.TenantMemberListResponse, error) +} + +func (t *tenantMemberServer) Create(ctx context.Context, r *v1.TenantMemberCreateRequest) (*v1.TenantMemberResponse, error) { + return t.create(ctx, r) +} + +func (t *tenantMemberServer) Delete(context.Context, *v1.TenantMemberDeleteRequest) (*v1.TenantMemberResponse, error) { + panic("unimplemented") +} + +func (t *tenantMemberServer) Find(ctx context.Context, r *v1.TenantMemberFindRequest) (*v1.TenantMemberListResponse, error) { + return t.find(ctx, r) +} + +func (t *tenantMemberServer) Get(context.Context, *v1.TenantMemberGetRequest) (*v1.TenantMemberResponse, error) { + panic("unimplemented") +} + +func (t *tenantMemberServer) Update(context.Context, *v1.TenantMemberUpdateRequest) (*v1.TenantMemberResponse, error) { + panic("unimplemented") +} diff --git a/pkg/datastore/query_runner.go b/pkg/datastore/query_runner.go index 5cfe9f6..3c10395 100644 --- a/pkg/datastore/query_runner.go +++ b/pkg/datastore/query_runner.go @@ -15,7 +15,7 @@ func RunQuery[E any](ctx context.Context, log *slog.Logger, db *sqlx.DB, builder } if log.Enabled(ctx, slog.LevelDebug) { - log.Debug("query", "sql", query, "values", vals) + log.Debug("query", "sql", query, "values", vals, "input", input) } rows, err := db.NamedQueryContext(ctx, query, input) diff --git a/pkg/service/projectmember.go b/pkg/service/projectmember.go index 4ba70fa..e3acf11 100644 --- a/pkg/service/projectmember.go +++ b/pkg/service/projectmember.go @@ -53,16 +53,36 @@ func (s *projectMemberService) Create(ctx context.Context, req *v1.ProjectMember err = s.projectMemberStore.Create(ctx, projectMember) return projectMember.NewProjectMemberResponse(), err } + func (s *projectMemberService) Update(ctx context.Context, req *v1.ProjectMemberUpdateRequest) (*v1.ProjectMemberResponse, error) { projectMember := req.ProjectMember - err := s.projectMemberStore.Update(ctx, projectMember) + + old, err := s.projectMemberStore.Get(ctx, projectMember.Meta.Id) + if err != nil { + return nil, err + } + + if old.ProjectId != projectMember.ProjectId { + return nil, status.Error(codes.InvalidArgument, "updating the project id of a project member is not allowed") + } + if old.TenantId != projectMember.TenantId { + return nil, status.Error(codes.InvalidArgument, "updating the tenant id of a project member is not allowed") + } + if old.Namespace != projectMember.Namespace { + return nil, status.Error(codes.InvalidArgument, "updating the namespace of a project member is not allowed") + } + + err = s.projectMemberStore.Update(ctx, projectMember) + return projectMember.NewProjectMemberResponse(), err } + func (s *projectMemberService) Delete(ctx context.Context, req *v1.ProjectMemberDeleteRequest) (*v1.ProjectMemberResponse, error) { projectMember := req.NewProjectMember() err := s.projectMemberStore.Delete(ctx, projectMember.Meta.Id) return projectMember.NewProjectMemberResponse(), err } + func (s *projectMemberService) Get(ctx context.Context, req *v1.ProjectMemberGetRequest) (*v1.ProjectMemberResponse, error) { projectMember, err := s.projectMemberStore.Get(ctx, req.Id) if err != nil { @@ -70,8 +90,11 @@ func (s *projectMemberService) Get(ctx context.Context, req *v1.ProjectMemberGet } return projectMember.NewProjectMemberResponse(), nil } + func (s *projectMemberService) Find(ctx context.Context, req *v1.ProjectMemberFindRequest) (*v1.ProjectMemberListResponse, error) { - filter := make(map[string]any) + filter := map[string]any{ + "COALESCE(projectmember ->> 'namespace', '')": req.Namespace, + } if req.ProjectId != nil { filter["projectmember ->> 'project_id'"] = req.ProjectId } @@ -83,11 +106,14 @@ func (s *projectMemberService) Find(ctx context.Context, req *v1.ProjectMemberFi f := fmt.Sprintf("projectmember -> 'meta' -> 'annotations' ->> '%s'", key) filter[f] = value } + res, _, err := s.projectMemberStore.Find(ctx, nil, filter) if err != nil { return nil, err } + resp := new(v1.ProjectMemberListResponse) resp.ProjectMembers = append(resp.ProjectMembers, res...) + return resp, nil } diff --git a/pkg/service/projectmember_test.go b/pkg/service/projectmember_test.go index 3268b38..5765820 100644 --- a/pkg/service/projectmember_test.go +++ b/pkg/service/projectmember_test.go @@ -3,15 +3,22 @@ package service import ( "context" "log/slog" + "slices" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" v1 "github.com/metal-stack/masterdata-api/api/v1" "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/metal-stack/metal-lib/pkg/testcommon" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/runtime/protoimpl" "testing" + "github.com/metal-stack/masterdata-api/pkg/datastore" "github.com/metal-stack/masterdata-api/pkg/test/mocks" ) @@ -46,41 +53,6 @@ func TestCreateProjectMember(t *testing.T) { assert.Equal(t, pmcr.ProjectMember.ProjectId, resp.GetProjectMember().GetProjectId()) } -func TestUpdateProjectMember(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.ProjectMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - projectStorageMock := mocks.NewMockStorage[*v1.Project](t) - ts := &projectMemberService{ - projectMemberStore: storageMock, - tenantStore: tenantStorageMock, - projectStore: projectStorageMock, - log: slog.Default(), - } - ctx := context.Background() - - meta := &v1.Meta{Id: "p2", Annotations: map[string]string{"key": "value"}} - pm1 := &v1.ProjectMember{ - Meta: meta, - ProjectId: "p1", - TenantId: "t1", - } - meta.Annotations = map[string]string{"key": "value2"} - pmur := &v1.ProjectMemberUpdateRequest{ - ProjectMember: &v1.ProjectMember{ - Meta: meta, - ProjectId: "p1", - TenantId: "t1", - }, - } - - storageMock.On("Update", ctx, pm1).Return(nil) - resp, err := ts.Update(ctx, pmur) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.NotNil(t, resp.GetProjectMember()) - assert.Equal(t, pmur.GetProjectMember().Meta.Annotations, resp.GetProjectMember().Meta.Annotations) -} - func TestDeleteProjectMember(t *testing.T) { storageMock := mocks.NewMockStorage[*v1.ProjectMember](t) tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) @@ -133,54 +105,472 @@ func TestGetProjectMember(t *testing.T) { assert.Equal(t, tgr.Id, resp.GetProjectMember().GetMeta().GetId()) } -func TestFindProjectMemberByProject(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.ProjectMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - projectStorageMock := mocks.NewMockStorage[*v1.Project](t) - ts := &projectMemberService{ - projectMemberStore: storageMock, - tenantStore: tenantStorageMock, - projectStore: projectStorageMock, - log: slog.Default(), +func TestFindProjectMember(t *testing.T) { + ctx := t.Context() + ves := []datastore.Entity{ + &v1.Project{}, + &v1.ProjectMember{}, + &v1.Tenant{}, + &v1.TenantMember{}, } - ctx := context.Background() - // filter by name - var t6s []*v1.ProjectMember - tfr := &v1.ProjectMemberFindRequest{ - ProjectId: pointer.Pointer("p1"), + container, db, err := StartPostgres(ctx, ves...) + require.NoError(t, err) + defer func() { + require.NoError(t, db.Close()) + require.NoError(t, container.Terminate(ctx)) + }() + + var ( + projectMemberStore = datastore.New(log, db, &v1.ProjectMember{}) + projectStore = datastore.New(log, db, &v1.Project{}) + tenantStore = datastore.New(log, db, &v1.Tenant{}) + + testTenant1 = &v1.Tenant{ + Meta: &v1.Meta{ + Id: "tenant-1", + Kind: "Tenant", + Apiversion: "v1", + Version: 1, + }, + Name: "tenant 1", + Description: "test tenant 1", + } + testTenant2 = &v1.Tenant{ + Meta: &v1.Meta{ + Id: "tenant-2", + Kind: "Tenant", + Apiversion: "v1", + Version: 1, + }, + Name: "tenant 2", + Description: "test tenant 2", + } + testProject1 = &v1.Project{ + Meta: &v1.Meta{ + Id: "project-1", + Kind: "Project", + Apiversion: "v1", + Version: 1, + }, + Name: "project 1", + Description: "test project 1", + TenantId: "tenant-1", + } + testProjectMember1 = &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + } + testProjectMember2 = &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "2", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "viewer", + }, + Labels: []string{"c", "d"}, + }, + ProjectId: "project-1", + TenantId: "tenant-2", + Namespace: "a", + } + testProjectMember3 = &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "3", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"e", "f"}, + }, + ProjectId: "project-2", + TenantId: "tenant-2", + Namespace: "a", + } + testProjectMember4 = &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "4", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + }, + ProjectId: "project-2", + TenantId: "tenant-2", + Namespace: "", + } + + service = &projectMemberService{ + log: log, + projectMemberStore: projectMemberStore, + tenantStore: tenantStore, + projectStore: projectStore, + } + ) + + tests := []struct { + name string + prepare func() + req *v1.ProjectMemberFindRequest + want *v1.ProjectMemberListResponse + wantErr error + }{ + { + name: "find by project", + req: &v1.ProjectMemberFindRequest{ + ProjectId: pointer.Pointer("project-1"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, projectStore.Create(ctx, testProject1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember2)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember3)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember4)) + }, + want: &v1.ProjectMemberListResponse{ + ProjectMembers: []*v1.ProjectMember{ + testProjectMember1, + testProjectMember2, + }, + }, + wantErr: nil, + }, + { + name: "find by project id (no results) #1", + req: &v1.ProjectMemberFindRequest{ + ProjectId: pointer.Pointer("no-result"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, projectStore.Create(ctx, testProject1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember2)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember3)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember4)) + }, + want: &v1.ProjectMemberListResponse{ + ProjectMembers: nil, + }, + wantErr: nil, + }, + { + name: "find by project id (no results) #2", + req: &v1.ProjectMemberFindRequest{ + ProjectId: pointer.Pointer("project-1"), + Namespace: "wrong-namespace", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, projectStore.Create(ctx, testProject1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember2)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember3)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember4)) + }, + want: &v1.ProjectMemberListResponse{ + ProjectMembers: nil, + }, + wantErr: nil, + }, + { + name: "find by tenant", + req: &v1.ProjectMemberFindRequest{ + TenantId: pointer.Pointer("tenant-2"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, projectStore.Create(ctx, testProject1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember2)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember3)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember4)) + }, + want: &v1.ProjectMemberListResponse{ + ProjectMembers: []*v1.ProjectMember{ + testProjectMember2, + testProjectMember3, + }, + }, + wantErr: nil, + }, + { + name: "find by annotation", + req: &v1.ProjectMemberFindRequest{ + Annotations: map[string]string{"role": "owner"}, + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, projectStore.Create(ctx, testProject1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember1)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember2)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember3)) + require.NoError(t, projectMemberStore.Create(ctx, testProjectMember4)) + }, + want: &v1.ProjectMemberListResponse{ + ProjectMembers: []*v1.ProjectMember{ + testProjectMember1, + testProjectMember3, + }, + }, + wantErr: nil, + }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, ve := range ves { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE "+ve.TableName()) + require.NoError(t, err) + } - f2 := make(map[string]any) - f2["projectmember ->> 'project_id'"] = pointer.Pointer("p1") - storageMock.On("Find", ctx, mock.AnythingOfType("*v1.Paging"), []any{f2}).Return(t6s, nil, nil) - resp, err := ts.Find(ctx, tfr) - require.NoError(t, err) - assert.NotNil(t, resp) -} + if tt.prepare != nil { + tt.prepare() + } -func TestFindProjectMemberByTenant(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.ProjectMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - projectStorageMock := mocks.NewMockStorage[*v1.Project](t) - ts := &projectMemberService{ - projectMemberStore: storageMock, - tenantStore: tenantStorageMock, - projectStore: projectStorageMock, - log: slog.Default(), + got, err := service.Find(ctx, tt.req) + if diff := cmp.Diff(err, tt.wantErr); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + return + } + + slices.SortFunc(got.ProjectMembers, func(i, j *v1.ProjectMember) int { + if i.Meta.Id < j.Meta.Id { + return -1 + } else { + return 1 + } + }) + + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreTypes(protoimpl.MessageState{}), cmpopts.IgnoreFields(v1.Meta{}, "CreatedTime"), testcommon.IgnoreUnexported()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } + }) } - ctx := context.Background() +} - // filter by name - var t6s []*v1.ProjectMember - tfr := &v1.ProjectMemberFindRequest{ - TenantId: pointer.Pointer("t1"), +func TestUpdateProjectMember(t *testing.T) { + ctx := t.Context() + ves := []datastore.Entity{ + &v1.Project{}, + &v1.ProjectMember{}, + &v1.Tenant{}, + &v1.TenantMember{}, } - f2 := make(map[string]any) - f2["projectmember ->> 'tenant_id'"] = pointer.Pointer("t1") - storageMock.On("Find", ctx, mock.AnythingOfType("*v1.Paging"), []any{f2}).Return(t6s, nil, nil) - resp, err := ts.Find(ctx, tfr) + container, db, err := StartPostgres(ctx, ves...) require.NoError(t, err) - assert.NotNil(t, resp) + defer func() { + require.NoError(t, db.Close()) + require.NoError(t, container.Terminate(ctx)) + }() + + var ( + projectMemberStore = datastore.New(log, db, &v1.ProjectMember{}) + projectStore = datastore.New(log, db, &v1.Project{}) + tenantStore = datastore.New(log, db, &v1.Tenant{}) + + service = &projectMemberService{ + log: log, + projectMemberStore: projectMemberStore, + tenantStore: tenantStore, + projectStore: projectStore, + } + ) + + tests := []struct { + name string + prepare func() + req *v1.ProjectMemberUpdateRequest + want *v1.ProjectMemberResponse + wantErr error + }{ + { + name: "update mutable fields", + req: &v1.ProjectMemberUpdateRequest{ + ProjectMember: &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + })) + }, + want: &v1.ProjectMemberResponse{ + ProjectMember: &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 2, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + }, + }, + wantErr: nil, + }, + { + name: "unable to update namespace", + req: &v1.ProjectMemberUpdateRequest{ + ProjectMember: &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "b", + }, + }, + prepare: func() { + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the namespace of a project member is not allowed"), + }, + { + name: "unable to update project", + req: &v1.ProjectMemberUpdateRequest{ + ProjectMember: &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + ProjectId: "project-2", + TenantId: "tenant-1", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the project id of a project member is not allowed"), + }, + { + name: "unable to update tenant", + req: &v1.ProjectMemberUpdateRequest{ + ProjectMember: &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-2", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "ProjectMember", + Apiversion: "v1", + Version: 1, + }, + ProjectId: "project-1", + TenantId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the tenant id of a project member is not allowed"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, ve := range ves { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE "+ve.TableName()) + require.NoError(t, err) + } + + if tt.prepare != nil { + tt.prepare() + } + + got, err := service.Update(ctx, tt.req) + if diff := cmp.Diff(err, tt.wantErr, cmpopts.EquateErrors()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + return + } + + if err == nil { + assert.NotNil(t, got.ProjectMember.Meta.UpdatedTime) + } + + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreTypes(protoimpl.MessageState{}), cmpopts.IgnoreFields(v1.Meta{}, "CreatedTime", "UpdatedTime"), testcommon.IgnoreUnexported()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } + }) + } } diff --git a/pkg/service/tenant.go b/pkg/service/tenant.go index 95ecff0..ecfa529 100644 --- a/pkg/service/tenant.go +++ b/pkg/service/tenant.go @@ -175,7 +175,9 @@ var ( ). From(projectMembers.TableName()). Join(projects.TableName() + " ON " + projects.TableName() + ".id = " + projectMembers.JSONField() + "->>'project_id'"). - Where(projectMembers.JSONField() + "->>'tenant_id' = :tenantId") + Where(projectMembers.JSONField() + "->>'tenant_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + projectMembers.JSONField() + "->> 'namespace', '') = :namespace") queryInheritedProjectParticipations = sq.Select( projects.JSONField(), @@ -183,7 +185,9 @@ var ( ). From(tenantMembers.TableName()). Join(projects.TableName() + " ON " + projects.JSONField() + "->>'tenant_id' = " + tenantMembers.JSONField() + "->>'tenant_id'"). - Where(tenantMembers.JSONField() + "->>'member_id' = :tenantId") + Where(tenantMembers.JSONField() + "->>'member_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + tenantMembers.JSONField() + "->> 'namespace', '') = :namespace") ) // FindParticipatingProjects returns all projects in which a member participates. @@ -200,7 +204,7 @@ func (s *tenantService) FindParticipatingProjects(ctx context.Context, req *v1.F res []*v1.ProjectWithMembershipAnnotations resultMap = map[string]*v1.ProjectWithMembershipAnnotations{} - input = map[string]any{"tenantId": req.TenantId} + input = map[string]any{"tenantId": req.TenantId, "namespace": req.Namespace} resultFn = func(e result) error { p, ok := resultMap[e.Project.Meta.Id] @@ -261,7 +265,9 @@ var ( ). From(tenantMembers.TableName()). Join(tenants.TableName() + " ON " + tenants.TableName() + ".id = " + tenantMembers.JSONField() + "->>'tenant_id'"). - Where(tenantMembers.JSONField() + "->>'member_id' = :tenantId") + Where(tenantMembers.JSONField() + "->>'member_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + tenantMembers.JSONField() + "->> 'namespace', '') = :namespace") queryInheritedTenantParticipations = sq.Select( tenants.JSONField(), @@ -270,7 +276,9 @@ var ( From(projectMembers.TableName()). Join(projects.TableName() + " ON " + projects.TableName() + ".id = " + projectMembers.JSONField() + "->>'project_id'"). Join(tenants.TableName() + " ON " + tenants.TableName() + ".id = " + projects.JSONField() + "->>'tenant_id'"). - Where(projectMembers.JSONField() + "->>'tenant_id' = :tenantId") + Where(projectMembers.JSONField() + "->>'tenant_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + projectMembers.JSONField() + "->> 'namespace', '') = :namespace") ) // FindParticipatingTenants returns all tenants in which a member participates. @@ -284,7 +292,7 @@ func (s *tenantService) FindParticipatingTenants(ctx context.Context, req *v1.Fi } var ( - input = map[string]any{"tenantId": req.TenantId} + input = map[string]any{"tenantId": req.TenantId, "namespace": req.Namespace} res []*v1.TenantWithMembershipAnnotations resultMap = map[string]*v1.TenantWithMembershipAnnotations{} @@ -348,7 +356,9 @@ var ( ). From(tenantMembers.TableName()). Join(tenants.TableName() + " ON " + tenants.TableName() + ".id = " + tenantMembers.JSONField() + "->>'member_id'"). - Where(tenantMembers.JSONField() + "->>'tenant_id' = :tenantId") + Where(tenantMembers.JSONField() + "->>'tenant_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + tenantMembers.JSONField() + "->> 'namespace', '') = :namespace") queryInheritedTenantMembers = sq.Select( tenants.JSONField(), @@ -357,7 +367,9 @@ var ( From(projectMembers.TableName()). Join(projects.TableName() + " ON " + projects.TableName() + ".id = " + projectMembers.JSONField() + "->>'project_id'"). Join(tenants.TableName() + " ON " + tenants.TableName() + ".id = " + projectMembers.JSONField() + "->>'tenant_id'"). - Where(projects.JSONField() + "->>'tenant_id' = :tenantId") + Where(projects.JSONField() + "->>'tenant_id' = :tenantId"). + // COALESCE is required to provide an empty string as default value in case the namespace field is not present + Where("COALESCE(" + projectMembers.JSONField() + "->> 'namespace', '') = :namespace") ) // ListTenantMembers returns all members of a tenant. @@ -374,7 +386,7 @@ func (s *tenantService) ListTenantMembers(ctx context.Context, req *v1.ListTenan res []*v1.TenantWithMembershipAnnotations resultMap = map[string]*v1.TenantWithMembershipAnnotations{} - input = map[string]any{"tenantId": req.TenantId} + input = map[string]any{"tenantId": req.TenantId, "namespace": req.Namespace} resultFn = func(e result) error { t, ok := resultMap[e.Tenant.Meta.Id] diff --git a/pkg/service/tenant_test.go b/pkg/service/tenant_test.go index 1a037a5..daedfea 100644 --- a/pkg/service/tenant_test.go +++ b/pkg/service/tenant_test.go @@ -457,10 +457,12 @@ func Test_tenantService_FindParticipatingProjects(t *testing.T) { IncludeInherited: pointer.Pointer(true), }, prepare: func() { - err := projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}}) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "1", TenantId: "someone else"}) - require.NoError(t, err) + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}})) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "1", + TenantId: "someone else", + })) }, want: &v1.FindParticipatingProjectsResponse{}, wantErr: nil, @@ -472,10 +474,63 @@ func Test_tenantService_FindParticipatingProjects(t *testing.T) { IncludeInherited: pointer.Pointer(true), }, prepare: func() { - err := projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}}) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "1", TenantId: "a"}) - require.NoError(t, err) + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}})) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "1", + TenantId: "a", + })) + }, + want: &v1.FindParticipatingProjectsResponse{ + Projects: []*v1.ProjectWithMembershipAnnotations{{ + Project: &v1.Project{ + Meta: &v1.Meta{ + Kind: "Project", + Apiversion: "v1", + Id: "1", + }, + }, + ProjectAnnotations: map[string]string{"role": "admin"}, + TenantAnnotations: nil, + }}, + }, + wantErr: nil, + }, + { + name: "no direct membership in other namespace", + req: &v1.FindParticipatingProjectsRequest{ + TenantId: "a", + IncludeInherited: pointer.Pointer(true), + Namespace: "other", + }, + prepare: func() { + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}})) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "1", + TenantId: "a", + })) + }, + want: &v1.FindParticipatingProjectsResponse{ + Projects: nil, + }, + wantErr: nil, + }, + { + name: "direct membership in a namespace", + req: &v1.FindParticipatingProjectsRequest{ + TenantId: "a", + IncludeInherited: pointer.Pointer(true), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}})) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "1", + TenantId: "a", + Namespace: "a", + })) }, want: &v1.FindParticipatingProjectsResponse{ Projects: []*v1.ProjectWithMembershipAnnotations{{ @@ -499,16 +554,23 @@ func Test_tenantService_FindParticipatingProjects(t *testing.T) { IncludeInherited: pointer.Pointer(false), }, prepare: func() { - err := projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}}) - require.NoError(t, err) - err = projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "2"}, TenantId: "b"}) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "1", TenantId: "a"}) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "2", TenantId: "b"}) - require.NoError(t, err) - err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "editor"}}, MemberId: "a", TenantId: "b"}) - require.NoError(t, err) + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}})) + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "2"}, TenantId: "b"})) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "1", + TenantId: "a", + })) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "2", + TenantId: "b", + })) + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "editor"}}, + MemberId: "a", + TenantId: "b", + })) }, want: &v1.FindParticipatingProjectsResponse{ Projects: []*v1.ProjectWithMembershipAnnotations{{ @@ -532,10 +594,8 @@ func Test_tenantService_FindParticipatingProjects(t *testing.T) { IncludeInherited: pointer.Pointer(true), }, prepare: func() { - err := projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}, TenantId: "b"}) - require.NoError(t, err) - err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "viewer"}}, TenantId: "b", MemberId: "a"}) - require.NoError(t, err) + require.NoError(t, projectStore.Create(ctx, &v1.Project{Meta: &v1.Meta{Id: "1"}, TenantId: "b"})) + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "viewer"}}, TenantId: "b", MemberId: "a"})) }, want: &v1.FindParticipatingProjectsResponse{ Projects: []*v1.ProjectWithMembershipAnnotations{{ @@ -560,34 +620,29 @@ func Test_tenantService_FindParticipatingProjects(t *testing.T) { IncludeInherited: pointer.Pointer(true), }, prepare: func() { - err := projectStore.Create(ctx, &v1.Project{ + require.NoError(t, projectStore.Create(ctx, &v1.Project{ Meta: &v1.Meta{Id: "direct-1"}, TenantId: "req-tenant", - }) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{ + })) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ Meta: &v1.Meta{Annotations: map[string]string{"role": "owner"}}, ProjectId: "direct-1", TenantId: "req-tenant", - }) - require.NoError(t, err) - err = tenantMemberStore.Create(ctx, &v1.TenantMember{ + })) + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ Meta: &v1.Meta{Annotations: map[string]string{"role": "editor"}}, MemberId: "req-tenant", TenantId: "parent", - }) - require.NoError(t, err) - err = projectStore.Create(ctx, &v1.Project{ + })) + require.NoError(t, projectStore.Create(ctx, &v1.Project{ Meta: &v1.Meta{Id: "indirect-2"}, TenantId: "parent", - }) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{ + })) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "indirect-2", TenantId: "parent", - }) - require.NoError(t, err) + })) }, want: &v1.FindParticipatingProjectsResponse{ Projects: []*v1.ProjectWithMembershipAnnotations{ @@ -742,6 +797,53 @@ func Test_tenantService_FindParticipatingTenants(t *testing.T) { }, wantErr: nil, }, + { + name: "no direct membership when in different namespace", + req: &v1.FindParticipatingTenantsRequest{ + TenantId: "a", + IncludeInherited: pointer.Pointer(true), + Namespace: "other", + }, + prepare: func() { + err := tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "b"}}) + require.NoError(t, err) + err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, MemberId: "a", TenantId: "b"}) + require.NoError(t, err) + }, + want: &v1.FindParticipatingTenantsResponse{ + Tenants: nil, + }, + wantErr: nil, + }, + { + name: "direct membership in namespace", + req: &v1.FindParticipatingTenantsRequest{ + TenantId: "a", + IncludeInherited: pointer.Pointer(true), + Namespace: "a", + }, + prepare: func() { + err := tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "b"}}) + require.NoError(t, err) + err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, Namespace: "a", MemberId: "a", TenantId: "b"}) + require.NoError(t, err) + }, + want: &v1.FindParticipatingTenantsResponse{ + Tenants: []*v1.TenantWithMembershipAnnotations{ + { + Tenant: &v1.Tenant{ + Meta: &v1.Meta{ + Kind: "Tenant", + Apiversion: "v1", + Id: "b", + }, + }, + TenantAnnotations: map[string]string{"role": "admin"}, + }, + }, + }, + wantErr: nil, + }, { name: "indirect membership", req: &v1.FindParticipatingTenantsRequest{ @@ -790,34 +892,43 @@ func Test_tenantService_FindParticipatingTenants(t *testing.T) { wantErr: nil, }, { - name: "direct and indirect memberships", + name: "direct and indirect memberships (without interference with other namespaces)", req: &v1.FindParticipatingTenantsRequest{ TenantId: "req-tnt", IncludeInherited: pointer.Pointer(true), }, prepare: func() { - err = tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "indirect-tnt"}}) - require.NoError(t, err) - err := projectStore.Create(ctx, &v1.Project{ + require.NoError(t, tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "indirect-tnt"}})) + require.NoError(t, projectStore.Create(ctx, &v1.Project{ Meta: &v1.Meta{Id: "indirect"}, TenantId: "indirect-tnt", - }) - require.NoError(t, err) - err = projectMemberStore.Create(ctx, &v1.ProjectMember{ + })) + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, ProjectId: "indirect", TenantId: "req-tnt", - }) - require.NoError(t, err) + })) + // should not interfere: + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "indirect", + TenantId: "req-tnt", + Namespace: "other", + })) - err = tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "direct-tnt"}}) - require.NoError(t, err) - err = tenantMemberStore.Create(ctx, &v1.TenantMember{ + require.NoError(t, tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "direct-tnt"}})) + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ Meta: &v1.Meta{Annotations: map[string]string{"relation": "direct"}}, TenantId: "direct-tnt", MemberId: "req-tnt", - }) - require.NoError(t, err) + })) + // should not interfere: + require.NoError(t, projectMemberStore.Create(ctx, &v1.ProjectMember{ + Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, + ProjectId: "indirect", + TenantId: "req-tnt", + Namespace: "other", + })) }, want: &v1.FindParticipatingTenantsResponse{ Tenants: []*v1.TenantWithMembershipAnnotations{ @@ -895,7 +1006,7 @@ func Test_tenantService_ListTenantMembers(t *testing.T) { s := &tenantService{ db: db, - log: slog.Default(), + log: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})), } var ( @@ -970,6 +1081,53 @@ func Test_tenantService_ListTenantMembers(t *testing.T) { }, wantErr: nil, }, + { + name: "no direct membership in other namespace", + req: &v1.ListTenantMembersRequest{ + TenantId: "acme", + IncludeInherited: pointer.Pointer(true), + Namespace: "other", + }, + prepare: func() { + err := tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "azure"}}) + require.NoError(t, err) + err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, MemberId: "azure", TenantId: "acme"}) + require.NoError(t, err) + }, + want: &v1.ListTenantMembersResponse{ + Tenants: nil, + }, + wantErr: nil, + }, + { + name: "direct membership in namespace", + req: &v1.ListTenantMembersRequest{ + TenantId: "acme", + IncludeInherited: pointer.Pointer(true), + Namespace: "a", + }, + prepare: func() { + err := tenantStore.Create(ctx, &v1.Tenant{Meta: &v1.Meta{Id: "azure"}}) + require.NoError(t, err) + err = tenantMemberStore.Create(ctx, &v1.TenantMember{Meta: &v1.Meta{Annotations: map[string]string{"role": "admin"}}, Namespace: "a", MemberId: "azure", TenantId: "acme"}) + require.NoError(t, err) + }, + want: &v1.ListTenantMembersResponse{ + Tenants: []*v1.TenantWithMembershipAnnotations{ + { + Tenant: &v1.Tenant{ + Meta: &v1.Meta{ + Kind: "Tenant", + Apiversion: "v1", + Id: "azure", + }, + }, + TenantAnnotations: map[string]string{"role": "admin"}, + }, + }, + }, + wantErr: nil, + }, { name: "indirect membership", req: &v1.ListTenantMembersRequest{ @@ -1050,12 +1208,10 @@ func Test_tenantService_ListTenantMembers(t *testing.T) { Meta: &v1.Meta{ Kind: "Tenant", Apiversion: "v1", - Id: "github", + Id: "azure", }, }, - TenantAnnotations: map[string]string{"tenant-role": "owner"}, ProjectIds: []string{ - "1", "2", }, }, @@ -1064,10 +1220,12 @@ func Test_tenantService_ListTenantMembers(t *testing.T) { Meta: &v1.Meta{ Kind: "Tenant", Apiversion: "v1", - Id: "azure", + Id: "github", }, }, + TenantAnnotations: map[string]string{"tenant-role": "owner"}, ProjectIds: []string{ + "1", "2", }, }, @@ -1092,6 +1250,15 @@ func Test_tenantService_ListTenantMembers(t *testing.T) { t.Errorf("(-want +got):\n%s", diff) return } + + slices.SortFunc(got.Tenants, func(i, j *v1.TenantWithMembershipAnnotations) int { + if i.Tenant.Meta.Id < j.Tenant.Meta.Id { + return -1 + } else { + return 1 + } + }) + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreTypes(protoimpl.MessageState{}), cmpopts.IgnoreFields(v1.Meta{}, "CreatedTime"), testcommon.IgnoreUnexported()); diff != "" { t.Errorf("(-want +got):\n%s", diff) } diff --git a/pkg/service/tenantmember.go b/pkg/service/tenantmember.go index 6eae834..3a0a019 100644 --- a/pkg/service/tenantmember.go +++ b/pkg/service/tenantmember.go @@ -51,25 +51,50 @@ func (s *tenantMemberService) Create(ctx context.Context, req *v1.TenantMemberCr err = s.tenantMemberStore.Create(ctx, tenantMember) return tenantMember.NewTenantMemberResponse(), err } + func (s *tenantMemberService) Update(ctx context.Context, req *v1.TenantMemberUpdateRequest) (*v1.TenantMemberResponse, error) { tenantMember := req.TenantMember - err := s.tenantMemberStore.Update(ctx, tenantMember) + + old, err := s.tenantMemberStore.Get(ctx, tenantMember.Meta.Id) + if err != nil { + return nil, err + } + + if old.TenantId != tenantMember.TenantId { + return nil, status.Error(codes.InvalidArgument, "updating the tenant id of a tenant member is not allowed") + } + if old.MemberId != tenantMember.MemberId { + return nil, status.Error(codes.InvalidArgument, "updating the member id of a tenant member is not allowed") + } + if old.Namespace != tenantMember.Namespace { + return nil, status.Error(codes.InvalidArgument, "updating the namespace of a tenant member is not allowed") + } + + err = s.tenantMemberStore.Update(ctx, tenantMember) + return tenantMember.NewTenantMemberResponse(), err } + func (s *tenantMemberService) Delete(ctx context.Context, req *v1.TenantMemberDeleteRequest) (*v1.TenantMemberResponse, error) { tenantMember := req.NewTenantMember() err := s.tenantMemberStore.Delete(ctx, tenantMember.Meta.Id) return tenantMember.NewTenantMemberResponse(), err } + func (s *tenantMemberService) Get(ctx context.Context, req *v1.TenantMemberGetRequest) (*v1.TenantMemberResponse, error) { tenantMember, err := s.tenantMemberStore.Get(ctx, req.Id) if err != nil { return nil, err } + return tenantMember.NewTenantMemberResponse(), nil } + func (s *tenantMemberService) Find(ctx context.Context, req *v1.TenantMemberFindRequest) (*v1.TenantMemberListResponse, error) { - filter := make(map[string]any) + filter := map[string]any{ + "COALESCE(tenantmember ->> 'namespace', '')": req.Namespace, + } + if req.TenantId != nil { filter["tenantmember ->> 'tenant_id'"] = req.TenantId } @@ -81,11 +106,14 @@ func (s *tenantMemberService) Find(ctx context.Context, req *v1.TenantMemberFind f := fmt.Sprintf("tenantmember -> 'meta' -> 'annotations' ->> '%s'", key) filter[f] = value } + res, _, err := s.tenantMemberStore.Find(ctx, nil, filter) if err != nil { return nil, err } + resp := new(v1.TenantMemberListResponse) resp.TenantMembers = append(resp.TenantMembers, res...) + return resp, nil } diff --git a/pkg/service/tenantmember_test.go b/pkg/service/tenantmember_test.go index 6762531..ebd5207 100644 --- a/pkg/service/tenantmember_test.go +++ b/pkg/service/tenantmember_test.go @@ -3,15 +3,22 @@ package service import ( "context" "log/slog" + "slices" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" v1 "github.com/metal-stack/masterdata-api/api/v1" "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/metal-stack/metal-lib/pkg/testcommon" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/runtime/protoimpl" "testing" + "github.com/metal-stack/masterdata-api/pkg/datastore" "github.com/metal-stack/masterdata-api/pkg/test/mocks" ) @@ -44,39 +51,6 @@ func TestCreateTenantMember(t *testing.T) { assert.Equal(t, pmcr.TenantMember.TenantId, resp.GetTenantMember().GetTenantId()) } -func TestUpdateTenantMember(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.TenantMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - ts := &tenantMemberService{ - tenantMemberStore: storageMock, - tenantStore: tenantStorageMock, - log: slog.Default(), - } - ctx := context.Background() - - meta := &v1.Meta{Id: "p2", Annotations: map[string]string{"key": "value"}} - pm1 := &v1.TenantMember{ - Meta: meta, - TenantId: "p1", - MemberId: "t1", - } - meta.Annotations = map[string]string{"key": "value2"} - pmur := &v1.TenantMemberUpdateRequest{ - TenantMember: &v1.TenantMember{ - Meta: meta, - TenantId: "p1", - MemberId: "t1", - }, - } - - storageMock.On("Update", ctx, pm1).Return(nil) - resp, err := ts.Update(ctx, pmur) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.NotNil(t, resp.GetTenantMember()) - assert.Equal(t, pmur.GetTenantMember().Meta.Annotations, resp.GetTenantMember().Meta.Annotations) -} - func TestDeleteTenantMember(t *testing.T) { storageMock := mocks.NewMockStorage[*v1.TenantMember](t) tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) @@ -125,50 +99,448 @@ func TestGetTenantMember(t *testing.T) { assert.Equal(t, tgr.Id, resp.GetTenantMember().GetMeta().GetId()) } -func TestFindTenantMemberByTenant(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.TenantMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - ts := &tenantMemberService{ - tenantMemberStore: storageMock, - tenantStore: tenantStorageMock, - log: slog.Default(), +func TestFindTenantMember(t *testing.T) { + ctx := t.Context() + ves := []datastore.Entity{ + &v1.Tenant{}, + &v1.TenantMember{}, } - ctx := context.Background() - // filter by name - var t6s []*v1.TenantMember - tfr := &v1.TenantMemberFindRequest{ - TenantId: pointer.Pointer("p1"), + container, db, err := StartPostgres(ctx, ves...) + require.NoError(t, err) + defer func() { + require.NoError(t, db.Close()) + require.NoError(t, container.Terminate(ctx)) + }() + + var ( + tenantMemberStore = datastore.New(log, db, &v1.TenantMember{}) + tenantStore = datastore.New(log, db, &v1.Tenant{}) + + testTenant1 = &v1.Tenant{ + Meta: &v1.Meta{ + Id: "tenant-1", + Kind: "Tenant", + Apiversion: "v1", + Version: 1, + }, + Name: "tenant 1", + Description: "test tenant 1", + } + testTenant2 = &v1.Tenant{ + Meta: &v1.Meta{ + Id: "tenant-2", + Kind: "Tenant", + Apiversion: "v1", + Version: 1, + }, + Name: "tenant 2", + Description: "test tenant 2", + } + testTenantMember1 = &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + } + testTenantMember2 = &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "2", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"c", "d"}, + }, + TenantId: "tenant-2", + MemberId: "tenant-2", + Namespace: "a", + } + testTenantMember3 = &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "3", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "editor", + }, + Labels: []string{"e", "f"}, + }, + TenantId: "tenant-1", + MemberId: "tenant-2", + Namespace: "a", + } + testTenantMember4 = &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "4", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + Annotations: map[string]string{ + "role": "editor", + }, + Labels: []string{"e", "f"}, + }, + TenantId: "tenant-1", + MemberId: "tenant-2", + Namespace: "", + } + + service = &tenantMemberService{ + log: log, + tenantMemberStore: tenantMemberStore, + tenantStore: tenantStore, + } + ) + + tests := []struct { + name string + prepare func() + req *v1.TenantMemberFindRequest + want *v1.TenantMemberListResponse + wantErr error + }{ + { + name: "find by tenant", + req: &v1.TenantMemberFindRequest{ + TenantId: pointer.Pointer("tenant-1"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember1)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember3)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember4)) + }, + want: &v1.TenantMemberListResponse{ + TenantMembers: []*v1.TenantMember{ + testTenantMember1, + testTenantMember3, + }, + }, + wantErr: nil, + }, + { + name: "find by tenant id (no results) #1", + req: &v1.TenantMemberFindRequest{ + TenantId: pointer.Pointer("no-result"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember1)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember3)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember4)) + }, + want: &v1.TenantMemberListResponse{ + TenantMembers: nil, + }, + wantErr: nil, + }, + { + name: "find by tenant id (no results) #2", + req: &v1.TenantMemberFindRequest{ + TenantId: pointer.Pointer("tenant-1"), + Namespace: "wrong-namespace", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember1)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember3)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember4)) + }, + want: &v1.TenantMemberListResponse{ + TenantMembers: nil, + }, + wantErr: nil, + }, + { + name: "find by tenant", + req: &v1.TenantMemberFindRequest{ + TenantId: pointer.Pointer("tenant-2"), + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember1)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember3)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember4)) + }, + want: &v1.TenantMemberListResponse{ + TenantMembers: []*v1.TenantMember{ + testTenantMember2, + }, + }, + wantErr: nil, + }, + { + name: "find by annotation", + req: &v1.TenantMemberFindRequest{ + Annotations: map[string]string{"role": "owner"}, + Namespace: "a", + }, + prepare: func() { + require.NoError(t, tenantStore.Create(ctx, testTenant1)) + require.NoError(t, tenantStore.Create(ctx, testTenant2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember1)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember2)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember3)) + require.NoError(t, tenantMemberStore.Create(ctx, testTenantMember4)) + }, + want: &v1.TenantMemberListResponse{ + TenantMembers: []*v1.TenantMember{ + testTenantMember1, + testTenantMember2, + }, + }, + wantErr: nil, + }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, ve := range ves { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE "+ve.TableName()) + require.NoError(t, err) + } - f2 := make(map[string]any) - f2["tenantmember ->> 'tenant_id'"] = pointer.Pointer("p1") - storageMock.On("Find", ctx, mock.AnythingOfType("*v1.Paging"), []any{f2}).Return(t6s, nil, nil) - resp, err := ts.Find(ctx, tfr) - require.NoError(t, err) - assert.NotNil(t, resp) -} + if tt.prepare != nil { + tt.prepare() + } -func TestFindTenantMemberByMember(t *testing.T) { - storageMock := mocks.NewMockStorage[*v1.TenantMember](t) - tenantStorageMock := mocks.NewMockStorage[*v1.Tenant](t) - ts := &tenantMemberService{ - tenantMemberStore: storageMock, - tenantStore: tenantStorageMock, - log: slog.Default(), + got, err := service.Find(ctx, tt.req) + if diff := cmp.Diff(err, tt.wantErr); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + return + } + + slices.SortFunc(got.TenantMembers, func(i, j *v1.TenantMember) int { + if i.Meta.Id < j.Meta.Id { + return -1 + } else { + return 1 + } + }) + + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreTypes(protoimpl.MessageState{}), cmpopts.IgnoreFields(v1.Meta{}, "CreatedTime"), testcommon.IgnoreUnexported()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } + }) } - ctx := context.Background() +} - // filter by name - var t6s []*v1.TenantMember - tfr := &v1.TenantMemberFindRequest{ - MemberId: pointer.Pointer("t1"), +func TestUpdateTenantMember(t *testing.T) { + ctx := t.Context() + ves := []datastore.Entity{ + &v1.Tenant{}, + &v1.TenantMember{}, } - f2 := make(map[string]any) - f2["tenantmember ->> 'member_id'"] = pointer.Pointer("t1") - storageMock.On("Find", ctx, mock.AnythingOfType("*v1.Paging"), []any{f2}).Return(t6s, nil, nil) - resp, err := ts.Find(ctx, tfr) + container, db, err := StartPostgres(ctx, ves...) require.NoError(t, err) - assert.NotNil(t, resp) + defer func() { + require.NoError(t, db.Close()) + require.NoError(t, container.Terminate(ctx)) + }() + + var ( + tenantMemberStore = datastore.New(log, db, &v1.TenantMember{}) + tenantStore = datastore.New(log, db, &v1.Tenant{}) + + service = &tenantMemberService{ + log: log, + tenantMemberStore: tenantMemberStore, + tenantStore: tenantStore, + } + ) + + tests := []struct { + name string + prepare func() + req *v1.TenantMemberUpdateRequest + want *v1.TenantMemberResponse + wantErr error + }{ + { + name: "update mutable fields", + req: &v1.TenantMemberUpdateRequest{ + TenantMember: &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + })) + }, + want: &v1.TenantMemberResponse{ + TenantMember: &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 2, + Annotations: map[string]string{ + "role": "owner", + }, + Labels: []string{"a", "b"}, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + }, + }, + wantErr: nil, + }, + { + name: "unable to update namespace", + req: &v1.TenantMemberUpdateRequest{ + TenantMember: &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "b", + }, + }, + prepare: func() { + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the namespace of a tenant member is not allowed"), + }, + { + name: "unable to update tenant id", + req: &v1.TenantMemberUpdateRequest{ + TenantMember: &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + TenantId: "tenant-2", + MemberId: "tenant-1", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the tenant id of a tenant member is not allowed"), + }, + { + name: "unable to update member id", + req: &v1.TenantMemberUpdateRequest{ + TenantMember: &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-2", + Namespace: "a", + }, + }, + prepare: func() { + require.NoError(t, tenantMemberStore.Create(ctx, &v1.TenantMember{ + Meta: &v1.Meta{ + Id: "1", + Kind: "TenantMember", + Apiversion: "v1", + Version: 1, + }, + TenantId: "tenant-1", + MemberId: "tenant-1", + Namespace: "a", + })) + }, + want: nil, + wantErr: status.Error(codes.InvalidArgument, "updating the member id of a tenant member is not allowed"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, ve := range ves { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE "+ve.TableName()) + require.NoError(t, err) + } + + if tt.prepare != nil { + tt.prepare() + } + + got, err := service.Update(ctx, tt.req) + if diff := cmp.Diff(err, tt.wantErr, cmpopts.EquateErrors()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + return + } + + if err == nil { + assert.NotNil(t, got.TenantMember.Meta.UpdatedTime) + } + + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreTypes(protoimpl.MessageState{}), cmpopts.IgnoreFields(v1.Meta{}, "CreatedTime", "UpdatedTime"), testcommon.IgnoreUnexported()); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } + }) + } } diff --git a/proto/v1/project_member.proto b/proto/v1/project_member.proto index 91d5772..76946f6 100644 --- a/proto/v1/project_member.proto +++ b/proto/v1/project_member.proto @@ -17,6 +17,8 @@ message ProjectMember { Meta meta = 1; string project_id = 2; string tenant_id = 4; + // Namespace introduces the possibility to associate memberships for different applications that use the masterdata-api as a backend. + string namespace = 5; } message ProjectMemberCreateRequest { @@ -39,6 +41,7 @@ message ProjectMemberFindRequest { optional string project_id = 1; optional string tenant_id = 2; map annotations = 6; + string namespace = 7; } message ProjectMemberResponse { diff --git a/proto/v1/tenant.proto b/proto/v1/tenant.proto index b1f808c..1f5bda3 100644 --- a/proto/v1/tenant.proto +++ b/proto/v1/tenant.proto @@ -25,16 +25,19 @@ service TenantService { message FindParticipatingProjectsRequest { string tenant_id = 1; optional bool include_inherited = 2; + string namespace = 3; } message FindParticipatingTenantsRequest { string tenant_id = 1; optional bool include_inherited = 2; + string namespace = 3; } message ListTenantMembersRequest { string tenant_id = 1; optional bool include_inherited = 2; + string namespace = 3; } message ListTenantMembersResponse { diff --git a/proto/v1/tenant_member.proto b/proto/v1/tenant_member.proto index 08235a6..1d7bafa 100644 --- a/proto/v1/tenant_member.proto +++ b/proto/v1/tenant_member.proto @@ -19,6 +19,8 @@ message TenantMember { string tenant_id = 2; // MemberId is the id of the member tenant string member_id = 3; + // Namespace introduces the possibility to associate memberships for different applications that use the masterdata-api as a backend. + string namespace = 4; } message TenantMemberCreateRequest { @@ -41,6 +43,7 @@ message TenantMemberFindRequest { optional string tenant_id = 1; optional string member_id = 2; map annotations = 6; + string namespace = 7; } message TenantMemberResponse {