Add named auth and the all new action endpoints
This commit is contained in:
parent
1a3d74e1ce
commit
62fd1eac55
|
@ -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"
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
60
serv/auth.go
60
serv/auth.go
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
initAllowList(confPath)
|
|
||||||
initPreparedList(confPath)
|
|
||||||
} else {
|
|
||||||
fatalInProd(err, "failed to connect to database")
|
fatalInProd(err, "failed to connect to database")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
initWatcher(confPath)
|
initCompiler()
|
||||||
|
initResolvers()
|
||||||
|
initAllowList(confPath)
|
||||||
|
initPreparedList(confPath)
|
||||||
|
|
||||||
startHTTP()
|
startHTTP()
|
||||||
}
|
}
|
||||||
|
|
134
serv/config.go
134
serv/config.go
|
@ -33,7 +33,40 @@ type config struct {
|
||||||
|
|
||||||
Inflections map[string]string
|
Inflections map[string]string
|
||||||
|
|
||||||
Auth struct {
|
Auth configAuth
|
||||||
|
Auths []configAuth
|
||||||
|
|
||||||
|
DB struct {
|
||||||
|
Type string
|
||||||
|
Host string
|
||||||
|
Port uint16
|
||||||
|
DBName string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Schema string
|
||||||
|
PoolSize int32 `mapstructure:"pool_size"`
|
||||||
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
|
SetUserID bool `mapstructure:"set_user_id"`
|
||||||
|
PingTimeout time.Duration `mapstructure:"ping_timeout"`
|
||||||
|
|
||||||
|
Vars map[string]string `mapstructure:"variables"`
|
||||||
|
Blocklist []string
|
||||||
|
|
||||||
|
Tables []configTable
|
||||||
|
} `mapstructure:"database"`
|
||||||
|
|
||||||
|
Actions []configAction
|
||||||
|
|
||||||
|
Tables []configTable
|
||||||
|
|
||||||
|
RolesQuery string `mapstructure:"roles_query"`
|
||||||
|
Roles []configRole
|
||||||
|
roles map[string]*configRole
|
||||||
|
abacEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type configAuth struct {
|
||||||
|
Name string
|
||||||
Type string
|
Type string
|
||||||
Cookie string
|
Cookie string
|
||||||
CredsInHeader bool `mapstructure:"creds_in_header"`
|
CredsInHeader bool `mapstructure:"creds_in_header"`
|
||||||
|
@ -56,33 +89,12 @@ type config struct {
|
||||||
PubKeyFile string `mapstructure:"public_key_file"`
|
PubKeyFile string `mapstructure:"public_key_file"`
|
||||||
PubKeyType string `mapstructure:"public_key_type"`
|
PubKeyType string `mapstructure:"public_key_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Header struct {
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
Exists bool
|
||||||
}
|
}
|
||||||
|
|
||||||
DB struct {
|
|
||||||
Type string
|
|
||||||
Host string
|
|
||||||
Port uint16
|
|
||||||
DBName string
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
Schema string
|
|
||||||
PoolSize int32 `mapstructure:"pool_size"`
|
|
||||||
MaxRetries int `mapstructure:"max_retries"`
|
|
||||||
SetUserID bool `mapstructure:"set_user_id"`
|
|
||||||
PingTimeout time.Duration `mapstructure:"ping_timeout"`
|
|
||||||
|
|
||||||
Vars map[string]string `mapstructure:"variables"`
|
|
||||||
Blocklist []string
|
|
||||||
|
|
||||||
Tables []configTable
|
|
||||||
} `mapstructure:"database"`
|
|
||||||
|
|
||||||
Tables []configTable
|
|
||||||
|
|
||||||
RolesQuery string `mapstructure:"roles_query"`
|
|
||||||
Roles []configRole
|
|
||||||
roles map[string]*configRole
|
|
||||||
abacEnabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type configColumn struct {
|
type configColumn struct {
|
||||||
|
@ -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")
|
||||||
|
}
|
||||||
|
|
14
serv/init.go
14
serv/init.go
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
92
serv/serv.go
92
serv/serv.go
|
@ -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,25 +145,35 @@ 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))
|
}
|
||||||
|
|
||||||
|
routes := map[string]http.Handler{
|
||||||
|
"/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 {
|
if conf.WebUI {
|
||||||
mux.Handle("/", http.FileServer(rice.MustFindBox("../web/build").HTTPBox()))
|
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) {
|
||||||
|
@ -166,33 +181,38 @@ func routeHandler() http.Handler {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
ge := strings.ToLower(os.Getenv("GO_ENV"))
|
p := fmt.Sprintf("/api/v1/actions/%s", strings.ToLower(a.Name))
|
||||||
|
|
||||||
switch {
|
if authc, ok := findAuth(a.AuthName); ok {
|
||||||
case strings.HasPrefix(ge, "pro"):
|
routes[p] = withAuth(fn, authc)
|
||||||
return "prod"
|
} else {
|
||||||
|
routes[p] = fn
|
||||||
case strings.HasPrefix(ge, "sta"):
|
}
|
||||||
return "stage"
|
}
|
||||||
|
return nil
|
||||||
case strings.HasPrefix(ge, "tes"):
|
|
||||||
return "test"
|
|
||||||
|
|
||||||
case strings.HasPrefix(ge, "dev"):
|
|
||||||
return "dev"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ge
|
func findAuth(name string) (configAuth, bool) {
|
||||||
}
|
var authc configAuth
|
||||||
|
|
||||||
func isDev() bool {
|
for _, a := range conf.Auths {
|
||||||
return strings.HasPrefix(os.Getenv("GO_ENV"), "dev")
|
if strings.EqualFold(a.Name, name) {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authc, false
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
35
tmpl/dev.yml
35
tmpl/dev.yml
|
@ -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"
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue