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

Feature/access level #112

Merged
merged 16 commits into from
Jan 27, 2021
Merged
Show file tree
Hide file tree
Changes from 14 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
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)

### Removed
- Removed identifier as a field from the API. [#85](https://github.com/xmidt-org/argus/pull/85)
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...)
joe94 marked this conversation as resolved.
Show resolved Hide resolved
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,
joe94 marked this conversation as resolved.
Show resolved Hide resolved
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",
kristinapathak marked this conversation as resolved.
Show resolved Hide resolved
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