1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-08-08 22:36:41 +02:00

completed coding user package and starting unittests

This commit is contained in:
Lee Brown
2019-05-27 02:44:40 -05:00
parent 82cd108ed6
commit 895128bbbe
19 changed files with 1946 additions and 379 deletions

View File

@@ -3,13 +3,13 @@ package main
import (
"encoding/json"
"expvar"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/schema"
"github.com/lib/pq"
"log"
"net/url"
"os"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
"github.com/gitwak/sqlxmigrate"
"github.com/kelseyhightower/envconfig"
_ "github.com/lib/pq"
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
@@ -114,17 +114,8 @@ func main() {
// =========================================================================
// Start Migrations
// Load list of Schema migrations and init new sqlxmigrate client
migrations := migrationList(masterDb, log)
m := sqlxmigrate.New(masterDb, sqlxmigrate.DefaultOptions, migrations)
m.SetLogger(log)
// Append any schema that need to be applied if this is a fresh migration
// ie. the migrations database table does not exist.
m.InitSchema(initSchema(masterDb, log))
// Execute the migrations
if err = m.Migrate(); err != nil {
if err = schema.Migrate(masterDb, log); err != nil {
log.Fatalf("main : Migrate : %v", err)
}
log.Printf("main : Migrate : Completed")

View File

@@ -2,21 +2,24 @@ module geeks-accelerator/oss/saas-starter-kit/example-project
require (
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808 // indirect
github.com/Masterminds/squirrel v1.1.0 // indirect
github.com/aws/aws-sdk-go v1.19.33
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dimfeld/httptreemux v5.0.1+incompatible
github.com/gitwak/gondolier v0.0.0-20190521205431-504d297a6c42 // indirect
github.com/gitwak/sqlxmigrate v0.0.0-20190525131054-1f06ba9f0748
github.com/gitwak/sqlxmigrate v0.0.0-20190527063335-e98d5d44fc0b
github.com/go-playground/locales v0.12.1
github.com/go-playground/universal-translator v0.16.0
github.com/go-redis/redis v6.15.2+incompatible
github.com/golang/protobuf v1.3.1 // indirect
github.com/google/go-cmp v0.2.0
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/huandu/go-sqlbuilder v1.4.0
github.com/jmoiron/sqlx v1.2.0
github.com/kelseyhightower/envconfig v1.3.0
github.com/kr/pretty v0.1.0 // indirect
github.com/leodido/go-urn v1.1.0 // indirect
github.com/lib/pq v1.1.1
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/opentracing/opentracing-go v1.1.0 // indirect
github.com/openzipkin/zipkin-go v0.1.1 // indirect
@@ -25,14 +28,19 @@ require (
github.com/philippgille/gokv v0.5.0 // indirect
github.com/pkg/errors v0.8.1
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
github.com/stretchr/objx v0.2.0 // indirect
github.com/tinylib/msgp v1.1.0 // indirect
go.opencensus.io v0.14.0
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b
golang.org/x/sys v0.0.0-20190516110030-61b9204099cb // indirect
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 // indirect
golang.org/x/sys v0.0.0-20190526052359-791d8a0f4d09 // indirect
golang.org/x/text v0.3.2 // indirect
golang.org/x/tools v0.0.0-20190525145741-7be61e1b0e51 // indirect
google.golang.org/appengine v1.6.0 // indirect
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v9 v9.28.0
gopkg.in/go-playground/validator.v9 v9.29.0
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
gopkg.in/yaml.v2 v2.2.1 // indirect
)

View File

@@ -1,11 +1,14 @@
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808 h1:rxDa2t7Ep7E26WMVHjl+mdLr9Un7yRSzz1CwRW6fWNY=
github.com/GuiaBolso/darwin v0.0.0-20170210191649-86919dfcf808/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
github.com/Masterminds/squirrel v1.1.0 h1:baP1qLdoQCeTw3ifCdOq2dkYc6vGcmRdaociKLbEJXs=
github.com/Masterminds/squirrel v1.1.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/aws/aws-sdk-go v1.19.32 h1:/usjSR6qsKfOKzk4tDNvZq7LqmP5+J0Cq/Uwsr2XVG8=
github.com/aws/aws-sdk-go v1.19.32/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.19.33 h1:qz9ZQtxCUuwBKdc5QiY6hKuISYGeRQyLVA2RryDEDaQ=
github.com/aws/aws-sdk-go v1.19.33/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dimfeld/httptreemux v5.0.1+incompatible h1:Qj3gVcDNoOthBAqftuD596rm4wg/adLLz5xh5CmpiCA=
@@ -18,6 +21,8 @@ github.com/gitwak/sqlxmigrate v0.0.0-20190525050002-e22c656832a9 h1:se8XE/N8ZWAC
github.com/gitwak/sqlxmigrate v0.0.0-20190525050002-e22c656832a9/go.mod h1:e7vYkZWKUHC2Vl0/dIiQRKR3z2HMuswoLf2IiQmnMoQ=
github.com/gitwak/sqlxmigrate v0.0.0-20190525131054-1f06ba9f0748 h1:ln68Q5KHq1hCO2yxOek7ejF0ijfhRkWJqI5D5jjWF3g=
github.com/gitwak/sqlxmigrate v0.0.0-20190525131054-1f06ba9f0748/go.mod h1:e7vYkZWKUHC2Vl0/dIiQRKR3z2HMuswoLf2IiQmnMoQ=
github.com/gitwak/sqlxmigrate v0.0.0-20190527063335-e98d5d44fc0b h1:e1tl9Xzj+Ews1RJiO+G+udgZ5r2IGT3iyyVLe7qcChI=
github.com/gitwak/sqlxmigrate v0.0.0-20190527063335-e98d5d44fc0b/go.mod h1:e7vYkZWKUHC2Vl0/dIiQRKR3z2HMuswoLf2IiQmnMoQ=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
@@ -27,10 +32,13 @@ github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/huandu/go-sqlbuilder v1.4.0 h1:2LIlTDOz63lOETLOIiKBPEu4PUbikmS5LUc3EekwYqM=
github.com/huandu/go-sqlbuilder v1.4.0/go.mod h1:mYfGcZTUS6yJsahUQ3imkYSkGGT3A+owd54+79kkW+U=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
@@ -43,12 +51,18 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01 h1:EPw7R3OAyxHBCyl0oqh3lUZqS5lu3KSxzzGasE0opXQ=
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
@@ -72,6 +86,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 h1:X9XMOYjxEfAYSy3xK1DzO5dMkkWhs9E9UCcS1IERx2k=
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0/go.mod h1:Ad7IjTpvzZO8Fl0vh9AzQ+j/jYZfyp2diGwI8m5q+ns=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
@@ -80,13 +96,27 @@ go.opencensus.io v0.14.0 h1:1eTLxqxSIAylcKoxnNkdhvvBNZDA8JwkKNXxgyma0IA=
go.opencensus.io v0.14.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b h1:2b9XGzhjiYsYPnKXoEfL7klWZQIt8IfyRCz62gCqqlQ=
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f h1:R423Cnkcp5JABoeemiGEPlt9tHXFfw5kvc0yqlxRPWo=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190516110030-61b9204099cb h1:k07iPOt0d6nEnwXF+kHB+iEg+WSuKe/SOQuFM2QoD+E=
golang.org/x/sys v0.0.0-20190516110030-61b9204099cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190526052359-791d8a0f4d09/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190525145741-7be61e1b0e51/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/DataDog/dd-trace-go.v1 v1.13.1 h1:oTzOClfuudNhW9Skkp2jxjqYO92uDKXqKLbiuPA13Rk=
gopkg.in/DataDog/dd-trace-go.v1 v1.13.1/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0 h1:p/8j8WV6HC+6c99FMWIPrPPs+PiXU/ShrBxHbO8S8V0=
@@ -98,6 +128,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.28.0 h1:6pzvnzx1RWaaQiAmv6e1DvCFULRaz5cKoP5j1VcrLsc=
gopkg.in/go-playground/validator.v9 v9.28.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/go-playground/validator.v9 v9.29.0 h1:5ofssLNYgAA/inWn6rTZ4juWpRJUwEnXc1LG2IeXwgQ=
gopkg.in/go-playground/validator.v9 v9.29.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=

View File

@@ -22,18 +22,21 @@ const Key ctxKey = 1
// Claims represents the authorization claims transmitted via a JWT.
type Claims struct {
Roles []string `json:"roles"`
AccountIds []string `json:"accounts"`
Roles []string `json:"roles"`
jwt.StandardClaims
}
// NewClaims constructs a Claims value for the identified user. The Claims
// expire within a specified duration of the provided time. Additional fields
// of the Claims can be set after calling NewClaims is desired.
func NewClaims(subject string, roles []string, now time.Time, expires time.Duration) Claims {
func NewClaims(userId, accountId string, accountIds []string, roles []string, now time.Time, expires time.Duration) Claims {
c := Claims{
Roles: roles,
AccountIds: accountIds,
Roles: roles,
StandardClaims: jwt.StandardClaims{
Subject: subject,
Subject: userId,
Audience: accountId,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(expires).Unix(),
},

View File

@@ -10,13 +10,19 @@ import (
// Container contains the information about the container.
type Container struct {
ID string
Port string
ID string
Port string
User string
Pass string
Database string
}
// StartMongo runs a mongo container to execute commands.
func StartMongo(log *log.Logger) (*Container, error) {
cmd := exec.Command("docker", "run", "-P", "-d", "mongo:3-jessie")
// StartPostgres runs a postgres container to execute commands.
func StartPostgres(log *log.Logger) (*Container, error) {
user := "postgres"
pass := "postgres"
cmd := exec.Command("docker", "run", "--env", "POSTGRES_USER="+user, "--env", "POSTGRES_PASSWORD="+pass, "-P", "-d", "postgres:11-alpine")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
@@ -36,9 +42,9 @@ func StartMongo(log *log.Logger) (*Container, error) {
var doc []struct {
NetworkSettings struct {
Ports struct {
TCP27017 []struct {
TCP5432 []struct {
HostPort string `json:"HostPort"`
} `json:"27017/tcp"`
} `json:"5432/tcp"`
} `json:"Ports"`
} `json:"NetworkSettings"`
}
@@ -47,8 +53,11 @@ func StartMongo(log *log.Logger) (*Container, error) {
}
c := Container{
ID: id,
Port: doc[0].NetworkSettings.Ports.TCP27017[0].HostPort,
ID: id,
Port: doc[0].NetworkSettings.Ports.TCP5432[0].HostPort,
User: user,
Pass: pass,
Database: "postgres",
}
log.Println("DB Port:", c.Port)
@@ -56,8 +65,8 @@ func StartMongo(log *log.Logger) (*Container, error) {
return &c, nil
}
// StopMongo stops and removes the specified container.
func StopMongo(log *log.Logger, c *Container) error {
// StopPostgres stops and removes the specified container.
func StopPostgres(log *log.Logger, c *Container) error {
if err := exec.Command("docker", "stop", c.ID).Run(); err != nil {
return err
}

View File

@@ -3,6 +3,8 @@ package tests
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/schema"
"io"
"log"
"os"
"runtime/debug"
@@ -13,7 +15,6 @@ import (
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/jmoiron/sqlx"
"github.com/pborman/uuid"
)
// Success and failure markers.
@@ -40,12 +41,13 @@ func New() *Test {
// ============================================================
// Init AWS Session
awsSession := session.Must(session.NewSession())
// ============================================================
// Startup Mongo container
// Startup Postgres container
container, err := docker.StartMongo(log)
container, err := docker.StartPostgres(log)
if err != nil {
log.Fatalln(err)
}
@@ -53,18 +55,43 @@ func New() *Test {
// ============================================================
// Configuration
dbDialTimeout := 25 * time.Second
dbHost := fmt.Sprintf("mongodb://localhost:%s/gotraining", container.Port)
dbHost := fmt.Sprintf("postgres://%s:%s@127.0.0.1:%s/%s?timezone=UTC&sslmode=disable", container.User, container.Pass, container.Port, container.Database)
fmt.Println(dbHost)
// ============================================================
// Start Mongo
// Start Postgres
log.Println("main : Started : Initialize Postgres")
var masterDB *sqlx.DB
for i := 0; i <= 20; i++ {
masterDB, err = sqlx.Open("postgres", dbHost)
if err != nil {
break
}
// Make sure the database is ready for queries.
_, err = masterDB.Exec("SELECT 1")
if err != nil {
if err != io.EOF {
break
}
time.Sleep(time.Second)
} else {
break
}
}
log.Println("main : Started : Initialize Mongo")
masterDB, err := db.New(dbHost, dbDialTimeout)
if err != nil {
log.Fatalf("startup : Register DB : %v", err)
}
// Execute the migrations
if err = schema.Migrate(masterDB, log); err != nil {
log.Fatalf("main : Migrate : %v", err)
}
log.Printf("main : Migrate : Completed")
return &Test{log, masterDB, container, awsSession}
}
@@ -72,7 +99,7 @@ func New() *Test {
// done in a defer immediately after calling New.
func (t *Test) TearDown() {
t.MasterDB.Close()
if err := docker.StopMongo(t.Log, t.container); err != nil {
if err := docker.StopPostgres(t.Log, t.container); err != nil {
t.Log.Println(err)
}
}
@@ -87,7 +114,7 @@ func Recover(t *testing.T) {
// Context returns an app level context for testing.
func Context() context.Context {
values := web.Values{
TraceID: uuid.New(),
TraceID: uint64(time.Now().UnixNano()),
Now: time.Now(),
}

View File

@@ -1,4 +1,4 @@
package main
package schema
import (
"github.com/jmoiron/sqlx"

View File

@@ -0,0 +1,146 @@
package schema
import (
"database/sql"
"log"
"github.com/gitwak/sqlxmigrate"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/pkg/errors"
)
// migrationList returns a list of migrations to be executed. If the id of the
// migration already exists in the migrations table it will be skipped.
func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return []*sqlxmigrate.Migration{
// create table users
{
ID: "20190522-01a",
Migrate: func(tx *sql.Tx) error {
q1 := `CREATE TYPE user_status_t as enum('active','disabled')`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `CREATE TABLE IF NOT EXISTS users (
id char(36) NOT NULL,
email varchar(200) NOT NULL,
name varchar(200) NOT NULL DEFAULT '',
password_hash varchar(256) NOT NULL,
password_salt varchar(36) NOT NULL,
password_reset varchar(36) DEFAULT NULL,
status user_status_t NOT NULL DEFAULT 'active',
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT email UNIQUE (email)
) ;`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q1 := `DROP TYPE user_status_t`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `DROP TABLE IF EXISTS users`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
},
// create new table accounts
{
ID: "20190522-01b",
Migrate: func(tx *sql.Tx) error {
q1 := `CREATE TYPE account_status_t as enum('active','pending','disabled')`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `CREATE TABLE IF NOT EXISTS accounts (
id char(36) NOT NULL,
name varchar(255) NOT NULL,
address1 varchar(255) NOT NULL DEFAULT '',
address2 varchar(255) NOT NULL DEFAULT '',
city varchar(100) NOT NULL DEFAULT '',
region varchar(255) NOT NULL DEFAULT '',
country varchar(255) NOT NULL DEFAULT '',
zipcode varchar(20) NOT NULL DEFAULT '',
status account_status_t NOT NULL DEFAULT 'active',
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
signup_user_id char(36) DEFAULT NULL,
billing_user_id char(36) DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT name UNIQUE (name)
)`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q1 := `DROP TYPE account_status_t`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `DROP TABLE IF EXISTS accounts`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
},
// create new table user_accounts
{
ID: "20190522-01c",
Migrate: func(tx *sql.Tx) error {
q1 := `CREATE TYPE user_account_role_t as enum('admin', 'user')`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `CREATE TABLE IF NOT EXISTS users_accounts (
id char(36) NOT NULL,
account_id char(36) NOT NULL,
user_id char(36) NOT NULL,
roles user_account_role_t[] NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT user_account UNIQUE (user_id,account_id)
)`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q1 := `DROP TYPE user_account_role_t`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `DROP TABLE IF EXISTS users_accounts`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
},
}
}

View File

@@ -0,0 +1,22 @@
package schema
import (
"log"
"github.com/gitwak/sqlxmigrate"
"github.com/jmoiron/sqlx"
)
func Migrate(masterDb *sqlx.DB, log *log.Logger) error {
// Load list of Schema migrations and init new sqlxmigrate client
migrations := migrationList(masterDb, log)
m := sqlxmigrate.New(masterDb, sqlxmigrate.DefaultOptions, migrations)
m.SetLogger(log)
// Append any schema that need to be applied if this is a fresh migration
// ie. the migrations database table does not exist.
m.InitSchema(initSchema(masterDb, log))
// Execute the migrations
return m.Migrate()
}

View File

@@ -0,0 +1,85 @@
package user
import (
"context"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"github.com/huandu/go-sqlbuilder"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
// TokenGenerator is the behavior we need in our Authenticate to generate
// tokens for authenticated users.
type TokenGenerator interface {
GenerateToken(auth.Claims) (string, error)
}
// Authenticate finds a user by their email and verifies their password. On
// success it returns a Token that can be used to authenticate in the future.
func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, now time.Time, email, password string) (Token, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Authenticate")
defer span.Finish()
// Generate sql query to select user by email address
query := sqlbuilder.NewSelectBuilder()
query.Where(query.Equal("email", email))
// Run the find, use empty claims to bypass ACLs
res, err := find(ctx, auth.Claims{}, dbConn, query, false)
if err != nil {
return Token{}, err
} else if res == nil || len(res) == 0 {
err = errors.WithStack(ErrAuthenticationFailure)
return Token{}, err
}
u := res[0]
// Append the salt from the user record to the supplied password.
saltedPassword := password + string(u.PasswordHash)
// Compare the provided password with the saved hash. Use the bcrypt
// comparison function so it is cryptographically secure.
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(saltedPassword)); err != nil {
err = errors.WithStack(ErrAuthenticationFailure)
return Token{}, err
}
// Get a list of all the account ids associated with the user.
accounts, err := FindAccountsByUserID(ctx, auth.Claims{}, dbConn, u.ID, false)
if err != nil {
return Token{}, err
}
// Claims needs an audience, select the first account associated with
// the user.
var (
accountId string
roles []string
)
if len(accounts) > 0 {
accountId = accounts[0].ID
roles = accounts[0].Roles
}
// Generate a list of all the account IDs associated with the user so
// the use has the ability to switch between accounts.
accountIds := []string{}
for _, a := range accounts {
accountIds = append(accountIds, a.ID)
}
// If we are this far the request is valid. Create some claims for the user.
claims := auth.NewClaims(u.ID, accountId, accountIds, roles, now, time.Hour)
// Generate a token for the user with the defined claims.
tkn, err := tknGen.GenerateToken(claims)
if err != nil {
return Token{}, errors.Wrap(err, "generating token")
}
return Token{Token: tkn}, nil
}

View File

@@ -1,45 +1,168 @@
package user
import (
"database/sql"
"database/sql/driver"
"time"
"gopkg.in/mgo.v2/bson"
"github.com/lib/pq"
"github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
)
// User represents someone with access to our system.
type User struct {
ID bson.ObjectId `bson:"_id" json:"id"`
Name string `bson:"name" json:"name"`
Email string `bson:"email" json:"email"` // TODO(jlw) enforce uniqueness
Roles []string `bson:"roles" json:"roles"`
ID string `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Email string `db:"email" json:"email"`
PasswordHash []byte `bson:"password_hash" json:"-"`
PasswordSalt string `db:"password_salt" json:"-"`
PasswordHash []byte `db:"password_hash" json:"-"`
PasswordReset sql.NullString `db:"password_reset" json:"-"`
DateModified time.Time `bson:"date_modified" json:"date_modified"`
DateCreated time.Time `bson:"date_created,omitempty" json:"date_created"`
Status UserStatus `db:"status" json:"status"`
Timezone string `db:"timezone" json:"timezone"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ArchivedAt pq.NullTime `db:"archived_at" json:"archived_at"`
}
// NewUser contains information needed to create a new User.
type NewUser struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required"` // TODO(jlw) enforce uniqueness.
Roles []string `json:"roles" validate:"required"` // TODO(jlw) Ensure only includes valid roles.
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"`
// CreateUserRequest contains information needed to create a new User.
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email,unique"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"`
Status *UserStatus `json:"status" validate:"oneof=active disabled"`
Timezone *string `json:"timezone"`
}
// UpdateUser defines what information may be provided to modify an existing
// UpdateUserRequest defines what information may be provided to modify an existing
// User. All fields are optional so clients can send just the fields they want
// changed. It uses pointer fields so we can differentiate between a field that
// was not provided and a field that was provided as explicitly blank. Normally
// we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling.
type UpdateUser struct {
Name *string `json:"name"`
Email *string `json:"email"` // TODO(jlw) enforce uniqueness.
Roles []string `json:"roles"` // TODO(jlw) Ensure only includes valid roles.
Password *string `json:"password"`
PasswordConfirm *string `json:"password_confirm" validate:"omitempty,eqfield=Password"`
type UpdateUserRequest struct {
ID string `validate:"required,uuid"`
Name *string `json:"name"`
Email *string `json:"email" validate:"email,unique"`
Status *UserStatus `json:"status" validate:"oneof=active disabled"`
Timezone *string `json:"timezone"`
}
// UpdatePassword defines what information may be provided to update user password.
type UpdatePasswordRequest struct {
ID string `validate:"required,uuid"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"`
}
// UserFindRequest defines the possible options for search for users
type UserFindRequest struct {
Where *string
Args []interface{}
Order []string
Limit *uint
Offset *uint
IncludedArchived bool
}
// UserAccount defines the one to many relationship of an user to an account.
// Each association of an user to an account has a set of roles defined for the user
// that will be applied when accessing the account.
type UserAccount struct {
ID string `db:"id" json:"id"`
UserID string `db:"user_id" json:"user_id"`
AccountID string `db:"account_id" json:"account_id"`
Roles []string `db:"roles" json:"roles"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ArchivedAt pq.NullTime `db:"archived_at" json:"archived_at"`
}
// AddAccountRequest defines the information needed to add a new account to a user.
type AddAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
Roles []string `json:"roles" validate:"oneof=admin user"`
}
// UpdateAccountRequest defines the information needed to update the roles for
// an existing user account.
type UpdateAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
Roles []string `json:"roles" validate:"oneof=admin user"`
unArchive bool
}
// RemoveAccountRequest defines the information needed to remove an existing
// account for a user. This will archive (soft-delete) the existing database entry.
type RemoveAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
}
// DeleteAccountRequest defines the information needed to delete an existing
// account for a user. This will hard delete the existing database entry.
type DeleteAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
}
// UserAccountFindRequest defines the possible options for search for users accounts
type UserAccountFindRequest struct {
Where *string
Args []interface{}
Order []string
Limit *uint
Offset *uint
IncludedArchived bool
}
// UserStatus values
const (
UserStatus_Active UserStatus = "active"
UserStatus_Disabled UserStatus = "disabled"
)
// UserStatus_Values provides list of valid UserStatus values
var UserStatus_Values = []UserStatus{
UserStatus_Active,
UserStatus_Disabled,
}
// UserStatus represents the status of a user.
type UserStatus string
// Scan supports reading the UserStatus value from the database.
func (s *UserStatus) Scan(value interface{}) error {
asBytes, ok := value.([]byte)
if !ok {
return errors.New("Scan source is not []byte")
}
*s = UserStatus(string(asBytes))
return nil
}
// Value converts the UserStatus value to be stored in the database.
func (s UserStatus) Value() (driver.Value, error) {
v := validator.New()
errs := v.Var(s, "required,oneof=active disabled")
if errs != nil {
return nil, errs
}
// validation would go here
return string(s), nil
}
// String converts the UserStatus value to a string.
func (s UserStatus) String() string {
return string(s)
}
// Token is the payload we deliver to users when they authenticate.

View File

@@ -2,19 +2,21 @@ package user
import (
"context"
"fmt"
"database/sql"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"github.com/huandu/go-sqlbuilder"
"github.com/jmoiron/sqlx"
"github.com/pborman/uuid"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
mgo "gopkg.in/mgo.v2"
"gopkg.in/mgo.v2/bson"
"gopkg.in/go-playground/validator.v9"
)
const usersCollection = "users"
// The database table for User
const usersTableName = "users"
var (
// ErrNotFound abstracts the mgo not found error.
@@ -31,113 +33,463 @@ var (
ErrForbidden = errors.New("Attempted action is not allowed")
)
// List retrieves a list of existing users from the database.
func List(ctx context.Context, dbConn *sqlx.DB) ([]User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.List")
defer span.Finish()
// usersMapColumns is the list of columns needed for mapRowsToUser
var usersMapColumns = "id,name,email,password_salt,password_hash,password_reset,status,timezone,created_at,updated_at,archived_at"
u := []User{}
f := func(collection *mgo.Collection) error {
return collection.Find(nil).All(&u)
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
return nil, errors.Wrap(err, "db.users.find()")
}
return u, nil
}
// Retrieve gets the specified user from the database.
func Retrieve(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) (*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Retrieve")
defer span.Finish()
if !bson.IsObjectIdHex(id) {
return nil, ErrInvalidID
}
// If you are not an admin and looking to retrieve someone else then you are rejected.
if !claims.HasRole(auth.RoleAdmin) && claims.Subject != id {
return nil, ErrForbidden
}
q := bson.M{"_id": bson.ObjectIdHex(id)}
var u *User
f := func(collection *mgo.Collection) error {
return collection.Find(q).One(&u)
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
if err == mgo.ErrNotFound {
return nil, ErrNotFound
}
return nil, errors.Wrap(err, fmt.Sprintf("db.users.find(%s)", q))
}
return u, nil
}
// Create inserts a new user into the database.
func Create(ctx context.Context, dbConn *sqlx.DB, nu *NewUser, now time.Time) (*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create")
defer span.Finish()
// Mongo truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
pw, err := bcrypt.GenerateFromPassword([]byte(nu.Password), bcrypt.DefaultCost)
// mapRowsToUser takes the SQL rows and maps it to the UserAccount struct
// with the columns defined by usersMapColumns
func mapRowsToUser(rows *sql.Rows) (*User, error) {
var (
u User
err error
)
err = rows.Scan(&u.ID, &u.Email, &u.PasswordSalt, &u.PasswordHash, &u.PasswordReset, &u.Status, &u.Timezone, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt)
if err != nil {
return nil, errors.Wrap(err, "generating password hash")
}
u := User{
ID: bson.NewObjectId(),
Name: nu.Name,
Email: nu.Email,
PasswordHash: pw,
Roles: nu.Roles,
DateCreated: now,
DateModified: now,
}
f := func(collection *mgo.Collection) error {
return collection.Insert(&u)
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("db.users.insert(%s)", &u))
return nil, errors.WithStack(err)
}
return &u, nil
}
// Update replaces a user document in the database.
func Update(ctx context.Context, dbConn *sqlx.DB, id string, upd *UpdateUser, now time.Time) error {
// CanReadUserId determines if claims has the authority to access the specified user ID.
func CanReadUserId(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
// If the request has claims from a specific user, ensure that the user
// has the correct access to the user.
if claims.Subject != "" {
// When the claims Subject - UserId - does not match the requested user, the
// claims audience - AccountId - should have a record.
if claims.Subject != userID {
query := sqlbuilder.NewSelectBuilder().Select("id").From(usersAccountsTableName)
query.Where(query.And(
query.Equal("account_id", claims.Audience),
query.Equal("user_id", userID),
))
sql, args := query.Build()
sql = dbConn.Rebind(sql)
var userAccountId string
err := dbConn.QueryRowContext(ctx, sql, args...).Scan(&userAccountId)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
return err
}
// When there is now userAccount ID returned, then the current user does not have access
// to the specified user.
if userAccountId == "" {
return errors.WithStack(ErrForbidden)
}
}
}
return nil
}
// CanModifyUserId determines if claims has the authority to modify the specified user ID.
func CanModifyUserId(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
// First check to see if claims can read the user ID
err := CanReadUserId(ctx, claims, dbConn, userID)
if err != nil {
return err
}
// If the request has claims from a specific user, ensure that the user
// has the correct role for updating an existing user.
if claims.Subject != "" {
if claims.Subject == userID {
// All users are allowed to update their own record
} else if claims.HasRole(auth.RoleAdmin) {
// Admin users can update users they have access to.
} else {
return errors.WithStack(ErrForbidden)
}
}
return nil
}
// claimsSql applies a sub-query to the provided query to enforce ACL based on
// the claims provided.
// 1. All role types can access their user ID
// 2. Any user with the same account ID
// 3. No claims, request is internal, no ACL applied
func applyClaimsUserSelect(ctx context.Context, claims auth.Claims, query *sqlbuilder.SelectBuilder) error {
// Claims are empty, don't apply any ACL
if claims.Audience == "" && claims.Subject == "" {
return nil
}
// Build select statement for users_accounts table
subQuery := sqlbuilder.NewSelectBuilder().Select("user_id").From(usersAccountsTableName)
var or []string
if claims.Audience != "" {
or = append(or, subQuery.Equal("account_id", claims.Audience))
}
if claims.Subject != "" {
or = append(or, subQuery.Equal("user_id", claims.Subject))
}
subQuery.Where(or...)
// Append sub query
query.Where(query.In("id", subQuery))
return nil
}
// selectQuery constructs a base select query for User
func selectQuery() *sqlbuilder.SelectBuilder {
query := sqlbuilder.NewSelectBuilder()
query.Select(usersMapColumns)
query.From(usersTableName)
return query
}
// userFindRequestQuery generates the select query for the given find request.
func userFindRequestQuery(req UserFindRequest) *sqlbuilder.SelectBuilder {
query := selectQuery()
if req.Where != nil {
query.Where(*req.Where)
}
if len(req.Order) > 0 {
query.OrderBy(req.Order...)
}
if req.Limit != nil {
query.Limit(int(*req.Limit))
}
if req.Offset != nil {
query.Offset(int(*req.Offset))
}
b := sqlbuilder.Buildf(query.String(), req.Args...)
query.BuilderAs(b, usersTableName)
return query
}
// List enables streaming retrieval of Users from the database. The query results
// will be written to the interface{} resultReceiver channel enabling processing the results while
// they're still being fetched. After all pages have been processed the channel is closed
// Possible types sent to the channel are limited to:
// - error
// - User
//
// rr := make(chan interface{})
//
// go List(ctx, claims, db, rr)
//
// for r := range rr {
// switch v := r.(type) {
// case User:
// // v is of type User
// // process the user here
// case error:
// // v is of type error
// // handle the error here
// }
// }
func List(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindRequest, results chan<- interface{}) {
query := userFindRequestQuery(req)
list(ctx, claims, dbConn, query, req.IncludedArchived, results)
}
// List enables streaming retrieval of Users from the database for the supplied query.
func list(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, includedArchived bool, results chan<- interface{}) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.List")
defer span.Finish()
// Close the channel on complete
defer close(results)
query.Select(usersMapColumns)
query.From(usersTableName)
if !includedArchived {
query.Where(query.IsNull("archived_at"))
}
// Check to see if a sub query needs to be applied for the claims
err := applyClaimsUserSelect(ctx, claims, query)
if err != nil {
results <- err
return
}
sql, args := query.Build()
sql = dbConn.Rebind(sql)
// fetch all places from the db
rows, err := dbConn.QueryContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
results <- errors.WithMessage(err, "list users failed")
return
}
// iterate over each row
for rows.Next() {
u, err := mapRowsToUser(rows)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
results <- err
return
}
results <- u
}
}
// Find gets all the users from the database based on the request params
func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindRequest) ([]*User, error) {
query := userFindRequestQuery(req)
return find(ctx, claims, dbConn, query, req.IncludedArchived)
}
// find gets all the users from the database based on the query
func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, includedArchived bool) ([]*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Find")
defer span.Finish()
query.Select(usersMapColumns)
query.From(usersTableName)
if !includedArchived {
query.Where(query.IsNull("archived_at"))
}
// Check to see if a sub query needs to be applied for the claims
err := applyClaimsUserSelect(ctx, claims, query)
if err != nil {
return nil, err
}
sql, args := query.Build()
sql = dbConn.Rebind(sql)
// fetch all places from the db
rows, err := dbConn.QueryContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessage(err, "find users failed")
return nil, err
}
// iterate over each row
resp := []*User{}
for rows.Next() {
u, err := mapRowsToUser(rows)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
return nil, err
}
resp = append(resp, u)
}
return resp, nil
}
// Retrieve gets the specified user from the database.
func FindById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, includedArchived bool) (*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindById")
defer span.Finish()
// Filter base select query by ID
query := selectQuery()
query.Where(query.Equal("id", id))
res, err := find(ctx, claims, dbConn, query, includedArchived)
if err != nil {
return nil, err
} else if res == nil || len(res) == 0 {
err = errors.WithMessagef(ErrNotFound, "user %s not found", id)
return nil, err
}
u := res[0]
return u, nil
}
// Validation an email address is unique excluding the current user ID.
func uniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bool, error) {
query := sqlbuilder.NewSelectBuilder().Select("id").From(usersTableName)
query.Where(query.And(
query.Equal("email", email),
query.NotEqual("id", userId),
))
queryStr, args := query.Build()
queryStr = dbConn.Rebind(queryStr)
var existingId string
err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId)
if err != nil && err != sql.ErrNoRows {
err = errors.Wrapf(err, "query - %s", query.String())
return false, err
}
// When an ID was found in the db, the email is not unique.
if existingId != "" {
return false, nil
}
return true, nil
}
// Create inserts a new user into the database.
func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req CreateUserRequest, now time.Time) (*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create")
defer span.Finish()
v := validator.New()
// Validation email address is unique in the database.
uniq, err := uniqueEmail(ctx, dbConn, req.Email, "")
if err != nil {
return nil, err
}
f := func(fl validator.FieldLevel) bool {
if fl.Field().String() == "invalid" {
return false
}
return uniq
}
v.RegisterValidation("unique", f)
// Validate the request.
err = v.Struct(req)
if err != nil {
return nil, err
}
// If the request has claims from a specific user, ensure that the user
// has the correct role for creating a new user.
if claims.Subject != "" {
// Users with the role of admin are ony allows to create users.
if !claims.HasRole(auth.RoleAdmin) {
err = errors.WithStack(ErrForbidden)
return nil, err
}
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
passwordSalt := uuid.NewRandom().String()
saltedPassword := req.Password + passwordSalt
passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(err, "generating password hash")
}
u := User{
ID: uuid.NewRandom().String(),
Name: req.Name,
Email: req.Email,
PasswordHash: passwordHash,
PasswordSalt: passwordSalt,
Status: UserStatus_Active,
Timezone: "America/Anchorage",
CreatedAt: now,
UpdatedAt: now,
}
if req.Status != nil {
u.Status = *req.Status
}
if req.Timezone != nil {
u.Timezone = *req.Timezone
}
// Build the insert SQL statement.
query := sqlbuilder.NewInsertBuilder()
query.InsertInto(usersTableName)
query.Cols("id", "name", "email", "password_hash", "password_salt", "status", "timezone", "created_at", "updated_at")
query.Values(u.ID, u.Name, u.Email, u.PasswordHash, u.PasswordSalt, u.Status.String(), u.Timezone, u.CreatedAt, u.UpdatedAt)
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessage(err, "create user failed")
return nil, err
}
return &u, nil
}
// Update replaces a user in the database.
func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateUserRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update")
defer span.Finish()
if !bson.IsObjectIdHex(id) {
return ErrInvalidID
}
v := validator.New()
fields := make(bson.M)
if upd.Name != nil {
fields["name"] = *upd.Name
}
if upd.Email != nil {
fields["email"] = *upd.Email
}
if upd.Roles != nil {
fields["roles"] = upd.Roles
}
if upd.Password != nil {
pw, err := bcrypt.GenerateFromPassword([]byte(*upd.Password), bcrypt.DefaultCost)
// Validation email address is unique in the database.
if req.Email != nil {
uniq, err := uniqueEmail(ctx, dbConn, *req.Email, req.ID)
if err != nil {
return errors.Wrap(err, "generating password hash")
return err
}
fields["password_hash"] = pw
f := func(fl validator.FieldLevel) bool {
if fl.Field().String() == "invalid" {
return false
}
return uniq
}
v.RegisterValidation("unique", f)
}
// Validate the request.
err := v.Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.ID)
if err != nil {
err = errors.WithMessagef(err, "Update %s failed", usersTableName)
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersTableName)
fields := []string{}
if req.Name != nil {
fields = append(fields, query.Assign("name", req.Name))
}
if req.Email != nil {
fields = append(fields, query.Assign("email", req.Email))
}
if req.Status != nil {
fields = append(fields, query.Assign("status", req.Status))
}
if req.Timezone != nil {
fields = append(fields, query.Assign("timezone", req.Timezone))
}
// If there's nothing to update we can quit early.
@@ -145,90 +497,221 @@ func Update(ctx context.Context, dbConn *sqlx.DB, id string, upd *UpdateUser, no
return nil
}
fields["date_modified"] = now
// Append the updated_at field
fields = append(fields, query.Assign("updated_at", now))
m := bson.M{"$set": fields}
q := bson.M{"_id": bson.ObjectIdHex(id)}
query.Set(fields...)
query.Where(query.Equal("id", req.ID))
f := func(collection *mgo.Collection) error {
return collection.Update(q, m)
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "update user %s failed", req.ID)
return err
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
if err == mgo.ErrNotFound {
return ErrNotFound
return nil
}
// Update replaces a user in the database.
func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdatePasswordRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update")
defer span.Finish()
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.ID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Generate new password hash for the provided password.
passwordSalt := uuid.NewRandom()
saltedPassword := req.Password + passwordSalt.String()
passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "generating password hash")
}
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersTableName)
query.Set(
query.Assign("password_hash", passwordHash),
query.Assign("password_salt", passwordSalt),
query.Assign("updated_at", now),
)
query.Where(query.Equal("id", req.ID))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "update password for user %s failed", req.ID)
return err
}
return nil
}
// Archive soft deleted the user from the database.
func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive")
defer span.Finish()
// Defines the struct to apply validation
req := struct {
ID string `validate:"required,uuid"`
}{
ID: userID,
}
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.ID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersTableName)
query.Set(
query.Assign("archived_at", now),
)
query.Where(query.Equal("id", req.ID))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "archive user %s failed", req.ID)
return err
}
// Archive all the associated user accounts
{
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersAccountsTableName)
query.Set(query.Assign("archived_at", now))
query.Where(query.And(
query.Equal("user_id", req.ID),
))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "archive accounts for user %s failed", req.ID)
return err
}
return errors.Wrap(err, fmt.Sprintf("db.customers.update(%s, %s)", q, m))
}
return nil
}
// Delete removes a user from the database.
func Delete(ctx context.Context, dbConn *sqlx.DB, id string) error {
func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete")
defer span.Finish()
if !bson.IsObjectIdHex(id) {
return ErrInvalidID
// Defines the struct to apply validation
req := struct {
ID string `validate:"required,uuid"`
}{
ID: userID,
}
q := bson.M{"_id": bson.ObjectIdHex(id)}
f := func(collection *mgo.Collection) error {
return collection.Remove(q)
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
if err == mgo.ErrNotFound {
return ErrNotFound
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.ID)
if err != nil {
return err
}
// Build the delete SQL statement.
query := sqlbuilder.NewDeleteBuilder()
query.DeleteFrom(usersTableName)
query.Where(query.Equal("id", req.ID))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "delete user %s failed", req.ID)
return err
}
// Delete all the associated user accounts
{
// Build the delete SQL statement.
query := sqlbuilder.NewDeleteBuilder()
query.DeleteFrom(usersAccountsTableName)
query.Where(query.And(
query.Equal("user_id", req.ID),
))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "delete accounts for user %s failed", req.ID)
return err
}
return errors.Wrap(err, fmt.Sprintf("db.users.remove(%s)", q))
}
return nil
}
// TokenGenerator is the behavior we need in our Authenticate to generate
// tokens for authenticated users.
type TokenGenerator interface {
GenerateToken(auth.Claims) (string, error)
}
// Authenticate finds a user by their email and verifies their password. On
// success it returns a Token that can be used to authenticate in the future.
func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, now time.Time, email, password string) (Token, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Authenticate")
defer span.Finish()
q := bson.M{"email": email}
var u *User
f := func(collection *mgo.Collection) error {
return collection.Find(q).One(&u)
}
if _, err := dbConn.ExecContext(ctx, usersCollection, f); err != nil {
// Normally we would return ErrNotFound in this scenario but we do not want
// to leak to an unauthenticated user which emails are in the system.
if err == mgo.ErrNotFound {
return Token{}, ErrAuthenticationFailure
}
return Token{}, errors.Wrap(err, fmt.Sprintf("db.users.find(%s)", q))
}
// Compare the provided password with the saved hash. Use the bcrypt
// comparison function so it is cryptographically secure.
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(password)); err != nil {
return Token{}, ErrAuthenticationFailure
}
// If we are this far the request is valid. Create some claims for the user
// and generate their token.
claims := auth.NewClaims(u.ID.Hex(), u.Roles, now, time.Hour)
tkn, err := tknGen.GenerateToken(claims)
if err != nil {
return Token{}, errors.Wrap(err, "generating token")
}
return Token{Token: tkn}, nil
}

View File

@@ -0,0 +1,388 @@
package user
import (
"context"
"database/sql"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"github.com/huandu/go-sqlbuilder"
"github.com/jmoiron/sqlx"
"github.com/pborman/uuid"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"gopkg.in/go-playground/validator.v9"
)
// The database table for UserAccount
const usersAccountsTableName = "users_accounts"
// The list of columns needed for mapRowsToUserAccount
var usersAccountsMapColumns = "id,user_id,account_id,roles,created_at,updated_at,archived_at"
// mapRowsToUserAccount takes the SQL rows and maps it to the UserAccount struct
// with the columns defined by usersAccountsMapColumns
func mapRowsToUserAccount(rows *sql.Rows) (*UserAccount, error) {
var (
ua UserAccount
err error
)
err = rows.Scan(&ua.ID, &ua.UserID, &ua.AccountID, &ua.Roles, &ua.CreatedAt, &ua.UpdatedAt, &ua.ArchivedAt)
if err != nil {
return nil, errors.WithStack(err)
}
return &ua, nil
}
// applyClaimsUserAccountSelect applies a sub query to enforce ACL for
// the supplied claims. If claims is empty then request must be internal and
// no sub-query is applied. Else a list of user IDs is found all associated
// user accounts.
func applyClaimsUserAccountSelect(ctx context.Context, claims auth.Claims, query *sqlbuilder.SelectBuilder) error {
if claims.Audience == "" && claims.Subject == "" {
return nil
}
// Build select statement for users_accounts table
subQuery := sqlbuilder.NewSelectBuilder().Select("user_id").From(usersAccountsTableName)
var or []string
if claims.Audience != "" {
or = append(or, subQuery.Equal("account_id", claims.Audience))
}
if claims.Subject != "" {
or = append(or, subQuery.Equal("user_id", claims.Subject))
}
subQuery.Where(or...)
// Append sub query
query.Where(query.In("user_id", subQuery))
return nil
}
// AccountSelectQuery
func accountSelectQuery() *sqlbuilder.SelectBuilder {
query := sqlbuilder.NewSelectBuilder()
query.Select(usersAccountsMapColumns)
query.From(usersAccountsTableName)
return query
}
// userFindRequestQuery generates the select query for the given find request.
func accountFindRequestQuery(req UserAccountFindRequest) *sqlbuilder.SelectBuilder {
query := accountSelectQuery()
if req.Where != nil {
query.Where(*req.Where)
}
if len(req.Order) > 0 {
query.OrderBy(req.Order...)
}
if req.Limit != nil {
query.Limit(int(*req.Limit))
}
if req.Offset != nil {
query.Limit(int(*req.Offset))
}
b := sqlbuilder.Buildf(query.String(), req.Args...)
query.BuilderAs(b, usersAccountsMapColumns)
return query
}
// Find gets all the users from the database based on the request params
func FindAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountFindRequest) ([]*UserAccount, error) {
query := accountFindRequestQuery(req)
return findAccounts(ctx, claims, dbConn, query, req.IncludedArchived)
}
// Find gets all the users from the database based on the select query
func findAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, includedArchived bool) ([]*UserAccount, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindAccounts")
defer span.Finish()
query.Select(usersAccountsMapColumns)
query.From(usersAccountsTableName)
if !includedArchived {
query.Where(query.IsNull("archived_at"))
}
// Check to see if a sub query needs to be applied for the claims
err := applyClaimsUserAccountSelect(ctx, claims, query)
if err != nil {
return nil, err
}
sql, args := query.Build()
sql = dbConn.Rebind(sql)
// fetch all places from the db
rows, err := dbConn.QueryContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessage(err, "find accounts failed")
return nil, err
}
// iterate over each row
resp := []*UserAccount{}
for rows.Next() {
ua, err := mapRowsToUserAccount(rows)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
return nil, err
}
resp = append(resp, ua)
}
return resp, nil
}
// Retrieve gets the specified user from the database.
func FindAccountsByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, includedArchived bool) ([]*UserAccount, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindAccountsByUserId")
defer span.Finish()
// Filter base select query by ID
query := sqlbuilder.NewSelectBuilder()
query.Where(query.Equal("user_id", userID))
query.OrderBy("id")
// Execute the find accounts method.
res, err := findAccounts(ctx, claims, dbConn, query, includedArchived)
if err != nil {
return nil, err
}
return res, nil
}
// AddAccount an account for a given user with specified roles.
func AddAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AddAccountRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.AddAccount")
defer span.Finish()
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.UserID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Check to see if there is an existing user account, including archived.
existQuery := accountSelectQuery()
existQuery.Where(existQuery.And(
existQuery.Equal("account_id", req.AccountID),
existQuery.Equal("user_id", req.UserID),
))
existing, err := findAccounts(ctx, claims, dbConn, existQuery, true)
if err != nil {
return err
}
// If there is an existing entry, then update instead of insert.
if len(existing) > 0 {
upReq := UpdateAccountRequest{
UserID: req.UserID,
AccountID: req.AccountID,
Roles: req.Roles,
unArchive: true,
}
return UpdateAccount(ctx, claims, dbConn, upReq, now)
}
// New auto-generated uuid for the record
id := uuid.NewRandom().String()
// Build the insert SQL statement.
query := sqlbuilder.NewInsertBuilder()
query.InsertInto(usersAccountsTableName)
query.Cols("id", "user_id", "account_id", "roles", "created_at", "updated_at")
query.Values(1, id, req.UserID, req.AccountID, req.Roles, now, now)
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "add account %s to user %s failed", req.AccountID, req.UserID)
return err
}
return nil
}
// UpdateAccount...
func UpdateAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateAccountRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update")
defer span.Finish()
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.UserID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersTableName)
query.Set(
query.Assign("roles", req.Roles),
query.Assign("updated_at", now),
)
query.Where(query.And(
query.Equal("user_id", req.UserID),
query.Equal("account_id", req.AccountID),
))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "update account %s for user %s failed", req.AccountID, req.UserID)
return err
}
return nil
}
// RemoveAccount...
func RemoveAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req RemoveAccountRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.RemoveAccount")
defer span.Finish()
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.UserID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Build the update SQL statement.
query := sqlbuilder.NewUpdateBuilder()
query.Update(usersAccountsTableName)
query.Set(query.Assign("archived_at", now))
query.Where(query.And(
query.Equal("user_id", req.UserID),
query.Equal("account_id", req.AccountID),
))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "remove account %s from user %s failed", req.AccountID, req.UserID)
return err
}
return nil
}
// DeleteAccount...
func DeleteAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req DeleteAccountRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.RemoveAccount")
defer span.Finish()
// Validate the request.
err := validator.New().Struct(req)
if err != nil {
return err
}
// Ensure the claims can modify the user specified in the request.
err = CanModifyUserId(ctx, claims, dbConn, req.UserID)
if err != nil {
return err
}
// If now empty set it to the current time.
if now.IsZero() {
now = time.Now()
}
// Always store the time as UTC.
now = now.UTC()
// Postgres truncates times to milliseconds when storing. We and do the same
// here so the value we return is consistent with what we store.
now = now.Truncate(time.Millisecond)
// Build the delete SQL statement.
query := sqlbuilder.NewDeleteBuilder()
query.DeleteFrom(usersAccountsTableName)
query.Where(query.And(
query.Equal("user_id", req.UserID),
query.Equal("account_id", req.AccountID),
))
// Execute the query with the provided context.
sql, args := query.Build()
sql = dbConn.Rebind(sql)
_, err = dbConn.ExecContext(ctx, sql, args...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessagef(err, "delete account %s for user %s failed", req.AccountID, req.UserID)
return err
}
return nil
}

View File

@@ -1,17 +1,16 @@
package user_test
package user
import (
"fmt"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests"
"github.com/dgrijalva/jwt-go"
"github.com/google/go-cmp/cmp"
"github.com/huandu/go-sqlbuilder"
"github.com/pborman/uuid"
"github.com/pkg/errors"
"os"
"testing"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/user"
"github.com/google/go-cmp/cmp"
"github.com/pkg/errors"
"gopkg.in/mgo.v2/bson"
)
var test *tests.Test
@@ -27,6 +26,387 @@ func testMain(m *testing.M) int {
return m.Run()
}
// TestUserFindRequestQuery validates userFindRequestQuery
func TestUserFindRequestQuery(t *testing.T) {
where := "name = ? or email = ?"
var (
limit uint = 12
offset uint = 34
)
req := UserFindRequest{
Where: &where,
Args: []interface{}{
"lee brown",
"lee@geeksinthewoods.com",
},
Order: []string{
"id asc",
"created_at desc",
},
Limit: &limit,
Offset: &offset,
}
expected := "SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE name = ? or email = ? ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34"
res := userFindRequestQuery(req)
if diff := cmp.Diff(res.String(), expected); diff != "" {
t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff)
}
}
// TestApplyClaimsUserSelect validates applyClaimsUserSelect
func TestApplyClaimsUserSelect(t *testing.T) {
var claimTests = []struct {
name string
claims auth.Claims
expectedSql string
error error
}{
{"EmptyClaims",
auth.Claims{},
"SELECT " + usersMapColumns + " FROM " + usersTableName,
nil,
},
{"RoleUser",
auth.Claims{
Roles: []string{auth.RoleUser},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
"SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE account_id = 'acc1' AND user_id = 'user1')",
nil,
},
{"RoleAdmin",
auth.Claims{
Roles: []string{auth.RoleAdmin},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
"SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE account_id = 'acc1' AND user_id = 'user1')",
nil,
},
}
t.Log("Given the need to validate ACLs are enforced by claims to a select query.")
{
for i, tt := range claimTests {
t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name)
{
ctx := tests.Context()
query := selectQuery()
err := applyClaimsUserSelect(ctx, tt.claims, query)
if err != tt.error {
t.Logf("\t\tGot : %+v", err)
t.Logf("\t\tWant: %+v", tt.error)
t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed)
}
sql, args := query.Build()
// Use mysql flavor so placeholders will get replaced for comparison.
sql, err = sqlbuilder.MySQL.Interpolate(sql, args)
if err != nil {
t.Log("\t\tGot :", err)
t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed)
}
if diff := cmp.Diff(sql, tt.expectedSql); diff != "" {
t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff)
}
t.Logf("\t%s\tapplyClaimsUserSelect ok.", tests.Success)
}
}
}
}
// TestCreateUser validates CreateUser
func TestCreateUser(t *testing.T) {
now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC)
// Use disabled status since default is active
us := UserStatus_Disabled
utz := "America/Santiago"
dupEmail := uuid.NewRandom().String() + "@geeksinthewoods.com"
var userTests = []struct {
name string
claims auth.Claims
req CreateUserRequest
error error
}{
{"EmptyClaims",
auth.Claims{},
CreateUserRequest{
Name: "Lee Brown",
Email: dupEmail,
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
Status: &us,
Timezone: &utz,
},
nil,
},
{"DuplicateEmailValidation",
auth.Claims{},
CreateUserRequest{
Name: "Lee Brown",
Email: dupEmail,
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
Status: &us,
Timezone: &utz,
},
errors.New("Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'unique' tag"),
},
{"RoleUser",
auth.Claims{
Roles: []string{auth.RoleUser},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
CreateUserRequest{
Name: "Lee Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
Status: &us,
Timezone: &utz,
},
ErrForbidden,
},
{"RoleAdmin",
auth.Claims{
Roles: []string{auth.RoleAdmin},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
CreateUserRequest{
Name: "Lee Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
Status: &us,
Timezone: &utz,
},
nil,
},
}
t.Log("Given the need to validate ACLs are enforced by claims for user create.")
{
for i, tt := range userTests {
t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name)
{
ctx := tests.Context()
dbConn := test.MasterDB
defer dbConn.Close()
res, err := Create(ctx, tt.claims, dbConn, tt.req, now)
if err != tt.error {
// TODO: need a better way to handle validation errors as they are
// of type interface validator.ValidationErrorsTranslations
var errStr string
if err != nil {
errStr = err.Error()
}
var expectStr string
if tt.error != nil {
expectStr = tt.error.Error()
}
if errStr != expectStr {
t.Logf("\t\tGot : %+v", err)
t.Logf("\t\tWant: %+v", tt.error)
t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed)
}
}
// If there was an error that was expected, then don't go any further
if tt.error != nil {
continue
}
expected := &User{
Name: tt.req.Name,
Email: tt.req.Email,
Status: *tt.req.Status,
Timezone: *tt.req.Timezone,
// Copy this fields from the result.
ID: res.ID,
PasswordSalt: res.PasswordSalt,
PasswordHash: res.PasswordHash,
PasswordReset: res.PasswordReset,
CreatedAt: res.CreatedAt,
UpdatedAt: res.UpdatedAt,
//ArchivedAt: nil,
}
if diff := cmp.Diff(res, expected); diff != "" {
t.Fatalf("\t%s\tExpected result should match. Diff:\n%s", tests.Failed, diff)
}
t.Logf("\t%s\tapplyClaimsUserSelect ok.", tests.Success)
}
}
}
}
// TestUpdateUser validates Update
func TestUpdateUser(t *testing.T) {
now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC)
// Use disabled status since default is active
us := UserStatus_Disabled
utz := "America/Santiago"
create := CreateUserRequest{
Name: "Lee Brown",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
Status: &us,
Timezone: &utz,
}
dupEmail := uuid.NewRandom().String() + "@geeksinthewoods.com"
var userTests = []struct {
name string
claims auth.Claims
req UpdateUserRequest
error error
}{
{"EmptyClaims",
auth.Claims{},
UpdateUserRequest{
Name: "Lee Brown",
Email: dupEmail,
Status: &us,
Timezone: &utz,
},
nil,
},
{"DuplicateEmailValidation",
auth.Claims{},
UpdateUserRequest{
Name: "Lee Brown",
Email: dupEmail,
Status: &us,
Timezone: &utz,
},
errors.New("Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'unique' tag"),
},
{"RoleUser",
auth.Claims{
Roles: []string{auth.RoleUser},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
UpdateUserRequest{
Name: "Lee Brown",
Email: &uuid.NewRandom().String(),
Status: &us,
Timezone: &utz,
},
ErrForbidden,
},
{"RoleAdmin",
auth.Claims{
Roles: []string{auth.RoleAdmin},
StandardClaims: jwt.StandardClaims{
Subject: "user1",
Audience: "acc1",
},
},
UpdateUserRequest{
Name: "Lee Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Status: &us,
Timezone: &utz,
},
nil,
},
}
t.Log("Given the need to validate ACLs are enforced by claims for user update.")
{
for i, tt := range userTests {
t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name)
{
ctx := tests.Context()
dbConn := test.MasterDB
defer dbConn.Close()
err := Update(ctx, tt.claims, dbConn, tt.req, now)
if err != tt.error {
// TODO: need a better way to handle validation errors as they are
// of type interface validator.ValidationErrorsTranslations
var errStr string
if err != nil {
errStr = err.Error()
}
var expectStr string
if tt.error != nil {
expectStr = tt.error.Error()
}
if errStr != expectStr {
t.Logf("\t\tGot : %+v", err)
t.Logf("\t\tWant: %+v", tt.error)
t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed)
}
}
// If there was an error that was expected, then don't go any further
if tt.error != nil {
continue
}
expected := &User{
Name: tt.req.Name,
Email: tt.req.Email,
Status: *tt.req.Status,
Timezone: *tt.req.Timezone,
// Copy this fields from the result.
ID: res.ID,
PasswordSalt: res.PasswordSalt,
PasswordHash: res.PasswordHash,
PasswordReset: res.PasswordReset,
CreatedAt: res.CreatedAt,
UpdatedAt: res.UpdatedAt,
//ArchivedAt: nil,
}
if diff := cmp.Diff(res, expected); diff != "" {
t.Fatalf("\t%s\tExpected result should match. Diff:\n%s", tests.Failed, diff)
}
t.Logf("\t%s\tapplyClaimsUserSelect ok.", tests.Success)
}
}
}
}
/*
// TestUser validates the full set of CRUD operations on User values.
func TestUser(t *testing.T) {
defer tests.Recover(t)
@@ -37,28 +417,23 @@ func TestUser(t *testing.T) {
{
ctx := tests.Context()
dbConn := test.MasterDB.Copy()
dbConn := test.MasterDB
defer dbConn.Close()
now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC)
// claims is information about the person making the request.
claims := auth.NewClaims(bson.NewObjectId().Hex(), []string{auth.RoleAdmin}, now, time.Hour)
nu := user.NewUser{
Name: "Bill Kennedy",
Email: "bill@ardanlabs.com",
Roles: []string{auth.RoleAdmin},
Password: "gophers",
PasswordConfirm: "gophers",
}
u, err := user.Create(ctx, dbConn, &nu, now)
u, err := Create(ctx, dbConn, &nu, now)
if err != nil {
t.Fatalf("\t%s\tShould be able to create user : %s.", tests.Failed, err)
}
t.Logf("\t%s\tShould be able to create user.", tests.Success)
// claims is information about the person making the request.
claims := auth.NewClaims(bson.NewObjectId().Hex(), []string{auth.RoleAdmin}, now, time.Hour)
savedU, err := user.Retrieve(ctx, claims, dbConn, u.ID.Hex())
if err != nil {
t.Fatalf("\t%s\tShould be able to retrieve user by ID: %s.", tests.Failed, err)
@@ -112,10 +487,13 @@ func TestUser(t *testing.T) {
t.Fatalf("\t%s\tShould NOT be able to retrieve user : %s.", tests.Failed, err)
}
t.Logf("\t%s\tShould NOT be able to retrieve user.", tests.Success)
}
}
}
// mockTokenGenerator is used for testing that Authenticate calls its provided
// token generator in a specific way.
type mockTokenGenerator struct{}
@@ -177,3 +555,4 @@ func TestAuthenticate(t *testing.T) {
}
}
}
*/

View File

@@ -1,129 +0,0 @@
package main
import (
"database/sql"
"log"
"github.com/gitwak/sqlxmigrate"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/pkg/errors"
)
// migrationList returns a list of migrations to be executed. If the id of the
// migration already exists in the migrations table it will be skipped.
func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return []*sqlxmigrate.Migration{
// create table users
{
ID: "20190522-01a",
Migrate: func(tx *sql.Tx) error {
q := `CREATE TABLE IF NOT EXISTS users (
id char(36) NOT NULL,
email varchar(200) NOT NULL,
title varchar(100) NOT NULL DEFAULT '',
first_name varchar(200) NOT NULL DEFAULT '',
last_name varchar(200) NOT NULL DEFAULT '',
password_hash varchar(200) NOT NULL,
password_reset varchar(200) DEFAULT NULL,
password_salt varchar(200) NOT NULL,
phone varchar(20) NOT NULL DEFAULT '',
status enum('active','disabled') NOT NULL DEFAULT 'active',
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
created_at timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp(0) DEFAULT NULL,
deleted_at timestamp(0) DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT email UNIQUE (email)
) ;`
if _, err := tx.Exec(q); err != nil {
return errors.WithMessagef(err, "Query failed %s", q)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q := `DROP TABLE IF EXISTS users`
if _, err := tx.Exec(q); err != nil {
return errors.WithMessagef(err, "Query failed %s", q)
}
return nil
},
},
// create new table accounts
{
ID: "20190522-01b",
Migrate: func(tx *sql.Tx) error {
q := `CREATE TABLE IF NOT EXISTS accounts (
id char(36) NOT NULL,
name varchar(255) NOT NULL,
address1 varchar(255) NOT NULL DEFAULT '',
address2 varchar(255) NOT NULL DEFAULT '',
city varchar(100) NOT NULL DEFAULT '',
region varchar(255) NOT NULL DEFAULT '',
country varchar(255) NOT NULL DEFAULT '',
zipcode varchar(20) NOT NULL DEFAULT '',
status enum('active','pending','disabled') NOT NULL DEFAULT 'active',
timezone varchar(128) NOT NULL DEFAULT 'America/Anchorage',
signup_user_id char(36) DEFAULT NULL,
billing_user_id char(36) DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime DEFAULT NULL,
deleted_at datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`
if _, err := tx.Exec(q); err != nil {
return errors.WithMessagef(err, "Query failed %s", q)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q := `DROP TABLE IF EXISTS accounts`
if _, err := tx.Exec(q); err != nil {
return errors.WithMessagef(err, "Query failed %s", q)
}
return nil
},
},
// create new table user_accounts
{
ID: "20190522-01c",
Migrate: func(tx *sql.Tx) error {
q1 := `CREATE TYPE IF NOT EXISTS role_t as enum('admin', 'user');`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `CREATE TABLE IF NOT EXISTS users_accounts (
id char(36) NOT NULL,
account_id char(36) NOT NULL,
user_id ichar(36) NOT NULL,
roles role_t[] NOT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime DEFAULT NULL,
deleted_at datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY user_account (user_id,account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q1 := `DROP TYPE IF EXISTS role_t`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `DROP TABLE IF EXISTS users_accounts`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
},
}
}