mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-20 10:20:51 +00:00
Monitoring safe rollout of time-bound service account token.
This commit is contained in:
parent
57108f6c3e
commit
ae0e52d28c
@ -351,6 +351,7 @@ func CreateKubeAPIServerConfig(
|
|||||||
|
|
||||||
ServiceAccountIssuer: s.ServiceAccountIssuer,
|
ServiceAccountIssuer: s.ServiceAccountIssuer,
|
||||||
ServiceAccountMaxExpiration: s.ServiceAccountTokenMaxExpiration,
|
ServiceAccountMaxExpiration: s.ServiceAccountTokenMaxExpiration,
|
||||||
|
ExtendExpiration: s.Authentication.ServiceAccounts.ExtendExpiration,
|
||||||
|
|
||||||
VersionedInformers: versionedInformers,
|
VersionedInformers: versionedInformers,
|
||||||
},
|
},
|
||||||
@ -689,6 +690,14 @@ func Complete(s *options.ServerRunOptions) (completedServerRunOptions, error) {
|
|||||||
s.Authentication.ServiceAccounts.MaxExpiration > upBound {
|
s.Authentication.ServiceAccounts.MaxExpiration > upBound {
|
||||||
return options, fmt.Errorf("the serviceaccount max expiration must be between 1 hour to 2^32 seconds")
|
return options, fmt.Errorf("the serviceaccount max expiration must be between 1 hour to 2^32 seconds")
|
||||||
}
|
}
|
||||||
|
if s.Authentication.ServiceAccounts.ExtendExpiration {
|
||||||
|
if s.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.WarnOnlyBoundTokenExpirationSeconds*time.Second {
|
||||||
|
klog.Warningf("service-account-extend-token-expiration is true, in order to correctly trigger safe transition logic, service-account-max-token-expiration must be set longer than 3607 seconds (currently %s)", s.Authentication.ServiceAccounts.MaxExpiration)
|
||||||
|
}
|
||||||
|
if s.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.ExpirationExtensionSeconds*time.Second {
|
||||||
|
klog.Warningf("service-account-extend-token-expiration is true, enabling tokens valid up to 1 year, which is longer than service-account-max-token-expiration set to %s", s.Authentication.ServiceAccounts.MaxExpiration)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ServiceAccountIssuer, err = serviceaccount.JWTTokenGenerator(s.Authentication.ServiceAccounts.Issuer, sk)
|
s.ServiceAccountIssuer, err = serviceaccount.JWTTokenGenerator(s.Authentication.ServiceAccounts.Issuer, sk)
|
||||||
|
@ -73,11 +73,12 @@ type OIDCAuthenticationOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServiceAccountAuthenticationOptions struct {
|
type ServiceAccountAuthenticationOptions struct {
|
||||||
KeyFiles []string
|
KeyFiles []string
|
||||||
Lookup bool
|
Lookup bool
|
||||||
Issuer string
|
Issuer string
|
||||||
JWKSURI string
|
JWKSURI string
|
||||||
MaxExpiration time.Duration
|
MaxExpiration time.Duration
|
||||||
|
ExtendExpiration bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenFileAuthenticationOptions struct {
|
type TokenFileAuthenticationOptions struct {
|
||||||
@ -304,6 +305,12 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) {
|
|||||||
fs.DurationVar(&s.ServiceAccounts.MaxExpiration, "service-account-max-token-expiration", s.ServiceAccounts.MaxExpiration, ""+
|
fs.DurationVar(&s.ServiceAccounts.MaxExpiration, "service-account-max-token-expiration", s.ServiceAccounts.MaxExpiration, ""+
|
||||||
"The maximum validity duration of a token created by the service account token issuer. If an otherwise valid "+
|
"The maximum validity duration of a token created by the service account token issuer. If an otherwise valid "+
|
||||||
"TokenRequest with a validity duration larger than this value is requested, a token will be issued with a validity duration of this value.")
|
"TokenRequest with a validity duration larger than this value is requested, a token will be issued with a validity duration of this value.")
|
||||||
|
|
||||||
|
fs.BoolVar(&s.ServiceAccounts.ExtendExpiration, "service-account-extend-token-expiration", s.ServiceAccounts.ExtendExpiration, ""+
|
||||||
|
"Turns on projected service account expiration extension during token generation, "+
|
||||||
|
"which helps safe transition from legacy token to bound service account token feature. "+
|
||||||
|
"If this flag is enabled, admission injected tokens would be extended up to 1 year to "+
|
||||||
|
"prevent unexpected failure during transition, ignoring value of service-account-max-token-expiration.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.TokenFile != nil {
|
if s.TokenFile != nil {
|
||||||
|
@ -190,6 +190,7 @@ type ExtraConfig struct {
|
|||||||
|
|
||||||
ServiceAccountIssuer serviceaccount.TokenGenerator
|
ServiceAccountIssuer serviceaccount.TokenGenerator
|
||||||
ServiceAccountMaxExpiration time.Duration
|
ServiceAccountMaxExpiration time.Duration
|
||||||
|
ExtendExpiration bool
|
||||||
|
|
||||||
// ServiceAccountIssuerDiscovery
|
// ServiceAccountIssuerDiscovery
|
||||||
ServiceAccountIssuerURL string
|
ServiceAccountIssuerURL string
|
||||||
@ -397,6 +398,7 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget)
|
|||||||
ServiceNodePortRange: c.ExtraConfig.ServiceNodePortRange,
|
ServiceNodePortRange: c.ExtraConfig.ServiceNodePortRange,
|
||||||
LoopbackClientConfig: c.GenericConfig.LoopbackClientConfig,
|
LoopbackClientConfig: c.GenericConfig.LoopbackClientConfig,
|
||||||
ServiceAccountIssuer: c.ExtraConfig.ServiceAccountIssuer,
|
ServiceAccountIssuer: c.ExtraConfig.ServiceAccountIssuer,
|
||||||
|
ExtendExpiration: c.ExtraConfig.ExtendExpiration,
|
||||||
ServiceAccountMaxExpiration: c.ExtraConfig.ServiceAccountMaxExpiration,
|
ServiceAccountMaxExpiration: c.ExtraConfig.ServiceAccountMaxExpiration,
|
||||||
APIAudiences: c.GenericConfig.Authentication.APIAudiences,
|
APIAudiences: c.GenericConfig.Authentication.APIAudiences,
|
||||||
}
|
}
|
||||||
|
@ -85,6 +85,7 @@ type LegacyRESTStorageProvider struct {
|
|||||||
|
|
||||||
ServiceAccountIssuer serviceaccount.TokenGenerator
|
ServiceAccountIssuer serviceaccount.TokenGenerator
|
||||||
ServiceAccountMaxExpiration time.Duration
|
ServiceAccountMaxExpiration time.Duration
|
||||||
|
ExtendExpiration bool
|
||||||
|
|
||||||
APIAudiences authenticator.Audiences
|
APIAudiences authenticator.Audiences
|
||||||
|
|
||||||
@ -181,9 +182,9 @@ func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(restOptionsGetter generi
|
|||||||
|
|
||||||
var serviceAccountStorage *serviceaccountstore.REST
|
var serviceAccountStorage *serviceaccountstore.REST
|
||||||
if c.ServiceAccountIssuer != nil && utilfeature.DefaultFeatureGate.Enabled(features.TokenRequest) {
|
if c.ServiceAccountIssuer != nil && utilfeature.DefaultFeatureGate.Enabled(features.TokenRequest) {
|
||||||
serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, podStorage.Pod.Store, secretStorage.Store)
|
serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, podStorage.Pod.Store, secretStorage.Store, c.ExtendExpiration)
|
||||||
} else {
|
} else {
|
||||||
serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, nil, nil)
|
serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, nil, nil, false)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LegacyRESTStorage{}, genericapiserver.APIGroupInfo{}, err
|
return LegacyRESTStorage{}, genericapiserver.APIGroupInfo{}, err
|
||||||
|
@ -38,7 +38,7 @@ type REST struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewREST returns a RESTStorage object that will work against service accounts.
|
// NewREST returns a RESTStorage object that will work against service accounts.
|
||||||
func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage *genericregistry.Store) (*REST, error) {
|
func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage *genericregistry.Store, extendExpiration bool) (*REST, error) {
|
||||||
store := &genericregistry.Store{
|
store := &genericregistry.Store{
|
||||||
NewFunc: func() runtime.Object { return &api.ServiceAccount{} },
|
NewFunc: func() runtime.Object { return &api.ServiceAccount{} },
|
||||||
NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} },
|
NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} },
|
||||||
@ -65,6 +65,7 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator,
|
|||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
auds: auds,
|
auds: auds,
|
||||||
maxExpirationSeconds: int64(max.Seconds()),
|
maxExpirationSeconds: int64(max.Seconds()),
|
||||||
|
extendExpiration: extendExpiration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) {
|
|||||||
DeleteCollectionWorkers: 1,
|
DeleteCollectionWorkers: 1,
|
||||||
ResourcePrefix: "serviceaccounts",
|
ResourcePrefix: "serviceaccounts",
|
||||||
}
|
}
|
||||||
rest, err := NewREST(restOptions, nil, nil, 0, nil, nil)
|
rest, err := NewREST(restOptions, nil, nil, 0, nil, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error from REST storage: %v", err)
|
t.Fatalf("unexpected error from REST storage: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
authenticationapiv1 "k8s.io/api/authentication/v1"
|
authenticationapiv1 "k8s.io/api/authentication/v1"
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
@ -46,6 +47,7 @@ type TokenREST struct {
|
|||||||
issuer token.TokenGenerator
|
issuer token.TokenGenerator
|
||||||
auds authenticator.Audiences
|
auds authenticator.Audiences
|
||||||
maxExpirationSeconds int64
|
maxExpirationSeconds int64
|
||||||
|
extendExpiration bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = rest.NamedCreater(&TokenREST{})
|
var _ = rest.NamedCreater(&TokenREST{})
|
||||||
@ -121,7 +123,21 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object,
|
|||||||
out.Spec.ExpirationSeconds = r.maxExpirationSeconds
|
out.Spec.ExpirationSeconds = r.maxExpirationSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
sc, pc := token.Claims(*svcacct, pod, secret, out.Spec.ExpirationSeconds, out.Spec.Audiences)
|
// Tweak expiration for safe transition of projected service account token.
|
||||||
|
// Warn (instead of fail) after requested expiration time.
|
||||||
|
// Fail after hard-coded extended expiration time.
|
||||||
|
// Only perform the extension when token is pod-bound.
|
||||||
|
var warnAfter int64
|
||||||
|
exp := out.Spec.ExpirationSeconds
|
||||||
|
if r.extendExpiration && pod != nil && out.Spec.ExpirationSeconds == token.WarnOnlyBoundTokenExpirationSeconds {
|
||||||
|
warnAfter = exp
|
||||||
|
exp = token.ExpirationExtensionSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current time before building the token, to make sure the expiration
|
||||||
|
// returned in TokenRequestStatus would be earlier than exp field in token.
|
||||||
|
nowTime := time.Now()
|
||||||
|
sc, pc := token.Claims(*svcacct, pod, secret, exp, warnAfter, out.Spec.Audiences)
|
||||||
tokdata, err := r.issuer.GenerateToken(sc, pc)
|
tokdata, err := r.issuer.GenerateToken(sc, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate token: %v", err)
|
return nil, fmt.Errorf("failed to generate token: %v", err)
|
||||||
@ -129,7 +145,7 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object,
|
|||||||
|
|
||||||
out.Status = authenticationapi.TokenRequestStatus{
|
out.Status = authenticationapi.TokenRequestStatus{
|
||||||
Token: tokdata,
|
Token: tokdata,
|
||||||
ExpirationTimestamp: metav1.Time{Time: sc.Expiry.Time()},
|
ExpirationTimestamp: metav1.Time{Time: nowTime.Add(time.Duration(out.Spec.ExpirationSeconds) * time.Second)},
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ go_library(
|
|||||||
"claims.go",
|
"claims.go",
|
||||||
"jwt.go",
|
"jwt.go",
|
||||||
"legacy.go",
|
"legacy.go",
|
||||||
|
"metrics.go",
|
||||||
"openidmetadata.go",
|
"openidmetadata.go",
|
||||||
"util.go",
|
"util.go",
|
||||||
],
|
],
|
||||||
@ -21,9 +22,12 @@ go_library(
|
|||||||
"//staging/src/k8s.io/api/core/v1:go_default_library",
|
"//staging/src/k8s.io/api/core/v1:go_default_library",
|
||||||
"//staging/src/k8s.io/apimachinery/pkg/util/errors:go_default_library",
|
"//staging/src/k8s.io/apimachinery/pkg/util/errors:go_default_library",
|
||||||
"//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library",
|
"//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library",
|
||||||
|
"//staging/src/k8s.io/apiserver/pkg/audit:go_default_library",
|
||||||
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
|
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
|
||||||
"//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount:go_default_library",
|
"//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount:go_default_library",
|
||||||
"//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library",
|
"//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library",
|
||||||
|
"//staging/src/k8s.io/component-base/metrics:go_default_library",
|
||||||
|
"//staging/src/k8s.io/component-base/metrics/legacyregistry:go_default_library",
|
||||||
"//vendor/gopkg.in/square/go-jose.v2:go_default_library",
|
"//vendor/gopkg.in/square/go-jose.v2:go_default_library",
|
||||||
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
||||||
"//vendor/k8s.io/klog:go_default_library",
|
"//vendor/k8s.io/klog:go_default_library",
|
||||||
|
@ -17,17 +17,26 @@ limitations under the License.
|
|||||||
package serviceaccount
|
package serviceaccount
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/square/go-jose.v2/jwt"
|
"gopkg.in/square/go-jose.v2/jwt"
|
||||||
"k8s.io/klog"
|
"k8s.io/apiserver/pkg/audit"
|
||||||
|
|
||||||
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||||
|
"k8s.io/klog"
|
||||||
"k8s.io/kubernetes/pkg/apis/core"
|
"k8s.io/kubernetes/pkg/apis/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Injected bound service account token expiration which triggers monitoring of its time-bound feature.
|
||||||
|
WarnOnlyBoundTokenExpirationSeconds = 60*60 + 7
|
||||||
|
|
||||||
|
// Extended expiration for those modifed tokens involved in safe rollout if time-bound feature.
|
||||||
|
ExpirationExtensionSeconds = 24 * 365 * 60 * 60
|
||||||
|
)
|
||||||
|
|
||||||
// time.Now stubbed out to allow testing
|
// time.Now stubbed out to allow testing
|
||||||
var now = time.Now
|
var now = time.Now
|
||||||
|
|
||||||
@ -36,10 +45,11 @@ type privateClaims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kubernetes struct {
|
type kubernetes struct {
|
||||||
Namespace string `json:"namespace,omitempty"`
|
Namespace string `json:"namespace,omitempty"`
|
||||||
Svcacct ref `json:"serviceaccount,omitempty"`
|
Svcacct ref `json:"serviceaccount,omitempty"`
|
||||||
Pod *ref `json:"pod,omitempty"`
|
Pod *ref `json:"pod,omitempty"`
|
||||||
Secret *ref `json:"secret,omitempty"`
|
Secret *ref `json:"secret,omitempty"`
|
||||||
|
WarnAfter jwt.NumericDate `json:"warnafter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ref struct {
|
type ref struct {
|
||||||
@ -47,7 +57,7 @@ type ref struct {
|
|||||||
UID string `json:"uid,omitempty"`
|
UID string `json:"uid,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirationSeconds int64, audience []string) (*jwt.Claims, interface{}) {
|
func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirationSeconds, warnafter int64, audience []string) (*jwt.Claims, interface{}) {
|
||||||
now := now()
|
now := now()
|
||||||
sc := &jwt.Claims{
|
sc := &jwt.Claims{
|
||||||
Subject: apiserverserviceaccount.MakeUsername(sa.Namespace, sa.Name),
|
Subject: apiserverserviceaccount.MakeUsername(sa.Namespace, sa.Name),
|
||||||
@ -77,6 +87,11 @@ func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirati
|
|||||||
UID: string(secret.UID),
|
UID: string(secret.UID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if warnafter != 0 {
|
||||||
|
pc.Kubernetes.WarnAfter = jwt.NewNumericDate(now.Add(time.Duration(warnafter) * time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
return sc, pc
|
return sc, pc
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +107,7 @@ type validator struct {
|
|||||||
|
|
||||||
var _ = Validator(&validator{})
|
var _ = Validator(&validator{})
|
||||||
|
|
||||||
func (v *validator) Validate(_ string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) {
|
func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) {
|
||||||
private, ok := privateObj.(*privateClaims)
|
private, ok := privateObj.(*privateClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
klog.Errorf("jwt validator expected private claim of type *privateClaims but got: %T", privateObj)
|
klog.Errorf("jwt validator expected private claim of type *privateClaims but got: %T", privateObj)
|
||||||
@ -169,6 +184,19 @@ func (v *validator) Validate(_ string, public *jwt.Claims, privateObj interface{
|
|||||||
podUID = podref.UID
|
podUID = podref.UID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check special 'warnafter' field for projected service account token transition.
|
||||||
|
warnafter := private.Kubernetes.WarnAfter
|
||||||
|
if warnafter != 0 {
|
||||||
|
if nowTime.After(warnafter.Time()) {
|
||||||
|
secondsAfterWarn := nowTime.Unix() - warnafter.Time().Unix()
|
||||||
|
auditInfo := fmt.Sprintf("subject: %s, seconds after warning threshold: %d", public.Subject, secondsAfterWarn)
|
||||||
|
audit.AddAuditAnnotation(ctx, "authentication.k8s.io/stale-token", auditInfo)
|
||||||
|
staleTokensTotal.Inc()
|
||||||
|
} else {
|
||||||
|
validTokensTotal.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &ServiceAccountInfo{
|
return &ServiceAccountInfo{
|
||||||
Namespace: private.Kubernetes.Namespace,
|
Namespace: private.Kubernetes.Namespace,
|
||||||
Name: private.Kubernetes.Svcacct.Name,
|
Name: private.Kubernetes.Svcacct.Name,
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
package serviceaccount
|
package serviceaccount
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
@ -62,11 +63,12 @@ func TestClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cs := []struct {
|
cs := []struct {
|
||||||
// input
|
// input
|
||||||
sa core.ServiceAccount
|
sa core.ServiceAccount
|
||||||
pod *core.Pod
|
pod *core.Pod
|
||||||
sec *core.Secret
|
sec *core.Secret
|
||||||
exp int64
|
exp int64
|
||||||
aud []string
|
warnafter int64
|
||||||
|
aud []string
|
||||||
// desired
|
// desired
|
||||||
sc *jwt.Claims
|
sc *jwt.Claims
|
||||||
pc *privateClaims
|
pc *privateClaims
|
||||||
@ -161,6 +163,31 @@ func TestClaims(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// warn after provided
|
||||||
|
sa: sa,
|
||||||
|
pod: pod,
|
||||||
|
sec: sec,
|
||||||
|
exp: 60 * 60 * 24,
|
||||||
|
warnafter: 60 * 60,
|
||||||
|
// nil audience
|
||||||
|
aud: nil,
|
||||||
|
|
||||||
|
sc: &jwt.Claims{
|
||||||
|
Subject: "system:serviceaccount:myns:mysvcacct",
|
||||||
|
IssuedAt: jwt.NumericDate(1514764800),
|
||||||
|
NotBefore: jwt.NumericDate(1514764800),
|
||||||
|
Expiry: jwt.NumericDate(1514764800 + 60*60*24),
|
||||||
|
},
|
||||||
|
pc: &privateClaims{
|
||||||
|
Kubernetes: kubernetes{
|
||||||
|
Namespace: "myns",
|
||||||
|
Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"},
|
||||||
|
Pod: &ref{Name: "mypod", UID: "mypod-uid"},
|
||||||
|
WarnAfter: jwt.NumericDate(1514764800 + 60*60),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for i, c := range cs {
|
for i, c := range cs {
|
||||||
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
||||||
@ -175,7 +202,7 @@ func TestClaims(t *testing.T) {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
sc, pc := Claims(c.sa, c.pod, c.sec, c.exp, c.aud)
|
sc, pc := Claims(c.sa, c.pod, c.sec, c.exp, c.warnafter, c.aud)
|
||||||
if spew(sc) != spew(c.sc) {
|
if spew(sc) != spew(c.sc) {
|
||||||
t.Errorf("standard claims differed\n\tsaw:\t%s\n\twant:\t%s", spew(sc), spew(c.sc))
|
t.Errorf("standard claims differed\n\tsaw:\t%s\n\twant:\t%s", spew(sc), spew(c.sc))
|
||||||
}
|
}
|
||||||
@ -310,7 +337,7 @@ func TestValidatePrivateClaims(t *testing.T) {
|
|||||||
for _, tc := range testcases {
|
for _, tc := range testcases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
v := &validator{tc.getter}
|
v := &validator{tc.getter}
|
||||||
_, err := v.Validate("", &jwt.Claims{Expiry: jwt.NumericDate(nowUnix)}, tc.private)
|
_, err := v.Validate(context.Background(), "", &jwt.Claims{Expiry: jwt.NumericDate(nowUnix)}, tc.private)
|
||||||
if err != nil && !tc.expectErr {
|
if err != nil && !tc.expectErr {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,7 @@ import (
|
|||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
utilerrors "k8s.io/apimachinery/pkg/util/errors"
|
utilerrors "k8s.io/apimachinery/pkg/util/errors"
|
||||||
|
"k8s.io/apiserver/pkg/audit"
|
||||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -244,7 +245,7 @@ type Validator interface {
|
|||||||
// Validate validates a token and returns user information or an error.
|
// Validate validates a token and returns user information or an error.
|
||||||
// Validator can assume that the issuer and signature of a token are already
|
// Validator can assume that the issuer and signature of a token are already
|
||||||
// verified when this function is called.
|
// verified when this function is called.
|
||||||
Validate(tokenData string, public *jwt.Claims, private interface{}) (*ServiceAccountInfo, error)
|
Validate(ctx context.Context, tokenData string, public *jwt.Claims, private interface{}) (*ServiceAccountInfo, error)
|
||||||
// NewPrivateClaims returns a struct that the authenticator should
|
// NewPrivateClaims returns a struct that the authenticator should
|
||||||
// deserialize the JWT payload into. The authenticator may then pass this
|
// deserialize the JWT payload into. The authenticator may then pass this
|
||||||
// struct back to the Validator as the 'private' argument to a Validate()
|
// struct back to the Validator as the 'private' argument to a Validate()
|
||||||
@ -287,6 +288,8 @@ func (j *jwtTokenAuthenticator) AuthenticateToken(ctx context.Context, tokenData
|
|||||||
tokenAudiences := authenticator.Audiences(public.Audience)
|
tokenAudiences := authenticator.Audiences(public.Audience)
|
||||||
if len(tokenAudiences) == 0 {
|
if len(tokenAudiences) == 0 {
|
||||||
// only apiserver audiences are allowed for legacy tokens
|
// only apiserver audiences are allowed for legacy tokens
|
||||||
|
audit.AddAuditAnnotation(ctx, "authentication.k8s.io/legacy-token", public.Subject)
|
||||||
|
legacyTokensTotal.Inc()
|
||||||
tokenAudiences = j.implicitAuds
|
tokenAudiences = j.implicitAuds
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,7 +306,7 @@ func (j *jwtTokenAuthenticator) AuthenticateToken(ctx context.Context, tokenData
|
|||||||
|
|
||||||
// If we get here, we have a token with a recognized signature and
|
// If we get here, we have a token with a recognized signature and
|
||||||
// issuer string.
|
// issuer string.
|
||||||
sa, err := j.validator.Validate(tokenData, public, private)
|
sa, err := j.validator.Validate(ctx, tokenData, public, private)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ package serviceaccount
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@ -62,7 +63,7 @@ type legacyValidator struct {
|
|||||||
|
|
||||||
var _ = Validator(&legacyValidator{})
|
var _ = Validator(&legacyValidator{})
|
||||||
|
|
||||||
func (v *legacyValidator) Validate(tokenData string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) {
|
func (v *legacyValidator) Validate(ctx context.Context, tokenData string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) {
|
||||||
private, ok := privateObj.(*legacyPrivateClaims)
|
private, ok := privateObj.(*legacyPrivateClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
klog.Errorf("jwt validator expected private claim of type *legacyPrivateClaims but got: %T", privateObj)
|
klog.Errorf("jwt validator expected private claim of type *legacyPrivateClaims but got: %T", privateObj)
|
||||||
|
63
pkg/serviceaccount/metrics.go
Normal file
63
pkg/serviceaccount/metrics.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 The Kubernetes Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package serviceaccount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"k8s.io/component-base/metrics"
|
||||||
|
"k8s.io/component-base/metrics/legacyregistry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const kubeServiceAccountSubsystem = "serviceaccount"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// LegacyTokensTotal is the number of legacy tokens used against apiserver.
|
||||||
|
legacyTokensTotal = metrics.NewCounter(
|
||||||
|
&metrics.CounterOpts{
|
||||||
|
Subsystem: kubeServiceAccountSubsystem,
|
||||||
|
Name: "legacy_tokens_total",
|
||||||
|
Help: "Cumulative legacy service account tokens used",
|
||||||
|
StabilityLevel: metrics.ALPHA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaleTokensTotal is the number of stale projected tokens not refreshed on
|
||||||
|
// client side.
|
||||||
|
staleTokensTotal = metrics.NewCounter(
|
||||||
|
&metrics.CounterOpts{
|
||||||
|
Subsystem: kubeServiceAccountSubsystem,
|
||||||
|
Name: "stale_tokens_total",
|
||||||
|
Help: "Cumulative stale projected service account tokens used",
|
||||||
|
StabilityLevel: metrics.ALPHA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidTokensTotal is the number of valid projected tokens used.
|
||||||
|
validTokensTotal = metrics.NewCounter(
|
||||||
|
&metrics.CounterOpts{
|
||||||
|
Subsystem: kubeServiceAccountSubsystem,
|
||||||
|
Name: "valid_tokens_total",
|
||||||
|
Help: "Cumulative valid projected service account tokens used",
|
||||||
|
StabilityLevel: metrics.ALPHA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
legacyregistry.MustRegister(legacyTokensTotal)
|
||||||
|
legacyregistry.MustRegister(staleTokensTotal)
|
||||||
|
legacyregistry.MustRegister(validTokensTotal)
|
||||||
|
}
|
@ -530,7 +530,7 @@ func (s *Plugin) createVolume(tokenVolumeName, secretName string) api.Volume {
|
|||||||
{
|
{
|
||||||
ServiceAccountToken: &api.ServiceAccountTokenProjection{
|
ServiceAccountToken: &api.ServiceAccountTokenProjection{
|
||||||
Path: "token",
|
Path: "token",
|
||||||
ExpirationSeconds: 60 * 60,
|
ExpirationSeconds: serviceaccount.WarnOnlyBoundTokenExpirationSeconds,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -252,7 +252,7 @@ func TestAssignsDefaultServiceAccountAndBoundTokenWithNoSecretTokens(t *testing.
|
|||||||
VolumeSource: api.VolumeSource{
|
VolumeSource: api.VolumeSource{
|
||||||
Projected: &api.ProjectedVolumeSource{
|
Projected: &api.ProjectedVolumeSource{
|
||||||
Sources: []api.VolumeProjection{
|
Sources: []api.VolumeProjection{
|
||||||
{ServiceAccountToken: &api.ServiceAccountTokenProjection{ExpirationSeconds: 3600, Path: "token"}},
|
{ServiceAccountToken: &api.ServiceAccountTokenProjection{ExpirationSeconds: 3607, Path: "token"}},
|
||||||
{ConfigMap: &api.ConfigMapProjection{LocalObjectReference: api.LocalObjectReference{Name: "kube-root-ca.crt"}, Items: []api.KeyToPath{{Key: "ca.crt", Path: "ca.crt"}}}},
|
{ConfigMap: &api.ConfigMapProjection{LocalObjectReference: api.LocalObjectReference{Name: "kube-root-ca.crt"}, Items: []api.KeyToPath{{Key: "ca.crt", Path: "ca.crt"}}}},
|
||||||
{DownwardAPI: &api.DownwardAPIProjection{Items: []api.DownwardAPIVolumeFile{{Path: "namespace", FieldRef: &api.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}},
|
{DownwardAPI: &api.DownwardAPIProjection{Items: []api.DownwardAPIVolumeFile{{Path: "namespace", FieldRef: &api.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}},
|
||||||
},
|
},
|
||||||
|
@ -27,6 +27,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -77,7 +78,7 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
const iss = "https://foo.bar.example.com"
|
const iss = "https://foo.bar.example.com"
|
||||||
aud := authenticator.Audiences{"api"}
|
aud := authenticator.Audiences{"api"}
|
||||||
|
|
||||||
maxExpirationSeconds := int64(60 * 60)
|
maxExpirationSeconds := int64(60 * 60 * 2)
|
||||||
maxExpirationDuration, err := time.ParseDuration(fmt.Sprintf("%ds", maxExpirationSeconds))
|
maxExpirationDuration, err := time.ParseDuration(fmt.Sprintf("%ds", maxExpirationSeconds))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err: %v", err)
|
t.Fatalf("err: %v", err)
|
||||||
@ -116,6 +117,7 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
masterConfig.ExtraConfig.ServiceAccountIssuer = tokenGenerator
|
masterConfig.ExtraConfig.ServiceAccountIssuer = tokenGenerator
|
||||||
masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration
|
masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration
|
||||||
masterConfig.GenericConfig.Authentication.APIAudiences = aud
|
masterConfig.GenericConfig.Authentication.APIAudiences = aud
|
||||||
|
masterConfig.ExtraConfig.ExtendExpiration = true
|
||||||
|
|
||||||
masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss
|
masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss
|
||||||
masterConfig.ExtraConfig.ServiceAccountJWKSURI = ""
|
masterConfig.ExtraConfig.ServiceAccountJWKSURI = ""
|
||||||
@ -373,7 +375,7 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
coresa := core.ServiceAccount{
|
coresa := core.ServiceAccount{
|
||||||
ObjectMeta: sa.ObjectMeta,
|
ObjectMeta: sa.ObjectMeta,
|
||||||
}
|
}
|
||||||
_, pc := serviceaccount.Claims(coresa, nil, nil, 0, nil)
|
_, pc := serviceaccount.Claims(coresa, nil, nil, 0, 0, nil)
|
||||||
tok, err := masterConfig.ExtraConfig.ServiceAccountIssuer.GenerateToken(sc, pc)
|
tok, err := masterConfig.ExtraConfig.ServiceAccountIssuer.GenerateToken(sc, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err signing expired token: %v", err)
|
t.Fatalf("err signing expired token: %v", err)
|
||||||
@ -383,6 +385,60 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
doTokenReview(t, cs, treq, true)
|
doTokenReview(t, cs, treq, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("expiration extended token", func(t *testing.T) {
|
||||||
|
var requestExp int64 = 60*60 + 7
|
||||||
|
treq := &authenticationv1.TokenRequest{
|
||||||
|
Spec: authenticationv1.TokenRequestSpec{
|
||||||
|
Audiences: []string{"api"},
|
||||||
|
ExpirationSeconds: &requestExp,
|
||||||
|
BoundObjectRef: &authenticationv1.BoundObjectReference{
|
||||||
|
Kind: "Pod",
|
||||||
|
APIVersion: "v1",
|
||||||
|
Name: pod.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sa, del := createDeleteSvcAcct(t, cs, sa)
|
||||||
|
defer del()
|
||||||
|
pod, delPod := createDeletePod(t, cs, pod)
|
||||||
|
defer delPod()
|
||||||
|
treq.Spec.BoundObjectRef.UID = pod.UID
|
||||||
|
|
||||||
|
treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doTokenReview(t, cs, treq, false)
|
||||||
|
|
||||||
|
// Give some tolerance to avoid flakiness since we are using real time.
|
||||||
|
var leeway int64 = 2
|
||||||
|
actualExpiry := jwt.NewNumericDate(time.Now().Add(time.Duration(24*365) * time.Hour))
|
||||||
|
assumedExpiry := jwt.NewNumericDate(time.Now().Add(time.Duration(requestExp) * time.Second))
|
||||||
|
exp, err := strconv.ParseInt(getSubObject(t, getPayload(t, treq.Status.Token), "exp"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error parsing exp: %v", err)
|
||||||
|
}
|
||||||
|
warnafter, err := strconv.ParseInt(getSubObject(t, getPayload(t, treq.Status.Token), "kubernetes.io", "warnafter"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error parsing warnafter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exp < int64(actualExpiry)-leeway || exp > int64(actualExpiry)+leeway {
|
||||||
|
t.Errorf("unexpected token exp %d, should within range of %d +- %d seconds", exp, actualExpiry, leeway)
|
||||||
|
}
|
||||||
|
if warnafter < int64(assumedExpiry)-leeway || warnafter > int64(assumedExpiry)+leeway {
|
||||||
|
t.Errorf("unexpected token warnafter %d, should within range of %d +- %d seconds", warnafter, assumedExpiry, leeway)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkExpiration(t, treq, requestExp)
|
||||||
|
expStatus := treq.Status.ExpirationTimestamp.Time.Unix()
|
||||||
|
if expStatus < int64(assumedExpiry)-leeway || warnafter > int64(assumedExpiry)+leeway {
|
||||||
|
t.Errorf("unexpected expiration returned in tokenrequest status %d, should within range of %d +- %d seconds", expStatus, assumedExpiry, leeway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("a token without an api audience is invalid", func(t *testing.T) {
|
t.Run("a token without an api audience is invalid", func(t *testing.T) {
|
||||||
treq := &authenticationv1.TokenRequest{
|
treq := &authenticationv1.TokenRequest{
|
||||||
Spec: authenticationv1.TokenRequestSpec{
|
Spec: authenticationv1.TokenRequestSpec{
|
||||||
|
Loading…
Reference in New Issue
Block a user