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