Add named auth and the all new action endpoints

This commit is contained in:
Vikram Rangnekar 2020-02-03 01:21:07 -05:00
parent 1a3d74e1ce
commit 62fd1eac55
15 changed files with 429 additions and 159 deletions

View File

@ -167,10 +167,13 @@ roles:
block: false block: false
- name: deals - name: deals
query: query:
limit: 3 limit: 3
columns: ["name", "description" ] aggregation: false
- name: purchases
query:
limit: 3
aggregation: false aggregation: false
- name: user - name: user
@ -183,12 +186,10 @@ roles:
query: query:
limit: 50 limit: 50
filters: ["{ user_id: { eq: $user_id } }"] filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description", "search_rank", "search_headline_description" ]
disable_functions: false disable_functions: false
insert: insert:
filters: ["{ user_id: { eq: $user_id } }"] filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
presets: presets:
- user_id: "$user_id" - user_id: "$user_id"
- created_at: "now" - created_at: "now"

View File

@ -1319,7 +1319,7 @@ auth:
max_active: 12000 max_active: 12000
``` ```
### JWT Token Auth ### JWT Tokens
```yaml ```yaml
auth: auth:
@ -1339,6 +1339,51 @@ We can get the JWT token either from the `authorization` header where we expect
For validation a `secret` or a public key (ecdsa or rsa) is required. When using public keys they have to be in a PEM format file. For validation a `secret` or a public key (ecdsa or rsa) is required. When using public keys they have to be in a PEM format file.
### HTTP Headers
```yaml
header:
name: X-AppEngine-QueueName
exists: true
#value: default
```
Header auth is usually the best option to authenticate requests to the action endpoints. For example you
might want to use an action to refresh a materalized view every hour and only want a cron service like the Google AppEngine Cron service to make that request in this case a config similar to the one above will do.
The `exists: true` parameter ensures that only the existance of the header is checked not its value. The `value` parameter lets you confirm that the value matches the one assgined to the parameter. This helps in the case you are using a shared secret to protect the endpoint.
### Named Auth
```yaml
# You can add additional named auths to use with actions
# In this example actions using this auth can only be
# called from the Google Appengine Cron service that
# sets a special header to all it's requests
auths:
- name: from_taskqueue
type: header
header:
name: X-Appengine-Cron
exists: true
```
In addition to the default auth configuration you can create additional named auth configurations to be used
with features like `actions`. For example while your main GraphQL endpoint uses JWT for authentication you may want to use a header value to ensure your actions can only be called by clients having access to a shared secret
or security header.
## Actions
Actions is a very useful feature that is currently work in progress. For now the best use case for actions is to
refresh database tables like materialized views or call a database procedure to refresh a cache table, etc. An action creates an http endpoint that anyone can call to have the SQL query executed. The below example will create an endpoint `/api/v1/actions/refresh_leaderboard_users` any request send to that endpoint will cause the sql query to be executed. the `auth_name` points to a named auth that should be used to secure this endpoint. In future we have big plans to allow your own custom code to run using actions.
```yaml
actions:
- name: refresh_leaderboard_users
sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users"
auth_name: from_taskqueue
```
#### Using CURL to test a query #### Using CURL to test a query
```bash ```bash
@ -1593,6 +1638,22 @@ auth:
# public_key_file: /secrets/public_key.pem # public_key_file: /secrets/public_key.pem
# public_key_type: ecdsa #rsa # public_key_type: ecdsa #rsa
# header:
# name: dnt
# exists: true
# value: localhost:8080
# You can add additional named auths to use with actions
# In this example actions using this auth can only be
# called from the Google Appengine Cron service that
# sets a special header to all it's requests
auths:
- name: from_taskqueue
type: header
header:
name: X-Appengine-Cron
exists: true
database: database:
type: postgres type: postgres
host: db host: db
@ -1623,6 +1684,17 @@ database:
- encrypted - encrypted
- token - token
# Create custom actions with their own api endpoints
# For example the below action will be available at /api/v1/actions/refresh_leaderboard_users
# A request to this url will execute the configured SQL query
# which in this case refreshes a materialized view in the database.
# The auth_name is from one of the configured auths
actions:
- name: refresh_leaderboard_users
sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users"
auth_name: from_taskqueue
tables: tables:
- name: customers - name: customers
remotes: remotes:

