acl: binding rules evaluation (#15697)

Binder provides an interface for binding claims and ACL roles/policies of Nomad.
This commit is contained in:
Piotr Kazmierczak 2023-01-10 16:08:08 +01:00 committed by GitHub
parent dee653d459
commit be36a1924f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 744 additions and 10 deletions

3
go.mod
View File

@ -72,6 +72,7 @@ require (
github.com/hashicorp/golang-lru v0.5.4
github.com/hashicorp/hcl v1.0.1-vault-3
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40
github.com/hashicorp/logutils v1.0.0
github.com/hashicorp/memberlist v0.5.0
github.com/hashicorp/net-rpc-msgpackrpc v0.0.0-20151116020338-a14192a58a69
@ -231,7 +232,7 @@ require (
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/pointerstructure v1.2.1 // indirect
github.com/mitchellh/pointerstructure v1.2.1
github.com/morikuni/aec v1.0.0 // indirect
github.com/mrunalp/fileutils v0.5.0 // indirect
github.com/muesli/reflow v0.3.0

2
go.sum
View File

@ -768,6 +768,8 @@ github.com/hashicorp/hcl v1.0.1-0.20201016140508-a07e7d50bbee h1:8B4HqvMUtYSjsGk
github.com/hashicorp/hcl v1.0.1-0.20201016140508-a07e7d50bbee/go.mod h1:gwlu9+/P9MmKtYrMsHeFRZPXj2CTPm11TDnMeaRHS7g=
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc h1:32lGaCPq5JPYNgFFTjl/cTIar9UWWxCbimCs5G2hMHg=
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc/go.mod h1:odKNpEeZv3COD+++SQcPyACuKOlM5eBoQlzRyN5utIQ=
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40 h1:ExwaL+hUy1ys2AWDbsbh/lxQS2EVCYxuj0LoyLTdB3Y=
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40/go.mod h1:n2TSygSNwsLJ76m8qFXTSc7beTb+auJxYdqrnoqwZWE=
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=

213
lib/auth/oidc/binder.go Normal file
View File

@ -0,0 +1,213 @@
package oidc
import (
"fmt"
"strings"
"github.com/hashicorp/go-bexpr"
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/hil"
"github.com/hashicorp/hil/ast"
"github.com/hashicorp/nomad/nomad/structs"
)
// Binder is responsible for collecting the ACL roles and policies to be
// assigned to a token generated as a result of "logging in" via an auth method.
//
// It does so by applying the auth method's configured binding rules.
type Binder struct {
store BinderStateStore
}
type Identity struct {
// Claims is the format of this Identity suitable for selection
// with a binding rule.
Claims interface{}
// ClaimMappings is the format of this Identity suitable for interpolation in a
// bind name within a binding rule.
ClaimMappings map[string]string
}
// NewBinder creates a Binder with the given state store.
func NewBinder(store BinderStateStore) *Binder {
return &Binder{store}
}
// BinderStateStore is the subset of state store methods used by the binder.
type BinderStateStore interface {
GetACLBindingRulesByAuthMethod(ws memdb.WatchSet, authMethod string) (memdb.ResultIterator, error)
GetACLRoleByName(ws memdb.WatchSet, roleName string) (*structs.ACLRole, error)
ACLPolicyByName(ws memdb.WatchSet, name string) (*structs.ACLPolicy, error)
}
// Bindings contains the ACL roles and policies to be assigned to the created
// token.
type Bindings struct {
Roles []*structs.ACLTokenRoleLink
Policies []string
}
// None indicates that the resulting bindings would not give the created token
// access to any resources.
func (b *Bindings) None() bool {
if b == nil {
return true
}
return len(b.Policies) == 0 && len(b.Roles) == 0
}
// Bind collects the ACL roles and policies to be assigned to the created token.
func (b *Binder) Bind(authMethod *structs.ACLAuthMethod, identity *Identity) (*Bindings, error) {
var (
bindings Bindings
err error
)
// Load the auth method's binding rules.
rulesIterator, err := b.store.GetACLBindingRulesByAuthMethod(nil, authMethod.Name)
if err != nil {
return nil, err
}
// Find the rules with selectors that match the identity's fields.
matchingRules := []*structs.ACLBindingRule{}
for {
raw := rulesIterator.Next()
if raw == nil {
break
}
rule := raw.(*structs.ACLBindingRule)
if doesSelectorMatch(rule.Selector, identity.Claims) {
matchingRules = append(matchingRules, rule)
}
}
if len(matchingRules) == 0 {
return &bindings, nil
}
// Compute role or policy names by interpolating the identity's claim
// mappings into the rule BindName templates.
for _, rule := range matchingRules {
bindName, valid, err := computeBindName(rule.BindType, rule.BindName, identity.ClaimMappings)
switch {
case err != nil:
return nil, fmt.Errorf("cannot compute %q bind name for bind target: %w", rule.BindType, err)
case !valid:
return nil, fmt.Errorf("computed %q bind name for bind target is invalid: %q", rule.BindType, bindName)
}
switch rule.BindType {
case structs.ACLBindingRuleBindTypeRole:
role, err := b.store.GetACLRoleByName(nil, bindName)
if err != nil {
return nil, err
}
if role != nil {
bindings.Roles = append(bindings.Roles, &structs.ACLTokenRoleLink{
ID: role.ID,
})
}
case structs.ACLBindingRuleBindTypePolicy:
policy, err := b.store.ACLPolicyByName(nil, bindName)
if err != nil {
return nil, err
}
if policy != nil {
bindings.Policies = append(bindings.Policies, policy.Name)
}
}
}
return &bindings, nil
}
// computeBindName processes the HIL for the provided bind type+name using the
// projected variables.
//
// - If the HIL is invalid ("", false, AN_ERROR) is returned.
// - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned.
// - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned.
func computeBindName(bindType, bindName string, claimMappings map[string]string) (string, bool, error) {
bindName, err := interpolateHIL(bindName, claimMappings, true)
if err != nil {
return "", false, err
}
var valid bool
switch bindType {
case structs.ACLBindingRuleBindTypePolicy:
valid = structs.ValidPolicyName.MatchString(bindName)
case structs.ACLBindingRuleBindTypeRole:
valid = structs.ValidACLRoleName.MatchString(bindName)
default:
return "", false, fmt.Errorf("unknown binding rule bind type: %s", bindType)
}
return bindName, valid, nil
}
// doesSelectorMatch checks that a single selector matches the provided vars.
func doesSelectorMatch(selector string, selectableVars interface{}) bool {
if selector == "" {
return true // catch-all
}
eval, err := bexpr.CreateEvaluator(selector)
if err != nil {
return false // fails to match if selector is invalid
}
result, err := eval.Evaluate(selectableVars)
if err != nil {
return false // fails to match if evaluation fails
}
return result
}
// interpolateHIL processes the string as if it were HIL and interpolates only
// the provided string->string map as possible variables.
func interpolateHIL(s string, vars map[string]string, lowercase bool) (string, error) {
if !strings.Contains(s, "${") {
// Skip going to the trouble of parsing something that has no HIL.
return s, nil
}
tree, err := hil.Parse(s)
if err != nil {
return "", err
}
vm := make(map[string]ast.Variable)
for k, v := range vars {
if lowercase {
v = strings.ToLower(v)
}
vm[k] = ast.Variable{
Type: ast.TypeString,
Value: v,
}
}
config := &hil.EvalConfig{
GlobalScope: &ast.BasicScope{
VarMap: vm,
},
}
result, err := hil.Eval(tree, config)
if err != nil {
return "", err
}
if result.Type != hil.TypeString {
return "", fmt.Errorf("generated unexpected hil type: %s", result.Type)
}
return result.Value.(string), nil
}

View File

@ -0,0 +1,181 @@
package oidc
import (
"testing"
"github.com/shoenig/test/must"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/state"
"github.com/hashicorp/nomad/nomad/structs"
)
func TestBinder_Bind(t *testing.T) {
ci.Parallel(t)
testStore := state.TestStateStore(t)
testBind := NewBinder(testStore)
// create an authMethod method and insert into the state store
authMethod := mock.ACLAuthMethod()
must.NoError(t, testStore.UpsertACLAuthMethods(0, []*structs.ACLAuthMethod{authMethod}))
// create some roles and insert into the state store
targetRole := &structs.ACLRole{
ID: uuid.Generate(),
Name: "vim-role",
}
otherRole := &structs.ACLRole{
ID: uuid.Generate(),
Name: "frontend-engineers",
}
must.NoError(t, testStore.UpsertACLRoles(
structs.MsgTypeTestSetup, 0, []*structs.ACLRole{targetRole, otherRole}, true,
))
// create binding rules and insert into the state store
bindingRules := []*structs.ACLBindingRule{
{
ID: uuid.Generate(),
Selector: "role==engineer",
BindType: structs.ACLBindingRuleBindTypeRole,
BindName: "${editor}-role",
AuthMethod: authMethod.Name,
},
{
ID: uuid.Generate(),
Selector: "role==engineer",
BindType: structs.ACLBindingRuleBindTypeRole,
BindName: "this-role-does-not-exist",
AuthMethod: authMethod.Name,
},
{
ID: uuid.Generate(),
Selector: "language==js",
BindType: structs.ACLBindingRuleBindTypeRole,
BindName: otherRole.Name,
AuthMethod: authMethod.Name,
},
}
must.NoError(t, testStore.UpsertACLBindingRules(0, bindingRules, true))
tests := []struct {
name string
authMethod *structs.ACLAuthMethod
identity *Identity
want *Bindings
wantErr bool
}{
{
"empty identity",
authMethod,
&Identity{},
&Bindings{},
false,
},
{
"role",
authMethod,
&Identity{
Claims: map[string]string{
"role": "engineer",
"language": "go",
},
ClaimMappings: map[string]string{
"editor": "vim",
},
},
&Bindings{Roles: []*structs.ACLTokenRoleLink{{ID: targetRole.ID}}},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := testBind.Bind(tt.authMethod, tt.identity)
if tt.wantErr {
must.Error(t, err)
} else {
must.NoError(t, err)
}
must.Eq(t, got, tt.want)
})
}
}
func Test_computeBindName(t *testing.T) {
ci.Parallel(t)
tests := []struct {
name string
bindType string
bindName string
claimMappings map[string]string
wantName string
wantTrue bool
wantErr bool
}{
{
"valid bind name and type",
structs.ACLBindingRuleBindTypeRole,
"cluster-admin",
map[string]string{"cluster-admin": "root"},
"cluster-admin",
true,
false,
},
{
"invalid type",
"amazing",
"cluster-admin",
map[string]string{"cluster-admin": "root"},
"",
false,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1, err := computeBindName(tt.bindType, tt.bindName, tt.claimMappings)
if tt.wantErr {
must.NotNil(t, err)
}
must.Eq(t, got, tt.wantName)
must.Eq(t, got1, tt.wantTrue)
})
}
}
func Test_doesSelectorMatch(t *testing.T) {
ci.Parallel(t)
tests := []struct {
name string
selector string
selectableVars interface{}
want bool
}{
{
"catch-all",
"",
nil,
true,
},
{
"valid selector but no selectable vars",
"nomad_engineering_team in Groups",
"",
false,
},
{
"valid selector and successful evaluation",
"nomad_engineering_team in Groups",
map[string][]string{"Groups": {"nomad_sales_team", "nomad_engineering_team"}},
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
must.Eq(t, doesSelectorMatch(tt.selector, tt.selectableVars), tt.want)
})
}
}

238
lib/auth/oidc/claims.go Normal file
View File

@ -0,0 +1,238 @@
package oidc
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/mitchellh/pointerstructure"
"github.com/hashicorp/nomad/nomad/structs"
)
// SelectorData returns the data for go-bexpr for selector evaluation.
func SelectorData(
am *structs.ACLAuthMethod, idClaims, userClaims json.RawMessage) (*structs.ACLAuthClaims, error) {
// Extract the claims into a map[string]interface{}
var all map[string]interface{}
if err := json.Unmarshal(idClaims, &all); err != nil {
return nil, err
}
// Ensure the issuer and subscriber data does not get overwritten.
if len(userClaims) > 0 {
iss, issOk := all["iss"]
sub, subOk := all["sub"]
if err := json.Unmarshal(userClaims, &all); err != nil {
return nil, err
}
if issOk {
all["iss"] = iss
}
if subOk {
all["sub"] = sub
}
}
return extractClaims(am, all)
}
// extractClaims takes the claim mapping configuration of the OIDC auth method,
// extracts the claims, and returns a map of data that can be used with
// go-bexpr.
func extractClaims(
am *structs.ACLAuthMethod, all map[string]interface{}) (*structs.ACLAuthClaims, error) {
values, err := extractMappings(all, am.Config.ClaimMappings)
if err != nil {
return nil, err
}
list, err := extractListMappings(all, am.Config.ListClaimMappings)
if err != nil {
return nil, err
}
return &structs.ACLAuthClaims{
Value: values,
List: list,
}, nil
}
// extractMappings extracts the string value mappings.
func extractMappings(
all map[string]interface{}, mapping map[string]string) (map[string]string, error) {
result := make(map[string]string)
for source, target := range mapping {
rawValue := getClaim(all, source)
if rawValue == nil {
continue
}
strValue, ok := stringifyClaimValue(rawValue)
if !ok {
return nil, fmt.Errorf("error converting claim '%s' to string from unknown type %T",
source, rawValue)
}
result[target] = strValue
}
return result, nil
}
// extractListMappings builds a metadata map of string list values from a set
// of claims and claims mappings. The referenced claims must be strings and
// the claims mappings must be of the structure:
//
// {
// "/some/claim/pointer": "metadata_key1",
// "another_claim": "metadata_key2",
// ...
// }
func extractListMappings(
all map[string]interface{}, mappings map[string]string) (map[string][]string, error) {
result := make(map[string][]string)
for source, target := range mappings {
rawValue := getClaim(all, source)
if rawValue == nil {
continue
}
rawList, ok := normalizeList(rawValue)
if !ok {
return nil, fmt.Errorf("%q list claim could not be converted to string list", source)
}
list := make([]string, 0, len(rawList))
for _, raw := range rawList {
value, ok := stringifyClaimValue(raw)
if !ok {
return nil, fmt.Errorf("value %v in %q list claim could not be parsed as string",
raw, source)
}
if value == "" {
continue
}
list = append(list, value)
}
result[target] = list
}
return result, nil
}
// getClaim returns a claim value from allClaims given a provided claim string.
// If this string is a valid JSONPointer, it will be interpreted as such to
// locate the claim. Otherwise, the claim string will be used directly.
//
// There is no fixup done to the returned data type here. That happens a layer
// up in the caller.
func getClaim(all map[string]interface{}, claim string) interface{} {
if !strings.HasPrefix(claim, "/") {
return all[claim]
}
val, err := pointerstructure.Get(all, claim)
if err != nil {
// We silently drop the error since keys that are invalid
// just have no values.
return nil
}
return val
}
// stringifyClaimValue will try to convert the provided raw value into a
// faithful string representation of that value per these rules:
//
// - strings => unchanged
// - bool => "true" / "false"
// - json.Number => String()
// - float32/64 => truncated to int64 and then formatted as an ascii string
// - intXX/uintXX => casted to int64 and then formatted as an ascii string
//
// If successful the string value and true are returned. otherwise an empty
// string and false are returned.
func stringifyClaimValue(rawValue interface{}) (string, bool) {
switch v := rawValue.(type) {
case string:
return v, true
case bool:
return strconv.FormatBool(v), true
case json.Number:
return v.String(), true
case float64:
// The claims unmarshalled by go-oidc don't use UseNumber, so
// they'll come in as float64 instead of an integer or json.Number.
return strconv.FormatInt(int64(v), 10), true
// The numerical type cases following here are only here for the sake
// of numerical type completion. Everything is truncated to an integer
// before being stringified.
case float32:
return strconv.FormatInt(int64(v), 10), true
case int8:
return strconv.FormatInt(int64(v), 10), true
case int16:
return strconv.FormatInt(int64(v), 10), true
case int32:
return strconv.FormatInt(int64(v), 10), true
case int64:
return strconv.FormatInt(v, 10), true
case int:
return strconv.FormatInt(int64(v), 10), true
case uint8:
return strconv.FormatInt(int64(v), 10), true
case uint16:
return strconv.FormatInt(int64(v), 10), true
case uint32:
return strconv.FormatInt(int64(v), 10), true
case uint64:
return strconv.FormatInt(int64(v), 10), true
case uint:
return strconv.FormatInt(int64(v), 10), true
default:
return "", false
}
}
// normalizeList takes an item or a slice and returns a slice. This is useful
// when providers are expected to return a list (typically of strings) but
// reduce it to a non-slice type when the list count is 1.
//
// There is no fixup done to elements of the returned slice here. That happens
// a layer up in the caller.
func normalizeList(raw interface{}) ([]interface{}, bool) {
switch v := raw.(type) {
case []interface{}:
return v, true
case string, // note: this list should be the same as stringifyClaimValue
bool,
json.Number,
float64,
float32,
int8,
int16,
int32,
int64,
int,
uint8,
uint16,
uint32,
uint64,
uint:
return []interface{}{v}, true
default:
return nil, false
}
}

