Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for using serviceaccount annotations #1

Merged
merged 3 commits into from
Feb 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type kubeAuthBackend struct {
// review. Mocks should only be used in tests.
reviewFactory tokenReviewFactory

// serviceAccountReaderFactory is used to read service account annotations
serviceAccountReaderFactory serviceAccountReaderFactory

// localSATokenReader caches the service account token in memory.
// It periodically reloads the token to support token rotation/renewal.
// Local token is used when running in a pod with following configuration
Expand Down Expand Up @@ -105,6 +108,7 @@ func Backend() *kubeAuthBackend {

// Set the review factory to default to calling into the kubernetes API.
b.reviewFactory = tokenReviewAPIFactory
b.serviceAccountReaderFactory = serviceAccountAPIFactory

return b
}
Expand Down
30 changes: 22 additions & 8 deletions path_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ then this plugin will use kubernetes.io/serviceaccount as the default issuer.
Name: "Disable use of local CA and service account JWT",
},
},
"enable_custom_metadata_from_annotations": {
Type: framework.TypeBool,
Description: "Enable reading and parsing annotations from service account for policy templating",
Default: false,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Enable reading and parsing service account annotations",
},
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathConfigWrite,
Expand Down Expand Up @@ -110,6 +118,7 @@ func (b *kubeAuthBackend) pathConfigRead(ctx context.Context, req *logical.Reque
"issuer": config.Issuer,
"disable_iss_validation": config.DisableISSValidation,
"disable_local_ca_jwt": config.DisableLocalCAJwt,
"enable_custom_metadata_from_annotations": config.EnableCustomMetadataFromAnnotations,
},
}

Expand All @@ -130,6 +139,7 @@ func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Requ
issuer := data.Get("issuer").(string)
disableIssValidation := data.Get("disable_iss_validation").(bool)
tokenReviewer := data.Get("token_reviewer_jwt").(string)
enableCustomMetadata := data.Get("enable_custom_metadata_from_annotations").(bool)

if tokenReviewer != "" {
// Validate it's a JWT
Expand All @@ -144,14 +154,15 @@ func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Requ
}

config := &kubeConfig{
PublicKeys: make([]interface{}, len(pemList)),
PEMKeys: pemList,
Host: host,
CACert: caCert,
TokenReviewerJWT: tokenReviewer,
Issuer: issuer,
DisableISSValidation: disableIssValidation,
DisableLocalCAJwt: disableLocalJWT,
PublicKeys: make([]interface{}, len(pemList)),
PEMKeys: pemList,
Host: host,
CACert: caCert,
TokenReviewerJWT: tokenReviewer,
Issuer: issuer,
DisableISSValidation: disableIssValidation,
DisableLocalCAJwt: disableLocalJWT,
EnableCustomMetadataFromAnnotations: enableCustomMetadata,
}

var err error
Expand Down Expand Up @@ -195,6 +206,9 @@ type kubeConfig struct {
// the local CA cert and service account jwt when running in a Kubernetes
// pod
DisableLocalCAJwt bool `json:"disable_local_ca_jwt"`
// EnableCustomMetadataFromAnnotations is an optional parameter which will cause
// us to read the kubernetes ServiceAccount's annotations as metadata of auth alias.
EnableCustomMetadataFromAnnotations bool `json:"enable_custom_metadata_from_annotations"`
}

// PasrsePublicKeyPEM is used to parse RSA and ECDSA public keys from PEMs
Expand Down
1 change: 1 addition & 0 deletions path_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func TestConfig_Read(t *testing.T) {
"issuer": "",
"disable_iss_validation": false,
"disable_local_ca_jwt": false,
"enable_custom_metadata_from_annotations": false,
}

req := &logical.Request{
Expand Down
36 changes: 33 additions & 3 deletions path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d
return nil, err
}

serviceAccount, err := b.parseAndValidateJWT(jwtStr, role, config)
serviceAccount, err := b.parseAndValidateJWT(ctx, jwtStr, role, config)
if err != nil {
return nil, err
}
Expand All @@ -113,6 +113,7 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d
if err != nil {
return nil, err
}

