From f9127a4e6b7a8dfbedd5c62f576d4d6ebfa03573 Mon Sep 17 00:00:00 2001 From: Andrew Burke <31974658+atburke@users.noreply.github.com> Date: Fri, 16 Aug 2024 13:15:21 -0700 Subject: [PATCH] Add static host users gRPC service (#45292) This change adds the gRPC service for static host users. --- api/client/client.go | 7 + api/client/statichostuser/statichostuser.go | 94 +++++ lib/auth/auth.go | 9 + lib/auth/grpcserver.go | 11 + lib/auth/init.go | 4 + lib/auth/statichostuser/service.go | 176 +++++++++ lib/auth/statichostuser/service_test.go | 386 ++++++++++++++++++++ 7 files changed, 687 insertions(+) create mode 100644 api/client/statichostuser/statichostuser.go create mode 100644 lib/auth/statichostuser/service.go create mode 100644 lib/auth/statichostuser/service_test.go diff --git a/api/client/client.go b/api/client/client.go index e6e8c6d27eef5..20d45bf22a9b7 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -57,6 +57,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/scim" "github.com/gravitational/teleport/api/client/secreport" + statichostuserclient "github.com/gravitational/teleport/api/client/statichostuser" "github.com/gravitational/teleport/api/client/userloginstate" "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/defaults" @@ -84,6 +85,7 @@ import ( secreportsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/secreports/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" userloginstatev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/userloginstate/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v1" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" userpreferencespb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" @@ -3264,6 +3266,11 @@ func (c *Client) DeleteKubernetesWaitingContainer(ctx context.Context, req *kube return c.GetKubernetesWaitingContainerClient().DeleteKubernetesWaitingContainer(ctx, req) } +// StaticHostUserClient returns a new static host user client. +func (c *Client) StaticHostUserClient() *statichostuserclient.Client { + return statichostuserclient.NewClient(userprovisioningpb.NewStaticHostUsersServiceClient(c.conn)) +} + // CreateDatabase creates a new database resource. func (c *Client) CreateDatabase(ctx context.Context, database types.Database) error { databaseV3, ok := database.(*types.DatabaseV3) diff --git a/api/client/statichostuser/statichostuser.go b/api/client/statichostuser/statichostuser.go new file mode 100644 index 0000000000000..f8100ff9998fc --- /dev/null +++ b/api/client/statichostuser/statichostuser.go @@ -0,0 +1,94 @@ +// Copyright 2024 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 statichostuser + +import ( + "context" + + "github.com/gravitational/trace" + + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v1" +) + +// Client is a StaticHostUser client. +type Client struct { + grpcClient userprovisioningpb.StaticHostUsersServiceClient +} + +// NewClient creates a new StaticHostUser client. +func NewClient(grpcClient userprovisioningpb.StaticHostUsersServiceClient) *Client { + return &Client{ + grpcClient: grpcClient, + } +} + +// ListStaticHostUsers lists static host users. +func (c *Client) ListStaticHostUsers(ctx context.Context, pageSize int, pageToken string) ([]*userprovisioningpb.StaticHostUser, string, error) { + resp, err := c.grpcClient.ListStaticHostUsers(ctx, &userprovisioningpb.ListStaticHostUsersRequest{ + PageSize: int32(pageSize), + PageToken: pageToken, + }) + if err != nil { + return nil, "", trace.Wrap(err) + } + return resp.Users, resp.NextPageToken, nil +} + +// GetStaticHostUser returns a static host user by name. +func (c *Client) GetStaticHostUser(ctx context.Context, name string) (*userprovisioningpb.StaticHostUser, error) { + if name == "" { + return nil, trace.BadParameter("missing name") + } + out, err := c.grpcClient.GetStaticHostUser(ctx, &userprovisioningpb.GetStaticHostUserRequest{ + Name: name, + }) + return out, trace.Wrap(err) +} + +// CreateStaticHostUser creates a static host user. +func (c *Client) CreateStaticHostUser(ctx context.Context, in *userprovisioningpb.StaticHostUser) (*userprovisioningpb.StaticHostUser, error) { + out, err := c.grpcClient.CreateStaticHostUser(ctx, &userprovisioningpb.CreateStaticHostUserRequest{ + User: in, + }) + return out, trace.Wrap(err) +} + +// UpdateStaticHostUser updates a static host user. +func (c *Client) UpdateStaticHostUser(ctx context.Context, in *userprovisioningpb.StaticHostUser) (*userprovisioningpb.StaticHostUser, error) { + out, err := c.grpcClient.UpdateStaticHostUser(ctx, &userprovisioningpb.UpdateStaticHostUserRequest{ + User: in, + }) + return out, trace.Wrap(err) +} + +// UpsertStaticHostUser upserts a static host user. +func (c *Client) UpsertStaticHostUser(ctx context.Context, in *userprovisioningpb.StaticHostUser) (*userprovisioningpb.StaticHostUser, error) { + out, err := c.grpcClient.UpsertStaticHostUser(ctx, &userprovisioningpb.UpsertStaticHostUserRequest{ + User: in, + }) + return out, trace.Wrap(err) +} + +// DeleteStaticHostUser deletes a static host user. Note that this does not +// remove any host users created on nodes from the resource. +func (c *Client) DeleteStaticHostUser(ctx context.Context, name string) error { + if name == "" { + return trace.BadParameter("missing name") + } + _, err := c.grpcClient.DeleteStaticHostUser(ctx, &userprovisioningpb.DeleteStaticHostUserRequest{ + Name: name, + }) + return trace.Wrap(err) +} diff --git a/lib/auth/auth.go b/lib/auth/auth.go index f78cc18683f11..b16071f63bd41 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -391,6 +391,13 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { } } + if cfg.StaticHostUsers == nil { + cfg.StaticHostUsers, err = local.NewStaticHostUserService(cfg.Backend) + if err != nil { + return nil, trace.Wrap(err) + } + } + closeCtx, cancelFunc := context.WithCancel(context.TODO()) services := &Services{ TrustInternal: cfg.Trust, @@ -430,6 +437,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { CrownJewels: cfg.CrownJewels, BotInstance: cfg.BotInstance, SPIFFEFederations: cfg.SPIFFEFederations, + StaticHostUser: cfg.StaticHostUsers, } as := Server{ @@ -627,6 +635,7 @@ type Services struct { services.AccessGraphSecretsGetter services.DevicesGetter services.BotInstance + services.StaticHostUser } // GetWebSession returns existing web session described by req. diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 408ce27189130..283ae4245e660 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -66,6 +66,7 @@ import ( presencev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/presence/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" userloginstatev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/userloginstate/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v1" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" userpreferencespb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" @@ -89,6 +90,7 @@ import ( notifications "github.com/gravitational/teleport/lib/auth/notifications/notificationsv1" "github.com/gravitational/teleport/lib/auth/okta" "github.com/gravitational/teleport/lib/auth/presence/presencev1" + statichostuserv1 "github.com/gravitational/teleport/lib/auth/statichostuser" "github.com/gravitational/teleport/lib/auth/trust/trustv1" "github.com/gravitational/teleport/lib/auth/userloginstate" "github.com/gravitational/teleport/lib/auth/userpreferences/userpreferencesv1" @@ -5422,6 +5424,15 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { vnetConfigServiceServer := vnetconfig.NewService(vnetConfigStorage, cfg.Authorizer) vnet.RegisterVnetConfigServiceServer(server, vnetConfigServiceServer) + staticHostUserServer, err := statichostuserv1.NewService(statichostuserv1.ServiceConfig{ + Authorizer: cfg.Authorizer, + Backend: cfg.AuthServer.Services, + }) + if err != nil { + return nil, trace.Wrap(err) + } + userprovisioningpb.RegisterStaticHostUsersServiceServer(server, staticHostUserServer) + // Only register the service if this is an open source build. Enterprise builds // register the actual service via an auth plugin, if we register here then all // Enterprise builds would fail with a duplicate service registered error. diff --git a/lib/auth/init.go b/lib/auth/init.go index 64aae856f22e2..974ae19f13cda 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -309,6 +309,10 @@ type InitConfig struct { // SPIFFEFederations is a service that manages storing SPIFFE federations. SPIFFEFederations services.SPIFFEFederations + + // StaticHostUsers is a service that manages host users that should be + // created on SSH nodes. + StaticHostUsers services.StaticHostUser } // Init instantiates and configures an instance of AuthServer diff --git a/lib/auth/statichostuser/service.go b/lib/auth/statichostuser/service.go new file mode 100644 index 0000000000000..e216a21f5a74b --- /dev/null +++ b/lib/auth/statichostuser/service.go @@ -0,0 +1,176 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package statichostuser + +import ( + "context" + + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/emptypb" + + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/services" +) + +// ServiceConfig holds configuration options for the static host user gRPC service. +type ServiceConfig struct { + // Authorizer is the authorizer used to check access to resources. + Authorizer authz.Authorizer + // Backend is the backend used to store static host users. + Backend services.StaticHostUser + // TODO(atburke): add cache +} + +// Service implements the static host user RPC service. +type Service struct { + userprovisioningpb.UnimplementedStaticHostUsersServiceServer + + authorizer authz.Authorizer + backend services.StaticHostUser +} + +// NewService creates a new static host user gRPC service. +func NewService(cfg ServiceConfig) (*Service, error) { + switch { + case cfg.Backend == nil: + return nil, trace.BadParameter("backend is required") + case cfg.Authorizer == nil: + return nil, trace.BadParameter("authorizer is required") + } + + return &Service{ + authorizer: cfg.Authorizer, + backend: cfg.Backend, + }, nil +} + +// ListStaticHostUsers lists static host users. +func (s *Service) ListStaticHostUsers(ctx context.Context, req *userprovisioningpb.ListStaticHostUsersRequest) (*userprovisioningpb.ListStaticHostUsersResponse, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbList, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + // TODO(atburke): Switch to using the cache after static host users have been added to the cache. + users, nextToken, err := s.backend.ListStaticHostUsers(ctx, int(req.PageSize), req.PageToken) + if err != nil { + return nil, trace.Wrap(err) + } + return &userprovisioningpb.ListStaticHostUsersResponse{ + Users: users, + NextPageToken: nextToken, + }, nil +} + +// GetStaticHostUser returns a static host user by name. +func (s *Service) GetStaticHostUser(ctx context.Context, req *userprovisioningpb.GetStaticHostUserRequest) (*userprovisioningpb.StaticHostUser, error) { + if req.Name == "" { + return nil, trace.BadParameter("missing name") + } + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + // TODO(atburke): Switch to using the cache after static host users have been added to the cache. + out, err := s.backend.GetStaticHostUser(ctx, req.Name) + return out, trace.Wrap(err) +} + +// CreateStaticHostUser creates a static host user. +func (s *Service) CreateStaticHostUser(ctx context.Context, req *userprovisioningpb.CreateStaticHostUserRequest) (*userprovisioningpb.StaticHostUser, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + out, err := s.backend.CreateStaticHostUser(ctx, req.User) + return out, trace.Wrap(err) +} + +// UpdateStaticHostUser updates a static host user. +func (s *Service) UpdateStaticHostUser(ctx context.Context, req *userprovisioningpb.UpdateStaticHostUserRequest) (*userprovisioningpb.StaticHostUser, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + out, err := s.backend.UpdateStaticHostUser(ctx, req.User) + return out, trace.Wrap(err) +} + +// UpsertStaticHostUser upserts a static host user. +func (s *Service) UpsertStaticHostUser(ctx context.Context, req *userprovisioningpb.UpsertStaticHostUserRequest) (*userprovisioningpb.StaticHostUser, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbCreate, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + out, err := s.backend.UpsertStaticHostUser(ctx, req.User) + return out, trace.Wrap(err) +} + +// DeleteStaticHostUser deletes a static host user. Note that this does not +// remove any host users created on nodes from the resource. +func (s *Service) DeleteStaticHostUser(ctx context.Context, req *userprovisioningpb.DeleteStaticHostUserRequest) (*emptypb.Empty, error) { + if req.Name == "" { + return nil, trace.BadParameter("missing name") + } + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindStaticHostUser, types.VerbDelete); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + return &emptypb.Empty{}, trace.Wrap(s.backend.DeleteStaticHostUser(ctx, req.Name)) +} diff --git a/lib/auth/statichostuser/service_test.go b/lib/auth/statichostuser/service_test.go new file mode 100644 index 0000000000000..1d6854ad8d1fc --- /dev/null +++ b/lib/auth/statichostuser/service_test.go @@ -0,0 +1,386 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package statichostuser + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/userprovisioning" + "github.com/gravitational/teleport/api/types/wrappers" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/tlsca" +) + +type authorizerFactory func(t *testing.T, client localClient) authz.Authorizer + +func staticHostUserName(i int) string { + return fmt.Sprintf("user-%d", i) +} + +func makeStaticHostUser(i int) *userprovisioningpb.StaticHostUser { + name := staticHostUserName(i) + return userprovisioning.NewStaticHostUser(name, &userprovisioningpb.StaticHostUserSpec{ + Login: name, + Groups: []string{"foo", "bar"}, + NodeLabels: &wrappers.LabelValues{ + Values: map[string]wrappers.StringValues{ + "foo": { + Values: []string{"bar"}, + }, + }, + }, + }) +} + +func authorizeWithVerbs(verbs []string, mfaVerified bool) authorizerFactory { + return func(t *testing.T, client localClient) authz.Authorizer { + return authz.AuthorizerFunc(func(ctx context.Context) (*authz.Context, error) { + authzContext := authorizerForDummyUser(t, ctx, client, verbs) + if mfaVerified { + authzContext.AdminActionAuthState = authz.AdminActionAuthMFAVerified + } else { + authzContext.AdminActionAuthState = authz.AdminActionAuthUnauthorized + } + return authzContext, nil + }) + } +} + +func assertTraceErr(f func(error) bool) require.ErrorAssertionFunc { + return func(t require.TestingT, err error, _ ...any) { + require.Error(t, err) + require.True(t, f(err), "unexpected error: %v", err) + } +} + +func TestStaticHostUserCRUD(t *testing.T) { + t.Parallel() + + accessTests := []struct { + name string + request func(ctx context.Context, svc *Service, localSvc *local.StaticHostUserService) error + allowVerbs []string + }{ + { + name: "list", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.ListStaticHostUsers(ctx, &userprovisioningpb.ListStaticHostUsersRequest{}) + return err + }, + allowVerbs: []string{types.VerbList, types.VerbRead}, + }, + { + name: "get", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.GetStaticHostUser(ctx, &userprovisioningpb.GetStaticHostUserRequest{ + Name: staticHostUserName(0), + }) + return err + }, + allowVerbs: []string{types.VerbRead}, + }, + { + name: "create", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.CreateStaticHostUser(ctx, &userprovisioningpb.CreateStaticHostUserRequest{ + User: makeStaticHostUser(10), + }) + return err + }, + allowVerbs: []string{types.VerbCreate}, + }, + { + name: "update", + request: func(ctx context.Context, svc *Service, localSvc *local.StaticHostUserService) error { + // Get the initial user from the local service to bypass RBAC. + hostUser, err := localSvc.GetStaticHostUser(ctx, staticHostUserName(0)) + if err != nil { + return trace.Wrap(err) + } + hostUser.Spec.Login = "bob" + _, err = svc.UpdateStaticHostUser(ctx, &userprovisioningpb.UpdateStaticHostUserRequest{ + User: hostUser, + }) + return err + }, + allowVerbs: []string{types.VerbRead, types.VerbUpdate}, + }, + { + name: "upsert", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.UpsertStaticHostUser(ctx, &userprovisioningpb.UpsertStaticHostUserRequest{ + User: makeStaticHostUser(10), + }) + return err + }, + allowVerbs: []string{types.VerbCreate, types.VerbUpdate}, + }, + { + name: "delete", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.DeleteStaticHostUser(ctx, &userprovisioningpb.DeleteStaticHostUserRequest{ + Name: staticHostUserName(0), + }) + return err + }, + allowVerbs: []string{types.VerbDelete}, + }, + } + + for _, tc := range accessTests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + + t.Run("allow", func(t *testing.T) { + t.Parallel() + // Create authorizer with required verbs. + authorizer := authorizeWithVerbs(tc.allowVerbs, true) + // CRUD action should succeed. + testStaticHostUserAccess(t, authorizer, tc.request, require.NoError) + }) + + t.Run("deny rbac", func(t *testing.T) { + t.Parallel() + // Create authorizer without required verbs. + authorizer := authorizeWithVerbs(nil, true) + // CRUD action should fail. + testStaticHostUserAccess(t, authorizer, tc.request, assertTraceErr(trace.IsAccessDenied)) + }) + + t.Run("deny mfa", func(t *testing.T) { + t.Parallel() + // Create authorizer without verified MFA. + authorizer := authorizeWithVerbs(tc.allowVerbs, false) + // CRUD action should fail. + testStaticHostUserAccess(t, authorizer, tc.request, assertTraceErr(trace.IsAccessDenied)) + }) + }) + } + + otherTests := []struct { + name string + request func(ctx context.Context, svc *Service, localSvc *local.StaticHostUserService) error + verbs []string + assert require.ErrorAssertionFunc + }{ + { + name: "get nonexistent resource", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.GetStaticHostUser(ctx, &userprovisioningpb.GetStaticHostUserRequest{ + Name: "fake", + }) + return err + }, + verbs: []string{types.VerbRead}, + assert: assertTraceErr(trace.IsNotFound), + }, + { + name: "create resource twice", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.CreateStaticHostUser(ctx, &userprovisioningpb.CreateStaticHostUserRequest{ + User: makeStaticHostUser(0), + }) + return err + }, + verbs: []string{types.VerbCreate}, + assert: assertTraceErr(trace.IsAlreadyExists), + }, + { + name: "delete nonexisting resource", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.DeleteStaticHostUser(ctx, &userprovisioningpb.DeleteStaticHostUserRequest{ + Name: staticHostUserName(10), + }) + return err + }, + verbs: []string{types.VerbDelete}, + assert: assertTraceErr(trace.IsNotFound), + }, + { + name: "update with wrong revision", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.UpdateStaticHostUser(ctx, &userprovisioningpb.UpdateStaticHostUserRequest{ + User: makeStaticHostUser(0), + }) + return err + }, + verbs: []string{types.VerbUpdate}, + assert: assertTraceErr(trace.IsCompareFailed), + }, + { + name: "update nonexistent resource", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.UpdateStaticHostUser(ctx, &userprovisioningpb.UpdateStaticHostUserRequest{ + User: makeStaticHostUser(10), + }) + return err + }, + verbs: []string{types.VerbUpdate}, + assert: assertTraceErr(trace.IsCompareFailed), + }, + { + name: "upsert with update permission only", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.UpsertStaticHostUser(ctx, &userprovisioningpb.UpsertStaticHostUserRequest{ + User: makeStaticHostUser(0), + }) + return err + }, + verbs: []string{types.VerbUpdate}, + assert: assertTraceErr(trace.IsAccessDenied), + }, + { + name: "upsert with create permission only", + request: func(ctx context.Context, svc *Service, _ *local.StaticHostUserService) error { + _, err := svc.UpsertStaticHostUser(ctx, &userprovisioningpb.UpsertStaticHostUserRequest{ + User: makeStaticHostUser(10), + }) + return err + }, + verbs: []string{types.VerbCreate}, + assert: assertTraceErr(trace.IsAccessDenied), + }, + } + for _, tc := range otherTests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + authorizer := authorizeWithVerbs(tc.verbs, true) + testStaticHostUserAccess(t, authorizer, tc.request, tc.assert) + }) + } +} + +func testStaticHostUserAccess( + t *testing.T, + authorizer func(t *testing.T, client localClient) authz.Authorizer, + request func(ctx context.Context, svc *Service, localSvc *local.StaticHostUserService) error, + assert require.ErrorAssertionFunc, +) { + ctx, resourceSvc, localSvc := initSvc(t, authorizer) + err := request(ctx, resourceSvc, localSvc) + assert(t, err) +} + +func authorizerForDummyUser(t *testing.T, ctx context.Context, localClient localClient, roleVerbs []string) *authz.Context { + const clusterName = "localhost" + + // Create role + roleName := "role-" + uuid.NewString() + var allowRules []types.Rule + if len(roleVerbs) != 0 { + allowRules = []types.Rule{ + { + Resources: []string{types.KindStaticHostUser}, + Verbs: roleVerbs, + }, + } + } + role, err := types.NewRole(roleName, types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: allowRules}, + }) + require.NoError(t, err) + + role, err = localClient.CreateRole(ctx, role) + require.NoError(t, err) + + // Create user + user, err := types.NewUser("user-" + uuid.NewString()) + require.NoError(t, err) + user.AddRole(roleName) + user, err = localClient.CreateUser(ctx, user) + require.NoError(t, err) + + localUser := authz.LocalUser{ + Username: user.GetName(), + Identity: tlsca.Identity{ + Username: user.GetName(), + Groups: []string{role.GetName()}, + }, + } + authCtx, err := authz.ContextForLocalUser(ctx, localUser, localClient, clusterName, true) + require.NoError(t, err) + + return authCtx +} + +type localClient interface { + authz.AuthorizerAccessPoint + + CreateUser(ctx context.Context, user types.User) (types.User, error) + CreateRole(ctx context.Context, role types.Role) (types.Role, error) +} + +func initSvc(t *testing.T, authorizerFn func(t *testing.T, client localClient) authz.Authorizer) (context.Context, *Service, *local.StaticHostUserService) { + ctx := context.Background() + backend, err := memory.New(memory.Config{}) + require.NoError(t, err) + + roleSvc := local.NewAccessService(backend) + userSvc := local.NewTestIdentityService(backend) + clusterSrv, err := local.NewClusterConfigurationService(backend) + require.NoError(t, err) + caSrv := local.NewCAService(backend) + + clusterConfigSvc, err := local.NewClusterConfigurationService(backend) + require.NoError(t, err) + _, err = clusterConfigSvc.UpsertAuthPreference(ctx, types.DefaultAuthPreference()) + require.NoError(t, err) + _, err = clusterConfigSvc.UpsertClusterAuditConfig(ctx, types.DefaultClusterAuditConfig()) + require.NoError(t, err) + _, err = clusterConfigSvc.UpsertClusterNetworkingConfig(ctx, types.DefaultClusterNetworkingConfig()) + require.NoError(t, err) + _, err = clusterConfigSvc.UpsertSessionRecordingConfig(ctx, types.DefaultSessionRecordingConfig()) + require.NoError(t, err) + + localResourceService, err := local.NewStaticHostUserService(backend) + require.NoError(t, err) + for i := 0; i < 10; i++ { + _, err := localResourceService.CreateStaticHostUser(ctx, makeStaticHostUser(i)) + require.NoError(t, err) + } + + client := struct { + *local.AccessService + *local.IdentityService + *local.ClusterConfigurationService + *local.CA + }{ + AccessService: roleSvc, + IdentityService: userSvc, + ClusterConfigurationService: clusterSrv, + CA: caSrv, + } + + resourceSvc, err := NewService(ServiceConfig{ + Authorizer: authorizerFn(t, client), + Backend: localResourceService, + }) + require.NoError(t, err) + + return ctx, resourceSvc, localResourceService +}