From 9e3a2b3666c3b30ccd1ac888f00eb41bdc00f672 Mon Sep 17 00:00:00 2001 From: Daniel Dibiasi <10942109+ddibiasi@users.noreply.github.com> Date: Thu, 11 Jul 2024 11:28:53 +0200 Subject: [PATCH] Added ApplyUpdate and corresponding tests, adapted README.md, added example in main.go #1 --- README.md | 118 ++++++++++++++++++++++++++++++++--------- cmd/main.go | 21 +++++++- mapper.go | 38 ++++++++++---- mapper_test.go | 140 ++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 256 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 82bf4f2..4caa57b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Typically users accessing REST routes are authenticated via some form of identity provider. `wm` bridges the gap between the persistence layer, User Auth and exposing models via REST (or other technologies). With `wm` it is possible to convert your DB model to a web model, **without** manually creating or generating web models. -To define which fields should be visible to what roles, just add struct tags and call `ToWeb()`. +To define which fields should be visible to what roles, just add struct tags.

@@ -32,38 +32,68 @@ type SmallExample struct { // Staff is only allowed read the Name field // Developers and admins are also allowed to write / change it Name string `wm:"staff:r;developer:rw;admin:rw"` + // Staff, Developer and Admin are allowed to read and write to field Comment + Comment string `wm:"staff:rw;developer:rw;admin:rw"` } ``` -The actual mapping is done via two functions: `ToWeb` and `ToDb`. +The actual mapping is done via three functions: `ToWeb`, `ToDb` and `ApplyUpdate`. -`ToWeb` creates a web model. A web model most often only contains subset of +`ToWeb` creates a web model. A web model most often only contains a subset of data in a model (i.e. don't expose secrets). This is achieved by creating a copy of the source struct and only setting values the specified user is allowed to read. ```go -source := SmallExample{Name: "Linus the cat"} +source := SmallExample{Name: "Linus the cat", Comment: "A fine boi"} role := "staff" webModel, err := wm.ToWeb(source, role) -// dbModel.Name is populated because staff is allowed to read from the field `Name` +// webModel.Name and webModel.Comment is populated because staff is allowed to read from the field `Name` and `Comment` +// { +// "Name": "Linus the cat", +// "Comment": "A fine boi" +// } ``` -`ToDb` creates a copy of the source struct to be used in your datalayer, but only -sets fields that the user is allowed to write to. +`ToDb` creates a new instance of the type of the source struct, but only +sets fields that the user is allowed to write to. +This method can be useful to create and store a new model in the database. ```go -source := SmallExample{Name: "Linus the cat"} +source := SmallExample{Name: "Linus the cat", Comment: "A heckin' chonker"} role := "staff" dbModel, err := wm.ToDb(source, role) // dbModel.Name is empty because staff is not allowed to write to the field `Name` +// dbModel.Comment is populated because staff is allowed to write to `Comment` +// { +// "Name": "", +// "Comment": "A heckin' chonker" +// } +``` + +`ApplyUpdate` applies changes from a new model to an old model. +Only sets fields from the new model on the old model, +if the supplied role is allowed to write to (W or RW). +This method can be useful to update existing models in the database. + +```go +old := SmallExample{Name: "Linus the cat", Comment: "A heckin' chonker"} +new := SmallExample{Name: "New name", Comment: "MEGACHONKER"} +role := "staff" +updatedModel, err := wm.ApplyUpdate(old, new, role) +// updatedModel.Name still is "Linus the cat" because staff is not allowed to change the name +// updatedModel.Comment is set to "MEGACHONKER" +// { +// "Name": "Linus the cat", +// "Comment": "MEGACHONKER" +// } ``` Please do not rely solely on `wm` to "sanitize" your models before storing it in the database. Make sure to check for SQL injections and other malicious techniques. ## Real World Examples -The following pseudo API provides an endpoint to `GET` and `POST` recipes. +The following pseudo API provides an endpoint to `GET`, `POST` and `PUT` recipes. The `Recipe` struct is either served or consumed. `Recipe.SecretIngredients` must not be exposed to Staff members. Staff members are only allowed to update the Details of a recipe, but are not allowed to write / change the name. @@ -108,6 +138,27 @@ func PostRecipe(database db.YourDbHandler, manager *scs.SessionManager) http.Han render.Status(r, http.StatusOK) } } + +func PutRecipe(database db.YourDbHandler, manager *scs.SessionManager) http.HandlerFunc { + return func (w http.ResponseWriter, r *http.Request) { + var webRecipe Recipe + err := httptools.ParseBodyToStruct(r.Body, &webRecipe) + if err != nil {...} + // get already existing recipe from db + dbRecipe, err := database.GetRecipeByName(webRecipe.Name) + if err != nil {...} + // user role is set on session via middleware + userRole := manager.GetString(r.Context(), "USER_ROLE_KEY") + // apply updates from webRecipe to dbRecipe + updatedRecipe, err := wm.ApplyUpdate(dbRecipe, webRecipe, userRole) + if err != nil {...} + // store in db + dbRecipe, err = database.AddRecipe(updatedRecipe) + if err != nil {...} + // set status to ok + render.Status(r, http.StatusOK) + } +} ``` ## Showcase @@ -128,32 +179,49 @@ The following snippet highlights what data each role sees: ```go ToWeb() --------------- -staff sees: -internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} +staff sees: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} -developer sees: -internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"", CanOnlyBeWrittenTo:""} +developer sees: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"", CanOnlyBeWrittenTo:""} -admin sees: -internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} +admin sees: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} -unauthorized sees: -internal.SecretItem{Name:"", Comment:"", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} +unauthorized sees: +&internal.SecretItem{Name:"", Comment:"", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} ``` ```go ToDb() --------------- -staff can set: -internal.SecretItem{Name:"", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} +staff can set: +&internal.SecretItem{Name:"", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} + +developer can set: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} + +admin can set: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} + +unauthorized can set: +&internal.SecretItem{Name:"", Comment:"", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} +``` + +Update all possible fields: +```go +ApplyUpdate() +--------------- +staff can set: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"Updated", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Updated"} -developer can set: -internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} +developer can set: +&internal.SecretItem{Name:"Updated", Comment:"Updated", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Updated"} -admin can set: -internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} +admin can set: +&internal.SecretItem{Name:"Updated", Comment:"Updated", SecretInfo:"Updated", TopSecret:"Updated", CanOnlyBeWrittenTo:"Updated"} -unauthorized can set: -internal.SecretItem{Name:"", Comment:"", SecretInfo:"", TopSecret:"", CanOnlyBeWrittenTo:""} +unauthorized can set: +&internal.SecretItem{Name:"Crab Burger Recipe", Comment:"The recipe of the famous crusty crab burger", SecretInfo:"Bun, Pickle, Patty, Lettuce", TopSecret:"Do not forget the tomato", CanOnlyBeWrittenTo:"Hecho en Crustáceo Cascarudo"} ``` diff --git a/cmd/main.go b/cmd/main.go index d73a0bb..7748962 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -28,10 +28,27 @@ func main() { fmt.Printf("\nToDb()\n---------------\n") for _, role := range roles { - webModel, err := wm.ToDb(secretItem, role) + dbModel, err := wm.ToDb(secretItem, role) if err != nil { panic(err) } - fmt.Printf("%s can set: \n%#v\n\n", role, webModel) + fmt.Printf("%s can set: \n%#v\n\n", role, dbModel) + } + + update := internal.SecretItem{ + Name: "Updated", + Comment: "Updated", + SecretInfo: "Updated", + TopSecret: "Updated", + CanOnlyBeWrittenTo: "Updated", + } + + fmt.Printf("\nApplyUpdate()\n---------------\n") + for _, role := range roles { + updatedModel, err := wm.ApplyUpdate(secretItem, update, role) + if err != nil { + panic(err) + } + fmt.Printf("%s can set: \n%#v\n\n", role, updatedModel) } } diff --git a/mapper.go b/mapper.go index 7809224..1f7f8be 100644 --- a/mapper.go +++ b/mapper.go @@ -23,21 +23,39 @@ var ( toDBActions = []string{WRITE, READ_WRITE} ) -func ToWeb[T any](dbModel T, role string) (webModel T, err error) { - return doMapping(dbModel, role, toWebActions) +// ToWeb converts dbModel to a webModel. +// Only sets fields on webModel the supplied role is allowed to read from (R or RW). +func ToWeb[T any](dbModel T, role string) (webModel *T, err error) { + return doMapping(dbModel, nil, role, toWebActions) } -func ToDb[T any](webModel T, role string) (dbModel T, err error) { - return doMapping(webModel, role, toDBActions) +// ToDb converts webModel to a dbModel. +// Only sets fields on dbModel the supplied role is allowed to write to (W or RW). +func ToDb[T any](webModel T, role string) (dbModel *T, err error) { + return doMapping(webModel, nil, role, toDBActions) } -func doMapping[T any](sourceModel T, role string, allowedActions []string) (targetModel T, err error) { +// ApplyUpdate applies changes from newModel to oldModel. +// Only sets fields from newModel on oldModel, +// if the supplied role is allowed to write to (W or RW). +func ApplyUpdate[T any](oldModel T, newModel T, role string) (diffModel *T, err error) { + return doMapping(newModel, &oldModel, role, toDBActions) +} + +// doMapping maps sets the fields of sourceModel on targetModel +// if the role is allowed to do so, according to allowedActions. +// If targetModel is nil, a new model of type T is created. +func doMapping[T any](sourceModel T, targetModel *T, role string, allowedActions []string) (*T, error) { + if targetModel == nil { + targetModel = new(T) + } + // sourceSchema needed to get struct tags sourceSchema := reflect.TypeOf(sourceModel) // sourceValues is needed to get values from sourceModel sourceValues := reflect.ValueOf(&sourceModel).Elem() // targetValues is needed to access values of targetModel - targetValues := reflect.ValueOf(&targetModel).Elem() + targetValues := reflect.ValueOf(targetModel).Elem() for i := 0; i < sourceSchema.NumField(); i++ { field := sourceSchema.Field(i) @@ -53,18 +71,18 @@ func doMapping[T any](sourceModel T, role string, allowedActions []string) (targ // permMapping == role:permission permMapping := strings.Split(acl, ":") if len(permMapping) != 2 { - return targetModel, ImproperFormatErr + return nil, ImproperFormatErr } // check if allowed to set field webModelField := targetValues.FieldByName(field.Name) if !webModelField.IsValid() { - return targetModel, FieldNotAddressableErr + return nil, FieldNotAddressableErr } if permMapping[0] == role { if slices.Contains(allowedActions, permMapping[1]) { // only set value on targetModel if role is allowed to perform action - test := sourceValues.FieldByName(field.Name) - webModelField.Set(test) + sourceVal := sourceValues.FieldByName(field.Name) + webModelField.Set(sourceVal) } } } diff --git a/mapper_test.go b/mapper_test.go index cbaabfd..c8e3392 100644 --- a/mapper_test.go +++ b/mapper_test.go @@ -23,11 +23,11 @@ func TestToWebAdmin(t *testing.T) { } // admin is allowed to see all fields - assert.Equal(t, webModel.Name, secretItem.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) - assert.Equal(t, webModel.SecretInfo, secretItem.SecretInfo) - assert.Equal(t, webModel.TopSecret, secretItem.TopSecret) - assert.Equal(t, webModel.CanOnlyBeWrittenTo, secretItem.CanOnlyBeWrittenTo) + assert.Equal(t, secretItem.Name, webModel.Name) + assert.Equal(t, secretItem.Comment, webModel.Comment) + assert.Equal(t, secretItem.SecretInfo, webModel.SecretInfo) + assert.Equal(t, secretItem.TopSecret, webModel.TopSecret) + assert.Equal(t, secretItem.CanOnlyBeWrittenTo, webModel.CanOnlyBeWrittenTo) } func TestToWebDeveloper(t *testing.T) { @@ -39,9 +39,9 @@ func TestToWebDeveloper(t *testing.T) { } // developer is not allowed to see all fields - assert.Equal(t, webModel.Name, secretItem.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) - assert.Equal(t, webModel.SecretInfo, secretItem.SecretInfo) + assert.Equal(t, secretItem.Name, webModel.Name) + assert.Equal(t, secretItem.Comment, webModel.Comment) + assert.Equal(t, secretItem.SecretInfo, webModel.SecretInfo) assert.Empty(t, webModel.TopSecret) assert.Empty(t, webModel.CanOnlyBeWrittenTo) } @@ -55,8 +55,8 @@ func TestToWebStaff(t *testing.T) { } // staff is not allowed to see all fields - assert.Equal(t, webModel.Name, secretItem.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) + assert.Equal(t, secretItem.Name, webModel.Name) + assert.Equal(t, secretItem.Comment, webModel.Comment) assert.Empty(t, webModel.SecretInfo) assert.Empty(t, webModel.TopSecret) assert.Empty(t, webModel.CanOnlyBeWrittenTo) @@ -83,15 +83,15 @@ func TestToDbAdmin(t *testing.T) { webModel, err := ToDb(secretItem, role) if err != nil { - t.Fatalf("Error creating webModel: %v", err) + t.Fatalf("Error creating dbModel: %v", err) } // admin is allowed to write to all fields - assert.Equal(t, webModel.Name, secretItem.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) - assert.Equal(t, webModel.SecretInfo, secretItem.SecretInfo) - assert.Equal(t, webModel.TopSecret, secretItem.TopSecret) - assert.Equal(t, webModel.CanOnlyBeWrittenTo, secretItem.CanOnlyBeWrittenTo) + assert.Equal(t, secretItem.Name, webModel.Name) + assert.Equal(t, secretItem.Comment, webModel.Comment) + assert.Equal(t, secretItem.SecretInfo, webModel.SecretInfo) + assert.Equal(t, secretItem.TopSecret, webModel.TopSecret) + assert.Equal(t, secretItem.CanOnlyBeWrittenTo, webModel.CanOnlyBeWrittenTo) } func TestToDbDeveloper(t *testing.T) { @@ -99,15 +99,15 @@ func TestToDbDeveloper(t *testing.T) { webModel, err := ToDb(secretItem, role) if err != nil { - t.Fatalf("Error creating webModel: %v", err) + t.Fatalf("Error creating dbModel: %v", err) } // developer is not allowed to write to all fields - assert.Equal(t, webModel.Name, secretItem.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) + assert.Equal(t, secretItem.Name, webModel.Name) + assert.Equal(t, secretItem.Comment, webModel.Comment) assert.Empty(t, webModel.SecretInfo) assert.Empty(t, webModel.TopSecret) - assert.Equal(t, webModel.CanOnlyBeWrittenTo, secretItem.CanOnlyBeWrittenTo) + assert.Equal(t, secretItem.CanOnlyBeWrittenTo, webModel.CanOnlyBeWrittenTo) } func TestToDbStaff(t *testing.T) { @@ -115,15 +115,15 @@ func TestToDbStaff(t *testing.T) { webModel, err := ToDb(secretItem, role) if err != nil { - t.Fatalf("Error creating webModel: %v", err) + t.Fatalf("Error creating dbModel: %v", err) } // staff is not allowed to write to all fields assert.Empty(t, webModel.Name) - assert.Equal(t, webModel.Comment, secretItem.Comment) + assert.Equal(t, secretItem.Comment, webModel.Comment) assert.Empty(t, webModel.SecretInfo) assert.Empty(t, webModel.TopSecret) - assert.Equal(t, webModel.CanOnlyBeWrittenTo, secretItem.CanOnlyBeWrittenTo) + assert.Equal(t, secretItem.CanOnlyBeWrittenTo, webModel.CanOnlyBeWrittenTo) } func TestToDbUnauthorized(t *testing.T) { @@ -131,7 +131,7 @@ func TestToDbUnauthorized(t *testing.T) { webModel, err := ToDb(secretItem, role) if err != nil { - t.Fatalf("Error creating webModel: %v", err) + t.Fatalf("Error creating dbModel: %v", err) } // unauthorized is not allowed to write to any fields @@ -141,3 +141,95 @@ func TestToDbUnauthorized(t *testing.T) { assert.Empty(t, webModel.TopSecret) assert.Empty(t, webModel.CanOnlyBeWrittenTo) } + +func TestApplyUpdateAdmin(t *testing.T) { + role := "admin" + newSecretItem := internal.SecretItem{ + Name: "Updated", + Comment: "Updated", + SecretInfo: "Updated", + TopSecret: "Updated", + CanOnlyBeWrittenTo: "Updated", + } + + updated, err := ApplyUpdate(secretItem, newSecretItem, role) + if err != nil { + t.Fatalf("Error creating updated: %v", err) + } + + // admin is allowed to write to all fields + assert.Equal(t, newSecretItem.Name, updated.Name) + assert.Equal(t, newSecretItem.Comment, updated.Comment) + assert.Equal(t, newSecretItem.SecretInfo, updated.SecretInfo) + assert.Equal(t, newSecretItem.TopSecret, updated.TopSecret) + assert.Equal(t, newSecretItem.CanOnlyBeWrittenTo, updated.CanOnlyBeWrittenTo) +} + +func TestApplyUpdateDeveloper(t *testing.T) { + role := "developer" + newSecretItem := internal.SecretItem{ + Name: "Updated", + Comment: "Updated", + SecretInfo: "Updated", + TopSecret: "Updated", + CanOnlyBeWrittenTo: "Updated", + } + + updated, err := ApplyUpdate(secretItem, newSecretItem, role) + if err != nil { + t.Fatalf("Error creating updated: %v", err) + } + + // developer is not allowed to write to all fields + assert.Equal(t, newSecretItem.Name, updated.Name) + assert.Equal(t, newSecretItem.Comment, updated.Comment) + assert.Equal(t, secretItem.SecretInfo, updated.SecretInfo) + assert.Equal(t, secretItem.TopSecret, updated.TopSecret) + assert.Equal(t, newSecretItem.CanOnlyBeWrittenTo, updated.CanOnlyBeWrittenTo) +} + +func TestApplyUpdateStaff(t *testing.T) { + role := "staff" + newSecretItem := internal.SecretItem{ + Name: "Updated", + Comment: "Updated", + SecretInfo: "Updated", + TopSecret: "Updated", + CanOnlyBeWrittenTo: "Updated", + } + + updated, err := ApplyUpdate(secretItem, newSecretItem, role) + if err != nil { + t.Fatalf("Error creating updated: %v", err) + } + + // staff is not allowed to write to all fields + assert.Equal(t, secretItem.Name, updated.Name) + assert.Equal(t, newSecretItem.Comment, updated.Comment) + assert.Equal(t, secretItem.SecretInfo, updated.SecretInfo) + assert.Equal(t, secretItem.TopSecret, updated.TopSecret) + assert.Equal(t, newSecretItem.CanOnlyBeWrittenTo, updated.CanOnlyBeWrittenTo) +} + +func TestApplyUpdateUnauthorized(t *testing.T) { + role := "unauthorized" + newSecretItem := internal.SecretItem{ + Name: "Updated", + Comment: "Updated", + SecretInfo: "Updated", + TopSecret: "Updated", + CanOnlyBeWrittenTo: "Updated", + } + + updated, err := ApplyUpdate(secretItem, newSecretItem, role) + if err != nil { + t.Fatalf("Error creating updated: %v", err) + } + + // staff is not allowed to write to all fields + assert.Equal(t, secretItem.Name, updated.Name) + assert.Equal(t, secretItem.Comment, updated.Comment) + assert.Equal(t, secretItem.SecretInfo, updated.SecretInfo) + assert.Equal(t, secretItem.TopSecret, updated.TopSecret) + assert.Equal(t, secretItem.CanOnlyBeWrittenTo, updated.CanOnlyBeWrittenTo) +}