blob: 4c3864509208aa547209adf4b031de4b0048191f [file] [log] [blame]
// Copyright 2020 The LUCI 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 serviceaccountsv2
import (
"context"
"fmt"
"net"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/appengine/gaetesting"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/clock/testclock"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/trace/tracetest"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/authtest"
"go.chromium.org/luci/server/auth/signing"
"go.chromium.org/luci/server/auth/signing/signingtest"
"go.chromium.org/luci/tokenserver/api/minter/v1"
. "github.com/smartystreets/goconvey/convey"
. "go.chromium.org/luci/common/testing/assertions"
)
const (
testAppID = "unit-tests"
testAppVer = "mocked-ver"
testServiceVer = testAppID + "/" + testAppVer
testCaller = identity.Identity("project:something")
testPeer = identity.Identity("user:service@example.com")
testPeerIP = "127.10.10.10"
testAccount = identity.Identity("user:sa@example.com")
testProject = "test-proj"
testRealm = testProject + ":test-realm"
testRequestID = "gae-request-id"
)
func init() {
tracetest.Enable()
}
func TestMintServiceAccountToken(t *testing.T) {
ctx := gaetesting.TestingContext()
ctx = tracetest.WithSpanContext(ctx, "gae-request-id")
ctx = logging.SetLevel(ctx, logging.Debug) // coverage for logRequest
ctx, _ = testclock.UseTime(ctx, testclock.TestRecentTimeUTC)
// Will be changed on per test case basis.
ctx = auth.WithState(ctx, &authtest.FakeState{
Identity: testCaller,
PeerIdentityOverride: testPeer,
PeerIPOverride: net.ParseIP(testPeerIP),
FakeDB: authtest.NewFakeDB(
authtest.MockPermission(testCaller, testRealm, permMintToken),
authtest.MockPermission(testAccount, testRealm, permExistInRealm),
),
})
mapping, _ := loadMapping(ctx, fmt.Sprintf(`mapping {
project: "%s"
service_account: "%s"
}`, testProject, testAccount.Email()))
// Records last received arguments of Mint*Token.
var lastAccessTokenCall auth.MintAccessTokenParams
var lastIDTokenCall auth.MintIDTokenParams
// Records last call to LogToken.
var loggedTok *MintedTokenInfo
rpc := MintServiceAccountTokenRPC{
Signer: signingtest.NewSigner(&signing.ServiceInfo{
AppID: testAppID,
AppVersion: testAppVer,
}),
Mapping: func(context.Context) (*Mapping, error) {
return mapping, nil
},
MintAccessToken: func(ctx context.Context, params auth.MintAccessTokenParams) (*auth.Token, error) {
lastAccessTokenCall = params
return &auth.Token{
Token: "access-token-for-" + params.ServiceAccount,
Expiry: clock.Now(ctx).Add(time.Hour).Truncate(time.Second),
}, nil
},
MintIDToken: func(ctx context.Context, params auth.MintIDTokenParams) (*auth.Token, error) {
lastIDTokenCall = params
return &auth.Token{
Token: "id-token-for-" + params.ServiceAccount,
Expiry: clock.Now(ctx).Add(time.Hour).Truncate(time.Second),
}, nil
},
LogToken: func(ctx context.Context, info *MintedTokenInfo) error {
loggedTok = info
return nil
},
}
Convey("Happy path", t, func() {
Convey("Access token", func() {
req := &minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"scope-z", "scope-a", "scope-a"},
AuditTags: []string{"k:v1", "k:v2"},
}
resp, err := rpc.MintServiceAccountToken(ctx, req)
So(err, ShouldBeNil)
So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
Token: "access-token-for-" + testAccount.Email(),
Expiry: timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
ServiceVersion: testServiceVer,
})
So(lastAccessTokenCall, ShouldResemble, auth.MintAccessTokenParams{
ServiceAccount: testAccount.Email(),
Scopes: []string{"scope-a", "scope-z"},
MinTTL: 5 * time.Minute,
})
// We can't use ShouldResemble here because it contains proto messages.
// Compare field-by-field instead.
So(loggedTok.Request, ShouldEqual, req)
So(loggedTok.Response, ShouldEqual, resp)
So(loggedTok.RequestedAt, ShouldResemble, clock.Now(ctx))
So(loggedTok.OAuthScopes, ShouldResemble, []string{"scope-a", "scope-z"})
So(loggedTok.RequestIdentity, ShouldEqual, testCaller)
So(loggedTok.PeerIdentity, ShouldEqual, testPeer)
So(loggedTok.ConfigRev, ShouldEqual, "fake-revision")
So(loggedTok.PeerIP.String(), ShouldEqual, testPeerIP)
So(loggedTok.RequestID, ShouldEqual, testRequestID)
So(loggedTok.AuthDBRev, ShouldEqual, 0) // FakeDB is always 0
})
Convey("ID token", func() {
req := &minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
IdTokenAudience: "test-audience",
AuditTags: []string{"k:v1", "k:v2"},
}
resp, err := rpc.MintServiceAccountToken(ctx, req)
So(err, ShouldBeNil)
So(resp, ShouldResembleProto, &minter.MintServiceAccountTokenResponse{
Token: "id-token-for-" + testAccount.Email(),
Expiry: timestamppb.New(testclock.TestRecentTimeUTC.Add(time.Hour).Truncate(time.Second)),
ServiceVersion: testServiceVer,
})
So(lastIDTokenCall, ShouldResemble, auth.MintIDTokenParams{
ServiceAccount: testAccount.Email(),
Audience: "test-audience",
MinTTL: 5 * time.Minute,
})
})
})
Convey("Request validation", t, func() {
call := func(req *minter.MintServiceAccountTokenRequest) error {
resp, err := rpc.MintServiceAccountToken(ctx, req)
So(err, ShouldNotBeNil)
So(resp, ShouldBeNil)
So(status.Code(err), ShouldEqual, codes.InvalidArgument)
return err
}
Convey("Bad token kind", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: 0,
}), ShouldErrLike, "token_kind is required")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: 1234,
}), ShouldErrLike, "unrecognized token_kind")
})
Convey("Bad service account", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
}), ShouldErrLike, "service_account is required")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: "bad email",
}), ShouldErrLike, "bad service_account")
})
Convey("Bad realm", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
}), ShouldErrLike, "realm is required")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: "not-global",
}), ShouldErrLike, "bad realm")
})
Convey("Bad access token request", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
}), ShouldErrLike, "oauth_scope is required")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz", ""},
}), ShouldErrLike, "bad oauth_scope: got an empty string")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz"},
IdTokenAudience: "aud",
}), ShouldErrLike, "id_token_audience must not be used")
})
Convey("Bad ID token request", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
}), ShouldErrLike, "id_token_audience is required")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz"},
IdTokenAudience: "aud",
}), ShouldErrLike, "oauth_scope must not be used")
})
Convey("Bad min_validity_duration", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz"},
MinValidityDuration: -1,
}), ShouldErrLike, "must be positive")
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz"},
MinValidityDuration: 3601,
}), ShouldErrLike, "must be not greater than 3600")
})
Convey("Bad audit_tags", func() {
So(call(&minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"zzz"},
AuditTags: []string{"not kv"},
}), ShouldErrLike, "bad audit_tags")
})
})
Convey("ACL checks", t, func() {
call := func(ctx context.Context) error {
resp, err := rpc.MintServiceAccountToken(ctx, &minter.MintServiceAccountTokenRequest{
TokenKind: minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN,
ServiceAccount: testAccount.Email(),
Realm: testRealm,
OauthScope: []string{"scope"},
})
So(err, ShouldNotBeNil)
So(resp, ShouldBeNil)
So(status.Code(err), ShouldEqual, codes.PermissionDenied)
return err
}
Convey("No mint permission", func() {
ctx := auth.WithState(ctx, &authtest.FakeState{
Identity: testCaller,
FakeDB: authtest.NewFakeDB(
// Note: no perm permMintToken here.
authtest.MockPermission(testAccount, testRealm, permExistInRealm),
),
})
So(call(ctx), ShouldErrLike, "unknown realm or no permission to use service accounts there")
})
Convey("No existInRealm permission", func() {
ctx := auth.WithState(ctx, &authtest.FakeState{
Identity: testCaller,
FakeDB: authtest.NewFakeDB(
authtest.MockPermission(testCaller, testRealm, permMintToken),
// Note: no perm permExistInRealm here.
),
})
So(call(ctx), ShouldErrLike, "is not in the realm")
})
Convey("Not in the mapping", func() {
mapping, _ = loadMapping(ctx, fmt.Sprintf(`mapping {}`))
So(call(ctx), ShouldErrLike, "is not allowed to be used")
})
})
}