Files
teleport/lib/auth/machineid/machineidv1/bot_service.go
T
Noah Stride b8881600f6 Remove legacy user generation label handling (#69335)
* Remove legacy fallback for bot generation

* fixup test
2026-08-11 08:46:02 +00:00

1271 lines
41 KiB
Go

/*
* Teleport
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
*/
package machineidv1
import (
"context"
"fmt"
"log/slog"
"maps"
"slices"
"time"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/scopes"
scopedaccess "github.com/gravitational/teleport/lib/scopes/access"
"github.com/gravitational/teleport/lib/services"
usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport"
"github.com/gravitational/teleport/lib/utils/set"
)
// SupportedJoinMethods should match SupportedJoinMethods declared in
// lib/tbot/config
var SupportedJoinMethods = []types.JoinMethod{
types.JoinMethodAzure,
types.JoinMethodAzureDevops,
types.JoinMethodCircleCI,
types.JoinMethodEnv0,
types.JoinMethodGCP,
types.JoinMethodGenericOIDC,
types.JoinMethodGitHub,
types.JoinMethodGitLab,
types.JoinMethodIAM,
types.JoinMethodKubernetes,
types.JoinMethodSpacelift,
types.JoinMethodToken,
types.JoinMethodTPM,
types.JoinMethodTerraformCloud,
types.JoinMethodBitbucket,
types.JoinMethodOracle,
types.JoinMethodBoundKeypair,
}
// Cache is the subset of the cached resources that the Service queries.
type Cache interface {
// GetUser returns a user by name.
GetUser(ctx context.Context, user string, withSecrets bool) (types.User, error)
// ListUsers lists users
ListUsers(ctx context.Context, req *userspb.ListUsersRequest) (*userspb.ListUsersResponse, error)
// GetRole returns a role by name.
GetRole(ctx context.Context, name string) (types.Role, error)
}
// Backend is the subset of the backend resources that the Service modifies.
type Backend interface {
// CreateUser creates user, only if the user entry does not exist
CreateUser(ctx context.Context, user types.User) (types.User, error)
// CreateRole creates role, only if the role entry does not exist
CreateRole(ctx context.Context, role types.Role) (types.Role, error)
// UpdateUser updates an existing user if revisions match.
UpdateUser(ctx context.Context, user types.User) (types.User, error)
// UpdateRole updates an existing role if revisions match.
UpdateRole(ctx context.Context, role types.Role) (types.Role, error)
// UpsertUser creates a new user or forcefully updates an existing user.
UpsertUser(ctx context.Context, user types.User) (types.User, error)
// UpsertRole creates a new role or forcefully updates an existing role.
UpsertRole(ctx context.Context, role types.Role) (types.Role, error)
// UpsertToken creates a new token or forcefully updates an existing token.
UpsertToken(ctx context.Context, token types.ProvisionToken) error
// DeleteRole deletes a role by name.
DeleteRole(ctx context.Context, name string) error
// DeleteUser deletes a user and all associated objects.
DeleteUser(ctx context.Context, user string) error
// GetUser returns a user by name.
GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error)
// GetRole returns a role by name.
GetRole(ctx context.Context, name string) (types.Role, error)
// GetToken returns a token by name.
GetToken(ctx context.Context, name string) (types.ProvisionToken, error)
// DeleteUserLoginState deletes a user login state.
DeleteUserLoginState(ctx context.Context, name string) error
}
// BotServiceConfig holds configuration options for
// the bots gRPC service.
type BotServiceConfig struct {
ScopedAuthorizer authz.ScopedAuthorizer
Cache Cache
Backend Backend
Logger *slog.Logger
Emitter apievents.Emitter
Reporter usagereporter.UsageReporter
Clock clockwork.Clock
// ScopesFeatures dictates whether scoped bot functionality is enabled.
ScopesFeatures scopes.Features
}
// NewBotService returns a new instance of the BotService.
func NewBotService(cfg BotServiceConfig) (*BotService, error) {
switch {
case cfg.Cache == nil:
return nil, trace.BadParameter("cache service is required")
case cfg.Backend == nil:
return nil, trace.BadParameter("backend service is required")
case cfg.ScopedAuthorizer == nil:
return nil, trace.BadParameter("scoped authorizer is required")
case cfg.Emitter == nil:
return nil, trace.BadParameter("emitter is required")
case cfg.Reporter == nil:
return nil, trace.BadParameter("reporter is required")
case cfg.Logger == nil:
return nil, trace.BadParameter("logger is required")
}
if cfg.Clock == nil {
cfg.Clock = clockwork.NewRealClock()
}
return &BotService{
logger: cfg.Logger,
scopedAuthorizer: cfg.ScopedAuthorizer,
cache: cfg.Cache,
backend: cfg.Backend,
emitter: cfg.Emitter,
reporter: cfg.Reporter,
clock: cfg.Clock,
scopesFeatures: cfg.ScopesFeatures,
}, nil
}
// BotService implements the teleport.machineid.v1.BotService RPC service.
type BotService struct {
pb.UnimplementedBotServiceServer
cache Cache
backend Backend
scopedAuthorizer authz.ScopedAuthorizer
logger *slog.Logger
emitter apievents.Emitter
reporter usagereporter.UsageReporter
clock clockwork.Clock
// scopesFeatures dictates whether scoped bot functionality is enabled.
scopesFeatures scopes.Features
}
// GetBot gets a bot by name. It will throw an error if the bot does not exist.
func (bs *BotService) GetBot(ctx context.Context, req *pb.GetBotRequest) (*pb.Bot, error) {
authCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
// Check if it's feasible that user has access before hitting the cluster
// state backend.
ruleCtx := authCtx.RuleContext()
if err := authCtx.CheckerContext.CheckMaybeHasAccessToRules(
&ruleCtx, types.KindBot, scopedaccess.Read,
); err != nil {
return nil, trace.Wrap(err)
}
if req.GetBotName() == "" {
return nil, trace.BadParameter("bot_name: must be non-empty")
}
bot, err := bs.getBot(ctx, scopes.QualifiedName{Scope: req.GetScope(), Name: req.GetBotName()})
if err != nil {
return nil, trace.Wrap(err, "fetching bot")
}
ruleCtx.Resource153 = bot
if err := authCtx.CheckerContext.Decision(
ctx, bot.GetScope(), func(checker *services.ScopedAccessChecker) error {
return checker.CheckAccessToRules(&ruleCtx, types.KindBot, scopedaccess.Read)
},
); err != nil {
// Return NotFound rather than Forbidden to avoid leaking existence of
// bot.
return nil, trace.NotFound("bot %q not found", req.GetBotName())
}
return bot, nil
}
func (bs *BotService) getBot(
ctx context.Context, name scopes.QualifiedName,
) (*pb.Bot, error) {
resourceName, err := services.BotResourceName(name)
if err != nil {
return nil, trace.Wrap(err, "building bot resource name")
}
user, err := bs.cache.GetUser(ctx, resourceName, false)
if err != nil {
return nil, trace.Wrap(err, "fetching bot user")
}
if botScope, _ := user.GetLabel(types.BotScopeLabel); botScope != "" {
bot, err := scopedBotFromUser(user)
if err != nil {
return nil, trace.Wrap(err, "converting from resources")
}
return bot, nil
}
// An unscoped bot's User and Role share a name.
role, err := bs.cache.GetRole(ctx, resourceName)
if err != nil {
return nil, trace.Wrap(err, "fetching bot role")
}
bot, err := botFromUserAndRole(user, role)
if err != nil {
return nil, trace.Wrap(err, "converting from resources")
}
return bot, nil
}
// ListBots lists all bots.
func (bs *BotService) ListBots(
ctx context.Context, req *pb.ListBotsRequest,
) (*pb.ListBotsResponse, error) {
authCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
ruleCtx := authCtx.RuleContext()
// Check generally if this user may have the ability to list bots - ignoring
// where conditions.
if err := authCtx.CheckerContext.CheckMaybeHasAccessToRules(
&ruleCtx, types.KindBot, scopedaccess.List,
); err != nil {
return nil, trace.Wrap(err)
}
// TODO(noah): We should add pre-hydrated Bot support to the cache to avoid
// needing to iterate over all users here. This is currently a fairly
// expensive implementation.
bots := []*pb.Bot{}
rsp, err := bs.cache.ListUsers(ctx, userspb.ListUsersRequest_builder{
PageSize: req.GetPageSize(),
PageToken: req.GetPageToken(),
}.Build())
if err != nil {
return nil, trace.Wrap(err, "listing users")
}
for _, u := range rsp.GetUsers() {
botName, isBot := u.GetLabel(types.BotLabel)
if !isBot {
continue
}
scope, _ := u.GetLabel(types.BotScopeLabel)
var bot *pb.Bot
if scope == "" {
// We only need to fetch the bot role for unscoped bots, whose Role
// shares its name with the User.
role, err := bs.cache.GetRole(ctx, u.GetName())
if err != nil {
bs.logger.WarnContext(
ctx,
"Failed to fetch role for bot during ListBots. Bot will be omitted from results",
"error", err,
"bot_name", botName,
)
continue
}
bot, err = botFromUserAndRole(u, role)
if err != nil {
bs.logger.WarnContext(
ctx,
"Failed to convert bot during ListBots. Bot will be omitted from results",
"error", err,
"bot_name", botName,
)
continue
}
} else {
bot, err = scopedBotFromUser(u)
if err != nil {
bs.logger.WarnContext(
ctx,
"Failed to convert scoped bot during ListBots. Bot will be omitted from results",
"error", err,
"bot_name", botName,
)
continue
}
}
// Check if user can access this specific Bot.
ruleCtx := authCtx.RuleContext()
ruleCtx.Resource153 = bot
if err := authCtx.CheckerContext.Decision(ctx, bot.GetScope(), func(checker *services.ScopedAccessChecker) error {
return checker.CheckAccessToRules(&ruleCtx, types.KindBot, scopedaccess.List)
}); err != nil {
// Ignore resources the user cannot access.
continue
}
bots = append(bots, bot)
}
return pb.ListBotsResponse_builder{
Bots: bots,
NextPageToken: rsp.GetNextPageToken(),
}.Build(), nil
}
// CreateBot creates a new bot. It will throw an error if the bot already
// exists.
func (bs *BotService) CreateBot(
ctx context.Context, req *pb.CreateBotRequest,
) (*pb.Bot, error) {
if err := setKindAndVersion(req.GetBot()); err != nil {
return nil, trace.Wrap(err, "setting kind and version")
}
authCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if err := StrongValidateBot(req.GetBot()); err != nil {
return nil, trace.Wrap(err, "validating bot")
}
// Validation comes before authz checks so we know that scope etc is
// well-formed.
ruleCtx := authCtx.RuleContext()
ruleCtx.Resource153 = req.GetBot()
if err := authCtx.CheckerContext.Decision(ctx, req.GetBot().GetScope(), func(checker *services.ScopedAccessChecker) error {
return checker.CheckAccessToRules(&ruleCtx, types.KindBot, scopedaccess.Create)
}); err != nil {
return nil, trace.Wrap(err)
}
if unscoped, ok := authCtx.UnscopedContext(); ok {
// We can only perform MFA checks on unscoped identities.
// TODO(noah/scopes): When scopes supports MFA, add check here :')
if err := unscoped.AuthorizeAdminActionAllowReusedMFA(); err != nil {
return nil, trace.Wrap(err)
}
}
var bot *pb.Bot
if req.GetBot().GetScope() != "" {
bot, err = bs.createScopedBot(ctx, authCtx, req.GetBot())
if err != nil {
return nil, trace.Wrap(err)
}
} else {
bot, err = bs.createUnscopedBot(ctx, authCtx, req.GetBot())
if err != nil {
return nil, trace.Wrap(err)
}
}
// TODO(strideynet): the usage event carries no scope, so this reports the
// unscoped name even for a scoped bot. The unscoped form cannot fail.
botUserName, _ := services.BotResourceName(scopes.QualifiedName{Name: bot.GetMetadata().GetName()})
bs.reporter.AnonymizeAndSubmit(&usagereporter.BotCreateEvent{
UserName: authz.ClientUsername(ctx),
BotUserName: botUserName,
RoleName: botUserName,
BotName: bot.GetMetadata().GetName(),
RoleCount: int64(len(bot.GetSpec().GetRoles())),
})
if err := bs.emitter.EmitAuditEvent(ctx, &apievents.BotCreate{
Metadata: apievents.Metadata{
Type: events.BotCreateEvent,
Code: events.BotCreateCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: bot.GetMetadata().GetName(),
Scope: bot.GetScope(),
},
}); err != nil {
bs.logger.WarnContext(
ctx, "Failed to emit BotCreate audit event",
"error", err,
)
}
return bot, nil
}
func (bs *BotService) createScopedBot(
ctx context.Context,
authCtx *authz.ScopedContext,
bot *pb.Bot,
) (*pb.Bot, error) {
if err := bs.scopesFeatures.AssertEnabled(); err != nil {
return nil, trace.Wrap(err)
}
user, err := scopedBotToUser(bot, bs.clock.Now(), authCtx.User.GetName())
if err != nil {
return nil, trace.Wrap(err, "converting to user resource")
}
user, err = bs.backend.CreateUser(ctx, user)
if err != nil {
return nil, trace.Wrap(err, "creating bot user")
}
bot, err = scopedBotFromUser(user)
if err != nil {
return nil, trace.Wrap(err, "converting from user resource")
}
return bot, nil
}
func (bs *BotService) createUnscopedBot(
ctx context.Context,
authCtx *authz.ScopedContext,
bot *pb.Bot,
) (*pb.Bot, error) {
user, role, err := botToUserAndRole(
bot, bs.clock.Now(), authCtx.User.GetName(),
)
if err != nil {
return nil, trace.Wrap(err, "converting to resources")
}
user, err = bs.backend.CreateUser(ctx, user)
if err != nil {
return nil, trace.Wrap(err, "creating bot user")
}
role, err = bs.backend.CreateRole(ctx, role)
if err != nil {
return nil, trace.Wrap(err, "creating bot role")
}
// Convert back from user and role, this ensures some consistency in what
// we return (i.e if client has provided a bot config that does not
// roundtrip).
bot, err = botFromUserAndRole(user, role)
if err != nil {
return nil, trace.Wrap(err, "converting from resources")
}
return bot, nil
}
// UpsertBot creates a new bot or forcefully updates an existing bot. This is
// a function rather than a method so that it can be used by both the gRPC
// service and the auth server init code when dealing with resources to be
// applied at startup.
func UpsertBot(
ctx context.Context,
backend Backend,
bot *pb.Bot,
now time.Time,
createdBy string,
scopesFeatures scopes.Features,
) (*pb.Bot, error) {
if bot.GetScope() != "" {
if err := scopesFeatures.AssertEnabled(); err != nil {
return nil, trace.Wrap(err)
}
}
if err := StrongValidateBot(bot); err != nil {
return nil, trace.Wrap(err, "validating bot")
}
// An upsert under a different scope addresses a different bot rather than
// transitioning an existing bot's scope, so there is no scope-transition
// guard here.
resourceName, err := services.BotResourceName(scopes.QualifiedName{Scope: bot.GetScope(), Name: bot.GetMetadata().GetName()})
if err != nil {
return nil, trace.Wrap(err, "building bot resource name")
}
// Fetch pre-existing user, we'll use this to preserve generation.
existingUser, err := backend.GetUser(ctx, resourceName, false)
if err != nil && !trace.IsNotFound(err) {
// We'll happily ignore a not-found error, in this case, we have an
// upsert for a non-existent bot. If we have any other kind of error,
// we want to propagate this up.
return nil, trace.Wrap(err, "fetching existing bot user")
}
// An unscoped bot's name can mimic a scoped bot's encoded user name, so
// refuse to write through a user that belongs to a different (scope, name)
// or to no bot at all.
if existingUser != nil {
existingBotName, _ := existingUser.GetLabel(types.BotLabel)
existingBotScope, _ := existingUser.GetLabel(types.BotScopeLabel)
if existingBotName != bot.GetMetadata().GetName() || existingBotScope != bot.GetScope() {
return nil, trace.AlreadyExists("bot %q: backing user is held by a different bot or user", bot.GetMetadata().GetName())
}
}
// Create User (and maybe Role if unscoped) from the Bot.
var user types.User
var role types.Role
if bot.GetScope() != "" {
user, err = scopedBotToUser(bot, now, createdBy)
if err != nil {
return nil, trace.Wrap(err, "converting scoped bot to user resource")
}
} else {
user, role, err = botToUserAndRole(bot, now, createdBy)
if err != nil {
return nil, trace.Wrap(err, "converting unscoped bot to resources")
}
}
// Preserve the legacy generation label if the existing user carries one.
// TODO(noah): DELETE IN v20 alongside the creation-time label write
// (see botToUserAndRole) - https://github.com/gravitational/teleport/issues/64484
if existingUser != nil {
//nolint:staticcheck // deprecated, kept for v18 downgrade compat until v20
if existingGeneration, ok := existingUser.GetLabel(types.BotGenerationLabel); ok {
meta := user.GetMetadata()
//nolint:staticcheck // deprecated, kept for v18 downgrade compat until v20
meta.Labels[types.BotGenerationLabel] = existingGeneration
user.SetMetadata(meta)
}
}
user, err = backend.UpsertUser(ctx, user)
if err != nil {
return nil, trace.Wrap(err, "upserting bot user")
}
if role != nil {
// Bot role only exists for unscoped bots.
role, err = backend.UpsertRole(ctx, role)
if err != nil {
return nil, trace.Wrap(err, "upserting bot role")
}
bot, err = botFromUserAndRole(user, role)
if err != nil {
return nil, trace.Wrap(err, "converting unscoped bot from resources")
}
return bot, nil
}
bot, err = scopedBotFromUser(user)
if err != nil {
return nil, trace.Wrap(err, "converting scoped bot from user resource")
}
return bot, nil
}
// UpsertBot creates a new bot or forcefully updates an existing bot.
func (bs *BotService) UpsertBot(ctx context.Context, req *pb.UpsertBotRequest) (*pb.Bot, error) {
if err := setKindAndVersion(req.GetBot()); err != nil {
return nil, trace.Wrap(err, "setting kind and version")
}
authCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if err := StrongValidateBot(req.GetBot()); err != nil {
return nil, trace.Wrap(err, "validating bot")
}
// Validation comes before authz checks so that we know scope (if present)
// is well-formed.
ruleCtx := authCtx.RuleContext()
ruleCtx.Resource153 = req.GetBot()
if err := authCtx.CheckerContext.Decision(
ctx,
req.GetBot().GetScope(),
func(checker *services.ScopedAccessChecker) error {
return checker.CheckAccessToRules(
&ruleCtx, types.KindBot, scopedaccess.Create, scopedaccess.Update,
)
},
); err != nil {
return nil, trace.Wrap(err)
}
if unscoped, ok := authCtx.UnscopedContext(); ok {
// We can only perform MFA checks on unscoped identities.
// TODO(noah/scopes): When scopes supports MFA, add check here :')
// Allow re-use for bulk upserts.
if err := unscoped.AuthorizeAdminActionAllowReusedMFA(); err != nil {
return nil, trace.Wrap(err)
}
}
bot, err := UpsertBot(
ctx, bs.backend, req.GetBot(), bs.clock.Now(), authCtx.User.GetName(), bs.scopesFeatures,
)
if err != nil {
return nil, trace.Wrap(err)
}
// TODO(strideynet): the usage event carries no scope, so this reports the
// unscoped name even for a scoped bot. The unscoped form cannot fail.
botUserName, _ := services.BotResourceName(scopes.QualifiedName{Name: bot.GetMetadata().GetName()})
bs.reporter.AnonymizeAndSubmit(&usagereporter.BotCreateEvent{
UserName: authz.ClientUsername(ctx),
BotUserName: botUserName,
RoleName: botUserName,
BotName: bot.GetMetadata().GetName(),
RoleCount: int64(len(bot.GetSpec().GetRoles())),
})
if err := bs.emitter.EmitAuditEvent(ctx, &apievents.BotCreate{
Metadata: apievents.Metadata{
Type: events.BotCreateEvent,
Code: events.BotCreateCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: bot.GetMetadata().GetName(),
Scope: bot.GetScope(),
},
}); err != nil {
bs.logger.WarnContext(
ctx, "Failed to emit BotCreate audit event",
"error", err,
)
}
return bot, nil
}
// UpdateBot updates an existing bot. It will throw an error if the bot does
// not exist.
func (bs *BotService) UpdateBot(
ctx context.Context, req *pb.UpdateBotRequest,
) (*pb.Bot, error) {
if err := setKindAndVersion(req.GetBot()); err != nil {
return nil, trace.Wrap(err, "setting kind and version")
}
scopedAuthCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
// Out of an abundance of caution, we're avoiding scoped identities/bots
// and the update RPC for now. There's no meaningful fields a scoped
// identity would need to interact with on a Bot or any identity on a
// scoped Bot.
authCtx, ok := scopedAuthCtx.UnscopedContext()
if !ok {
return nil, trace.AccessDenied("scoped identity cannot call update Bot RPC")
}
if err := authCtx.CheckAccessToResource153(
req.GetBot(),
types.VerbUpdate,
); err != nil {
return nil, trace.Wrap(err)
}
if err := authCtx.AuthorizeAdminAction(); err != nil {
return nil, trace.Wrap(err)
}
switch {
case !req.HasBot():
return nil, trace.BadParameter("bot: must be non-nil")
case !req.GetBot().HasMetadata():
return nil, trace.BadParameter("bot.metadata: must be non-nil")
case req.GetBot().GetMetadata().GetName() == "":
return nil, trace.BadParameter("bot.metadata.name: must be non-empty")
case !req.GetBot().HasSpec():
return nil, trace.BadParameter("bot.spec: must be non-nil")
case !req.HasUpdateMask():
return nil, trace.BadParameter("update_mask: must be non-nil")
case len(req.GetUpdateMask().Paths) == 0:
return nil, trace.BadParameter("update_mask.paths: must be non-empty")
}
// Scoped bots have no updatable fields: roles, traits and max_session_ttl
// cannot be set on them.
if req.GetBot().GetScope() != "" {
return nil, trace.BadParameter("cannot update scoped bot")
}
// Unscoped by the check above, so the User and Role share this name.
resourceName, err := services.BotResourceName(scopes.QualifiedName{Name: req.GetBot().GetMetadata().GetName()})
if err != nil {
return nil, trace.Wrap(err, "building bot resource name")
}
user, err := bs.backend.GetUser(ctx, resourceName, false)
if err != nil {
return nil, trace.Wrap(err, "getting bot user")
}
if scope := user.GetMetadata().Labels[types.BotScopeLabel]; scope != "" {
return nil, trace.BadParameter("cannot update scoped bot")
}
role, err := bs.backend.GetRole(ctx, resourceName)
if err != nil {
return nil, trace.Wrap(err, "getting bot role")
}
for _, path := range req.GetUpdateMask().Paths {
switch path {
case "spec.roles":
if slices.Contains(req.GetBot().GetSpec().GetRoles(), "") {
return nil, trace.BadParameter(
"spec.roles: must not contain empty strings",
)
}
role.SetImpersonateConditions(types.Allow, types.ImpersonateConditions{
Roles: req.GetBot().GetSpec().GetRoles(),
})
case "spec.traits":
traits := map[string][]string{}
for _, t := range req.GetBot().GetSpec().GetTraits() {
if len(t.GetValues()) == 0 {
continue
}
if traits[t.GetName()] == nil {
traits[t.GetName()] = []string{}
}
traits[t.GetName()] = append(traits[t.GetName()], t.GetValues()...)
}
user.SetTraits(traits)
case "spec.max_session_ttl":
opts := role.GetOptions()
opts.MaxSessionTTL = types.Duration(req.GetBot().GetSpec().GetMaxSessionTtl().AsDuration())
role.SetOptions(opts)
case "metadata.description":
meta := user.GetMetadata()
meta.Description = req.GetBot().GetMetadata().GetDescription()
user.SetMetadata(meta)
default:
return nil, trace.BadParameter("update_mask: unsupported path %q", path)
}
}
user, err = bs.backend.UpdateUser(ctx, user)
if err != nil {
return nil, trace.Wrap(err, "updating bot user")
}
role, err = bs.backend.UpdateRole(ctx, role)
if err != nil {
return nil, trace.Wrap(err, "updating bot role")
}
if err := bs.emitter.EmitAuditEvent(ctx, &apievents.BotUpdate{
Metadata: apievents.Metadata{
Type: events.BotUpdateEvent,
Code: events.BotUpdateCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: req.GetBot().GetMetadata().GetName(),
Scope: req.GetBot().GetScope(),
},
}); err != nil {
bs.logger.WarnContext(
ctx, "Failed to emit BotUpdate audit event",
"error", err,
)
}
bot, err := botFromUserAndRole(user, role)
if err != nil {
return nil, trace.Wrap(err, "converting from resources")
}
return bot, nil
}
func (bs *BotService) deleteBotUser(
ctx context.Context, botName string, user types.User,
) error {
if v := user.GetMetadata().Labels[types.BotLabel]; v != botName {
return trace.BadParameter(
"user missing bot label matching bot name; consider manually deleting user",
)
}
// Try to delete any ULS for this bot user. The Okta usermonitor could
// occasionally create invalid ULS entries for bots which are otherwise
// impossible to remove. This at least ensures they can be manually deleted,
// as ULS cannot be managed otherwise (not available via tctl, etc).
// `NotFound` is normal/expected with this bug fixed in the usermonitor,
// but at worst treat any other errors as a warning.
if err := bs.backend.DeleteUserLoginState(ctx, user.GetName()); err != nil {
if !trace.IsNotFound(err) {
bs.logger.WarnContext(
ctx, "failed to delete user login state for bot",
"user", user.GetName(),
"error", err,
)
}
}
return bs.backend.DeleteUser(ctx, user.GetName())
}
func (bs *BotService) deleteBotRole(ctx context.Context, botName string) error {
// Check the role that's being deleted is linked to the bot.
roleName, err := services.BotResourceName(scopes.QualifiedName{Name: botName})
if err != nil {
return trace.Wrap(err, "building bot resource name")
}
role, err := bs.backend.GetRole(ctx, roleName)
if err != nil {
return trace.Wrap(err, "fetching bot role")
}
if v := role.GetMetadata().Labels[types.BotLabel]; v != botName {
return trace.BadParameter(
"role missing bot label matching bot name; consider manually deleting role",
)
}
return bs.backend.DeleteRole(ctx, role.GetName())
}
// dummyBotWithName returns a dummy bot with the given name. This is used
// for evaluating RBAC for the Delete RPC
func dummyBotWithName(name string) *pb.Bot {
return pb.Bot_builder{
Kind: types.KindBot,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: name,
}.Build(),
}.Build()
}
// DeleteBot deletes an existing bot. It will throw an error if the bot does
// not exist.
func (bs *BotService) DeleteBot(
ctx context.Context, req *pb.DeleteBotRequest,
) (*emptypb.Empty, error) {
// Note: this does not remove any locks for the bot's user / role. That
// might be convenient in case of accidental bot locking but there doesn't
// seem to be any automatic deletion of locks in teleport today (other
// than expiration). Consistency around security controls seems important
// but we can revisit this if desired.
authCtx, err := bs.scopedAuthorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if req.GetBotName() == "" {
return nil, trace.BadParameter("bot_name: must be non-empty")
}
// Perform maybe-check before we hit the backend.
ruleCtx := authCtx.RuleContext()
if err := authCtx.CheckerContext.CheckMaybeHasAccessToRules(
&ruleCtx, types.KindBot, scopedaccess.Delete,
); err != nil {
return nil, trace.Wrap(err)
}
resourceName, err := services.BotResourceName(scopes.QualifiedName{Scope: req.GetScope(), Name: req.GetBotName()})
if err != nil {
return nil, trace.Wrap(err, "building bot resource name")
}
// Fetch user to determine if bot is scoped or unscoped.
user, err := bs.backend.GetUser(ctx, resourceName, false)
if err != nil {
return nil, trace.Wrap(err)
}
scope := user.GetMetadata().Labels[types.BotScopeLabel]
ruleCtx.Resource153 = dummyBotWithName(req.GetBotName())
if err := authCtx.CheckerContext.Decision(ctx, scope, func(checker *services.ScopedAccessChecker) error {
return checker.CheckAccessToRules(&ruleCtx, types.KindBot, scopedaccess.Delete)
}); err != nil {
return nil, trace.Wrap(err)
}
// If identity is unscoped, perform admin action MFA.
// TODO(noah/scopes): When scope identities support MFA, enforce it!
if unscoped, ok := authCtx.UnscopedContext(); ok {
if err := unscoped.AuthorizeAdminAction(); err != nil {
return nil, trace.Wrap(err)
}
}
userErr := bs.deleteBotUser(ctx, req.GetBotName(), user)
var roleErr error
if scope == "" {
// Only unscoped bots have a Bot role for us to delete.
roleErr = bs.deleteBotRole(ctx, req.GetBotName())
}
err = trace.NewAggregate(
trace.Wrap(userErr, "deleting bot user"),
trace.Wrap(roleErr, "deleting bot role"),
)
if err != nil {
return nil, trace.Wrap(err)
}
if err := bs.emitter.EmitAuditEvent(ctx, &apievents.BotDelete{
Metadata: apievents.Metadata{
Type: events.BotDeleteEvent,
Code: events.BotDeleteCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: req.GetBotName(),
Scope: req.GetScope(),
},
}); err != nil {
bs.logger.WarnContext(
ctx, "Failed to emit BotDelete audit event",
"error", err,
)
}
return &emptypb.Empty{}, nil
}
// setKindAndVersion patches for the fact that when this API was originally
// introduced, we did not enforce that the Kind/Version fields were set.
// This is largely not an issue since someone would need to invoke the API
// directly (as tctl will require that they are set). However, we do need these
// fields to be set correctly for authz to work properly.
//
// TODO(noah): In the future, we should commit to a breaking change to validate
// that these fields are set correctly.
func setKindAndVersion(b *pb.Bot) error {
if b == nil {
return trace.BadParameter("bot: must be non-nil")
}
if b.GetKind() == "" {
b.SetKind(types.KindBot)
}
if b.GetVersion() == "" {
b.SetVersion(types.V1)
}
return nil
}
// StrongValidateBot performs strong validation on scoped and unscoped bots,
// and is suitable for being called on write operations. This should not be
// called on read operations.
func StrongValidateBot(b *pb.Bot) error {
if b == nil {
return trace.BadParameter("must be non-nil")
}
if !b.HasMetadata() {
return trace.BadParameter("metadata: must be non-nil")
}
if b.GetMetadata().GetName() == "" {
return trace.BadParameter("metadata.name: must be non-empty")
}
if !b.HasSpec() {
return trace.BadParameter("spec: must be non-nil")
}
if slices.Contains(b.GetSpec().GetRoles(), "") {
return trace.BadParameter("spec.roles: must not contain empty strings")
}
// Scoped bot only validation
if b.GetScope() != "" {
// Validate scope-specific fields
if err := scopes.StrongValidate(b.GetScope()); err != nil {
return trace.Wrap(err, "scope:")
}
if err := scopes.StrongValidateResourceName(b.GetMetadata().GetName()); err != nil {
return trace.Wrap(err, "metadata.name:")
}
// Validate unsupported fields aren't set.
if len(b.GetSpec().GetRoles()) > 0 {
return trace.BadParameter("spec.roles: cannot be set on scoped bot")
}
if b.GetSpec().GetMaxSessionTtl().AsDuration() != 0 {
return trace.BadParameter("spec.max_session_ttl: cannot be set on scoped bot")
}
if len(b.GetSpec().GetTraits()) > 0 {
return trace.BadParameter("spec.traits: cannot be set on scoped bot")
}
}
return nil
}
// nonPropagatedLabels are labels that are not propagated from the User to the
// Bot when converting a User and Role to a Bot. Typically, these are internal
// labels that are managed by this service and exposing them to the end user
// would allow for misconfiguration.
var nonPropagatedLabels = set.New(
types.BotLabel,
//nolint:staticcheck // deprecated, kept for v18 downgrade compat until v20
types.BotGenerationLabel,
types.BotScopeLabel,
)
// botFromUserAndRole converts a user and role to an unscoped bot. This should
// not be called on a scoped bot user.
//
// Typically, we treat the bot user as the "canonical" source of information
// where possible. The bot role should be used for information which cannot
// come from the bot user.
func botFromUserAndRole(user types.User, role types.Role) (*pb.Bot, error) {
// User label is canonical source of bot name
botName, ok := user.GetLabel(types.BotLabel)
if !ok {
return nil, trace.BadParameter("user missing bot label")
}
scope, _ := user.GetLabel(types.BotScopeLabel)
if scope != "" {
return nil, trace.BadParameter("botFromUserAndRole called on user with scope label")
}
b := pb.Bot_builder{
Kind: types.KindBot,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: botName,
Expires: botExpiryFromUser(user),
Description: user.GetMetadata().Description,
}.Build(),
Status: pb.BotStatus_builder{
UserName: user.GetName(),
RoleName: role.GetName(),
}.Build(),
Spec: pb.BotSpec_builder{
Roles: role.GetImpersonateConditions(types.Allow).Roles,
MaxSessionTtl: durationpb.New(role.GetOptions().MaxSessionTTL.Duration()),
}.Build(),
}.Build()
// Copy in labels from the user
b.GetMetadata().SetLabels(map[string]string{})
for k, v := range user.GetMetadata().Labels {
// We exclude the labels that are implicitly added to the user by the
// bot service.
if nonPropagatedLabels.Contains(k) {
continue
}
b.GetMetadata().GetLabels()[k] = v
}
// Copy in traits
for k, v := range user.GetTraits() {
if len(v) == 0 {
continue
}
b.GetSpec().SetTraits(append(b.GetSpec().GetTraits(), pb.Trait_builder{
Name: k,
Values: v,
}.Build()))
}
return b, nil
}
func scopedBotFromUser(user types.User) (*pb.Bot, error) {
// User label is canonical source of bot name
botName, ok := user.GetLabel(types.BotLabel)
if !ok {
return nil, trace.BadParameter("user missing bot label")
}
scope, ok := user.GetLabel(types.BotScopeLabel)
if !ok || scope == "" {
return nil, trace.BadParameter("scopedBotFromUser called on user without scope label")
}
b := pb.Bot_builder{
Kind: types.KindBot,
Version: types.V1,
Scope: scope,
Metadata: headerv1.Metadata_builder{
Name: botName,
Expires: botExpiryFromUser(user),
Description: user.GetMetadata().Description,
}.Build(),
Status: pb.BotStatus_builder{
UserName: user.GetName(),
}.Build(),
Spec: &pb.BotSpec{},
}.Build()
// Copy in labels from the user
b.GetMetadata().SetLabels(map[string]string{})
for k, v := range user.GetMetadata().Labels {
// We exclude the labels that are implicitly added to the user by the
// bot service.
if nonPropagatedLabels.Contains(k) {
continue
}
b.GetMetadata().GetLabels()[k] = v
}
return b, nil
}
func botToUserAndRole(bot *pb.Bot, now time.Time, createdBy string) (types.User, types.Role, error) {
if bot.GetScope() != "" {
return nil, nil, trace.BadParameter("botToUserAndRole called on scoped bot")
}
// Setup role
resourceName, err := services.BotResourceName(scopes.QualifiedName{Name: bot.GetMetadata().GetName()})
if err != nil {
return nil, nil, trace.Wrap(err, "building bot resource name")
}
// Continue to use the legacy max session TTL (12 hours) as the default, but
// allow overrides via the optional bot spec field.
maxSessionTTL := defaults.DefaultBotMaxSessionTTL
if bot.GetSpec().HasMaxSessionTtl() {
maxSessionTTL = bot.GetSpec().GetMaxSessionTtl().AsDuration()
}
role, err := types.NewRole(resourceName, types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Rules: []types.Rule{
// Bots read certificate authorities to watch for CA rotations
types.NewRule(types.KindCertAuthority, []string{types.VerbReadNoSecrets}),
},
Impersonate: &types.ImpersonateConditions{
Roles: bot.GetSpec().GetRoles(),
},
},
})
if err != nil {
return nil, nil, trace.Wrap(err, "new role")
}
roleMeta := role.GetMetadata()
roleMeta.Description = fmt.Sprintf(
"Automatically generated role for bot %s", bot.GetMetadata().GetName(),
)
roleMeta.Labels = map[string]string{
types.BotLabel: bot.GetMetadata().GetName(),
}
roleMeta.Expires = userAndRoleExpiryFromBot(bot)
role.SetMetadata(roleMeta)
// Setup user
user, err := types.NewUser(resourceName)
if err != nil {
return nil, nil, trace.Wrap(err, "new user")
}
user.SetRoles([]string{resourceName})
userMeta := user.GetMetadata()
// First copy in the labels from the Bot resource
userMeta.Labels = map[string]string{}
maps.Copy(userMeta.Labels, bot.GetMetadata().GetLabels())
// Then set these labels over the top - we exclude these when converting
// back.
userMeta.Labels[types.BotLabel] = bot.GetMetadata().GetName()
// The generation counter now lives on the BotInstance, but v18 auth
// servers refuse to upsert a bot user without this label, so keep writing
// it while a v18 downgrade is supported.
// TODO(noah): DELETE IN v20 - https://github.com/gravitational/teleport/issues/64484
//nolint:staticcheck // deprecated, see above
userMeta.Labels[types.BotGenerationLabel] = "0"
// Clears scope label that user should not be able to set.
delete(userMeta.Labels, types.BotScopeLabel)
userMeta.Expires = userAndRoleExpiryFromBot(bot)
// We track the Bot description within the User description field because
// the Role description already has a message.
userMeta.Description = bot.GetMetadata().GetDescription()
user.SetMetadata(userMeta)
traits := map[string][]string{}
for _, t := range bot.GetSpec().GetTraits() {
if len(t.GetValues()) == 0 {
continue
}
if traits[t.GetName()] == nil {
traits[t.GetName()] = []string{}
}
traits[t.GetName()] = append(traits[t.GetName()], t.GetValues()...)
}
user.SetTraits(traits)
user.SetCreatedBy(types.CreatedBy{
User: types.UserRef{Name: createdBy},
Time: now,
})
return user, role, nil
}
func scopedBotToUser(bot *pb.Bot, now time.Time, createdBy string) (types.User, error) {
if bot.GetScope() == "" {
return nil, trace.BadParameter("scopedBotToUser called on unscoped bot")
}
// Setup user
resourceName, err := services.BotResourceName(scopes.QualifiedName{Scope: bot.GetScope(), Name: bot.GetMetadata().GetName()})
if err != nil {
return nil, trace.Wrap(err, "building bot resource name")
}
user, err := types.NewUser(resourceName)
if err != nil {
return nil, trace.Wrap(err, "new user")
}
userMeta := user.GetMetadata()
// First copy in the labels from the Bot resource
userMeta.Labels = map[string]string{}
maps.Copy(userMeta.Labels, bot.GetMetadata().GetLabels())
// Then set these labels over the top - we exclude these when converting
// back.
userMeta.Labels[types.BotLabel] = bot.GetMetadata().GetName()
// Kept for v18 downgrade compat, see botToUserAndRole.
// TODO(noah): DELETE IN v20 - https://github.com/gravitational/teleport/issues/64484
//nolint:staticcheck // deprecated, see above
userMeta.Labels[types.BotGenerationLabel] = "0"
userMeta.Labels[types.BotScopeLabel] = bot.GetScope()
userMeta.Expires = userAndRoleExpiryFromBot(bot)
// We track the Bot description within the User description field because
// the Role description already has a message.
userMeta.Description = bot.GetMetadata().GetDescription()
user.SetMetadata(userMeta)
user.SetCreatedBy(types.CreatedBy{
User: types.UserRef{Name: createdBy},
Time: now,
})
return user, nil
}
func userAndRoleExpiryFromBot(bot *pb.Bot) *time.Time {
if bot.GetMetadata().GetExpires() == nil {
return nil
}
expiry := bot.GetMetadata().GetExpires().AsTime()
if expiry.IsZero() || expiry.Unix() == 0 {
return nil
}
return &expiry
}
func botExpiryFromUser(user types.User) *timestamppb.Timestamp {
userMeta := user.GetMetadata()
userExpiry := userMeta.Expiry()
if userExpiry.IsZero() || userExpiry.Unix() == 0 {
return nil
}
return timestamppb.New(userExpiry)
}
// BotToUserAndRole converts the given bot into a user and role for storage.
func BotToUserAndRole(bot *pb.Bot, createdBy string) (types.User, types.Role, error) {
return botToUserAndRole(bot, time.Now(), createdBy)
}