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
- name: deals
query:
limit: 3
columns: ["name", "description" ]
aggregation: false
- name: purchases
query:
limit: 3
aggregation: false
- name: user
@ -183,12 +186,10 @@ roles:
query:
limit: 50
filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description", "search_rank", "search_headline_description" ]
disable_functions: false
insert:
filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
presets:
- user_id: "$user_id"
- created_at: "now"

View File

@ -1319,7 +1319,7 @@ auth:
max_active: 12000
```
### JWT Token Auth
### JWT Tokens
```yaml
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.
### 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
```bash
@ -1593,6 +1638,22 @@ auth:
# public_key_file: /secrets/public_key.pem
# 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:
type: postgres
host: db
@ -1623,6 +1684,17 @@ database:
- encrypted
- 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:
- name: customers
remotes:

View File

@ -151,6 +151,7 @@ SELECT
pg_catalog.format_type(f.atttypid,f.atttypmod) AS type,
CASE
WHEN f.attndims != 0 THEN true
WHEN right(pg_catalog.format_type(f.atttypid,f.atttypmod), 2) = '[]' THEN true
ELSE false
END AS array,
CASE
@ -175,7 +176,7 @@ FROM pg_attribute f
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_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 c.relname = $2 -- Replace with table name
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 (
"context"
"net/http"
"strings"
)
type ctxkey int
@ -14,7 +13,7 @@ const (
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) {
ctx := r.Context()
@ -37,28 +36,53 @@ func headerAuth(next http.Handler) http.HandlerFunc {
}
}
func withAuth(next http.Handler) http.Handler {
at := conf.Auth.Type
ru := conf.Auth.Rails.URL
func headerHandler(authc configAuth, next http.Handler) http.HandlerFunc {
hdr := authc.Header
if conf.Auth.CredsInHeader {
next = headerAuth(next)
if len(hdr.Name) == 0 {
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":
if strings.HasPrefix(ru, "memcache:") {
return railsMemcacheHandler(next)
}
if strings.HasPrefix(ru, "redis:") {
return railsRedisHandler(next)
}
return railsCookieHandler(next)
return railsHandler(authc, next)
case "jwt":
return jwtHandler(next)
return jwtHandler(authc, next)
case "header":
return headerHandler(authc, next)
}
return next

View File

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

View File

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

View File

@ -311,3 +311,13 @@ func getMigrationVars() map[string]interface{} {
"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) {
var err error
initWatcher(confPath)
if conf, err = initConf(); err != nil {
fatalInProd(err, "failed to read config")
}
if conf != nil {
db, err = initDBPool(conf)
if err == nil {
initCompiler()
initAllowList(confPath)
initPreparedList(confPath)
} else {
if err != nil {
fatalInProd(err, "failed to connect to database")
}
}
initWatcher(confPath)
initCompiler()
initResolvers()
initAllowList(confPath)
initPreparedList(confPath)
startHTTP()
}

View File

@ -33,7 +33,40 @@ type config struct {
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
Cookie string
CredsInHeader bool `mapstructure:"creds_in_header"`
@ -56,33 +89,12 @@ type config struct {
PubKeyFile string `mapstructure:"public_key_file"`
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 {
@ -156,6 +168,12 @@ type configRole struct {
tablesMap map[string]*configRoleTable
}
type configAction struct {
Name string
SQL string
AuthName string `mapstructure:"auth_name"`
}
func newConfig(name string) *viper.Viper {
vi := viper.New()
@ -283,26 +301,48 @@ func (c *config) Init(vi *viper.Viper) error {
func (c *config) validate() {
rm := make(map[string]struct{})
for i := range c.Roles {
name := c.Roles[i].Name
for _, v := range c.Roles {
name := strings.ToLower(v.Name)
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{}{}
}
tm := make(map[string]struct{})
for i := range c.Tables {
name := c.Tables[i].Name
for _, v := range c.Tables {
name := strings.ToLower(v.Name)
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{}{}
}
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 {
logger.Warn().Msgf("no 'roles_query' defined.")
}
@ -349,3 +389,31 @@ func sanitize(s string) string {
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 {
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) {

View File

@ -22,16 +22,20 @@ type resolvFn struct {
Fn func(h http.Header, id []byte) ([]byte, error)
}
func initResolvers() error {
func initResolvers() {
var err error
rmap = make(map[uint64]*resolvFn)
for _, t := range conf.Tables {
err := initRemotes(t)
err = initRemotes(t)
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 {

View File

@ -101,9 +101,14 @@ func startHTTP() {
hostPort = defaultHP
}
routes, err := routeHandler()
if err != nil {
errlog.Fatal().Err(err).Send()
}
srv := &http.Server{
Addr: hostPort,
Handler: routeHandler(),
Handler: routes,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
@ -140,25 +145,35 @@ func startHTTP() {
<-idleConnsClosed
}
func routeHandler() http.Handler {
var apiH http.Handler
if conf != nil && conf.HTTPGZip {
gzipH := gziphandler.MustNewGzipLevelHandler(6)
apiH = gzipH(http.HandlerFunc(apiV1))
} else {
apiH = http.HandlerFunc(apiV1)
}
func routeHandler() (http.Handler, error) {
mux := http.NewServeMux()
if conf != nil {
mux.HandleFunc("/health", health)
mux.Handle("/api/v1/graphql", withAuth(apiH))
if conf == nil {
return mux, nil
}
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 {
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) {
@ -166,33 +181,38 @@ func routeHandler() http.Handler {
mux.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
return http.HandlerFunc(fn), nil
}
func getConfigName() string {
if len(os.Getenv("GO_ENV")) == 0 {
return "dev"
func setActionRoutes(routes map[string]http.Handler) error {
var err error
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 {
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"
if authc, ok := findAuth(a.AuthName); ok {
routes[p] = withAuth(fn, authc)
} else {
routes[p] = fn
}
return ge
}
return nil
}
func isDev() bool {
return strings.HasPrefix(os.Getenv("GO_ENV"), "dev")
func findAuth(name string) (configAuth, bool) {
var authc configAuth
for _, a := range conf.Auths {
if strings.EqualFold(a.Name, name) {
return a, true
}
}
return authc, false
}

View File

@ -7,6 +7,7 @@ import (
"io"
"sort"
"strings"
"sync"
"github.com/cespare/xxhash/v2"
"github.com/dosco/super-graph/jsn"
@ -127,9 +128,14 @@ func findStmt(role string, stmts []stmt) *stmt {
}
func fatalInProd(err error, msg string) {
if isDev() {
errlog.Error().Err(err).Msg(msg)
} else {
var wg sync.WaitGroup
if !isDev() {
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
auth:
# Can be 'rails' or 'jwt'
# Can be 'rails', 'jwt' or 'header'
type: rails
cookie: _{% app_name_slug %}_session
@ -83,6 +83,22 @@ auth:
# public_key_file: /secrets/public_key.pem
# 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:
type: postgres
host: db
@ -116,6 +132,16 @@ database:
- encrypted
- 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:
- name: customers
remotes:
@ -137,6 +163,7 @@ tables:
name: me
table: users
roles_query: "SELECT * FROM users WHERE id = $user_id"
roles:
@ -168,20 +195,16 @@ roles:
query:
limit: 50
filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
disable_functions: false
insert:
filters: ["{ user_id: { eq: $user_id } }"]
columns: ["id", "name", "description" ]
presets:
- user_id: "$user_id"
- created_at: "now"
update:
filters: ["{ user_id: { eq: $user_id } }"]
columns:
- id
- name
presets:
- updated_at: "now"