blob: 748b0283a8c51b9b2d8af78ed5626f227b50a421 [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 serviceaccounts
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/authdb"
"go.chromium.org/luci/server/auth/realms"
"go.chromium.org/luci/server/auth/signing"
"go.chromium.org/luci/tokenserver/api/minter/v1"
"go.chromium.org/luci/tokenserver/appengine/impl/utils"
"go.chromium.org/luci/tokenserver/appengine/impl/utils/projectidentity"
)
var (
// Grants permission to mint tokens for accounts that belong to a realm.
permMintToken = realms.RegisterPermission("luci.serviceAccounts.mintToken")
// Grants permission to *be* a service account that is in the realm.
permExistInRealm = realms.RegisterPermission("luci.serviceAccounts.existInRealm")
)
// MintServiceAccountTokenRPC implements the corresponding method.
type MintServiceAccountTokenRPC struct {
// Signer is used only for its ServiceInfo.
//
// In prod it is the default server signer that uses server's service account.
Signer signing.Signer
// Mapping returns project<->account mapping to use for the request.
//
// In prod it is GlobalMappingCache.Mapping.
Mapping func(context.Context) (*Mapping, error)
// ProjectIdentities manages project scoped identities.
//
// In prod it is projectidentity.ProjectIdentities.
ProjectIdentities func(context.Context) projectidentity.Storage
// MintAccessToken produces an OAuth token for a service account.
//
// In prod it is auth.MintAccessTokenForServiceAccount.
MintAccessToken func(context.Context, auth.MintAccessTokenParams) (*auth.Token, error)
// MintIDToken produces an ID token for a service account.
//
// In prod it is auth.MintIDTokenForServiceAccount.
MintIDToken func(context.Context, auth.MintIDTokenParams) (*auth.Token, error)
// LogToken is mocked in tests.
//
// In prod it is produced by NewTokenLogger.
LogToken TokenLogger
}
// validatedRequest is extracted from MintServiceAccountTokenRequest.
type validatedRequest struct {
kind minter.ServiceAccountTokenKind
account string // e.g. "something@blah.iam.gserviceaccount.com"
realm string // e.g. "<project>:<realm>"
project string // just "<project>" part
oauthScopes []string // non-empty iff kind is ..._ACCESS_TOKEN
idTokenAudience string // non-empty iff kind is ..._ID_TOKEN
minTTL time.Duration
auditTags []string
}
// callEnv groups a bunch of arguments to simplify passing them to functions.
//
// They all are basically extracted from context.Context and do not depend on
// the body of the request.
type callEnv struct {
state auth.State
db authdb.DB
caller identity.Identity // used in ACLs
peer identity.Identity // used in logs only
mapping *Mapping
}
// MintServiceAccountToken mints an OAuth2 access token or OpenID ID token
// that belongs to some service account using LUCI Realms for authorization.
//
// See proto docs for more details.
func (r *MintServiceAccountTokenRPC) MintServiceAccountToken(ctx context.Context, req *minter.MintServiceAccountTokenRequest) (*minter.MintServiceAccountTokenResponse, error) {
state := auth.GetState(ctx)
env := &callEnv{
state: state,
db: state.DB(),
caller: state.User().Identity,
peer: state.PeerIdentity(),
}
// Mapping is needed to check ACLs (step 3).
var err error
if env.mapping, err = r.Mapping(ctx); err != nil {
logging.Errorf(ctx, "Failed to grab Mapping: %s", err)
return nil, status.Errorf(codes.Internal, "internal server error")
}
// Log the request and details about the call environment.
r.logRequest(ctx, env, req)
// Validate the format of the request (e.g. check required fields and so on).
validated, err := r.validateRequest(req)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%s", err)
}
// Check it passes ACLs as described in the proto doc for this RPC.
if err := r.checkACLs(ctx, env, validated); err != nil {
return nil, err
}
// Impersonate through a project-scoped account if the LUCI project is
// opted-in to use this mechanism.
//
// There's a special case for accounts belonging to "@internal:..." realms.
// They are not part of any LUCI project and they are defined in global LUCI
// configs. Keep using token server's own global account when impersonating
// them.
var delegates []string
if env.mapping.UseProjectScopedAccount(validated.project) && validated.project != realms.InternalProject {
switch ident, err := r.ProjectIdentities(ctx).LookupByProject(ctx, validated.project); {
case err == projectidentity.ErrNotFound:
logging.WithError(err).Errorf(ctx, "No project-scoped account for project %s", validated.project)
return nil, status.Errorf(codes.InvalidArgument, "project-scoped account for project %s is not configured", validated.project)
case err != nil:
logging.WithError(err).Errorf(ctx, "Error while looking up project-scoped account for %s", validated.project)
return nil, status.Errorf(codes.Internal, "internal error")
default:
logging.Infof(ctx, "Delegating through project-scoped account %q", ident.Email)
delegates = []string{ident.Email}
}
}
// Mint the token of the corresponding kind.
var tok *auth.Token
switch {
case validated.kind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN:
tok, err = r.MintAccessToken(ctx, auth.MintAccessTokenParams{
ServiceAccount: validated.account,
Scopes: validated.oauthScopes,
Delegates: delegates,
MinTTL: validated.minTTL,
})
case validated.kind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN:
tok, err = r.MintIDToken(ctx, auth.MintIDTokenParams{
ServiceAccount: validated.account,
Audience: validated.idTokenAudience,
Delegates: delegates,
MinTTL: validated.minTTL,
})
default:
panic("impossible") // already checked in validateRequest
}
if err != nil {
logging.Errorf(ctx, "Failed to mint a token for %q: %s", validated.account, err)
code := codes.InvalidArgument // mostly likely misconfigured IAM roles
if transient.Tag.In(err) {
code = codes.Internal
}
return nil, status.Errorf(code, "failed to mint token for %q - %s", validated.account, err)
}
// Grab a string that identifies token server version. This almost always
// just hits local memory cache.
serviceVer, err := utils.ServiceVersion(ctx, r.Signer)
if err != nil {
return nil, status.Errorf(codes.Internal, "can't grab service version - %s", err)
}
// The RPC response.
resp := &minter.MintServiceAccountTokenResponse{
Token: tok.Token,
Expiry: timestamppb.New(tok.Expiry),
ServiceVersion: serviceVer,
}
// Log it to BigQuery.
if r.LogToken != nil {
info := MintedTokenInfo{
Request: req,
Response: resp,
RequestedAt: clock.Now(ctx),
OAuthScopes: validated.oauthScopes,
RequestIdentity: env.caller,
PeerIdentity: env.peer,
ConfigRev: env.mapping.ConfigRevision(),
PeerIP: env.state.PeerIP(),
RequestID: trace.SpanContextFromContext(ctx).TraceID().String(),
AuthDBRev: authdb.Revision(state.DB()),
}
// Errors during logging are considered not fatal. We have a monitoring
// counter that tracks number of errors, so they are not totally invisible.
if err := r.LogToken(ctx, &info); err != nil {
logging.Errorf(ctx, "Failed to insert the token info into the BigQuery log: %s", err)
}
}
return resp, nil
}
// logRequest logs the body of the request and details about the call.
func (r *MintServiceAccountTokenRPC) logRequest(ctx context.Context, env *callEnv, req *minter.MintServiceAccountTokenRequest) {
if !logging.IsLogging(ctx, logging.Debug) {
return
}
opts := protojson.MarshalOptions{Indent: " "}
logging.Debugf(ctx, "Peer: %s", env.peer)
logging.Debugf(ctx, "Identity: %s", env.caller)
logging.Debugf(ctx, "Mapping: %s", env.mapping.ConfigRevision())
logging.Debugf(ctx, "AuthDB: %d", authdb.Revision(env.db))
logging.Debugf(ctx, "MintServiceAccountTokenRequest:\n%s", opts.Format(req))
}
// validateRequest checks the request is well-formed.
func (r *MintServiceAccountTokenRPC) validateRequest(req *minter.MintServiceAccountTokenRequest) (*validatedRequest, error) {
// Validate TokenKind.
switch req.TokenKind {
case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN:
case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN:
// good
case minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_UNSPECIFIED:
return nil, fmt.Errorf("token_kind is required")
default:
return nil, fmt.Errorf("unrecognized token_kind %d", req.TokenKind)
}
// Validate ServiceAccount.
if req.ServiceAccount == "" {
return nil, fmt.Errorf("service_account is required")
}
if _, err := identity.MakeIdentity("user:" + req.ServiceAccount); err != nil {
return nil, fmt.Errorf("bad service_account: %s", err)
}
// Validate and parse Realm.
if req.Realm == "" {
return nil, fmt.Errorf("realm is required")
}
if err := realms.ValidateRealmName(req.Realm, realms.GlobalScope); err != nil {
return nil, fmt.Errorf("bad realm: %s", err)
}
project, _ := realms.Split(req.Realm)
// Validate SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN fields.
var oauthScopes stringset.Set
if req.TokenKind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ACCESS_TOKEN {
if len(req.OauthScope) == 0 {
return nil, fmt.Errorf("oauth_scope is required when token_kind is %s", req.TokenKind)
}
for _, scope := range req.OauthScope {
if scope == "" {
return nil, fmt.Errorf("bad oauth_scope: got an empty string")
}
}
oauthScopes = stringset.NewFromSlice(req.OauthScope...)
} else {
if len(req.OauthScope) != 0 {
return nil, fmt.Errorf("oauth_scope must not be used when token_kind is %s", req.TokenKind)
}
}
// Validate SERVICE_ACCOUNT_TOKEN_ID_TOKEN fields.
if req.TokenKind == minter.ServiceAccountTokenKind_SERVICE_ACCOUNT_TOKEN_ID_TOKEN {
if req.IdTokenAudience == "" {
return nil, fmt.Errorf("id_token_audience is required when token_kind is %s", req.TokenKind)
}
} else {
if req.IdTokenAudience != "" {
return nil, fmt.Errorf("id_token_audience must not be used when token_kind is %s", req.TokenKind)
}
}
// Validate MinValidityDuration, substitute defaults.
minTTL := time.Duration(req.MinValidityDuration) * time.Second
if minTTL == 0 {
minTTL = 5 * time.Minute
}
switch {
case minTTL < 0:
return nil, fmt.Errorf("bad min_validity_duration: got %d, must be positive", req.MinValidityDuration)
case minTTL > time.Hour:
return nil, fmt.Errorf("bad min_validity_duration: got %d, must be not greater than 3600", req.MinValidityDuration)
}
// Validate AuditTags.
if err := utils.ValidateTags(req.AuditTags); err != nil {
return nil, fmt.Errorf("bad audit_tags: %s", err)
}
return &validatedRequest{
kind: req.TokenKind,
account: req.ServiceAccount,
realm: req.Realm,
project: project,
oauthScopes: oauthScopes.ToSortedSlice(),
idTokenAudience: req.IdTokenAudience,
minTTL: minTTL,
auditTags: req.AuditTags,
}, nil
}
// checkACLs returns an grpc error if the request is forbidden.
//
// Logs errors inside.
func (r *MintServiceAccountTokenRPC) checkACLs(ctx context.Context, env *callEnv, req *validatedRequest) error {
// Check that caller is allowed to mint tokens for accounts in the realm.
switch yes, err := env.db.HasPermission(ctx, env.caller, permMintToken, req.realm, nil); {
case err != nil:
logging.Errorf(ctx, "HasPermission(%q, %q, %q) failed: %s", env.caller, permMintToken, req.realm, err)
return status.Errorf(codes.Internal, "internal server error")
case !yes:
logging.Errorf(ctx, "Caller %q has no permission to mint tokens in the realm %q or it doesn't exist", env.caller, req.realm)
return status.Errorf(codes.PermissionDenied, "unknown realm or no permission to use service accounts there")
}
// Check the service account is defined in the realm.
accountID := identity.Identity("user:" + req.account)
switch yes, err := env.db.HasPermission(ctx, accountID, permExistInRealm, req.realm, nil); {
case err != nil:
logging.Errorf(ctx, "HasPermission(%q, %q, %q) failed: %s", accountID, permExistInRealm, req.realm, err)
return status.Errorf(codes.Internal, "internal server error")
case !yes:
logging.Errorf(ctx, "Service account %q is not in the realm %q", req.account, req.realm)
return status.Errorf(codes.PermissionDenied, "the service account %q is not in the realm %q", req.account, req.realm)
}
// Check the service account is allowed to be defined in this realm at all
// according to the global Token Server config. Skip if we'll be using
// the project-scoped account to mint the token. The mapping is essentially
// stored in IAM policies in this case.
if !env.mapping.UseProjectScopedAccount(req.project) {
if !env.mapping.CanProjectUseAccount(req.project, req.account) {
logging.Errorf(ctx, "Service account %q is not allowed to be used by the project %q", req.account, req.project)
return status.Errorf(codes.PermissionDenied,
"the service account %q is not allowed to be used by the project %q per %s configuration",
req.account, req.project, configFileName)
}
}
return nil
}