Adjust provision.Token interface to unsplit Bot accessors in preparation for SQN (#67746)

* Adjust `provision.Token` interface to unsplit Bot accessors in preparation for SQN (variant b)

* add todo godocs
This commit is contained in:
Noah Stride
2026-06-16 08:28:45 +00:00
committed by GitHub
parent 79b38cde5f
commit 9fe6985113
13 changed files with 227 additions and 70 deletions
+9 -15
View File
@@ -176,11 +176,10 @@ type ProvisionToken interface {
GetAWSIIDTTL() Duration
// GetJoinMethod returns joining method that must be used with this token.
GetJoinMethod() JoinMethod
// GetBotName returns the BotName field which must be set for joining bots.
GetBotName() string
// GetBotScope returns the BotScope field which must be set for bots joining
// with a scoped token. It is empty for unscoped bots.
GetBotScope() string
// GetBot returns the name and scope of the bot that this token can join.
// An empty name indicates that this is not a bot token. Provision tokens
// can only refer to unscoped bots, so the scope is always empty.
GetBot() (name, scope string)
// IsStatic returns true if the token is statically configured
IsStatic() bool
// GetSuggestedLabels returns the set of labels that the resource should add when adding itself to the cluster
@@ -609,16 +608,11 @@ func (p *ProvisionTokenV2) IsStatic() bool {
return p.Origin() == OriginConfigFile
}
// GetBotName returns the BotName field which must be set for joining bots.
func (p *ProvisionTokenV2) GetBotName() string {
return p.Spec.BotName
}
// GetBotScope returns the BotScope field which must be set for bots joining
// with a scoped token. It is empty for unscoped bots.
func (p *ProvisionTokenV2) GetBotScope() string {
// Always empty for ProvisionTokenV2
return ""
// GetBot returns the name and scope of the bot that this token can join. An
// empty name indicates that this is not a bot token. Provision tokens can
// only refer to unscoped bots, so the scope is always empty.
func (p *ProvisionTokenV2) GetBot() (name, scope string) {
return p.Spec.BotName, ""
}
// GetKind returns resource kind
+2 -5
View File
@@ -135,12 +135,10 @@ func (a *Server) handleJoinFailure(
if pt != nil {
botJoinEvent.Method = string(pt.GetJoinMethod())
botJoinEvent.TokenName = pt.GetSafeName()
botJoinEvent.BotName = pt.GetBotName()
// We don't want to perform a backend fetch here, so we'll use the
// bot scope indicated in the token rather than the one embedded in
// the user.
botJoinEvent.Scope = pt.GetBotScope()
botJoinEvent.BotName, botJoinEvent.Scope = pt.GetBot()
}
evt = botJoinEvent
} else {
@@ -361,7 +359,7 @@ func (a *Server) GenerateBotCertsForJoin(
) (*proto.Certs, string, error) {
// bots use this endpoint but get a user cert
// botResourceName must be set, enforced in CheckAndSetDefaults
botName := token.GetBotName()
botName, tokenBotScope := token.GetBot()
joinMethod := token.GetJoinMethod()
// Check this is a join method for bots we support.
@@ -463,7 +461,6 @@ func (a *Server) GenerateBotCertsForJoin(
return nil, "", trace.AccessDenied("scoped token usage mode must be 'bot' for bot joining")
}
tokenBotScope := scoped.GetScoped().GetSpec().GetBotScope()
if botScope != tokenBotScope {
a.logger.WarnContext(ctx, "bot scope must match token scope",
"token_scope", tokenBotScope,
+6 -2
View File
@@ -86,9 +86,13 @@ type JoinStateParams struct {
}
func (p *JoinStateParams) GetSubject() (string, error) {
botName, _ := p.Token.GetBot()
switch {
case p.Token.GetBotName() != "":
return p.Token.GetBotName(), nil
case botName != "":
// TODO(strideynet): the bare bot name is only a unique identifier
// while bots are globally namespaced. Revisit the subject for scoped
// bots once bot users are namespaced by scope.
return botName, nil
case p.HostID != "":
return p.HostID, nil
case p.Token.GetBoundKeypairStatus().BoundHostID != "":
+13 -4
View File
@@ -610,6 +610,9 @@ func emitBoundKeypairRecoveryEvent(
}
}
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
botName, _ := token.GetBot()
if err := params.AuthService.EmitAuditEvent(context.WithoutCancel(ctx), &apievents.BoundKeypairRecovery{
Metadata: apievents.Metadata{
Type: events.BoundKeypairRecovery,
@@ -620,7 +623,7 @@ func emitBoundKeypairRecoveryEvent(
RemoteAddr: params.Diag.Get().RemoteAddr,
},
TokenName: token.GetName(),
BotName: token.GetBotName(),
BotName: botName,
PublicKey: boundPublicKey,
RecoveryCount: recoveryCount,
RecoveryMode: token.GetBoundKeypair().Recovery.Mode,
@@ -650,6 +653,9 @@ func emitBoundKeypairRotationEvent(
}
}
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
botName, _ := token.GetBot()
if err := params.AuthService.EmitAuditEvent(context.WithoutCancel(ctx), &apievents.BoundKeypairRotation{
Metadata: apievents.Metadata{
Type: events.BoundKeypairRotation,
@@ -660,7 +666,7 @@ func emitBoundKeypairRotationEvent(
RemoteAddr: params.Diag.Get().RemoteAddr,
},
TokenName: token.GetName(),
BotName: token.GetBotName(),
BotName: botName,
PreviousPublicKey: prevPublicKey,
NewPublicKey: newPublicKey,
}); err != nil {
@@ -676,6 +682,9 @@ func tryLockTokenInvalidJoinState(
) {
log := params.Logger.With("join_token", token.GetName(), "validation_error", validationError)
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
botName, _ := token.GetBot()
if auditErr := params.AuthService.EmitAuditEvent(context.WithoutCancel(ctx), &apievents.BoundKeypairJoinStateVerificationFailed{
Metadata: apievents.Metadata{
Type: events.BoundKeypairJoinStateVerificationFailed,
@@ -689,7 +698,7 @@ func tryLockTokenInvalidJoinState(
RemoteAddr: params.Diag.Get().RemoteAddr,
},
TokenName: token.GetName(),
BotName: token.GetBotName(),
BotName: botName,
}); auditErr != nil {
log.WarnContext(ctx, "Failed to emit failed join state verification event", "error", auditErr)
}
@@ -700,7 +709,7 @@ func tryLockTokenInvalidJoinState(
"The join token %q has been locked by bot %q after a client "+
"failed to verify its join state, possibly indicating a "+
"stolen keypair.",
token.GetName(), token.GetBotName(),
token.GetName(), botName,
)
} else {
message = fmt.Sprintf(
+5 -5
View File
@@ -43,11 +43,11 @@ type Token interface {
GetRoles() types.SystemRoles
// Expiry returns the token's expiration time.
Expiry() time.Time
// GetBotName returns the BotName field which must be set for joining bots.
GetBotName() string
// GetBotScope returns the BotScope field which must be set for bots joining
// with a scoped token. It is empty for unscoped bots.
GetBotScope() string
// GetBot returns the name and scope of the bot that this token can join.
// An empty name indicates that this is not a bot token. An empty scope
// refers to an unscoped bot; scoped bot tokens always carry both
// components.
GetBot() (name, scope string)
// GetAssignedScope returns the scope that will be assigned to provisioned resources
// provisioned using the wrapped [joiningv1.ScopedToken].
GetAssignedScope() string
+1 -3
View File
@@ -278,11 +278,9 @@ func (s *Server) Join(stream messages.ServerStream) (err error) {
i.SafeTokenName = token.GetSafeName()
i.TokenJoinMethod = string(configuredJoinMethod(token))
i.TokenExpires = token.Expiry()
i.BotName = token.GetBotName()
// It's not worth fetching the true bot scope here (via bot user label)
// so we'll just include the one embedded in the token.
i.BotScope = token.GetBotScope()
i.BotName, i.BotScope = token.GetBot()
})
// Validate that the requested join method matches the join method
+3 -1
View File
@@ -207,7 +207,9 @@ func AdaptRegisterUsingBoundKeypairMethod(
i.SafeTokenName = provisionToken.GetSafeName()
i.TokenJoinMethod = string(provisionToken.GetJoinMethod())
i.TokenExpires = provisionToken.Expiry()
i.BotName = provisionToken.GetBotName()
// TODO(strideynet): When bots become scope namespaced, ensure this
// call site reflects scopedness.
i.BotName, _ = provisionToken.GetBot()
})
if provisionToken.GetJoinMethod() != types.JoinMethodBoundKeypair {
return nil, trace.BadParameter("specified join token is not for `%s` method", types.JoinMethodBoundKeypair)
+58 -28
View File
@@ -296,7 +296,7 @@ func validateJoinMethod(token *joiningv1.ScopedToken) error {
// validateBot is used to validate plausibly-bot tokens (if `isBotToken()` has
// returned true).
func validateBotToken(token *joiningv1.ScopedToken, roles types.SystemRoles) error {
func strongValidateBotToken(token *joiningv1.ScopedToken, roles types.SystemRoles) error {
spec := token.GetSpec()
if spec.GetBotName() == "" {
@@ -362,6 +362,30 @@ func validateNonBotToken(token *joiningv1.ScopedToken) error {
return nil
}
// validateBotRef weak-validates the bot reference fields of a scoped token
// spec and returns the referenced bot's name and scope. Bot tokens must carry
// both components. For non-bot tokens the bot fields are ignored and empty
// values are returned: stray bot fields on a non-bot token are rejected by
// strong validation at write time, but tolerated here to match weak
// validation's posture toward existing resources.
func validateBotRef(spec *joiningv1.ScopedTokenSpec, isBotToken bool) (name, scope string, err error) {
if !isBotToken {
return "", "", nil
}
if spec.GetBotName() == "" {
return "", "", trace.BadParameter("expected non-empty bot_name for a scoped bot token")
}
if spec.GetBotScope() == "" {
return "", "", trace.BadParameter("expected non-empty bot_scope for a scoped bot token")
}
if err := scopes.WeakValidate(spec.GetBotScope()); err != nil {
return "", "", trace.Wrap(err, "validating scoped token bot_scope")
}
return spec.GetBotName(), spec.GetBotScope(), nil
}
// StrongValidateToken checks if the scoped token is well-formed according to
// all scoped token rules. This function *must* be used to validate any scoped
// token being created from scratch. When validating existing scoped token
@@ -424,7 +448,7 @@ func StrongValidateToken(token *joiningv1.ScopedToken) error {
}
if roles.Include(types.RoleBot) {
if err := validateBotToken(token, roles); err != nil {
if err := strongValidateBotToken(token, roles); err != nil {
return trace.Wrap(err)
}
} else {
@@ -456,19 +480,10 @@ func WeakValidateToken(token *joiningv1.ScopedToken) error {
// Determine if this is a bot token without potentially trying to validate
// other unknown roles.
isBotToken := slices.Contains(spec.GetRoles(), string(types.RoleBot))
if isBotToken {
if token.GetSpec().GetBotName() == "" {
return trace.BadParameter("expected non-empty bot_name for a scoped bot token")
}
if token.GetSpec().GetBotScope() == "" {
return trace.BadParameter("expected non-empty bot_scope for a scoped bot token")
}
if err := scopes.WeakValidate(spec.GetBotScope()); err != nil {
return trace.Wrap(err, "validating scoped token bot_scope")
}
} else {
if _, _, err := validateBotRef(spec, isBotToken); err != nil {
return trace.Wrap(err)
}
if !isBotToken {
if err := scopes.WeakValidate(token.GetSpec().GetAssignedScope()); err != nil {
return trace.Wrap(err, "validating scoped token assigned scope")
}
@@ -548,13 +563,17 @@ type Token struct {
scoped *joiningv1.ScopedToken
joinMethod types.JoinMethod
roles types.SystemRoles
botName string
botScope string
}
// NewToken returns the wrapped version of the given [joiningv1.ScopedToken].
// It will return an error if the configured join method is not a valid
// [types.JoinMethod] or if any of the configured roles are not a valid
// [types.SystemRole]. The validated join method and roles are cached on the
// [Scoped] wrapper itself so they can be read without repeating validation.
// [types.JoinMethod], if any of the configured roles are not a valid
// [types.SystemRole], or if the bot_name/bot_scope fields are inconsistent
// with the configured roles. The validated join method, roles, and bot
// reference are cached on the [Token] wrapper itself so they can be read
// without repeating validation.
func NewToken(token *joiningv1.ScopedToken) (*Token, error) {
joinMethod := types.JoinMethod(token.GetSpec().GetJoinMethod())
if err := types.ValidateJoinMethod(joinMethod); err != nil {
@@ -566,7 +585,18 @@ func NewToken(token *joiningv1.ScopedToken) (*Token, error) {
return nil, trace.Wrap(err)
}
return &Token{scoped: token, joinMethod: joinMethod, roles: roles}, nil
botName, botScope, err := validateBotRef(token.GetSpec(), roles.Include(types.RoleBot))
if err != nil {
return nil, trace.Wrap(err)
}
return &Token{
scoped: token,
joinMethod: joinMethod,
roles: roles,
botName: botName,
botScope: botScope,
}, nil
}
// GetName returns the name of a [joiningv1.ScopedToken].
@@ -614,16 +644,16 @@ func (t *Token) Expiry() time.Time {
return expiry.AsTime()
}
// GetBotName returns an empty string because scoped tokens do not currently
// support configuring a bot name.
func (t *Token) GetBotName() string {
return t.scoped.GetSpec().GetBotName()
}
// GetBot returns the cached name and scope of the bot that this token can
// join, validated when the [joiningv1.ScopedToken] was wrapped. An empty name
// indicates that this is not a bot token; otherwise both components are
// non-empty.
func (t *Token) GetBot() (name, scope string) {
if t == nil {
return "", ""
}
// GetBotScope returns the BotScope field which must be set for bots joining
// with a scoped token. It is empty for unscoped bots.
func (t *Token) GetBotScope() string {
return t.scoped.GetSpec().GetBotScope()
return t.botName, t.botScope
}
// GetAssignedScope returns the scope that will be assigned to resources
+110
View File
@@ -1115,6 +1115,116 @@ func TestScopedTokenAzureRoundTrip(t *testing.T) {
}, token.GetAzure())
}
func TestNewTokenGetBot(t *testing.T) {
t.Parallel()
newBotToken := func() *joiningv1.ScopedToken {
return joiningv1.ScopedToken_builder{
Kind: types.KindScopedToken,
Scope: "/aa/bb",
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "testtoken",
}.Build(),
Spec: joiningv1.ScopedTokenSpec_builder{
Roles: []string{types.RoleBot.String()},
BotScope: "/aa/bb",
BotName: "test-bot",
JoinMethod: string(types.JoinMethodBoundKeypair),
UsageMode: joining.TokenUsageModeBot,
}.Build(),
}.Build()
}
newNonBotToken := func() *joiningv1.ScopedToken {
return joiningv1.ScopedToken_builder{
Kind: types.KindScopedToken,
Scope: "/aa/bb",
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "testtoken",
}.Build(),
Spec: joiningv1.ScopedTokenSpec_builder{
Roles: []string{types.RoleNode.String()},
AssignedScope: "/aa/bb",
JoinMethod: string(types.JoinMethodToken),
UsageMode: string(joining.TokenUsageModeUnlimited),
}.Build(),
}.Build()
}
cases := []struct {
name string
token *joiningv1.ScopedToken
modFn func(*joiningv1.ScopedToken)
expectedBotName string
expectedBotScope string
expectedErr string
}{
{
name: "bot token",
token: newBotToken(),
expectedBotName: "test-bot",
expectedBotScope: "/aa/bb",
},
{
name: "non-bot token",
token: newNonBotToken(),
},
{
// Stray bot fields on a non-bot token are rejected by strong
// validation at write time but tolerated when wrapping, matching
// weak validation; the bot reference is empty.
name: "non-bot token with stray bot fields",
token: newNonBotToken(),
modFn: func(tok *joiningv1.ScopedToken) {
tok.GetSpec().SetBotName("test-bot")
tok.GetSpec().SetBotScope("/aa/bb")
},
},
{
name: "bot token without bot_name",
token: newBotToken(),
modFn: func(tok *joiningv1.ScopedToken) {
tok.GetSpec().SetBotName("")
},
expectedErr: "expected non-empty bot_name",
},
{
name: "bot token without bot_scope",
token: newBotToken(),
modFn: func(tok *joiningv1.ScopedToken) {
tok.GetSpec().SetBotScope("")
},
expectedErr: "expected non-empty bot_scope",
},
{
name: "bot token with malformed bot_scope",
token: newBotToken(),
modFn: func(tok *joiningv1.ScopedToken) {
tok.GetSpec().SetBotScope("aa/bb}")
},
expectedErr: "validating scoped token bot_scope",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.modFn != nil {
tc.modFn(tc.token)
}
token, err := joining.NewToken(tc.token)
if tc.expectedErr != "" {
require.ErrorContains(t, err, tc.expectedErr)
return
}
require.NoError(t, err)
botName, botScope := token.GetBot()
require.Equal(t, tc.expectedBotName, botName)
require.Equal(t, tc.expectedBotScope, botScope)
})
}
}
func TestImmutableLabelHashing(t *testing.T) {
labels := joiningv1.ImmutableLabels_builder{
Ssh: map[string]string{
+4 -1
View File
@@ -294,7 +294,10 @@ func MatchToken(t types.ProvisionToken, anyRoles types.SystemRoles, botName stri
return false
}
if botName != "" && (!t.GetRoles().Include(types.RoleBot) || t.GetBotName() != botName) {
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
tokenBotName, _ := t.GetBot()
if botName != "" && (!t.GetRoles().Include(types.RoleBot) || tokenBotName != botName) {
return false
}
+4 -1
View File
@@ -621,7 +621,10 @@ func TestEditToken(t *testing.T) {
// Fetch the token and compare
editedToken, err := env.server.Auth().GetToken(ctx, tokenName)
require.NoError(t, err)
require.Equal(t, "test-bot_EDITED", editedToken.GetBotName())
// TODO(strideynet): When bots become scope namespaced, ensure
// this call site reflects scopedness.
editedBotName, _ := editedToken.GetBot()
require.Equal(t, "test-bot_EDITED", editedBotName)
require.Equal(t, expiry, *editedToken.GetMetadata().Expires)
require.Equal(t, map[string]string{
"test-key": "test-value",
+4 -1
View File
@@ -65,10 +65,13 @@ func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) {
_, isCloudSystem := token.GetMetadata().Labels["teleport.internal/cloud/token"]
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
botName, _ := token.GetBot()
uiToken := &JoinToken{
ID: token.GetName(),
SafeName: token.GetSafeName(),
BotName: token.GetBotName(),
BotName: botName,
Expiry: token.Expiry(),
Roles: token.GetRoles(),
IsStatic: token.IsStatic(),
+8 -4
View File
@@ -501,9 +501,11 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro
return trace.BadParameter("token %q is not valid for role %q",
c.tokenID, types.RoleBot)
}
if token.GetBotName() != c.botName {
// TODO(strideynet): When bots become scope namespaced, ensure this
// call site reflects scopedness.
if tokenBotName, _ := token.GetBot(); tokenBotName != c.botName {
return trace.BadParameter("token %q is valid for bot with name %q, not %q",
c.tokenID, token.GetBotName(), c.botName)
c.tokenID, tokenBotName, c.botName)
}
}
@@ -954,9 +956,11 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie
return trace.BadParameter("token %q is not valid for role %q",
c.tokenID, types.RoleBot)
}
if token.GetBotName() != c.botName {
// TODO(strideynet): When bots become scope namespaced, ensure this call
// site reflects scopedness.
if tokenBotName, _ := token.GetBot(); tokenBotName != c.botName {
return trace.BadParameter("token %q is valid for bot with name %q, not %q",
c.tokenID, token.GetBotName(), c.botName)
c.tokenID, tokenBotName, c.botName)
}
return trace.Wrap(c.outputToken(ctx, client, bot, token))