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

     1  // Copyright 2023 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package crossmodel
     5  
     6  import (
     7  	"context"
     8  	"net/http"
     9  	"net/url"
    10  	"sort"
    11  	"strings"
    12  
    13  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
    14  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers"
    15  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery"
    16  	"github.com/juju/clock"
    17  	"github.com/juju/errors"
    18  	"github.com/juju/names/v5"
    19  	"gopkg.in/macaroon.v2"
    20  	"gopkg.in/yaml.v2"
    21  
    22  	"github.com/juju/juju/apiserver/authentication"
    23  	"github.com/juju/juju/apiserver/bakeryutil"
    24  	coremacaroon "github.com/juju/juju/core/macaroon"
    25  	"github.com/juju/juju/core/permission"
    26  	"github.com/juju/juju/state/bakerystorage"
    27  )
    28  
    29  // OfferBakery is a bakery service for offer access.
    30  type OfferBakery struct {
    31  	clock clock.Clock
    32  
    33  	bakery authentication.ExpirableStorageBakery
    34  }
    35  
    36  // OfferBakeryInterface is the interface that OfferBakery implements.
    37  type OfferBakeryInterface interface {
    38  	getClock() clock.Clock
    39  	setClock(clock.Clock)
    40  	getBakery() authentication.ExpirableStorageBakery
    41  
    42  	RefreshDischargeURL(string) (string, error)
    43  	GetConsumeOfferCaveats(offerUUID, sourceModelUUID, username string) []checkers.Caveat
    44  	InferDeclaredFromMacaroon(macaroon.Slice, map[string]string) map[string]string
    45  	CreateDischargeMacaroon(
    46  		context.Context, string, string, map[string]string, map[string]string, bakery.Op, bakery.Version,
    47  	) (*bakery.Macaroon, error)
    48  }
    49  
    50  func (o *OfferBakery) getClock() clock.Clock {
    51  	return o.clock
    52  }
    53  
    54  func (o *OfferBakery) setClock(clock clock.Clock) {
    55  	o.clock = clock
    56  }
    57  
    58  func (o *OfferBakery) getBakery() authentication.ExpirableStorageBakery {
    59  	return o.bakery
    60  }
    61  
    62  // RefreshDischargeURL updates the discharge URL for the bakery service.
    63  func (o *OfferBakery) RefreshDischargeURL(accessEndpoint string) (string, error) {
    64  	return accessEndpoint, nil
    65  }
    66  
    67  // NewOfferBakeryForTest is for testing.
    68  func NewOfferBakeryForTest(bakery authentication.ExpirableStorageBakery, clk clock.Clock) *OfferBakery {
    69  	return &OfferBakery{bakery: bakery, clock: clk}
    70  }
    71  
    72  // NewLocalOfferBakery creates a new bakery service for local offer access.
    73  func NewLocalOfferBakery(
    74  	location string,
    75  	bakeryConfig bakerystorage.BakeryConfig,
    76  	store bakerystorage.ExpirableStorage,
    77  	checker bakery.FirstPartyCaveatChecker,
    78  ) (*OfferBakery, error) {
    79  	key, err := bakeryConfig.GetOffersThirdPartyKey()
    80  	if err != nil {
    81  		return nil, errors.Trace(err)
    82  	}
    83  	locator := bakeryutil.BakeryThirdPartyLocator{PublicKey: key.Public}
    84  	localOfferBakery := bakery.New(
    85  		bakery.BakeryParams{
    86  			Checker:       checker,
    87  			RootKeyStore:  store,
    88  			Locator:       locator,
    89  			Key:           key,
    90  			OpsAuthorizer: CrossModelAuthorizer{},
    91  			Location:      location,
    92  		},
    93  	)
    94  	bakery := &bakeryutil.ExpirableStorageBakery{
    95  		Bakery:   localOfferBakery,
    96  		Location: location,
    97  		Key:      key,
    98  		Store:    store,
    99  		Locator:  locator,
   100  	}
   101  	return &OfferBakery{bakery: bakery, clock: clock.WallClock}, nil
   102  }
   103  
   104  // JaaSOfferBakery is a bakery service for offer access.
   105  type JaaSOfferBakery struct {
   106  	*OfferBakery
   107  
   108  	location               string
   109  	currrentAccessEndpoint string
   110  	bakeryConfig           bakerystorage.BakeryConfig
   111  	store                  bakerystorage.ExpirableStorage
   112  	checker                bakery.FirstPartyCaveatChecker
   113  }
   114  
   115  // RefreshDischargeURL updates the discharge URL for the bakery service.
   116  func (o *JaaSOfferBakery) RefreshDischargeURL(accessEndpoint string) (string, error) {
   117  	accessEndpoint, err := o.cleanDischargeURL(accessEndpoint)
   118  	if err != nil {
   119  		return "", errors.Trace(err)
   120  	}
   121  	if o.currrentAccessEndpoint == accessEndpoint {
   122  		return accessEndpoint, nil
   123  	}
   124  	o.currrentAccessEndpoint = accessEndpoint
   125  	return accessEndpoint, errors.Trace(o.refreshBakery(accessEndpoint))
   126  }
   127  
   128  func (o *JaaSOfferBakery) cleanDischargeURL(addr string) (string, error) {
   129  	refreshURL, err := url.Parse(addr)
   130  	if err != nil {
   131  		return "", errors.Trace(err)
   132  	}
   133  	refreshURL.Path = "macaroons"
   134  	return refreshURL.String(), nil
   135  }
   136  
   137  func (o *JaaSOfferBakery) refreshBakery(accessEndpoint string) (err error) {
   138  	thirdPartyInfo, err := httpbakery.ThirdPartyInfoForLocation(
   139  		context.TODO(), &http.Client{Transport: DefaultTransport}, accessEndpoint,
   140  	)
   141  	logger.Tracef("got third party info %#v from %q", thirdPartyInfo, accessEndpoint)
   142  	if err != nil {
   143  		return errors.Trace(err)
   144  	}
   145  	key, err := o.bakeryConfig.GetExternalUsersThirdPartyKey()
   146  	if err != nil {
   147  		return errors.Trace(err)
   148  	}
   149  
   150  	pkCache := bakery.NewThirdPartyStore()
   151  	pkCache.AddInfo(accessEndpoint, thirdPartyInfo)
   152  	locator := httpbakery.NewThirdPartyLocator(nil, pkCache)
   153  
   154  	o.bakery = &bakeryutil.ExpirableStorageBakery{
   155  		Bakery: bakery.New(
   156  			bakery.BakeryParams{
   157  				Checker:       o.checker,
   158  				RootKeyStore:  o.store,
   159  				Locator:       locator,
   160  				Key:           key,
   161  				OpsAuthorizer: CrossModelAuthorizer{},
   162  				Location:      o.location,
   163  			},
   164  		),
   165  		Location: o.location,
   166  		Key:      key,
   167  		Store:    o.store,
   168  		Locator:  locator,
   169  	}
   170  	return nil
   171  }
   172  
   173  var (
   174  	// Override for testing.
   175  	DefaultTransport = http.DefaultTransport
   176  )
   177  
   178  // NewJaaSOfferBakery creates a new bakery service for JaaS offer access.
   179  func NewJaaSOfferBakery(
   180  	loginTokenRefreshURL, location string,
   181  	bakeryConfig bakerystorage.BakeryConfig,
   182  	store bakerystorage.ExpirableStorage,
   183  	checker bakery.FirstPartyCaveatChecker,
   184  ) (*JaaSOfferBakery, error) {
   185  	offerBakery := &JaaSOfferBakery{
   186  		location:     location,
   187  		bakeryConfig: bakeryConfig,
   188  		store:        store,
   189  		checker:      checker,
   190  		OfferBakery:  &OfferBakery{clock: clock.WallClock},
   191  	}
   192  	if _, err := offerBakery.RefreshDischargeURL(loginTokenRefreshURL); err != nil {
   193  		return nil, errors.Trace(err)
   194  	}
   195  	return offerBakery, nil
   196  }
   197  
   198  // GetConsumeOfferCaveats returns the caveats for consuming an offer.
   199  func (o *OfferBakery) GetConsumeOfferCaveats(offerUUID, sourceModelUUID, username string) []checkers.Caveat {
   200  	return []checkers.Caveat{
   201  		checkers.TimeBeforeCaveat(o.clock.Now().Add(offerPermissionExpiryTime)),
   202  		checkers.DeclaredCaveat(sourcemodelKey, sourceModelUUID),
   203  		checkers.DeclaredCaveat(usernameKey, username),
   204  		checkers.DeclaredCaveat(offeruuidKey, offerUUID),
   205  	}
   206  }
   207  
   208  // GetConsumeOfferCaveats returns the caveats for consuming an offer.
   209  func (o *JaaSOfferBakery) GetConsumeOfferCaveats(offerUUID, sourceModelUUID, username string) []checkers.Caveat {
   210  	// We do not declare the offer UUID here since we will discharge the
   211  	// macaroon to JaaS to verify the offer access for JaaS flow.
   212  	return []checkers.Caveat{
   213  		checkers.TimeBeforeCaveat(o.clock.Now().Add(offerPermissionExpiryTime)),
   214  		checkers.DeclaredCaveat(sourcemodelKey, sourceModelUUID),
   215  		checkers.DeclaredCaveat(usernameKey, username),
   216  	}
   217  }
   218  
   219  // InferDeclaredFromMacaroon returns the declared attributes from the macaroon.
   220  func (o *OfferBakery) InferDeclaredFromMacaroon(mac macaroon.Slice, requiredValues map[string]string) map[string]string {
   221  	return checkers.InferDeclared(coremacaroon.MacaroonNamespace, mac)
   222  }
   223  
   224  // InferDeclaredFromMacaroon returns the declared attributes from the macaroon.
   225  func (o *JaaSOfferBakery) InferDeclaredFromMacaroon(mac macaroon.Slice, requiredValues map[string]string) map[string]string {
   226  	declared := checkers.InferDeclared(coremacaroon.MacaroonNamespace, mac)
   227  	authlogger.Debugf("check macaroons with declared attrs: %v", declared)
   228  	// We only need to inject relationKey for jaas flow
   229  	// because the relation key injected in juju discharge
   230  	// process will not be injected in Jaas discharge endpoint.
   231  	if _, ok := declared[relationKey]; !ok {
   232  		if relation, ok := requiredValues[relationKey]; ok {
   233  			declared[relationKey] = relation
   234  		}
   235  	}
   236  	return declared
   237  }
   238  
   239  func localOfferPermissionYaml(sourceModelUUID, username, offerURL, relationKey string, permission permission.Access) (string, error) {
   240  	out, err := yaml.Marshal(offerPermissionCheck{
   241  		SourceModelUUID: sourceModelUUID,
   242  		User:            username,
   243  		OfferUUID:       offerURL,
   244  		Relation:        relationKey,
   245  		Permission:      string(permission),
   246  	})
   247  	if err != nil {
   248  		return "", err
   249  	}
   250  	return string(out), nil
   251  }
   252  
   253  // CreateDischargeMacaroon creates a discharge macaroon.
   254  func (o *OfferBakery) CreateDischargeMacaroon(
   255  	ctx context.Context, accessEndpoint, username string,
   256  	requiredValues, declaredValues map[string]string,
   257  	op bakery.Op, version bakery.Version,
   258  ) (*bakery.Macaroon, error) {
   259  	requiredSourceModelUUID := requiredValues[sourcemodelKey]
   260  	requiredOffer := requiredValues[offeruuidKey]
   261  	requiredRelation := requiredValues[relationKey]
   262  	authYaml, err := localOfferPermissionYaml(
   263  		requiredSourceModelUUID, username, requiredOffer, requiredRelation,
   264  		permission.ConsumeAccess,
   265  	)
   266  	if err != nil {
   267  		return nil, errors.Trace(err)
   268  	}
   269  	bakery, err := o.bakery.ExpireStorageAfter(offerPermissionExpiryTime)
   270  	if err != nil {
   271  		return nil, errors.Trace(err)
   272  	}
   273  	requiredKeys := []string{usernameKey}
   274  	for k := range requiredValues {
   275  		requiredKeys = append(requiredKeys, k)
   276  	}
   277  	sort.Strings(requiredKeys)
   278  	return bakery.NewMacaroon(
   279  		ctx,
   280  		version,
   281  		[]checkers.Caveat{
   282  			checkers.NeedDeclaredCaveat(
   283  				checkers.Caveat{
   284  					Location:  accessEndpoint,
   285  					Condition: offerPermissionCaveat + " " + authYaml,
   286  				},
   287  				requiredKeys...,
   288  			),
   289  			checkers.TimeBeforeCaveat(o.clock.Now().Add(offerPermissionExpiryTime)),
   290  		}, op,
   291  	)
   292  }
   293  
   294  // CreateDischargeMacaroon creates a discharge macaroon.
   295  func (o *JaaSOfferBakery) CreateDischargeMacaroon(
   296  	ctx context.Context, accessEndpoint, username string,
   297  	requiredValues, declaredValues map[string]string,
   298  	op bakery.Op, version bakery.Version,
   299  ) (*bakery.Macaroon, error) {
   300  	requiredOffer := requiredValues[offeruuidKey]
   301  	conditionParts := []string{
   302  		"is-consumer", names.NewUserTag(username).String(), requiredOffer,
   303  	}
   304  
   305  	conditionCaveat := checkers.Caveat{
   306  		Location:  accessEndpoint,
   307  		Condition: strings.Join(conditionParts, " "),
   308  	}
   309  	var declaredCaveats []checkers.Caveat
   310  	for k, v := range declaredValues {
   311  		declaredCaveats = append(declaredCaveats, checkers.DeclaredCaveat(k, v))
   312  	}
   313  	bakery, err := o.bakery.ExpireStorageAfter(offerPermissionExpiryTime)
   314  	if err != nil {
   315  		return nil, errors.Trace(err)
   316  	}
   317  	macaroon, err := bakery.NewMacaroon(
   318  		ctx,
   319  		version,
   320  		append(
   321  			[]checkers.Caveat{
   322  				conditionCaveat,
   323  				checkers.TimeBeforeCaveat(o.clock.Now().Add(offerPermissionExpiryTime)),
   324  			},
   325  			declaredCaveats...,
   326  		), op,
   327  	)
   328  	return macaroon, err
   329  }