Skip to content

Commit

Permalink
Merge pull request #2 from Hoss-Mobility/1-add-possibility-update-models
Browse files Browse the repository at this point in the history
Add ApplyUpdate #1
  • Loading branch information
ddibiasi authored Jul 11, 2024
2 parents f0bf86e + 9e3a2b3 commit d2fad80
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 61 deletions.
118 changes: 93 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="https://dibiasi.dev/share/wm.jpg" width=30% height=30% class="center">
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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"}
```

21 changes: 19 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
38 changes: 28 additions & 10 deletions mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
}
Expand Down
Loading

0 comments on commit d2fad80

Please sign in to comment.