View File

@ -151,6 +151,7 @@ SELECT
pg_catalog.format_type(f.atttypid,f.atttypmod) AS type, pg_catalog.format_type(f.atttypid,f.atttypmod) AS type,
CASE CASE
WHEN f.attndims != 0 THEN true WHEN f.attndims != 0 THEN true
WHEN right(pg_catalog.format_type(f.atttypid,f.atttypmod), 2) = '[]' THEN true
ELSE false ELSE false
END AS array, END AS array,
CASE CASE
@ -175,7 +176,7 @@ FROM pg_attribute f
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_constraint p ON p.conrelid = c.oid AND f.attnum = ANY (p.conkey) LEFT JOIN pg_constraint p ON p.conrelid = c.oid AND f.attnum = ANY (p.conkey)
LEFT JOIN pg_class AS g ON p.confrelid = g.oid LEFT JOIN pg_class AS g ON p.confrelid = g.oid
WHERE c.relkind = ('r'::char) WHERE c.relkind IN ('r', 'v', 'm', 'f')
AND n.nspname = $1 -- Replace with Schema name AND n.nspname = $1 -- Replace with Schema name
AND c.relname = $2 -- Replace with table name AND c.relname = $2 -- Replace with table name
AND f.attnum > 0 AND f.attnum > 0

41
serv/actions.go Normal file
View File

@ -0,0 +1,41 @@
package serv
import (
"fmt"
"net/http"
)
type actionFn func(w http.ResponseWriter, r *http.Request) error
func newAction(a configAction) (http.Handler, error) {
var fn actionFn
var err error
if len(a.SQL) != 0 {
fn, err = newSQLAction(a)
} else {
return nil, fmt.Errorf("invalid config for action '%s'", a.Name)
}
if err != nil {
return nil, err
}
httpFn := func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
errlog.Error().Err(err).Send()
errorResp(w, err)
}
}
return http.HandlerFunc(httpFn), nil
}
func newSQLAction(a configAction) (actionFn, error) {
fn := func(w http.ResponseWriter, r *http.Request) error {
_, err := db.Exec(r.Context(), a.SQL)
return err
}
return fn, nil
}

View File

