Skip to content

Commit 6f41712

Browse files
committed
feat: add role-based token lifetime configuration
Change-Id: Ief7d00a53f20340b36060e7c5f15499a672696f3 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 1d48131 commit 6f41712

File tree

17 files changed

+1207
-22
lines changed

17 files changed

+1207
-22
lines changed

cli/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
813813
return xerrors.Errorf("set deployment id: %w", err)
814814
}
815815

816+
// Process the role-based token lifetime configurations
817+
if err := coderd.ProcessRoleTokenLifetimesConfig(ctx, &vals.Sessions, options.Database, options.Logger); err != nil {
818+
return xerrors.Errorf("failed to initialize role token lifetimes configuration: %w", err)
819+
}
820+
816821
// Manage push notifications.
817822
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
818823
if experiments.Enabled(codersdk.ExperimentWebPush) {

cli/testdata/coder_server_--help.golden

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ OPTIONS:
6161
server postgres-builtin-url". Note that any special characters in the
6262
URL must be URL-encoded.
6363

64+
--role-token-lifetimes string, $CODER_ROLE_TOKEN_LIFETIMES (default: {})
65+
A JSON mapping of role names to maximum token lifetimes (e.g.,
66+
`{"owner": "720h", "MyOrg/admin": "168h"}`). Overrides the global
67+
max_token_lifetime for specified roles. Site-level roles are direct
68+
names (e.g., 'admin'). Organization-level roles use the format
69+
'OrgName/rolename'. Durations use Go duration format: hours (h),
70+
minutes (m), seconds (s). Common conversions: 1 day = 24h, 7 days =
71+
168h, 30 days = 720h.
72+
6473
--ssh-keygen-algorithm string, $CODER_SSH_KEYGEN_ALGORITHM (default: ed25519)
6574
The algorithm to use for generating ssh keys. Accepted values are
6675
"ed25519", "ecdsa", or "rsa4096".

cli/testdata/server-config.yaml.golden

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,14 @@ experiments: []
445445
# performed once per day.
446446
# (default: false, type: bool)
447447
updateCheck: false
448+
# A JSON mapping of role names to maximum token lifetimes (e.g., `{"owner":
449+
# "720h", "MyOrg/admin": "168h"}`). Overrides the global max_token_lifetime for
450+
# specified roles. Site-level roles are direct names (e.g., 'admin').
451+
# Organization-level roles use the format 'OrgName/rolename'. Durations use Go
452+
# duration format: hours (h), minutes (m), seconds (s). Common conversions: 1 day
453+
# = 24h, 7 days = 168h, 30 days = 720h.
454+
# (default: {}, type: string)
455+
roleTokenLifetimes: '{}'
448456
# The default lifetime duration for API tokens. This value is used when creating a
449457
# token without specifying a duration, such as when authenticating the CLI or an
450458
# IDE plugin.

coderd/apidoc/docs.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apikey.go

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd
22

33
import (
44
"context"
5+
"database/sql"
56
"fmt"
67
"net/http"
78
"strconv"
@@ -75,8 +76,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
7576
}
7677

7778
if createToken.Lifetime != 0 {
78-
err := api.validateAPIKeyLifetime(createToken.Lifetime)
79-
if err != nil {
79+
if err := api.validateAPIKeyLifetime(ctx, createToken.Lifetime, user.ID); err != nil {
8080
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
8181
Message: "Failed to validate create API key request.",
8282
Detail: err.Error(),
@@ -338,35 +338,104 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
338338
// @Success 200 {object} codersdk.TokenConfig
339339
// @Router /users/{user}/keys/tokens/tokenconfig [get]
340340
func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) {
341-
values, err := api.DeploymentValues.WithoutSecrets()
342-
if err != nil {
343-
httpapi.InternalServerError(rw, err)
344-
return
341+
ctx := r.Context()
342+
// Prefer httpmw.UserParam(r) as it's consistently used in this file (e.g., postToken, postAPIKey).
343+
// The user object from UserParam should contain the ID.
344+
user := httpmw.UserParam(r)
345+
346+
var userRoles []string
347+
348+
// Check if user has a valid ID.
349+
if user.ID != uuid.Nil {
350+
authzInfo, err := api.Database.GetAuthorizationUserRoles(ctx, user.ID)
351+
if err != nil {
352+
api.Logger.Error(ctx, "failed to get authorization roles for token config", "user_id", user.ID.String(), "error", err)
353+
// Fallback to global max if roles can't be fetched
354+
userRoles = []string{}
355+
} else {
356+
userRoles = authzInfo.Roles
357+
}
358+
} else {
359+
// This case implies an issue with user retrieval (e.g. UserParam returning a zero User struct)
360+
// or an unauthenticated request reaching this point.
361+
// Logging as Warn because it's unexpected for an authenticated endpoint.
362+
api.Logger.Warn(ctx, "user ID is nil in token config request context")
363+
// Fallback to global max, implying default/maximum permissions.
364+
userRoles = []string{}
345365
}
346366

367+
maxLifetime := api.getMaxTokenLifetimeForUserRoles(userRoles)
368+
347369
httpapi.Write(
348-
r.Context(), rw, http.StatusOK,
370+
ctx, rw, http.StatusOK,
349371
codersdk.TokenConfig{
350-
MaxTokenLifetime: values.Sessions.MaximumTokenDuration.Value(),
372+
MaxTokenLifetime: maxLifetime,
351373
},
352374
)
353375
}
354376

355-
func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
377+
func (api *API) validateAPIKeyLifetime(ctx context.Context, lifetime time.Duration, userID uuid.UUID) error {
356378
if lifetime <= 0 {
357379
return xerrors.New("lifetime must be positive number greater than 0")
358380
}
359381

360-
if lifetime > api.DeploymentValues.Sessions.MaximumTokenDuration.Value() {
382+
authzInfo, err := api.Database.GetAuthorizationUserRoles(ctx, userID)
383+
if err != nil {
384+
api.Logger.Error(ctx, "failed to get user authorization info during token validation", "user_id", userID.String(), "error", err)
385+
if xerrors.Is(err, sql.ErrNoRows) {
386+
return xerrors.Errorf("user %s not found", userID)
387+
}
388+
return xerrors.Errorf("internal server error validating token lifetime for user %s", userID)
389+
}
390+
391+
// Check if user is suspended
392+
if authzInfo.Status == database.UserStatusSuspended {
393+
return xerrors.Errorf("user %s is suspended and cannot create tokens", userID)
394+
}
395+
396+
maxAllowedLifetime := api.getMaxTokenLifetimeForUserRoles(authzInfo.Roles)
397+
398+
if lifetime > maxAllowedLifetime {
361399
return xerrors.Errorf(
362-
"lifetime must be less than %v",
363-
api.DeploymentValues.Sessions.MaximumTokenDuration,
400+
"requested lifetime of %v exceeds the maximum allowed %v based on your roles",
401+
lifetime,
402+
maxAllowedLifetime,
364403
)
365404
}
366-
367405
return nil
368406
}
369407

408+
// getMaxTokenLifetimeForUserRoles determines the most generous token lifetime a user is entitled to
409+
// based on their roles and the CODER_ROLE_TOKEN_LIFETIMES configuration.
410+
// Roles are expected in the internal format ("rolename" or "rolename:org_id").
411+
func (api *API) getMaxTokenLifetimeForUserRoles(roles []string) time.Duration {
412+
globalMaxDefault := api.DeploymentValues.Sessions.MaximumTokenDuration.Value()
413+
414+
// Early return for empty config
415+
if api.DeploymentValues.Sessions.RoleTokenLifetimes.Value() == "" ||
416+
api.DeploymentValues.Sessions.RoleTokenLifetimes.Value() == "{}" {
417+
return globalMaxDefault
418+
}
419+
420+
// Early return for no roles
421+
if len(roles) == 0 {
422+
return globalMaxDefault
423+
}
424+
425+
// Find the maximum lifetime among all roles
426+
// This includes both role-specific lifetimes and the global default
427+
maxLifetime := globalMaxDefault
428+
429+
for _, roleStr := range roles {
430+
roleDuration := api.DeploymentValues.Sessions.MaxTokenLifetimeForRole(roleStr)
431+
if roleDuration > maxLifetime {
432+
maxLifetime = roleDuration
433+
}
434+
}
435+
436+
return maxLifetime
437+
}
438+
370439
func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*http.Cookie, *database.APIKey, error) {
371440
key, sessionToken, err := apikey.Generate(params)
372441
if err != nil {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy