From 9f5da8b801694870030712ce9edc196a267de0ef Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:10:47 -0500 Subject: [PATCH 01/15] add api key tables --- gadb/models.go | 22 ++++++++++ .../20230907112347-graphql-api-key.sql | 32 ++++++++++++++ migrate/schema.sql | 44 +++++++++++++++++-- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 migrate/migrations/20230907112347-graphql-api-key.sql diff --git a/gadb/models.go b/gadb/models.go index 891f2c6a9e..5723cf8d36 100644 --- a/gadb/models.go +++ b/gadb/models.go @@ -904,6 +904,28 @@ type GorpMigration struct { ID string } +type GqlApiKey struct { + CreatedAt time.Time + CreatedBy uuid.NullUUID + DeletedAt sql.NullTime + DeletedBy uuid.NullUUID + Description string + ExpiresAt time.Time + ID uuid.UUID + Name string + Policy json.RawMessage + UpdatedAt time.Time + UpdatedBy uuid.NullUUID +} + +type GqlApiKeyUsage struct { + ApiKeyID uuid.NullUUID + ID int64 + IpAddress pqtype.Inet + UsedAt time.Time + UserAgent sql.NullString +} + type HeartbeatMonitor struct { HeartbeatInterval int64 ID uuid.UUID diff --git a/migrate/migrations/20230907112347-graphql-api-key.sql b/migrate/migrations/20230907112347-graphql-api-key.sql new file mode 100644 index 0000000000..b76f3235bc --- /dev/null +++ b/migrate/migrations/20230907112347-graphql-api-key.sql @@ -0,0 +1,32 @@ +-- +migrate Up +CREATE TABLE gql_api_keys( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamp with time zone NOT NULL DEFAULT now(), + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + -- We must use json instead of jsonb because we need to be able to compute a reproducable hash of the policy + -- jsonb will not work because it does not guarantee a stable order of keys or whitespace consistency. + -- + -- We also don't need to be able to query the policy, so json is fine. + policy json NOT NULL, + expires_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + deleted_by uuid REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE gql_api_key_usage( + id bigserial PRIMARY KEY, + api_key_id uuid REFERENCES gql_api_keys(id) ON DELETE CASCADE UNIQUE, + used_at timestamp with time zone NOT NULL DEFAULT now(), + user_agent text, + ip_address inet +); + +-- +migrate Down +DROP TABLE gql_api_key_usage; + +DROP TABLE gql_api_keys; + diff --git a/migrate/schema.sql b/migrate/schema.sql index d5602ff0e2..ada581bccf 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,7 +1,7 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=35c108711cef0a38d78d5872c062fe576844c7f16e309378a051faa813578de0 - --- DISK=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b - --- PSQL=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b - +-- DATA=d5fcb3771bbfc1ccb244be27c0cb496ab74b6654d0948e3946d3bd4f6852069e - +-- DISK=05f86ed0fdc6cf25e0162624d600d9a8efd491d4dd4eb5a1411ba1f9704f22d8 - +-- PSQL=05f86ed0fdc6cf25e0162624d600d9a8efd491d4dd4eb5a1411ba1f9704f22d8 - -- -- pgdump-lite database dump -- @@ -1602,6 +1602,44 @@ CREATE TABLE gorp_migrations ( CREATE UNIQUE INDEX gorp_migrations_pkey ON public.gorp_migrations USING btree (id); +CREATE TABLE gql_api_key_usage ( + api_key_id uuid, + id bigint DEFAULT nextval('gql_api_key_usage_id_seq'::regclass) NOT NULL, + ip_address inet, + used_at timestamp with time zone DEFAULT now() NOT NULL, + user_agent text, + CONSTRAINT gql_api_key_usage_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES gql_api_keys(id) ON DELETE CASCADE, + CONSTRAINT gql_api_key_usage_api_key_id_key UNIQUE (api_key_id), + CONSTRAINT gql_api_key_usage_pkey PRIMARY KEY (id) +); + +CREATE UNIQUE INDEX gql_api_key_usage_api_key_id_key ON public.gql_api_key_usage USING btree (api_key_id); +CREATE UNIQUE INDEX gql_api_key_usage_pkey ON public.gql_api_key_usage USING btree (id); + + +CREATE TABLE gql_api_keys ( + created_at timestamp with time zone DEFAULT now() NOT NULL, + created_by uuid, + deleted_at timestamp with time zone, + deleted_by uuid, + description text NOT NULL, + expires_at timestamp with time zone NOT NULL, + id uuid NOT NULL, + name text NOT NULL, + policy json NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + updated_by uuid, + CONSTRAINT gql_api_keys_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT gql_api_keys_deleted_by_fkey FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT gql_api_keys_name_key UNIQUE (name), + CONSTRAINT gql_api_keys_pkey PRIMARY KEY (id), + CONSTRAINT gql_api_keys_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX gql_api_keys_name_key ON public.gql_api_keys USING btree (name); +CREATE UNIQUE INDEX gql_api_keys_pkey ON public.gql_api_keys USING btree (id); + + CREATE TABLE heartbeat_monitors ( heartbeat_interval interval NOT NULL, id uuid NOT NULL, From 96d80a07082c3a65cc73263f506fa423bf628b31 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:18:12 -0500 Subject: [PATCH 02/15] update schema for create and delete --- graphql2/schema.graphql | 15 +++++++++++++++ validation/validate/oneof.go | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index fd198f7a4b..d5b100f8b9 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -579,10 +579,25 @@ type Mutation { setConfig(input: [ConfigValueInput!]): Boolean! setSystemLimits(input: [SystemLimitInput!]!): Boolean! + createGQLAPIKey(input: CreateGQLAPIKeyInput!): CreatedGQLAPIKey! + deleteGQLAPIKey(id: ID!): Boolean! + createBasicAuth(input: CreateBasicAuthInput!): Boolean! updateBasicAuth(input: UpdateBasicAuthInput!): Boolean! } +type CreatedGQLAPIKey { + id: ID! + token: String! +} + +input CreateGQLAPIKeyInput { + name: String! + description: String! + allowedFields: [String!]! + expiresAt: ISOTimestamp! +} + input CreateBasicAuthInput { username: String! password: String! diff --git a/validation/validate/oneof.go b/validation/validate/oneof.go index 6e7a6163f8..9af9f0f787 100644 --- a/validation/validate/oneof.go +++ b/validation/validate/oneof.go @@ -2,12 +2,13 @@ package validate import ( "fmt" - "github.com/target/goalert/validation" "strings" + + "github.com/target/goalert/validation" ) // OneOf will check that value is one of the provided options. -func OneOf(fname string, value interface{}, options ...interface{}) error { +func OneOf[T comparable](fname string, value T, options ...T) error { for _, o := range options { if o == value { return nil From 2dcc82600ae20e2ad42db59ad97638644a8d06e1 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:21:47 -0500 Subject: [PATCH 03/15] manage keys --- apikey/context.go | 20 ++ apikey/jwt.go | 37 +++ apikey/lastusedcache.go | 58 ++++ apikey/polcache.go | 137 +++++++++ apikey/policy.go | 7 + apikey/queries.sql | 29 ++ apikey/store.go | 175 +++++++++++ gadb/queries.sql.go | 75 +++++ graphql2/generated.go | 495 ++++++++++++++++++++++++++---- graphql2/gqlgen.yml | 2 - graphql2/graphqlapp/gqlapikeys.go | 35 +++ graphql2/graphqlapp/user.go | 34 +- graphql2/models_gen.go | 20 ++ graphql2/schema.go | 67 ++++ permission/source.go | 3 + sqlc.yaml | 1 + 16 files changed, 1124 insertions(+), 71 deletions(-) create mode 100644 apikey/context.go create mode 100644 apikey/jwt.go create mode 100644 apikey/lastusedcache.go create mode 100644 apikey/polcache.go create mode 100644 apikey/policy.go create mode 100644 apikey/queries.sql create mode 100644 apikey/store.go create mode 100644 graphql2/graphqlapp/gqlapikeys.go create mode 100644 graphql2/schema.go diff --git a/apikey/context.go b/apikey/context.go new file mode 100644 index 0000000000..840b283093 --- /dev/null +++ b/apikey/context.go @@ -0,0 +1,20 @@ +package apikey + +import "context" + +type contextKey int + +const ( + contextKeyPolicy contextKey = iota +) + +// PolicyFromContext returns the Policy associated with the given context. +func PolicyFromContext(ctx context.Context) *GQLPolicy { + p, _ := ctx.Value(contextKeyPolicy).(*GQLPolicy) + return p +} + +// ContextWithPolicy returns a new context with the given Policy attached. +func ContextWithPolicy(ctx context.Context, p *GQLPolicy) context.Context { + return context.WithValue(ctx, contextKeyPolicy, p) +} diff --git a/apikey/jwt.go b/apikey/jwt.go new file mode 100644 index 0000000000..a0e7b8080d --- /dev/null +++ b/apikey/jwt.go @@ -0,0 +1,37 @@ +package apikey + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// Issuer is the JWT issuer for GraphQL API keys. +const Issuer = "goalert" + +// Audience is the JWT audience for GraphQL API keys. +const Audience = "apikey-v1/graphql-v1" + +// Claims is the set of claims that are encoded into a JWT for a GraphQL API key. +type Claims struct { + jwt.RegisteredClaims + PolicyHash []byte `json:"pol"` +} + +// NewGraphQLClaims returns a new Claims object for a GraphQL API key with the embedded policy hash. +func NewGraphQLClaims(id uuid.UUID, policyHash []byte, expires time.Time) jwt.Claims { + n := time.Now() + return &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.NewString(), + Subject: id.String(), + ExpiresAt: jwt.NewNumericDate(expires), + IssuedAt: jwt.NewNumericDate(n), + NotBefore: jwt.NewNumericDate(n.Add(-time.Minute)), + Issuer: Issuer, + Audience: []string{Audience}, + }, + PolicyHash: policyHash, + } +} diff --git a/apikey/lastusedcache.go b/apikey/lastusedcache.go new file mode 100644 index 0000000000..8ce311c5b5 --- /dev/null +++ b/apikey/lastusedcache.go @@ -0,0 +1,58 @@ +package apikey + +import ( + "context" + "net" + "sync" + "time" + + "github.com/golang/groupcache/lru" + "github.com/google/uuid" + "github.com/target/goalert/gadb" + "github.com/target/goalert/validation/validate" +) + +// lastUsedCache is used to track the last time we recorded usage for a given API key. +type lastUsedCache struct { + lru *lru.Cache + + mx sync.Mutex + updateFunc func(ctx context.Context, id uuid.UUID, ua, ip string) error +} + +// newLastUsedCache will create a new lastUsedCache with the given max size and updateFunc. +func newLastUsedCache(max int, updateFunc func(ctx context.Context, id uuid.UUID, ua, ip string) error) *lastUsedCache { + return &lastUsedCache{ + lru: lru.New(max), + updateFunc: updateFunc, + } +} + +// RecordUsage will record usage for the given API key ID, user agent, and IP address up to once per minute. +func (c *lastUsedCache) RecordUsage(ctx context.Context, id uuid.UUID, ua, ip string) error { + c.mx.Lock() + defer c.mx.Unlock() + if t, ok := c.lru.Get(id); ok && time.Since(t.(time.Time)) < time.Minute { + return nil + } + + c.lru.Add(id, time.Now()) + return c.updateFunc(ctx, id, ua, ip) +} + +// _updateLastUsed will record usage for the given API key ID, user agent, and IP address. +func (s *Store) _updateLastUsed(ctx context.Context, id uuid.UUID, ua, ip string) error { + ua = validate.SanitizeText(ua, 1024) + ip, _, _ = net.SplitHostPort(ip) + ip = validate.SanitizeText(ip, 255) + params := gadb.APIKeyRecordUsageParams{ + KeyID: id, + UserAgent: ua, + } + params.IpAddress.IPNet.IP = net.ParseIP(ip) + params.IpAddress.IPNet.Mask = net.CIDRMask(32, 32) + if params.IpAddress.IPNet.IP != nil { + params.IpAddress.Valid = true + } + return gadb.New(s.db).APIKeyRecordUsage(ctx, params) +} diff --git a/apikey/polcache.go b/apikey/polcache.go new file mode 100644 index 0000000000..f65316d247 --- /dev/null +++ b/apikey/polcache.go @@ -0,0 +1,137 @@ +package apikey + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "sync" + + "github.com/golang/groupcache/lru" + "github.com/google/uuid" + "github.com/target/goalert/gadb" +) + +// polCache handles caching of policyInfo objects, as well as negative caching +// of invalid keys. +type polCache struct { + lru *lru.Cache + neg *lru.Cache + mx sync.Mutex + + cfg polCacheConfig +} + +type polCacheConfig struct { + FillFunc func(context.Context, uuid.UUID) (*policyInfo, bool, error) + Verify func(context.Context, uuid.UUID) (bool, error) + MaxSize int +} + +// newPolCache will create a new polCache with the given config. +func newPolCache(cfg polCacheConfig) *polCache { + return &polCache{ + lru: lru.New(cfg.MaxSize), + neg: lru.New(cfg.MaxSize), + cfg: cfg, + } +} + +// Revoke will add the key to the negative cache. +func (c *polCache) Revoke(ctx context.Context, key uuid.UUID) error { + c.mx.Lock() + defer c.mx.Unlock() + + c.neg.Add(key, nil) + c.lru.Remove(key) + + return nil +} + +// Get will return the policyInfo for the given key. +// +// If the key is in the cache, it will be verified before returning. +// +// If it is not in the cache, it will be fetched and added to the cache. +// +// If either the key is invalid or the policy is invalid, the key will be +// added to the negative cache. +func (c *polCache) Get(ctx context.Context, key uuid.UUID) (value *policyInfo, ok bool, err error) { + c.mx.Lock() + defer c.mx.Unlock() + + if _, ok := c.neg.Get(key); ok { + return value, false, nil + } + + if v, ok := c.lru.Get(key); ok { + // Check if the key is still valid before returning it, + // if it is not valid, we can remove it from the cache. + isValid, err := c.cfg.Verify(ctx, key) + if err != nil { + return value, false, err + } + + // Since each key has a unique ID and is signed, we can + // safely assume that an invalid key will always be invalid + // and can be cached. + if !isValid { + c.neg.Add(key, nil) + c.lru.Remove(key) + return value, false, nil + } + + return v.(*policyInfo), true, nil + } + + // If the key is not in the cache, we need to fetch it, + // and add it to the cache. We can safely assume that + // the key is valid, when returned from the FillFunc. + value, isValid, err := c.cfg.FillFunc(ctx, key) + if err != nil { + return value, false, err + } + if !isValid { + c.neg.Add(key, nil) + return value, false, nil + } + + c.lru.Add(key, value) + return value, true, nil +} + +// _verifyPolicyID will verify that the given key is valid. +func (s *Store) _verifyPolicyID(ctx context.Context, id uuid.UUID) (bool, error) { + valid, err := gadb.New(s.db).APIKeyAuthCheck(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + + return valid, nil +} + +// _fetchPolicyInfo will fetch the policyInfo for the given key. +func (s *Store) _fetchPolicyInfo(ctx context.Context, id uuid.UUID) (*policyInfo, bool, error) { + polData, err := gadb.New(s.db).APIKeyAuthPolicy(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + + var info policyInfo + err = json.Unmarshal(polData, &info.Policy) + if err != nil { + return nil, false, err + } + + h := sha256.Sum256(polData) + info.Hash = h[:] + + return &info, true, nil +} diff --git a/apikey/policy.go b/apikey/policy.go new file mode 100644 index 0000000000..094284bc5a --- /dev/null +++ b/apikey/policy.go @@ -0,0 +1,7 @@ +package apikey + +// GQLPolicy is a GraphQL API key policy. +type GQLPolicy struct { + Version int + AllowedFields []string +} diff --git a/apikey/queries.sql b/apikey/queries.sql new file mode 100644 index 0000000000..860257217c --- /dev/null +++ b/apikey/queries.sql @@ -0,0 +1,29 @@ +-- name: APIKeyInsert :exec +INSERT INTO gql_api_keys(id, name, description, POLICY, created_by, updated_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7); + +-- name: APIKeyDelete :exec +DELETE FROM gql_api_keys +WHERE id = $1; + +-- name: APIKeyAuthPolicy :one +-- APIKeyAuth returns the API key policy with the given id, if it exists and is not expired. +SELECT + gql_api_keys.policy +FROM + gql_api_keys +WHERE + gql_api_keys.id = $1 + AND gql_api_keys.deleted_at IS NULL + AND gql_api_keys.expires_at > now(); + +-- name: APIKeyAuthCheck :one +SELECT + TRUE +FROM + gql_api_keys +WHERE + gql_api_keys.id = $1 + AND gql_api_keys.deleted_at IS NULL + AND gql_api_keys.expires_at > now(); + diff --git a/apikey/store.go b/apikey/store.go new file mode 100644 index 0000000000..4182ff2667 --- /dev/null +++ b/apikey/store.go @@ -0,0 +1,175 @@ +package apikey + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/google/uuid" + "github.com/target/goalert/gadb" + "github.com/target/goalert/graphql2" + "github.com/target/goalert/keyring" + "github.com/target/goalert/permission" + "github.com/target/goalert/util/log" + "github.com/target/goalert/validation" + "github.com/target/goalert/validation/validate" +) + +// Store is used to manage API keys. +type Store struct { + db *sql.DB + key keyring.Keyring + + polCache *polCache + lastUsedCache *lastUsedCache +} + +type policyInfo struct { + Hash []byte + Policy GQLPolicy +} + +// NewStore will create a new Store. +func NewStore(ctx context.Context, db *sql.DB, key keyring.Keyring) (*Store, error) { + s := &Store{ + db: db, + key: key, + } + + s.polCache = newPolCache(polCacheConfig{ + FillFunc: s._fetchPolicyInfo, + Verify: s._verifyPolicyID, + MaxSize: 1000, + }) + + s.lastUsedCache = newLastUsedCache(1000, s._updateLastUsed) + + return s, nil +} + +func (s *Store) DeleteAdminGraphQLKey(ctx context.Context, id uuid.UUID) error { + err := permission.LimitCheckAny(ctx, permission.Admin) + if err != nil { + return err + } + + return gadb.New(s.db).APIKeyDelete(ctx, id) +} + +func (s *Store) AuthorizeGraphQL(ctx context.Context, tok, ua, ip string) (context.Context, error) { + var claims Claims + _, err := s.key.VerifyJWT(tok, &claims, Issuer, Audience) + if err != nil { + return ctx, permission.Unauthorized() + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + log.Logf(ctx, "apikey: invalid subject: %v", err) + return ctx, permission.Unauthorized() + } + + info, valid, err := s.polCache.Get(ctx, id) + if err != nil { + return nil, err + } + if !valid { + // Successful negative cache lookup, we return Unauthorized because although the token was validated, the key was revoked/removed. + return ctx, permission.Unauthorized() + } + if !bytes.Equal(info.Hash, claims.PolicyHash) { + // Successful cache lookup, but the policy has changed since the token was issued and so the token is no longer valid. + s.polCache.Revoke(ctx, id) + + // We want to log this as a warning, because it is a potential security issue. + log.Log(ctx, fmt.Errorf("apikey: policy hash mismatch for key %s", id)) + return ctx, permission.Unauthorized() + } + + err = s.lastUsedCache.RecordUsage(ctx, id, ua, ip) + if err != nil { + // Recording usage is not critical, so we log the error and continue. + log.Log(ctx, err) + } + + ctx = permission.SourceContext(ctx, &permission.SourceInfo{ + ID: id.String(), + Type: permission.SourceTypeGQLAPIKey, + }) + ctx = permission.UserContext(ctx, "", permission.RoleUnknown) + + ctx = ContextWithPolicy(ctx, &info.Policy) + return ctx, nil +} + +// NewAdminGQLKeyOpts is used to create a new GraphQL API key. +type NewAdminGQLKeyOpts struct { + Name string + Desc string + Fields []string + Expires time.Time +} + +// CreateAdminGraphQLKey will create a new GraphQL API key returning the ID and token. +func (s *Store) CreateAdminGraphQLKey(ctx context.Context, opt NewAdminGQLKeyOpts) (uuid.UUID, string, error) { + err := permission.LimitCheckAny(ctx, permission.Admin) + if err != nil { + return uuid.Nil, "", err + } + + err = validate.Many( + validate.IDName("Name", opt.Name), + validate.Text("Description", opt.Desc, 0, 255), + validate.Range("Fields", len(opt.Fields), 1, len(graphql2.SchemaFields())), + ) + for i, f := range opt.Fields { + err = validate.Many(err, validate.OneOf(fmt.Sprintf("Fields[%d]", i), f, graphql2.SchemaFields()...)) + } + if time.Until(opt.Expires) <= 0 { + err = validate.Many(err, validation.NewFieldError("Expires", "must be in the future")) + } + if err != nil { + return uuid.Nil, "", err + } + + sort.Strings(opt.Fields) + policyData, err := json.Marshal(GQLPolicy{ + Version: 1, + AllowedFields: opt.Fields, + }) + if err != nil { + return uuid.Nil, "", err + } + + var user uuid.NullUUID + userID, err := uuid.Parse(permission.UserID(ctx)) + if err == nil { + user = uuid.NullUUID{UUID: userID, Valid: true} + } + + id := uuid.New() + err = gadb.New(s.db).APIKeyInsert(ctx, gadb.APIKeyInsertParams{ + ID: id, + Name: opt.Name, + Description: opt.Desc, + ExpiresAt: opt.Expires, + Policy: policyData, + CreatedBy: user, + UpdatedBy: user, + }) + if err != nil { + return uuid.Nil, "", err + } + + hash := sha256.Sum256([]byte(policyData)) + tok, err := s.key.SignJWT(NewGraphQLClaims(id, hash[:], opt.Expires)) + if err != nil { + return uuid.Nil, "", err + } + + return id, tok, nil +} diff --git a/gadb/queries.sql.go b/gadb/queries.sql.go index c0d5e5ca45..ac558fa19b 100644 --- a/gadb/queries.sql.go +++ b/gadb/queries.sql.go @@ -16,6 +16,81 @@ import ( "github.com/sqlc-dev/pqtype" ) +const aPIKeyAuthCheck = `-- name: APIKeyAuthCheck :one +SELECT + TRUE +FROM + gql_api_keys +WHERE + gql_api_keys.id = $1 + AND gql_api_keys.deleted_at IS NULL + AND gql_api_keys.expires_at > now() +` + +func (q *Queries) APIKeyAuthCheck(ctx context.Context, id uuid.UUID) (bool, error) { + row := q.db.QueryRowContext(ctx, aPIKeyAuthCheck, id) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const aPIKeyAuthPolicy = `-- name: APIKeyAuthPolicy :one +SELECT + gql_api_keys.policy +FROM + gql_api_keys +WHERE + gql_api_keys.id = $1 + AND gql_api_keys.deleted_at IS NULL + AND gql_api_keys.expires_at > now() +` + +// APIKeyAuth returns the API key policy with the given id, if it exists and is not expired. +func (q *Queries) APIKeyAuthPolicy(ctx context.Context, id uuid.UUID) (json.RawMessage, error) { + row := q.db.QueryRowContext(ctx, aPIKeyAuthPolicy, id) + var policy json.RawMessage + err := row.Scan(&policy) + return policy, err +} + +const aPIKeyDelete = `-- name: APIKeyDelete :exec +DELETE FROM gql_api_keys +WHERE id = $1 +` + +func (q *Queries) APIKeyDelete(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, aPIKeyDelete, id) + return err +} + +const aPIKeyInsert = `-- name: APIKeyInsert :exec +INSERT INTO gql_api_keys(id, name, description, POLICY, created_by, updated_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) +` + +type APIKeyInsertParams struct { + ID uuid.UUID + Name string + Description string + Policy json.RawMessage + CreatedBy uuid.NullUUID + UpdatedBy uuid.NullUUID + ExpiresAt time.Time +} + +func (q *Queries) APIKeyInsert(ctx context.Context, arg APIKeyInsertParams) error { + _, err := q.db.ExecContext(ctx, aPIKeyInsert, + arg.ID, + arg.Name, + arg.Description, + arg.Policy, + arg.CreatedBy, + arg.UpdatedBy, + arg.ExpiresAt, + ) + return err +} + const alertFeedback = `-- name: AlertFeedback :many SELECT alert_id, diff --git a/graphql2/generated.go b/graphql2/generated.go index 8c3260353e..3cef80d824 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -19,7 +19,6 @@ import ( "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" "github.com/target/goalert/assignment" - "github.com/target/goalert/auth" "github.com/target/goalert/calsub" "github.com/target/goalert/escalation" "github.com/target/goalert/heartbeat" @@ -85,7 +84,6 @@ type ResolverRoot interface { UserContactMethod() UserContactMethodResolver UserNotificationRule() UserNotificationRuleResolver UserOverride() UserOverrideResolver - UserSession() UserSessionResolver } type DirectiveRoot struct { @@ -172,6 +170,11 @@ type ComplexityRoot struct { Value func(childComplexity int) int } + CreatedGQLAPIKey struct { + ID func(childComplexity int) int + Token func(childComplexity int) int + } + DebugCarrierInfo struct { MobileCountryCode func(childComplexity int) int MobileNetworkCode func(childComplexity int) int @@ -294,6 +297,7 @@ type ComplexityRoot struct { CreateBasicAuth func(childComplexity int, input CreateBasicAuthInput) int CreateEscalationPolicy func(childComplexity int, input CreateEscalationPolicyInput) int CreateEscalationPolicyStep func(childComplexity int, input CreateEscalationPolicyStepInput) int + CreateGQLAPIKey func(childComplexity int, input CreateGQLAPIKeyInput) int CreateHeartbeatMonitor func(childComplexity int, input CreateHeartbeatMonitorInput) int CreateIntegrationKey func(childComplexity int, input CreateIntegrationKeyInput) int CreateRotation func(childComplexity int, input CreateRotationInput) int @@ -308,6 +312,7 @@ type ComplexityRoot struct { DebugSendSms func(childComplexity int, input DebugSendSMSInput) int DeleteAll func(childComplexity int, input []assignment.RawTarget) int DeleteAuthSubject func(childComplexity int, input user.AuthSubject) int + DeleteGQLAPIKey func(childComplexity int, id string) int EndAllAuthSessionsByCurrentUser func(childComplexity int) int EscalateAlerts func(childComplexity int, input []int) int LinkAccount func(childComplexity int, token string) int @@ -756,6 +761,8 @@ type MutationResolver interface { UpdateAlertsByService(ctx context.Context, input UpdateAlertsByServiceInput) (bool, error) SetConfig(ctx context.Context, input []ConfigValueInput) (bool, error) SetSystemLimits(ctx context.Context, input []SystemLimitInput) (bool, error) + CreateGQLAPIKey(ctx context.Context, input CreateGQLAPIKeyInput) (*CreatedGQLAPIKey, error) + DeleteGQLAPIKey(ctx context.Context, id string) (bool, error) CreateBasicAuth(ctx context.Context, input CreateBasicAuthInput) (bool, error) UpdateBasicAuth(ctx context.Context, input UpdateBasicAuthInput) (bool, error) } @@ -855,7 +862,7 @@ type UserResolver interface { CalendarSubscriptions(ctx context.Context, obj *user.User) ([]calsub.Subscription, error) AuthSubjects(ctx context.Context, obj *user.User) ([]user.AuthSubject, error) - Sessions(ctx context.Context, obj *user.User) ([]auth.UserSession, error) + Sessions(ctx context.Context, obj *user.User) ([]UserSession, error) OnCallSteps(ctx context.Context, obj *user.User) ([]escalation.Step, error) IsFavorite(ctx context.Context, obj *user.User) (bool, error) } @@ -882,9 +889,6 @@ type UserOverrideResolver interface { RemoveUser(ctx context.Context, obj *override.UserOverride) (*user.User, error) Target(ctx context.Context, obj *override.UserOverride) (*assignment.RawTarget, error) } -type UserSessionResolver interface { - Current(ctx context.Context, obj *auth.UserSession) (bool, error) -} type executableSchema struct { resolvers ResolverRoot @@ -1214,6 +1218,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ConfigValue.Value(childComplexity), true + case "CreatedGQLAPIKey.id": + if e.complexity.CreatedGQLAPIKey.ID == nil { + break + } + + return e.complexity.CreatedGQLAPIKey.ID(childComplexity), true + + case "CreatedGQLAPIKey.token": + if e.complexity.CreatedGQLAPIKey.Token == nil { + break + } + + return e.complexity.CreatedGQLAPIKey.Token(childComplexity), true + case "DebugCarrierInfo.mobileCountryCode": if e.complexity.DebugCarrierInfo.MobileCountryCode == nil { break @@ -1760,6 +1778,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CreateEscalationPolicyStep(childComplexity, args["input"].(CreateEscalationPolicyStepInput)), true + case "Mutation.createGQLAPIKey": + if e.complexity.Mutation.CreateGQLAPIKey == nil { + break + } + + args, err := ec.field_Mutation_createGQLAPIKey_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateGQLAPIKey(childComplexity, args["input"].(CreateGQLAPIKeyInput)), true + case "Mutation.createHeartbeatMonitor": if e.complexity.Mutation.CreateHeartbeatMonitor == nil { break @@ -1928,6 +1958,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.DeleteAuthSubject(childComplexity, args["input"].(user.AuthSubject)), true + case "Mutation.deleteGQLAPIKey": + if e.complexity.Mutation.DeleteGQLAPIKey == nil { + break + } + + args, err := ec.field_Mutation_deleteGQLAPIKey_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteGQLAPIKey(childComplexity, args["id"].(string)), true + case "Mutation.endAllAuthSessionsByCurrentUser": if e.complexity.Mutation.EndAllAuthSessionsByCurrentUser == nil { break @@ -3959,6 +4001,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCreateBasicAuthInput, ec.unmarshalInputCreateEscalationPolicyInput, ec.unmarshalInputCreateEscalationPolicyStepInput, + ec.unmarshalInputCreateGQLAPIKeyInput, ec.unmarshalInputCreateHeartbeatMonitorInput, ec.unmarshalInputCreateIntegrationKeyInput, ec.unmarshalInputCreateRotationInput, @@ -4250,6 +4293,21 @@ func (ec *executionContext) field_Mutation_createEscalationPolicy_args(ctx conte return args, nil } +func (ec *executionContext) field_Mutation_createGQLAPIKey_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 CreateGQLAPIKeyInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNCreateGQLAPIKeyInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreateGQLAPIKeyInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_createHeartbeatMonitor_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -4460,6 +4518,21 @@ func (ec *executionContext) field_Mutation_deleteAuthSubject_args(ctx context.Co return args, nil } +func (ec *executionContext) field_Mutation_deleteGQLAPIKey_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["id"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + arg0, err = ec.unmarshalNID2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_escalateAlerts_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -7600,6 +7673,94 @@ func (ec *executionContext) fieldContext_ConfigValue_deprecated(ctx context.Cont return fc, nil } +func (ec *executionContext) _CreatedGQLAPIKey_id(ctx context.Context, field graphql.CollectedField, obj *CreatedGQLAPIKey) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CreatedGQLAPIKey_id(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNID2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CreatedGQLAPIKey_id(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreatedGQLAPIKey", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CreatedGQLAPIKey_token(ctx context.Context, field graphql.CollectedField, obj *CreatedGQLAPIKey) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CreatedGQLAPIKey_token(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Token, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CreatedGQLAPIKey_token(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreatedGQLAPIKey", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _DebugCarrierInfo_name(ctx context.Context, field graphql.CollectedField, obj *twilio.CarrierInfo) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DebugCarrierInfo_name(ctx, field) if err != nil { @@ -13495,6 +13656,122 @@ func (ec *executionContext) fieldContext_Mutation_setSystemLimits(ctx context.Co return fc, nil } +func (ec *executionContext) _Mutation_createGQLAPIKey(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createGQLAPIKey(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CreateGQLAPIKey(rctx, fc.Args["input"].(CreateGQLAPIKeyInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*CreatedGQLAPIKey) + fc.Result = res + return ec.marshalNCreatedGQLAPIKey2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreatedGQLAPIKey(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createGQLAPIKey(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_CreatedGQLAPIKey_id(ctx, field) + case "token": + return ec.fieldContext_CreatedGQLAPIKey_token(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CreatedGQLAPIKey", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createGQLAPIKey_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteGQLAPIKey(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deleteGQLAPIKey(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeleteGQLAPIKey(rctx, fc.Args["id"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deleteGQLAPIKey(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteGQLAPIKey_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createBasicAuth(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createBasicAuth(ctx, field) if err != nil { @@ -22494,9 +22771,9 @@ func (ec *executionContext) _User_sessions(ctx context.Context, field graphql.Co } return graphql.Null } - res := resTmp.([]auth.UserSession) + res := resTmp.([]UserSession) fc.Result = res - return ec.marshalNUserSession2ᚕgithubᚗcomᚋtargetᚋgoalertᚋauthᚐUserSessionᚄ(ctx, field.Selections, res) + return ec.marshalNUserSession2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐUserSessionᚄ(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_User_sessions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -24317,7 +24594,7 @@ func (ec *executionContext) fieldContext_UserOverrideConnection_pageInfo(ctx con return fc, nil } -func (ec *executionContext) _UserSession_id(ctx context.Context, field graphql.CollectedField, obj *auth.UserSession) (ret graphql.Marshaler) { +func (ec *executionContext) _UserSession_id(ctx context.Context, field graphql.CollectedField, obj *UserSession) (ret graphql.Marshaler) { fc, err := ec.fieldContext_UserSession_id(ctx, field) if err != nil { return graphql.Null @@ -24361,7 +24638,7 @@ func (ec *executionContext) fieldContext_UserSession_id(ctx context.Context, fie return fc, nil } -func (ec *executionContext) _UserSession_current(ctx context.Context, field graphql.CollectedField, obj *auth.UserSession) (ret graphql.Marshaler) { +func (ec *executionContext) _UserSession_current(ctx context.Context, field graphql.CollectedField, obj *UserSession) (ret graphql.Marshaler) { fc, err := ec.fieldContext_UserSession_current(ctx, field) if err != nil { return graphql.Null @@ -24375,7 +24652,7 @@ func (ec *executionContext) _UserSession_current(ctx context.Context, field grap }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.UserSession().Current(rctx, obj) + return obj.Current, nil }) if err != nil { ec.Error(ctx, err) @@ -24396,8 +24673,8 @@ func (ec *executionContext) fieldContext_UserSession_current(ctx context.Context fc = &graphql.FieldContext{ Object: "UserSession", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type Boolean does not have child fields") }, @@ -24405,7 +24682,7 @@ func (ec *executionContext) fieldContext_UserSession_current(ctx context.Context return fc, nil } -func (ec *executionContext) _UserSession_userAgent(ctx context.Context, field graphql.CollectedField, obj *auth.UserSession) (ret graphql.Marshaler) { +func (ec *executionContext) _UserSession_userAgent(ctx context.Context, field graphql.CollectedField, obj *UserSession) (ret graphql.Marshaler) { fc, err := ec.fieldContext_UserSession_userAgent(ctx, field) if err != nil { return graphql.Null @@ -24449,7 +24726,7 @@ func (ec *executionContext) fieldContext_UserSession_userAgent(ctx context.Conte return fc, nil } -func (ec *executionContext) _UserSession_createdAt(ctx context.Context, field graphql.CollectedField, obj *auth.UserSession) (ret graphql.Marshaler) { +func (ec *executionContext) _UserSession_createdAt(ctx context.Context, field graphql.CollectedField, obj *UserSession) (ret graphql.Marshaler) { fc, err := ec.fieldContext_UserSession_createdAt(ctx, field) if err != nil { return graphql.Null @@ -24493,7 +24770,7 @@ func (ec *executionContext) fieldContext_UserSession_createdAt(ctx context.Conte return fc, nil } -func (ec *executionContext) _UserSession_lastAccessAt(ctx context.Context, field graphql.CollectedField, obj *auth.UserSession) (ret graphql.Marshaler) { +func (ec *executionContext) _UserSession_lastAccessAt(ctx context.Context, field graphql.CollectedField, obj *UserSession) (ret graphql.Marshaler) { fc, err := ec.fieldContext_UserSession_lastAccessAt(ctx, field) if err != nil { return graphql.Null @@ -26983,6 +27260,62 @@ func (ec *executionContext) unmarshalInputCreateEscalationPolicyStepInput(ctx co return it, nil } +func (ec *executionContext) unmarshalInputCreateGQLAPIKeyInput(ctx context.Context, obj interface{}) (CreateGQLAPIKeyInput, error) { + var it CreateGQLAPIKeyInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"name", "description", "allowedFields", "expiresAt"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "name": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "description": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("description")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Description = data + case "allowedFields": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("allowedFields")) + data, err := ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.AllowedFields = data + case "expiresAt": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("expiresAt")) + data, err := ec.unmarshalNISOTimestamp2timeᚐTime(ctx, v) + if err != nil { + return it, err + } + it.ExpiresAt = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputCreateHeartbeatMonitorInput(ctx context.Context, obj interface{}) (CreateHeartbeatMonitorInput, error) { var it CreateHeartbeatMonitorInput asMap := map[string]interface{}{} @@ -31353,6 +31686,50 @@ func (ec *executionContext) _ConfigValue(ctx context.Context, sel ast.SelectionS return out } +var createdGQLAPIKeyImplementors = []string{"CreatedGQLAPIKey"} + +func (ec *executionContext) _CreatedGQLAPIKey(ctx context.Context, sel ast.SelectionSet, obj *CreatedGQLAPIKey) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, createdGQLAPIKeyImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreatedGQLAPIKey") + case "id": + out.Values[i] = ec._CreatedGQLAPIKey_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "token": + out.Values[i] = ec._CreatedGQLAPIKey_token(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var debugCarrierInfoImplementors = []string{"DebugCarrierInfo"} func (ec *executionContext) _DebugCarrierInfo(ctx context.Context, sel ast.SelectionSet, obj *twilio.CarrierInfo) graphql.Marshaler { @@ -32833,6 +33210,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createGQLAPIKey": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createGQLAPIKey(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteGQLAPIKey": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteGQLAPIKey(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createBasicAuth": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createBasicAuth(ctx, field) @@ -37210,7 +37601,7 @@ func (ec *executionContext) _UserOverrideConnection(ctx context.Context, sel ast var userSessionImplementors = []string{"UserSession"} -func (ec *executionContext) _UserSession(ctx context.Context, sel ast.SelectionSet, obj *auth.UserSession) graphql.Marshaler { +func (ec *executionContext) _UserSession(ctx context.Context, sel ast.SelectionSet, obj *UserSession) graphql.Marshaler { fields := graphql.CollectFields(ec.OperationContext, sel, userSessionImplementors) out := graphql.NewFieldSet(fields) @@ -37222,58 +37613,27 @@ func (ec *executionContext) _UserSession(ctx context.Context, sel ast.SelectionS case "id": out.Values[i] = ec._UserSession_id(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "current": - field := field - - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._UserSession_current(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue + out.Values[i] = ec._UserSession_current(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "userAgent": out.Values[i] = ec._UserSession_userAgent(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "createdAt": out.Values[i] = ec._UserSession_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "lastAccessAt": out.Values[i] = ec._UserSession_lastAccessAt(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } default: panic("unknown field " + strconv.Quote(field.Name)) @@ -38049,6 +38409,11 @@ func (ec *executionContext) unmarshalNCreateEscalationPolicyStepInput2githubᚗc return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNCreateGQLAPIKeyInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreateGQLAPIKeyInput(ctx context.Context, v interface{}) (CreateGQLAPIKeyInput, error) { + res, err := ec.unmarshalInputCreateGQLAPIKeyInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNCreateHeartbeatMonitorInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreateHeartbeatMonitorInput(ctx context.Context, v interface{}) (CreateHeartbeatMonitorInput, error) { res, err := ec.unmarshalInputCreateHeartbeatMonitorInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -38099,6 +38464,20 @@ func (ec *executionContext) unmarshalNCreateUserOverrideInput2githubᚗcomᚋtar return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalNCreatedGQLAPIKey2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreatedGQLAPIKey(ctx context.Context, sel ast.SelectionSet, v CreatedGQLAPIKey) graphql.Marshaler { + return ec._CreatedGQLAPIKey(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCreatedGQLAPIKey2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐCreatedGQLAPIKey(ctx context.Context, sel ast.SelectionSet, v *CreatedGQLAPIKey) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CreatedGQLAPIKey(ctx, sel, v) +} + func (ec *executionContext) marshalNDebugCarrierInfo2githubᚗcomᚋtargetᚋgoalertᚋnotificationᚋtwilioᚐCarrierInfo(ctx context.Context, sel ast.SelectionSet, v twilio.CarrierInfo) graphql.Marshaler { return ec._DebugCarrierInfo(ctx, sel, &v) } @@ -40333,11 +40712,11 @@ func (ec *executionContext) marshalNUserRole2githubᚗcomᚋtargetᚋgoalertᚋg return v } -func (ec *executionContext) marshalNUserSession2githubᚗcomᚋtargetᚋgoalertᚋauthᚐUserSession(ctx context.Context, sel ast.SelectionSet, v auth.UserSession) graphql.Marshaler { +func (ec *executionContext) marshalNUserSession2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐUserSession(ctx context.Context, sel ast.SelectionSet, v UserSession) graphql.Marshaler { return ec._UserSession(ctx, sel, &v) } -func (ec *executionContext) marshalNUserSession2ᚕgithubᚗcomᚋtargetᚋgoalertᚋauthᚐUserSessionᚄ(ctx context.Context, sel ast.SelectionSet, v []auth.UserSession) graphql.Marshaler { +func (ec *executionContext) marshalNUserSession2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐUserSessionᚄ(ctx context.Context, sel ast.SelectionSet, v []UserSession) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup isLen1 := len(v) == 1 @@ -40361,7 +40740,7 @@ func (ec *executionContext) marshalNUserSession2ᚕgithubᚗcomᚋtargetᚋgoale if !isLen1 { defer wg.Done() } - ret[i] = ec.marshalNUserSession2githubᚗcomᚋtargetᚋgoalertᚋauthᚐUserSession(ctx, sel, v[i]) + ret[i] = ec.marshalNUserSession2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐUserSession(ctx, sel, v[i]) } if isLen1 { f(i) diff --git a/graphql2/gqlgen.yml b/graphql2/gqlgen.yml index d3794aabcb..727c747b21 100644 --- a/graphql2/gqlgen.yml +++ b/graphql2/gqlgen.yml @@ -84,8 +84,6 @@ models: model: github.com/target/goalert/limit.ID DebugCarrierInfo: model: github.com/target/goalert/notification/twilio.CarrierInfo - UserSession: - model: github.com/target/goalert/auth.UserSession Notice: model: github.com/target/goalert/notice.Notice NoticeType: diff --git a/graphql2/graphqlapp/gqlapikeys.go b/graphql2/graphqlapp/gqlapikeys.go new file mode 100644 index 0000000000..582102b3d1 --- /dev/null +++ b/graphql2/graphqlapp/gqlapikeys.go @@ -0,0 +1,35 @@ +package graphqlapp + +import ( + "context" + + "github.com/target/goalert/apikey" + "github.com/target/goalert/graphql2" +) + +func (a *Mutation) DeleteGQLAPIKey(ctx context.Context, input string) (bool, error) { + id, err := parseUUID("ID", input) + if err != nil { + return false, err + } + + err = a.APIKeyStore.DeleteAdminGraphQLKey(ctx, id) + return err == nil, err +} + +func (a *Mutation) CreateGQLAPIKey(ctx context.Context, input graphql2.CreateGQLAPIKeyInput) (*graphql2.CreatedGQLAPIKey, error) { + id, tok, err := a.APIKeyStore.CreateAdminGraphQLKey(ctx, apikey.NewAdminGQLKeyOpts{ + Name: input.Name, + Desc: input.Description, + Expires: input.ExpiresAt, + Fields: input.AllowedFields, + }) + if err != nil { + return nil, err + } + + return &graphql2.CreatedGQLAPIKey{ + ID: id.String(), + Token: tok, + }, nil +} diff --git a/graphql2/graphqlapp/user.go b/graphql2/graphqlapp/user.go index 350a02794d..0ef315fff8 100644 --- a/graphql2/graphqlapp/user.go +++ b/graphql2/graphqlapp/user.go @@ -4,7 +4,6 @@ import ( context "context" "database/sql" - "github.com/target/goalert/auth" "github.com/target/goalert/auth/basic" "github.com/target/goalert/calsub" "github.com/target/goalert/validation" @@ -22,28 +21,41 @@ import ( ) type ( - User App - UserSession App + User App ) func (a *App) User() graphql2.UserResolver { return (*User)(a) } -func (a *App) UserSession() graphql2.UserSessionResolver { return (*UserSession)(a) } +func (a *User) Sessions(ctx context.Context, obj *user.User) ([]graphql2.UserSession, error) { + sess, err := a.AuthHandler.FindAllUserSessions(ctx, obj.ID) + if err != nil { + return nil, err + } -func (a *User) Sessions(ctx context.Context, obj *user.User) ([]auth.UserSession, error) { - return a.AuthHandler.FindAllUserSessions(ctx, obj.ID) -} + out := make([]graphql2.UserSession, len(sess)) + for i, s := range sess { -func (a *UserSession) Current(ctx context.Context, obj *auth.UserSession) (bool, error) { + out[i] = graphql2.UserSession{ + ID: s.ID, + UserAgent: s.UserAgent, + CreatedAt: s.CreatedAt, + LastAccessAt: s.LastAccessAt, + Current: isCurrentSession(ctx, s.ID), + } + } + + return out, nil +} +func isCurrentSession(ctx context.Context, sessID string) bool { src := permission.Source(ctx) if src == nil { - return false, nil + return false } if src.Type != permission.SourceTypeAuthProvider { - return false, nil + return false } - return obj.ID == src.ID, nil + return src.ID == sessID } func (a *User) AuthSubjects(ctx context.Context, obj *user.User) ([]user.AuthSubject, error) { diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 7af777028d..876169d599 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -139,6 +139,13 @@ type CreateEscalationPolicyStepInput struct { NewSchedule *CreateScheduleInput `json:"newSchedule,omitempty"` } +type CreateGQLAPIKeyInput struct { + Name string `json:"name"` + Description string `json:"description"` + AllowedFields []string `json:"allowedFields"` + ExpiresAt time.Time `json:"expiresAt"` +} + type CreateHeartbeatMonitorInput struct { ServiceID *string `json:"serviceID,omitempty"` Name string `json:"name"` @@ -220,6 +227,11 @@ type CreateUserOverrideInput struct { RemoveUserID *string `json:"removeUserID,omitempty"` } +type CreatedGQLAPIKey struct { + ID string `json:"id"` + Token string `json:"token"` +} + type DebugCarrierInfoInput struct { Number string `json:"number"` } @@ -686,6 +698,14 @@ type UserSearchOptions struct { FavoritesFirst *bool `json:"favoritesFirst,omitempty"` } +type UserSession struct { + ID string `json:"id"` + Current bool `json:"current"` + UserAgent string `json:"userAgent"` + CreatedAt time.Time `json:"createdAt"` + LastAccessAt time.Time `json:"lastAccessAt"` +} + type VerifyContactMethodInput struct { ContactMethodID string `json:"contactMethodID"` Code int `json:"code"` diff --git a/graphql2/schema.go b/graphql2/schema.go new file mode 100644 index 0000000000..823e7ddef4 --- /dev/null +++ b/graphql2/schema.go @@ -0,0 +1,67 @@ +package graphql2 + +import ( + _ "embed" + "sort" + + "github.com/target/goalert/validation" + "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/parser" + "github.com/vektah/gqlparser/v2/validator" +) + +//go:embed schema.graphql +var schema string + +var schemaFields []string +var astSchema *ast.Schema + +// Schema will return the GraphQL schema. +func Schema() string { + return schema +} + +func init() { + schDoc, err := parser.ParseSchema(&ast.Source{Input: schema}) + if err != nil { + panic(err) + } + + sch, err := gqlparser.LoadSchema(&ast.Source{Input: schema}) + if err != nil { + panic(err) + } + astSchema = sch + + for _, typ := range schDoc.Definitions { + if typ.Kind != ast.Object { + continue + } + for _, f := range typ.Fields { + schemaFields = append(schemaFields, typ.Name+"."+f.Name) + } + } + sort.Strings(schemaFields) +} + +// SchemaFields will return a list of all fields in the schema. +func SchemaFields() []string { return schemaFields } + +// QueryFields will return a list of all fields that the given query references. +func QueryFields(query string) ([]string, error) { + qDoc, qErr := gqlparser.LoadQuery(astSchema, query) + if len(qErr) > 0 { + return nil, validation.NewFieldError("Query", qErr.Error()) + } + + var fields []string + var e validator.Events + e.OnField(func(w *validator.Walker, field *ast.Field) { + fields = append(fields, field.ObjectDefinition.Name+"."+field.Name) + }) + validator.Walk(astSchema, qDoc, &e) + + sort.Strings(fields) + return fields, nil +} diff --git a/permission/source.go b/permission/source.go index ef01fa2839..221c1b20f7 100644 --- a/permission/source.go +++ b/permission/source.go @@ -26,6 +26,9 @@ const ( // SourceTypeCalendarSubscription is set when a context is authorized for use of a calendar subscription. SourceTypeCalendarSubscription + + // SourceTypeGQLAPIKey is set when a context is authorized for use of the GraphQL API. + SourceTypeGQLAPIKey ) // SourceInfo provides information about the source of a context's authorization. diff --git a/sqlc.yaml b/sqlc.yaml index d03b95d9ba..f2de6cfd04 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -24,6 +24,7 @@ sql: - engine/statusmgr/queries.sql - auth/authlink/queries.sql - alert/alertlog/queries.sql + - apikey/queries.sql engine: postgresql gen: go: From 579ddeac935c788160fc9e11960aca543af9dd2f Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:22:21 -0500 Subject: [PATCH 04/15] enforce api key fields --- graphql2/graphqlapp/app.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index dc9f5243a7..c714f2a090 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net/http" + "slices" "strconv" "time" @@ -16,6 +17,7 @@ import ( "github.com/target/goalert/alert" "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" + "github.com/target/goalert/apikey" "github.com/target/goalert/auth" "github.com/target/goalert/auth/authlink" "github.com/target/goalert/auth/basic" @@ -77,6 +79,7 @@ type App struct { SlackStore *slack.ChannelSender HeartbeatStore *heartbeat.Store NoticeStore *notice.Store + APIKeyStore *apikey.Store AuthLinkStore *authlink.Store @@ -153,6 +156,30 @@ func (a *App) Handler() http.Handler { return ok && enabled }}) + h.AroundFields(func(ctx context.Context, next graphql.Resolver) (res interface{}, err error) { + src := permission.Source(ctx) + if src.Type != permission.SourceTypeGQLAPIKey { + return next(ctx) + } + + p := apikey.PolicyFromContext(ctx) + if p == nil || p.Version != 1 { + return nil, permission.NewAccessDenied("invalid API key") + } + + f := graphql.GetFieldContext(ctx) + objName := f.Field.Field.ObjectDefinition.Name + fieldName := f.Field.Field.Definition.Name + + field := objName + "." + fieldName + + if slices.Contains(p.AllowedFields, field) { + return next(ctx) + } + + return nil, permission.NewAccessDenied("field not allowed by API key") + }) + h.AroundFields(func(ctx context.Context, next graphql.Resolver) (res interface{}, err error) { defer func() { err := recover() From 27ff8d63f48571aa8a4c2ae171ccdfcb15d1a525 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:23:20 -0500 Subject: [PATCH 05/15] add new key type to auth --- app/app.go | 2 ++ app/initauth.go | 1 + app/initgraphql.go | 1 + app/initstores.go | 8 ++++++++ auth/handler.go | 13 +++++++++++-- auth/handlerconfig.go | 2 ++ 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/app.go b/app/app.go index 46754bdfe3..a0a2f4d823 100644 --- a/app/app.go +++ b/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/target/goalert/alert" "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" + "github.com/target/goalert/apikey" "github.com/target/goalert/app/lifecycle" "github.com/target/goalert/auth" "github.com/target/goalert/auth/authlink" @@ -120,6 +121,7 @@ type App struct { TimeZoneStore *timezone.Store NoticeStore *notice.Store AuthLinkStore *authlink.Store + APIKeyStore *apikey.Store } // NewApp constructs a new App and binds the listening socket. diff --git a/app/initauth.go b/app/initauth.go index cfe9d63062..1f98473fad 100644 --- a/app/initauth.go +++ b/app/initauth.go @@ -19,6 +19,7 @@ func (app *App) initAuth(ctx context.Context) error { IntKeyStore: app.IntegrationKeyStore, CalSubStore: app.CalSubStore, APIKeyring: app.APIKeyring, + APIKeyStore: app.APIKeyStore, }) if err != nil { return errors.Wrap(err, "init auth handler") diff --git a/app/initgraphql.go b/app/initgraphql.go index 3fdf01740f..861eccd89d 100644 --- a/app/initgraphql.go +++ b/app/initgraphql.go @@ -41,6 +41,7 @@ func (app *App) initGraphQL(ctx context.Context) error { NotificationManager: app.notificationManager, AuthLinkStore: app.AuthLinkStore, SWO: app.cfg.SWO, + APIKeyStore: app.APIKeyStore, } return nil diff --git a/app/initstores.go b/app/initstores.go index bcd4277006..87386e5959 100644 --- a/app/initstores.go +++ b/app/initstores.go @@ -7,6 +7,7 @@ import ( "github.com/target/goalert/alert" "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" + "github.com/target/goalert/apikey" "github.com/target/goalert/auth/authlink" "github.com/target/goalert/auth/basic" "github.com/target/goalert/auth/nonce" @@ -297,5 +298,12 @@ func (app *App) initStores(ctx context.Context) error { return errors.Wrap(err, "init notice store") } + if app.APIKeyStore == nil { + app.APIKeyStore, err = apikey.NewStore(ctx, app.db, app.APIKeyring) + } + if err != nil { + return errors.Wrap(err, "init API key store") + } + return nil } diff --git a/auth/handler.go b/auth/handler.go index ac863eb0f3..7cf08c1a31 100644 --- a/auth/handler.go +++ b/auth/handler.go @@ -554,6 +554,17 @@ func (h *Handler) authWithToken(w http.ResponseWriter, req *http.Request, next h return false } + ctx := req.Context() + if req.URL.Path == "/api/graphql" && strings.HasPrefix(tokStr, "ey") { + ctx, err = h.cfg.APIKeyStore.AuthorizeGraphQL(ctx, tokStr, req.UserAgent(), req.RemoteAddr) + if errutil.HTTPError(req.Context(), w, err) { + return true + } + + next.ServeHTTP(w, req.WithContext(ctx)) + return true + } + tok, _, err := authtoken.Parse(tokStr, func(t authtoken.Type, p, sig []byte) (bool, bool) { if t == authtoken.TypeSession { return h.cfg.SessionKeyring.Verify(p, sig) @@ -565,8 +576,6 @@ func (h *Handler) authWithToken(w http.ResponseWriter, req *http.Request, next h return true } - // TODO: update once scopes are implemented - ctx := req.Context() switch req.URL.Path { case "/v1/api/alerts", "/api/v2/generic/incoming": ctx, err = h.cfg.IntKeyStore.Authorize(ctx, *tok, integrationkey.TypeGeneric) diff --git a/auth/handlerconfig.go b/auth/handlerconfig.go index 7526e113ad..ceb502df12 100644 --- a/auth/handlerconfig.go +++ b/auth/handlerconfig.go @@ -1,6 +1,7 @@ package auth import ( + "github.com/target/goalert/apikey" "github.com/target/goalert/calsub" "github.com/target/goalert/integrationkey" "github.com/target/goalert/keyring" @@ -14,4 +15,5 @@ type HandlerConfig struct { APIKeyring keyring.Keyring IntKeyStore *integrationkey.Store CalSubStore *calsub.Store + APIKeyStore *apikey.Store } From 5b1b860e27f67ae68cf6e46f5fdd357a8d1792d6 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:24:54 -0500 Subject: [PATCH 06/15] add usage query --- apikey/queries.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apikey/queries.sql b/apikey/queries.sql index 860257217c..e65e3c7a6d 100644 --- a/apikey/queries.sql +++ b/apikey/queries.sql @@ -6,6 +6,14 @@ INSERT INTO gql_api_keys(id, name, description, POLICY, created_by, updated_by, DELETE FROM gql_api_keys WHERE id = $1; +-- name: APIKeyRecordUsage :exec +-- APIKeyRecordUsage records the usage of an API key. +INSERT INTO gql_api_key_usage(api_key_id, user_agent, ip_address) + VALUES (@key_id::uuid, @user_agent::text, @ip_address::inet) +ON CONFLICT (api_key_id) + DO UPDATE SET + used_at = now(), user_agent = @user_agent::text, ip_address = @ip_address::inet; + -- name: APIKeyAuthPolicy :one -- APIKeyAuth returns the API key policy with the given id, if it exists and is not expired. SELECT From 9a47e872502bddb708c8ce05b982d71a8d1a168b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:25:11 -0500 Subject: [PATCH 07/15] regen --- gadb/queries.sql.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/gadb/queries.sql.go b/gadb/queries.sql.go index ac558fa19b..c1047b2a52 100644 --- a/gadb/queries.sql.go +++ b/gadb/queries.sql.go @@ -91,6 +91,26 @@ func (q *Queries) APIKeyInsert(ctx context.Context, arg APIKeyInsertParams) erro return err } +const aPIKeyRecordUsage = `-- name: APIKeyRecordUsage :exec +INSERT INTO gql_api_key_usage(api_key_id, user_agent, ip_address) + VALUES ($1::uuid, $2::text, $3::inet) +ON CONFLICT (api_key_id) + DO UPDATE SET + used_at = now(), user_agent = $2::text, ip_address = $3::inet +` + +type APIKeyRecordUsageParams struct { + KeyID uuid.UUID + UserAgent string + IpAddress pqtype.Inet +} + +// APIKeyRecordUsage records the usage of an API key. +func (q *Queries) APIKeyRecordUsage(ctx context.Context, arg APIKeyRecordUsageParams) error { + _, err := q.db.ExecContext(ctx, aPIKeyRecordUsage, arg.KeyID, arg.UserAgent, arg.IpAddress) + return err +} + const alertFeedback = `-- name: AlertFeedback :many SELECT alert_id, From bc59704a711922869d14a13501d1b849b1204ca5 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:25:17 -0500 Subject: [PATCH 08/15] regen --- web/src/schema.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index aa212234cb..053488bf38 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -422,10 +422,24 @@ export interface Mutation { updateAlertsByService: boolean setConfig: boolean setSystemLimits: boolean + createGQLAPIKey: CreatedGQLAPIKey + deleteGQLAPIKey: boolean createBasicAuth: boolean updateBasicAuth: boolean } +export interface CreatedGQLAPIKey { + id: string + token: string +} + +export interface CreateGQLAPIKeyInput { + name: string + description: string + allowedFields: string[] + expiresAt: ISOTimestamp +} + export interface CreateBasicAuthInput { username: string password: string From 1b8c6dd314c288efae0985adec06937e9e205cad Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:31:31 -0500 Subject: [PATCH 09/15] cleanup field validation --- apikey/store.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apikey/store.go b/apikey/store.go index 4182ff2667..3c0df4fc70 100644 --- a/apikey/store.go +++ b/apikey/store.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/json" "fmt" + "slices" "sort" "time" @@ -126,12 +127,16 @@ func (s *Store) CreateAdminGraphQLKey(ctx context.Context, opt NewAdminGQLKeyOpt validate.Text("Description", opt.Desc, 0, 255), validate.Range("Fields", len(opt.Fields), 1, len(graphql2.SchemaFields())), ) - for i, f := range opt.Fields { - err = validate.Many(err, validate.OneOf(fmt.Sprintf("Fields[%d]", i), f, graphql2.SchemaFields()...)) - } if time.Until(opt.Expires) <= 0 { err = validate.Many(err, validation.NewFieldError("Expires", "must be in the future")) } + for i, f := range opt.Fields { + if slices.Contains(graphql2.SchemaFields(), f) { + continue + } + + err = validate.Many(err, validation.NewFieldError(fmt.Sprintf("Fields[%d]", i), "is not a valid field")) + } if err != nil { return uuid.Nil, "", err } From a4a8c49cfcfc734f96089c75071927c5c95c1071 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:34:21 -0500 Subject: [PATCH 10/15] set role --- apikey/policy.go | 3 +++ apikey/store.go | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apikey/policy.go b/apikey/policy.go index 094284bc5a..f916bb6383 100644 --- a/apikey/policy.go +++ b/apikey/policy.go @@ -1,7 +1,10 @@ package apikey +import "github.com/target/goalert/permission" + // GQLPolicy is a GraphQL API key policy. type GQLPolicy struct { Version int AllowedFields []string + Role permission.Role } diff --git a/apikey/store.go b/apikey/store.go index 3c0df4fc70..65f4bbd09a 100644 --- a/apikey/store.go +++ b/apikey/store.go @@ -101,7 +101,7 @@ func (s *Store) AuthorizeGraphQL(ctx context.Context, tok, ua, ip string) (conte ID: id.String(), Type: permission.SourceTypeGQLAPIKey, }) - ctx = permission.UserContext(ctx, "", permission.RoleUnknown) + ctx = permission.UserContext(ctx, "", info.Policy.Role) ctx = ContextWithPolicy(ctx, &info.Policy) return ctx, nil @@ -113,6 +113,7 @@ type NewAdminGQLKeyOpts struct { Desc string Fields []string Expires time.Time + Role permission.Role } // CreateAdminGraphQLKey will create a new GraphQL API key returning the ID and token. @@ -126,6 +127,7 @@ func (s *Store) CreateAdminGraphQLKey(ctx context.Context, opt NewAdminGQLKeyOpt validate.IDName("Name", opt.Name), validate.Text("Description", opt.Desc, 0, 255), validate.Range("Fields", len(opt.Fields), 1, len(graphql2.SchemaFields())), + validate.OneOf("Role", opt.Role, permission.RoleAdmin, permission.RoleUser), ) if time.Until(opt.Expires) <= 0 { err = validate.Many(err, validation.NewFieldError("Expires", "must be in the future")) @@ -145,6 +147,7 @@ func (s *Store) CreateAdminGraphQLKey(ctx context.Context, opt NewAdminGQLKeyOpt policyData, err := json.Marshal(GQLPolicy{ Version: 1, AllowedFields: opt.Fields, + Role: opt.Role, }) if err != nil { return uuid.Nil, "", err From d9a5a6723994472ba89156e42126580d4ae45476 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:36:09 -0500 Subject: [PATCH 11/15] add role to API --- graphql2/generated.go | 11 ++++++++++- graphql2/graphqlapp/gqlapikeys.go | 2 ++ graphql2/models_gen.go | 1 + graphql2/schema.graphql | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/graphql2/generated.go b/graphql2/generated.go index 3cef80d824..a70646f692 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -27267,7 +27267,7 @@ func (ec *executionContext) unmarshalInputCreateGQLAPIKeyInput(ctx context.Conte asMap[k] = v } - fieldsInOrder := [...]string{"name", "description", "allowedFields", "expiresAt"} + fieldsInOrder := [...]string{"name", "description", "allowedFields", "expiresAt", "role"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -27310,6 +27310,15 @@ func (ec *executionContext) unmarshalInputCreateGQLAPIKeyInput(ctx context.Conte return it, err } it.ExpiresAt = data + case "role": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) + data, err := ec.unmarshalNUserRole2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐUserRole(ctx, v) + if err != nil { + return it, err + } + it.Role = data } } diff --git a/graphql2/graphqlapp/gqlapikeys.go b/graphql2/graphqlapp/gqlapikeys.go index 582102b3d1..277b33263e 100644 --- a/graphql2/graphqlapp/gqlapikeys.go +++ b/graphql2/graphqlapp/gqlapikeys.go @@ -5,6 +5,7 @@ import ( "github.com/target/goalert/apikey" "github.com/target/goalert/graphql2" + "github.com/target/goalert/permission" ) func (a *Mutation) DeleteGQLAPIKey(ctx context.Context, input string) (bool, error) { @@ -23,6 +24,7 @@ func (a *Mutation) CreateGQLAPIKey(ctx context.Context, input graphql2.CreateGQL Desc: input.Description, Expires: input.ExpiresAt, Fields: input.AllowedFields, + Role: permission.Role(input.Role), }) if err != nil { return nil, err diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 876169d599..acb0d9e78d 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -144,6 +144,7 @@ type CreateGQLAPIKeyInput struct { Description string `json:"description"` AllowedFields []string `json:"allowedFields"` ExpiresAt time.Time `json:"expiresAt"` + Role UserRole `json:"role"` } type CreateHeartbeatMonitorInput struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index d5b100f8b9..5c5cd1a922 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -596,6 +596,7 @@ input CreateGQLAPIKeyInput { description: String! allowedFields: [String!]! expiresAt: ISOTimestamp! + role: UserRole! } input CreateBasicAuthInput { From bc889fd5fe81cf57c9efcafb3086045dc31ec946 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 13:50:24 -0500 Subject: [PATCH 12/15] remove cache for initial PR --- apikey/lastused.go | 27 ++++++++ apikey/lastusedcache.go | 58 ----------------- apikey/polcache.go | 137 ---------------------------------------- apikey/policyinfo.go | 39 ++++++++++++ apikey/store.go | 23 +------ 5 files changed, 68 insertions(+), 216 deletions(-) create mode 100644 apikey/lastused.go delete mode 100644 apikey/lastusedcache.go delete mode 100644 apikey/polcache.go create mode 100644 apikey/policyinfo.go diff --git a/apikey/lastused.go b/apikey/lastused.go new file mode 100644 index 0000000000..b18c6fd43a --- /dev/null +++ b/apikey/lastused.go @@ -0,0 +1,27 @@ +package apikey + +import ( + "context" + "net" + + "github.com/google/uuid" + "github.com/target/goalert/gadb" + "github.com/target/goalert/validation/validate" +) + +// _updateLastUsed will record usage for the given API key ID, user agent, and IP address. +func (s *Store) _updateLastUsed(ctx context.Context, id uuid.UUID, ua, ip string) error { + ua = validate.SanitizeText(ua, 1024) + ip, _, _ = net.SplitHostPort(ip) + ip = validate.SanitizeText(ip, 255) + params := gadb.APIKeyRecordUsageParams{ + KeyID: id, + UserAgent: ua, + } + params.IpAddress.IPNet.IP = net.ParseIP(ip) + params.IpAddress.IPNet.Mask = net.CIDRMask(32, 32) + if params.IpAddress.IPNet.IP != nil { + params.IpAddress.Valid = true + } + return gadb.New(s.db).APIKeyRecordUsage(ctx, params) +} diff --git a/apikey/lastusedcache.go b/apikey/lastusedcache.go deleted file mode 100644 index 8ce311c5b5..0000000000 --- a/apikey/lastusedcache.go +++ /dev/null @@ -1,58 +0,0 @@ -package apikey - -import ( - "context" - "net" - "sync" - "time" - - "github.com/golang/groupcache/lru" - "github.com/google/uuid" - "github.com/target/goalert/gadb" - "github.com/target/goalert/validation/validate" -) - -// lastUsedCache is used to track the last time we recorded usage for a given API key. -type lastUsedCache struct { - lru *lru.Cache - - mx sync.Mutex - updateFunc func(ctx context.Context, id uuid.UUID, ua, ip string) error -} - -// newLastUsedCache will create a new lastUsedCache with the given max size and updateFunc. -func newLastUsedCache(max int, updateFunc func(ctx context.Context, id uuid.UUID, ua, ip string) error) *lastUsedCache { - return &lastUsedCache{ - lru: lru.New(max), - updateFunc: updateFunc, - } -} - -// RecordUsage will record usage for the given API key ID, user agent, and IP address up to once per minute. -func (c *lastUsedCache) RecordUsage(ctx context.Context, id uuid.UUID, ua, ip string) error { - c.mx.Lock() - defer c.mx.Unlock() - if t, ok := c.lru.Get(id); ok && time.Since(t.(time.Time)) < time.Minute { - return nil - } - - c.lru.Add(id, time.Now()) - return c.updateFunc(ctx, id, ua, ip) -} - -// _updateLastUsed will record usage for the given API key ID, user agent, and IP address. -func (s *Store) _updateLastUsed(ctx context.Context, id uuid.UUID, ua, ip string) error { - ua = validate.SanitizeText(ua, 1024) - ip, _, _ = net.SplitHostPort(ip) - ip = validate.SanitizeText(ip, 255) - params := gadb.APIKeyRecordUsageParams{ - KeyID: id, - UserAgent: ua, - } - params.IpAddress.IPNet.IP = net.ParseIP(ip) - params.IpAddress.IPNet.Mask = net.CIDRMask(32, 32) - if params.IpAddress.IPNet.IP != nil { - params.IpAddress.Valid = true - } - return gadb.New(s.db).APIKeyRecordUsage(ctx, params) -} diff --git a/apikey/polcache.go b/apikey/polcache.go deleted file mode 100644 index f65316d247..0000000000 --- a/apikey/polcache.go +++ /dev/null @@ -1,137 +0,0 @@ -package apikey - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/json" - "errors" - "sync" - - "github.com/golang/groupcache/lru" - "github.com/google/uuid" - "github.com/target/goalert/gadb" -) - -// polCache handles caching of policyInfo objects, as well as negative caching -// of invalid keys. -type polCache struct { - lru *lru.Cache - neg *lru.Cache - mx sync.Mutex - - cfg polCacheConfig -} - -type polCacheConfig struct { - FillFunc func(context.Context, uuid.UUID) (*policyInfo, bool, error) - Verify func(context.Context, uuid.UUID) (bool, error) - MaxSize int -} - -// newPolCache will create a new polCache with the given config. -func newPolCache(cfg polCacheConfig) *polCache { - return &polCache{ - lru: lru.New(cfg.MaxSize), - neg: lru.New(cfg.MaxSize), - cfg: cfg, - } -} - -// Revoke will add the key to the negative cache. -func (c *polCache) Revoke(ctx context.Context, key uuid.UUID) error { - c.mx.Lock() - defer c.mx.Unlock() - - c.neg.Add(key, nil) - c.lru.Remove(key) - - return nil -} - -// Get will return the policyInfo for the given key. -// -// If the key is in the cache, it will be verified before returning. -// -// If it is not in the cache, it will be fetched and added to the cache. -// -// If either the key is invalid or the policy is invalid, the key will be -// added to the negative cache. -func (c *polCache) Get(ctx context.Context, key uuid.UUID) (value *policyInfo, ok bool, err error) { - c.mx.Lock() - defer c.mx.Unlock() - - if _, ok := c.neg.Get(key); ok { - return value, false, nil - } - - if v, ok := c.lru.Get(key); ok { - // Check if the key is still valid before returning it, - // if it is not valid, we can remove it from the cache. - isValid, err := c.cfg.Verify(ctx, key) - if err != nil { - return value, false, err - } - - // Since each key has a unique ID and is signed, we can - // safely assume that an invalid key will always be invalid - // and can be cached. - if !isValid { - c.neg.Add(key, nil) - c.lru.Remove(key) - return value, false, nil - } - - return v.(*policyInfo), true, nil - } - - // If the key is not in the cache, we need to fetch it, - // and add it to the cache. We can safely assume that - // the key is valid, when returned from the FillFunc. - value, isValid, err := c.cfg.FillFunc(ctx, key) - if err != nil { - return value, false, err - } - if !isValid { - c.neg.Add(key, nil) - return value, false, nil - } - - c.lru.Add(key, value) - return value, true, nil -} - -// _verifyPolicyID will verify that the given key is valid. -func (s *Store) _verifyPolicyID(ctx context.Context, id uuid.UUID) (bool, error) { - valid, err := gadb.New(s.db).APIKeyAuthCheck(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - if err != nil { - return false, err - } - - return valid, nil -} - -// _fetchPolicyInfo will fetch the policyInfo for the given key. -func (s *Store) _fetchPolicyInfo(ctx context.Context, id uuid.UUID) (*policyInfo, bool, error) { - polData, err := gadb.New(s.db).APIKeyAuthPolicy(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - - var info policyInfo - err = json.Unmarshal(polData, &info.Policy) - if err != nil { - return nil, false, err - } - - h := sha256.Sum256(polData) - info.Hash = h[:] - - return &info, true, nil -} diff --git a/apikey/policyinfo.go b/apikey/policyinfo.go new file mode 100644 index 0000000000..a47ec1026a --- /dev/null +++ b/apikey/policyinfo.go @@ -0,0 +1,39 @@ +package apikey + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + + "github.com/google/uuid" + "github.com/target/goalert/gadb" +) + +type policyInfo struct { + Hash []byte + Policy GQLPolicy +} + +// _fetchPolicyInfo will fetch the policyInfo for the given key. +func (s *Store) _fetchPolicyInfo(ctx context.Context, id uuid.UUID) (*policyInfo, bool, error) { + polData, err := gadb.New(s.db).APIKeyAuthPolicy(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + + var info policyInfo + err = json.Unmarshal(polData, &info.Policy) + if err != nil { + return nil, false, err + } + + h := sha256.Sum256(polData) + info.Hash = h[:] + + return &info, true, nil +} diff --git a/apikey/store.go b/apikey/store.go index 65f4bbd09a..6816310b28 100644 --- a/apikey/store.go +++ b/apikey/store.go @@ -25,14 +25,6 @@ import ( type Store struct { db *sql.DB key keyring.Keyring - - polCache *polCache - lastUsedCache *lastUsedCache -} - -type policyInfo struct { - Hash []byte - Policy GQLPolicy } // NewStore will create a new Store. @@ -42,14 +34,6 @@ func NewStore(ctx context.Context, db *sql.DB, key keyring.Keyring) (*Store, err key: key, } - s.polCache = newPolCache(polCacheConfig{ - FillFunc: s._fetchPolicyInfo, - Verify: s._verifyPolicyID, - MaxSize: 1000, - }) - - s.lastUsedCache = newLastUsedCache(1000, s._updateLastUsed) - return s, nil } @@ -74,7 +58,7 @@ func (s *Store) AuthorizeGraphQL(ctx context.Context, tok, ua, ip string) (conte return ctx, permission.Unauthorized() } - info, valid, err := s.polCache.Get(ctx, id) + info, valid, err := s._fetchPolicyInfo(ctx, id) if err != nil { return nil, err } @@ -83,15 +67,12 @@ func (s *Store) AuthorizeGraphQL(ctx context.Context, tok, ua, ip string) (conte return ctx, permission.Unauthorized() } if !bytes.Equal(info.Hash, claims.PolicyHash) { - // Successful cache lookup, but the policy has changed since the token was issued and so the token is no longer valid. - s.polCache.Revoke(ctx, id) - // We want to log this as a warning, because it is a potential security issue. log.Log(ctx, fmt.Errorf("apikey: policy hash mismatch for key %s", id)) return ctx, permission.Unauthorized() } - err = s.lastUsedCache.RecordUsage(ctx, id, ua, ip) + err = s._updateLastUsed(ctx, id, ua, ip) if err != nil { // Recording usage is not critical, so we log the error and continue. log.Log(ctx, err) From 795beb1afa91fa3371bc70fbce8057ec5425b42f Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 12 Sep 2023 16:34:40 -0500 Subject: [PATCH 13/15] add experimental flag --- auth/handler.go | 3 ++- expflag/flags.go | 6 ++++-- graphql2/graphqlapp/gqlapikeys.go | 9 +++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/auth/handler.go b/auth/handler.go index 7cf08c1a31..8a61f96561 100644 --- a/auth/handler.go +++ b/auth/handler.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" "github.com/target/goalert/auth/authtoken" "github.com/target/goalert/config" + "github.com/target/goalert/expflag" "github.com/target/goalert/integrationkey" "github.com/target/goalert/permission" "github.com/target/goalert/user" @@ -555,7 +556,7 @@ func (h *Handler) authWithToken(w http.ResponseWriter, req *http.Request, next h } ctx := req.Context() - if req.URL.Path == "/api/graphql" && strings.HasPrefix(tokStr, "ey") { + if expflag.ContextHas(ctx, expflag.GQLAPIKey) && req.URL.Path == "/api/graphql" && strings.HasPrefix(tokStr, "ey") { ctx, err = h.cfg.APIKeyStore.AuthorizeGraphQL(ctx, tokStr, req.UserAgent(), req.RemoteAddr) if errutil.HTTPError(req.Context(), w, err) { return true diff --git a/expflag/flags.go b/expflag/flags.go index 87ce976edd..b36b84721d 100644 --- a/expflag/flags.go +++ b/expflag/flags.go @@ -5,11 +5,13 @@ import "sort" type Flag string const ( - Example Flag = "example" + Example Flag = "example" + GQLAPIKey Flag = "gql-api-keys" ) var desc = map[Flag]string{ - Example: "An example experimental flag to demonstrate usage.", + Example: "An example experimental flag to demonstrate usage.", + GQLAPIKey: "Admin-only GraphQL API key support.", } // AllFlags returns a slice of all experimental flags sorted by name. diff --git a/graphql2/graphqlapp/gqlapikeys.go b/graphql2/graphqlapp/gqlapikeys.go index 277b33263e..b7014a5fd7 100644 --- a/graphql2/graphqlapp/gqlapikeys.go +++ b/graphql2/graphqlapp/gqlapikeys.go @@ -4,11 +4,16 @@ import ( "context" "github.com/target/goalert/apikey" + "github.com/target/goalert/expflag" "github.com/target/goalert/graphql2" "github.com/target/goalert/permission" + "github.com/target/goalert/validation" ) func (a *Mutation) DeleteGQLAPIKey(ctx context.Context, input string) (bool, error) { + if !expflag.ContextHas(ctx, expflag.GQLAPIKey) { + return false, validation.NewGenericError("experimental flag not enabled") + } id, err := parseUUID("ID", input) if err != nil { return false, err @@ -19,6 +24,10 @@ func (a *Mutation) DeleteGQLAPIKey(ctx context.Context, input string) (bool, err } func (a *Mutation) CreateGQLAPIKey(ctx context.Context, input graphql2.CreateGQLAPIKeyInput) (*graphql2.CreatedGQLAPIKey, error) { + if !expflag.ContextHas(ctx, expflag.GQLAPIKey) { + return nil, validation.NewGenericError("experimental flag not enabled") + } + id, tok, err := a.APIKeyStore.CreateAdminGraphQLKey(ctx, apikey.NewAdminGQLKeyOpts{ Name: input.Name, Desc: input.Description, From dedca3e665e0c599fa34f74c622277180632b7db Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 13 Sep 2023 17:04:18 -0500 Subject: [PATCH 14/15] move migration --- ...aphql-api-key.sql => 20230913170424-graphql-api-key.sql} | 0 migrate/schema.sql | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename migrate/migrations/{20230907112347-graphql-api-key.sql => 20230913170424-graphql-api-key.sql} (100%) diff --git a/migrate/migrations/20230907112347-graphql-api-key.sql b/migrate/migrations/20230913170424-graphql-api-key.sql similarity index 100% rename from migrate/migrations/20230907112347-graphql-api-key.sql rename to migrate/migrations/20230913170424-graphql-api-key.sql diff --git a/migrate/schema.sql b/migrate/schema.sql index ada581bccf..e6bf4a3e72 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,7 +1,7 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=d5fcb3771bbfc1ccb244be27c0cb496ab74b6654d0948e3946d3bd4f6852069e - --- DISK=05f86ed0fdc6cf25e0162624d600d9a8efd491d4dd4eb5a1411ba1f9704f22d8 - --- PSQL=05f86ed0fdc6cf25e0162624d600d9a8efd491d4dd4eb5a1411ba1f9704f22d8 - +-- DATA=0b19243cada19342718f4e83f6864ffa4e8418b86add493e330125c76d43e1c3 - +-- DISK=1d5a0de0c0813ed2efc12f5468a2ecdf7778d0cbde2213990eb4576708500fe6 - +-- PSQL=1d5a0de0c0813ed2efc12f5468a2ecdf7778d0cbde2213990eb4576708500fe6 - -- -- pgdump-lite database dump -- From 9a81db4860b80282316879c4443baf75b22e91d4 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 13 Sep 2023 17:05:41 -0500 Subject: [PATCH 15/15] regen --- permission/sourcetype_string.go | 5 +++-- web/src/expflag.d.ts | 2 +- web/src/schema.d.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/permission/sourcetype_string.go b/permission/sourcetype_string.go index 3e242c4890..c9f21edf2b 100644 --- a/permission/sourcetype_string.go +++ b/permission/sourcetype_string.go @@ -15,11 +15,12 @@ func _() { _ = x[SourceTypeHeartbeat-4] _ = x[SourceTypeNotificationChannel-5] _ = x[SourceTypeCalendarSubscription-6] + _ = x[SourceTypeGQLAPIKey-7] } -const _SourceType_name = "SourceTypeNotificationCallbackSourceTypeIntegrationKeySourceTypeAuthProviderSourceTypeContactMethodSourceTypeHeartbeatSourceTypeNotificationChannelSourceTypeCalendarSubscription" +const _SourceType_name = "SourceTypeNotificationCallbackSourceTypeIntegrationKeySourceTypeAuthProviderSourceTypeContactMethodSourceTypeHeartbeatSourceTypeNotificationChannelSourceTypeCalendarSubscriptionSourceTypeGQLAPIKey" -var _SourceType_index = [...]uint8{0, 30, 54, 76, 99, 118, 147, 177} +var _SourceType_index = [...]uint8{0, 30, 54, 76, 99, 118, 147, 177, 196} func (i SourceType) String() string { if i < 0 || i >= SourceType(len(_SourceType_index)-1) { diff --git a/web/src/expflag.d.ts b/web/src/expflag.d.ts index bb6aff9b77..c8c02252f1 100644 --- a/web/src/expflag.d.ts +++ b/web/src/expflag.d.ts @@ -1,3 +1,3 @@ // Code generated by expflag/cmd/tsgen DO NOT EDIT. -type ExpFlag = 'example' +type ExpFlag = 'example' | 'gql-api-keys' diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 433e164e23..0706ea9455 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -439,6 +439,7 @@ export interface CreateGQLAPIKeyInput { description: string allowedFields: string[] expiresAt: ISOTimestamp + role: UserRole } export interface CreateBasicAuthInput {