diff --git a/lib/auth/users/usersv1/service.go b/lib/auth/users/usersv1/service.go new file mode 100644 index 00000000000..ecffc4532f0 --- /dev/null +++ b/lib/auth/users/usersv1/service.go @@ -0,0 +1,418 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package usersv1 + +import ( + "context" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/gravitational/teleport/api/constants" + apidefaults "github.com/gravitational/teleport/api/defaults" + userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + "github.com/gravitational/teleport/api/types" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/services" + usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" +) + +// Cache is the subset of the cached resources that the Service queries. +type Cache interface { + // GetUser returns a user by name. + GetUser(ctx context.Context, user string, withSecrets bool) (types.User, error) + // GetRole returns a role by name. + GetRole(ctx context.Context, name string) (types.Role, error) +} + +// Backend is the subset of the backend resources that the Service modifies. +type Backend interface { + // CreateUser creates user, only if the user entry does not exist + CreateUser(ctx context.Context, user types.User) (types.User, error) + // UpdateUser updates an existing user if revisions match. + UpdateUser(ctx context.Context, user types.User) (types.User, error) + // UpsertUser creates a new user or forcefully updates an existing user. + UpsertUser(ctx context.Context, user types.User) (types.User, error) + // DeleteRole deletes a role by name. + DeleteRole(ctx context.Context, name string) error + // DeleteUser deletes a user and all associated objects. + DeleteUser(ctx context.Context, user string) error +} + +// ServiceConfig holds configuration options for +// the users gRPC service. +type ServiceConfig struct { + Authorizer authz.Authorizer + Cache Cache + Backend Backend + Logger logrus.FieldLogger + Emitter apievents.Emitter + Reporter usagereporter.UsageReporter + Clock clockwork.Clock +} + +// Service implements the teleport.users.v1.UsersService RPC service. +type Service struct { + userspb.UnimplementedUsersServiceServer + + authorizer authz.Authorizer + cache Cache + backend Backend + logger logrus.FieldLogger + emitter apievents.Emitter + reporter usagereporter.UsageReporter + clock clockwork.Clock +} + +// NewService returns a new users gRPC service. +func NewService(cfg ServiceConfig) (*Service, error) { + switch { + case cfg.Cache == nil: + return nil, trace.BadParameter("cache service is required") + case cfg.Backend == nil: + return nil, trace.BadParameter("backend service is required") + case cfg.Authorizer == nil: + return nil, trace.BadParameter("authorizer is required") + case cfg.Emitter == nil: + return nil, trace.BadParameter("emitter is required") + case cfg.Reporter == nil: + return nil, trace.BadParameter("reporter is required") + } + + if cfg.Logger == nil { + cfg.Logger = logrus.WithField(trace.Component, "users.service") + } + if cfg.Clock == nil { + cfg.Clock = clockwork.NewRealClock() + } + + return &Service{ + logger: cfg.Logger, + authorizer: cfg.Authorizer, + cache: cfg.Cache, + backend: cfg.Backend, + emitter: cfg.Emitter, + reporter: cfg.Reporter, + clock: cfg.Clock, + }, nil +} + +// currentUserAction is a special checker that allows certain actions for users +// even if they are not admins, e.g. update their own passwords, +// or generate certificates, otherwise it will require admin privileges +func currentUserAction(authzContext authz.Context, username string) error { + if authz.IsLocalUser(authzContext) && username == authzContext.User.GetName() { + return nil + } + return authzContext.Checker.CheckAccessToRule(&services.Context{User: authzContext.User}, + apidefaults.Namespace, types.KindUser, types.VerbCreate, true) +} + +func (s *Service) getCurrentUser(ctx context.Context, authCtx *authz.Context) (*types.UserV2, error) { + // check access to roles + for _, role := range authCtx.User.GetRoles() { + _, err := s.cache.GetRole(ctx, role) + if err != nil { + return nil, trace.Wrap(err) + } + } + + withoutSecrets := authCtx.User.WithoutSecrets() + user, ok := withoutSecrets.(types.User) + if !ok { + return nil, trace.BadParameter("expected types.User when fetching current user information, got %T", withoutSecrets) + } + + v2, ok := user.(*types.UserV2) + if !ok { + return nil, trace.BadParameter("encountered unexpected user type") + } + + return v2, nil +} + +func (s *Service) GetUser(ctx context.Context, req *userspb.GetUserRequest) (*types.UserV2, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if req.Name == "" && req.CurrentUser { + user, err := s.getCurrentUser(ctx, authCtx) + return user, trace.Wrap(err) + } + + if req.WithSecrets { + // TODO(fspmarshall): replace admin requirement with VerbReadWithSecrets once we've + // migrated to that model. + if !authz.HasBuiltinRole(*authCtx, string(types.RoleAdmin)) { + err := trace.AccessDenied("user %q requested access to user %q with secrets", authCtx.User.GetName(), req.Name) + s.logger.Warn(err) + if err := s.emitter.EmitAuditEvent(ctx, &apievents.UserLogin{ + Metadata: apievents.Metadata{ + Type: events.UserLoginEvent, + Code: events.UserLocalLoginFailureCode, + }, + Method: events.LoginMethodClientCert, + Status: apievents.Status{ + Success: false, + Error: trace.Unwrap(err).Error(), + UserMessage: err.Error(), + }, + }); err != nil { + s.logger.WithError(err).Warn("Failed to emit local login failure event.") + } + return nil, trace.AccessDenied("this request can be only executed by an admin") + } + } else { + // if secrets are not being accessed, let users always read + // their own info. + if err := currentUserAction(*authCtx, req.Name); err != nil { + // not current user, perform normal permission check. + if _, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindUser, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + } + } + + user, err := s.cache.GetUser(ctx, req.Name, req.WithSecrets) + if err != nil { + return nil, trace.Wrap(err) + } + + v2, ok := user.(*types.UserV2) + if !ok { + s.logger.Warnf("expected type services.UserV2, got %T for user %q", user, user.GetName()) + return nil, trace.BadParameter("encountered unexpected user type") + } + + return v2, nil +} + +func (s *Service) CreateUser(ctx context.Context, req *userspb.CreateUserRequest) (*types.UserV2, error) { + if _, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindUser, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + + if req.User.GetCreatedBy().IsEmpty() { + req.User.SetCreatedBy(types.CreatedBy{ + User: types.UserRef{Name: authz.ClientUsername(ctx)}, + Time: s.clock.Now().UTC(), + }) + } + + created, err := s.backend.CreateUser(ctx, req.User) + if err != nil { + return nil, trace.Wrap(err) + } + + connectorName := constants.LocalConnector + if created.GetCreatedBy().Connector != nil { + connectorName = created.GetCreatedBy().Connector.ID + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.UserCreate{ + Metadata: apievents.Metadata{ + Type: events.UserCreateEvent, + Code: events.UserCreateCode, + }, + UserMetadata: authz.ClientUserMetadataWithUser(ctx, created.GetCreatedBy().User.Name), + ResourceMetadata: apievents.ResourceMetadata{ + Name: created.GetName(), + Expires: created.Expiry(), + }, + Connector: connectorName, + Roles: created.GetRoles(), + }); err != nil { + s.logger.WithError(err).Warn("Failed to emit user create event.") + } + + usagereporter.EmitEditorChangeEvent(created.GetName(), nil, created.GetRoles(), s.reporter.AnonymizeAndSubmit) + + v2, ok := created.(*types.UserV2) + if !ok { + s.logger.Warnf("expected type services.UserV2, got %T for user %q", created, created.GetName()) + return nil, trace.BadParameter("encountered unexpected user type") + } + + return v2, nil +} + +func (s *Service) UpdateUser(ctx context.Context, req *userspb.UpdateUserRequest) (*types.UserV2, error) { + if _, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindUser, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + + prevUser, err := s.cache.GetUser(ctx, req.User.GetName(), false) + var omitEditorEvent bool + if err != nil { + // don't return error here since this call is for event emitting purposes only + s.logger.WithError(err).Warn("Failed getting previous user during update") + omitEditorEvent = true + } + + updated, err := s.backend.UpdateUser(ctx, req.User) + if err != nil { + return nil, trace.Wrap(err) + } + + connectorName := constants.LocalConnector + if updated.GetCreatedBy().Connector != nil { + connectorName = updated.GetCreatedBy().Connector.ID + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.UserCreate{ + Metadata: apievents.Metadata{ + Type: events.UserUpdatedEvent, + Code: events.UserUpdateCode, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: updated.GetName(), + Expires: updated.Expiry(), + }, + Connector: connectorName, + Roles: updated.GetRoles(), + }); err != nil { + s.logger.WithError(err).Warn("Failed to emit user update event.") + } + + if !omitEditorEvent { + usagereporter.EmitEditorChangeEvent(updated.GetName(), prevUser.GetRoles(), updated.GetRoles(), s.reporter.AnonymizeAndSubmit) + } + + v2, ok := updated.(*types.UserV2) + if !ok { + s.logger.Warnf("expected type services.UserV2, got %T for user %q", updated, updated.GetName()) + return nil, trace.BadParameter("encountered unexpected user type") + } + + return v2, nil +} + +func (s *Service) UpsertUser(ctx context.Context, req *userspb.UpsertUserRequest) (*types.UserV2, error) { + authzCtx, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindUser, types.VerbCreate, types.VerbUpdate) + if err != nil { + return nil, trace.Wrap(err) + } + + if createdBy := req.User.GetCreatedBy(); createdBy.IsEmpty() { + req.User.SetCreatedBy(types.CreatedBy{ + User: types.UserRef{Name: authzCtx.User.GetName()}, + }) + } + + prevUser, err := s.cache.GetUser(ctx, req.User.GetName(), false) + var omitEditorEvent bool + if err != nil { + // don't return error here since this call is for event emitting purposes only + s.logger.WithError(err).Warn("Failed getting previous user during update") + omitEditorEvent = true + } + + upserted, err := s.backend.UpsertUser(ctx, req.User) + if err != nil { + return nil, trace.Wrap(err) + } + + connectorName := constants.LocalConnector + if upserted.GetCreatedBy().Connector != nil { + connectorName = upserted.GetCreatedBy().Connector.ID + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.UserCreate{ + Metadata: apievents.Metadata{ + Type: events.UserCreateEvent, + Code: events.UserCreateCode, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: upserted.GetName(), + Expires: upserted.Expiry(), + }, + Connector: connectorName, + Roles: upserted.GetRoles(), + }); err != nil { + s.logger.WithError(err).Warn("Failed to emit user upsert event.") + } + + if !omitEditorEvent { + usagereporter.EmitEditorChangeEvent(upserted.GetName(), prevUser.GetRoles(), upserted.GetRoles(), s.reporter.AnonymizeAndSubmit) + } + + v2, ok := upserted.(*types.UserV2) + if !ok { + s.logger.Warnf("expected type services.UserV2, got %T for user %q", upserted, upserted.GetName()) + return nil, trace.BadParameter("encountered unexpected user type") + } + + return v2, nil +} + +func (s *Service) DeleteUser(ctx context.Context, req *userspb.DeleteUserRequest) (*emptypb.Empty, error) { + if _, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindUser, types.VerbDelete); err != nil { + return nil, trace.Wrap(err) + } + + prevUser, err := s.cache.GetUser(ctx, req.Name, false) + var omitEditorEvent bool + if err != nil && !trace.IsNotFound(err) { + // don't return error here, delete may still succeed + s.logger.WithError(err).Warn("Failed getting previous user during delete operation") + prevUser = nil + omitEditorEvent = true + } + + role, err := s.cache.GetRole(ctx, services.RoleNameForUser(req.Name)) + if err != nil { + if !trace.IsNotFound(err) { + return &emptypb.Empty{}, trace.Wrap(err) + } + } else { + if err := s.backend.DeleteRole(ctx, role.GetName()); err != nil { + if !trace.IsNotFound(err) { + return &emptypb.Empty{}, trace.Wrap(err) + } + } + } + + if err := s.backend.DeleteUser(ctx, req.Name); err != nil { + return &emptypb.Empty{}, trace.Wrap(err) + } + + // If the user was successfully deleted, emit an event. + if err := s.emitter.EmitAuditEvent(ctx, &apievents.UserDelete{ + Metadata: apievents.Metadata{ + Type: events.UserDeleteEvent, + Code: events.UserDeleteCode, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.Name, + }, + }); err != nil { + s.logger.WithError(err).Warn("Failed to emit user delete event.") + } + + if !omitEditorEvent { + usagereporter.EmitEditorChangeEvent(req.Name, prevUser.GetRoles(), nil, s.reporter.AnonymizeAndSubmit) + } + + return &emptypb.Empty{}, nil +} diff --git a/lib/auth/users/usersv1/service_test.go b/lib/auth/users/usersv1/service_test.go new file mode 100644 index 00000000000..8642919be3e --- /dev/null +++ b/lib/auth/users/usersv1/service_test.go @@ -0,0 +1,692 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package usersv1 + +import ( + "context" + "encoding/base32" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/events/eventstest" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/tlsca" + usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" +) + +type fakeAuthorizer struct { + authorize bool + + authzContext *authz.Context +} + +// Authorize implements authz.Authorizer +func (a fakeAuthorizer) Authorize(ctx context.Context) (*authz.Context, error) { + identity, err := authz.UserFromContext(ctx) + if err == nil { + user, err := types.NewUser("alice") + if err != nil { + return nil, trace.Wrap(err) + } + + return &authz.Context{ + User: user, + Checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbList, types.VerbRead, types.VerbUpdate, types.VerbCreate, types.VerbDelete}, + }, + }, + }, + Identity: identity, + }, nil + } + + if a.authzContext != nil { + return a.authzContext, nil + } + + user, err := types.NewUser("alice") + if err != nil { + return nil, trace.Wrap(err) + } + return &authz.Context{ + User: user, + Checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbList, types.VerbRead, types.VerbUpdate, types.VerbCreate, types.VerbDelete}, + }, + }, + }, + Identity: &authz.LocalUser{ + Username: "alice", + Identity: tlsca.Identity{ + Groups: []string{"dev"}, + }, + }, + }, nil + +} + +type fakeChecker struct { + services.AccessChecker + rules []types.Rule + roles []string + checks []check +} + +type check struct { + kind, verb string +} + +func (f *fakeChecker) CheckAccessToRule(context services.RuleContext, namespace string, kind string, verb string, silent bool) error { + c := check{kind, verb} + f.checks = append(f.checks, c) + + for _, r := range f.rules { + if r.HasResource(kind) && r.HasVerb(verb) { + return nil + } + } + return trace.AccessDenied("access to %s with verb %s is not allowed", kind, verb) +} + +// HasRole checks if the checker includes the role +func (f *fakeChecker) HasRole(target string) bool { + for _, role := range f.roles { + if role == target { + return true + } + } + + return false +} + +type serviceOpt = func(*Service) + +func withAuthorizer(authz authz.Authorizer) serviceOpt { + return func(service *Service) { + service.authorizer = authz + } +} + +type env struct { + *Service + emitter *eventstest.ChannelEmitter + backend Backend +} + +func newTestEnv(opts ...serviceOpt) (*env, error) { + bk, err := memory.New(memory.Config{}) + if err != nil { + return nil, trace.Wrap(err, "creating memory backend") + } + + service := struct { + services.Identity + services.Access + }{ + Identity: local.NewIdentityService(bk), + Access: local.NewAccessService(bk), + } + + emitter := eventstest.NewChannelEmitter(10) + + svc, err := NewService(ServiceConfig{ + Authorizer: fakeAuthorizer{authorize: true}, + Cache: service, + Backend: service, + Emitter: emitter, + Reporter: usagereporter.DiscardUsageReporter{}, + }) + if err != nil { + return nil, trace.Wrap(err, "creating users service") + } + + for _, opt := range opts { + opt(svc) + } + + return &env{ + Service: svc, + emitter: emitter, + backend: service, + }, nil +} + +func TestCreateUser(t *testing.T) { + t.Parallel() + env, err := newTestEnv() + require.NoError(t, err, "creating test service") + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + + // Create a new user. + created, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + require.NoError(t, err, "creating user llama") + + // Validate that the user now exists. + resp, err := env.GetUser(ctx, &userspb.GetUserRequest{Name: created.GetName()}) + require.NoError(t, err, "failed getting created user") + require.Empty(t, cmp.Diff(created, resp, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + + // Attempt to create a duplicate user + created2, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "duplicate user was created successfully") + assert.Nil(t, created2, "received unexpected user ") + require.True(t, trace.IsAlreadyExists(err), "creating duplicate user allowed") + + event := <-env.emitter.C() + assert.Equal(t, events.UserCreateEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserCreateCode, event.GetCode(), "unexpected event code") +} + +func TestDeleteUser(t *testing.T) { + t.Parallel() + env, err := newTestEnv() + require.NoError(t, err, "creating test service") + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + + // Create the user which will be deleted. + created, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + require.NoError(t, err, "creating user llama") + + event := <-env.emitter.C() + assert.Equal(t, events.UserCreateEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserCreateCode, event.GetCode(), "unexpected event code") + + // Delete the user. + _, err = env.DeleteUser(ctx, &userspb.DeleteUserRequest{Name: created.GetName()}) + require.NoError(t, err) + + event = <-env.emitter.C() + assert.Equal(t, events.UserDeleteEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserDeleteCode, event.GetCode(), "unexpected event code") + + // Attempt to delete the user again, this time deletion should fail because + // the user no longer exists. + _, err = env.DeleteUser(ctx, &userspb.DeleteUserRequest{Name: created.GetName()}) + assert.Error(t, err, "deleting nonexistent user succeeded") + require.True(t, trace.IsNotFound(err), "expected a not found error deleting nonexistent user got %T", err) +} + +func TestGetUser(t *testing.T) { + t.Parallel() + + // create an admin authz context to test listing users with secrets + authzContext, err := authz.ContextForBuiltinRole(authz.BuiltinRole{ + Role: types.RoleAdmin, + Username: string(types.RoleAdmin), + }, &types.SessionRecordingConfigV2{}) + require.NoError(t, err, "creating authorization context") + + env, err := newTestEnv(withAuthorizer(fakeAuthorizer{authzContext: authzContext})) + require.NoError(t, err, "creating test service") + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + require.NoError(t, generateUserSecrets(llama), "generating user secrets") + + // Validate that the user does not exist. + resp, err := env.GetUser(ctx, &userspb.GetUserRequest{Name: llama.GetName()}) + assert.Error(t, err, "expected retrieving nonexistent user to fail") + assert.Nil(t, resp, "non-nil response returned from error") + assert.True(t, trace.IsNotFound(err), "expected not found error got %T", err) + + // Create a new user. + created, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + require.NoError(t, err, "creating user llama") + + // Validate that the user now exists and that querying by name takes precedence over + // retrieving the current user. + resp, err = env.GetUser(ctx, &userspb.GetUserRequest{Name: created.GetName(), CurrentUser: true}) + assert.NoError(t, err, "failed getting created user") + assert.Empty(t, cmp.Diff(created, resp, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"), cmpopts.IgnoreFields(types.UserSpecV2{}, "LocalAuth"))) + assert.Nil(t, resp.GetLocalAuth(), "user secrets were provided when not requested") + + // Validate that getting the current user returns "alice" and not "llama". + resp, err = env.GetUser(authz.ContextWithUser(ctx, &authz.LocalUser{ + Username: "alice", + Identity: tlsca.Identity{ + Groups: []string{"dev"}, + }, + }), &userspb.GetUserRequest{CurrentUser: true}) + assert.NoError(t, err, "failed getting created user") + assert.NotEmpty(t, cmp.Diff(created, resp, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + assert.Equal(t, "alice", resp.GetName(), "expected current user to return alice") + assert.Nil(t, resp.GetLocalAuth(), "secrets returned with current user") + + // Validate that requesting a users secrets returns them. + resp, err = env.GetUser(ctx, &userspb.GetUserRequest{Name: created.GetName(), WithSecrets: true}) + assert.NoError(t, err, "failed getting created user") + assert.Empty(t, cmp.Diff(created, resp, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + assert.NotNil(t, resp.GetLocalAuth(), "user secrets were not provided requested") + assert.Empty(t, cmp.Diff(llama.GetLocalAuth(), resp.GetLocalAuth()), "user secrets do not match") + + // Validate that getting the current user never returns secrets + resp, err = env.GetUser(authz.ContextWithUser(ctx, &authz.LocalUser{ + Username: "alice", + Identity: tlsca.Identity{ + Groups: []string{"dev"}, + }, + }), &userspb.GetUserRequest{CurrentUser: true, WithSecrets: true}) + assert.NoError(t, err, "failed getting created user") + assert.NotEmpty(t, cmp.Diff(created, resp, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + assert.Equal(t, "alice", resp.GetName(), "expected current user to return alice") + assert.Nil(t, resp.GetLocalAuth(), "secrets returned with current user") +} + +func TestUpdateUser(t *testing.T) { + t.Parallel() + env, err := newTestEnv() + require.NoError(t, err, "creating test service") + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + + // Attempt to update a nonexistent user. + updated, err := env.UpdateUser(ctx, &userspb.UpdateUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "duplicate user was created successfully") + assert.Nil(t, updated, "received unexpected user") + require.True(t, trace.IsNotFound(err), "updated nonexistent user") + + // Create a new user. + created, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + require.NoError(t, err, "creating user llama") + + event := <-env.emitter.C() + assert.Equal(t, events.UserCreateEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserCreateCode, event.GetCode(), "unexpected event code") + + // Attempt to update the user again. + created.SetLogins([]string{"alpaca"}) + updated, err = env.UpdateUser(ctx, &userspb.UpdateUserRequest{User: created}) + require.NoError(t, err, "failed updating user") + require.Empty(t, cmp.Diff(created, updated, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + require.Equal(t, []string{"alpaca"}, updated.GetLogins(), "logins were not updated") + + event = <-env.emitter.C() + assert.Equal(t, events.UserUpdatedEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserUpdateCode, event.GetCode(), "unexpected event code") +} + +func TestUpsertUser(t *testing.T) { + t.Parallel() + env, err := newTestEnv() + require.NoError(t, err, "creating test service") + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + + // Create a user via upsert. + upserted, err := env.UpsertUser(ctx, &userspb.UpsertUserRequest{User: llama.(*types.UserV2)}) + require.NoError(t, err, "failed upserting user") + + // Validate that the user was created. + created, err := env.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "duplicate user was created successfully") + assert.Nil(t, created, "received unexpected user ") + require.True(t, trace.IsAlreadyExists(err), "creating duplicate user allowed") + + event := <-env.emitter.C() + assert.Equal(t, events.UserCreateEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserCreateCode, event.GetCode(), "unexpected event code") + + // Attempt to update the user again. + upserted.SetLogins([]string{"alpaca"}) + updated, err := env.UpsertUser(ctx, &userspb.UpsertUserRequest{User: upserted}) + require.NoError(t, err, "failed upserting user") + require.Empty(t, cmp.Diff(upserted, updated, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + require.Equal(t, []string{"alpaca"}, updated.GetLogins(), "logins were not updated") + + event = <-env.emitter.C() + assert.Equal(t, events.UserCreateEvent, event.GetType(), "unexpected event type") + assert.Equal(t, events.UserCreateCode, event.GetCode(), "unexpected event code") +} + +func generateUserSecrets(u types.User) error { + hash, err := bcrypt.GenerateFromPassword([]byte("insecure"), bcrypt.MinCost) + if err != nil { + return trace.Wrap(err) + } + + dev, err := services.NewTOTPDevice("otp", base32.StdEncoding.EncodeToString([]byte("abc123")), time.Now()) + if err != nil { + return trace.Wrap(err) + } + + u.SetLocalAuth(&types.LocalAuthSecrets{ + PasswordHash: hash, + MFA: []*types.MFADevice{dev}, + }) + return nil +} + +func TestRBAC(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + llama, err := types.NewUser("llama") + require.NoError(t, err, "creating new user llama") + + tests := []struct { + desc string + f func(t *testing.T, service *Service) + checker *fakeChecker + expectChecks []check + }{ + { + desc: "get no access", + f: func(t *testing.T, service *Service) { + _, err := service.GetUser(ctx, &userspb.GetUserRequest{Name: "alice"}) + assert.Error(t, err, "expected RBAC to prevent getting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + {kind: types.KindUser, verb: types.VerbRead}, + }, + }, + { + desc: "get current users when no access", + f: func(t *testing.T, service *Service) { + user, err := service.GetUser(ctx, &userspb.GetUserRequest{CurrentUser: true}) + assert.NoError(t, err, "expected RBAC to allow getting the current user") + assert.Empty(t, cmp.Diff(llama, user, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + }, + { + desc: "get with secrets no access", + f: func(t *testing.T, service *Service) { + _, err := service.GetUser(ctx, &userspb.GetUserRequest{Name: "alice", WithSecrets: true}) + assert.Error(t, err, "expected RBAC to prevent getting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbRead, types.VerbCreate, types.VerbList}, + }, + }, + }, + expectChecks: []check{}, + }, + { + desc: "create no access", + f: func(t *testing.T, service *Service) { + _, err := service.CreateUser(ctx, &userspb.CreateUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "expected RBAC to prevent creating user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + }, + }, + { + desc: "create", + f: func(t *testing.T, service *Service) { + u := utils.CloneProtoMsg(llama.(*types.UserV2)) + u.SetName("alpaca") + created, err := service.CreateUser(ctx, &userspb.CreateUserRequest{User: u}) + assert.NoError(t, err, "expected RBAC to allow creating user") + assert.Empty(t, cmp.Diff(u, created, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbCreate}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + }, + }, + { + desc: "update no access", + f: func(t *testing.T, service *Service) { + _, err := service.UpdateUser(ctx, &userspb.UpdateUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "expected RBAC to prevent updating user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "update", + f: func(t *testing.T, service *Service) { + u := utils.CloneProtoMsg(llama.(*types.UserV2)) + u.SetLogins([]string{"alpaca"}) + updated, err := service.UpdateUser(ctx, &userspb.UpdateUserRequest{User: u}) + assert.NoError(t, err, "expected RBAC to allow updating user") + assert.Empty(t, cmp.Diff(u, updated, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbUpdate}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "upsert no access", + f: func(t *testing.T, service *Service) { + _, err := service.UpsertUser(ctx, &userspb.UpsertUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "expected RBAC to prevent upserting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "upsert without create", + f: func(t *testing.T, service *Service) { + _, err := service.UpsertUser(ctx, &userspb.UpsertUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "expected RBAC to prevent upserting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbUpdate}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "upsert without update", + f: func(t *testing.T, service *Service) { + _, err := service.UpsertUser(ctx, &userspb.UpsertUserRequest{User: llama.(*types.UserV2)}) + assert.Error(t, err, "expected RBAC to prevent upserting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbCreate}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "upsert", + f: func(t *testing.T, service *Service) { + upserted, err := service.UpsertUser(ctx, &userspb.UpsertUserRequest{User: llama.(*types.UserV2)}) + assert.NoError(t, err, "expected RBAC to allow updating user") + assert.Empty(t, cmp.Diff(llama, upserted, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Revision"))) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbCreate, types.VerbUpdate}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbCreate}, + {kind: types.KindUser, verb: types.VerbUpdate}, + }, + }, + { + desc: "delete no access", + f: func(t *testing.T, service *Service) { + _, err := service.DeleteUser(ctx, &userspb.DeleteUserRequest{Name: llama.GetName()}) + assert.Error(t, err, "expected RBAC to prevent deleting user") + assert.True(t, trace.IsAccessDenied(err), "expected access denied error got %T", err) + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbDelete}, + }, + }, + { + desc: "delete", + f: func(t *testing.T, service *Service) { + _, err := service.DeleteUser(ctx, &userspb.DeleteUserRequest{Name: llama.GetName()}) + assert.NoError(t, err, "expected RBAC to allow deleting user") + }, + checker: &fakeChecker{ + rules: []types.Rule{ + { + Resources: []string{types.KindUser}, + Verbs: []string{types.VerbDelete}, + }, + }, + }, + expectChecks: []check{ + {kind: types.KindUser, verb: types.VerbDelete}, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + + env, err := newTestEnv(withAuthorizer(&fakeAuthorizer{authzContext: &authz.Context{ + User: llama, + Checker: test.checker, + Identity: authz.LocalUser{ + Username: "alice", + Identity: tlsca.Identity{ + Groups: []string{"dev"}, + }, + }, + }})) + require.NoError(t, err, "creating test service") + + // Create the user directly on the backend to bypass RBAC enforced by the test cases. + _, err = env.backend.CreateUser(ctx, llama.(*types.UserV2)) + require.NoError(t, err, "creating test user") + + // Validate RBAC is enforced. + test.f(t, env.Service) + require.ElementsMatch(t, test.expectChecks, test.checker.checks) + }) + } + +}