diff --git a/discharger.go b/discharger.go new file mode 100644 index 000000000..8c323bdf7 --- /dev/null +++ b/discharger.go @@ -0,0 +1,159 @@ +// Copyright 2023 Canonical Ltd. + +package jimm + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" + "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers" + "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/dbrootkeystore" + "github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery" + jjmacaroon "github.com/juju/juju/core/macaroon" + "github.com/juju/names/v4" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/internal/db" + "github.com/canonical/jimm/internal/dbmodel" + "github.com/canonical/jimm/internal/errors" + "github.com/canonical/jimm/internal/openfga" + ofganames "github.com/canonical/jimm/internal/openfga/names" + jimmnames "github.com/canonical/jimm/pkg/names" +) + +var defaultDischargeExpiry = 15 * time.Minute + +func newMacaroonDischarger(p Params, db *db.Database, ofgaClient *openfga.OFGAClient) (*macaroonDischarger, error) { + var kp bakery.KeyPair + if p.PublicKey == "" || p.PrivateKey == "" { + generatedKP, err := bakery.GenerateKey() + if err != nil { + return nil, errors.E(err, "failed to generate a bakery keypair") + } + kp = *generatedKP + } else { + if err := kp.Private.UnmarshalText([]byte(p.PrivateKey)); err != nil { + return nil, errors.E(err, "cannot unmarshal private key") + } + if err := kp.Public.UnmarshalText([]byte(p.PublicKey)); err != nil { + return nil, errors.E(err, "cannot unmarshal public key") + } + } + + checker := checkers.New(jjmacaroon.MacaroonNamespace) + b := bakery.New( + bakery.BakeryParams{ + Checker: checker, + RootKeyStore: dbrootkeystore.NewRootKeys(100, nil).NewStore( + db, + dbrootkeystore.Policy{ + ExpiryDuration: p.MacaroonExpiryDuration, + }, + ), + Key: &kp, + Location: "jimm " + p.ControllerUUID, + }, + ) + + return &macaroonDischarger{ + ofgaClient: ofgaClient, + bakery: b, + kp: kp, + }, nil +} + +type macaroonDischarger struct { + ofgaClient *openfga.OFGAClient + bakery *bakery.Bakery + kp bakery.KeyPair +} + +// thirdPartyCaveatCheckerFunction returns a function that +// checks third party caveats addressed to this service. +// Caveat format is: +// +// is- +// +// Examples of caveats are: +// +// is-reader +// is-consumer +// is-administrator +// is-reader +// is-writer +// is-admininistrator +// is-admininistrator +// +// The discharged macaroon will contain a time-before first party caveat and +// a declared caveat declaring relation to the required entity in form of: +// +// +// +// Example: +// 1. if the third party caveat condition is: +// is-reader +// the declared caveat will contain +// reader +// 2. if the third party caveat condition is: +// is-writer +// the declared caveat will contain +// writer +func (md *macaroonDischarger) checkThirdPartyCaveat(ctx context.Context, req *http.Request, cavInfo *bakery.ThirdPartyCaveatInfo, _ *httpbakery.DischargeToken) ([]checkers.Caveat, error) { + caveatTokens := strings.Split(string(cavInfo.Condition), " ") + if len(caveatTokens) != 3 { + zapctx.Error(ctx, "caveat token length incorrect", zap.Int("length", len(caveatTokens))) + return nil, checkers.ErrCaveatNotRecognized + } + relationString := caveatTokens[0] + userTagString := caveatTokens[1] + objectTagString := caveatTokens[2] + + if !strings.HasPrefix(relationString, "is-") { + zapctx.Error(ctx, "caveat token relation string missing prefix") + return nil, checkers.ErrCaveatNotRecognized + } + relationString = strings.TrimPrefix(relationString, "is-") + relation, err := ofganames.ParseRelation(relationString) + if err != nil { + zapctx.Error(ctx, "caveat token relation invalid", zap.Error(err)) + return nil, checkers.ErrCaveatNotRecognized + } + + userTag, err := names.ParseUserTag(userTagString) + if err != nil { + zapctx.Error(ctx, "failed to parse caveat user tag", zap.Error(err)) + return nil, checkers.ErrCaveatNotRecognized + } + + objectTag, err := jimmnames.ParseTag(objectTagString) + if err != nil { + zapctx.Error(ctx, "failed to parse caveat object tag", zap.Error(err)) + return nil, checkers.ErrCaveatNotRecognized + } + + user := openfga.NewUser( + &dbmodel.User{ + Username: userTag.Id(), + }, + md.ofgaClient, + ) + + allowed, err := openfga.CheckRelation(ctx, user, objectTag, relation) + if err != nil { + zapctx.Error(ctx, "failed to check request caveat relation", zap.Error(err)) + return nil, errors.E(err) + } + + if allowed { + return []checkers.Caveat{ + checkers.DeclaredCaveat(relationString, objectTagString), + checkers.TimeBeforeCaveat(time.Now().Add(defaultDischargeExpiry)), + }, nil + } + zapctx.Debug(ctx, "macaroon dishcharge denied", zap.String("user", user.Username), zap.String("object", objectTag.Id())) + return nil, httpbakery.ErrPermissionDenied +} diff --git a/service.go b/service.go index c716f1b23..1daf9fcf1 100644 --- a/service.go +++ b/service.go @@ -16,7 +16,6 @@ import ( cofga "github.com/canonical/ofga" "github.com/go-chi/chi/v5" "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" - "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers" "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/dbrootkeystore" "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/identchecker" "github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery" @@ -49,7 +48,6 @@ import ( "github.com/canonical/jimm/internal/servermon" "github.com/canonical/jimm/internal/vault" "github.com/canonical/jimm/internal/wellknownapi" - jimmnames "github.com/canonical/jimm/pkg/names" ) const ( @@ -348,31 +346,21 @@ func NewService(ctx context.Context, p Params) (*Service, error) { // to enable Juju controllers to check for permissions using a macaroon-based workflow (atm only // for cross model relations). func (s *Service) setupDischarger(p Params, openFGAclient *openfga.OFGAClient) (*bakery.KeyPair, *http.ServeMux, error) { - var kp bakery.KeyPair - if p.PublicKey == "" || p.PrivateKey == "" { - generatedKP, err := bakery.GenerateKey() - if err != nil { - return nil, nil, errors.E(err, "failed to generate a bakery keypair") - } - kp = *generatedKP - } else { - if err := kp.Private.UnmarshalText([]byte(p.PrivateKey)); err != nil { - return nil, nil, errors.E(err, "cannot unmarshal private key") - } - if err := kp.Public.UnmarshalText([]byte(p.PublicKey)); err != nil { - return nil, nil, errors.E(err, "cannot unmarshal public key") - } + macaroonDischarger, err := newMacaroonDischarger(p, &s.jimm.Database, openFGAclient) + if err != nil { + return nil, nil, errors.E(err) } + discharger := httpbakery.NewDischarger( httpbakery.DischargerParams{ - Key: &kp, - Checker: httpbakery.ThirdPartyCaveatCheckerFunc(s.thirdPartyCaveatCheckerFunction(openFGAclient)), + Key: &macaroonDischarger.kp, + Checker: httpbakery.ThirdPartyCaveatCheckerFunc(macaroonDischarger.checkThirdPartyCaveat), }, ) dischargeMux := http.NewServeMux() discharger.AddMuxHandlers(dischargeMux, localDischargePath) - return &kp, dischargeMux, nil + return &macaroonDischarger.kp, dischargeMux, nil } func openDB(ctx context.Context, dsn string) (*gorm.DB, error) { @@ -576,77 +564,3 @@ func ensureControllerAdministrators(ctx context.Context, client *openfga.OFGACli } return client.AddRelation(ctx, tuples...) } - -var defaultDischargeExpiry = 15 * time.Minute - -// thirdPartyCaveatCheckerFunction returns a function that -// checks third party caveats addressed to this service. -// Caveat format is: -// -// is- -// -// Examples of caveats are: -// -// is-reader -// is-consumer -// is-administrator -// is-reader -// is-writer -// is-admininistrator -// is-admininistrator -func (s *Service) thirdPartyCaveatCheckerFunction(ofgaClient *openfga.OFGAClient) func(ctx context.Context, req *http.Request, cavInfo *bakery.ThirdPartyCaveatInfo, _ *httpbakery.DischargeToken) ([]checkers.Caveat, error) { - return func(ctx context.Context, req *http.Request, cavInfo *bakery.ThirdPartyCaveatInfo, _ *httpbakery.DischargeToken) ([]checkers.Caveat, error) { - caveatTokens := strings.Split(string(cavInfo.Condition), " ") - if len(caveatTokens) != 3 { - zapctx.Error(ctx, "caveat token length incorrect", zap.Int("length", len(caveatTokens))) - return nil, checkers.ErrCaveatNotRecognized - } - relationString := caveatTokens[0] - userTagString := caveatTokens[1] - objectTagString := caveatTokens[2] - - if !strings.HasPrefix(relationString, "is-") { - zapctx.Error(ctx, "caveat token relation string missing prefix") - return nil, checkers.ErrCaveatNotRecognized - } - relationString = strings.TrimPrefix(relationString, "is-") - relation, err := ofganames.ParseRelation(relationString) - if err != nil { - zapctx.Error(ctx, "caveat token relation invalid", zap.Error(err)) - return nil, checkers.ErrCaveatNotRecognized - } - - userTag, err := names.ParseUserTag(userTagString) - if err != nil { - zapctx.Error(ctx, "failed to parse caveat user tag", zap.Error(err)) - return nil, checkers.ErrCaveatNotRecognized - } - - objectTag, err := jimmnames.ParseTag(objectTagString) - if err != nil { - zapctx.Error(ctx, "failed to parse caveat object tag", zap.Error(err)) - return nil, checkers.ErrCaveatNotRecognized - } - - user := openfga.NewUser( - &dbmodel.User{ - Username: userTag.Id(), - }, - ofgaClient, - ) - - allowed, err := openfga.CheckRelation(ctx, user, objectTag, relation) - if err != nil { - zapctx.Error(ctx, "failed to check request caveat relation", zap.Error(err)) - return nil, errors.E(err) - } - - if allowed { - return []checkers.Caveat{ - checkers.TimeBeforeCaveat(time.Now().Add(defaultDischargeExpiry)), - }, nil - } - zapctx.Debug(ctx, "macaroon dishcharge denied", zap.String("user", user.Username), zap.String("object", objectTag.Id())) - return nil, httpbakery.ErrPermissionDenied - } -} diff --git a/service_test.go b/service_test.go index cf5e6c0e7..646f4fb3a 100644 --- a/service_test.go +++ b/service_test.go @@ -24,6 +24,7 @@ import ( "github.com/juju/juju/api" "github.com/juju/juju/api/client/cloud" jujucloud "github.com/juju/juju/cloud" + "github.com/juju/juju/core/macaroon" "github.com/juju/names/v4" "github.com/canonical/jimm" @@ -296,10 +297,11 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { ctx := context.Background() tests := []struct { - about string - setup func(c *qt.C, ofgaClient *openfga.OFGAClient, user *dbmodel.User) - caveats []string - expectedError string + about string + setup func(c *qt.C, ofgaClient *openfga.OFGAClient, user *dbmodel.User) + caveats []string + expectDeclared map[string]string + expectedError string }{{ about: "unknown caveats", caveats: []string{"unknown-caveat"}, @@ -315,7 +317,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ReaderRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-reader %s %s", user.ResourceTag(), offer.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-reader %s %s", user.ResourceTag(), offer.ResourceTag())}, + expectDeclared: map[string]string{"reader": offer.ResourceTag().String()}, }, { about: "user is not an offer consumer", caveats: []string{fmt.Sprintf("is-consumer %s %s", user.ResourceTag(), offer.ResourceTag())}, @@ -327,7 +330,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ConsumerRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-consumer %s %s", user.ResourceTag(), offer.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-consumer %s %s", user.ResourceTag(), offer.ResourceTag())}, + expectDeclared: map[string]string{"consumer": offer.ResourceTag().String()}, }, { about: "user is not an offer administrator", caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), offer.ResourceTag())}, @@ -339,7 +343,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.AdministratorRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), offer.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), offer.ResourceTag())}, + expectDeclared: map[string]string{"administrator": offer.ResourceTag().String()}, }, { about: "user is not a model administrator", caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), model.ResourceTag())}, @@ -351,7 +356,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetModelAccess(ctx, model.ResourceTag(), ofganames.AdministratorRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), model.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), model.ResourceTag())}, + expectDeclared: map[string]string{"administrator": model.ResourceTag().String()}, }, { about: "user is not a model reader", caveats: []string{fmt.Sprintf("is-reader %s %s", user.ResourceTag(), model.ResourceTag())}, @@ -363,7 +369,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetModelAccess(ctx, model.ResourceTag(), ofganames.ReaderRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-reader %s %s", user.ResourceTag(), model.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-reader %s %s", user.ResourceTag(), model.ResourceTag())}, + expectDeclared: map[string]string{"reader": model.ResourceTag().String()}, }, { about: "user is not a model writer", caveats: []string{fmt.Sprintf("is-writer %s %s", user.ResourceTag(), model.ResourceTag())}, @@ -375,7 +382,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetModelAccess(ctx, model.ResourceTag(), ofganames.WriterRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-writer %s %s", user.ResourceTag(), model.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-writer %s %s", user.ResourceTag(), model.ResourceTag())}, + expectDeclared: map[string]string{"writer": model.ResourceTag().String()}, }, { about: "user is not a controller administrator", caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), controller.ResourceTag())}, @@ -387,7 +395,8 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { err := u.SetControllerAccess(ctx, controller.ResourceTag(), ofganames.AdministratorRelation) c.Assert(err, qt.IsNil) }, - caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), controller.ResourceTag())}, + caveats: []string{fmt.Sprintf("is-administrator %s %s", user.ResourceTag(), controller.ResourceTag())}, + expectDeclared: map[string]string{"administrator": controller.ResourceTag().String()}, }} for _, test := range tests { c.Run(test.about, func(c *qt.C) { @@ -422,10 +431,17 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { test.setup(c, ofgaClient, &user) } - m, err := bakery.NewMacaroon([]byte("root key"), []byte("id"), "location", bakery.LatestVersion, nil) + m, err := bakery.NewMacaroon( + []byte("root key"), + []byte("id"), + "location", + bakery.LatestVersion, + macaroon.MacaroonNamespace, + ) c.Assert(err, qt.IsNil) kp := bakery.MustGenerateKey() + for _, caveat := range test.caveats { err = m.AddCaveat(context.TODO(), checkers.Caveat{ Location: srv.URL + "/macaroons", @@ -440,7 +456,11 @@ func TestThirdPartyCaveatDischarge(t *testing.T) { c.Assert(err, qt.ErrorMatches, test.expectedError) } else { c.Assert(err, qt.IsNil) - c.Check(ms, qt.HasLen, 2) + c.Assert(ms, qt.HasLen, 2) + + declaredCaveats := checkers.InferDeclared(macaroon.MacaroonNamespace, ms) + c.Logf("declared caveats %v", declaredCaveats) + c.Assert(declaredCaveats, qt.DeepEquals, test.expectDeclared) } }) }