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 }