mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
1271 lines
41 KiB
Go
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)
|
|
}
|