From a8ea1f9fec376082a286e3c5522c1a5ee86f93aa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Jun 2025 12:40:51 +0200 Subject: [PATCH] feat(api): add max admin token lifetime configuration and validation Change-Id: I4540ce3eeb46ab58909ac37e60c3ece93668212a Signed-off-by: Thomas Kosiewski --- cli/testdata/coder_server_--help.golden | 4 + cli/testdata/server-config.yaml.golden | 4 + coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/apikey.go | 49 +++++++++-- coderd/apikey_test.go | 82 +++++++++++++++++++ codersdk/deployment.go | 13 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 16 ++-- docs/reference/cli/server.md | 11 +++ .../cli/testdata/coder_server_--help.golden | 4 + site/src/api/typesGenerated.ts | 1 + 12 files changed, 178 insertions(+), 13 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 1cefe8767f3b0..26e63ceb8418f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -332,6 +332,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 7403819a2d10b..cc064e8fa2d6e 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -25,6 +25,10 @@ networking: # The maximum lifetime duration users can specify when creating an API token. # (default: 876600h0m0s, type: duration) maxTokenLifetime: 876600h0m0s + # The maximum lifetime duration administrators can specify when creating an API + # token. + # (default: 168h0m0s, type: duration) + maxAdminTokenLifetime: 168h0m0s # The token expiry duration for browser sessions. Sessions may last longer if they # are actively making requests, but this functionality can be disabled via # --disable-session-expiry-refresh. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 07a0407c0014d..d11a0635d6f52 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 076f170d27e72..aabe0b9b12672 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" } diff --git a/coderd/apikey.go b/coderd/apikey.go index ddcf7767719e5..895be440ef930 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -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 { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 43e3325339983..dbf5a3520a6f0 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -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() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 696e6bda52682..ac72ed2fc1ec1 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -468,6 +468,8 @@ type SessionLifetime struct { DefaultTokenDuration serpent.Duration `json:"default_token_lifetime,omitempty" typescript:",notnull"` MaximumTokenDuration serpent.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + + MaximumAdminTokenDuration serpent.Duration `json:"max_admin_token_lifetime,omitempty" typescript:",notnull"` } type DERP struct { @@ -2340,6 +2342,17 @@ func (c *DeploymentValues) Options() serpent.OptionSet { YAML: "maxTokenLifetime", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, + { + Name: "Maximum Admin Token Lifetime", + Description: "The maximum lifetime duration administrators can specify when creating an API token.", + Flag: "max-admin-token-lifetime", + Env: "CODER_MAX_ADMIN_TOKEN_LIFETIME", + Default: (7 * 24 * time.Hour).String(), + Value: &c.Sessions.MaximumAdminTokenDuration, + Group: &deploymentGroupNetworkingHTTP, + YAML: "maxAdminTokenLifetime", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + }, { Name: "Default Token Lifetime", Description: "The default lifetime duration for API tokens. This value is used when creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin.", diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 12454145569bb..e0fb97a1513e0 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -454,6 +454,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6b0f8254a720c..4191ab8970e92 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2625,6 +2625,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -3124,6 +3125,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -6767,18 +6769,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | -| `default_token_lifetime` | integer | false | | | -| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | -| `max_token_lifetime` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | +| `default_token_lifetime` | integer | false | | | +| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | +| `max_admin_token_lifetime` | integer | false | | | +| `max_token_lifetime` | integer | false | | | ## codersdk.SlimRole diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 1b4052e335e66..8b47ac00dbc7b 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -910,6 +910,17 @@ Periodically check for new releases of Coder and inform the owner. The check is The maximum lifetime duration users can specify when creating an API token. +### --max-admin-token-lifetime + +| | | +|-------------|----------------------------------------------------| +| Type | duration | +| Environment | $CODER_MAX_ADMIN_TOKEN_LIFETIME | +| YAML | networking.http.maxAdminTokenLifetime | +| Default | 168h0m0s | + +The maximum lifetime duration administrators can specify when creating an API token. + ### --default-token-lifetime | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index d11304742d974..edacc0c43fc0b 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -333,6 +333,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9fa6e45fa30da..c662b27386401 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2519,6 +2519,7 @@ export interface SessionLifetime { readonly default_duration: number; readonly default_token_lifetime?: number; readonly max_token_lifetime?: number; + readonly max_admin_token_lifetime?: number; } // From codersdk/client.go