go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tokenserver/appengine/impl/serviceaccounts/rpc_mint_service_account_token.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  	"time"
    21  
    22  	"go.opentelemetry.io/otel/trace"
    23  	"google.golang.org/grpc/codes"
    24  	"google.golang.org/grpc/status"
    25  	"google.golang.org/protobuf/encoding/protojson"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/auth/identity"
    29  	"go.chromium.org/luci/common/clock"
    30  	"go.chromium.org/luci/common/data/stringset"
    31  	"go.chromium.org/luci/common/logging"
    32  	"go.chromium.org/luci/common/retry/transient"
    33  	"go.chromium.org/luci/server/auth"
    34  	"go.chromium.org/luci/server/auth/authdb"
    35  	"go.chromium.org/luci/server/auth/realms"
    36  	"go.chromium.org/luci/server/auth/signing"
    37  
    38  	"go.chromium.org/luci/tokenserver/api/minter/v1"
    39  	"go.chromium.org/luci/tokenserver/appengine/impl/utils"
    40  	"go.chromium.org/luci/tokenserver/appengine/impl/utils/projectidentity"
    41  )
    42  
    43  var (
    44  	// Grants permission to mint tokens for accounts that belong to a realm.
    45  	permMintToken = realms.RegisterPermission("luci.serviceAccounts.mintToken")
    46  	// Grants permission to *be* a service account that is in the realm.
    47  	permExistInRealm = realms.RegisterPermission("luci.serviceAccounts.existInRealm")
    48  )
    49  
    50  // MintServiceAccountTokenRPC implements the corresponding method.
    51  type MintServiceAccountTokenRPC struct {
    52  	// Signer is used only for its ServiceInfo.
    53  	//
    54  	// In prod it is the default server signer that uses server's service account.
    55  	Signer signing.Signer
    56  
    57  	// Mapping returns project<->account mapping to use for the request.
    58  	//
    59  	// In prod it is GlobalMappingCache.Mapping.
    60  	Mapping func(context.Context) (*Mapping, error)
    61  
    62  	// ProjectIdentities manages project scoped identities.
    63  	//
    64  	// In prod it is projectidentity.ProjectIdentities.
    65  	ProjectIdentities func(context.Context) projectidentity.Storage
    66  
    67  	// MintAccessToken produces an OAuth token for a service account.
    68  	//
    69  	// In prod it is auth.MintAccessTokenForServiceAccount.
    70  	MintAccessToken func(context.Context, auth.MintAccessTokenParams) (*auth.Token, error)
    71  
    72  	// MintIDToken produces an ID token for a service account.
    73  	//
    74  	// In prod it is auth.MintIDTokenForServiceAccount.
    75  	MintIDToken func(context.Context, auth.MintIDTokenParams) (*auth.Token, error)
    76  
    77  	// LogToken is mocked in tests.
    78  	//
    79  	// In prod it is produced by NewTokenLogger.
    80  	LogToken TokenLogger
    81  }
    82  
    83  // validatedRequest is extracted from MintServiceAccountTokenRequest.
    84  type validatedRequest struct {
    85  	kind            minter.ServiceAccountTokenKind
    86  	account         string   // e.g. "something@blah.iam.gserviceaccount.com"
    87  	realm           string   // e.g. "<project>:<realm>"
    88  	project         string   // just "<project>" part
    89  	oauthScopes     []string // non-empty iff kind is ..._ACCESS_TOKEN
    90  	idTokenAudience string   // non-empty iff kind is ..._ID_TOKEN
    91  	minTTL          time.Duration
    92  	auditTags       []string
    93  }
    94  
    95  // callEnv groups a bunch of arguments to simplify passing them to functions.
    96  //
    97  // They all are basically extracted from context.Context and do not depend on
    98  // the body of the request.
    99  type callEnv struct {
   100  	state   auth.State
   101  	db      authdb.DB
   102  	caller  identity.Identity // used in ACLs
   103  	peer    identity.Identity // used in logs only
   104  	mapping *Mapping
   105  }
   106  
   107  // MintServiceAccountToken mints an OAuth2 access token or OpenID ID token
   108  // that belongs to some service account using LUCI Realms for authorization.
   109  //
   110  // See proto docs for more details.
   111  func (r *MintServiceAccountTokenRPC) MintServiceAccountToken(ctx context.Context, req *minter.MintServiceAccountTokenRequest) (*minter.MintServiceAccountTokenResponse, error) {
   112  	state := auth.GetState(ctx)
   113  	env := &callEnv{
   114  		state:  state,
   115  		db:     state.DB(),
   116  		caller: state.User().Identity,
   117  		peer:   state.PeerIdentity(),
   118  	}
   119  
   120  	// Mapping is needed to check ACLs (step 3).
   121  	var err error
   122  	if env.mapping, err = r.Mapping(ctx); err != nil {
   123  		logging.Errorf(ctx, "Failed to grab Mapping: %s", err)
   124  		return nil, status.Errorf(codes.Internal, "internal server error")
   125  	}
   126  
   127  	// Log the request and details about the call environment.
   128  	r.logRequest(ctx, env, req)
   129  
   130  	// Validate the format of the request (e.g. check required fields and so on).
   131  	validated, err := r.validateRequest(req)
   132  	if err != nil {
   133  		return nil, status.Errorf(codes.InvalidArgument, "%s", err)
   134  	}
   135  
   136  	// Check it passes ACLs as described in the proto doc for this RPC.
   137  	if err := r.checkACLs(ctx, env, validated); err != nil {
   138  		return nil, err
   139  	}
   140  
   141  	// Impersonate through a project-scoped account if the LUCI project is
   142  	// opted-in to use this mechanism.
   143  	//
   144  	// There's a special case for accounts belonging to "@internal:..." realms.
   145  	// They are not part of any LUCI project and they are defined in global LUCI
   146  	// configs. Keep using token server's own global account when impersonating
   147  	// them.
   148  	var delegates []string
   149  	if env.mapping.UseProjectScopedAccount(validated.project) && validated.project != realms.InternalProject {
   150  		switch ident, err := r.ProjectIdentities(ctx).LookupByProject(ctx, validated.project); {
   151  		case err == projectidentity.ErrNotFound:
   152  			logging.WithError(err).Errorf(ctx, "No project-scoped account for project %s", validated.project)
   153  			return nil, status.Errorf(codes.InvalidArgument, "project-scoped account for project %s is not configured", validated.project)
   154  		case err != nil:
   155  			logging.WithError(err).Errorf(ctx, "Error while looking up project-scoped account for %s", validated.project)
   156  			return nil, status.Errorf(codes.Internal, "internal error")
   157  		default:
   158  			logging.Infof(ctx, "Delegating through project-scoped account %q", ident.Email)
   159  			delegates = []string{ident.Email}
   160  		}
   161  	}
   162  
   163  	// Mint the token of the corresponding kind.
   164  	var tok *auth.Token
   165  	switch {
   166  	case validated.kind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN:
   167  		tok, err = r.MintAccessToken(ctx, auth.MintAccessTokenParams{
   168  			ServiceAccount: validated.account,
   169  			Scopes:         validated.oauthScopes,
   170  			Delegates:      delegates,
   171  			MinTTL:         validated.minTTL,
   172  		})
   173  	case validated.kind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN:
   174  		tok, err = r.MintIDToken(ctx, auth.MintIDTokenParams{
   175  			ServiceAccount: validated.account,
   176  			Audience:       validated.idTokenAudience,
   177  			Delegates:      delegates,
   178  			MinTTL:         validated.minTTL,
   179  		})
   180  	default:
   181  		panic("impossible") // already checked in validateRequest
   182  	}
   183  
   184  	if err != nil {
   185  		logging.Errorf(ctx, "Failed to mint a token for %q: %s", validated.account, err)
   186  		code := codes.InvalidArgument // mostly likely misconfigured IAM roles
   187  		if transient.Tag.In(err) {
   188  			code = codes.Internal
   189  		}
   190  		return nil, status.Errorf(code, "failed to mint token for %q - %s", validated.account, err)
   191  	}
   192  
   193  	// Grab a string that identifies token server version. This almost always
   194  	// just hits local memory cache.
   195  	serviceVer, err := utils.ServiceVersion(ctx, r.Signer)
   196  	if err != nil {
   197  		return nil, status.Errorf(codes.Internal, "can't grab service version - %s", err)
   198  	}
   199  
   200  	// The RPC response.
   201  	resp := &minter.MintServiceAccountTokenResponse{
   202  		Token:          tok.Token,
   203  		Expiry:         timestamppb.New(tok.Expiry),
   204  		ServiceVersion: serviceVer,
   205  	}
   206  
   207  	// Log it to BigQuery.
   208  	if r.LogToken != nil {
   209  		info := MintedTokenInfo{
   210  			Request:         req,
   211  			Response:        resp,
   212  			RequestedAt:     clock.Now(ctx),
   213  			OAuthScopes:     validated.oauthScopes,
   214  			RequestIdentity: env.caller,
   215  			PeerIdentity:    env.peer,
   216  			ConfigRev:       env.mapping.ConfigRevision(),
   217  			PeerIP:          env.state.PeerIP(),
   218  			RequestID:       trace.SpanContextFromContext(ctx).TraceID().String(),
   219  			AuthDBRev:       authdb.Revision(state.DB()),
   220  		}
   221  		// Errors during logging are considered not fatal. We have a monitoring
   222  		// counter that tracks number of errors, so they are not totally invisible.
   223  		if err := r.LogToken(ctx, &info); err != nil {
   224  			logging.Errorf(ctx, "Failed to insert the token info into the BigQuery log: %s", err)
   225  		}
   226  	}
   227  
   228  	return resp, nil
   229  }
   230  
   231  // logRequest logs the body of the request and details about the call.
   232  func (r *MintServiceAccountTokenRPC) logRequest(ctx context.Context, env *callEnv, req *minter.MintServiceAccountTokenRequest) {
   233  	if !logging.IsLogging(ctx, logging.Debug) {
   234  		return
   235  	}
   236  	opts := protojson.MarshalOptions{Indent: "  "}
   237  	logging.Debugf(ctx, "Peer:     %s", env.peer)
   238  	logging.Debugf(ctx, "Identity: %s", env.caller)
   239  	logging.Debugf(ctx, "Mapping:  %s", env.mapping.ConfigRevision())
   240  	logging.Debugf(ctx, "AuthDB:   %d", authdb.Revision(env.db))
   241  	logging.Debugf(ctx, "MintServiceAccountTokenRequest:\n%s", opts.Format(req))
   242  }
   243  
   244  // validateRequest checks the request is well-formed.
   245  func (r *MintServiceAccountTokenRPC) validateRequest(req *minter.MintServiceAccountTokenRequest) (*validatedRequest, error) {
   246  	// Validate TokenKind.
   247  	switch req.TokenKind {
   248  	case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN:
   249  	case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN:
   250  		// good
   251  	case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_UNSPECIFIED:
   252  		return nil, fmt.Errorf("token_kind is required")
   253  	default:
   254  		return nil, fmt.Errorf("unrecognized token_kind %d", req.TokenKind)
   255  	}
   256  
   257  	// Validate ServiceAccount.
   258  	if req.ServiceAccount == "" {
   259  		return nil, fmt.Errorf("service_account is required")
   260  	}
   261  	if _, err := identity.MakeIdentity("user:" + req.ServiceAccount); err != nil {
   262  		return nil, fmt.Errorf("bad service_account: %s", err)
   263  	}
   264  
   265  	// Validate and parse Realm.
   266  	if req.Realm == "" {
   267  		return nil, fmt.Errorf("realm is required")
   268  	}
   269  	if err := realms.ValidateRealmName(req.Realm, realms.GlobalScope); err != nil {
   270  		return nil, fmt.Errorf("bad realm: %s", err)
   271  	}
   272  	project, _ := realms.Split(req.Realm)
   273  
   274  	// Validate SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN fields.
   275  	var oauthScopes stringset.Set
   276  	if req.TokenKind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN {
   277  		if len(req.OauthScope) == 0 {
   278  			return nil, fmt.Errorf("oauth_scope is required when token_kind is %s", req.TokenKind)
   279  		}
   280  		for _, scope := range req.OauthScope {
   281  			if scope == "" {
   282  				return nil, fmt.Errorf("bad oauth_scope: got an empty string")
   283  			}
   284  		}
   285  		oauthScopes = stringset.NewFromSlice(req.OauthScope...)
   286  	} else {
   287  		if len(req.OauthScope) != 0 {
   288  			return nil, fmt.Errorf("oauth_scope must not be used when token_kind is %s", req.TokenKind)
   289  		}
   290  	}
   291  
   292  	// Validate SERVICE_ACCOUNT_TOKEN_ID_TOKEN fields.
   293  	if req.TokenKind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN {
   294  		if req.IdTokenAudience == "" {
   295  			return nil, fmt.Errorf("id_token_audience is required when token_kind is %s", req.TokenKind)
   296  		}
   297  	} else {
   298  		if req.IdTokenAudience != "" {
   299  			return nil, fmt.Errorf("id_token_audience must not be used when token_kind is %s", req.TokenKind)
   300  		}
   301  	}
   302  
   303  	// Validate MinValidityDuration, substitute defaults.
   304  	minTTL := time.Duration(req.MinValidityDuration) * time.Second
   305  	if minTTL == 0 {
   306  		minTTL = 5 * time.Minute
   307  	}
   308  	switch {
   309  	case minTTL < 0:
   310  		return nil, fmt.Errorf("bad min_validity_duration: got %d, must be positive", req.MinValidityDuration)
   311  	case minTTL > time.Hour:
   312  		return nil, fmt.Errorf("bad min_validity_duration: got %d, must be not greater than 3600", req.MinValidityDuration)
   313  	}
   314  
   315  	// Validate AuditTags.
   316  	if err := utils.ValidateTags(req.AuditTags); err != nil {
   317  		return nil, fmt.Errorf("bad audit_tags: %s", err)
   318  	}
   319  
   320  	return &validatedRequest{
   321  		kind:            req.TokenKind,
   322  		account:         req.ServiceAccount,
   323  		realm:           req.Realm,
   324  		project:         project,
   325  		oauthScopes:     oauthScopes.ToSortedSlice(),
   326  		idTokenAudience: req.IdTokenAudience,
   327  		minTTL:          minTTL,
   328  		auditTags:       req.AuditTags,
   329  	}, nil
   330  }
   331  
   332  // checkACLs returns an grpc error if the request is forbidden.
   333  //
   334  // Logs errors inside.
   335  func (r *MintServiceAccountTokenRPC) checkACLs(ctx context.Context, env *callEnv, req *validatedRequest) error {
   336  	// Check that caller is allowed to mint tokens for accounts in the realm.
   337  	switch yes, err := env.db.HasPermission(ctx, env.caller, permMintToken, req.realm, nil); {
   338  	case err != nil:
   339  		logging.Errorf(ctx, "HasPermission(%q, %q, %q) failed: %s", env.caller, permMintToken, req.realm, err)
   340  		return status.Errorf(codes.Internal, "internal server error")
   341  	case !yes:
   342  		logging.Errorf(ctx, "Caller %q has no permission to mint tokens in the realm %q or it doesn't exist", env.caller, req.realm)
   343  		return status.Errorf(codes.PermissionDenied, "unknown realm or no permission to use service accounts there")
   344  	}
   345  
   346  	// Check the service account is defined in the realm.
   347  	accountID := identity.Identity("user:" + req.account)
   348  	switch yes, err := env.db.HasPermission(ctx, accountID, permExistInRealm, req.realm, nil); {
   349  	case err != nil:
   350  		logging.Errorf(ctx, "HasPermission(%q, %q, %q) failed: %s", accountID, permExistInRealm, req.realm, err)
   351  		return status.Errorf(codes.Internal, "internal server error")
   352  	case !yes:
   353  		logging.Errorf(ctx, "Service account %q is not in the realm %q", req.account, req.realm)
   354  		return status.Errorf(codes.PermissionDenied, "the service account %q is not in the realm %q", req.account, req.realm)
   355  	}
   356  
   357  	// Check the service account is allowed to be defined in this realm at all
   358  	// according to the global Token Server config. Skip if we'll be using
   359  	// the project-scoped account to mint the token. The mapping is essentially
   360  	// stored in IAM policies in this case.
   361  	if !env.mapping.UseProjectScopedAccount(req.project) {
   362  		if !env.mapping.CanProjectUseAccount(req.project, req.account) {
   363  			logging.Errorf(ctx, "Service account %q is not allowed to be used by the project %q", req.account, req.project)
   364  			return status.Errorf(codes.PermissionDenied,
   365  				"the service account %q is not allowed to be used by the project %q per %s configuration",
   366  				req.account, req.project, configFileName)
   367  		}
   368  	}
   369  
   370  	return nil
   371  }