Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

api: add base admin API key support #3274

Merged
merged 19 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apikey/context.go
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 37 additions & 0 deletions apikey/jwt.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
27 changes: 27 additions & 0 deletions apikey/lastused.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions apikey/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +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
}
39 changes: 39 additions & 0 deletions apikey/policyinfo.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions apikey/queries.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- 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: 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
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();

164 changes: 164 additions & 0 deletions apikey/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package apikey

import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"slices"
"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
}

// 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,
}

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._fetchPolicyInfo(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) {
// 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._updateLastUsed(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, "", info.Policy.Role)

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
Role permission.Role
}

// 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())),
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"))
}
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
}

sort.Strings(opt.Fields)
policyData, err := json.Marshal(GQLPolicy{
Version: 1,
AllowedFields: opt.Fields,
Role: opt.Role,
})
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
}
2 changes: 2 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/initauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions app/initgraphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/initstores.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Loading