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 }