github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/apiserver/common/crossmodel/auth.go (about)

     1  // Copyright 2017 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package crossmodel
     5  
     6  import (
     7  	"context"
     8  	"time"
     9  
    10  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
    11  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers"
    12  	"github.com/juju/errors"
    13  	"github.com/juju/names/v5"
    14  	"gopkg.in/macaroon.v2"
    15  	"gopkg.in/yaml.v2"
    16  
    17  	"github.com/juju/juju/apiserver/authentication"
    18  	"github.com/juju/juju/apiserver/common"
    19  	apiservererrors "github.com/juju/juju/apiserver/errors"
    20  	coremacaroon "github.com/juju/juju/core/macaroon"
    21  	"github.com/juju/juju/core/permission"
    22  	"github.com/juju/juju/rpc/params"
    23  )
    24  
    25  const (
    26  	usernameKey    = "username"
    27  	offeruuidKey   = "offer-uuid"
    28  	sourcemodelKey = "source-model-uuid"
    29  	relationKey    = "relation-key"
    30  
    31  	offerPermissionCaveat = "has-offer-permission"
    32  
    33  	// offerPermissionExpiryTime is used to expire offer macaroons.
    34  	// It should be long enough to allow machines hosting workloads to
    35  	// be provisioned so that the macaroon is still valid when the macaroon
    36  	// is next used. If a machine takes longer, that's ok, a new discharge
    37  	// will be obtained.
    38  	offerPermissionExpiryTime = 3 * time.Minute
    39  )
    40  
    41  // RelationInfoFromMacaroons returns any relation and offer in the macaroons' declared caveats.
    42  func RelationInfoFromMacaroons(mac macaroon.Slice) (string, string, bool) {
    43  	declared := checkers.InferDeclared(coremacaroon.MacaroonNamespace, mac)
    44  	relKey, ok1 := declared[relationKey]
    45  	offerUUID, ok2 := declared[offeruuidKey]
    46  	return relKey, offerUUID, ok1 && ok2
    47  }
    48  
    49  // CrossModelAuthorizer authorises any cmr operation presented to it.
    50  type CrossModelAuthorizer struct{}
    51  
    52  // AuthorizeOps implements OpsAuthorizer.AuthorizeOps.
    53  func (CrossModelAuthorizer) AuthorizeOps(ctx context.Context, authorizedOp bakery.Op, queryOps []bakery.Op) ([]bool, []checkers.Caveat, error) {
    54  	authlogger.Debugf("authorize cmr query ops check for %#v: %#v", authorizedOp, queryOps)
    55  	allowed := make([]bool, len(queryOps))
    56  	for i := range allowed {
    57  		allowed[i] = queryOps[i].Action == consumeOp || queryOps[i].Action == relateOp
    58  	}
    59  	return allowed, nil, nil
    60  }
    61  
    62  // AuthContext is used to validate macaroons used to access
    63  // application offers.
    64  type AuthContext struct {
    65  	offerBakery OfferBakeryInterface
    66  	systemState Backend
    67  
    68  	offerThirdPartyKey *bakery.KeyPair
    69  
    70  	offerAccessEndpoint string
    71  }
    72  
    73  // NewAuthContext creates a new authentication context for checking
    74  // macaroons used with application offer requests.
    75  func NewAuthContext(
    76  	systemState Backend,
    77  	offerThirdPartyKey *bakery.KeyPair,
    78  	offerBakery OfferBakeryInterface,
    79  ) (*AuthContext, error) {
    80  	ctxt := &AuthContext{
    81  		systemState:        systemState,
    82  		offerThirdPartyKey: offerThirdPartyKey,
    83  		offerBakery:        offerBakery,
    84  	}
    85  	return ctxt, nil
    86  }
    87  
    88  // WithDischargeURL create an auth context based on this context and used
    89  // to perform third party discharges at the specified URL.
    90  func (a *AuthContext) WithDischargeURL(offerAccessEndpoint string) (*AuthContext, error) {
    91  	ctxtCopy := *a
    92  	newEndpoint, err := ctxtCopy.offerBakery.RefreshDischargeURL(offerAccessEndpoint)
    93  	if err != nil {
    94  		return nil, errors.Trace(err)
    95  	}
    96  	ctxtCopy.offerAccessEndpoint = newEndpoint
    97  	return &ctxtCopy, nil
    98  }
    99  
   100  // OfferThirdPartyKey returns the key used to discharge offer macaroons.
   101  func (a *AuthContext) OfferThirdPartyKey() *bakery.KeyPair {
   102  	return a.offerThirdPartyKey
   103  }
   104  
   105  type offerPermissionCheck struct {
   106  	SourceModelUUID string `yaml:"source-model-uuid"`
   107  	User            string `yaml:"username"`
   108  	OfferUUID       string `yaml:"offer-uuid"`
   109  	Relation        string `yaml:"relation-key"`
   110  	Permission      string `yaml:"permission"`
   111  }
   112  
   113  // CheckOfferAccessCaveat checks that the specified caveat required to be satisfied
   114  // to gain access to an offer is valid, and returns the attributes return to check
   115  // that the caveat is satisfied.
   116  func (a *AuthContext) CheckOfferAccessCaveat(caveat string) (*offerPermissionCheck, error) {
   117  	op, rest, err := checkers.ParseCaveat(caveat)
   118  	if err != nil {
   119  		return nil, errors.Annotatef(err, "cannot parse caveat %q", caveat)
   120  	}
   121  	if op != offerPermissionCaveat {
   122  		return nil, checkers.ErrCaveatNotRecognized
   123  	}
   124  	var details offerPermissionCheck
   125  	err = yaml.Unmarshal([]byte(rest), &details)
   126  	if err != nil {
   127  		return nil, errors.Trace(err)
   128  	}
   129  	authlogger.Debugf("offer access caveat details: %+v", details)
   130  	if !names.IsValidModel(details.SourceModelUUID) {
   131  		return nil, errors.NotValidf("source-model-uuid %q", details.SourceModelUUID)
   132  	}
   133  	if !names.IsValidUser(details.User) {
   134  		return nil, errors.NotValidf("username %q", details.User)
   135  	}
   136  	if err := permission.ValidateOfferAccess(permission.Access(details.Permission)); err != nil {
   137  		return nil, errors.NotValidf("permission %q", details.Permission)
   138  	}
   139  	return &details, nil
   140  }
   141  
   142  // CheckLocalAccessRequest checks that the user in the specified permission
   143  // check details has consume access to the offer in the details.
   144  // It returns an error with a *bakery.VerificationError cause if the macaroon
   145  // verification failed. If the macaroon is valid, CheckLocalAccessRequest
   146  // returns a list of caveats to add to the discharge macaroon.
   147  func (a *AuthContext) CheckLocalAccessRequest(details *offerPermissionCheck) ([]checkers.Caveat, error) {
   148  	authlogger.Debugf("authenticate local offer access: %+v", details)
   149  	if err := a.checkOfferAccess(a.systemState.UserPermission, details.User, details.OfferUUID); err != nil {
   150  		return nil, errors.Trace(err)
   151  	}
   152  
   153  	firstPartyCaveats := []checkers.Caveat{
   154  		checkers.DeclaredCaveat(sourcemodelKey, details.SourceModelUUID),
   155  		checkers.DeclaredCaveat(offeruuidKey, details.OfferUUID),
   156  		checkers.DeclaredCaveat(usernameKey, details.User),
   157  		checkers.TimeBeforeCaveat(a.offerBakery.getClock().Now().Add(offerPermissionExpiryTime)),
   158  	}
   159  	if details.Relation != "" {
   160  		firstPartyCaveats = append(firstPartyCaveats, checkers.DeclaredCaveat(relationKey, details.Relation))
   161  	}
   162  	return firstPartyCaveats, nil
   163  }
   164  
   165  type userAccessFunc func(names.UserTag, names.Tag) (permission.Access, error)
   166  
   167  func (a *AuthContext) checkOfferAccess(userAccess userAccessFunc, username, offerUUID string) error {
   168  	userTag := names.NewUserTag(username)
   169  	isAdmin, err := hasAccess(userAccess, userTag, permission.SuperuserAccess, a.systemState.ControllerTag())
   170  	if is := errors.Is(err, authentication.ErrorEntityMissingPermission); err != nil && !is {
   171  		return apiservererrors.ErrPerm
   172  	}
   173  	if isAdmin {
   174  		return nil
   175  	}
   176  	isAdmin, err = hasAccess(userAccess, userTag, permission.AdminAccess, a.systemState.ModelTag())
   177  	if is := errors.Is(err, authentication.ErrorEntityMissingPermission); err != nil && !is {
   178  		return apiservererrors.ErrPerm
   179  	}
   180  	if isAdmin {
   181  		return nil
   182  	}
   183  	isConsume, err := hasAccess(userAccess, userTag, permission.ConsumeAccess, names.NewApplicationOfferTag(offerUUID))
   184  	if is := errors.Is(err, authentication.ErrorEntityMissingPermission); err != nil && !is {
   185  		return err
   186  	}
   187  	if err != nil {
   188  		return err
   189  	} else if !isConsume {
   190  		return apiservererrors.ErrPerm
   191  	}
   192  	return nil
   193  }
   194  
   195  func hasAccess(userAccess func(names.UserTag, names.Tag) (permission.Access, error), userTag names.UserTag, access permission.Access, target names.Tag) (bool, error) {
   196  	has, err := common.HasPermission(userAccess, userTag, access, target)
   197  	if errors.Is(err, errors.NotFound) {
   198  		return false, nil
   199  	}
   200  	return has, err
   201  }
   202  
   203  // CreateConsumeOfferMacaroon creates a macaroon that authorises access to the specified offer.
   204  func (a *AuthContext) CreateConsumeOfferMacaroon(
   205  	ctx context.Context, offer *params.ApplicationOfferDetailsV5, username string, version bakery.Version,
   206  ) (*bakery.Macaroon, error) {
   207  	sourceModelTag, err := names.ParseModelTag(offer.SourceModelTag)
   208  	if err != nil {
   209  		return nil, errors.Trace(err)
   210  	}
   211  	offerUUID := offer.OfferUUID
   212  	bakery, err := a.offerBakery.getBakery().ExpireStorageAfter(offerPermissionExpiryTime)
   213  	if err != nil {
   214  		return nil, errors.Trace(err)
   215  	}
   216  	return bakery.NewMacaroon(
   217  		ctx, version,
   218  		a.offerBakery.GetConsumeOfferCaveats(offer.OfferUUID, sourceModelTag.Id(), username),
   219  		crossModelConsumeOp(offerUUID),
   220  	)
   221  }
   222  
   223  // CreateRemoteRelationMacaroon creates a macaroon that authorises access to the specified relation.
   224  func (a *AuthContext) CreateRemoteRelationMacaroon(
   225  	ctx context.Context, sourceModelUUID, offerUUID, username string, rel names.Tag, version bakery.Version,
   226  ) (*bakery.Macaroon, error) {
   227  	expiryTime := a.offerBakery.getClock().Now().Add(offerPermissionExpiryTime)
   228  	bakery, err := a.offerBakery.getBakery().ExpireStorageAfter(offerPermissionExpiryTime)
   229  	if err != nil {
   230  		return nil, errors.Trace(err)
   231  	}
   232  
   233  	offerMacaroon, err := bakery.NewMacaroon(
   234  		ctx,
   235  		version,
   236  		[]checkers.Caveat{
   237  			checkers.TimeBeforeCaveat(expiryTime),
   238  			checkers.DeclaredCaveat(sourcemodelKey, sourceModelUUID),
   239  			checkers.DeclaredCaveat(offeruuidKey, offerUUID),
   240  			checkers.DeclaredCaveat(usernameKey, username),
   241  			checkers.DeclaredCaveat(relationKey, rel.Id()),
   242  		}, crossModelRelateOp(rel.Id()))
   243  
   244  	return offerMacaroon, err
   245  }
   246  
   247  type authenticator struct {
   248  	ctxt *AuthContext
   249  }
   250  
   251  const (
   252  	consumeOp = "consume"
   253  	relateOp  = "relate"
   254  )
   255  
   256  func crossModelConsumeOp(offerUUID string) bakery.Op {
   257  	return bakery.Op{
   258  		Entity: offerUUID,
   259  		Action: consumeOp,
   260  	}
   261  }
   262  
   263  func crossModelRelateOp(relationID string) bakery.Op {
   264  	return bakery.Op{
   265  		Entity: relationID,
   266  		Action: relateOp,
   267  	}
   268  }
   269  
   270  // Authenticator returns an instance used to authenticate macaroons used to access offers.
   271  func (a *AuthContext) Authenticator() *authenticator {
   272  	return &authenticator{ctxt: a}
   273  }
   274  
   275  func (a *authenticator) checkMacaroonCaveats(op bakery.Op, relationId, sourceModelUUID, offerUUID string) error {
   276  	var entity string
   277  	switch op.Action {
   278  	case consumeOp:
   279  		if sourceModelUUID == "" {
   280  			return &bakery.VerificationError{Reason: errors.New("missing source model UUID")}
   281  		}
   282  		if offerUUID == "" {
   283  			return &bakery.VerificationError{Reason: errors.New("missing offer UUID")}
   284  		}
   285  		entity = offerUUID
   286  	case relateOp:
   287  		if relationId == "" {
   288  			return &bakery.VerificationError{Reason: errors.New("missing relation")}
   289  		}
   290  		entity = relationId
   291  	default:
   292  		return &bakery.VerificationError{Reason: errors.Errorf("invalid action %q", op.Action)}
   293  	}
   294  	if entity != op.Entity {
   295  		return errors.Unauthorizedf("cmr operation %v not allowed for %v", op.Action, entity)
   296  	}
   297  	return nil
   298  }
   299  
   300  func (a *authenticator) checkMacaroons(
   301  	ctx context.Context, mac macaroon.Slice, version bakery.Version, requiredValues map[string]string, op bakery.Op,
   302  ) (map[string]string, error) {
   303  	authlogger.Debugf("check %d macaroons with required attrs: %v", len(mac), requiredValues)
   304  	for _, m := range mac {
   305  		if m == nil {
   306  			authlogger.Warningf("unexpected nil cross model macaroon")
   307  			continue
   308  		}
   309  		authlogger.Debugf("- mac %s", m.Id())
   310  	}
   311  	declared := a.ctxt.offerBakery.InferDeclaredFromMacaroon(mac, requiredValues)
   312  	authlogger.Debugf("check macaroons with declared attrs: %v", declared)
   313  
   314  	username, ok := declared[usernameKey]
   315  	if !ok {
   316  		return nil, apiservererrors.ErrPerm
   317  	}
   318  	relation := declared[relationKey]
   319  	sourceModelUUID := declared[sourcemodelKey]
   320  	offerUUID := declared[offeruuidKey]
   321  
   322  	auth := a.ctxt.offerBakery.getBakery().Auth(mac)
   323  	ai, err := auth.Allow(ctx, op)
   324  	if err == nil && len(ai.Conditions()) > 0 {
   325  		if err = a.checkMacaroonCaveats(op, relation, sourceModelUUID, offerUUID); err == nil {
   326  			authlogger.Debugf("ok macaroon check ok, attr: %v, conditions: %v", declared, ai.Conditions())
   327  			return declared, nil
   328  		}
   329  		if _, ok := err.(*bakery.VerificationError); !ok {
   330  			return nil, apiservererrors.ErrPerm
   331  		}
   332  	}
   333  
   334  	cause := err
   335  	if cause == nil {
   336  		cause = errors.New("invalid cmr macaroon")
   337  	}
   338  	authlogger.Debugf("generating discharge macaroon because: %v", cause)
   339  
   340  	m, err := a.ctxt.offerBakery.CreateDischargeMacaroon(ctx, a.ctxt.offerAccessEndpoint, username, requiredValues, declared, op, version)
   341  	if err != nil {
   342  		err = errors.Annotate(err, "cannot create macaroon")
   343  		authlogger.Errorf("cannot create cross model macaroon: %v", err)
   344  		return nil, err
   345  	}
   346  
   347  	return nil, &apiservererrors.DischargeRequiredError{
   348  		Cause:          cause,
   349  		Macaroon:       m,
   350  		LegacyMacaroon: m.M(),
   351  	}
   352  }
   353  
   354  // CheckOfferMacaroons verifies that the specified macaroons allow access to the offer.
   355  func (a *authenticator) CheckOfferMacaroons(ctx context.Context, sourceModelUUID, offerUUID string, mac macaroon.Slice, version bakery.Version) (map[string]string, error) {
   356  	requiredValues := map[string]string{
   357  		sourcemodelKey: sourceModelUUID,
   358  		offeruuidKey:   offerUUID,
   359  	}
   360  	return a.checkMacaroons(ctx, mac, version, requiredValues, crossModelConsumeOp(offerUUID))
   361  }
   362  
   363  // CheckRelationMacaroons verifies that the specified macaroons allow access to the relation.
   364  func (a *authenticator) CheckRelationMacaroons(ctx context.Context, sourceModelUUID, offerUUID string, relationTag names.Tag, mac macaroon.Slice, version bakery.Version) error {
   365  	requiredValues := map[string]string{
   366  		sourcemodelKey: sourceModelUUID,
   367  		offeruuidKey:   offerUUID,
   368  		relationKey:    relationTag.Id(),
   369  	}
   370  	_, err := a.checkMacaroons(ctx, mac, version, requiredValues, crossModelRelateOp(relationTag.Id()))
   371  	return err
   372  }