@ -3,7 +3,6 @@ package serv
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
) )
type ctxkey int type ctxkey int
@ -14,7 +13,7 @@ const (
userRoleKey userRoleKey
) )
func headerAuth(next http.Handler) http.HandlerFunc { func headerAuth(authc configAuth, next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -37,28 +36,53 @@ func headerAuth(next http.Handler) http.HandlerFunc {
} }
} }
func withAuth(next http.Handler) http.Handler { func headerHandler(authc configAuth, next http.Handler) http.HandlerFunc {
at := conf.Auth.Type hdr := authc.Header
ru := conf.Auth.Rails.URL
if conf.Auth.CredsInHeader { if len(hdr.Name) == 0 {
next = headerAuth(next) errlog.Fatal().Str("auth", authc.Name).Msg("no header.name defined")
} }
switch at { if !hdr.Exists && len(hdr.Value) == 0 {
errlog.Fatal().Str("auth", authc.Name).Msg("no header.value defined")
}
return func(w http.ResponseWriter, r *http.Request) {
var fo1 bool
value := r.Header.Get(hdr.Name)
switch {
case hdr.Exists:
fo1 = (len(value) == 0)
default:
fo1 = (value != hdr.Value)
}
if fo1 {
http.Error(w, "401 unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
}
}
func withAuth(next http.Handler, authc configAuth) http.Handler {
if authc.CredsInHeader {
next = headerAuth(authc, next)
}
switch authc.Type {
case "rails": case "rails":
if strings.HasPrefix(ru, "memcache:") { return railsHandler(authc, next)
return railsMemcacheHandler(next)
}
if strings.HasPrefix(ru, "redis:") {
return railsRedisHandler(next)
}
return railsCookieHandler(next)
case "jwt": case "jwt":
return jwtHandler(next) return jwtHandler(authc, next)
case "header":
return headerHandler(authc, next)
} }
return next return next

View File

@ -14,18 +14,18 @@ const (
jwtAuth0 int = iota + 1 jwtAuth0 int = iota + 1
) )
func jwtHandler(next http.Handler) http.HandlerFunc { func jwtHandler(authc configAuth, next http.Handler) http.HandlerFunc {
var key interface{} var key interface{}
var jwtProvider int var jwtProvider int
cookie := conf.Auth.Cookie cookie := authc.Cookie
if conf.Auth.JWT.Provider == "auth0" { if authc.JWT.Provider == "auth0" {
jwtProvider = jwtAuth0 jwtProvider = jwtAuth0
} }
secret := conf.Auth.JWT.Secret secret := authc.JWT.Secret
publicKeyFile := conf.Auth.JWT.PubKeyFile publicKeyFile := authc.JWT.PubKeyFile
switch { switch {
case len(secret) != 0: case len(secret) != 0:
@ -37,7 +37,7 @@ func jwtHandler(next http.Handler) http.HandlerFunc {
errlog.Fatal().Err(err).Send() errlog.Fatal().Err(err).Send()
} }
switch conf.Auth.JWT.PubKeyType { switch authc.JWT.PubKeyType {
case "ecdsa": case "ecdsa":
key, err = jwt.ParseECPublicKeyFromPEM(kd) key, err = jwt.ParseECPublicKeyFromPEM(kd)

View File

@ -6,32 +6,47 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/bradfitz/gomemcache/memcache" "github.com/bradfitz/gomemcache/memcache"
"github.com/dosco/super-graph/rails" "github.com/dosco/super-graph/rails"
"github.com/garyburd/redigo/redis" "github.com/garyburd/redigo/redis"
) )
func railsRedisHandler(next http.Handler) http.HandlerFunc { func railsHandler(authc configAuth, next http.Handler) http.HandlerFunc {
cookie := conf.Auth.Cookie ru := authc.Rails.URL
if strings.HasPrefix(ru, "memcache:") {
return railsMemcacheHandler(authc, next)
}
if strings.HasPrefix(ru, "redis:") {
return railsRedisHandler(authc, next)
}
return railsCookieHandler(authc, next)
}
func railsRedisHandler(authc configAuth, next http.Handler) http.HandlerFunc {
cookie := authc.Cookie
if len(cookie) == 0 { if len(cookie) == 0 {
errlog.Fatal().Msg("no auth.cookie defined") errlog.Fatal().Msg("no auth.cookie defined")
} }
if len(conf.Auth.Rails.URL) == 0 { if len(authc.Rails.URL) == 0 {
errlog.Fatal().Msg("no auth.rails.url defined") errlog.Fatal().Msg("no auth.rails.url defined")
} }
rp := &redis.Pool{ rp := &redis.Pool{
MaxIdle: conf.Auth.Rails.MaxIdle, MaxIdle: authc.Rails.MaxIdle,
MaxActive: conf.Auth.Rails.MaxActive, MaxActive: authc.Rails.MaxActive,
Dial: func() (redis.Conn, error) { Dial: func() (redis.Conn, error) {
c, err := redis.DialURL(conf.Auth.Rails.URL) c, err := redis.DialURL(authc.Rails.URL)
if err != nil { if err != nil {
errlog.Fatal().Err(err).Send() errlog.Fatal().Err(err).Send()
} }
pwd := conf.Auth.Rails.Password pwd := authc.Rails.Password
if len(pwd) != 0 { if len(pwd) != 0 {
if _, err := c.Do("AUTH", pwd); err != nil { if _, err := c.Do("AUTH", pwd); err != nil {
errlog.Fatal().Err(err).Send() errlog.Fatal().Err(err).Send()
@ -66,17 +81,17 @@ func railsRedisHandler(next http.Handler) http.HandlerFunc {
} }
} }
func railsMemcacheHandler(next http.Handler) http.HandlerFunc { func railsMemcacheHandler(authc configAuth, next http.Handler) http.HandlerFunc {
cookie := conf.Auth.Cookie cookie := authc.Cookie
if len(cookie) == 0 { if len(cookie) == 0 {
errlog.Fatal().Msg("no auth.cookie defined") errlog.Fatal().Msg("no auth.cookie defined")
} }
if len(conf.Auth.Rails.URL) == 0 { if len(authc.Rails.URL) == 0 {
errlog.Fatal().Msg("no auth.rails.url defined") errlog.Fatal().Msg("no auth.rails.url defined")
} }
rURL, err := url.Parse(conf.Auth.Rails.URL) rURL, err := url.Parse(authc.Rails.URL)
if err != nil { if err != nil {
errlog.Fatal().Err(err).Send() errlog.Fatal().Err(err).Send()
} }
@ -108,13 +123,13 @@ func railsMemcacheHandler(next http.Handler) http.HandlerFunc {
} }
} }
func railsCookieHandler(next http.Handler) http.HandlerFunc { func railsCookieHandler(authc configAuth, next http.Handler) http.HandlerFunc {
cookie := conf.Auth.Cookie cookie := authc.Cookie
if len(cookie) == 0 { if len(cookie) == 0 {
errlog.Fatal().Msg("no auth.cookie defined") errlog.Fatal().Msg("no auth.cookie defined")
} }
ra, err := railsAuth(conf) ra, err := railsAuth(authc)
if err != nil { if err != nil {
errlog.Fatal().Err(err).Send() errlog.Fatal().Err(err).Send()
} }
@ -139,13 +154,13 @@ func railsCookieHandler(next http.Handler) http.HandlerFunc {
} }
} }
func railsAuth(c *config) (*rails.Auth, error) { func railsAuth(authc configAuth) (*rails.Auth, error) {
secret := c.Auth.Rails.SecretKeyBase secret := authc.Rails.SecretKeyBase
if len(secret) == 0 { if len(secret) == 0 {
return nil, errors.New("no auth.rails.secret_key_base defined") return nil, errors.New("no auth.rails.secret_key_base defined")
} }
version := c.Auth.Rails.Version version := authc.Rails.Version
if len(version) == 0 { if len(version) == 0 {
return nil, errors.New("no auth.rails.version defined") return nil, errors.New("no auth.rails.version defined")
} }
@ -155,16 +170,16 @@ func railsAuth(c *config) (*rails.Auth, error) {
return nil, err return nil, err
} }
if len(c.Auth.Rails.Salt) != 0 { if len(authc.Rails.Salt) != 0 {
ra.Salt = c.Auth.Rails.Salt ra.Salt = authc.Rails.Salt
} }
if len(conf.Auth.Rails.SignSalt) != 0 { if len(authc.Rails.SignSalt) != 0 {
ra.SignSalt = c.Auth.Rails.SignSalt ra.SignSalt = authc.Rails.SignSalt
} }
if len(conf.Auth.Rails.AuthSalt) != 0 { if len(authc.Rails.AuthSalt) != 0 {
ra.AuthSalt = c.Auth.Rails.AuthSalt ra.AuthSalt = authc.Rails.AuthSalt
} }
return ra, nil return ra, nil

View File

@ -311,3 +311,13 @@ func getMigrationVars() map[string]interface{} {
"env": strings.ToLower(os.Getenv("GO_ENV")), "env": strings.ToLower(os.Getenv("GO_ENV")),
} }
} }
func initConfOnce() {
var err error
if conf == nil {
if conf, err = initConf(); err != nil {
errlog.Fatal().Err(err).Msg("failed to read config")
}
}
}

View File

@ -7,23 +7,22 @@ import (
func cmdServ(cmd *cobra.Command, args []string) { func cmdServ(cmd *cobra.Command, args []string) {
var err error var err error
initWatcher(confPath)
if conf, err = initConf(); err != nil { if conf, err = initConf(); err != nil {
fatalInProd(err, "failed to read config") fatalInProd(err, "failed to read config")
} }
if conf != nil { db, err = initDBPool(conf)
db, err = initDBPool(conf)
if err == nil { if err != nil {
initCompiler() fatalInProd(err, "failed to connect to database")
initAllowList(confPath)
initPreparedList(confPath)
} else {
fatalInProd(err, "failed to connect to database")
}
} }
initWatcher(confPath) initCompiler()
initResolvers()
initAllowList(confPath)
initPreparedList(confPath)
startHTTP() startHTTP()
} }

View File

@ -33,30 +33,8 @@ type config struct {
Inflections map[string]string Inflections map[string]string
Auth struct { Auth configAuth
Type string Auths []configAuth
Cookie string
CredsInHeader bool `mapstructure:"creds_in_header"`
Rails struct {
Version string
SecretKeyBase string `mapstructure:"secret_key_base"`
URL string
Password string
MaxIdle int `mapstructure:"max_idle"`
MaxActive int `mapstructure:"max_active"`
Salt string
SignSalt string `mapstructure:"sign_salt"`
AuthSalt string `mapstructure:"auth_salt"`
}
JWT struct {
Provider string
Secret string
PubKeyFile string `mapstructure:"public_key_file"`
PubKeyType string `mapstructure:"public_key_type"`
}
}
DB struct { DB struct {
Type string Type string
@ -77,6 +55,8 @@ type config struct {
Tables []configTable Tables []configTable
} `mapstructure:"database"` } `mapstructure:"database"`
Actions []configAction
Tables []configTable Tables []configTable
RolesQuery string `mapstructure:"roles_query"` RolesQuery string `mapstructure:"roles_query"`
@ -85,6 +65,38 @@ type config struct {
abacEnabled bool abacEnabled bool
} }
type configAuth struct {
Name string
Type string
Cookie string
CredsInHeader bool `mapstructure:"creds_in_header"`
Rails struct {
Version string
SecretKeyBase string `mapstructure:"secret_key_base"`
URL string
Password string
MaxIdle int `mapstructure:"max_idle"`
MaxActive int `mapstructure:"max_active"`
Salt string
SignSalt string `mapstructure:"sign_salt"`
AuthSalt string `mapstructure:"auth_salt"`
}
JWT struct {
Provider string
Secret string
PubKeyFile string `mapstructure:"public_key_file"`
PubKeyType string `mapstructure:"public_key_type"`
}
Header struct {
Name string
Value string
Exists bool
}
}
type configColumn struct { type configColumn struct {
Name string Name string
Type string Type string
@ -156,6 +168,12 @@ type configRole struct {
tablesMap map[string]*configRoleTable tablesMap map[string]*configRoleTable
} }
type configAction struct {
Name string
SQL string
AuthName string `mapstructure:"auth_name"`
}
func newConfig(name string) *viper.Viper { func newConfig(name string) *viper.Viper {
vi := viper.New() vi := viper.New()
@ -283,26 +301,48 @@ func (c *config) Init(vi *viper.Viper) error {
func (c *config) validate() { func (c *config) validate() {
rm := make(map[string]struct{}) rm := make(map[string]struct{})
for i := range c.Roles { for _, v := range c.Roles {
name := c.Roles[i].Name name := strings.ToLower(v.Name)
if _, ok := rm[name]; ok { if _, ok := rm[name]; ok {
errlog.Fatal().Msgf("duplicate config for role '%s'", c.Roles[i].Name) errlog.Fatal().Msgf("duplicate config for role '%s'", v.Name)
} }
rm[name] = struct{}{} rm[name] = struct{}{}
} }
tm := make(map[string]struct{}) tm := make(map[string]struct{})
for i := range c.Tables { for _, v := range c.Tables {
name := c.Tables[i].Name name := strings.ToLower(v.Name)
if _, ok := tm[name]; ok { if _, ok := tm[name]; ok {
errlog.Fatal().Msgf("duplicate config for table '%s'", c.Tables[i].Name) errlog.Fatal().Msgf("duplicate config for table '%s'", v.Name)
} }
tm[name] = struct{}{} tm[name] = struct{}{}
} }
am := make(map[string]struct{})
for _, v := range c.Auths {
name := strings.ToLower(v.Name)
if _, ok := am[name]; ok {
errlog.Fatal().Msgf("duplicate config for auth '%s'", v.Name)
}
am[name] = struct{}{}
}
for _, v := range c.Actions {
if len(v.AuthName) == 0 {
continue
}
authName := strings.ToLower(v.AuthName)
if _, ok := am[authName]; !ok {
errlog.Fatal().Msgf("invalid auth_name for action '%s'", v.Name)
}
}
if len(c.RolesQuery) == 0 { if len(c.RolesQuery) == 0 {
logger.Warn().Msgf("no 'roles_query' defined.") logger.Warn().Msgf("no 'roles_query' defined.")
} }
@ -349,3 +389,31 @@ func sanitize(s string) string {
return strings.ToLower(m) return strings.ToLower(m)
}) })
} }
func getConfigName() string {
if len(os.Getenv("GO_ENV")) == 0 {
return "dev"
}
ge := strings.ToLower(os.Getenv("GO_ENV"))
switch {
case strings.HasPrefix(ge, "pro"):
return "prod"
case strings.HasPrefix(ge, "sta"):
return "stage"
case strings.HasPrefix(ge, "tes"):
return "test"
case strings.HasPrefix(ge, "dev"):
return "dev"
}
return ge
}
func isDev() bool {
return strings.HasPrefix(os.Getenv("GO_ENV"), "dev")
}

View File

@ -148,20 +148,6 @@ func initCompiler() {
if err != nil { if err != nil {
errlog.Fatal().Err(err).Msg("failed to initialize compilers") errlog.Fatal().Err(err).Msg("failed to initialize compilers")
} }
if err := initResolvers(); err != nil {
errlog.Fatal().Err(err).Msg("failed to initialized resolvers")
}
}
func initConfOnce() {
var err error
if conf == nil {
if conf, err = initConf(); err != nil {
errlog.Fatal().Err(err).Msg("failed to read config")
}
}
} }
func initAllowList(cpath string) { func initAllowList(cpath string) {

View File

@ -22,16 +22,20 @@ type resolvFn struct {
Fn func(h http.Header, id []byte) ([]byte, error) Fn func(h http.Header, id []byte) ([]byte, error)
} }
func initResolvers() error { func initResolvers() {
var err error
rmap = make(map[uint64]*resolvFn) rmap = make(map[uint64]*resolvFn)
for _, t := range conf.Tables { for _, t := range conf.Tables {
err := initRemotes(t) err = initRemotes(t)
if err != nil { if err != nil {
return err break
} }
} }
return nil
if err != nil {
errlog.Fatal().Err(err).Msg("failed to initialize resolvers")
}
} }
func initRemotes(t configTable) error { func initRemotes(t configTable) error {

View File

@ -101,9 +101,14 @@ func startHTTP() {
hostPort = defaultHP hostPort = defaultHP
} }
routes, err := routeHandler()
if err != nil {
errlog.Fatal().Err(err).Send()
}
srv := &http.Server{ srv := &http.Server{
Addr: hostPort, Addr: hostPort,
Handler: routeHandler(), Handler: routes,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
@ -140,59 +145,74 @@ func startHTTP() {
<-idleConnsClosed <-idleConnsClosed
} }
func routeHandler() http.Handler { func routeHandler() (http.Handler, error) {
var apiH http.Handler
if conf != nil && conf.HTTPGZip {
gzipH := gziphandler.MustNewGzipLevelHandler(6)
apiH = gzipH(http.HandlerFunc(apiV1))
} else {
apiH = http.HandlerFunc(apiV1)
}
mux := http.NewServeMux() mux := http.NewServeMux()
if conf != nil { if conf == nil {
mux.HandleFunc("/health", health) return mux, nil
mux.Handle("/api/v1/graphql", withAuth(apiH)) }
if conf.WebUI { routes := map[string]http.Handler{
mux.Handle("/", http.FileServer(rice.MustFindBox("../web/build").HTTPBox())) "/health": http.HandlerFunc(health),
"/api/v1/graphql": withAuth(http.HandlerFunc(apiV1), conf.Auth),
}
if err := setActionRoutes(routes); err != nil {
return nil, err
}
if conf.WebUI {
routes["/"] = http.FileServer(rice.MustFindBox("../web/build").HTTPBox())
}
if conf.HTTPGZip {
gz := gziphandler.MustNewGzipLevelHandler(6)
for k, v := range routes {
routes[k] = gz(v)
} }
} }
for k, v := range routes {
mux.Handle(k, v)
}
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", serverName) w.Header().Set("Server", serverName)
mux.ServeHTTP(w, r) mux.ServeHTTP(w, r)
} }
return http.HandlerFunc(fn) return http.HandlerFunc(fn), nil
} }
func getConfigName() string { func setActionRoutes(routes map[string]http.Handler) error {
if len(os.Getenv("GO_ENV")) == 0 { var err error
return "dev"
for _, a := range conf.Actions {
var fn http.Handler
fn, err = newAction(a)
if err != nil {
break
}
p := fmt.Sprintf("/api/v1/actions/%s", strings.ToLower(a.Name))
if authc, ok := findAuth(a.AuthName); ok {
routes[p] = withAuth(fn, authc)
} else {
routes[p] = fn
}
} }
return nil
}
ge := strings.ToLower(os.Getenv("GO_ENV")) func findAuth(name string) (configAuth, bool) {
var authc configAuth
switch { for _, a := range conf.Auths {
case strings.HasPrefix(ge, "pro"): if strings.EqualFold(a.Name, name) {
return "prod" return a, true
}
case strings.HasPrefix(ge, "sta"):
return "stage"
case strings.HasPrefix(ge, "tes"):
return "test"
case strings.HasPrefix(ge, "dev"):
return "dev"
} }
return authc, false
return ge
}
func isDev() bool {
return strings.HasPrefix(os.Getenv("GO_ENV"), "dev")
} }

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"sort" "sort"
"strings" "strings"
"sync"
"github.com/cespare/xxhash/v2" "github.com/cespare/xxhash/v2"
"github.com/dosco/super-graph/jsn" "github.com/dosco/super-graph/jsn"
@ -127,9 +128,14 @@ func findStmt(role string, stmts []stmt) *stmt {
} }
func fatalInProd(err error, msg string) { func fatalInProd(err error, msg string) {
if isDev() { var wg sync.WaitGroup
errlog.Error().Err(err).Msg(msg)
} else { if !isDev() {
errlog.Fatal().Err(err).Msg(msg) errlog.Fatal().Err(err).Msg(msg)
} }
errlog.Error().Err(err).Msg(msg)
wg.Add(1)
wg.Wait()
} }

View File

@ -49,7 +49,7 @@ migrations_path: ./config/migrations
# sheep: sheep # sheep: sheep
auth: auth:
# Can be 'rails' or 'jwt' # Can be 'rails', 'jwt' or 'header'
type: rails type: rails
cookie: _{% app_name_slug %}_session cookie: _{% app_name_slug %}_session
@ -83,6 +83,22 @@ auth:
# public_key_file: /secrets/public_key.pem # public_key_file: /secrets/public_key.pem
# public_key_type: ecdsa #rsa # public_key_type: ecdsa #rsa
# header:
# name: dnt
# exists: true
# value: localhost:8080
# You can add additional named auths to use with actions
# In this example actions using this auth can only be
# called from the Google Appengine Cron service that
# sets a special header to all it's requests
auths:
- name: from_taskqueue
type: header
header:
name: X-Appengine-Cron
exists: true
database: database:
type: postgres type: postgres
host: db host: db
@ -116,6 +132,16 @@ database:
- encrypted - encrypted
- token - token
# Create custom actions with their own api endpoints
# For example the below action will be available at /api/v1/actions/refresh_leaderboard_users
# A request to this url will execute the configured SQL query
# which in this case refreshes a materialized view in the database.
# The auth_name is from one of the configured auths
actions:
- name: refresh_leaderboard_users
sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users"
auth_name: from_taskqueue
tables: tables:
- name: customers - name: customers
remotes: remotes:
@ -137,6 +163,7 @@ tables:
name: me name: me
table: users table: users
roles_query: "SELECT * FROM users WHERE id = $user_id" roles_query: "SELECT * FROM users WHERE id = $user_id"
roles: roles:
@ -168,20 +195,16 @@ roles:
query: query:
limit: 50 limit: 50
filters: ["{ user_id: { eq: $user_id } }"] filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
disable_functions: false disable_functions: false
insert: insert:
filters: ["{ user_id: { eq: $user_id } }"] filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
presets: presets:
- user_id: "$user_id"
- created_at: "now" - created_at: "now"
update: update:
filters: ["{ user_id: { eq: $user_id } }"] filters: ["{ user_id: { eq: $user_id } }"]
columns:
- id
- name
presets: presets:
- updated_at: "now" - updated_at: "now"