From 98177f3c3fd8ce5b0cc506d556600a79773e594c Mon Sep 17 00:00:00 2001 From: Marco Dinis Date: Thu, 19 Sep 2024 10:56:41 +0100 Subject: [PATCH] User Tasks: services and clients implementation (#46131) This PR adds the implementation for the User Tasks: - services (backend+cache) - clients (API + tctl) - light validation to set up the path for later PRs --- api/client/client.go | 10 + api/client/events.go | 8 + api/client/usertask/usertask.go | 106 +++++++ api/types/constants.go | 3 + api/types/usertasks/object.go | 95 ++++++ api/types/usertasks/object_test.go | 69 ++++ lib/auth/accesspoint/accesspoint.go | 2 + lib/auth/auth.go | 8 + lib/auth/authclient/api.go | 14 + lib/auth/authclient/clt.go | 9 + lib/auth/grpcserver.go | 12 + lib/auth/helpers.go | 1 + lib/auth/init.go | 3 + lib/auth/usertasks/usertasksv1/service.go | 206 ++++++++++++ .../usertasks/usertasksv1/service_test.go | 158 ++++++++++ lib/authz/permissions.go | 2 + lib/cache/cache.go | 40 +++ lib/cache/cache_test.go | 61 ++++ lib/cache/collections.go | 61 ++++ lib/service/service.go | 4 + lib/services/local/events.go | 30 ++ lib/services/local/user_task.go | 101 ++++++ lib/services/local/user_task_test.go | 298 ++++++++++++++++++ lib/services/presets.go | 1 + lib/services/resource.go | 2 + lib/services/user_task.go | 53 ++++ lib/services/user_task_test.go | 134 ++++++++ lib/services/useracl.go | 4 + tool/tctl/common/collection.go | 30 ++ tool/tctl/common/resource_command.go | 68 ++++ 30 files changed, 1593 insertions(+) create mode 100644 api/client/usertask/usertask.go create mode 100644 api/types/usertasks/object.go create mode 100644 api/types/usertasks/object_test.go create mode 100644 lib/auth/usertasks/usertasksv1/service.go create mode 100644 lib/auth/usertasks/usertasksv1/service_test.go create mode 100644 lib/services/local/user_task.go create mode 100644 lib/services/local/user_task_test.go create mode 100644 lib/services/user_task.go create mode 100644 lib/services/user_task_test.go diff --git a/api/client/client.go b/api/client/client.go index e5eedbdd9d536..affe48678d688 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -61,6 +61,7 @@ import ( "github.com/gravitational/teleport/api/client/secreport" statichostuserclient "github.com/gravitational/teleport/api/client/statichostuser" "github.com/gravitational/teleport/api/client/userloginstate" + usertaskapi "github.com/gravitational/teleport/api/client/usertask" "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/defaults" accesslistv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accesslist/v1" @@ -90,6 +91,7 @@ import ( userloginstatev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/userloginstate/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + usertaskv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" userpreferencespb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" "github.com/gravitational/teleport/api/internalutils/stream" @@ -4688,6 +4690,14 @@ func (c *Client) UserLoginStateClient() *userloginstate.Client { return userloginstate.NewClient(userloginstatev1.NewUserLoginStateServiceClient(c.conn)) } +// UserTasksServiceClient returns a UserTask client. +// Clients connecting to older Teleport versions, still get a UserTask client +// when calling this method, but all RPCs will return "not implemented" errors +// (as per the default gRPC behavior). +func (c *Client) UserTasksServiceClient() *usertaskapi.Client { + return usertaskapi.NewClient(usertaskv1.NewUserTaskServiceClient(c.conn)) +} + // GetCertAuthority retrieves a CA by type and domain. func (c *Client) GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) { ca, err := c.TrustClient().GetCertAuthority(ctx, &trustpb.GetCertAuthorityRequest{ diff --git a/api/client/events.go b/api/client/events.go index 73766c3f64240..fd8c3b8b357d9 100644 --- a/api/client/events.go +++ b/api/client/events.go @@ -27,6 +27,7 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" accesslistv1conv "github.com/gravitational/teleport/api/types/accesslist/convert/v1" @@ -108,6 +109,10 @@ func EventToGRPC(in types.Event) (*proto.Event, error) { out.Resource = &proto.Event_AutoUpdateVersion{ AutoUpdateVersion: r, } + case *usertasksv1.UserTask: + out.Resource = &proto.Event_UserTask{ + UserTask: r, + } default: return nil, trace.BadParameter("resource type %T is not supported", r) } @@ -557,6 +562,9 @@ func EventFromGRPC(in *proto.Event) (*types.Event, error) { } else if r := in.GetAutoUpdateVersion(); r != nil { out.Resource = types.Resource153ToLegacy(r) return &out, nil + } else if r := in.GetUserTask(); r != nil { + out.Resource = types.Resource153ToLegacy(r) + return &out, nil } else { return nil, trace.BadParameter("received unsupported resource %T", in.Resource) } diff --git a/api/client/usertask/usertask.go b/api/client/usertask/usertask.go new file mode 100644 index 0000000000000..5cb92983c3b8e --- /dev/null +++ b/api/client/usertask/usertask.go @@ -0,0 +1,106 @@ +// 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 usertask + +import ( + "context" + + "github.com/gravitational/trace" + + usertaskv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" +) + +// Client is a client for the User Task API. +type Client struct { + grpcClient usertaskv1.UserTaskServiceClient +} + +// NewClient creates a new User Task client. +func NewClient(grpcClient usertaskv1.UserTaskServiceClient) *Client { + return &Client{ + grpcClient: grpcClient, + } +} + +// ListUserTasks returns a list of User Tasks. +func (c *Client) ListUserTasks(ctx context.Context, pageSize int64, nextToken string) ([]*usertaskv1.UserTask, string, error) { + resp, err := c.grpcClient.ListUserTasks(ctx, &usertaskv1.ListUserTasksRequest{ + PageSize: pageSize, + PageToken: nextToken, + }) + if err != nil { + return nil, "", trace.Wrap(err) + } + + return resp.UserTasks, resp.NextPageToken, nil +} + +// CreateUserTask creates a new User Task. +func (c *Client) CreateUserTask(ctx context.Context, req *usertaskv1.UserTask) (*usertaskv1.UserTask, error) { + rsp, err := c.grpcClient.CreateUserTask(ctx, &usertaskv1.CreateUserTaskRequest{ + UserTask: req, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return rsp, nil +} + +// GetUserTask returns a User Task by name. +func (c *Client) GetUserTask(ctx context.Context, name string) (*usertaskv1.UserTask, error) { + rsp, err := c.grpcClient.GetUserTask(ctx, &usertaskv1.GetUserTaskRequest{ + Name: name, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return rsp, nil +} + +// UpdateUserTask updates an existing User Task. +func (c *Client) UpdateUserTask(ctx context.Context, req *usertaskv1.UserTask) (*usertaskv1.UserTask, error) { + rsp, err := c.grpcClient.UpdateUserTask(ctx, &usertaskv1.UpdateUserTaskRequest{ + UserTask: req, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return rsp, nil +} + +// UpsertUserTask upserts a User Task. +func (c *Client) UpsertUserTask(ctx context.Context, req *usertaskv1.UserTask) (*usertaskv1.UserTask, error) { + rsp, err := c.grpcClient.UpsertUserTask(ctx, &usertaskv1.UpsertUserTaskRequest{ + UserTask: req, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return rsp, nil +} + +// DeleteUserTask deletes a User Task. +func (c *Client) DeleteUserTask(ctx context.Context, name string) error { + _, err := c.grpcClient.DeleteUserTask(ctx, &usertaskv1.DeleteUserTaskRequest{ + Name: name, + }) + return trace.Wrap(err) +} + +// DeleteAllUserTasks deletes all User Tasks. +// Not implemented. Added to satisfy the interface. +func (c *Client) DeleteAllUserTasks(_ context.Context) error { + return trace.NotImplemented("DeleteAllUserTasks is not implemented") +} diff --git a/api/types/constants.go b/api/types/constants.go index 12bfe0c65a5f7..88930e05bb568 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -485,6 +485,9 @@ const ( // KindIntegration is a connection to a 3rd party system API. KindIntegration = "integration" + // KindUserTask is a task representing an issue with some other resource. + KindUserTask = "user_task" + // KindClusterMaintenanceConfig determines maintenance times for the cluster. KindClusterMaintenanceConfig = "cluster_maintenance_config" diff --git a/api/types/usertasks/object.go b/api/types/usertasks/object.go new file mode 100644 index 0000000000000..978d10c3d1121 --- /dev/null +++ b/api/types/usertasks/object.go @@ -0,0 +1,95 @@ +/* + * 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 usertasks + +import ( + "github.com/gravitational/trace" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/api/types" +) + +// NewUserTask creates a new UserTask object. +// It validates the object before returning it. +func NewUserTask(name string, spec *usertasksv1.UserTaskSpec) (*usertasksv1.UserTask, error) { + cj := &usertasksv1.UserTask{ + Kind: types.KindUserTask, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + }, + Spec: spec, + } + + if err := ValidateUserTask(cj); err != nil { + return nil, trace.Wrap(err) + } + + return cj, nil +} + +const ( + // TaskTypeDiscoverEC2 identifies a User Tasks that is created + // when an auto-enrollment of an EC2 instance fails. + // UserTasks that have this Task Type must include the DiscoverEC2 field. + TaskTypeDiscoverEC2 = "discover-ec2" +) + +// ValidateUserTask validates the UserTask object without modifying it. +func ValidateUserTask(uit *usertasksv1.UserTask) error { + switch { + case uit.GetKind() != types.KindUserTask: + return trace.BadParameter("invalid kind") + case uit.GetVersion() != types.V1: + return trace.BadParameter("invalid version") + case uit.GetSubKind() != "": + return trace.BadParameter("invalid sub kind, must be empty") + case uit.GetMetadata() == nil: + return trace.BadParameter("user task metadata is nil") + case uit.Metadata.GetName() == "": + return trace.BadParameter("user task name is empty") + case uit.GetSpec() == nil: + return trace.BadParameter("user task spec is nil") + case uit.GetSpec().Integration == "": + return trace.BadParameter("integration is required") + } + + switch uit.Spec.TaskType { + case TaskTypeDiscoverEC2: + if err := validateDiscoverEC2TaskType(uit); err != nil { + return trace.Wrap(err) + } + default: + return trace.BadParameter("task type %q is not valid", uit.Spec.TaskType) + } + + return nil +} + +func validateDiscoverEC2TaskType(uit *usertasksv1.UserTask) error { + if uit.Spec.DiscoverEc2 == nil { + return trace.BadParameter("%s requires the discover_ec2 field", TaskTypeDiscoverEC2) + } + if uit.Spec.IssueType == "" { + return trace.BadParameter("issue type is required") + } + + return nil +} diff --git a/api/types/usertasks/object_test.go b/api/types/usertasks/object_test.go new file mode 100644 index 0000000000000..a8f4c6769ca82 --- /dev/null +++ b/api/types/usertasks/object_test.go @@ -0,0 +1,69 @@ +/* + * 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 usertasks_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/api/types/usertasks" +) + +func TestValidateUserTask(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task *usertasksv1.UserTask + wantErr require.ErrorAssertionFunc + }{ + { + name: "NilUserTask", + task: nil, + wantErr: require.Error, + }, + { + name: "ValidUserTask", + task: &usertasksv1.UserTask{ + Kind: "user_task", + Version: "v1", + Metadata: &headerv1.Metadata{ + Name: "test", + }, + Spec: &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "failed to enroll ec2 instances", + DiscoverEc2: &usertasksv1.DiscoverEC2{}, + }, + }, + wantErr: require.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := usertasks.ValidateUserTask(tt.task) + tt.wantErr(t, err) + }) + } +} diff --git a/lib/auth/accesspoint/accesspoint.go b/lib/auth/accesspoint/accesspoint.go index f65ce7ffba7dc..5c0bc9b957ee0 100644 --- a/lib/auth/accesspoint/accesspoint.go +++ b/lib/auth/accesspoint/accesspoint.go @@ -98,6 +98,7 @@ type Config struct { StaticHostUsers services.StaticHostUser Trust services.Trust UserGroups services.UserGroups + UserTasks services.UserTasks UserLoginStates services.UserLoginStates Users services.UsersService WebSession types.WebSessionInterface @@ -193,6 +194,7 @@ func NewCache(cfg Config) (*cache.Cache, error) { Trust: cfg.Trust, UserGroups: cfg.UserGroups, UserLoginStates: cfg.UserLoginStates, + UserTasks: cfg.UserTasks, Users: cfg.Users, WebSession: cfg.WebSession, WebToken: cfg.WebToken, diff --git a/lib/auth/auth.go b/lib/auth/auth.go index e02c910f808ed..49f83e6be83aa 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -315,6 +315,12 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { return nil, trace.Wrap(err) } } + if cfg.UserTasks == nil { + cfg.UserTasks, err = local.NewUserTasksService(cfg.Backend) + if err != nil { + return nil, trace.Wrap(err) + } + } if cfg.DiscoveryConfigs == nil { cfg.DiscoveryConfigs, err = local.NewDiscoveryConfigService(cfg.Backend) if err != nil { @@ -430,6 +436,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { SessionTrackerService: cfg.SessionTrackerService, ConnectionsDiagnostic: cfg.ConnectionsDiagnostic, Integrations: cfg.Integrations, + UserTasks: cfg.UserTasks, DiscoveryConfigs: cfg.DiscoveryConfigs, Okta: cfg.Okta, AccessLists: cfg.AccessLists, @@ -630,6 +637,7 @@ type Services struct { services.StatusInternal services.Integrations services.IntegrationsTokenGenerator + services.UserTasks services.DiscoveryConfigs services.Okta services.AccessLists diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index edce17d68ccea..d3f8080c44181 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -36,6 +36,7 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/types/discoveryconfig" @@ -802,6 +803,9 @@ type DiscoveryAccessPoint interface { // UpdateDiscoveryConfigStatus updates the status of a discovery config. UpdateDiscoveryConfigStatus(ctx context.Context, name string, status discoveryconfig.Status) (*discoveryconfig.DiscoveryConfig, error) + + // UpsertUserTask creates or updates an User Task + UpsertUserTask(ctx context.Context, req *usertasksv1.UserTask) (*usertasksv1.UserTask, error) } // ReadOktaAccessPoint is a read only API interface to be @@ -1170,6 +1174,11 @@ type Cache interface { // IntegrationsGetter defines read/list methods for integrations. services.IntegrationsGetter + // GetUserTask returns the user tasks resource by name. + GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) + // ListUserTasks returns the user tasks resources. + ListUserTasks(ctx context.Context, pageSize int64, nextToken string) ([]*usertasksv1.UserTask, string, error) + // NotificationGetter defines list methods for notifications. services.NotificationGetter @@ -1437,6 +1446,11 @@ func (w *DiscoveryWrapper) UpdateDiscoveryConfigStatus(ctx context.Context, name return w.NoCache.UpdateDiscoveryConfigStatus(ctx, name, status) } +// UpserUserTask creates or updates an User Task. +func (w *DiscoveryWrapper) UpsertUserTask(ctx context.Context, req *usertasksv1.UserTask) (*usertasksv1.UserTask, error) { + return w.NoCache.UpsertUserTask(ctx, req) +} + // Close closes all associated resources func (w *DiscoveryWrapper) Close() error { err := w.NoCache.Close() diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index b7e4f03deaad0..52b856c575c17 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -36,6 +36,7 @@ import ( "github.com/gravitational/teleport/api/client/externalauditstorage" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/secreport" + "github.com/gravitational/teleport/api/client/usertask" apidefaults "github.com/gravitational/teleport/api/defaults" accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" clusterconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/clusterconfig/v1" @@ -662,6 +663,11 @@ func (c *Client) IntegrationAWSOIDCClient() integrationv1.AWSOIDCServiceClient { return integrationv1.NewAWSOIDCServiceClient(c.APIClient.GetConnection()) } +// UserTasksClient returns a client for managing User Task resources. +func (c *Client) UserTasksClient() services.UserTasks { + return c.APIClient.UserTasksServiceClient() +} + func (c *Client) NotificationServiceClient() notificationsv1.NotificationServiceClient { return notificationsv1.NewNotificationServiceClient(c.APIClient.GetConnection()) } @@ -1606,6 +1612,9 @@ type ClientI interface { // IntegrationAWSOIDCClient returns a client to the Integration AWS OIDC gRPC service. IntegrationAWSOIDCClient() integrationv1.AWSOIDCServiceClient + // UserTasksServiceClient returns an User Task service client. + UserTasksServiceClient() *usertask.Client + // NewKeepAliver returns a new instance of keep aliver NewKeepAliver(ctx context.Context) (types.KeepAliver, error) diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index c1394e9c4d6dd..4ada633e1d79c 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -68,6 +68,7 @@ import ( userloginstatev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userloginstate/v1" userprovisioningv2pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" usersv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + usertaskv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" vnetv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" userpreferencesv1pb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" "github.com/gravitational/teleport/api/internalutils/stream" @@ -95,6 +96,7 @@ import ( "github.com/gravitational/teleport/lib/auth/userpreferences/userpreferencesv1" "github.com/gravitational/teleport/lib/auth/userprovisioning/userprovisioningv2" "github.com/gravitational/teleport/lib/auth/users/usersv1" + "github.com/gravitational/teleport/lib/auth/usertasks/usertasksv1" "github.com/gravitational/teleport/lib/auth/vnetconfig/vnetconfigv1" "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/backend" @@ -5309,6 +5311,16 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { } integrationv1pb.RegisterAWSOIDCServiceServer(server, integrationAWSOIDCServiceServer) + userTask, err := usertasksv1.NewService(usertasksv1.ServiceConfig{ + Authorizer: cfg.Authorizer, + Backend: cfg.AuthServer.Services, + Cache: cfg.AuthServer.Cache, + }) + if err != nil { + return nil, trace.Wrap(err) + } + usertaskv1pb.RegisterUserTaskServiceServer(server, userTask) + discoveryConfig, err := discoveryconfigv1.NewService(discoveryconfigv1.ServiceConfig{ Authorizer: cfg.Authorizer, Backend: cfg.AuthServer.Services, diff --git a/lib/auth/helpers.go b/lib/auth/helpers.go index 83d11f2feed3e..451898d593138 100644 --- a/lib/auth/helpers.go +++ b/lib/auth/helpers.go @@ -348,6 +348,7 @@ func NewTestAuthServer(cfg TestAuthServerConfig) (*TestAuthServer, error) { StaticHostUsers: svces.StaticHostUser, Trust: svces.TrustInternal, UserGroups: svces.UserGroups, + UserTasks: svces.UserTasks, UserLoginStates: svces.UserLoginStates, Users: svces.Identity, WebSession: svces.Identity.WebSessions(), diff --git a/lib/auth/init.go b/lib/auth/init.go index e9c767d628a67..281d8c6e4f2c4 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -233,6 +233,9 @@ type InitConfig struct { // Integrations is a service that manages Integrations. Integrations services.Integrations + // UserTasks is a service that manages UserTasks. + UserTasks services.UserTasks + // DiscoveryConfigs is a service that manages DiscoveryConfigs. DiscoveryConfigs services.DiscoveryConfigs diff --git a/lib/auth/usertasks/usertasksv1/service.go b/lib/auth/usertasks/usertasksv1/service.go new file mode 100644 index 0000000000000..abbd9e5002708 --- /dev/null +++ b/lib/auth/usertasks/usertasksv1/service.go @@ -0,0 +1,206 @@ +/* + * 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 usertasksv1 + +import ( + "context" + + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/emptypb" + + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/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 UserTask gRPC service. +type ServiceConfig struct { + // Authorizer is the authorizer to use. + Authorizer authz.Authorizer + + // Backend is the backend for storing UserTask. + Backend services.UserTasks + + // Cache is the cache for storing UserTask. + Cache Reader +} + +// CheckAndSetDefaults checks the ServiceConfig fields and returns an error if +// a required param is not provided. +// Authorizer, Cache and Backend are required params +func (s *ServiceConfig) CheckAndSetDefaults() error { + if s.Authorizer == nil { + return trace.BadParameter("authorizer is required") + } + if s.Backend == nil { + return trace.BadParameter("backend is required") + } + if s.Cache == nil { + return trace.BadParameter("cache is required") + } + + return nil +} + +// Reader contains the methods defined for cache access. +type Reader interface { + ListUserTasks(ctx context.Context, pageSize int64, nextToken string) ([]*usertasksv1.UserTask, string, error) + GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) +} + +// Service implements the teleport.UserTask.v1.UserTaskService RPC service. +type Service struct { + usertasksv1.UnimplementedUserTaskServiceServer + + authorizer authz.Authorizer + backend services.UserTasks + cache Reader +} + +// NewService returns a new UserTask gRPC service. +func NewService(cfg ServiceConfig) (*Service, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return &Service{ + authorizer: cfg.Authorizer, + backend: cfg.Backend, + cache: cfg.Cache, + }, nil +} + +// CreateUserTask creates user task resource. +func (s *Service) CreateUserTask(ctx context.Context, req *usertasksv1.CreateUserTaskRequest) (*usertasksv1.UserTask, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + + rsp, err := s.backend.CreateUserTask(ctx, req.UserTask) + if err != nil { + return nil, trace.Wrap(err) + } + + return rsp, nil +} + +// ListUserTasks returns a list of user tasks. +func (s *Service) ListUserTasks(ctx context.Context, req *usertasksv1.ListUserTasksRequest) (*usertasksv1.ListUserTasksResponse, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbRead, types.VerbList); err != nil { + return nil, trace.Wrap(err) + } + + rsp, nextToken, err := s.cache.ListUserTasks(ctx, req.PageSize, req.PageToken) + if err != nil { + return nil, trace.Wrap(err) + } + + return &usertasksv1.ListUserTasksResponse{ + UserTasks: rsp, + NextPageToken: nextToken, + }, nil +} + +// GetUserTask returns user task resource. +func (s *Service) GetUserTask(ctx context.Context, req *usertasksv1.GetUserTaskRequest) (*usertasksv1.UserTask, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + + rsp, err := s.cache.GetUserTask(ctx, req.GetName()) + if err != nil { + return nil, trace.Wrap(err) + } + + return rsp, nil + +} + +// UpdateUserTask updates user task resource. +func (s *Service) UpdateUserTask(ctx context.Context, req *usertasksv1.UpdateUserTaskRequest) (*usertasksv1.UserTask, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + + rsp, err := s.backend.UpdateUserTask(ctx, req.UserTask) + if err != nil { + return nil, trace.Wrap(err) + } + + return rsp, nil +} + +// UpsertUserTask upserts user task resource. +func (s *Service) UpsertUserTask(ctx context.Context, req *usertasksv1.UpsertUserTaskRequest) (*usertasksv1.UserTask, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbUpdate, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + + rsp, err := s.backend.UpsertUserTask(ctx, req.UserTask) + if err != nil { + return nil, trace.Wrap(err) + } + + return rsp, nil + +} + +// DeleteUserTask deletes user task resource. +func (s *Service) DeleteUserTask(ctx context.Context, req *usertasksv1.DeleteUserTaskRequest) (*emptypb.Empty, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindUserTask, types.VerbDelete); err != nil { + return nil, trace.Wrap(err) + } + + if err := s.backend.DeleteUserTask(ctx, req.GetName()); err != nil { + return nil, trace.Wrap(err) + } + + return &emptypb.Empty{}, nil +} diff --git a/lib/auth/usertasks/usertasksv1/service_test.go b/lib/auth/usertasks/usertasksv1/service_test.go new file mode 100644 index 0000000000000..3b9627c1ada73 --- /dev/null +++ b/lib/auth/usertasks/usertasksv1/service_test.go @@ -0,0 +1,158 @@ +/* + * 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 usertasksv1 + +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/utils" +) + +func TestServiceAccess(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + allowedVerbs []string + allowedStates []authz.AdminActionAuthState + }{ + { + name: "CreateUserTask", + allowedVerbs: []string{types.VerbCreate}, + }, + { + name: "UpdateUserTask", + allowedVerbs: []string{types.VerbUpdate}, + }, + { + name: "DeleteUserTask", + allowedVerbs: []string{types.VerbDelete}, + }, + { + name: "UpsertUserTask", + allowedVerbs: []string{types.VerbCreate, types.VerbUpdate}, + }, + { + name: "ListUserTasks", + allowedVerbs: []string{types.VerbRead, types.VerbList}, + }, + { + name: "GetUserTask", + allowedVerbs: []string{types.VerbRead}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + for _, verbs := range utils.Combinations(tt.allowedVerbs) { + t.Run(fmt.Sprintf("verbs=%v", verbs), func(t *testing.T) { + service := newService(t, fakeChecker{allowedVerbs: verbs}) + err := callMethod(t, service, tt.name) + // expect access denied except with full set of verbs. + if len(verbs) == len(tt.allowedVerbs) { + require.False(t, trace.IsAccessDenied(err)) + } else { + require.True(t, trace.IsAccessDenied(err), "expected access denied for verbs %v, got err=%v", verbs, err) + } + }) + } + }) + } + + // verify that all declared methods have matching test cases + t.Run("verify coverage", func(t *testing.T) { + for _, method := range usertasksv1.UserTaskService_ServiceDesc.Methods { + t.Run(method.MethodName, func(t *testing.T) { + match := false + for _, testCase := range testCases { + match = match || testCase.name == method.MethodName + } + require.True(t, match, "method %v without coverage, no matching tests", method.MethodName) + }) + } + }) +} + +// callMethod calls a method with given name in the UserTask service +func callMethod(t *testing.T, service *Service, method string) error { + for _, desc := range usertasksv1.UserTaskService_ServiceDesc.Methods { + if desc.MethodName == method { + _, err := desc.Handler(service, context.Background(), func(_ any) error { return nil }, nil) + return err + } + } + require.FailNow(t, "method %v not found", method) + panic("this line should never be reached: FailNow() should interrupt the test") +} + +type fakeChecker struct { + allowedVerbs []string + services.AccessChecker +} + +func (f fakeChecker) CheckAccessToRule(_ services.RuleContext, _ string, resource string, verb string) error { + if resource == types.KindUserTask { + if slices.Contains(f.allowedVerbs, verb) { + return nil + } + } + + return trace.AccessDenied("access denied to rule=%v/verb=%v", resource, verb) +} + +func newService(t *testing.T, checker services.AccessChecker) *Service { + t.Helper() + + b, err := memory.New(memory.Config{}) + require.NoError(t, err) + + backendService, err := local.NewUserTasksService(b) + require.NoError(t, err) + + authorizer := authz.AuthorizerFunc(func(ctx context.Context) (*authz.Context, error) { + user, err := types.NewUser("llama") + if err != nil { + return nil, err + } + return &authz.Context{ + User: user, + Checker: checker, + }, nil + }) + + service, err := NewService(ServiceConfig{ + Authorizer: authorizer, + Backend: backendService, + Cache: backendService, + }) + require.NoError(t, err) + return service +} diff --git a/lib/authz/permissions.go b/lib/authz/permissions.go index de3c1c070c1b0..f8b5587018608 100644 --- a/lib/authz/permissions.go +++ b/lib/authz/permissions.go @@ -919,6 +919,7 @@ func roleSpecForProxy(clusterName string) types.RoleSpecV6 { types.NewRule(types.KindAuditQuery, services.RO()), types.NewRule(types.KindSecurityReport, services.RO()), types.NewRule(types.KindSecurityReportState, services.RO()), + types.NewRule(types.KindUserTask, services.RO()), // this rule allows cloud proxies to read // plugins of `openai` type, since Assist uses the OpenAI API and runs in Proxy. { @@ -1197,6 +1198,7 @@ func definitionForBuiltinRole(clusterName string, recConfig readonly.SessionReco types.NewRule(types.KindDiscoveryConfig, services.RO()), types.NewRule(types.KindIntegration, append(services.RO(), types.VerbUse)), types.NewRule(types.KindSemaphore, services.RW()), + types.NewRule(types.KindUserTask, services.RW()), }, // Discovery service should only access kubes/apps/dbs that originated from discovery. KubernetesLabels: types.Labels{types.OriginLabel: []string{types.OriginCloud}}, diff --git a/lib/cache/cache.go b/lib/cache/cache.go index 3690d3e2fd762..4d42f1013ce48 100644 --- a/lib/cache/cache.go +++ b/lib/cache/cache.go @@ -46,6 +46,7 @@ import ( notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/internalutils/stream" apitracing "github.com/gravitational/teleport/api/observability/tracing" "github.com/gravitational/teleport/api/types" @@ -188,6 +189,7 @@ func ForAuth(cfg Config) Config { {Kind: types.KindStaticHostUser}, {Kind: types.KindAutoUpdateVersion}, {Kind: types.KindAutoUpdateConfig}, + {Kind: types.KindUserTask}, } cfg.QueueSize = defaults.AuthQueueSize // We don't want to enable partial health for auth cache because auth uses an event stream @@ -242,6 +244,7 @@ func ForProxy(cfg Config) Config { {Kind: types.KindKubeWaitingContainer}, {Kind: types.KindAutoUpdateConfig}, {Kind: types.KindAutoUpdateVersion}, + {Kind: types.KindUserTask}, } cfg.QueueSize = defaults.ProxyQueueSize return cfg @@ -407,6 +410,7 @@ func ForDiscovery(cfg Config) Config { {Kind: types.KindApp}, {Kind: types.KindDiscoveryConfig}, {Kind: types.KindIntegration}, + {Kind: types.KindUserTask}, {Kind: types.KindProxy}, } cfg.QueueSize = defaults.DiscoveryQueueSize @@ -519,6 +523,7 @@ type Cache struct { userGroupsCache services.UserGroups oktaCache services.Okta integrationsCache services.Integrations + userTasksCache services.UserTasks discoveryConfigsCache services.DiscoveryConfigs headlessAuthenticationsCache services.HeadlessAuthenticationService secReportsCache services.SecReports @@ -695,6 +700,8 @@ type Config struct { DiscoveryConfigs services.DiscoveryConfigs // UserLoginStates is the user login state service. UserLoginStates services.UserLoginStates + // UserTasks is the user tasks service. + UserTasks services.UserTasks // SecEvents is the security report service. SecReports services.SecReports // AccessLists is the access lists service. @@ -884,6 +891,12 @@ func New(config Config) (*Cache, error) { return nil, trace.Wrap(err) } + userTasksCache, err := local.NewUserTasksService(config.Backend) + if err != nil { + cancel() + return nil, trace.Wrap(err) + } + discoveryConfigsCache, err := local.NewDiscoveryConfigService(config.Backend) if err != nil { cancel() @@ -993,6 +1006,7 @@ func New(config Config) (*Cache, error) { userGroupsCache: userGroupsCache, oktaCache: oktaCache, integrationsCache: integrationsCache, + userTasksCache: userTasksCache, discoveryConfigsCache: discoveryConfigsCache, headlessAuthenticationsCache: local.NewIdentityService(config.Backend), secReportsCache: secReportsCache, @@ -2932,6 +2946,32 @@ func (c *Cache) GetIntegration(ctx context.Context, name string) (types.Integrat return rg.reader.GetIntegration(ctx, name) } +// ListUserTasks returns a list of UserTask resources. +func (c *Cache) ListUserTasks(ctx context.Context, pageSize int64, nextKey string) ([]*usertasksv1.UserTask, string, error) { + ctx, span := c.Tracer.Start(ctx, "cache/ListUserTasks") + defer span.End() + + rg, err := readCollectionCache(c, c.collections.userTasks) + if err != nil { + return nil, "", trace.Wrap(err) + } + defer rg.Release() + return rg.reader.ListUserTasks(ctx, pageSize, nextKey) +} + +// GetUserTask returns the specified UserTask resource. +func (c *Cache) GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) { + ctx, span := c.Tracer.Start(ctx, "cache/GetUserTask") + defer span.End() + + rg, err := readCollectionCache(c, c.collections.userTasks) + if err != nil { + return nil, trace.Wrap(err) + } + defer rg.Release() + return rg.reader.GetUserTask(ctx, name) +} + // ListDiscoveryConfigs returns a paginated list of all DiscoveryConfig resources. func (c *Cache) ListDiscoveryConfigs(ctx context.Context, pageSize int, nextKey string) ([]*discoveryconfig.DiscoveryConfig, string, error) { ctx, span := c.Tracer.Start(ctx, "cache/ListDiscoveryConfigs") diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go index 0d2a43439dee3..bad3cd5f48e81 100644 --- a/lib/cache/cache_test.go +++ b/lib/cache/cache_test.go @@ -52,6 +52,7 @@ import ( labelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/label/v1" notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" update "github.com/gravitational/teleport/api/types/autoupdate" @@ -63,6 +64,7 @@ import ( "github.com/gravitational/teleport/api/types/trait" "github.com/gravitational/teleport/api/types/userloginstate" "github.com/gravitational/teleport/api/types/userprovisioning" + "github.com/gravitational/teleport/api/types/usertasks" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/lite" "github.com/gravitational/teleport/lib/backend/memory" @@ -122,6 +124,7 @@ type testPack struct { userGroups services.UserGroups okta services.Okta integrations services.Integrations + userTasks services.UserTasks discoveryConfigs services.DiscoveryConfigs userLoginStates services.UserLoginStates secReports services.SecReports @@ -299,6 +302,12 @@ func newPackWithoutCache(dir string, opts ...packOption) (*testPack, error) { } p.integrations = igSvc + userTasksSvc, err := local.NewUserTasksService(p.backend) + if err != nil { + return nil, trace.Wrap(err) + } + p.userTasks = userTasksSvc + dcSvc, err := local.NewDiscoveryConfigService(p.backend) if err != nil { return nil, trace.Wrap(err) @@ -405,6 +414,7 @@ func newPack(dir string, setupConfig func(c Config) Config, opts ...packOption) UserGroups: p.userGroups, Okta: p.okta, Integrations: p.integrations, + UserTasks: p.userTasks, DiscoveryConfigs: p.discoveryConfigs, UserLoginStates: p.userLoginStates, SecReports: p.secReports, @@ -812,6 +822,7 @@ func TestCompletenessInit(t *testing.T) { UserGroups: p.userGroups, Okta: p.okta, Integrations: p.integrations, + UserTasks: p.userTasks, DiscoveryConfigs: p.discoveryConfigs, UserLoginStates: p.userLoginStates, SecReports: p.secReports, @@ -892,6 +903,7 @@ func TestCompletenessReset(t *testing.T) { UserGroups: p.userGroups, Okta: p.okta, Integrations: p.integrations, + UserTasks: p.userTasks, DiscoveryConfigs: p.discoveryConfigs, UserLoginStates: p.userLoginStates, SecReports: p.secReports, @@ -1098,6 +1110,7 @@ func TestListResources_NodesTTLVariant(t *testing.T) { UserGroups: p.userGroups, Okta: p.okta, Integrations: p.integrations, + UserTasks: p.userTasks, DiscoveryConfigs: p.discoveryConfigs, UserLoginStates: p.userLoginStates, SecReports: p.secReports, @@ -1189,6 +1202,7 @@ func initStrategy(t *testing.T) { UserGroups: p.userGroups, Okta: p.okta, Integrations: p.integrations, + UserTasks: p.userTasks, DiscoveryConfigs: p.discoveryConfigs, UserLoginStates: p.userLoginStates, SecReports: p.secReports, @@ -2275,6 +2289,52 @@ func TestIntegrations(t *testing.T) { }) } +// TestUserTasks tests that CRUD operations on user notification resources are +// replicated from the backend to the cache. +func TestUserTasks(t *testing.T) { + t.Parallel() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + testResources153(t, p, testFuncs153[*usertasksv1.UserTask]{ + newResource: func(name string) (*usertasksv1.UserTask, error) { + return newUserTasks(t, name), nil + }, + create: func(ctx context.Context, item *usertasksv1.UserTask) error { + _, err := p.userTasks.CreateUserTask(ctx, item) + return trace.Wrap(err) + }, + list: func(ctx context.Context) ([]*usertasksv1.UserTask, error) { + items, _, err := p.userTasks.ListUserTasks(ctx, 0, "") + return items, trace.Wrap(err) + }, + cacheList: func(ctx context.Context) ([]*usertasksv1.UserTask, error) { + items, _, err := p.userTasks.ListUserTasks(ctx, 0, "") + return items, trace.Wrap(err) + }, + deleteAll: p.userTasks.DeleteAllUserTasks, + }) +} + +func newUserTasks(t *testing.T, name string) *usertasksv1.UserTask { + t.Helper() + + return &usertasksv1.UserTask{ + Kind: types.KindUserTask, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + }, + Spec: &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: usertasks.TaskTypeDiscoverEC2, + IssueType: "my-issue-type", + DiscoverEc2: &usertasksv1.DiscoverEC2{}, + }, + } +} + // TestDiscoveryConfig tests that CRUD operations on DiscoveryConfig resources are // replicated from the backend to the cache. func TestDiscoveryConfig(t *testing.T) { @@ -3384,6 +3444,7 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) { types.KindStaticHostUser: types.Resource153ToLegacy(newStaticHostUser(t, "test")), types.KindAutoUpdateConfig: types.Resource153ToLegacy(newAutoUpdateConfig(t)), types.KindAutoUpdateVersion: types.Resource153ToLegacy(newAutoUpdateVersion(t)), + types.KindUserTask: types.Resource153ToLegacy(newUserTasks(t, "test")), } for name, cfg := range cases { diff --git a/lib/cache/collections.go b/lib/cache/collections.go index e285f4170b2b6..d7d36010f33ce 100644 --- a/lib/cache/collections.go +++ b/lib/cache/collections.go @@ -38,6 +38,7 @@ import ( notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/types/discoveryconfig" @@ -194,6 +195,11 @@ type crownjewelsGetter interface { GetCrownJewel(ctx context.Context, name string) (*crownjewelv1.CrownJewel, error) } +type userTasksGetter interface { + ListUserTasks(ctx context.Context, pageSize int64, nextToken string) ([]*usertasksv1.UserTask, string, error) + GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) +} + // cacheCollections is a registry of resource collections used by Cache. type cacheCollections struct { // byKind is a map of registered collections by resource Kind/SubKind @@ -222,6 +228,7 @@ type cacheCollections struct { discoveryConfigs collectionReader[services.DiscoveryConfigsGetter] installers collectionReader[installerGetter] integrations collectionReader[services.IntegrationsGetter] + userTasks collectionReader[userTasksGetter] crownJewels collectionReader[crownjewelsGetter] kubeClusters collectionReader[kubernetesClusterGetter] kubeWaitingContainers collectionReader[kubernetesWaitingContainerGetter] @@ -656,6 +663,15 @@ func setupCollections(c *Cache, watches []types.WatchKind) (*cacheCollections, e watch: watch, } collections.byKind[resourceKind] = collections.integrations + case types.KindUserTask: + if c.UserTasks == nil { + return nil, trace.BadParameter("missing parameter user tasks") + } + collections.userTasks = &genericCollection[*usertasksv1.UserTask, userTasksGetter, userTasksExecutor]{ + cache: c, + watch: watch, + } + collections.byKind[resourceKind] = collections.userTasks case types.KindDiscoveryConfig: if c.DiscoveryConfigs == nil { return nil, trace.BadParameter("missing parameter DiscoveryConfigs") @@ -2520,6 +2536,51 @@ func (crownJewelsExecutor) getReader(cache *Cache, cacheOK bool) crownjewelsGett var _ executor[*crownjewelv1.CrownJewel, crownjewelsGetter] = crownJewelsExecutor{} +type userTasksExecutor struct{} + +func (userTasksExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]*usertasksv1.UserTask, error) { + var resources []*usertasksv1.UserTask + var nextToken string + for { + var page []*usertasksv1.UserTask + var err error + page, nextToken, err = cache.UserTasks.ListUserTasks(ctx, 0 /* page size */, nextToken) + if err != nil { + return nil, trace.Wrap(err) + } + resources = append(resources, page...) + + if nextToken == "" { + break + } + } + return resources, nil +} + +func (userTasksExecutor) upsert(ctx context.Context, cache *Cache, resource *usertasksv1.UserTask) error { + _, err := cache.userTasksCache.UpsertUserTask(ctx, resource) + return trace.Wrap(err) +} + +func (userTasksExecutor) deleteAll(ctx context.Context, cache *Cache) error { + return cache.userTasksCache.DeleteAllUserTasks(ctx) +} + +func (userTasksExecutor) delete(ctx context.Context, cache *Cache, resource types.Resource) error { + return cache.userTasksCache.DeleteUserTask(ctx, resource.GetName()) +} + +func (userTasksExecutor) isSingleton() bool { return false } + +func (userTasksExecutor) getReader(cache *Cache, cacheOK bool) userTasksGetter { + if cacheOK { + return cache.userTasksCache + } + return cache.Config.UserTasks +} + +var _ executor[*usertasksv1.UserTask, userTasksGetter] = userTasksExecutor{} + //nolint:revive // Because we want this to be IdP. type samlIdPServiceProvidersExecutor struct{} diff --git a/lib/service/service.go b/lib/service/service.go index bc91001f6deeb..9a7899a7b4294 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -2525,6 +2525,7 @@ func (process *TeleportProcess) newAccessCacheForServices(cfg accesspoint.Config cfg.StaticHostUsers = services.StaticHostUser cfg.Trust = services.TrustInternal cfg.UserGroups = services.UserGroups + cfg.UserTasks = services.UserTasks cfg.UserLoginStates = services.UserLoginStates cfg.Users = services.Identity cfg.WebSession = services.Identity.WebSessions() @@ -2555,6 +2556,7 @@ func (process *TeleportProcess) newAccessCacheForClient(cfg accesspoint.Config, cfg.DynamicAccess = client cfg.Events = client cfg.Integrations = client + cfg.UserTasks = client.UserTasksServiceClient() cfg.KubeWaitingContainers = client cfg.Kubernetes = client cfg.Notifications = client @@ -2638,6 +2640,7 @@ type combinedDiscoveryClient struct { authclient.ClientI discoveryConfigClient eksClustersEnroller + services.UserTasks } // newLocalCacheForDiscovery returns a new instance of access point for a discovery service. @@ -2646,6 +2649,7 @@ func (process *TeleportProcess) newLocalCacheForDiscovery(clt authclient.ClientI ClientI: clt, discoveryConfigClient: clt.DiscoveryConfigClient(), eksClustersEnroller: clt.IntegrationAWSOIDCClient(), + UserTasks: clt.UserTasksServiceClient(), } // if caching is disabled, return access point diff --git a/lib/services/local/events.go b/lib/services/local/events.go index e16d1a4a70335..b445921b7b721 100644 --- a/lib/services/local/events.go +++ b/lib/services/local/events.go @@ -183,6 +183,8 @@ func (e *EventsService) NewWatcher(ctx context.Context, watch types.Watch) (type parser = newOktaAssignmentParser() case types.KindIntegration: parser = newIntegrationParser() + case types.KindUserTask: + parser = newUserTaskParser() case types.KindDiscoveryConfig: parser = newDiscoveryConfigParser() case types.KindHeadlessAuthentication: @@ -1767,6 +1769,34 @@ func (p *integrationParser) parse(event backend.Event) (types.Resource, error) { } } +func newUserTaskParser() *userTaskParser { + return &userTaskParser{ + baseParser: newBaseParser(backend.NewKey(userTasksKey)), + } +} + +type userTaskParser struct { + baseParser +} + +func (p *userTaskParser) parse(event backend.Event) (types.Resource, error) { + switch event.Type { + case types.OpDelete: + return resourceHeader(event, types.KindUserTask, types.V1, 0) + case types.OpPut: + r, err := services.UnmarshalUserTask(event.Item.Value, + services.WithExpires(event.Item.Expires), + services.WithRevision(event.Item.Revision), + ) + if err != nil { + return nil, trace.Wrap(err) + } + return types.Resource153ToLegacy(r), nil + default: + return nil, trace.BadParameter("event %v is not supported", event.Type) + } +} + func newDiscoveryConfigParser() *discoveryConfigParser { return &discoveryConfigParser{ baseParser: newBaseParser(backend.NewKey(discoveryConfigPrefix)), diff --git a/lib/services/local/user_task.go b/lib/services/local/user_task.go new file mode 100644 index 0000000000000..abb9ec60e937f --- /dev/null +++ b/lib/services/local/user_task.go @@ -0,0 +1,101 @@ +/* + * 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 local + +import ( + "context" + + "github.com/gravitational/trace" + + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/usertasks" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local/generic" +) + +type UserTasksService struct { + service *generic.ServiceWrapper[*usertasksv1.UserTask] +} + +const userTasksKey = "user_tasks" + +// NewUserTasksService creates a new UserTasksService. +func NewUserTasksService(backend backend.Backend) (*UserTasksService, error) { + service, err := generic.NewServiceWrapper( + generic.ServiceWrapperConfig[*usertasksv1.UserTask]{ + Backend: backend, + ResourceKind: types.KindUserTask, + BackendPrefix: userTasksKey, + MarshalFunc: services.MarshalProtoResource[*usertasksv1.UserTask], + UnmarshalFunc: services.UnmarshalProtoResource[*usertasksv1.UserTask], + }) + if err != nil { + return nil, trace.Wrap(err) + } + return &UserTasksService{service: service}, nil +} + +func (s *UserTasksService) ListUserTasks(ctx context.Context, pagesize int64, lastKey string) ([]*usertasksv1.UserTask, string, error) { + r, nextToken, err := s.service.ListResources(ctx, int(pagesize), lastKey) + return r, nextToken, trace.Wrap(err) +} + +func (s *UserTasksService) GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) { + r, err := s.service.GetResource(ctx, name) + return r, trace.Wrap(err) +} + +func (s *UserTasksService) CreateUserTask(ctx context.Context, userTask *usertasksv1.UserTask) (*usertasksv1.UserTask, error) { + if err := usertasks.ValidateUserTask(userTask); err != nil { + return nil, trace.Wrap(err) + } + + r, err := s.service.CreateResource(ctx, userTask) + return r, trace.Wrap(err) +} + +func (s *UserTasksService) UpdateUserTask(ctx context.Context, userTask *usertasksv1.UserTask) (*usertasksv1.UserTask, error) { + if err := usertasks.ValidateUserTask(userTask); err != nil { + return nil, trace.Wrap(err) + } + + r, err := s.service.ConditionalUpdateResource(ctx, userTask) + return r, trace.Wrap(err) +} + +func (s *UserTasksService) UpsertUserTask(ctx context.Context, userTask *usertasksv1.UserTask) (*usertasksv1.UserTask, error) { + if err := usertasks.ValidateUserTask(userTask); err != nil { + return nil, trace.Wrap(err) + } + + r, err := s.service.UpsertResource(ctx, userTask) + return r, trace.Wrap(err) +} + +func (s *UserTasksService) DeleteUserTask(ctx context.Context, name string) error { + err := s.service.DeleteResource(ctx, name) + return trace.Wrap(err) +} + +func (s *UserTasksService) DeleteAllUserTasks(ctx context.Context) error { + err := s.service.DeleteAllResources(ctx) + return trace.Wrap(err) +} diff --git a/lib/services/local/user_task_test.go b/lib/services/local/user_task_test.go new file mode 100644 index 0000000000000..e051c8d7456db --- /dev/null +++ b/lib/services/local/user_task_test.go @@ -0,0 +1,298 @@ +/* + * 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 local_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/jonboulle/clockwork" + "github.com/mailgun/holster/v3/clock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/api/types/usertasks" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local" +) + +func TestCreateUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + + obj, err := usertasks.NewUserTask("obj", &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "ssm_agent_not_running", + DiscoverEc2: &usertasksv1.DiscoverEC2{}, + }) + require.NoError(t, err) + + // first attempt should succeed + objOut, err := service.CreateUserTask(ctx, obj) + require.NoError(t, err) + require.Equal(t, obj, objOut) + + // second attempt should fail, object already exists + _, err = service.CreateUserTask(ctx, obj) + require.Error(t, err) +} + +func TestUpsertUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + obj, err := usertasks.NewUserTask("obj", &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "ssm_agent_not_running", + DiscoverEc2: &usertasksv1.DiscoverEC2{}, + }) + require.NoError(t, err) + // the first attempt should succeed + objOut, err := service.UpsertUserTask(ctx, obj) + require.NoError(t, err) + require.Equal(t, obj, objOut) + + // the second attempt should also succeed + objOut, err = service.UpsertUserTask(ctx, obj) + require.NoError(t, err) + require.Equal(t, obj, objOut) +} + +func TestGetUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + prepopulateUserTask(t, service, 1) + + tests := []struct { + name string + key string + wantErr bool + wantObj *usertasksv1.UserTask + }{ + { + name: "object does not exist", + key: "dummy", + wantErr: true, + wantObj: nil, + }, + { + name: "success", + key: getUserTaskObject(t, 0).GetMetadata().GetName(), + wantErr: false, + wantObj: getUserTaskObject(t, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Fetch a specific object. + obj, err := service.GetUserTask(ctx, tt.key) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + cmpOpts := []cmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + require.Equal(t, "", cmp.Diff(tt.wantObj, obj, cmpOpts...)) + }) + } +} + +func TestUpdateUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + prepopulateUserTask(t, service, 1) + + expiry := timestamppb.New(clock.Now().Add(30 * time.Minute)) + + // Fetch the object from the backend so the revision is populated. + obj, err := service.GetUserTask(ctx, getUserTaskObject(t, 0).GetMetadata().GetName()) + require.NoError(t, err) + // update the expiry time + obj.Metadata.Expires = expiry + + objUpdated, err := service.UpdateUserTask(ctx, obj) + require.NoError(t, err) + require.Equal(t, expiry, objUpdated.Metadata.Expires) + + objFresh, err := service.GetUserTask(ctx, obj.Metadata.Name) + require.NoError(t, err) + require.Equal(t, expiry, objFresh.Metadata.Expires) +} + +func TestUpdateUserTaskMissingRevision(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + prepopulateUserTask(t, service, 1) + + expiry := timestamppb.New(clock.Now().Add(30 * time.Minute)) + + obj := getUserTaskObject(t, 0) + obj.Metadata.Expires = expiry + + // Update should be rejected as the revision is missing. + _, err := service.UpdateUserTask(ctx, obj) + require.Error(t, err) +} + +func TestDeleteUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + service := getUserTasksService(t) + prepopulateUserTask(t, service, 1) + + tests := []struct { + name string + key string + wantErr bool + }{ + { + name: "object does not exist", + key: "dummy", + wantErr: true, + }, + { + name: "success", + key: getUserTaskObject(t, 0).GetMetadata().GetName(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Fetch a specific object. + err := service.DeleteUserTask(ctx, tt.key) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestListUserTask(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + counts := []int{0, 1, 5, 10} + for _, count := range counts { + t.Run(fmt.Sprintf("count=%v", count), func(t *testing.T) { + service := getUserTasksService(t) + prepopulateUserTask(t, service, count) + + t.Run("one page", func(t *testing.T) { + // Fetch all objects. + elements, nextToken, err := service.ListUserTasks(ctx, 200, "") + require.NoError(t, err) + require.Empty(t, nextToken) + require.Len(t, elements, count) + + for i := 0; i < count; i++ { + cmpOpts := []cmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + require.Equal(t, "", cmp.Diff(getUserTaskObject(t, i), elements[i], cmpOpts...)) + } + }) + + t.Run("paginated", func(t *testing.T) { + // Fetch a paginated list of objects + elements := make([]*usertasksv1.UserTask, 0) + nextToken := "" + for { + out, token, err := service.ListUserTasks(ctx, 2, nextToken) + require.NoError(t, err) + nextToken = token + + elements = append(elements, out...) + if nextToken == "" { + break + } + } + + for i := 0; i < count; i++ { + cmpOpts := []cmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + require.Equal(t, "", cmp.Diff(getUserTaskObject(t, i), elements[i], cmpOpts...)) + } + }) + }) + } +} + +func getUserTasksService(t *testing.T) services.UserTasks { + backend, err := memory.New(memory.Config{ + Context: context.Background(), + Clock: clockwork.NewFakeClock(), + }) + require.NoError(t, err) + + service, err := local.NewUserTasksService(backend) + require.NoError(t, err) + return service +} + +func getUserTaskObject(t *testing.T, index int) *usertasksv1.UserTask { + name := fmt.Sprintf("obj%v", index) + obj, err := usertasks.NewUserTask(name, &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "ssm_agent_not_running", + DiscoverEc2: &usertasksv1.DiscoverEC2{}, + }) + require.NoError(t, err) + require.NoError(t, err) + + return obj +} + +func prepopulateUserTask(t *testing.T, service services.UserTasks, count int) { + for i := 0; i < count; i++ { + _, err := service.CreateUserTask(context.Background(), getUserTaskObject(t, i)) + require.NoError(t, err) + } +} diff --git a/lib/services/presets.go b/lib/services/presets.go index b9c134197c288..75e7adfe6e0c9 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -181,6 +181,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindSPIFFEFederation, RW()), types.NewRule(types.KindNotification, RW()), types.NewRule(types.KindStaticHostUser, RW()), + types.NewRule(types.KindUserTask, RW()), }, }, }, diff --git a/lib/services/resource.go b/lib/services/resource.go index 7449aef189c8a..a73cecf9920fd 100644 --- a/lib/services/resource.go +++ b/lib/services/resource.go @@ -245,6 +245,8 @@ func ParseShortcut(in string) (string, error) { return types.KindSPIFFEFederation, nil case types.KindStaticHostUser, types.KindStaticHostUser + "s", "host_user", "host_users": return types.KindStaticHostUser, nil + case types.KindUserTask, types.KindUserTask + "s": + return types.KindUserTask, nil } return "", trace.BadParameter("unsupported resource: %q - resources should be expressed as 'type/name', for example 'connector/github'", in) } diff --git a/lib/services/user_task.go b/lib/services/user_task.go new file mode 100644 index 0000000000000..64f3ae2d54c6c --- /dev/null +++ b/lib/services/user_task.go @@ -0,0 +1,53 @@ +/* + * 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 services + +import ( + "context" + + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" +) + +// UserTasks is the interface for managing user tasks resources. +type UserTasks interface { + // CreateUserTask creates a new user tasks resource. + CreateUserTask(context.Context, *usertasksv1.UserTask) (*usertasksv1.UserTask, error) + // UpsertUserTask creates or updates the user tasks resource. + UpsertUserTask(context.Context, *usertasksv1.UserTask) (*usertasksv1.UserTask, error) + // GetUserTask returns the user tasks resource by name. + GetUserTask(ctx context.Context, name string) (*usertasksv1.UserTask, error) + // ListUserTasks returns the user tasks resources. + ListUserTasks(ctx context.Context, pageSize int64, nextToken string) ([]*usertasksv1.UserTask, string, error) + // UpdateUserTask updates the user tasks resource. + UpdateUserTask(context.Context, *usertasksv1.UserTask) (*usertasksv1.UserTask, error) + // DeleteUserTask deletes the user tasks resource by name. + DeleteUserTask(context.Context, string) error + // DeleteAllUserTasks deletes all user tasks. + DeleteAllUserTasks(context.Context) error +} + +// MarshalUserTask marshals the UserTask object into a JSON byte array. +func MarshalUserTask(object *usertasksv1.UserTask, opts ...MarshalOption) ([]byte, error) { + return MarshalProtoResource(object, opts...) +} + +// UnmarshalUserTask unmarshals the UserTask object from a JSON byte array. +func UnmarshalUserTask(data []byte, opts ...MarshalOption) (*usertasksv1.UserTask, error) { + return UnmarshalProtoResource[*usertasksv1.UserTask](data, opts...) +} diff --git a/lib/services/user_task_test.go b/lib/services/user_task_test.go new file mode 100644 index 0000000000000..46be4b7c209a6 --- /dev/null +++ b/lib/services/user_task_test.go @@ -0,0 +1,134 @@ +/* + * 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 services + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" + "github.com/gravitational/teleport/lib/utils" +) + +func TestMarshalUserTaskRoundTrip(t *testing.T) { + t.Parallel() + + obj := &usertasksv1.UserTask{ + Version: "v1", + Kind: "user_task", + Metadata: &headerv1.Metadata{ + Name: "example-user-task", + Labels: map[string]string{ + "env": "example", + }, + }, + Spec: &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "SSM_AGENT_MISSING", + State: "OPEN", + DiscoverEc2: &usertasksv1.DiscoverEC2{Instances: map[string]*usertasksv1.DiscoverEC2Instance{ + "i-1234567890": { + Name: "instance-name", + Region: "us-east-1", + InvocationUrl: "https://example.com/", + DiscoveryConfig: "config", + DiscoveryGroup: "group", + SyncTime: timestamppb.Now(), + }, + }}, + }, + } + + out, err := MarshalUserTask(obj) + require.NoError(t, err) + newObj, err := UnmarshalUserTask(out) + require.NoError(t, err) + require.True(t, proto.Equal(obj, newObj), "messages are not equal") +} + +func TestUnmarshalUserTask(t *testing.T) { + t.Parallel() + + syncTime := timestamppb.Now() + syncTimeString := syncTime.AsTime().Format(time.RFC3339Nano) + + correctUserTaskYAML := fmt.Sprintf(` +version: v1 +kind: user_task +metadata: + name: example-user-task + labels: + env: example +spec: + integration: my-integration + task_type: discover-ec2 + issue_type: SSM_AGENT_MISSING + state: OPEN + discover_ec2: + instances: + i-1234567890: + name: instance-name + region: us-east-1 + invocation_url: https://example.com/ + discovery_config: config + discovery_group: group + sync_time: "%s" +`, syncTimeString) + + data, err := utils.ToJSON([]byte(correctUserTaskYAML)) + require.NoError(t, err) + + expected := &usertasksv1.UserTask{ + Version: "v1", + Kind: "user_task", + Metadata: &headerv1.Metadata{ + Name: "example-user-task", + Labels: map[string]string{ + "env": "example", + }, + }, + Spec: &usertasksv1.UserTaskSpec{ + Integration: "my-integration", + TaskType: "discover-ec2", + IssueType: "SSM_AGENT_MISSING", + State: "OPEN", + DiscoverEc2: &usertasksv1.DiscoverEC2{Instances: map[string]*usertasksv1.DiscoverEC2Instance{ + "i-1234567890": { + Name: "instance-name", + Region: "us-east-1", + InvocationUrl: "https://example.com/", + DiscoveryConfig: "config", + DiscoveryGroup: "group", + SyncTime: syncTime, + }, + }}, + }, + } + + obj, err := UnmarshalUserTask(data) + require.NoError(t, err) + require.True(t, proto.Equal(expected, obj), "UserTask objects are not equal") +} diff --git a/lib/services/useracl.go b/lib/services/useracl.go index ef8c1ec7b6bf7..6df6e63316e67 100644 --- a/lib/services/useracl.go +++ b/lib/services/useracl.go @@ -84,6 +84,8 @@ type UserACL struct { Plugins ResourceAccess `json:"plugins"` // Integrations defines whether the user has access to manage integrations. Integrations ResourceAccess `json:"integrations"` + // UserTasks defines whether the user has access to manage UserTasks. + UserTasks ResourceAccess `json:"userTasks"` // DeviceTrust defines access to device trust. DeviceTrust ResourceAccess `json:"deviceTrust"` // Locks defines access to locking resources. @@ -198,6 +200,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des bots := newAccess(userRoles, ctx, types.KindBot) botInstances := newAccess(userRoles, ctx, types.KindBotInstance) crownJewelAccess := newAccess(userRoles, ctx, types.KindCrownJewel) + userTasksAccess := newAccess(userRoles, ctx, types.KindUserTask) var auditQuery ResourceAccess var securityReports ResourceAccess @@ -231,6 +234,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des License: license, Plugins: pluginsAccess, Integrations: integrationsAccess, + UserTasks: userTasksAccess, DiscoveryConfig: discoveryConfigsAccess, DeviceTrust: deviceTrust, Locks: lockAccess, diff --git a/tool/tctl/common/collection.go b/tool/tctl/common/collection.go index 3126f565e663f..fbcee004f5121 100644 --- a/tool/tctl/common/collection.go +++ b/tool/tctl/common/collection.go @@ -38,6 +38,7 @@ import ( loginrulepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/loginrule/v1" machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" @@ -1794,3 +1795,32 @@ func printSortedStringSlice(s []string) string { slices.Sort(s) return strings.Join(s, ",") } + +type userTaskCollection struct { + items []*usertasksv1.UserTask +} + +func (c *userTaskCollection) resources() []types.Resource { + r := make([]types.Resource, 0, len(c.items)) + for _, resource := range c.items { + r = append(r, types.Resource153ToLegacy(resource)) + } + return r +} + +// writeText formats the user tasks into a table and writes them into w. +// If verbose is disabled, labels column can be truncated to fit into the console. +func (c *userTaskCollection) writeText(w io.Writer, verbose bool) error { + var rows [][]string + for _, item := range c.items { + labels := common.FormatLabels(item.GetMetadata().GetLabels(), verbose) + rows = append(rows, []string{item.Metadata.GetName(), labels, item.Spec.TaskType, item.Spec.IssueType, item.Spec.GetIntegration()}) + } + headers := []string{"Name", "Labels", "TaskType", "IssueType", "Integration"} + t := asciitable.MakeTable(headers, rows...) + + // stable sort by name. + t.SortRowsBy([]int{0}, true) + _, err := t.AsBuffer().WriteTo(w) + return trace.Wrap(err) +} diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index bb90404387d80..b1bd0125e03c7 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -51,6 +51,7 @@ import ( machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" + usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/internalutils/stream" "github.com/gravitational/teleport/api/mfa" @@ -168,6 +169,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindPlugin: rc.createPlugin, types.KindSPIFFEFederation: rc.createSPIFFEFederation, types.KindStaticHostUser: rc.createStaticHostUser, + types.KindUserTask: rc.createUserTask, } rc.UpdateHandlers = map[ResourceKind]ResourceCreateHandler{ types.KindUser: rc.updateUser, @@ -184,6 +186,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindAccessGraphSettings: rc.updateAccessGraphSettings, types.KindPlugin: rc.updatePlugin, types.KindStaticHostUser: rc.updateStaticHostUser, + types.KindUserTask: rc.updateUserTask, } rc.config = config @@ -963,6 +966,28 @@ func (rc *ResourceCommand) createCrownJewel(ctx context.Context, client *authcli return nil } +func (rc *ResourceCommand) createUserTask(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error { + resource, err := services.UnmarshalUserTask(raw.Raw) + if err != nil { + return trace.Wrap(err) + } + + c := client.UserTasksServiceClient() + if rc.force { + if _, err := c.UpsertUserTask(ctx, resource); err != nil { + return trace.Wrap(err) + } + fmt.Printf("user task %q has been updated\n", resource.GetMetadata().GetName()) + } else { + if _, err := c.CreateUserTask(ctx, resource); err != nil { + return trace.Wrap(err) + } + fmt.Printf("user task %q has been created\n", resource.GetMetadata().GetName()) + } + + return nil +} + func (rc *ResourceCommand) createSPIFFEFederation(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error { in, err := services.UnmarshalSPIFFEFederation(raw.Raw) if err != nil { @@ -992,6 +1017,18 @@ func (rc *ResourceCommand) updateCrownJewel(ctx context.Context, client *authcli return nil } +func (rc *ResourceCommand) updateUserTask(ctx context.Context, client *authclient.Client, resource services.UnknownResource) error { + in, err := services.UnmarshalUserTask(resource.Raw) + if err != nil { + return trace.Wrap(err) + } + if _, err := client.UserTasksServiceClient().UpdateUserTask(ctx, in); err != nil { + return trace.Wrap(err) + } + fmt.Printf("user task %q has been updated\n", in.GetMetadata().GetName()) + return nil +} + func (rc *ResourceCommand) createDatabase(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error { database, err := services.UnmarshalDatabase(raw.Raw) if err != nil { @@ -1759,6 +1796,12 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client *authclient.Client } fmt.Printf("Integration %q removed\n", rc.ref.Name) + case types.KindUserTask: + if err := client.UserTasksServiceClient().DeleteUserTask(ctx, rc.ref.Name); err != nil { + return trace.Wrap(err) + } + fmt.Printf("user task %q has been deleted\n", rc.ref.Name) + case types.KindDiscoveryConfig: remote := client.DiscoveryConfigClient() if err := remote.DeleteDiscoveryConfig(ctx, rc.ref.Name); err != nil { @@ -2775,6 +2818,31 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient } } return &integrationCollection{integrations: resources}, nil + case types.KindUserTask: + userTasksClient := client.UserTasksClient() + if rc.ref.Name != "" { + uit, err := userTasksClient.GetUserTask(ctx, rc.ref.Name) + if err != nil { + return nil, trace.Wrap(err) + } + return &userTaskCollection{items: []*usertasksv1.UserTask{uit}}, nil + } + + var tasks []*usertasksv1.UserTask + nextToken := "" + for { + resp, token, err := userTasksClient.ListUserTasks(ctx, 0 /* default size */, nextToken) + if err != nil { + return nil, trace.Wrap(err) + } + tasks = append(tasks, resp...) + + if token == "" { + break + } + nextToken = token + } + return &userTaskCollection{items: tasks}, nil case types.KindDiscoveryConfig: remote := client.DiscoveryConfigClient() if rc.ref.Name != "" {