Skip to content

Commit

Permalink
add permissions middleware handler
Browse files Browse the repository at this point in the history
Produces an echo middleware which adds a checker function into the
request context that is later used to check if the current actor has
access to the requesting resource and action.

Signed-off-by: Mike Mason <[email protected]>
  • Loading branch information
mikemrm committed Jun 22, 2023
1 parent b4c007d commit 817fcc7
Show file tree
Hide file tree
Showing 6 changed files with 531 additions and 0 deletions.
19 changes: 19 additions & 0 deletions pkg/permissions/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package permissions

import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.infratographer.com/x/viperx"
)

// Config defines the permissions configuration structure
type Config struct {
// URL is the URL checks should be executed against
URL string
}

// MustViperFlags adds permissions config flags and viper bindings
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.String("permissions-url", "", "sets the permissions url checks should be run against")
viperx.MustBindFlag(v, "permissions.url", flags.Lookup("permissions-url"))
}
4 changes: 4 additions & 0 deletions pkg/permissions/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package permissions implements an echo middleware to simplify checking permission
// checks in downstream handlers by adding a checking function to the context which
// may later be called to check permissions.
package permissions
20 changes: 20 additions & 0 deletions pkg/permissions/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package permissions

import "errors"

var (
// ErrNoAuthToken is the error returned when there is no auth token provided for the API request
ErrNoAuthToken = errors.New("no auth token provided for client")

// ErrInvalidAuthToken is the error returned when the auth token is not the expected value
ErrInvalidAuthToken = errors.New("invalid auth token")

// ErrPermissionDenied is the error returned when permission is denied to a call
ErrPermissionDenied = errors.New("subject doesn't have access")

// ErrBadResponse is the error returned when we receive a bad response from the server
ErrBadResponse = errors.New("bad response from server")

// ErrCheckerNotFound is the error returned when CheckAccess does not find the appropriate checker context
ErrCheckerNotFound = errors.New("no checker found in context")
)
47 changes: 47 additions & 0 deletions pkg/permissions/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package permissions

import (
"net/http"

"github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
)

// Option defines an option configurator
type Option func(p *Permissions) error

// WithLogger sets the logger for the auth handler
func WithLogger(logger *zap.SugaredLogger) Option {
return func(p *Permissions) error {
p.logger = logger

return nil
}
}

// WithHTTPClient sets the underlying http client the auth handler uses
func WithHTTPClient(client *http.Client) Option {
return func(p *Permissions) error {
p.client = client

return nil
}
}

// WithSkipper sets the echo middleware skipper function
func WithSkipper(skipper middleware.Skipper) Option {
return func(p *Permissions) error {
p.skipper = skipper

return nil
}
}

// WithDefaultChecker sets the default checker if the middleware is skipped
func WithDefaultChecker(checker Checker) Option {
return func(p *Permissions) error {
p.defaultChecker = checker

return nil
}
}
208 changes: 208 additions & 0 deletions pkg/permissions/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package permissions

import (
"context"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/pkg/errors"
"go.infratographer.com/x/echojwtx"
"go.infratographer.com/x/gidx"
"go.uber.org/zap"
)

const (
bearerPrefix = "Bearer "
)

var (
ctxKeyChecker = checkerCtxKey{}

// DefaultAllowChecker defaults to allow when checker is disabled or skipped
DefaultAllowChecker Checker = func(_ context.Context, _ gidx.PrefixedID, _ string) error {
return nil
}

// DefaultDenyChecker defaults to denied when checker is disabled or skipped
DefaultDenyChecker Checker = func(_ context.Context, _ gidx.PrefixedID, _ string) error {
return ErrPermissionDenied
}

defaultClient = &http.Client{
Timeout: 5 * time.Second,
}
)

// Checker defines the checker function definition
type Checker func(ctx context.Context, resource gidx.PrefixedID, action string) error

type checkerCtxKey struct{}

// Permissions handles supporting authorization checks
type Permissions struct {
enabled bool
logger *zap.SugaredLogger
client *http.Client
url *url.URL
skipper middleware.Skipper
defaultChecker Checker
}

// Middleware produces echo middleware to handle authorization checks
func (p *Permissions) Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !p.enabled || p.skipper(c) {
setCheckerContext(c, p.defaultChecker)

return next(c)
}

actor := echojwtx.Actor(c)
if actor == "" {
return echo.ErrUnauthorized.WithInternal(ErrNoAuthToken)
}

authHeader := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))

if len(authHeader) <= len(bearerPrefix) {
return echo.ErrUnauthorized.WithInternal(ErrInvalidAuthToken)
}

if !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
return echo.ErrUnauthorized.WithInternal(ErrInvalidAuthToken)
}

token := authHeader[len(bearerPrefix):]

setCheckerContext(c, p.checker(c, actor, token))

return next(c)
}
}
}

func (p *Permissions) checker(c echo.Context, actor, token string) Checker {
return func(ctx context.Context, resource gidx.PrefixedID, action string) error {
logger := p.logger.With("actor", actor, "resource", resource.String(), "action", action)

values := url.Values{}
values.Add("resource", resource.String())
values.Add("action", action)

url := *p.url
url.RawQuery = values.Encode()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
if err != nil {
logger.Errorw("failed to create checker request", "error", err)

return errors.WithStack(err)
}

req.Header.Set(echo.HeaderAuthorization, c.Request().Header.Get(echo.HeaderAuthorization))

resp, err := p.client.Do(req)
if err != nil {
err = errors.WithStack(err)

logger.Errorw("failed to make request", "error", err)

return err
}

defer resp.Body.Close()

err = ensureValidServerResponse(resp)
if err != nil {
body, _ := io.ReadAll(resp.Body) //nolint:errcheck // ignore any errors reading as this is just for logging.

switch {
case errors.Is(err, ErrPermissionDenied):
logger.Warnw("unauthorized access to resource")
case errors.Is(err, ErrBadResponse):
logger.Errorw("bad response from server", "error", err, "response.status_code", resp.StatusCode, "response.body", string(body))
}

return err
}

logger.Debug("access granted to resource")

return nil
}
}

// New creates a new Permissions instance
func New(config Config, options ...Option) (*Permissions, error) {
p := &Permissions{
enabled: config.URL != "",
client: defaultClient,
skipper: middleware.DefaultSkipper,
defaultChecker: DefaultDenyChecker,
}

if config.URL != "" {
uri, err := url.Parse(config.URL)
if err != nil {
return nil, err
}

p.url = uri
}

for _, opt := range options {
if err := opt(p); err != nil {
return nil, err
}
}

if p.logger == nil {
p.logger = zap.NewNop().Sugar()
}

return p, nil
}

func setCheckerContext(c echo.Context, checker Checker) {
if checker == nil {
checker = DefaultDenyChecker
}

req := c.Request().WithContext(
context.WithValue(
c.Request().Context(),
ctxKeyChecker,
checker,
),
)

c.SetRequest(req)
}

func ensureValidServerResponse(resp *http.Response) error {
if resp.StatusCode >= http.StatusMultiStatus {
if resp.StatusCode == http.StatusForbidden {
return ErrPermissionDenied
}

return ErrBadResponse
}

return nil
}

// CheckAccess runs the checker function to check if the provided resource and action are supported.
func CheckAccess(ctx context.Context, resource gidx.PrefixedID, action string) error {
checker, ok := ctx.Value(ctxKeyChecker).(Checker)
if !ok {
return ErrCheckerNotFound
}

return checker(ctx, resource, action)
}
Loading

0 comments on commit 817fcc7

Please sign in to comment.