auth := &logical.Auth{
Alias: &logical.Alias{
Name: aliasName,
Expand All @@ -136,6 +137,21 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d
DisplayName: fmt.Sprintf("%s-%s", serviceAccount.namespace(), serviceAccount.name()),
}

if serviceAccount.Annotations != nil {
for key, value := range serviceAccount.Annotations {
// Ensure it's not possible to overwrite service_account_* information
if _, exists := auth.Alias.Metadata[key]; exists {
continue
}
if _, exists := auth.Metadata[key]; exists {
continue
}

auth.Alias.Metadata[key] = value
auth.Metadata[key] = value
}
}

role.PopulateTokenAuth(auth)

return &logical.Response{
Expand Down Expand Up @@ -198,7 +214,7 @@ func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Reque

// validation of the JWT against the provided role ensures alias look ahead requests
// are authentic.
sa, err := b.parseAndValidateJWT(jwtStr, role, config)
sa, err := b.parseAndValidateJWT(ctx, jwtStr, role, config)
if err != nil {
return nil, err
}
Expand All @@ -218,7 +234,7 @@ func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Reque
}

// parseAndValidateJWT is used to parse, validate and lookup the JWT token.
func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) {
func (b *kubeAuthBackend) parseAndValidateJWT(ctx context.Context, jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) {
// Parse into JWT
parsedJWT, err := jws.ParseJWT([]byte(jwtStr))
if err != nil {
Expand Down Expand Up @@ -272,6 +288,15 @@ func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEn
return nil, err
}

if config.EnableCustomMetadataFromAnnotations {
annotations, err := b.serviceAccountReaderFactory(config).ReadAnnotations(ctx, sa.Name, sa.Namespace)
if err != nil {
return nil, fmt.Errorf("failed to read serviceaccount annotations: %v", err)
}

sa.Annotations = annotations
}

// If we don't have any public keys to verify, return the sa and end early.
if len(config.PublicKeys) == 0 {
return sa, nil
Expand Down Expand Up @@ -357,6 +382,11 @@ type serviceAccount struct {
Kubernetes *projectedServiceToken `mapstructure:"kubernetes.io"`
Expiration int64 `mapstructure:"exp"`
IssuedAt int64 `mapstructure:"iat"`

// Kubernetes annotations for the service account with the `allowedAnnotationPrefix`,
// which will be loaded here if `config.EnableCustomMetadataFromAnnotations` is
// enabled.
Annotations map[string]string
}

// uid returns the UID for the service account, preferring the projected service
Expand Down
119 changes: 108 additions & 11 deletions path_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import (
)

var (
testNamespace = "default"
testName = "vault-auth"
testUID = "d77f89bc-9055-11e7-a068-0800276d99bf"
testMockFactory = mockTokenReviewFactory(testName, testNamespace, testUID)
testNamespace = "default"
testName = "vault-auth"
testUID = "d77f89bc-9055-11e7-a068-0800276d99bf"
testMockTokenReviewFactory = mockTokenReviewFactory(testName, testNamespace, testUID)
testMockServiceAccountReaderFactory = mockServiceAccountReaderFactory(map[string]string{"service_role": "authz"})

testGlobbedNamespace = "def*"
testGlobbedName = "vault-*"
Expand All @@ -32,10 +33,11 @@ var (
)

type testBackendConfig struct {
pems []string
saName string
saNamespace string
aliasNameSource string
pems []string
saName string
saNamespace string
aliasNameSource string
customMetadataFromAnnotations bool
}

func defaultTestBackendConfig() *testBackendConfig {
Expand All @@ -55,6 +57,7 @@ func setupBackend(t *testing.T, config *testBackendConfig) (logical.Backend, log
"pem_keys": config.pems,
"kubernetes_host": "host",
"kubernetes_ca_cert": testCACert,
"enable_custom_metadata_from_annotations": config.customMetadataFromAnnotations,
}

req := &logical.Request{
Expand Down Expand Up @@ -92,7 +95,8 @@ func setupBackend(t *testing.T, config *testBackendConfig) (logical.Backend, log
t.Fatalf("err:%s resp:%#v\n", err, resp)
}

b.(*kubeAuthBackend).reviewFactory = testMockFactory
b.(*kubeAuthBackend).reviewFactory = testMockTokenReviewFactory
b.(*kubeAuthBackend).serviceAccountReaderFactory = testMockServiceAccountReaderFactory
return b, storage
}

Expand Down Expand Up @@ -586,6 +590,78 @@ func TestLoginSvcAcctAndNamespaceSplats(t *testing.T) {
}
}

func TestLoginWithServiceAccountAnnotations(t *testing.T) {
config := defaultTestBackendConfig()
config.customMetadataFromAnnotations = true
b, storage := setupBackend(t, config)

data := map[string]interface{}{
"role": "plugin-test",
"jwt": jwtData,
}

req := &logical.Request{
Operation: logical.UpdateOperation,
Path: "login",
Storage: storage,
Data: data,
Connection: &logical.Connection{
RemoteAddr: "127.0.0.1",
},
}

resp, err := b.HandleRequest(context.Background(), req)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("err:%s resp:%#v\n", err, resp)
}

if role := resp.Auth.Metadata["service_role"]; role != "authz" {
t.Fatalf("expected service_role in Auth.Metadata, got: %s", role)
}

if role := resp.Auth.Alias.Metadata["service_role"]; role != "authz" {
t.Fatalf("expected service_role in Auth.Alias.Metadata, got: %s", role)
}

// test that we can't overwrite service_account_name and other properties with annotations

b.(*kubeAuthBackend).serviceAccountReaderFactory = mockServiceAccountReaderFactory(map[string]string{
"service_account_name": "overwritten",
"service_account_uid": "overwritten",
"service_account_namespace": "overwritten",
"service_account_secret_name": "overwritten",
})
resp, err = b.HandleRequest(context.Background(), req)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("err:%s resp:%#v\n", err, resp)
}

if val := resp.Auth.Metadata["service_account_name"]; val != "vault-auth" {
t.Fatalf("unexpected service_account_name: %s", val)
}
if val := resp.Auth.Alias.Metadata["service_account_name"]; val != "vault-auth" {
t.Fatalf("unexpected service_account_name: %s", val)
}
if val := resp.Auth.Metadata["service_account_namespace"]; val != "default" {
t.Fatalf("unexpected service_account_namespace: %s", val)
}
if val := resp.Auth.Alias.Metadata["service_account_namespace"]; val != "default" {
t.Fatalf("unexpected service_account_namespace: %s", val)
}
if val := resp.Auth.Metadata["service_account_uid"]; val != "d77f89bc-9055-11e7-a068-0800276d99bf" {
t.Fatalf("unexpected service_account_uid: %s", val)
}
if val := resp.Auth.Alias.Metadata["service_account_uid"]; val != "d77f89bc-9055-11e7-a068-0800276d99bf" {
t.Fatalf("unexpected service_account_uid: %s", val)
}
if val := resp.Auth.Metadata["service_account_secret_name"]; val != "vault-auth-token-t5pcn" {
t.Fatalf("unexpected service_account_secret_name: %s", val)
}
if val := resp.Auth.Alias.Metadata["service_account_secret_name"]; val != "vault-auth-token-t5pcn" {
t.Fatalf("unexpected service_account_secret_name: %s", val)
}
}

func TestAliasLookAhead(t *testing.T) {
testCases := map[string]struct {
role string
Expand Down Expand Up @@ -904,12 +980,12 @@ func TestLoginProjectedToken(t *testing.T) {
"normal": {
role: "plugin-test",
jwt: jwtData,
tokenReview: testMockFactory,
tokenReview: testMockTokenReviewFactory,
},
"fail": {
role: "plugin-test-x",
jwt: jwtData,
tokenReview: testMockFactory,
tokenReview: testMockTokenReviewFactory,
e: roleNameError,
},
"projected-token": {
Expand Down Expand Up @@ -950,6 +1026,7 @@ func TestLoginProjectedToken(t *testing.T) {
}

b.(*kubeAuthBackend).reviewFactory = tc.tokenReview
b.(*kubeAuthBackend).serviceAccountReaderFactory = testMockServiceAccountReaderFactory

resp, err := b.HandleRequest(context.Background(), req)
if err != nil && tc.e == nil {
Expand Down Expand Up @@ -1009,6 +1086,26 @@ func TestAliasLookAheadProjectedToken(t *testing.T) {
}
}

type mockServiceAccountReader struct {
annotations map[string]string
}

func mockServiceAccountReaderFactory(annotations map[string]string) serviceAccountReaderFactory {
return func(config *kubeConfig) serviceAccountReader {
return &mockServiceAccountReader{
annotations: annotations,
}
}
}

func (s *mockServiceAccountReader) ReadAnnotations(ctx context.Context, name, namespace string) (map[string]string, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}

return s.annotations, nil
}

// jwtProjectedData is a Projected Service Account jwt with expiration set to
// 05 Nov 2030 04:19:57 (UTC)
//
Expand Down
Loading