View File

@ -0,0 +1,91 @@
package oidc
import (
"encoding/json"
"testing"
"github.com/shoenig/test/must"
"github.com/hashicorp/nomad/nomad/structs"
)
func TestSelectorData(t *testing.T) {
cases := []struct {
Name string
Mapping map[string]string
ListMapping map[string]string
Data map[string]interface{}
Expected *structs.ACLAuthClaims
}{
{
"no mappings",
nil,
nil,
map[string]interface{}{"iss": "https://hashicorp.com"},
&structs.ACLAuthClaims{
Value: map[string]string{},
List: map[string][]string{},
},
},
{
"key",
map[string]string{"iss": "issuer"},
nil,
map[string]interface{}{"iss": "https://hashicorp.com"},
&structs.ACLAuthClaims{
Value: map[string]string{"issuer": "https://hashicorp.com"},
List: map[string][]string{},
},
},
{
"key doesn't exist",
map[string]string{"iss": "issuer"},
nil,
map[string]interface{}{"nope": "https://hashicorp.com"},
&structs.ACLAuthClaims{
Value: map[string]string{},
List: map[string][]string{},
},
},
{
"list",
nil,
map[string]string{"groups": "g"},
map[string]interface{}{
"groups": []interface{}{
"A", 42, false,
},
},
&structs.ACLAuthClaims{
Value: map[string]string{},
List: map[string][]string{
"g": {"A", "42", "false"},
},
},
},
}
for _, tt := range cases {
t.Run(tt.Name, func(t *testing.T) {
am := &structs.ACLAuthMethod{
Config: &structs.ACLAuthMethodConfig{
ClaimMappings: tt.Mapping,
ListClaimMappings: tt.ListMapping,
},
}
// Marshal our test data
jsonRaw, err := json.Marshal(tt.Data)
must.NoError(t, err)
// Get real selector data
actual, err := SelectorData(am, jsonRaw, nil)
must.NoError(t, err)
must.Eq(t, actual, tt.Expected)
})
}
}

