From fbadbf385d597308636e5109a4f01702e196db12 Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Thu, 20 Nov 2025 13:44:15 +0200 Subject: [PATCH] IAM: Add validation for new tokens with no expiration when token_expiration_day_limit is set (#114166) * do not create sa with no expiration when day limit is set * disable No expiration option if day limit is set * make i18n-extract * run prettier * address feedback --- pkg/services/serviceaccounts/api/token.go | 4 ++ .../serviceaccounts/api/token_test.go | 63 ++++++++++++++++--- .../components/CreateTokenModal.tsx | 51 +++++++++++---- public/locales/en-US/grafana.json | 3 + 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index dcfb82b10f5..620d278ee1e 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -157,6 +157,10 @@ func (api *ServiceAccountsAPI) CreateToken(c *contextmodel.ReqContext) response. } if api.cfg.SATokenExpirationDayLimit > 0 { + if cmd.SecondsToLive == 0 { + return response.Error(http.StatusBadRequest, "Cannot create token with no expiration date when service_accounts.token_expiration_day_limit is set", nil) + } + dayExpireLimit := time.Now().Add(time.Duration(api.cfg.SATokenExpirationDayLimit) * time.Hour * 24).Truncate(24 * time.Hour) expirationDate := time.Now().Add(time.Duration(cmd.SecondsToLive) * time.Second).Truncate(24 * time.Hour) if expirationDate.After(dayExpireLimit) { diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 087d786b723..e6218c10638 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -60,14 +60,15 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) { func TestServiceAccountsAPI_CreateToken(t *testing.T) { type TestCase struct { - desc string - id int64 - body string - permissions []accesscontrol.Permission - tokenTTL int64 - expectedErr error - expectedAPIKey *apikey.APIKey - expectedCode int + desc string + id int64 + body string + permissions []accesscontrol.Permission + tokenTTL int64 + tokenExpirationDayLimit int + expectedErr error + expectedAPIKey *apikey.APIKey + expectedCode int } tests := []TestCase{ @@ -105,12 +106,58 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, expectedCode: http.StatusBadRequest, }, + { + desc: "should not be able to create token for service account if max ttl is configured and exceeds the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 11000}`, + tokenTTL: 10000, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be able to create token for service account if max ttl is configured and within the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 5000}`, + tokenTTL: 10000, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedAPIKey: &apikey.APIKey{}, + expectedCode: http.StatusOK, + }, + { + desc: "should not be able to create token for service account if token expiration day limit is configured but not set in body", + id: 1, + body: `{"name": "test"}`, + tokenTTL: -1, + tokenExpirationDayLimit: 30, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should not be able to create token for service account if token expiration day limit is configured and exceeds the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 340000}`, // 340000 seconds is > 3 days + tokenTTL: -1, + tokenExpirationDayLimit: 3, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be able to create token for service account if token expiration day limit is configured and within the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 250000}`, // 250000 seconds is almost 3 days + tokenTTL: -1, + tokenExpirationDayLimit: 3, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedAPIKey: &apikey.APIKey{}, + expectedCode: http.StatusOK, + }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { server := setupTests(t, func(a *ServiceAccountsAPI) { a.cfg.ApiKeyMaxSecondsToLive = tt.tokenTTL + a.cfg.SATokenExpirationDayLimit = tt.tokenExpirationDayLimit a.service = &satests.FakeServiceAccountService{ ExpectedErr: tt.expectedErr, ExpectedAPIKey: tt.expectedAPIKey, diff --git a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx index a0daa852b09..fdad92e254e 100644 --- a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx +++ b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx @@ -16,10 +16,8 @@ import { useStyles2, } from '@grafana/ui'; -const EXPIRATION_OPTIONS = [ - { label: 'No expiration', value: false }, - { label: 'Set expiration date', value: true }, -]; +const NO_EXPIRATION_OPTION = 'no-expiration'; +const CUSTOM_EXPIRATION_OPTION = 'custom-expiration'; export type ServiceAccountToken = { name: string; @@ -44,11 +42,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT } else { maxExpirationDate.setDate(8640000000000000); } - const defaultExpirationDate = config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0; + + const isTokenExpirationDayLimitConfigured = + config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0; + + const defaultExpirationOption = isTokenExpirationDayLimitConfigured ? CUSTOM_EXPIRATION_OPTION : NO_EXPIRATION_OPTION; const [defaultTokenName, setDefaultTokenName] = useState(''); const [newTokenName, setNewTokenName] = useState(''); - const [isWithExpirationDate, setIsWithExpirationDate] = useState(defaultExpirationDate); + const [expirationOption, setExpirationOption] = useState(defaultExpirationOption); const [newTokenExpirationDate, setNewTokenExpirationDate] = useState(tomorrow); const [isExpirationDateValid, setIsExpirationDateValid] = useState(newTokenExpirationDate !== ''); const styles = useStyles2(getStyles); @@ -69,14 +71,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT const onGenerateToken = () => { onCreateToken({ name: newTokenName || defaultTokenName, - secondsToLive: isWithExpirationDate ? getSecondsToLive(newTokenExpirationDate) : undefined, + secondsToLive: + expirationOption === CUSTOM_EXPIRATION_OPTION ? getSecondsToLive(newTokenExpirationDate) : undefined, }); }; const onCloseInternal = () => { setNewTokenName(''); setDefaultTokenName(''); - setIsWithExpirationDate(defaultExpirationDate); + setExpirationOption(defaultExpirationOption); setNewTokenExpirationDate(tomorrow); setIsExpirationDateValid(newTokenExpirationDate !== ''); onClose(); @@ -84,6 +87,24 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT const modalTitle = !token ? 'Add service account token' : 'Service account token created'; + const getExpirationOptions = () => { + const noExpirationDescription = t( + 'serviceaccounts.create-token-modal.description-no-expiration-disabled', + 'Cannot create a token with no expiration date when token expiration day limit is configured' + ); + return [ + { + label: t('serviceaccounts.create-token-modal.label-no-expiration', 'No expiration'), + value: NO_EXPIRATION_OPTION, + description: isTokenExpirationDayLimitConfigured ? noExpirationDescription : undefined, + }, + { + label: t('serviceaccounts.create-token-modal.label-set-expiration-date', 'Set expiration date'), + value: CUSTOM_EXPIRATION_OPTION, + }, + ]; + }; + return ( {!token ? ( @@ -109,13 +130,14 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT - {isWithExpirationDate && ( + {expirationOption === CUSTOM_EXPIRATION_OPTION && ( )} - diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 28512178dd7..d29fe101bd6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12543,11 +12543,14 @@ "copy-clipboard": "Copy to clipboard", "copy-to-clipboard-and-close": "Copy to clipboard and close", "description-name-to-easily-identify-the-token": "Name to easily identify the token", + "description-no-expiration-disabled": "Cannot create a token with no expiration date when token expiration day limit is configured", "description-token": "Copy the token now as you will not be able to see it again. Losing a token requires creating a new one.", "generate-token": "Generate token", "label-display-name": "Display name", "label-expiration": "Expiration", "label-expiration-date": "Expiration date", + "label-no-expiration": "No expiration", + "label-set-expiration-date": "Set expiration date", "label-token": "Token" }, "get-actions-cell": {