Skip to content

Commit 63eaf5c

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

File tree

17 files changed

+1244
-28
lines changed

17 files changed

+1244
-28
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: 80 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"
@@ -18,6 +19,7 @@ import (
1819
"github.com/coder/coder/v2/coderd/database/dbtime"
1920
"github.com/coder/coder/v2/coderd/httpapi"
2021
"github.com/coder/coder/v2/coderd/httpmw"
22+
"github.com/coder/coder/v2/coderd/rbac"
2123
"github.com/coder/coder/v2/coderd/rbac/policy"
2224
"github.com/coder/coder/v2/coderd/telemetry"
2325
"github.com/coder/coder/v2/codersdk"
@@ -75,8 +77,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
7577
}
7678

7779
if createToken.Lifetime != 0 {
78-
err := api.validateAPIKeyLifetime(createToken.Lifetime)
79-
if err != nil {
80+
if err := api.validateAPIKeyLifetime(ctx, createToken.Lifetime, user.ID); err != nil {
8081
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
8182
Message: "Failed to validate create API key request.",
8283
Detail: err.Error(),
@@ -338,35 +339,101 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
338339
// @Success 200 {object} codersdk.TokenConfig
339340
// @Router /users/{user}/keys/tokens/tokenconfig [get]
340341
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
342+
ctx := r.Context()
343+
user := httpmw.UserParam(r)
344+
345+
var roleIdentifiers []rbac.RoleIdentifier
346+
347+
if user.ID != uuid.Nil {
348+
subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, rbac.ScopeAll)
349+
switch {
350+
case err != nil:
351+
api.Logger.Error(ctx, "failed to get user RBAC subject for token config", "user_id", user.ID.String(), "error", err)
352+
roleIdentifiers = []rbac.RoleIdentifier{}
353+
case userStatus == database.UserStatusSuspended:
354+
roleIdentifiers = []rbac.RoleIdentifier{}
355+
default:
356+
// Extract role names from the RBAC subject and convert to internal format
357+
roleIdentifiers = subject.Roles.Names()
358+
}
359+
} else {
360+
api.Logger.Warn(ctx, "user ID is nil in token config request context")
361+
roleIdentifiers = []rbac.RoleIdentifier{}
345362
}
346363

364+
maxLifetime := api.getMaxTokenLifetimeForUserRoles(roleIdentifiers)
365+
347366
httpapi.Write(
348-
r.Context(), rw, http.StatusOK,
367+
ctx, rw, http.StatusOK,
349368
codersdk.TokenConfig{
350-
MaxTokenLifetime: values.Sessions.MaximumTokenDuration.Value(),
369+
MaxTokenLifetime: maxLifetime,
351370
},
352371
)
353372
}
354373

355-
func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
374+
func (api *API) validateAPIKeyLifetime(ctx context.Context, lifetime time.Duration, userID uuid.UUID) error {
356375
if lifetime <= 0 {
357376
return xerrors.New("lifetime must be positive number greater than 0")
358377
}
359378

360-
if lifetime > api.DeploymentValues.Sessions.MaximumTokenDuration.Value() {
379+
subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll)
380+
if err != nil {
381+
api.Logger.Error(ctx, "failed to get user RBAC subject during token validation", "user_id", userID.String(), "error", err)
382+
if xerrors.Is(err, sql.ErrNoRows) {
383+
return xerrors.Errorf("user %s not found", userID)
384+
}
385+
return xerrors.Errorf("internal server error validating token lifetime for user %s", userID)
386+
}
387+
388+
if userStatus == database.UserStatusSuspended {
389+
return xerrors.Errorf("user %s is suspended and cannot create tokens", userID)
390+
}
391+
392+
// Extract role names from the RBAC subject and convert to internal format
393+
roleIdentifiers := subject.Roles.Names()
394+
maxAllowedLifetime := api.getMaxTokenLifetimeForUserRoles(roleIdentifiers)
395+
396+
if lifetime > maxAllowedLifetime {
361397
return xerrors.Errorf(
362-
"lifetime must be less than %v",
363-
api.DeploymentValues.Sessions.MaximumTokenDuration,
398+
"requested lifetime of %v exceeds the maximum allowed %v based on your roles",
399+
lifetime,
400+
maxAllowedLifetime,
364401
)
365402
}
366-
367403
return nil
368404
}
369405

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