mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add separate max token lifetime for administrators (#18267)
# Add separate token lifetime limits for administrators This PR introduces a new configuration option `--max-admin-token-lifetime` that allows administrators to create API tokens with longer lifetimes than regular users. By default, administrators can create tokens with a lifetime of up to 7 days (168 hours), while the existing `--max-token-lifetime` setting continues to apply to regular users. The implementation: - Adds a new `MaximumAdminTokenDuration` field to the session configuration - Modifies the token validation logic to check the user's role and apply the appropriate lifetime limit - Updates the token configuration endpoint to return the correct maximum lifetime based on the user's role - Adds tests to verify that administrators can create tokens with longer and shorter lifetimes - Updates documentation and help text to reflect the new option This change allows organizations to grant administrators extended token lifetimes while maintaining tighter security controls for regular users. Fixes #17395
This commit is contained in:
3
coderd/apidoc/docs.go
generated
3
coderd/apidoc/docs.go
generated
@ -15705,6 +15705,9 @@ const docTemplate = `{
|
||||
"description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_admin_token_lifetime": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_token_lifetime": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
3
coderd/apidoc/swagger.json
generated
3
coderd/apidoc/swagger.json
generated
@ -14283,6 +14283,9 @@
|
||||
"description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_admin_token_lifetime": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_token_lifetime": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -75,7 +76,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if createToken.Lifetime != 0 {
|
||||
err := api.validateAPIKeyLifetime(createToken.Lifetime)
|
||||
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to validate create API key request.",
|
||||
@ -338,35 +339,69 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {object} codersdk.TokenConfig
|
||||
// @Router /users/{user}/keys/tokens/tokenconfig [get]
|
||||
func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
values, err := api.DeploymentValues.WithoutSecrets()
|
||||
user := httpmw.UserParam(r)
|
||||
maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get token configuration.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(
|
||||
r.Context(), rw, http.StatusOK,
|
||||
codersdk.TokenConfig{
|
||||
MaxTokenLifetime: values.Sessions.MaximumTokenDuration.Value(),
|
||||
MaxTokenLifetime: maxLifetime,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
|
||||
func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, lifetime time.Duration) error {
|
||||
if lifetime <= 0 {
|
||||
return xerrors.New("lifetime must be positive number greater than 0")
|
||||
}
|
||||
|
||||
if lifetime > api.DeploymentValues.Sessions.MaximumTokenDuration.Value() {
|
||||
maxLifetime, err := api.getMaxTokenLifetime(ctx, userID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get max token lifetime: %w", err)
|
||||
}
|
||||
|
||||
if lifetime > maxLifetime {
|
||||
return xerrors.Errorf(
|
||||
"lifetime must be less than %v",
|
||||
api.DeploymentValues.Sessions.MaximumTokenDuration,
|
||||
maxLifetime,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getMaxTokenLifetime returns the maximum allowed token lifetime for a user.
|
||||
// It distinguishes between regular users and owners.
|
||||
func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) {
|
||||
subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll)
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("failed to get user rbac subject: %w", err)
|
||||
}
|
||||
|
||||
roles, err := subject.Roles.Expand()
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("failed to expand user roles: %w", err)
|
||||
}
|
||||
|
||||
maxLifetime := api.DeploymentValues.Sessions.MaximumTokenDuration.Value()
|
||||
for _, role := range roles {
|
||||
if role.Identifier.Name == codersdk.RoleOwner {
|
||||
// Owners have a different max lifetime.
|
||||
maxLifetime = api.DeploymentValues.Sessions.MaximumAdminTokenDuration.Value()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return maxLifetime, nil
|
||||
}
|
||||
|
||||
func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*http.Cookie, *database.APIKey, error) {
|
||||
key, sessionToken, err := apikey.Generate(params)
|
||||
if err != nil {
|
||||
|
@ -144,6 +144,88 @@ func TestTokenUserSetMaxLifetime(t *testing.T) {
|
||||
require.ErrorContains(t, err, "lifetime must be less")
|
||||
}
|
||||
|
||||
func TestTokenAdminSetMaxLifetime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
dc := coderdtest.DeploymentValues(t)
|
||||
dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 7)
|
||||
dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 14)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: dc,
|
||||
})
|
||||
adminUser := coderdtest.CreateFirstUser(t, client)
|
||||
nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
||||
|
||||
// Admin should be able to create a token with a lifetime longer than the non-admin max.
|
||||
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Admin should NOT be able to create a token with a lifetime longer than the admin max.
|
||||
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 15,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "lifetime must be less")
|
||||
|
||||
// Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max.
|
||||
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 8,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "lifetime must be less")
|
||||
|
||||
// Non-admin should be able to create a token with a lifetime shorter than the non-admin max.
|
||||
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 6,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTokenAdminSetMaxLifetimeShorter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
dc := coderdtest.DeploymentValues(t)
|
||||
dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 14)
|
||||
dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 7)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: dc,
|
||||
})
|
||||
adminUser := coderdtest.CreateFirstUser(t, client)
|
||||
nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
||||
|
||||
// Admin should NOT be able to create a token with a lifetime longer than the admin max.
|
||||
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 8,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "lifetime must be less")
|
||||
|
||||
// Admin should be able to create a token with a lifetime shorter than the admin max.
|
||||
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 6,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Non-admin should be able to create a token with a lifetime longer than the admin max.
|
||||
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max.
|
||||
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 15,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "lifetime must be less")
|
||||
}
|
||||
|
||||
func TestTokenCustomDefaultLifetime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
Reference in New Issue
Block a user