View File

@ -168,11 +168,11 @@ const (
)
var (
// validACLRoleName is used to validate an ACL role name.
validACLRoleName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
// ValidACLRoleName is used to validate an ACL role name.
ValidACLRoleName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
// validACLAuthMethodName is used to validate an ACL auth method name.
validACLAuthMethod = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
ValidACLAuthMethod = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
)
// ACLTokenRoleLink is used to link an ACL token to an ACL role. The ACL token
@ -406,7 +406,7 @@ func (a *ACLRole) Validate() error {
var mErr multierror.Error
if !validACLRoleName.MatchString(a.Name) {
if !ValidACLRoleName.MatchString(a.Name) {
mErr.Errors = append(mErr.Errors, fmt.Errorf("invalid name '%s'", a.Name))
}
@ -777,7 +777,7 @@ func (a *ACLAuthMethod) Merge(b *ACLAuthMethod) {
func (a *ACLAuthMethod) Validate(minTTL, maxTTL time.Duration) error {
var mErr multierror.Error
if !validACLAuthMethod.MatchString(a.Name) {
if !ValidACLAuthMethod.MatchString(a.Name) {
mErr.Errors = append(mErr.Errors, fmt.Errorf("invalid name '%s'", a.Name))
}
@ -829,6 +829,14 @@ func (a *ACLAuthMethodConfig) Copy() *ACLAuthMethodConfig {
return c
}
// ACLAuthClaims is the claim mapping of the OIDC auth method in a format that
// can be used with go-bexpr. This structure is used during rule binding
// evaluation.
type ACLAuthClaims struct {
Value map[string]string
List map[string][]string
}
// ACLAuthMethodStub is used for listing ACL auth methods
type ACLAuthMethodStub struct {
Name string
@ -916,7 +924,7 @@ type ACLWhoAmIResponse struct {
// ACL Roles and Policies.
type ACLBindingRule struct {
// ID is an internally generated UUID for this role and is controlled by
// ID is an internally generated UUID for this rule and is controlled by
// Nomad.
ID string

View File

@ -50,8 +50,8 @@ import (
)
var (
// validPolicyName is used to validate a policy name
validPolicyName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
// ValidPolicyName is used to validate a policy name
ValidPolicyName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
// b32 is a lowercase base32 encoding for use in URL friendly service hashes
b32 = base32.NewEncoding(strings.ToLower("abcdefghijklmnopqrstuvwxyz234567"))
@ -11975,7 +11975,7 @@ func (a *ACLPolicy) Stub() *ACLPolicyListStub {
func (a *ACLPolicy) Validate() error {
var mErr multierror.Error
if !validPolicyName.MatchString(a.Name) {
if !ValidPolicyName.MatchString(a.Name) {
err := fmt.Errorf("invalid name '%s'", a.Name)
mErr.Errors = append(mErr.Errors, err)
}