From 3ef495445a130a92716eb8bd780e42d71df9af2d Mon Sep 17 00:00:00 2001 From: William Petit Date: Fri, 4 Sep 2020 10:10:32 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Mise=20en=20place=20d'un=20syst=C3=A8me=20d?= =?UTF-8?q?e=20v=C3=A9rification=20des=20autorisations=20c=C3=B4t=C3=A9=20?= =?UTF-8?q?serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Création d'un service d'autorisation dynamique basé sur des "voter" (à la Symfony) - Mise en place des autorisations sur les principales queries/mutations de l'API GraphQL --- cmd/server/container.go | 9 ++ internal/config/config.go | 2 +- internal/graph/dsf_handler.go | 45 ++++++++- internal/graph/error.go | 7 ++ internal/graph/helper.go | 30 ++++++ internal/graph/workgroup_handler.go | 125 +++++++++++++++++-------- internal/model/action.go | 13 +++ internal/model/dsf_repository.go | 11 +++ internal/model/dsf_voter.go | 48 ++++++++++ internal/model/helper.go | 15 +++ internal/model/workgroup_repository.go | 12 +++ internal/model/workgroup_voter.go | 54 +++++++++++ internal/route/mount.go | 8 +- internal/voter/decision.go | 22 +++++ internal/voter/manager.go | 64 +++++++++++++ internal/voter/provider.go | 13 +++ internal/voter/service.go | 33 +++++++ internal/voter/strategy.go | 77 +++++++++++++++ internal/voter/strategy_test.go | 125 +++++++++++++++++++++++++ 19 files changed, 669 insertions(+), 44 deletions(-) create mode 100644 internal/graph/error.go create mode 100644 internal/model/action.go create mode 100644 internal/model/dsf_voter.go create mode 100644 internal/model/helper.go create mode 100644 internal/model/workgroup_voter.go create mode 100644 internal/voter/decision.go create mode 100644 internal/voter/manager.go create mode 100644 internal/voter/provider.go create mode 100644 internal/voter/service.go create mode 100644 internal/voter/strategy.go create mode 100644 internal/voter/strategy_test.go diff --git a/cmd/server/container.go b/cmd/server/container.go index 7b51fd0..d9017f8 100644 --- a/cmd/server/container.go +++ b/cmd/server/container.go @@ -5,6 +5,9 @@ import ( "net/http" "time" + "forge.cadoles.com/Cadoles/daddy/internal/model" + "forge.cadoles.com/Cadoles/daddy/internal/voter" + "github.com/wader/gormstore" "forge.cadoles.com/Cadoles/daddy/internal/auth" @@ -99,5 +102,11 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules)) + ctn.Provide(voter.ServiceName, voter.ServiceProvider( + voter.StrategyUnanimous, + model.NewDecisionSupportFileVoter(), + model.NewWorkgroupVoter(), + )) + return ctn, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 9c49eae..3745b4a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,7 +90,7 @@ func NewDefault() *Config { Address: ":8081", CookieAuthenticationKey: "", CookieEncryptionKey: "", - CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour + CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hours TemplateDir: "template", PublicDir: "public", FrontendURL: "http://localhost:8080", diff --git a/internal/graph/dsf_handler.go b/internal/graph/dsf_handler.go index c4a1ad3..b0fdc78 100644 --- a/internal/graph/dsf_handler.go +++ b/internal/graph/dsf_handler.go @@ -12,6 +12,15 @@ import ( ) func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) { + authorized, err := isAuthorized(ctx, &model.DecisionSupportFile{}, model.ActionCreate) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + ctn := container.Must(ctx) db := orm.Must(ctn).DB() @@ -31,7 +40,21 @@ func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *mo repo := model.NewDSFRepository(db) - dsf, err := repo.Update(ctx, id, changes) + dsf, err := repo.Find(ctx, id) + if err != nil { + return nil, errs.WithStack(err) + } + + authorized, err := isAuthorized(ctx, dsf, model.ActionUpdate) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + + dsf, err = repo.Update(ctx, id, changes) if err != nil { return nil, errs.WithStack(err) } @@ -45,7 +68,25 @@ func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSuppo repo := model.NewDSFRepository(db) - return repo.Search(ctx, filter) + found, err := repo.Search(ctx, filter) + if err != nil { + return nil, errs.WithStack(err) + } + + dsfs := make([]*model.DecisionSupportFile, 0) + + for _, d := range found { + authorized, err := isAuthorized(ctx, d, model.ActionRead) + if err != nil { + return nil, errs.WithStack(err) + } + + if authorized { + dsfs = append(dsfs, d) + } + } + + return dsfs, nil } func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) { diff --git a/internal/graph/error.go b/internal/graph/error.go new file mode 100644 index 0000000..0228373 --- /dev/null +++ b/internal/graph/error.go @@ -0,0 +1,7 @@ +package graph + +import "errors" + +var ( + ErrForbidden = errors.New("forbidden") +) diff --git a/internal/graph/helper.go b/internal/graph/helper.go index 3351cc7..1607490 100644 --- a/internal/graph/helper.go +++ b/internal/graph/helper.go @@ -3,6 +3,8 @@ package graph import ( "context" + "forge.cadoles.com/Cadoles/daddy/internal/voter" + "forge.cadoles.com/Cadoles/daddy/internal/model" "forge.cadoles.com/Cadoles/daddy/internal/orm" "forge.cadoles.com/Cadoles/daddy/internal/session" @@ -46,3 +48,31 @@ func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) { return user, db, nil } + +func isAuthorized(ctx context.Context, obj interface{}, action interface{}) (bool, error) { + user, _, err := getSessionUser(ctx) + if err != nil { + return false, errors.WithStack(err) + } + + ctn, err := container.From(ctx) + if err != nil { + return false, errors.WithStack(err) + } + + voterSrv, err := voter.From(ctn) + if err != nil { + return false, errors.WithStack(err) + } + + decision, err := voterSrv.Authorized(ctx, user, obj, action) + if err != nil { + return false, errors.WithStack(err) + } + + if decision == voter.Allow { + return true, nil + } + + return false, nil +} diff --git a/internal/graph/workgroup_handler.go b/internal/graph/workgroup_handler.go index 07f89dd..6eab3e4 100644 --- a/internal/graph/workgroup_handler.go +++ b/internal/graph/workgroup_handler.go @@ -2,10 +2,10 @@ package graph import ( "context" - "strconv" "forge.cadoles.com/Cadoles/daddy/internal/model" "github.com/pkg/errors" + errs "github.com/pkg/errors" ) func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*model.Workgroup, error) { @@ -24,20 +24,28 @@ func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*m } } - workgroups, err := repo.FindWorkgroups(ctx, criteria...) + found, err := repo.FindWorkgroups(ctx, criteria...) if err != nil { return nil, errors.WithStack(err) } + workgroups := make([]*model.Workgroup, 0) + + for _, wg := range found { + authorized, err := isAuthorized(ctx, wg, model.ActionRead) + if err != nil { + return nil, errs.WithStack(err) + } + + if authorized { + workgroups = append(workgroups, wg) + } + } + return workgroups, nil } func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) { - workgroupID, err := parseWorkgroupID(rawWorkgroupID) - if err != nil { - return nil, errors.WithStack(err) - } - user, db, err := getSessionUser(ctx) if err != nil { return nil, errors.WithStack(err) @@ -45,7 +53,21 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor repo := model.NewWorkgroupRepository(db) - workgroup, err := repo.AddUserToWorkgroup(ctx, user.ID, workgroupID) + workgroup, err := repo.Find(ctx, rawWorkgroupID) + if err != nil { + return nil, errors.WithStack(err) + } + + authorized, err := isAuthorized(ctx, workgroup, model.ActionJoin) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + + workgroup, err = repo.AddUserToWorkgroup(ctx, user.ID, workgroup.ID) if err != nil { return nil, errors.WithStack(err) } @@ -53,12 +75,7 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor return workgroup, nil } -func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) { - workgroupID, err := parseWorkgroupID(rawWorkgroupID) - if err != nil { - return nil, errors.WithStack(err) - } - +func handleLeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) { user, db, err := getSessionUser(ctx) if err != nil { return nil, errors.WithStack(err) @@ -66,7 +83,21 @@ func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo repo := model.NewWorkgroupRepository(db) - workgroup, err := repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroupID) + workgroup, err := repo.Find(ctx, workgroupID) + if err != nil { + return nil, errors.WithStack(err) + } + + authorized, err := isAuthorized(ctx, workgroup, model.ActionLeave) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + + workgroup, err = repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroup.ID) if err != nil { return nil, errors.WithStack(err) } @@ -75,6 +106,15 @@ func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo } func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) { + authorized, err := isAuthorized(ctx, &model.Workgroup{}, model.ActionCreate) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + db, err := getDB(ctx) if err != nil { return nil, errors.WithStack(err) @@ -90,12 +130,7 @@ func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) return workgroup, nil } -func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) { - workgroupID, err := parseWorkgroupID(rawWorkgroupID) - if err != nil { - return nil, errors.WithStack(err) - } - +func handleCloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) { db, err := getDB(ctx) if err != nil { return nil, errors.WithStack(err) @@ -103,7 +138,21 @@ func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo repo := model.NewWorkgroupRepository(db) - workgroup, err := repo.CloseWorkgroup(ctx, workgroupID) + workgroup, err := repo.Find(ctx, workgroupID) + if err != nil { + return nil, errors.WithStack(err) + } + + authorized, err := isAuthorized(ctx, workgroup, model.ActionClose) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + + workgroup, err = repo.CloseWorkgroup(ctx, workgroup.ID) if err != nil { return nil, errors.WithStack(err) } @@ -111,12 +160,7 @@ func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo return workgroup, nil } -func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) { - workgroupID, err := parseWorkgroupID(rawWorkgroupID) - if err != nil { - return nil, errors.WithStack(err) - } - +func handleUpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) { db, err := getDB(ctx) if err != nil { return nil, errors.WithStack(err) @@ -124,19 +168,24 @@ func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes m repo := model.NewWorkgroupRepository(db) - workgroup, err := repo.UpdateWorkgroup(ctx, workgroupID, changes) + workgroup, err := repo.Find(ctx, workgroupID) + if err != nil { + return nil, errors.WithStack(err) + } + + authorized, err := isAuthorized(ctx, workgroup, model.ActionUpdate) + if err != nil { + return nil, errs.WithStack(err) + } + + if !authorized { + return nil, errs.WithStack(ErrForbidden) + } + + workgroup, err = repo.UpdateWorkgroup(ctx, workgroup.ID, changes) if err != nil { return nil, errors.WithStack(err) } return workgroup, nil } - -func parseWorkgroupID(workgroupID string) (uint, error) { - workgroupID64, err := strconv.ParseUint(workgroupID, 10, 32) - if err != nil { - return 0, errors.WithStack(err) - } - - return uint(workgroupID64), nil -} diff --git a/internal/model/action.go b/internal/model/action.go new file mode 100644 index 0000000..737096b --- /dev/null +++ b/internal/model/action.go @@ -0,0 +1,13 @@ +package model + +type Action string + +const ( + ActionCreate Action = "create" + ActionRead Action = "read" + ActionUpdate Action = "update" + ActionDelete Action = "delete" + ActionJoin Action = "join" + ActionLeave Action = "leave" + ActionClose Action = "close" +) diff --git a/internal/model/dsf_repository.go b/internal/model/dsf_repository.go index bfc3be0..1292458 100644 --- a/internal/model/dsf_repository.go +++ b/internal/model/dsf_repository.go @@ -88,6 +88,17 @@ func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *Dec return nil } +func (r *DSFRepository) Find(ctx context.Context, id string) (*DecisionSupportFile, error) { + dsf := &DecisionSupportFile{} + query := r.db.Model(dsf).Preload("Workgroup").Where("id = ?", id) + + if err := query.First(&dsf).Error; err != nil { + return nil, errs.WithStack(err) + } + + return dsf, nil +} + func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) { query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup") diff --git a/internal/model/dsf_voter.go b/internal/model/dsf_voter.go new file mode 100644 index 0000000..baa4c7d --- /dev/null +++ b/internal/model/dsf_voter.go @@ -0,0 +1,48 @@ +package model + +import ( + "context" + + "forge.cadoles.com/Cadoles/daddy/internal/voter" +) + +type DecisionSupportFileVoter struct { +} + +func (v *DecisionSupportFileVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) { + user, ok := subject.(*User) + if !ok { + return voter.Abstain, nil + } + + dsf, ok := obj.(*DecisionSupportFile) + if !ok { + return voter.Abstain, nil + } + + action, ok := act.(Action) + if !ok { + return voter.Abstain, nil + } + + switch action { + case ActionCreate: + return voter.Allow, nil + case ActionRead: + return voter.Allow, nil + case ActionUpdate: + if inWorkgroup(user, dsf.Workgroup) { + return voter.Allow, nil + } + + return voter.Deny, nil + case ActionDelete: + return voter.Deny, nil + } + + return voter.Abstain, nil +} + +func NewDecisionSupportFileVoter() *DecisionSupportFileVoter { + return &DecisionSupportFileVoter{} +} diff --git a/internal/model/helper.go b/internal/model/helper.go new file mode 100644 index 0000000..9f56dd9 --- /dev/null +++ b/internal/model/helper.go @@ -0,0 +1,15 @@ +package model + +func inWorkgroup(user *User, workgroup *Workgroup) bool { + if workgroup == nil { + return false + } + + for _, w := range user.Workgroups { + if w.ID == workgroup.ID { + return true + } + } + + return false +} diff --git a/internal/model/workgroup_repository.go b/internal/model/workgroup_repository.go index 283c1fe..3c73ed1 100644 --- a/internal/model/workgroup_repository.go +++ b/internal/model/workgroup_repository.go @@ -6,6 +6,7 @@ import ( "github.com/jinzhu/gorm" "github.com/pkg/errors" + errs "github.com/pkg/errors" ) type WorkgroupRepository struct { @@ -135,6 +136,17 @@ func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userI return workgroup, nil } +func (r *WorkgroupRepository) Find(ctx context.Context, id string) (*Workgroup, error) { + wg := &Workgroup{} + query := r.db.Model(wg).Where("id = ?", id) + + if err := query.First(&wg).Error; err != nil { + return nil, errs.WithStack(err) + } + + return wg, nil +} + func NewWorkgroupRepository(db *gorm.DB) *WorkgroupRepository { return &WorkgroupRepository{db} } diff --git a/internal/model/workgroup_voter.go b/internal/model/workgroup_voter.go new file mode 100644 index 0000000..d9e8fc0 --- /dev/null +++ b/internal/model/workgroup_voter.go @@ -0,0 +1,54 @@ +package model + +import ( + "context" + + "forge.cadoles.com/Cadoles/daddy/internal/voter" +) + +type WorkgroupVoter struct { +} + +func (v *WorkgroupVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) { + user, ok := subject.(*User) + if !ok { + return voter.Abstain, nil + } + + workgroup, ok := obj.(*Workgroup) + if !ok { + return voter.Abstain, nil + } + + action, ok := act.(Action) + if !ok { + return voter.Abstain, nil + } + + switch action { + case ActionCreate: + return voter.Allow, nil + case ActionRead: + return voter.Allow, nil + case ActionJoin: + return voter.Allow, nil + case ActionLeave: + fallthrough + case ActionUpdate: + fallthrough + case ActionClose: + if inWorkgroup(user, workgroup) { + return voter.Allow, nil + } else { + return voter.Deny, nil + } + case ActionDelete: + return voter.Deny, nil + } + + return voter.Abstain, nil +} + +func NewWorkgroupVoter() *WorkgroupVoter { + return &WorkgroupVoter{} +} diff --git a/internal/route/mount.go b/internal/route/mount.go index aa8109a..b295101 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -36,10 +36,12 @@ func Mount(r *chi.Mux, config *config.Config) error { }).Handler) r.Use(session.UserEmailMiddleware) + gqlConfig := generated.Config{ + Resolvers: &graph.Resolver{}, + } + gql := handler.New( - generated.NewExecutableSchema(generated.Config{ - Resolvers: &graph.Resolver{}, - }), + generated.NewExecutableSchema(gqlConfig), ) gql.AddTransport(transport.POST{}) diff --git a/internal/voter/decision.go b/internal/voter/decision.go new file mode 100644 index 0000000..bbf299e --- /dev/null +++ b/internal/voter/decision.go @@ -0,0 +1,22 @@ +package voter + +type Decision int + +const ( + Allow Decision = iota + Deny + Abstain +) + +func AsString(d Decision) string { + switch d { + case Allow: + return "allow" + case Deny: + return "deny" + case Abstain: + return "abstain" + default: + return "unknown" + } +} diff --git a/internal/voter/manager.go b/internal/voter/manager.go new file mode 100644 index 0000000..0e99ca9 --- /dev/null +++ b/internal/voter/manager.go @@ -0,0 +1,64 @@ +package voter + +import ( + "context" + + "github.com/davecgh/go-spew/spew" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +type Voter interface { + Vote(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error) +} + +type Strategy func(ctx context.Context, decisions []Decision) (Decision, error) + +type Manager struct { + strategy Strategy + voters []Voter +} + +func (m *Manager) Authorized(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error) { + decisions := make([]Decision, 0, len(m.voters)) + + logger.Debug( + ctx, + "checking authorization", + logger.F("subject", subject), + logger.F("object", obj), + logger.F("action", action), + ) + + for _, v := range m.voters { + dec, err := v.Vote(ctx, subject, obj, action) + if err != nil { + return Deny, errors.WithStack(err) + } + + decisions = append(decisions, dec) + } + + spew.Dump(decisions) + + result, err := m.strategy(ctx, decisions) + if err != nil { + return Deny, errors.WithStack(err) + } + + logger.Debug( + ctx, + "authorization checked", + logger.F("subject", subject), + logger.F("object", obj), + logger.F("action", action), + logger.F("result", AsString(result)), + ) + + return result, nil +} + +func NewManager(strategy Strategy, voters ...Voter) *Manager { + return &Manager{strategy, voters} +} diff --git a/internal/voter/provider.go b/internal/voter/provider.go new file mode 100644 index 0000000..e20f68e --- /dev/null +++ b/internal/voter/provider.go @@ -0,0 +1,13 @@ +package voter + +import ( + "gitlab.com/wpetit/goweb/service" +) + +func ServiceProvider(strategy Strategy, voters ...Voter) service.Provider { + manager := NewManager(strategy, voters...) + + return func(ctn *service.Container) (interface{}, error) { + return manager, nil + } +} diff --git a/internal/voter/service.go b/internal/voter/service.go new file mode 100644 index 0000000..9f1d355 --- /dev/null +++ b/internal/voter/service.go @@ -0,0 +1,33 @@ +package voter + +import ( + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/service" +) + +const ServiceName service.Name = "voter" + +// From retrieves the voter service in the given container. +func From(container *service.Container) (*Manager, error) { + service, err := container.Service(ServiceName) + if err != nil { + return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName) + } + + srv, ok := service.(*Manager) + if !ok { + return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName) + } + + return srv, nil +} + +// Must retrieves the voter service in the given container or panic otherwise. +func Must(container *service.Container) *Manager { + srv, err := From(container) + if err != nil { + panic(err) + } + + return srv +} diff --git a/internal/voter/strategy.go b/internal/voter/strategy.go new file mode 100644 index 0000000..34d9668 --- /dev/null +++ b/internal/voter/strategy.go @@ -0,0 +1,77 @@ +package voter + +import "context" + +// StrategyUnanimous returns Allow if all voters allow the operations. +func StrategyUnanimous(ctx context.Context, decisions []Decision) (Decision, error) { + allAbstains := true + + for _, d := range decisions { + if d == Deny { + return Deny, nil + } + + if d != Abstain { + allAbstains = false + } + } + + if allAbstains { + return Abstain, nil + } + + return Allow, nil +} + +// StrategyAffirmative returns Allow if at least one voter allow the operation. +func StrategyAffirmative(ctx context.Context, decisions []Decision) (Decision, error) { + allAbstains := true + + for _, d := range decisions { + if d == Allow { + return Allow, nil + } + + if allAbstains && d != Abstain { + allAbstains = false + } + } + + if allAbstains { + return Abstain, nil + } + + return Deny, nil +} + +// StrategyConsensus returns Allow if the majority of voters allow the operation. +func StrategyConsensus(ctx context.Context, decisions []Decision) (Decision, error) { + deny := 0 + allow := 0 + abstain := 0 + + for _, d := range decisions { + switch { + case d == Allow: + allow++ + case d == Deny: + deny++ + case d == Abstain: + abstain++ + } + } + + if abstain > allow && abstain > deny { + return Abstain, nil + } + + if allow > abstain && allow > deny { + return Allow, nil + } + + if deny > allow && deny > abstain { + return Deny, nil + } + + return Abstain, nil +} diff --git a/internal/voter/strategy_test.go b/internal/voter/strategy_test.go new file mode 100644 index 0000000..f01de52 --- /dev/null +++ b/internal/voter/strategy_test.go @@ -0,0 +1,125 @@ +package voter + +import ( + "context" + "testing" +) + +func TestStrategyUnanimous(t *testing.T) { + testCases := []struct { + Decisions []Decision + Expect Decision + }{ + { + Decisions: []Decision{Allow, Allow, Allow}, + Expect: Allow, + }, + { + Decisions: []Decision{Abstain, Abstain, Abstain}, + Expect: Abstain, + }, + { + Decisions: []Decision{Deny, Abstain, Abstain}, + Expect: Deny, + }, + { + Decisions: []Decision{Deny, Allow, Abstain}, + Expect: Deny, + }, + } + + for _, tc := range testCases { + ctx := context.Background() + + result, err := StrategyUnanimous(ctx, tc.Decisions) + if err != nil { + t.Error(err) + } + + if e, g := tc.Expect, result; e != g { + t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g)) + } + } +} + +func TestStrategyAffirmative(t *testing.T) { + testCases := []struct { + Decisions []Decision + Expect Decision + }{ + { + Decisions: []Decision{Allow, Allow, Allow}, + Expect: Allow, + }, + { + Decisions: []Decision{Abstain, Abstain, Abstain}, + Expect: Abstain, + }, + { + Decisions: []Decision{Deny, Abstain, Abstain}, + Expect: Deny, + }, + { + Decisions: []Decision{Deny, Allow, Abstain}, + Expect: Allow, + }, + } + + for _, tc := range testCases { + ctx := context.Background() + + result, err := StrategyAffirmative(ctx, tc.Decisions) + if err != nil { + t.Error(err) + } + + if e, g := tc.Expect, result; e != g { + t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g)) + } + } +} + +func TestStrategyConsensus(t *testing.T) { + testCases := []struct { + Decisions []Decision + Expect Decision + }{ + { + Decisions: []Decision{Allow, Allow, Allow}, + Expect: Allow, + }, + { + Decisions: []Decision{Abstain, Abstain, Abstain}, + Expect: Abstain, + }, + { + Decisions: []Decision{Deny, Allow, Abstain}, + Expect: Abstain, + }, + { + Decisions: []Decision{Deny, Deny, Allow}, + Expect: Deny, + }, + { + Decisions: []Decision{Deny, Deny, Allow, Allow}, + Expect: Abstain, + }, + { + Decisions: []Decision{Deny, Deny, Allow, Allow, Allow}, + Expect: Allow, + }, + } + + for _, tc := range testCases { + ctx := context.Background() + + result, err := StrategyConsensus(ctx, tc.Decisions) + if err != nil { + t.Error(err) + } + + if e, g := tc.Expect, result; e != g { + t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g)) + } + } +} From 9c6ebae9bc41266a4cdb421b9982b581866dbff1 Mon Sep 17 00:00:00 2001 From: William Petit Date: Fri, 4 Sep 2020 11:19:24 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Ajout=20d'une=20query=20GraphQL=20pour=20v?= =?UTF-8?q?=C3=A9rifier=20les=20autorisations=20c=C3=B4t=C3=A9=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Intégration des vérifications de droits sur la page de création/modification des groupes de travail --- .../src/components/WorkgroupPage/InfoForm.tsx | 17 +++++- .../WorkgroupPage/WorkgroupPage.tsx | 2 + client/src/gql/client.tsx | 5 ++ client/src/gql/mutations/workgroups.tsx | 39 +++++++++++- client/src/gql/queries/authorization.tsx | 19 ++++++ internal/graph/authorization_handler.go | 59 +++++++++++++++++++ internal/graph/error.go | 3 +- internal/graph/query.graphql | 7 +++ internal/graph/query.resolvers.go | 4 ++ internal/model/user_repository.go | 12 ++++ 10 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 client/src/gql/queries/authorization.tsx create mode 100644 internal/graph/authorization_handler.go diff --git a/client/src/components/WorkgroupPage/InfoForm.tsx b/client/src/components/WorkgroupPage/InfoForm.tsx index cb3a3f8..9921b36 100644 --- a/client/src/components/WorkgroupPage/InfoForm.tsx +++ b/client/src/components/WorkgroupPage/InfoForm.tsx @@ -1,12 +1,13 @@ import React, { useState, ChangeEvent, useEffect } from 'react'; import { Workgroup } from '../../types/workgroup'; +import { useIsAuthorized } from '../../gql/queries/authorization'; export interface InfoFormProps { workgroup: Workgroup onChange?: (workgroup: Workgroup) => void } -export function InfoForm({ workgroup, onChange }: InfoFormProps) { +export function InfoForm({ workgroup, onChange }: InfoFormProps) { const [ state, setState ] = useState({ changed: false, workgroup: { @@ -17,6 +18,15 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) { } }); + const { isAuthorized } = useIsAuthorized({ + variables: { + action: 'update', + object: { + workgroupId: state.workgroup.id, + } + } + }, state.workgroup.id === '' ? true : false); + useEffect(() => { setState({ changed: false, @@ -60,7 +70,8 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
-
@@ -85,7 +96,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) { null }
-
- -
-
- -
-
- -
+
+
+
- +
+ +
+
diff --git a/client/src/gql/client.tsx b/client/src/gql/client.tsx index 7af8aea..259ca55 100644 --- a/client/src/gql/client.tsx +++ b/client/src/gql/client.tsx @@ -34,7 +34,6 @@ export const client = new ApolloClient({ function mergeArrayByField(fieldName: string) { return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => { - if (incoming.length === 0) return []; const merged: any[] = existing ? existing.slice(0) : []; diff --git a/client/src/gql/mutations/dsf.tsx b/client/src/gql/mutations/dsf.tsx index 4accf85..d37a883 100644 --- a/client/src/gql/mutations/dsf.tsx +++ b/client/src/gql/mutations/dsf.tsx @@ -9,7 +9,16 @@ mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) { status, sections, createdAt, - updatedAt + updatedAt, + workgroup { + id, + name, + members { + id, + email, + name + } + }, } }`; @@ -27,7 +36,16 @@ mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChange status, sections, createdAt, - updatedAt + updatedAt, + workgroup { + id, + name, + members { + id, + email, + name + } + }, } }`; diff --git a/client/src/gql/queries/dsf.tsx b/client/src/gql/queries/dsf.tsx index e41b5fc..0afbdcb 100644 --- a/client/src/gql/queries/dsf.tsx +++ b/client/src/gql/queries/dsf.tsx @@ -16,7 +16,9 @@ export const QUERY_DECISION_SUPPORT_FILES = gql` id, name, members { - id + id, + email, + name } }, } diff --git a/internal/model/workgroup_repository.go b/internal/model/workgroup_repository.go index 3c73ed1..6f83de0 100644 --- a/internal/model/workgroup_repository.go +++ b/internal/model/workgroup_repository.go @@ -48,7 +48,7 @@ func (r *WorkgroupRepository) CreateWorkgroup(ctx context.Context, changes Workg Name: changes.Name, } - if err := r.db.Model(&Workgroup{}).Create(workgroup).Error; err != nil { + if err := r.db.Model(&Workgroup{}).Preload("Members").Create(workgroup).Error; err != nil { return nil, errors.WithStack(err) } @@ -138,7 +138,7 @@ func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userI func (r *WorkgroupRepository) Find(ctx context.Context, id string) (*Workgroup, error) { wg := &Workgroup{} - query := r.db.Model(wg).Where("id = ?", id) + query := r.db.Model(wg).Preload("Members").Where("id = ?", id) if err := query.First(&wg).Error; err != nil { return nil, errs.WithStack(err) From 71102cfb3b7384adff7093bb63c2de8a0eff45b5 Mon Sep 17 00:00:00 2001 From: William Petit Date: Fri, 4 Sep 2020 16:17:48 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Conservation=20de=20l'=C3=A9tat=20connect?= =?UTF-8?q?=C3=A9=20entre=202=20rafraichissement=20de=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'état de connexion est conservé dans le sessionStorage et réutilisé par défaut lors du rafraichissement de la page. Si une erreur 401 survient lors d'un appel à l'API alors l'utilisateur est redirigé vers la page d'accueil. --- client/src/components/App.tsx | 86 +++++++++++++------ .../DashboardPage/WorkgroupsPanel.tsx | 9 +- client/src/components/HomePage/HomePage.tsx | 7 +- client/src/components/LogoutPage.tsx | 15 ++++ client/src/components/Navbar.tsx | 4 +- client/src/gql/client.tsx | 60 ------------- client/src/hooks/useLoggedIn.tsx | 22 ++++- client/src/index.tsx | 5 +- client/src/util/apollo.ts | 74 ++++++++++++++++ internal/voter/manager.go | 4 - 10 files changed, 177 insertions(+), 109 deletions(-) create mode 100644 client/src/components/LogoutPage.tsx delete mode 100644 client/src/gql/client.tsx create mode 100644 client/src/util/apollo.ts diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index f0b3e67..adfb1ae 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useState, useEffect } from 'react'; import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom"; import { HomePage } from './HomePage/HomePage'; import { ProfilePage } from './ProfilePage/ProfilePage'; @@ -6,44 +6,76 @@ import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage'; import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage'; import { DashboardPage } from './DashboardPage/DashboardPage'; import { useUserProfile } from '../gql/queries/profile'; -import { LoggedInContext } from '../hooks/useLoggedIn'; +import { LoggedInContext, getSavedLoggedIn, saveLoggedIn } from '../hooks/useLoggedIn'; import { PrivateRoute } from './PrivateRoute'; import { useKonamiCode } from '../hooks/useKonamiCode'; import { Modal } from './Modal'; +import { createClient } from '../util/apollo'; +import { ApolloProvider } from '@apollo/client'; +import { LogoutPage } from './LogoutPage'; export interface AppProps { } + + export const App: FunctionComponent = () => { - const { user } = useUserProfile(); + const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn()); + + const client = createClient((loggedIn) => { + setLoggedIn(loggedIn); + }); + + useEffect(() => { + saveLoggedIn(loggedIn); + }, [loggedIn]); const [ showBoneyM, setShowBoneyM ] = useState(false); useKonamiCode(() => setShowBoneyM(true)); return ( - - - - - - - - - } /> - - - { - showBoneyM ? - setShowBoneyM(false)}> - - : - null - } - + + + + + + + + + + + + } /> + + + { + showBoneyM ? + setShowBoneyM(false)}> + + : + null + } + + ); -} \ No newline at end of file +} + +interface UserSessionCheckProps { + setLoggedIn: (boolean) => void +} + +const UserSessionCheck: FunctionComponent = ({ setLoggedIn }) => { + const { user, loading } = useUserProfile(); + + useEffect(() => { + if (loading) return; + setLoggedIn(user.id !== ''); + }, [user]); + + return null; +}; \ No newline at end of file diff --git a/client/src/components/DashboardPage/WorkgroupsPanel.tsx b/client/src/components/DashboardPage/WorkgroupsPanel.tsx index c7ea0d1..5bb9684 100644 --- a/client/src/components/DashboardPage/WorkgroupsPanel.tsx +++ b/client/src/components/DashboardPage/WorkgroupsPanel.tsx @@ -1,10 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { } from 'react'; import { Workgroup, inWorkgroup } from '../../types/workgroup'; -import { User } from '../../types/user'; -import { Link } from 'react-router-dom'; -import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups'; -import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile'; -import { WithLoader } from '../WithLoader'; +import { useWorkgroups } from '../../gql/queries/workgroups'; +import { useUserProfile } from '../../gql/queries/profile'; import { ItemPanel, Item } from './ItemPanel'; export function WorkgroupsPanel() { diff --git a/client/src/components/HomePage/HomePage.tsx b/client/src/components/HomePage/HomePage.tsx index f455d4e..a3af9ff 100644 --- a/client/src/components/HomePage/HomePage.tsx +++ b/client/src/components/HomePage/HomePage.tsx @@ -3,14 +3,15 @@ import { Page } from '../Page'; import { WelcomeContent } from './WelcomeContent'; import { useUserProfile } from '../../gql/queries/profile'; import { useHistory } from 'react-router'; +import { useLoggedIn } from '../../hooks/useLoggedIn'; export function HomePage() { - const { user } = useUserProfile(); + const loggedIn = useLoggedIn(); const history = useHistory(); useEffect(() => { - if (user.id !== '') history.push('/dashboard'); - }, [user.id]) + if (loggedIn) history.push('/dashboard'); + }, [loggedIn]) return ( diff --git a/client/src/components/LogoutPage.tsx b/client/src/components/LogoutPage.tsx new file mode 100644 index 0000000..56cd70a --- /dev/null +++ b/client/src/components/LogoutPage.tsx @@ -0,0 +1,15 @@ +import React, { FunctionComponent, useEffect } from "react"; +import { saveLoggedIn } from "../hooks/useLoggedIn"; +import { Config } from "../config"; + +export interface LogoutPageProps { + +} + +export const LogoutPage: FunctionComponent = () => { + useEffect(() => { + saveLoggedIn(false); + window.location.replace(Config.logoutURL); + }, []); + return null; +}; \ No newline at end of file diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index a5d38fe..036e8e4 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -44,11 +44,11 @@ export function Navbar() { Mon profil - + - + : diff --git a/client/src/gql/client.tsx b/client/src/gql/client.tsx deleted file mode 100644 index 259ca55..0000000 --- a/client/src/gql/client.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; -import { Config } from '../config'; -import { WebSocketLink } from "@apollo/client/link/ws"; -import { RetryLink } from "@apollo/client/link/retry"; -import { SubscriptionClient } from "subscriptions-transport-ws"; -import { User } from '../types/user'; - -const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, { - reconnect: true, -}); - -const link = new RetryLink({attempts: {max: 2}}).split( - (operation) => operation.operationName === 'subscription', - new WebSocketLink(subscriptionClient), - new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' }) -); - -const cache = new InMemoryCache({ - typePolicies: { - Workgroup: { - fields: { - members: { - merge: mergeArrayByField("id"), - } - } - } - } -}); - -export const client = new ApolloClient({ - cache: cache, - link: link, -}); - -function mergeArrayByField(fieldName: string) { - return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => { - - const merged: any[] = existing ? existing.slice(0) : []; - - const objectFieldToIndex: Record = Object.create(null); - if (existing) { - existing.forEach((obj, index) => { - objectFieldToIndex[readField(fieldName, obj)] = index; - }); - } - - incoming.forEach(obj => { - const field = readField(fieldName, obj); - const index = objectFieldToIndex[field]; - if (typeof index === "number") { - merged[index] = mergeObjects(merged[index], obj); - } else { - objectFieldToIndex[name] = merged.length; - merged.push(obj); - } - }); - - return merged; - } -} \ No newline at end of file diff --git a/client/src/hooks/useLoggedIn.tsx b/client/src/hooks/useLoggedIn.tsx index 8c92e4d..2764c04 100644 --- a/client/src/hooks/useLoggedIn.tsx +++ b/client/src/hooks/useLoggedIn.tsx @@ -1,7 +1,23 @@ -import React, { useState, useContext } from "react"; +import React, { useContext, useEffect } from "react"; -export const LoggedInContext = React.createContext(false); +const LOGGED_IN_KEY = 'loggedIn'; + +export const LoggedInContext = React.createContext(getSavedLoggedIn()); export const useLoggedIn = () => { return useContext(LoggedInContext); -}; \ No newline at end of file +}; + +export function saveLoggedIn(loggedIn: boolean) { + console.log("saveLoggedIn", JSON.stringify(loggedIn)) + window.sessionStorage.setItem(LOGGED_IN_KEY, JSON.stringify(loggedIn)); +} + +export function getSavedLoggedIn(): boolean { + try { + const loggedIn = JSON.parse(window.sessionStorage.getItem(LOGGED_IN_KEY)); + return !!loggedIn; + } catch(err) { + return false; + } +} \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index cee36c1..03c2010 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -2,7 +2,6 @@ import './sass/_all.scss'; import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './components/App'; -import { client } from './gql/client'; import '@fortawesome/fontawesome-free/js/fontawesome' import '@fortawesome/fontawesome-free/js/solid' @@ -12,8 +11,6 @@ import './resources/favicon.png'; import { ApolloProvider } from '@apollo/client'; ReactDOM.render( - - - , + , document.getElementById('app') ); diff --git a/client/src/util/apollo.ts b/client/src/util/apollo.ts new file mode 100644 index 0000000..519dee5 --- /dev/null +++ b/client/src/util/apollo.ts @@ -0,0 +1,74 @@ + +import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client'; +import { Config } from '../config'; +import { WebSocketLink } from "@apollo/client/link/ws"; +import { RetryLink } from "@apollo/client/link/retry"; +import { onError } from "@apollo/client/link/error"; +import { SubscriptionClient } from "subscriptions-transport-ws"; +import { User } from '../types/user'; + +export function createClient(setLoggedIn: (boolean) => void) { + const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, { + reconnect: true, + }); + + const errorLink = onError(({ operation }) => { + const { response } = operation.getContext(); + if (response.status === 401) setLoggedIn(false); + }); + + const retryLink = new RetryLink({attempts: {max: 2}}).split( + (operation) => operation.operationName === 'subscription', + new WebSocketLink(subscriptionClient), + new HttpLink({ + uri: Config.graphQLEndpoint, + credentials: 'include', + }) + ); + + const cache = new InMemoryCache({ + typePolicies: { + Workgroup: { + fields: { + members: { + merge: mergeArrayByField("id"), + } + } + } + } + }); + + return new ApolloClient({ + cache: cache, + link: from([ + errorLink, + retryLink + ]), + }); +} + +export function mergeArrayByField(fieldName: string) { + return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => { + const merged: any[] = existing ? existing.slice(0) : []; + + const objectFieldToIndex: Record = Object.create(null); + if (existing) { + existing.forEach((obj, index) => { + objectFieldToIndex[readField(fieldName, obj)] = index; + }); + } + + incoming.forEach(obj => { + const field = readField(fieldName, obj); + const index = objectFieldToIndex[field]; + if (typeof index === "number") { + merged[index] = mergeObjects(merged[index], obj); + } else { + objectFieldToIndex[name] = merged.length; + merged.push(obj); + } + }); + + return merged; + } +} \ No newline at end of file diff --git a/internal/voter/manager.go b/internal/voter/manager.go index 0e99ca9..cf56bd7 100644 --- a/internal/voter/manager.go +++ b/internal/voter/manager.go @@ -3,8 +3,6 @@ package voter import ( "context" - "github.com/davecgh/go-spew/spew" - "github.com/pkg/errors" "gitlab.com/wpetit/goweb/logger" ) @@ -40,8 +38,6 @@ func (m *Manager) Authorized(ctx context.Context, subject interface{}, obj inter decisions = append(decisions, dec) } - spew.Dump(decisions) - result, err := m.strategy(ctx, decisions) if err != nil { return Deny, errors.WithStack(err)