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 }