Mise en place d'un système de vérification des autorisations côté

serveur

- 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
This commit is contained in:
wpetit 2020-09-04 10:10:32 +02:00
parent bc56c9dbae
commit 3ef495445a
19 changed files with 669 additions and 44 deletions

View File

@ -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
}

View File

@ -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",

View File

@ -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) {

7
internal/graph/error.go Normal file
View File

@ -0,0 +1,7 @@
package graph
import "errors"
var (
ErrForbidden = errors.New("forbidden")
)

View File

@ -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
}

View File

@ -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
}

13
internal/model/action.go Normal file
View File

@ -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"
)

View File

@ -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")

View File

@ -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{}
}

15
internal/model/helper.go Normal file
View File

@ -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
}

View File

@ -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}
}

View File

@ -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{}
}

View File

@ -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{})

View File

@ -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"
}
}

64
internal/voter/manager.go Normal file
View File

@ -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}
}

View File

@ -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
}
}

33
internal/voter/service.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}
}
}