go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tokenserver/appengine/impl/serviceaccounts/rpc_mint_service_account_token_test.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package serviceaccounts
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"net"
    21  	"testing"
    22  	"time"
    23  
    24  	"go.opentelemetry.io/otel/trace"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/grpc/status"
    27  	"google.golang.org/protobuf/types/known/timestamppb"
    28  
    29  	"go.chromium.org/luci/appengine/gaetesting"
    30  	"go.chromium.org/luci/auth/identity"
    31  	"go.chromium.org/luci/common/clock"
    32  	"go.chromium.org/luci/common/clock/testclock"
    33  	"go.chromium.org/luci/common/logging"
    34  	"go.chromium.org/luci/server/auth"
    35  	"go.chromium.org/luci/server/auth/authtest"
    36  	"go.chromium.org/luci/server/auth/realms"
    37  	"go.chromium.org/luci/server/auth/signing"
    38  	"go.chromium.org/luci/server/auth/signing/signingtest"
    39  
    40  	"go.chromium.org/luci/tokenserver/api/minter/v1"
    41  	"go.chromium.org/luci/tokenserver/appengine/impl/utils/projectidentity"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  
    45  	. "go.chromium.org/luci/common/testing/assertions"
    46  )
    47  
    48  const (
    49  	testAppID         = "unit-tests"
    50  	testAppVer        = "mocked-ver"
    51  	testServiceVer    = testAppID + "/" + testAppVer
    52  	testCaller        = identity.Identity("project:something")
    53  	testPeer          = identity.Identity("user:service@example.com")
    54  	testPeerIP        = "127.10.10.10"
    55  	testAccount       = identity.Identity("user:sa@example.com")
    56  	testProject       = "test-proj"
    57  	testRealm         = testProject + ":test-realm"
    58  	testProjectScoped = "test-proj-scoped"
    59  	testRealmScoped   = testProjectScoped + ":test-realm"
    60  	testInternalRealm = realms.InternalProject + ":test-realm"
    61  )
    62  
    63  var testRequestID = trace.TraceID{1, 2, 3, 4, 5}
    64  
    65  func TestMintServiceAccountToken(t *testing.T) {
    66  	ctx := gaetesting.TestingContext()
    67  	ctx = trace.ContextWithSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{
    68  		TraceID: testRequestID,
    69  	}))
    70  	ctx = logging.SetLevel(ctx, logging.Debug) // coverage for logRequest
    71  	ctx, _ = testclock.UseTime(ctx, testclock.TestRecentTimeUTC)
    72  
    73  	// Will be changed on per test case basis.
    74  	ctx = auth.WithState(ctx, &authtest.FakeState{
    75  		Identity:             testCaller,
    76  		PeerIdentityOverride: testPeer,
    77  		PeerIPOverride:       net.ParseIP(testPeerIP),
    78  		FakeDB: authtest.NewFakeDB(
    79  			authtest.MockPermission(testCaller, testRealm, permMintToken),
    80  			authtest.MockPermission(testAccount, testRealm, permExistInRealm),
    81  			authtest.MockPermission(testCaller, testRealmScoped, permMintToken),
    82  			authtest.MockPermission(testAccount, testRealmScoped, permExistInRealm),
    83  			authtest.MockPermission(testCaller, testInternalRealm, permMintToken),
    84  			authtest.MockPermission(testAccount, testInternalRealm, permExistInRealm),
    85  		),
    86  	})
    87  	mapping, _ := loadMapping(ctx, fmt.Sprintf(`
    88  		mapping {
    89  			project: "%s"
    90  			service_account: "%s"
    91  		}
    92  
    93  		use_project_scoped_account: "%s"
    94  		use_project_scoped_account: "%s"
    95  	`, testProject, testAccount.Email(), testProjectScoped, realms.InternalProject))
    96  
    97  	_, err := projectidentity.ProjectIdentities(ctx).Create(ctx, &projectidentity.ProjectIdentity{
    98  		Project: testProjectScoped,
    99  		Email:   "scoped@example.com",
   100  	})
   101  	if err != nil {
   102  		panic(err)
   103  	}
   104  
   105  	// Records last received arguments of Mint*Token.
   106  	var lastAccessTokenCall auth.MintAccessTokenParams
   107  	var lastIDTokenCall auth.MintIDTokenParams
   108  
   109  	// Records last call to LogToken.
   110  	var loggedTok *MintedTokenInfo
   111  
   112  	rpc := MintServiceAccountTokenRPC{
   113  		Signer: signingtest.NewSigner(&signing.ServiceInfo{
   114  			AppID:      testAppID,
   115  			AppVersion: testAppVer,
   116  		}),
   117  		Mapping: func(context.Context) (*Mapping, error) {
   118  			return mapping, nil
   119  		},
   120  		ProjectIdentities: projectidentity.ProjectIdentities,
   121  		MintAccessToken: func(ctx context.Context, params auth.MintAccessTokenParams) (*auth.Token, error) {
   122  			lastAccessTokenCall = params
   123  			return &auth.Token{
   124  				Token:  "access-token-for-" + params.ServiceAccount,
   125  				Expiry: clock.Now(ctx).Add(time.Hour).Truncate(time.Second),
   126  			}, nil
   127  		},
   128  		MintIDToken: func(ctx context.Context, params auth.MintIDTokenParams) (*auth.Token, error) {
   129  			lastIDTokenCall = params
   130  			return &auth.Token{
   131  				Token:  "id-token-for-" + params.ServiceAccount,
   132  				Expiry: clock.Now(ctx).Add(time.Hour).Truncate(time.Second),
   133  			}, nil
   134  		},
   135  		LogToken: func(ctx context.Context, info *MintedTokenInfo) error {
   136  			loggedTok = info
   137  			return nil
   138  		},
   139  	}
   140  
   141  	Convey("Happy path", t, func() {
   142  		Convey("Access token", func() {
   143  			req := &minter.MintServiceAccountTokenRequest{
   144  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   145  				ServiceAccount: testAccount.Email(),
   146  				Realm:          testRealm,
   147  				OauthScope:     []string{"scope-z", "scope-a", "scope-a"},
   148  				AuditTags:      []string{"k:v1", "k:v2"},
   149  			}
   150  			resp, err := rpc.MintServiceAccountToken(ctx, req)
   151  			So(err, ShouldBeNil)
   152  			So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
   153  				Token:          "access-token-for-" + testAccount.Email(),
   154  				Expiry:         timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
   155  				ServiceVersion: testServiceVer,
   156  			})
   157  
   158  			So(lastAccessTokenCall, ShouldResemble, auth.MintAccessTokenParams{
   159  				ServiceAccount: testAccount.Email(),
   160  				Scopes:         []string{"scope-a", "scope-z"},
   161  				MinTTL:         5 * time.Minute,
   162  			})
   163  
   164  			// We can't use ShouldResemble here because it contains proto messages.
   165  			// Compare field-by-field instead.
   166  			So(loggedTok.Request, ShouldEqual, req)
   167  			So(loggedTok.Response, ShouldEqual, resp)
   168  			So(loggedTok.RequestedAt, ShouldResemble, clock.Now(ctx))
   169  			So(loggedTok.OAuthScopes, ShouldResemble, []string{"scope-a", "scope-z"})
   170  			So(loggedTok.RequestIdentity, ShouldEqual, testCaller)
   171  			So(loggedTok.PeerIdentity, ShouldEqual, testPeer)
   172  			So(loggedTok.ConfigRev, ShouldEqual, "fake-revision")
   173  			So(loggedTok.PeerIP.String(), ShouldEqual, testPeerIP)
   174  			So(loggedTok.RequestID, ShouldEqual, testRequestID.String())
   175  			So(loggedTok.AuthDBRev, ShouldEqual, 0) // FakeDB is always 0
   176  		})
   177  
   178  		Convey("ID token", func() {
   179  			req := &minter.MintServiceAccountTokenRequest{
   180  				TokenKind:       minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
   181  				ServiceAccount:  testAccount.Email(),
   182  				Realm:           testRealm,
   183  				IdTokenAudience: "test-audience",
   184  				AuditTags:       []string{"k:v1", "k:v2"},
   185  			}
   186  			resp, err := rpc.MintServiceAccountToken(ctx, req)
   187  			So(err, ShouldBeNil)
   188  			So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
   189  				Token:          "id-token-for-" + testAccount.Email(),
   190  				Expiry:         timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
   191  				ServiceVersion: testServiceVer,
   192  			})
   193  
   194  			So(lastIDTokenCall, ShouldResemble, auth.MintIDTokenParams{
   195  				ServiceAccount: testAccount.Email(),
   196  				Audience:       "test-audience",
   197  				MinTTL:         5 * time.Minute,
   198  			})
   199  		})
   200  
   201  		Convey("Delegation through project-scoped account", func() {
   202  			req := &minter.MintServiceAccountTokenRequest{
   203  				TokenKind:       minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
   204  				ServiceAccount:  testAccount.Email(),
   205  				Realm:           testRealmScoped,
   206  				IdTokenAudience: "test-audience",
   207  				AuditTags:       []string{"k:v1", "k:v2"},
   208  			}
   209  			resp, err := rpc.MintServiceAccountToken(ctx, req)
   210  			So(err, ShouldBeNil)
   211  			So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
   212  				Token:          "id-token-for-" + testAccount.Email(),
   213  				Expiry:         timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
   214  				ServiceVersion: testServiceVer,
   215  			})
   216  
   217  			So(lastIDTokenCall, ShouldResemble, auth.MintIDTokenParams{
   218  				ServiceAccount: testAccount.Email(),
   219  				Audience:       "test-audience",
   220  				Delegates:      []string{"scoped@example.com"},
   221  				MinTTL:         5 * time.Minute,
   222  			})
   223  		})
   224  
   225  		Convey("Delegation through project-scoped account in @internal", func() {
   226  			req := &minter.MintServiceAccountTokenRequest{
   227  				TokenKind:       minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
   228  				ServiceAccount:  testAccount.Email(),
   229  				Realm:           testInternalRealm,
   230  				IdTokenAudience: "test-audience",
   231  				AuditTags:       []string{"k:v1", "k:v2"},
   232  			}
   233  			resp, err := rpc.MintServiceAccountToken(ctx, req)
   234  			So(err, ShouldBeNil)
   235  			So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
   236  				Token:          "id-token-for-" + testAccount.Email(),
   237  				Expiry:         timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
   238  				ServiceVersion: testServiceVer,
   239  			})
   240  
   241  			So(lastIDTokenCall, ShouldResemble, auth.MintIDTokenParams{
   242  				ServiceAccount: testAccount.Email(),
   243  				Audience:       "test-audience",
   244  				MinTTL:         5 * time.Minute,
   245  			})
   246  		})
   247  	})
   248  
   249  	Convey("Request validation", t, func() {
   250  		call := func(req *minter.MintServiceAccountTokenRequest) error {
   251  			resp, err := rpc.MintServiceAccountToken(ctx, req)
   252  			So(err, ShouldNotBeNil)
   253  			So(resp, ShouldBeNil)
   254  			So(status.Code(err), ShouldEqual, codes.InvalidArgument)
   255  			return err
   256  		}
   257  
   258  		Convey("Bad token kind", func() {
   259  			So(call(&minter.MintServiceAccountTokenRequest{
   260  				TokenKind: 0,
   261  			}), ShouldErrLike, "token_kind is required")
   262  
   263  			So(call(&minter.MintServiceAccountTokenRequest{
   264  				TokenKind: 1234,
   265  			}), ShouldErrLike, "unrecognized token_kind")
   266  		})
   267  
   268  		Convey("Bad service account", func() {
   269  			So(call(&minter.MintServiceAccountTokenRequest{
   270  				TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   271  			}), ShouldErrLike, "service_account is required")
   272  
   273  			So(call(&minter.MintServiceAccountTokenRequest{
   274  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   275  				ServiceAccount: "bad email",
   276  			}), ShouldErrLike, "bad service_account")
   277  		})
   278  
   279  		Convey("Bad realm", func() {
   280  			So(call(&minter.MintServiceAccountTokenRequest{
   281  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   282  				ServiceAccount: testAccount.Email(),
   283  			}), ShouldErrLike, "realm is required")
   284  
   285  			So(call(&minter.MintServiceAccountTokenRequest{
   286  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   287  				ServiceAccount: testAccount.Email(),
   288  				Realm:          "not-global",
   289  			}), ShouldErrLike, "bad realm")
   290  		})
   291  
   292  		Convey("Bad access token request", func() {
   293  			So(call(&minter.MintServiceAccountTokenRequest{
   294  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   295  				ServiceAccount: testAccount.Email(),
   296  				Realm:          testRealm,
   297  			}), ShouldErrLike, "oauth_scope is required")
   298  
   299  			So(call(&minter.MintServiceAccountTokenRequest{
   300  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   301  				ServiceAccount: testAccount.Email(),
   302  				Realm:          testRealm,
   303  				OauthScope:     []string{"zzz", ""},
   304  			}), ShouldErrLike, "bad oauth_scope: got an empty string")
   305  
   306  			So(call(&minter.MintServiceAccountTokenRequest{
   307  				TokenKind:       minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   308  				ServiceAccount:  testAccount.Email(),
   309  				Realm:           testRealm,
   310  				OauthScope:      []string{"zzz"},
   311  				IdTokenAudience: "aud",
   312  			}), ShouldErrLike, "id_token_audience must not be used")
   313  		})
   314  
   315  		Convey("Bad ID token request", func() {
   316  			So(call(&minter.MintServiceAccountTokenRequest{
   317  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
   318  				ServiceAccount: testAccount.Email(),
   319  				Realm:          testRealm,
   320  			}), ShouldErrLike, "id_token_audience is required")
   321  
   322  			So(call(&minter.MintServiceAccountTokenRequest{
   323  				TokenKind:       minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
   324  				ServiceAccount:  testAccount.Email(),
   325  				Realm:           testRealm,
   326  				OauthScope:      []string{"zzz"},
   327  				IdTokenAudience: "aud",
   328  			}), ShouldErrLike, "oauth_scope must not be used")
   329  		})
   330  
   331  		Convey("Bad min_validity_duration", func() {
   332  			So(call(&minter.MintServiceAccountTokenRequest{
   333  				TokenKind:           minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   334  				ServiceAccount:      testAccount.Email(),
   335  				Realm:               testRealm,
   336  				OauthScope:          []string{"zzz"},
   337  				MinValidityDuration: -1,
   338  			}), ShouldErrLike, "must be positive")
   339  
   340  			So(call(&minter.MintServiceAccountTokenRequest{
   341  				TokenKind:           minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   342  				ServiceAccount:      testAccount.Email(),
   343  				Realm:               testRealm,
   344  				OauthScope:          []string{"zzz"},
   345  				MinValidityDuration: 3601,
   346  			}), ShouldErrLike, "must be not greater than 3600")
   347  		})
   348  
   349  		Convey("Bad audit_tags", func() {
   350  			So(call(&minter.MintServiceAccountTokenRequest{
   351  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   352  				ServiceAccount: testAccount.Email(),
   353  				Realm:          testRealm,
   354  				OauthScope:     []string{"zzz"},
   355  				AuditTags:      []string{"not kv"},
   356  			}), ShouldErrLike, "bad audit_tags")
   357  		})
   358  
   359  		Convey("Missing project-scoped identity", func() {
   360  			So(projectidentity.ProjectIdentities(ctx).Delete(ctx, &projectidentity.ProjectIdentity{
   361  				Project: testProjectScoped,
   362  			}), ShouldBeNil)
   363  			So(call(&minter.MintServiceAccountTokenRequest{
   364  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   365  				ServiceAccount: testAccount.Email(),
   366  				Realm:          testRealmScoped,
   367  				OauthScope:     []string{"zzz"},
   368  			}), ShouldErrLike, "project-scoped account for project test-proj-scoped is not configured")
   369  		})
   370  	})
   371  
   372  	Convey("ACL checks", t, func() {
   373  		call := func(ctx context.Context) error {
   374  			resp, err := rpc.MintServiceAccountToken(ctx, &minter.MintServiceAccountTokenRequest{
   375  				TokenKind:      minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
   376  				ServiceAccount: testAccount.Email(),
   377  				Realm:          testRealm,
   378  				OauthScope:     []string{"scope"},
   379  			})
   380  			So(err, ShouldNotBeNil)
   381  			So(resp, ShouldBeNil)
   382  			So(status.Code(err), ShouldEqual, codes.PermissionDenied)
   383  			return err
   384  		}
   385  
   386  		Convey("No mint permission", func() {
   387  			ctx := auth.WithState(ctx, &authtest.FakeState{
   388  				Identity: testCaller,
   389  				FakeDB: authtest.NewFakeDB(
   390  					// Note: no perm permMintToken here.
   391  					authtest.MockPermission(testAccount, testRealm, permExistInRealm),
   392  				),
   393  			})
   394  			So(call(ctx), ShouldErrLike, "unknown realm or no permission to use service accounts there")
   395  		})
   396  
   397  		Convey("No existInRealm permission", func() {
   398  			ctx := auth.WithState(ctx, &authtest.FakeState{
   399  				Identity: testCaller,
   400  				FakeDB: authtest.NewFakeDB(
   401  					authtest.MockPermission(testCaller, testRealm, permMintToken),
   402  					// Note: no perm permExistInRealm here.
   403  				),
   404  			})
   405  			So(call(ctx), ShouldErrLike, "is not in the realm")
   406  		})
   407  
   408  		Convey("Not in the mapping", func() {
   409  			mapping, _ = loadMapping(ctx, fmt.Sprintf(`mapping {}`))
   410  			So(call(ctx), ShouldErrLike, "is not allowed to be used")
   411  		})
   412  	})
   413  }