Skip to content

Commit

Permalink
Feature/access level (#112)
Browse files Browse the repository at this point in the history
* Add initial changes for access level feature

* avoid copying config at each function pass

* update TTL related info in readme

* document max ttl default better

* Make code default for maxItemTTL 1day

* Update auth/bearerTokenFactory.go

Co-authored-by: kristinaspring <kmspring57@gmail.com>

* Improve resolver name

* resolver name fix and breake comment into lines

* update access level func reference

* Update tests

* Add access level values to spruce yaml

* add issue comment

* add release notes

* Make default TTL less aggressive

* fix typo in spruce config file

Co-authored-by: kristinaspring <kmspring57@gmail.com>
  • Loading branch information
joe94 and kristinapathak committed Jan 27, 2021
1 parent bffa41d commit 1dbdab5
Show file tree
Hide file tree
Showing 18 changed files with 515 additions and 139 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- UUID field is now ID. [#88](https://github.com/xmidt-org/argus/pull/88)
- Update buildtime format in Makefile to match RPM spec file. [#95](https://github.com/xmidt-org/argus/pull/95)
- Update configuration structure for inbound authn/z. [#101](https://github.com/xmidt-org/argus/pull/101)
- Admin mode flag now originates from JWT claims instead of an HTTP header. [#112](https://github.com/xmidt-org/argus/pull/112)

### Added
- Item ID is validated to have the format of a SHA256 message hex digest. [#106](https://github.com/xmidt-org/argus/pull/106)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ The body must be in JSON format with the following attributes:
* _id_ - Required. See above.
* _data_ - Required. RAW JSON to be stored. Opaque to argus.
* _owner_ - Optional. Free form string to identify the owner of this object.
* _ttl_ - Optional. Specified in units of seconds. Defaults to 0 if omitted, which means this object will not auto expire.
* _ttl_ - Optional. Specified in units of seconds. Defaults to the value of the server configuration option `itemMaxTTL`. If a configuration value is not specified, the value would be a day (~ 24*60^2 seconds).
)

An optional header `X-Midt-Owner` can be sent to associate the object with an owner. The value of this header will be bound to a new record, which would require the same value passed in a `X-Midt-Owner` header for subsequent reads or modifications. This in effect creates a secret attribute bound to the life of newly created records.

Expand Down
63 changes: 44 additions & 19 deletions argus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,29 +102,15 @@ dynamo:
# secretKey is the AWS secretKey to go with the accessKey to access dynamodb.
secretKey: "secretKey"

# itemTTL configures the default time based ttls for each item.
itemTTL:
# maxTTL is limit the maxTTL provided via the api.
# refer to https://golang.org/pkg/time/#ParseDuration for valid strings.
# (Optional) default: 1y
maxTTL: "1d"

# itemMaxTTL defines the limit for TTL values provided by users of the API.
# refer to https://golang.org/pkg/time/#ParseDuration for valid strings.
# (Optional) default: 24h (a day)
itemMaxTTL: "24h"

##############################################################################
# Authorization Credentials
##############################################################################

# request is a config section related to operation authorization
# and request validation.
request:
authorization:
# adminToken serves as a master key which allows performing operations on any
# item regardless of their ownership status.
adminToken: "Hzu1WpIe7S8G"

validation:
# maxTTL specifies the cap for the TTL of items when values are specified.
maxTTL: "24h"

authx:
inbound:
profiles:
Expand All @@ -144,6 +130,45 @@ authx:
purpose: 0
updateInterval: 24h

# accessLevel defines config around the injection of an attribute to bascule tokens
# which application code can leverage to decide if a given request is allowed to execute some operation.
# Note that accessLevel differs from capabilityCheck in that it allows more complex access hierarchy.
# That is, while capabilityCheck verifies whether a user is allowed to use an API endpoint, accessLevel
# assigns a number to the user's request which application code can use for security purposes.
# An access level is defined as a non-negative number and the higher the number, the higher the access the
# request has for the target application.
# (Optional). If section is not provided, the lowest access level value of 0 will be assigned to the attribute.
accessLevel:
# attributeKey is the key that application code can use to fetch the access level from the provided bascule token.
# (Optional) defaults to 'access-level'
attributeKey: access-level

# capabilitySource provides configuration to the component which generates the access level for an incoming request
# based on its endpoint capabilities. This component assigns only two access levels: 1 for elevated access and 0 otherwise.
# Components that assign more than two values might be added in the future.
# (Optional)
capabilitySource:
# name is the capability we will search for inside the capability list pointed by path.
# If this value is found in the list, the access level assigned to the request will be 1. Otherwise, it will be 0.
# (Optional) defaults to 'xmidt:svc:admin'
name: "xmidt:svc:admin"

# path is the list of nested keys to get to the claim which contains the capabilities.
# For example, if your JWT payload looks like this:
# ```
# {
# "iat": 1234567899,
# "nbf": 1234567899,
# "my_company": {
# "capabilities": ["capability0", "capability1"]
# }
# }
# ```
# you'll want to set path to ["my_company", "capabilities"]
# (Optional) default: ["capabilities"]
path: ["capabilities"]


# # capabilityCheck provides the details needed for checking an incoming JWT's
# # capabilities. If the type of check isn't provided, no checking is done. The
# # type can be "monitor" or "enforce". If "monitor" is provided, the capabilities
Expand Down
96 changes: 96 additions & 0 deletions auth/accessLevel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package auth

import (
"github.com/spf13/cast"
"github.com/xmidt-org/bascule"
)

// Exported access level default values which application code may want to use.
const (
DefaultAccessLevelAttributeKey = "access-level"
DefaultAccessLevelAttributeValue = 0
)

const (
ElevatedAccessLevelAttributeValue = 1
)

// internal default values
const (
defaultAccessLevelCapabilityName = "xmidt:svc:admin"
)

var defaultAccessLevelPath = []string{"capabilities"}

type accessLevel struct {
Resolve accessLevelResolver
AttributeKey string
}

// accessLevelResolver lets users of accessLevelBearerTokenFactory determine what access level value is assigned to a
// request based on its capabilities.
type accessLevelResolver func(bascule.Attributes) int

type accessLevelCapabilitySource struct {
// Name is the capability we will search for inside the capability list pointed by path.
// If this value is found in the list, the access level assigned to the request will be 1. Otherwise, it will be 0.
// (Optional) defaults to 'xmidt:svc:admin'
Name string

// Path is the list of nested keys to get to the claim which contains the capabilities.
// (Optional) default: ["capabilities"]
Path []string
}

type accessLevelConfig struct {
AttributeKey string
CapabilitySource accessLevelCapabilitySource
}

func defaultAccessLevel() accessLevel {
return accessLevel{
AttributeKey: DefaultAccessLevelAttributeKey,
Resolve: func(_ bascule.Attributes) int {
return DefaultAccessLevelAttributeValue
},
}
}

func validateAccessLevelConfig(config *accessLevelConfig) {
if len(config.AttributeKey) < 1 {
config.AttributeKey = DefaultAccessLevelAttributeKey
}

if len(config.CapabilitySource.Name) < 1 {
config.CapabilitySource.Name = defaultAccessLevelCapabilityName
}

if len(config.CapabilitySource.Path) < 1 {
config.CapabilitySource.Path = defaultAccessLevelPath
}
}

func newContainsAttributeAccessLevel(config *accessLevelConfig) accessLevel {
validateAccessLevelConfig(config)

resolve := func(attributes bascule.Attributes) int {
capabilitiesClaim, ok := bascule.GetNestedAttribute(attributes, config.CapabilitySource.Path...)
if !ok {
return DefaultAccessLevelAttributeValue
}
capabilities := cast.ToStringSlice(capabilitiesClaim)

for _, capability := range capabilities {
if capability == config.CapabilitySource.Name {
return ElevatedAccessLevelAttributeValue
}
}

return DefaultAccessLevelAttributeValue
}

return accessLevel{
AttributeKey: config.AttributeKey,
Resolve: resolve,
}
}
93 changes: 93 additions & 0 deletions auth/bearerTokenFactory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package auth

import (
"context"
"errors"
"net/http"

"emperror.dev/emperror"
"github.com/dgrijalva/jwt-go"
"github.com/xmidt-org/bascule"
"github.com/xmidt-org/bascule/basculehttp"
"github.com/xmidt-org/bascule/key"
)

const jwtPrincipalKey = "sub"

// accessLevelBearerTokenFactory extends basculehttp.BearerTokenFactory by letting
// the user of the factory inject an access level attribute to the jwt token.
// Application code should handle case in which the value is not injected (i.e. basic auth tokens).
type accessLevelBearerTokenFactory struct {
DefaultKeyID string
Resolver key.Resolver
Parser bascule.JWTParser
Leeway bascule.Leeway
AccessLevel accessLevel
}

// ParseAndValidate expects the given value to be a JWT with a kid header. The
// kid should be resolvable by the Resolver and the JWT should be Parseable and
// pass any basic validation checks done by the Parser. If everything goes
// well, a Token of type "jwt" is returned.
func (a accessLevelBearerTokenFactory) ParseAndValidate(ctx context.Context, _ *http.Request, _ bascule.Authorization, value string) (bascule.Token, error) {
if len(value) == 0 {
return nil, errors.New("empty value")
}

leewayclaims := bascule.ClaimsWithLeeway{
MapClaims: make(jwt.MapClaims),
Leeway: a.Leeway,
}

jwsToken, err := a.Parser.ParseJWT(value, &leewayclaims, defaultKeyfunc(ctx, a.DefaultKeyID, a.Resolver))
if err != nil {
return nil, emperror.Wrap(err, "failed to parse JWS")
}
if !jwsToken.Valid {
return nil, basculehttp.ErrorInvalidToken
}

claims, ok := jwsToken.Claims.(*bascule.ClaimsWithLeeway)

if !ok {
return nil, emperror.Wrap(basculehttp.ErrorUnexpectedClaims, "failed to parse JWS")
}

claimsMap, err := claims.GetMap()
if err != nil {
return nil, emperror.WrapWith(err, "failed to get map of claims", "claims struct", claims)
}

jwtClaims := bascule.NewAttributes(claimsMap)

principalVal, ok := jwtClaims.Get(jwtPrincipalKey)
if !ok {
return nil, emperror.WrapWith(basculehttp.ErrorInvalidPrincipal, "principal value not found", "principal key", jwtPrincipalKey, "jwtClaims", claimsMap)
}
principal, ok := principalVal.(string)
if !ok {
return nil, emperror.WrapWith(basculehttp.ErrorInvalidPrincipal, "principal value not a string", "principal", principalVal)
}

if a.AccessLevel.Resolve != nil {
claimsMap[a.AccessLevel.AttributeKey] = a.AccessLevel.Resolve(jwtClaims)
jwtClaims = bascule.NewAttributes(claimsMap)
}

return bascule.NewToken("jwt", principal, jwtClaims), nil
}

func defaultKeyfunc(ctx context.Context, defaultKeyID string, keyResolver key.Resolver) jwt.Keyfunc {
return func(token *jwt.Token) (interface{}, error) {
keyID, ok := token.Header["kid"].(string)
if !ok {
keyID = defaultKeyID
}

pair, err := keyResolver.ResolveKey(ctx, keyID)
if err != nil {
return nil, emperror.Wrap(err, "failed to resolve key")
}
return pair.Public(), nil
}
}
16 changes: 14 additions & 2 deletions auth/constructor.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type primaryBearerTokenFactoryIn struct {
DefaultKeyID string `name:"primary_bearer_default_kid"`
Resolver key.Resolver `name:"primary_bearer_key_resolver"`
Leeway bascule.Leeway `name:"primary_bearer_leeway"`
AccessLevel accessLevel `name:"primary_bearer_access_level"`
}

type primaryBasculeMetricListenerIn struct {
Expand All @@ -39,6 +40,7 @@ func providePrimaryBasculeConstructorOptions(apiBase string) fx.Option {
return basculehttp.WithCErrorResponseFunc(in.Listener.OnErrorResponse)
},
},

fx.Annotated{
Group: "primary_bascule_constructor_options",
Target: func(in primaryBearerTokenFactoryIn) basculehttp.COption {
Expand All @@ -47,11 +49,12 @@ func providePrimaryBasculeConstructorOptions(apiBase string) fx.Option {
return nil
}
in.Logger.Log(level.Key(), level.DebugValue(), xlog.MessageKey(), "building bearer token factory option", "server", "primary")
return basculehttp.WithTokenFactory("Bearer", basculehttp.BearerTokenFactory{
DefaultKeyId: in.DefaultKeyID,
return basculehttp.WithTokenFactory("Bearer", accessLevelBearerTokenFactory{
DefaultKeyID: in.DefaultKeyID,
Resolver: in.Resolver,
Parser: bascule.DefaultJWTParser,
Leeway: in.Leeway,
AccessLevel: in.AccessLevel,
})
},
},
Expand Down Expand Up @@ -130,5 +133,14 @@ func providePrimaryTokenFactoryInput() fx.Option {
return in.Profile.Bearer.Leeway
},
},
fx.Annotated{
Name: "primary_bearer_access_level",
Target: func(in primaryProfileIn) accessLevel {
if anyNil(in.Profile, in.Profile.AccessLevel) {
return defaultAccessLevel()
}
return newContainsAttributeAccessLevel(in.Profile.AccessLevel)
},
},
)
}
1 change: 1 addition & 0 deletions auth/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type profile struct {
TargetServers []string
Basic []string
Bearer *jwtValidator
AccessLevel *accessLevelConfig
CapabilityCheck *capabilityValidatorConfig
}

Expand Down
13 changes: 12 additions & 1 deletion auth/provide.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func ProvidePrimaryServerChain(apiBase string) fx.Option {
fx.Provide(
profileFactory{serverName: "primary"}.annotated(),
basculemetrics.MeasuresFactory{ServerName: "primary"}.Annotated(),

accessLevelAttributeKeyAnnotated(),
fx.Annotated{
Name: "primary_alice_listener",
Target: func(in primaryBasculeMetricListenerIn) alice.Constructor {
Expand All @@ -42,6 +42,17 @@ func ProvidePrimaryServerChain(apiBase string) fx.Option {
))
}

// accessLevelAttributeKeyAnnotated shares the access level attribute key which outside packages such as argus/store can
// as parameters.
func accessLevelAttributeKeyAnnotated() fx.Annotated {
return fx.Annotated{
Name: "access_level_attribute_key",
Target: func(in primaryBearerTokenFactoryIn) string {
return in.AccessLevel.AttributeKey
},
}
}

// anyNil returns true if any of the provided objects are nil, false otherwise.
func anyNil(objects ...interface{}) bool {
for _, object := range objects {
Expand Down
Loading

0 comments on commit 1dbdab5

Please sign in to comment.