diff --git a/backend/functional/features/check.feature b/backend/functional/features/check.feature index 3557ffe8..51491c7f 100644 --- a/backend/functional/features/check.feature +++ b/backend/functional/features/check.feature @@ -154,6 +154,7 @@ Feature: check "kind": "post", "value": "789", "attributes": [ + {"key": "owner_id", "value": "owner-123"}, {"key": "is_editable", "value": true} ] } @@ -192,6 +193,7 @@ Feature: check "kind": "post", "value": "10-updated-after", "attributes": [ + {"key": "owner_id", "value": "owner-123"}, {"key": "is_editable", "value": true} ] } @@ -278,3 +280,253 @@ Feature: check ] } """ + + Scenario: Check for access (using ABAC greater operator) + Given I authenticate with username "admin" and password "changeme" + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.123", + "kind": "post", + "value": "123", + "attributes": [ + {"key": "number", "value": 10} + ] + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.456", + "kind": "post", + "value": "456", + "attributes": [ + {"key": "number", "value": 20} + ] + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.789", + "kind": "post", + "value": "789" + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/principals" with payload: + """ + { + "id": "my-principal" + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/policies" with payload: + """ + { + "id": "my-post-greater-10", + "resources": [ + "post.*" + ], + "actions": ["create"], + "attribute_rules": [ + "resource.number > 10" + ] + } + """ + And the response code should be 200 + And I wait "1s" + When I send "POST" request to "/v1/check" with payload: + """ + { + "checks": [ + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "create" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "update" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "456", + "action": "create" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "789", + "action": "create" + } + ] + } + """ + And the response code should be 200 + And the response should match json: + """ + { + "checks": [ + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "create", + "is_allowed": false + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "update", + "is_allowed": false + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "456", + "action": "create", + "is_allowed": true + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "789", + "action": "create", + "is_allowed": false + } + ] + } + """ + + Scenario: Check for access (using ABAC lower operator) + Given I authenticate with username "admin" and password "changeme" + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.123", + "kind": "post", + "value": "123", + "attributes": [ + {"key": "number", "value": 10} + ] + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.456", + "kind": "post", + "value": "456", + "attributes": [ + {"key": "number", "value": 20} + ] + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/resources" with payload: + """ + { + "id": "post.789", + "kind": "post", + "value": "789" + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/principals" with payload: + """ + { + "id": "my-principal" + } + """ + And the response code should be 200 + And I send "POST" request to "/v1/policies" with payload: + """ + { + "id": "my-post-greater-10", + "resources": [ + "post.*" + ], + "actions": ["create"], + "attribute_rules": [ + "resource.number < 20" + ] + } + """ + And the response code should be 200 + And I wait "1s" + When I send "POST" request to "/v1/check" with payload: + """ + { + "checks": [ + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "create" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "update" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "456", + "action": "create" + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "789", + "action": "create" + } + ] + } + """ + And the response code should be 200 + And the response should match json: + """ + { + "checks": [ + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "create", + "is_allowed": true + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "123", + "action": "update", + "is_allowed": false + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "456", + "action": "create", + "is_allowed": false + }, + { + "principal": "my-principal", + "resource_kind": "post", + "resource_value": "789", + "action": "create", + "is_allowed": false + } + ] + } + """ diff --git a/backend/internal/attribute/rule.go b/backend/internal/attribute/rule.go index eee4f185..661d7407 100644 --- a/backend/internal/attribute/rule.go +++ b/backend/internal/attribute/rule.go @@ -3,14 +3,17 @@ package attribute import ( "errors" "regexp" + "strconv" "strings" + + "github.com/eko/authz/backend/internal/entity/model" ) var ( resourceAttributeRegexp = regexp.MustCompile(`(resource\.)(.+)`) principalAttributeRegexp = regexp.MustCompile(`(principal\.)(.+)`) - ruleRegexp = regexp.MustCompile(`([resource|principal]?\.?.+)\s?(==|!=)\s?([resource|principal]?\.?.+)`) + ruleRegexp = regexp.MustCompile(`([resource|principal]?\.?.+)\s?(==|!=|>|<)\s?([resource|principal]?\.?.+)`) // ErrInvalidRuleFormat is returned when a rule format is invalid. ErrInvalidRuleFormat = errors.New("rule is invalid: should have at least one resource. or a principal.") @@ -23,6 +26,22 @@ const ( // For example: my.owner_id == 123 RuleOperatorEqual RuleOperator = "==" + // RuleOperatorGreater represents a greater value attribute rule. + // For example: my.number > 123 + RuleOperatorGreater RuleOperator = ">" + + // RuleOperatorGreater represents a greater or equal value attribute rule. + // For example: my.number >= 123 + RuleOperatorGreaterEqual RuleOperator = ">=" + + // RuleOperatorLower represents a lower value attribute rule. + // For example: my.number < 123 + RuleOperatorLower RuleOperator = "<" + + // RuleOperatorLowerEqual represents a lower or equal value attribute rule. + // For example: my.number <= 123 + RuleOperatorLowerEqual RuleOperator = "<=" + // RuleOperatorEqual represents a NOT equal attribute rule. // For example: my.owner_id != 123 RuleOperatorNotEqual RuleOperator = "!=" @@ -40,6 +59,59 @@ type Rule struct { Value string `json:"Value"` } +func (r *Rule) MatchPrincipal(attributes model.Attributes) bool { + value := attributes.GetAttribute(r.PrincipalAttribute) + + if r.PrincipalAttribute == "" || value == "" { + return true + } + + return r.match(value) +} + +func (r *Rule) MatchResource(attributes model.Attributes) bool { + value := attributes.GetAttribute(r.ResourceAttribute) + + if r.ResourceAttribute == "" || value == "" { + return true + } + + return r.match(value) +} + +func (r *Rule) match(value string) bool { + switch r.Operator { + case RuleOperatorEqual: + return value == r.Value + case RuleOperatorGreater, RuleOperatorGreaterEqual, RuleOperatorLower, RuleOperatorLowerEqual: + intValue, valueErr := strconv.ParseInt(value, 10, 0) + ruleIntValue, ruleValueErr := strconv.ParseInt(r.Value, 10, 0) + + if valueErr != nil || ruleValueErr != nil { + return false + } + + switch r.Operator { + case RuleOperatorGreater: + return intValue > ruleIntValue + case RuleOperatorGreaterEqual: + return intValue >= ruleIntValue + case RuleOperatorLower: + return intValue < ruleIntValue + case RuleOperatorLowerEqual: + return intValue <= ruleIntValue + default: + return false + } + + case RuleOperatorNotEqual: + return value != r.Value + + default: + return false + } +} + // ToString converts the rule structure to string. func (r *Rule) ToString() string { if (r.ResourceAttribute == "" && r.PrincipalAttribute == "") || diff --git a/backend/internal/attribute/rule_test.go b/backend/internal/attribute/rule_test.go index 7ee1485a..f5dd4f41 100644 --- a/backend/internal/attribute/rule_test.go +++ b/backend/internal/attribute/rule_test.go @@ -3,9 +3,250 @@ package attribute import ( "testing" + "github.com/eko/authz/backend/internal/entity/model" "github.com/stretchr/testify/assert" ) +func TestRule_MatchPrincipal(t *testing.T) { + // Given + testCases := []struct { + name string + attributes model.Attributes + rule *Rule + expected bool + }{ + { + name: "Empty attributes and rules", + attributes: model.Attributes{}, + rule: &Rule{}, + expected: true, + }, + { + name: "Attributes matching > rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorGreater, + Value: "40", + }, + expected: true, + }, + { + name: "Attributes not matching >= rule when equal", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorGreaterEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching >= rule when greater", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "51"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorGreaterEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching > rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorGreater, + Value: "60", + }, + expected: false, + }, + { + name: "Attributes matching < rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorLower, + Value: "60", + }, + expected: true, + }, + { + name: "Attributes matching <= rule when equal", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorLowerEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes matching <= rule when lower", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "49"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorLowerEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching < rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + PrincipalAttribute: "my_attribute", + Operator: RuleOperatorLower, + Value: "40", + }, + expected: false, + }, + } + + // When - Then + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.expected, testCase.rule.MatchPrincipal(testCase.attributes)) + }) + } +} + +func TestRule_MatchResource(t *testing.T) { + // Given + testCases := []struct { + name string + attributes model.Attributes + rule *Rule + expected bool + }{ + { + name: "Empty attributes and rules", + attributes: model.Attributes{}, + rule: &Rule{}, + expected: true, + }, + { + name: "Attributes matching > rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorGreater, + Value: "40", + }, + expected: true, + }, + { + name: "Attributes not matching >= rule when equal", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorGreaterEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching >= rule when greater", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "51"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorGreaterEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching > rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorGreater, + Value: "60", + }, + expected: false, + }, + { + name: "Attributes matching < rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorLower, + Value: "60", + }, + expected: true, + }, + { + name: "Attributes matching <= rule when equal", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorLowerEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes matching <= rule when lower", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "49"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorLowerEqual, + Value: "50", + }, + expected: true, + }, + { + name: "Attributes not matching < rule", + attributes: model.Attributes{ + {Key: "my_attribute", Value: "50"}, + }, + rule: &Rule{ + ResourceAttribute: "my_attribute", + Operator: RuleOperatorLower, + Value: "40", + }, + expected: false, + }, + } + + // When - Then + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.expected, testCase.rule.MatchResource(testCase.attributes)) + }) + } +} + func TestConvertStringToRuleOperator(t *testing.T) { testCases := []struct { name string diff --git a/backend/internal/compile/compiler.go b/backend/internal/compile/compiler.go index 98c515f8..4e5b9a52 100644 --- a/backend/internal/compile/compiler.go +++ b/backend/internal/compile/compiler.go @@ -288,31 +288,31 @@ func (c *compiler) retrieveResources(resources []*model.Resource, rule *attribut filters["authz_attributes.key_name"] = repository.FieldValue{ Operator: "=", Value: rule.ResourceAttribute, } - - switch rule.Operator { - case attribute.RuleOperatorEqual: - filters["authz_attributes.value"] = repository.FieldValue{ - Operator: "=", Value: rule.Value, - } - case attribute.RuleOperatorNotEqual: - filters["authz_attributes.value"] = repository.FieldValue{ - Operator: "<>", Value: rule.Value, - } - } } allResources, _, err := c.resourceManager.GetRepository().Find( repository.WithJoin( - "INNER JOIN authz_resources_attributes ON authz_resources.id = authz_resources_attributes.resource_id", - "INNER JOIN authz_attributes ON authz_resources_attributes.attribute_id = authz_attributes.id", + "LEFT JOIN authz_resources_attributes ON authz_resources.id = authz_resources_attributes.resource_id", + "LEFT JOIN authz_attributes ON authz_resources_attributes.attribute_id = authz_attributes.id", ), repository.WithFilter(filters), + repository.WithPreloads("Attributes"), ) if err != nil { return nil, err } - result = append(result, allResources...) + matchingResources := []*model.Resource{} + + for _, resource := range allResources { + if !rule.MatchResource(resource.Attributes) { + continue + } + + matchingResources = append(matchingResources, resource) + } + + result = append(result, matchingResources...) } return result, nil @@ -325,31 +325,31 @@ func (c *compiler) retrievePrincipals(rule *attribute.Rule) ([]*model.Principal, filters["authz_attributes.key_name"] = repository.FieldValue{ Operator: "=", Value: rule.PrincipalAttribute, } - - switch rule.Operator { - case attribute.RuleOperatorEqual: - filters["authz_attributes.value"] = repository.FieldValue{ - Operator: "=", Value: rule.Value, - } - case attribute.RuleOperatorNotEqual: - filters["authz_attributes.value"] = repository.FieldValue{ - Operator: "<>", Value: rule.Value, - } - } } allPrincipals, _, err := c.principalManager.GetRepository().Find( repository.WithJoin( - "INNER JOIN authz_principals_attributes ON authz_principals.id = authz_principals_attributes.principal_id", - "INNER JOIN authz_attributes ON authz_principals_attributes.attribute_id = authz_attributes.id", + "LEFT JOIN authz_principals_attributes ON authz_principals.id = authz_principals_attributes.principal_id", + "LEFT JOIN authz_attributes ON authz_principals_attributes.attribute_id = authz_attributes.id", ), repository.WithFilter(filters), + repository.WithPreloads("Attributes"), ) if err != nil { return nil, err } - return allPrincipals, nil + matchingPrincipals := []*model.Principal{} + + for _, principal := range allPrincipals { + if !rule.MatchPrincipal(principal.Attributes) { + continue + } + + matchingPrincipals = append(matchingPrincipals, principal) + } + + return matchingPrincipals, nil } func (c *compiler) CompilePrincipal(principal *model.Principal) error { diff --git a/backend/internal/entity/model/attribute.go b/backend/internal/entity/model/attribute.go index 08a6d92c..d78e308c 100644 --- a/backend/internal/entity/model/attribute.go +++ b/backend/internal/entity/model/attribute.go @@ -1,5 +1,17 @@ package model +type Attributes []*Attribute + +func (a Attributes) GetAttribute(key string) string { + for _, attribute := range a { + if attribute.Key == key { + return attribute.Value + } + } + + return "" +} + type Attribute struct { ID int `json:"-" gorm:"primarykey"` Key string `json:"key" gorm:"column:key_name"` diff --git a/backend/internal/entity/model/principal.go b/backend/internal/entity/model/principal.go index 664cdf62..392cab01 100644 --- a/backend/internal/entity/model/principal.go +++ b/backend/internal/entity/model/principal.go @@ -8,12 +8,12 @@ import ( ) type Principal struct { - ID string `json:"id" gorm:"primarykey"` - Roles []*Role `json:"roles,omitempty" gorm:"many2many:authz_principals_roles;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - Attributes []*Attribute `json:"attributes,omitempty" gorm:"many2many:authz_principals_attributes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - IsLocked bool `json:"is_locked" gorm:"is_locked"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id" gorm:"primarykey"` + Roles []*Role `json:"roles,omitempty" gorm:"many2many:authz_principals_roles;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Attributes Attributes `json:"attributes,omitempty" gorm:"many2many:authz_principals_attributes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + IsLocked bool `json:"is_locked" gorm:"is_locked"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Principal) TableName() string { diff --git a/backend/internal/entity/model/resource.go b/backend/internal/entity/model/resource.go index 9a1bb646..6b6abd0c 100644 --- a/backend/internal/entity/model/resource.go +++ b/backend/internal/entity/model/resource.go @@ -5,25 +5,15 @@ import ( ) type Resource struct { - ID string `json:"id" gorm:"primarykey"` - Kind string `json:"kind" gorm:"kind"` - Value string `json:"value" gorm:"value"` - Attributes []*Attribute `json:"attributes,omitempty" gorm:"many2many:authz_resources_attributes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - IsLocked bool `json:"is_locked" gorm:"is_locked"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id" gorm:"primarykey"` + Kind string `json:"kind" gorm:"kind"` + Value string `json:"value" gorm:"value"` + Attributes Attributes `json:"attributes,omitempty" gorm:"many2many:authz_resources_attributes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + IsLocked bool `json:"is_locked" gorm:"is_locked"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Resource) TableName() string { return "authz_resources" } - -func (r *Resource) GetAttribute(key string) string { - for _, attribute := range r.Attributes { - if attribute.Key == key { - return attribute.Value - } - } - - return "" -} diff --git a/docs/model/abac.md b/docs/model/abac.md index db85ac28..c3bee0b7 100644 --- a/docs/model/abac.md +++ b/docs/model/abac.md @@ -15,6 +15,10 @@ First, you can check for both resource and principal attribute names: ``` resource. == principal. resource. != principal. +resource. > principal. +resource. >= principal. +resource. < principal. +resource. <= principal. ``` Or, you can check for a specific value of a resource attribute: @@ -22,6 +26,10 @@ Or, you can check for a specific value of a resource attribute: ``` resource. == resource. != +resource. > +resource. >= +resource. < +resource. <= ``` Or, you can check for a specific value of a principal attribute: @@ -29,6 +37,10 @@ Or, you can check for a specific value of a principal attribute: ``` principal. == principal. != +principal. > +principal. >= +principal. < +principal. <= ``` ## Blog post example