Skip to content

Commit

Permalink
feat: add master configurations for access token max and default life…
Browse files Browse the repository at this point in the history
…spans [DET-10464] (#10101)

Co-authored-by: ShreyaLnuHpe <shreya.lnu@hpe.com>
Co-authored-by: Bradley Laney <bradley.laney@hpe.com>
  • Loading branch information
3 people authored Oct 25, 2024
1 parent 782f7a0 commit 30ad3c0
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 112 deletions.
12 changes: 12 additions & 0 deletions docs/reference/deploy/master-config-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,18 @@ Integer identifier of a role to be assigned. Defaults to ``2``, which is the rol
Initial password for the built-in ``determined`` and ``admin`` users. Applies on first launch when a
cluster's database is bootstrapped, otherwise it is ignored.

``token``
=========

Applies only to Determined Enterprise Edition. Defines default and maximum lifespan settings for
access tokens. These settings allow administrators to control how long access tokens can remain
valid, enhancing security while supporting automation.

- ``default_lifespan_days``: Specifies the default lifespan (in days) for new access tokens.
Defaults to 30 days.
- ``max_lifespan_days``: Specifies the maximum allowed lifespan (in days) for access tokens.
Setting this to ``-1`` allows for an infinite token lifespan. Defaults to ``-1``.

**************
``webhooks``
**************
Expand Down
13 changes: 8 additions & 5 deletions docs/release-notes/api-cli-access-token.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

**New Features**

- API/CLI: Add support for :ref:`access tokens <access-tokens>`. Add the ability create and
administer access tokens for users to authenticate in automated workflows. Users can define the
lifespan of these tokens, making it easier to securely authenticate and run processes. This
feature enhances automation while maintaining strong security protocols by allowing tighter
control over token usage and expiration.
- API/CLI: Add support for access tokens. Add the ability to create and administer access tokens
for users to authenticate in automated workflows. Users can define the lifespan of these tokens,
making it easier to securely authenticate and run processes. Users can set global defaults and
limits for the validity of access tokens by configuring ``default_lifespan_days`` and
``max_lifespan_days`` in the master configuration. Setting ``max_lifespan_days`` to ``-1``
indicates an **infinite** lifespan for the access token. This feature enhances automation while
maintaining strong security protocols by allowing tighter control over token usage and
expiration. This feature requires Determined Enterprise Edition.

- CLI:

Expand Down
6 changes: 4 additions & 2 deletions harness/determined/cli/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ def create_token(args: argparse.Namespace) -> None:

# convert days into hours Go duration format
expiration_in_hours = None
if args.expiration_days:
expiration_in_hours = str(24 * args.expiration_days) + "h"
if args.expiration_days is not None:
expiration_in_hours = (
"-1" if args.expiration_days == -1 else f"{24 * args.expiration_days}h"
)

request = bindings.v1PostAccessTokenRequest(
userId=user.id, lifespan=expiration_in_hours, description=args.description
Expand Down
4 changes: 4 additions & 0 deletions helm/charts/determined/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ useNodePortForMaster: false
# authz option (EE-only) sets the authorization mode.
# authz:
# type: rbac
# access token option (EE-Only) allows to authenticate in automated workflows.
# token:
# max_lifespan_days: -1
# default_lifespan_days: 30

# oidc (EE-only) enables OpenID Connect Integration, which is only available if enterpriseEdition
# is true. It allows users to use single sign-on with their organization’s identity provider.
Expand Down
33 changes: 25 additions & 8 deletions master/internal/api_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"google.golang.org/grpc/status"

"github.com/determined-ai/determined/master/internal/api"
"github.com/determined-ai/determined/master/internal/config"
"github.com/determined-ai/determined/master/internal/db"
"github.com/determined-ai/determined/master/internal/grpcutil"
"github.com/determined-ai/determined/master/internal/license"
Expand All @@ -36,7 +37,6 @@ func (a *apiServer) PostAccessToken(
if err != nil {
return nil, err
}

targetFullUser, err := getFullModelUser(ctx, model.UserID(req.UserId))
if err != nil {
return nil, err
Expand All @@ -46,14 +46,31 @@ func (a *apiServer) PostAccessToken(
return nil, status.Error(codes.PermissionDenied, err.Error())
}

tokenExpiration := token.DefaultTokenLifespan
if req.Lifespan != "" {
d, err := time.ParseDuration(req.Lifespan)
if err != nil || d < 0 {
return nil, status.Error(codes.InvalidArgument,
"Lifespan must be a Go-formatted duration string with a positive value")
maxTokenLifespan := a.m.config.Security.Token.MaxLifespan()
tokenExpiration := a.m.config.Security.Token.DefaultLifespan()
if req.Lifespan != nil {
if *req.Lifespan == config.InfiniteTokenLifespanString {
tokenExpiration = maxTokenLifespan
} else {
d, err := time.ParseDuration(*req.Lifespan)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument,
"failed to parse lifespan %s: %s", *req.Lifespan, err)
} else if d < 0 {
return nil, status.Error(codes.InvalidArgument,
"lifespan must be a Go-formatted duration string with a positive value")
}
tokenExpiration = d
}
tokenExpiration = d
}

// Ensure the token lifespan does not exceed the maximum allowed or minimum -1 value.
if tokenExpiration > maxTokenLifespan {
return nil, status.Error(codes.InvalidArgument, "token Lifespan must be less than max token lifespan")
}
if tokenExpiration < 0 {
return nil, status.Error(codes.InvalidArgument, "token lifespan must be greater than 0 days,"+
" unless set to -1 for infinite lifespan")
}

token, tokenID, err := token.CreateAccessToken(
Expand Down
15 changes: 6 additions & 9 deletions master/internal/api_token_intg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/determined-ai/determined/master/internal/token"
"github.com/determined-ai/determined/master/internal/user"
"github.com/determined-ai/determined/master/pkg/model"
"github.com/determined-ai/determined/master/pkg/ptrs"
"github.com/determined-ai/determined/proto/pkg/apiv1"
)

Expand Down Expand Up @@ -67,11 +68,11 @@ func TestPostAccessTokenWithLifespan(t *testing.T) {
// With lifespan input
resp, err := api.PostAccessToken(ctx, &apiv1.PostAccessTokenRequest{
UserId: int32(userID),
Lifespan: lifespan,
Lifespan: ptrs.Ptr("5s"),
Description: desc,
})
token, tokenID := resp.Token, resp.TokenId
require.NoError(t, err)
token, tokenID := resp.Token, resp.TokenId
require.NotNil(t, token)
require.NotNil(t, tokenID)

Expand Down Expand Up @@ -261,13 +262,9 @@ func getTestUser(ctx context.Context) (model.UserID, error) {
func testSetLifespan(ctx context.Context, t *testing.T, userID model.UserID, lifespan string,
tokenID model.TokenID,
) error {
expLifespan := token.DefaultTokenLifespan
var err error
if lifespan != "" {
expLifespan, err = time.ParseDuration(lifespan)
if err != nil {
return fmt.Errorf("Invalid duration format")
}
expLifespan, err := time.ParseDuration(lifespan)
if err != nil {
return fmt.Errorf("Invalid duration format")
}
var expiry, createdAt time.Time
err = db.Bun().NewSelect().
Expand Down
6 changes: 6 additions & 0 deletions master/internal/api_user_intg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ func setupAPITest(t *testing.T, pgdb *db.PgDB,
},
TaskContainerDefaults: model.TaskContainerDefaultsConfig{},
ResourceConfig: *config.DefaultResourceConfig(),
Security: config.SecurityConfig{
Token: config.TokenConfig{
MaxLifespanDays: config.MaxAllowedTokenLifespanDays,
DefaultLifespanDays: config.DefaultTokenLifespanDays,
},
},
},
taskSpec: &tasks.TaskSpec{SSHConfig: config.SSHConfig{KeyType: "ED25519"}},
allRms: map[string]rm.ResourceManager{config.DefaultClusterName: mockRM},
Expand Down
70 changes: 69 additions & 1 deletion master/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/url"
"path/filepath"
"sync"
"time"

"github.com/jinzhu/copier"
log "github.com/sirupsen/logrus"
Expand All @@ -35,11 +36,20 @@ var (
masterConfig *Config
)

// KubernetesDefaultPriority is the default K8 resource manager priority.
const (
// KubernetesDefaultPriority is the default K8 resource manager priority.
KubernetesDefaultPriority = 50
sslModeDisable = "disable"
preemptionScheduler = "preemption"
// InfiniteTokenLifespan is the value to set the token lifespan to infinite.
InfiniteTokenLifespan = -1
// InfiniteTokenLifespanString is the string representation of InfiniteTokenLifespan.
InfiniteTokenLifespanString = "-1"
// DefaultTokenLifespanDays is the default token lifespan in days.
DefaultTokenLifespanDays = 30
// MaxAllowedTokenLifespanDays is the max allowed lifespan for tokens.
// This is the maximum number of days a go duration can represent.
MaxAllowedTokenLifespanDays = 106751
)

const (
Expand Down Expand Up @@ -120,6 +130,10 @@ func DefaultConfig() *Config {
KeyType: KeyTypeED25519,
},
AuthZ: *DefaultAuthZConfig(),
Token: TokenConfig{
MaxLifespanDays: DefaultTokenLifespanDays,
DefaultLifespanDays: DefaultTokenLifespanDays,
},
},
// If left unspecified, the port is later filled in with 8080 (no TLS) or 8443 (TLS).
Port: 0,
Expand Down Expand Up @@ -403,6 +417,13 @@ func (c *Config) Resolve() error {
c.SAML.GroupsAttributeName = ""
}

if c.Security.Token.MaxLifespanDays == InfiniteTokenLifespan {
c.Security.Token.MaxLifespanDays = MaxAllowedTokenLifespanDays
}
if c.Security.Token.DefaultLifespanDays == InfiniteTokenLifespan {
c.Security.Token.DefaultLifespanDays = MaxAllowedTokenLifespanDays
}

return nil
}

Expand Down Expand Up @@ -455,10 +476,57 @@ type SecurityConfig struct {
TLS TLSConfig `json:"tls"`
SSH SSHConfig `json:"ssh"`
AuthZ AuthZConfig `json:"authz"`
Token TokenConfig `json:"token"`

InitialUserPassword string `json:"initial_user_password"`
}

// TokenConfig is the configuration setting for tokens.
type TokenConfig struct {
MaxLifespanDays int `json:"max_lifespan_days"`
DefaultLifespanDays int `json:"default_lifespan_days"`
}

// MaxLifespan returns MaxLifespanDays as a time.Duration.
func (t *TokenConfig) MaxLifespan() time.Duration {
return time.Duration(t.MaxLifespanDays) * 24 * time.Hour
}

// DefaultLifespan returns DefaultLifespanDays as a time.Duration.
func (t *TokenConfig) DefaultLifespan() time.Duration {
return time.Duration(t.DefaultLifespanDays) * 24 * time.Hour
}

// Validate implements the check.Validatable interface for the TokenConfig.
func (t *TokenConfig) Validate() []error {
var errs []error
if t.MaxLifespanDays < 0 {
errs = append(errs, errors.New("max token lifespan must be greater than 0 days, unless"+
" set to -1 for infinite lifespan"),
)
}
if t.DefaultLifespanDays < 0 {
errs = append(errs, errors.New("default token lifespan must be greater than 0 days,"+
" unless set to -1 for infinite lifespan"),
)
}
if t.DefaultLifespanDays > t.MaxLifespanDays {
errs = append(errs, errors.New("default token lifespan must be less than max token"+
" lifespan"))
}
if t.MaxLifespanDays > MaxAllowedTokenLifespanDays {
errs = append(errs, fmt.Errorf("max token lifespan should be less than %v, Go's max duration"+
" value", MaxAllowedTokenLifespanDays),
)
}
if t.DefaultLifespanDays > MaxAllowedTokenLifespanDays {
errs = append(errs, fmt.Errorf("default token lifespan days should be less than %v, Go's max"+
" duration value", MaxAllowedTokenLifespanDays),
)
}
return errs
}

// SSHConfig is the configuration setting for SSH.
type SSHConfig struct {
RsaKeySize int `json:"rsa_key_size"`
Expand Down
12 changes: 5 additions & 7 deletions master/internal/token/postgres_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@ import (
"github.com/uptrace/bun"
"gopkg.in/guregu/null.v3"

"github.com/determined-ai/determined/master/internal/config"
"github.com/determined-ai/determined/master/internal/db"
"github.com/determined-ai/determined/master/pkg/model"
)

const (
// DefaultTokenLifespan is how long a newly created access token is valid.
DefaultTokenLifespan = 30 * 24 * time.Hour
)

// AccessTokenOption modifies a model.UserSession to apply optional settings to the AccessToken
// object.
type AccessTokenOption func(f *model.UserSession)
Expand All @@ -43,14 +39,16 @@ func WithTokenDescription(description string) AccessTokenOption {
// CreateAccessToken creates a new access token and store in
// user_sessions db.
func CreateAccessToken(
ctx context.Context, userID model.UserID, opts ...AccessTokenOption,
ctx context.Context,
userID model.UserID,
opts ...AccessTokenOption,
) (string, model.TokenID, error) {
now := time.Now().UTC()
// Populate the default values in the model.
accessToken := &model.UserSession{
UserID: userID,
CreatedAt: now,
Expiry: now.Add(DefaultTokenLifespan),
Expiry: now.Add(config.DefaultTokenLifespanDays * 24 * time.Hour),
TokenType: model.TokenTypeAccessToken,
Description: null.StringFromPtr(nil),
RevokedAt: null.Time{},
Expand Down
12 changes: 6 additions & 6 deletions master/internal/token/postgres_token_intg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/o1egl/paseto"
"github.com/stretchr/testify/require"

"github.com/determined-ai/determined/master/internal/config"
"github.com/determined-ai/determined/master/internal/db"
"github.com/determined-ai/determined/master/internal/user"
"github.com/determined-ai/determined/master/pkg/etc"
Expand Down Expand Up @@ -54,8 +55,7 @@ func TestCreateAccessToken(t *testing.T) {
require.NotNil(t, tokenID)

restoredToken := restoreTokenInfo(token, t)

expLifespan := DefaultTokenLifespan
expLifespan := config.DefaultTokenLifespanDays * 24 * time.Hour
actLifespan := restoredToken.Expiry.Sub(restoredToken.CreatedAt)
require.Equal(t, expLifespan, actLifespan)

Expand All @@ -78,7 +78,7 @@ func TestCreateAccessTokenHasExpiry(t *testing.T) {
require.NoError(t, err)

// Add a AccessToken with custom (Now() + 3 Months) Expiry Time.
expLifespan := DefaultTokenLifespan * 3
expLifespan := config.DefaultTokenLifespanDays * 24 * time.Hour
token, tokenID, err := CreateAccessToken(context.TODO(), testUser.ID,
WithTokenExpiry(&expLifespan), WithTokenDescription(desc))
require.NoError(t, err)
Expand Down Expand Up @@ -117,15 +117,15 @@ func TestUpdateAccessToken(t *testing.T) {

// Test before updating Access token
description := "description"
require.True(t, accessToken.RevokedAt.IsZero())
require.False(t, accessToken.Proto().Revoked)
require.NotEqual(t, description, accessToken.Description)

opt := AccessTokenUpdateOptions{Description: &description, SetRevoked: true}
tokenInfo, err := UpdateAccessToken(context.TODO(), model.TokenID(accessToken.ID), opt)
require.NoError(t, err)

// Test after updating access token
require.False(t, tokenInfo.RevokedAt.IsZero())
require.True(t, tokenInfo.Proto().Revoked)
require.Contains(t, description, tokenInfo.Description.String)

// Delete from DB by UserID for cleanup
Expand Down Expand Up @@ -228,8 +228,8 @@ func getAccessToken(ctx context.Context, userID model.UserID) ([]model.UserSessi
err := db.Bun().NewSelect().
Table("user_sessions").
Where("user_id = ?", userID).
Where("revoked_at IS NULL").
Where("token_type = ?", model.TokenTypeAccessToken).
Where("revoked_at IS NULL").
Scan(ctx, &tokenInfos)
if err != nil {
return nil, err
Expand Down
Binary file modified proto/buf.image.bin
Binary file not shown.
Loading

0 comments on commit 30ad3c0

Please sign in to comment.