diff --git a/cmd/hexa/hexa_test.go b/cmd/hexa/hexa_test.go index c7488f1..57f1b61 100644 --- a/cmd/hexa/hexa_test.go +++ b/cmd/hexa/hexa_test.go @@ -487,8 +487,8 @@ func (suite *testSuite) Test08_MapFromCmd() { command = "map from cedar ../../examples/policyExamples/cedarAlice.txt" res, err = suite.executeCommand(command, 0) assert.NoError(suite.T(), err, "Should be successful map of cedar") - assert.Contains(suite.T(), string(res), "Photo:VacationPhoto94.jpg") - assert.Contains(suite.T(), string(res), "\"Rule\": \"resource in Account::\\\"stacey\\\"\"") + assert.Contains(suite.T(), string(res), "\"Photo:\\\"VacationPhoto94.jpg\\\"") + assert.Contains(suite.T(), string(res), " \"Rule\": \"resource in Account:\\\"stacey\\\"\",") command = "map from gcp ../../examples/policyExamples/example_bindings.json" res, err = suite.executeCommand(command, 0) diff --git a/models/conditionLangs/cedarConditions/map_cedar.go b/models/conditionLangs/cedarConditions/map_cedar.go index 0f4e29b..16073c4 100644 --- a/models/conditionLangs/cedarConditions/map_cedar.go +++ b/models/conditionLangs/cedarConditions/map_cedar.go @@ -94,7 +94,9 @@ func mapCedarRelationComparator(node cedarjson.NodeJSON) (string, error) { case types.String: return strconv.Quote(val.String()), nil case types.EntityUID: - return fmt.Sprintf("%s::\"%s\"", item.Type, item.ID), nil + iType := strings.Replace(string(item.Type), "::", ":", -1) + + return fmt.Sprintf("%s:\"%s\"", iType, item.ID), nil default: return val.String(), nil } diff --git a/models/conditionLangs/cedarConditions/map_test.go b/models/conditionLangs/cedarConditions/map_test.go index 270f5c5..ec3ae03 100644 --- a/models/conditionLangs/cedarConditions/map_test.go +++ b/models/conditionLangs/cedarConditions/map_test.go @@ -53,7 +53,7 @@ func TestMapCedar(t *testing.T) { Action: conditions.AAllow, }, false}, {"In test", "when { resource in Album::\"alice_vacation\" }", &conditions.ConditionInfo{ - Rule: "resource in Album::\"alice_vacation\"", + Rule: "resource in Album:\"alice_vacation\"", Action: conditions.AAllow, }, false}, {"In set", "when { resource.id in [\"a\",\"b\"] }", &conditions.ConditionInfo{ @@ -82,7 +82,7 @@ func TestMapCedar(t *testing.T) { Action: conditions.AAllow, }, true}, {"Is In", "when { principal is User in Group::\"accounting\"}", &conditions.ConditionInfo{ - Rule: "principal is User and principal in Group::\"accounting\"", + Rule: "principal is User and principal in Group:\"accounting\"", Action: conditions.AAllow, }, false}, {"Multi-or", "when { principal.id > 4 || principal.type >= \"c\" || resource.id < 100 || resource.name <= \"m\" }", @@ -128,7 +128,7 @@ when { resource.owner != "somebody" } }, false}, {"Entity addressing", "when { principal == User::\"a1b2c3d4-e5f6-a1b2-c3d4-EXAMPLE11111\" }", &conditions.ConditionInfo{ - Rule: "principal eq User::\"a1b2c3d4-e5f6-a1b2-c3d4-EXAMPLE11111\"", + Rule: "principal eq User:\"a1b2c3d4-e5f6-a1b2-c3d4-EXAMPLE11111\"", Action: conditions.AAllow, }, false}, {"Greater test", "when { principal.id.greaterThan(4) }", diff --git a/models/conditionLangs/gcpcel/gcp_condition_mapper_test.go b/models/conditionLangs/gcpcel/gcp_condition_mapper_test.go index 9709cd2..e4a24e4 100644 --- a/models/conditionLangs/gcpcel/gcp_condition_mapper_test.go +++ b/models/conditionLangs/gcpcel/gcp_condition_mapper_test.go @@ -120,12 +120,12 @@ func TestNegToProvider(t *testing.T) { Rule: "bleh is bad", } celString, err := mapper.MapConditionToProvider(condition) - assert.Errorf(t, err, "invalid IDQL idqlCondition: Unsupported comparison operator: is") + assert.Errorf(t, err, "invalid condition: Unsupported comparison operator: is") assert.Equal(t, "", celString, "Should be empty string") valuePath := conditions.ConditionInfo{Rule: "emails[type eq \"work\" and value ew \"strata.io\""} celString, err = mapper.MapConditionToProvider(valuePath) - assert.Errorf(t, err, "invalid IDQL idqlCondition: Missing close ']' bracket") + assert.Errorf(t, err, "invalid condition: Missing close ']' bracket") assert.Equal(t, "", celString, "Should be empty string") valuePath = conditions.ConditionInfo{Rule: "emails[type eq \"work\" and value ew \"strata.io\"]"} @@ -145,7 +145,7 @@ func TestNegToProvider(t *testing.T) { badCompare := conditions.ConditionInfo{Rule: "level GT 3 and abc GR 2"} celString, err = mapper.MapConditionToProvider(badCompare) - assert.Errorf(t, err, "invalid IDQL idqlCondition: Unsupported comparison operator: GR") + assert.Errorf(t, err, "invalid condition: Unsupported comparison operator: GR") assert.Equal(t, "", celString, "Should be empty string") } diff --git a/models/formats/cedar/cedar_mapper.go b/models/formats/cedar/cedar_mapper.go index 4a7e9f7..1c2821e 100644 --- a/models/formats/cedar/cedar_mapper.go +++ b/models/formats/cedar/cedar_mapper.go @@ -231,7 +231,7 @@ func mapCedarScope(verb string, scope policyjson.ScopeJSON) []string { } return []string{} case "==": - id := scope.Entity.ID.String() + id := strconv.Quote(scope.Entity.ID.String()) entityType := string(scope.Entity.Type) path := hexaTypes.Entity{ Type: hexaTypes.RelTypeEquals, @@ -245,7 +245,7 @@ func mapCedarScope(verb string, scope policyjson.ScopeJSON) []string { if scope.In != nil { // is in - inEntityStr := fmt.Sprintf("%s:%s", scope.In.Entity.Type, scope.In.Entity.ID) + inEntityStr := fmt.Sprintf("%s:%s", scope.In.Entity.Type, strconv.Quote(string(scope.In.Entity.ID))) inEntity := hexaTypes.ParseEntity(inEntityStr) inEntities := []hexaTypes.Entity{*inEntity} @@ -266,7 +266,7 @@ func mapCedarScope(verb string, scope policyjson.ScopeJSON) []string { case "in": if scope.Entity != nil { eType := string(scope.Entity.Type) - id := scope.Entity.ID.String() + id := strconv.Quote(scope.Entity.ID.String()) inEntity := hexaTypes.Entity{ Type: hexaTypes.RelTypeEquals, Types: []string{eType}, @@ -282,7 +282,7 @@ func mapCedarScope(verb string, scope policyjson.ScopeJSON) []string { items := make([]hexaTypes.Entity, len(scope.Entities)) for i, entity := range scope.Entities { - id := entity.ID.String() + id := strconv.Quote(entity.ID.String()) pathItem := hexaTypes.Entity{ Type: hexaTypes.RelTypeEquals, Id: &id, diff --git a/models/formats/cedar/cedar_test.go b/models/formats/cedar/cedar_test.go index 492c680..39ca6cb 100644 --- a/models/formats/cedar/cedar_test.go +++ b/models/formats/cedar/cedar_test.go @@ -58,10 +58,10 @@ permit ( "version": "0.7" }, "subjects": [ - "User:alice" + "User:\"alice\"" ], - "actions": [ "Action:viewPhoto" ], - "object": "Photo:VacationPhoto.jpg" + "actions": [ "Action:\"viewPhoto\"" ], + "object": "Photo:\"VacationPhoto.jpg\"" }`, err: false}, { @@ -74,14 +74,14 @@ permit ( idql: `{ "meta": {"version": "0.7"}, "subjects": [ - "User[Group:AVTeam]" + "User[Group:\"AVTeam\"]" ], "actions": [ - "PhotoOp:view", - "PhotoOp:edit", - "PhotoOp:delete" + "PhotoOp:\"view\"", + "PhotoOp:\"edit\"", + "PhotoOp:\"delete\"" ], - "object": "Photo:VacationPhoto.jpg" + "object": "Photo:\"VacationPhoto.jpg\"" }`, err: false}, { name: "Conditions", @@ -95,12 +95,12 @@ unless { principal has parents };`, idql: `{ "meta": {"version": "0.7"}, "subjects": [ - "[UserGroup:AVTeam]" + "[UserGroup:\"AVTeam\"]" ], - "actions": [ "Action:viewPhoto" ], + "actions": [ "Action:\"viewPhoto\"" ], "object": "Photo:", "Condition": { - "Rule": "resource in PhotoApp::Account::\"stacey\" and not (principal.parents pr)", + "Rule": "resource in PhotoApp:Account:\"stacey\" and not (principal.parents pr)", "Action": "allow" } }`, @@ -118,10 +118,10 @@ when { resource in PhotoShop::"Photo" };`, "subjects": [ "User:" ], - "actions": [ "Action:viewPhoto" ], + "actions": [ "Action:\"viewPhoto\"" ], "object": "", "Condition": { - "Rule": "resource in PhotoShop::\"Photo\"", + "Rule": "resource in PhotoShop:\"Photo\"", "Action": "allow" } }`, diff --git a/models/policyInfoModel/schema.go b/models/policyInfoModel/schema.go index 7fe8223..0bab46c 100644 --- a/models/policyInfoModel/schema.go +++ b/models/policyInfoModel/schema.go @@ -2,33 +2,72 @@ // application. The model is based on [Cedar Schema](https://docs.cedarpolicy.com/schema/schema.html). package policyInfoModel -import "encoding/json" +import ( + "encoding/json" + "strings" -type LongType struct { - Type string `json:"type"` // is "Long" + hexaTypes "github.com/hexa-org/policy-mapper/pkg/hexapolicy/types" +) + +const ( + TypePerson string = "PersonType" + TypeRecord string = "Record" + TypeSet string = "Set" + TypeBool string = "Bool" + TypeString string = "String" + TypeDate string = "Date" + TypeNumeric string = "Numeric" + TypeLong string = "Long" + TypeExtension string = "Extension" +) + +type hasAttributes interface { + FindAttrTypes(path string, schema SchemaType) *AttrType } type SetType struct { - Type string `json:"type"` // is "Set" - Element []interface{} `json:"element"` + Element *AttrType `json:"element"` } type AttrType struct { Type string `json:"type"` Name string `json:"name,omitempty"` Required bool `json:"required"` + SetType + RecordType } type RecordType struct { - Type string `json:"type"` // fixed as "RecordType" Attributes map[string]AttrType `json:"attributes"` } +func (h AttrType) FindAttrTypes(path string, schema SchemaType) *AttrType { + comps := strings.SplitN(path, ".", 2) + if h.SetType.Element != nil { + sType, ok := h.SetType.Element.Attributes[comps[0]] + if ok { + if len(comps) == 1 { + return &sType + } + return sType.FindAttrTypes(comps[1], schema) + } + } + + if h.RecordType.Attributes != nil { + return doFindAttr(h.RecordType.Attributes, path, schema) + } + return nil +} + type ContextType struct { Type string `json:"type"` // fixed as "RecordType" Attributes map[string]AttrType `json:"attributes"` } +func (c ContextType) FindAttrTypes(path string, schema SchemaType) *AttrType { + return doFindAttr(c.Attributes, path, schema) +} + type ResourceTypes []string type PrincipalTypes []string @@ -49,18 +88,70 @@ type ShapeTypes struct { Attributes map[string]AttrType `json:"attributes"` } +func (s ShapeTypes) FindAttrType(path string, schema SchemaType) *AttrType { + return doFindAttr(s.Attributes, path, schema) +} + +func doFindAttr(attributes map[string]AttrType, path string, schema SchemaType) *AttrType { + if path == "" || attributes == nil { + return nil + } + comps := strings.SplitN(path, ".", 2) + for name, attrType := range attributes { + if strings.EqualFold(comps[0], name) { + if len(comps) == 1 { + return &attrType + } + subAttr := attrType.FindAttrTypes(comps[1], schema) + if subAttr != nil { + return subAttr + } + } + switch attrType.Type { + case TypeString, TypeBool, TypeDate, TypeNumeric, TypeLong, TypeExtension, TypeRecord, TypeSet: + continue + default: + // This is a custom type - lookup under "commonTypes" + cType, ok := schema.CommonTypes[attrType.Type] + if ok { + attr := cType.FindAttrTypes(path, schema) + if attr != nil { + return attr + } + } + } + + } + return nil +} + // EntityType ::= IDENT ':' '{' [ 'memberOfTypes' ':' '[' [ IDENT { ',' IDENT } ] ']' ] ',' [ 'shape': TypeJson ] '}' type EntityType struct { MemberOfTypes []string `json:"memberOfTypes,omitempty"` Shape ShapeTypes `json:"shape,omitempty"` } +func (e EntityType) FindAttrType(path string, schema SchemaType) *AttrType { + return e.Shape.FindAttrType(path, schema) +} + type SchemaType struct { EntityTypes map[string]EntityType `json:"entityTypes,omitempty"` Actions map[string]ActionType `json:"actions,omitempty"` CommonTypes map[string]ContextType `json:"commonTypes,omitempty"` } +// FindAttrType locates an AttrType definition by using the path format: :. +func (s SchemaType) FindAttrType(entity hexaTypes.Entity) *AttrType { + eType, ok := s.EntityTypes[entity.GetType()] + if ok { + if entity.IsPath() { + return eType.FindAttrType(entity.GetId(), s) + } + } + return nil +} + type Namespaces map[string]SchemaType func ParseSchemaFile(schemaBytes []byte) (*Namespaces, error) { diff --git a/models/policyInfoModel/schema_test.go b/models/policyInfoModel/schema_test.go index a412a35..a980da7 100644 --- a/models/policyInfoModel/schema_test.go +++ b/models/policyInfoModel/schema_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + hexaTypes "github.com/hexa-org/policy-mapper/pkg/hexapolicy/types" "github.com/santhosh-tekuri/jsonschema/v6" "github.com/stretchr/testify/assert" ) @@ -40,6 +41,52 @@ func TestParsePhotoSchema(t *testing.T) { assert.Equal(t, "Long", personType.Attributes["age"].Type) } +func TestParseCmvSchema(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + fileBytes, err := os.ReadFile(filepath.Join(file, "../test", "cmvSchemaTest.json")) + assert.NoError(t, err) + + namespaces, err := ParseSchemaFile(fileBytes) + assert.NoError(t, err) + assert.NotNil(t, namespaces) + + schemas := *namespaces + app, ok := schemas["PhotoApp"] + if !ok { + assert.Fail(t, "Expected PhotoApp Schema") + } + assert.NotNil(t, app) + + entity := hexaTypes.ParseEntity("User:userId") + aType := app.FindAttrType(*entity) + + assert.NotNil(t, aType) + assert.Equal(t, TypeString, aType.Type) + + entityEmails := hexaTypes.ParseEntity("User:emails.primary") + aType = app.FindAttrType(*entityEmails) + assert.NotNil(t, aType) + assert.Equal(t, TypeBool, aType.Type) + + entityUserName := hexaTypes.ParseEntity("User:name") + aType = app.FindAttrType(*entityUserName) + assert.NotNil(t, aType) + assert.Equal(t, TypeRecord, aType.Type) + + entityNameFamily := hexaTypes.ParseEntity("User:name.familyName") + aType = app.FindAttrType(*entityNameFamily) + assert.NotNil(t, aType) + assert.Equal(t, TypeString, aType.Type) + + entityBad := hexaTypes.ParseEntity("User:name.bad") + aType = app.FindAttrType(*entityBad) + assert.Nil(t, aType) + + entityBadType := hexaTypes.ParseEntity("Bad:name") + aType = app.FindAttrType(*entityBadType) + assert.Nil(t, aType) +} + func TestParseHealthSchema(t *testing.T) { _, file, _, _ := runtime.Caller(0) fileBytes, err := os.ReadFile(filepath.Join(file, "../test", "healthSchema.json")) diff --git a/models/policyInfoModel/test/cmvSchemaTest.json b/models/policyInfoModel/test/cmvSchemaTest.json new file mode 100644 index 0000000..d0c2838 --- /dev/null +++ b/models/policyInfoModel/test/cmvSchemaTest.json @@ -0,0 +1,175 @@ +{ + "PhotoApp": { + "commonTypes": { + "PersonType": { + "type": "Record", + "attributes": { + "age": { + "type": "Long" + }, + "name": { + "type": "Record", + "attributes": { + "formatted": { + "type": "String" + }, + "familyName": { + "type": "String" + }, + "givenName": { + "type": "String" + }, + "middleName": { + "type": "String" + }, + "honorificPrefix": { + "type": "String" + }, + "honorificSuffix": { + "type": "String" + } + } + }, + "lastReviewed": { + "type": "Date" + }, + "isEmployee": { + "type": "Bool" + } + } + }, + "ContextType": { + "type": "Record", + "attributes": { + "ip": { + "type": "Extension", + "name": "ipaddr", + "required": false + }, + "authenticated": { + "type": "Boolean", + "required": true + } + } + } + }, + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "userId": { + "type": "String" + }, + "personInformation": { + "type": "PersonType" + }, + "emails": { + "type": "Set", + "element": { + "type": "Record", + "attributes": { + "primary": { + "type": "Bool", + "required": false + }, + "type": { + "type": "String" + }, + "value": { + "type": "String" + } + } + } + } + } + }, + "memberOfTypes": [ + "UserGroup" + ] + }, + "UserGroup": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Photo": { + "shape": { + "type": "Record", + "attributes": { + "account": { + "type": "Entity", + "name": "Account", + "required": true + }, + "private": { + "type": "Boolean", + "required": true + } + } + }, + "memberOfTypes": [ + "Album", + "Account" + ] + }, + "Album": { + "shape": { + "type": "Record", + "attributes": {} + } + }, + "Account": { + "shape": { + "type": "Record", + "attributes": {} + } + } + }, + "actions": { + "viewPhoto": { + "appliesTo": { + "principalTypes": [ + "User", + "UserGroup" + ], + "resourceTypes": [ + "Photo" + ], + "context": { + "type": "ContextType" + } + } + }, + "createPhoto": { + "appliesTo": { + "principalTypes": [ + "User", + "UserGroup" + ], + "resourceTypes": [ + "Photo" + ], + "context": { + "type": "ContextType" + } + } + }, + "listPhotos": { + "appliesTo": { + "principalTypes": [ + "User", + "UserGroup" + ], + "resourceTypes": [ + "Photo" + ], + "context": { + "type": "ContextType" + } + } + } + } + } +} \ No newline at end of file diff --git a/pkg/hexapolicy/conditions/conditions.go b/pkg/hexapolicy/conditions/conditions.go index 5672d58..e27a75b 100644 --- a/pkg/hexapolicy/conditions/conditions.go +++ b/pkg/hexapolicy/conditions/conditions.go @@ -21,6 +21,10 @@ type ConditionInfo struct { Action string `json:"Action,omitempty"` // allow/deny/audit default is allow } +func (c *ConditionInfo) Ast() (conditionparser.Expression, error) { + return conditionparser.ParseFilter(c.Rule) +} + // Equals performs an AST level compare to test filters are equivalent. NOTE: does not test equivalent attribute expressions at this time // e.g. level < 5 vs. not(level >= 5) will return as unequal though logically equal. So while a true is always correct, some equivalent expressions will report false func (c *ConditionInfo) Equals(compare *ConditionInfo) bool { @@ -201,30 +205,58 @@ func compareWalkRecursively(e conditionparser.Expression, ch chan string) { return } -func FindEntities(ast conditionparser.Expression) []types.Entity { - var ret []types.Entity +// FindEntityUses returns all AttributeExpression or ValuePathExpression elements where one or more of the operands +// is an Entity that can be validated against schema. +func FindEntityUses(ast conditionparser.Expression) []conditionparser.Expression { + var ret []conditionparser.Expression switch exp := ast.(type) { case conditionparser.PrecedenceExpression: - entities := FindEntities(exp.Expression) + entities := FindEntityUses(exp.Expression) ret = append(ret, entities...) case conditionparser.LogicalExpression: - left := FindEntities(exp.Left) - right := FindEntities(exp.Right) + left := FindEntityUses(exp.Left) + right := FindEntityUses(exp.Right) ret = append(ret, left...) ret = append(ret, right...) case conditionparser.NotExpression: - ret = append(ret, FindEntities(exp.Expression)...) + ret = append(ret, FindEntityUses(exp.Expression)...) case conditionparser.AttributeExpression: value := exp.AttributePath - if value != nil && value.OperandType() == types.RelTypeVariable { - ret = append(ret, value.(types.Entity)) - } - value = exp.CompareValue - if value != nil && value.OperandType() == types.RelTypeVariable { - ret = append(ret, value.(types.Entity)) + compValue := exp.CompareValue + if (value != nil && value.OperandType() == types.TypeVariable) || + (compValue != nil && compValue.OperandType() == types.TypeVariable) { + ret = append(ret, exp) } case conditionparser.ValuePathExpression: - ret = append(ret, exp.Attribute) + ret = append(ret, exp) + } + return ret +} + +func FindEntities(ast conditionparser.Expression) []types.Entity { + var ret []types.Entity + exps := FindEntityUses(ast) + for _, exp := range exps { + switch v := exp.(type) { + case conditionparser.AttributeExpression: + if v.AttributePath != nil && v.AttributePath.OperandType() == types.TypeVariable { + ret = append(ret, v.AttributePath.(types.Entity)) + } + + // todo Need to add the sub-attributes here (e.g. emails[type eq \"work\"]) + + if v.CompareValue != nil && v.CompareValue.OperandType() == types.TypeVariable { + ret = append(ret, v.CompareValue.(types.Entity)) + } + case conditionparser.ValuePathExpression: + if v.Attribute.OperandType() == types.TypeVariable { + ret = append(ret, v.Attribute) + + } + if v.CompareValue != nil && v.CompareValue.OperandType() == types.TypeVariable { + ret = append(ret, v.CompareValue.(types.Entity)) + } + } } return ret } diff --git a/pkg/hexapolicy/conditions/conditions_test.go b/pkg/hexapolicy/conditions/conditions_test.go index 60abc82..cdb35b0 100644 --- a/pkg/hexapolicy/conditions/conditions_test.go +++ b/pkg/hexapolicy/conditions/conditions_test.go @@ -160,34 +160,40 @@ func TestEquals(t *testing.T) { func TestFindEntities(t *testing.T) { tests := []struct { - Name string - Filter string - WantCount int + Name string + Filter string + EntityCount int + ExpCount int }{ { "Multi logic", "(level gt 5 or test eq \"abc\" or level lt 10) and (username sw \"emp\" or username eq \"guest\")", 5, + 5, }, { "Multi Entity expression", "level gt account.number", 2, + 1, }, { "No entities", "5 gt 2", 0, + 0, }, { "Not expression", "not(name.surname eq \"smith\")", 1, + 1, }, { "Value Path", "mail[type eq \"work\"] ew \"@example.com\"", 1, + 1, }, } @@ -197,12 +203,14 @@ func TestFindEntities(t *testing.T) { ast, err := conditions.ParseExpressionAst(test.Filter) assert.NoErrorf(t, err, "error parsing expression") + exps := conditions.FindEntityUses(ast) + assert.Equal(t, test.ExpCount, len(exps)) entities := conditions.FindEntities(ast) - if test.WantCount == 0 { + if test.EntityCount == 0 { assert.Nil(t, entities, "expected no entities") } else { - assert.Equal(t, test.WantCount, len(entities), "Check expected result matches: %d", test.WantCount) + assert.Equal(t, test.EntityCount, len(entities), "Check expected result matches: %d", test.EntityCount) } }) diff --git a/pkg/hexapolicy/conditions/parser/parser.go b/pkg/hexapolicy/conditions/parser/parser.go index aca827b..8b3507c 100644 --- a/pkg/hexapolicy/conditions/parser/parser.go +++ b/pkg/hexapolicy/conditions/parser/parser.go @@ -127,7 +127,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { break } if valPathCnt >= 1 { - return nil, errors.New("invalid IDQL idqlCondition: A second '[' was detected while looking for a ']' in a value path idqlCondition") + return nil, errors.New("invalid condition: A second '[' was detected while looking for a ']' in a value path idqlCondition") } valPathCnt++ break @@ -178,7 +178,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { } } if charPos == len(expression) && valPathCnt > 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing close ']' bracket") + return nil, errors.New("invalid condition: Missing close ']' bracket") } break @@ -221,7 +221,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { if isValue { value = phrase if strings.HasSuffix(value, ")") && bracketCount == 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing open '(' bracket") + return nil, errors.New("invalid condition: Missing open '(' bracket") } /* if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { @@ -258,7 +258,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { break } if bracketCount == 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing open '(' bracket") + return nil, errors.New("invalid condition: Missing open '(' bracket") } break case ']': @@ -266,7 +266,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { break } if valPathCnt == 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing open '[' bracket") + return nil, errors.New("invalid condition: Missing open '[' bracket") } case 'n', 'N': if !isValue { @@ -334,10 +334,10 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { } if bracketCount > 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing close ')' bracket") + return nil, errors.New("invalid condition: Missing close ')' bracket") } if valPathCnt > 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing ']' bracket") + return nil, errors.New("invalid condition: Missing ']' bracket") } if wordIndex > -1 && charPos == len(expression) { filterAttr := attr @@ -345,12 +345,12 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { filterAttr = parentAttr + "." + attr } if filterAttr == "" { - return nil, errors.New("invalid IDQL idqlCondition: Incomplete expression") + return nil, errors.New("invalid condition: Incomplete expression") } if isAttr && cond != "" { value = expression[wordIndex:] if strings.HasSuffix(value, ")") && bracketCount == 0 { - return nil, errors.New("invalid IDQL idqlCondition: Missing open '(' bracket") + return nil, errors.New("invalid condition: Missing open '(' bracket") } /* No need to remote quotes if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { @@ -402,7 +402,7 @@ func parseFilterSub(expression string, parentAttr string) (Expression, error) { return clauses[0], nil } - return nil, errors.New("invalid IDQL idqlCondition: Missing and/or clause") + return nil, errors.New("invalid condition: Missing and/or clause") } func createExpression(attribute string, cond string, value string, vpe *ValuePathExpression) (Expression, error) { @@ -426,6 +426,6 @@ func createExpression(attribute string, cond string, value string, vpe *ValuePat return NewAttributeExpression(attribute, CompareOperator(lCond), value) default: - return nil, errors.New("invalid IDQL idqlCondition: Unsupported comparison operator: " + cond) + return nil, errors.New("invalid condition: Unsupported comparison operator: " + cond) } } diff --git a/pkg/hexapolicy/conditions/parser/parser_test.go b/pkg/hexapolicy/conditions/parser/parser_test.go index 82302b0..a413384 100644 --- a/pkg/hexapolicy/conditions/parser/parser_test.go +++ b/pkg/hexapolicy/conditions/parser/parser_test.go @@ -65,7 +65,7 @@ func TestNegParseTests(t *testing.T) { ast, err := ParseFilter("username == blah") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Unsupported comparison operator: ==") + assert.EqualError(t, err, "invalid condition: Unsupported comparison operator: ==") } assert.Nil(t, ast, "No idqlCondition should be parsed") @@ -73,7 +73,7 @@ func TestNegParseTests(t *testing.T) { ast, err = ParseFilter("((username pr or quota eq 0) and black eq white") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing close ')' bracket") + assert.EqualError(t, err, "invalid condition: Missing close ')' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") @@ -81,42 +81,42 @@ func TestNegParseTests(t *testing.T) { ast, err = ParseFilter("username pr or quota eq \"none\") and black eq white") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing open '(' bracket") + assert.EqualError(t, err, "invalid condition: Missing open '(' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("username eq \"none\")") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing open '(' bracket") + assert.EqualError(t, err, "invalid condition: Missing open '(' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("username eq \"none\" and") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Incomplete expression") + assert.EqualError(t, err, "invalid condition: Incomplete expression") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("username eq \"none\" or abc") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Incomplete expression") + assert.EqualError(t, err, "invalid condition: Incomplete expression") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("emails[type eq work and value ew \"hexa.org\"") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing close ']' bracket") + assert.EqualError(t, err, "invalid condition: Missing close ']' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("emails[type[sub eq val] eq work and value ew \"hexa.org\"") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: A second '[' was detected while looking for a ']' in a value path idqlCondition") + assert.EqualError(t, err, "invalid condition: A second '[' was detected while looking for a ']' in a value path idqlCondition") } assert.Nil(t, ast, "No idqlCondition should be parsed") @@ -124,28 +124,28 @@ func TestNegParseTests(t *testing.T) { ast, err = ParseFilter("(username == \"malformed\")") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Unsupported comparison operator: ==") + assert.EqualError(t, err, "invalid condition: Unsupported comparison operator: ==") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("emails.type] eq work") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing open '[' bracket") + assert.EqualError(t, err, "invalid condition: Missing open '[' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("emails.type) eq work and a eq b") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Missing open '(' bracket") + assert.EqualError(t, err, "invalid condition: Missing open '(' bracket") } assert.Nil(t, ast, "No idqlCondition should be parsed") ast, err = ParseFilter("emails[type == work] and a eq b") if err != nil { fmt.Println(err.Error()) - assert.EqualError(t, err, "invalid IDQL idqlCondition: Unsupported comparison operator: ==") + assert.EqualError(t, err, "invalid condition: Unsupported comparison operator: ==") } assert.Nil(t, ast, "No idqlCondition should be parsed") } diff --git a/pkg/hexapolicy/conditions/parser/types.go b/pkg/hexapolicy/conditions/parser/types.go index e7aeafe..0b5f1f6 100644 --- a/pkg/hexapolicy/conditions/parser/types.go +++ b/pkg/hexapolicy/conditions/parser/types.go @@ -127,10 +127,10 @@ func NewAttributeExpression(attributePath string, operator CompareOperator, comp // attribute is detected. If detected, the parsed Entity value is returned for the operand func (e AttributeExpression) GetEntityPaths() []types.Entity { var paths []types.Entity - if e.AttributePath.OperandType() == types.RelTypeVariable { + if e.AttributePath.OperandType() == types.TypeVariable { paths = append(paths, e.AttributePath.(types.Entity)) } - if e.CompareValue.OperandType() == types.RelTypeVariable { + if e.CompareValue.OperandType() == types.TypeVariable { paths = append(paths, e.CompareValue.(types.Entity)) } return paths @@ -166,7 +166,7 @@ func (e ValuePathExpression) GetEntityPaths() []types.Entity { paths = append(paths, e.Attribute) - if e.CompareValue.OperandType() == types.RelTypeVariable { + if e.CompareValue.OperandType() == types.TypeVariable { paths = append(paths, e.CompareValue.(types.Entity)) } return paths diff --git a/pkg/hexapolicy/pimValidate/validate.go b/pkg/hexapolicy/pimValidate/validate.go index 9cdf5bf..323bbe7 100644 --- a/pkg/hexapolicy/pimValidate/validate.go +++ b/pkg/hexapolicy/pimValidate/validate.go @@ -3,10 +3,13 @@ package pimValidate import ( "errors" "fmt" + "slices" "strings" "github.com/hexa-org/policy-mapper/models/policyInfoModel" "github.com/hexa-org/policy-mapper/pkg/hexapolicy" + "github.com/hexa-org/policy-mapper/pkg/hexapolicy/conditions" + "github.com/hexa-org/policy-mapper/pkg/hexapolicy/conditions/parser" "github.com/hexa-org/policy-mapper/pkg/hexapolicy/types" ) @@ -48,6 +51,11 @@ func (v *Validator) ValidatePolicy(policy hexapolicy.PolicyInfo) []error { errs = append(errs, tErrs...) } + tErrs = v.checkConditions(policy) + if tErrs != nil { + errs = append(errs, tErrs...) + } + return errs } @@ -86,7 +94,7 @@ func (v *Validator) checkSubject(subject hexapolicy.SubjectInfo) []error { entityType := entity.GetType() schema, ok := v.namespaces[namespace] if !ok { - errs = append(errs, errors.New(fmt.Sprintf("invalid subject PIM namespace (%s)", namespace))) + errs = append(errs, errors.New(fmt.Sprintf("invalid subject namespace \"%s\"", namespace))) continue } _, ok = schema.EntityTypes[entityType] @@ -108,7 +116,7 @@ func (v *Validator) checkObject(resource hexapolicy.ObjectInfo) error { entityType := entity.GetType() schema, ok := v.namespaces[namespace] if !ok { - return errors.New(fmt.Sprintf("invalid object PIM namespace (%s)", namespace)) + return errors.New(fmt.Sprintf("invalid object namespace \"%s\"", namespace)) } if entityType == "" { return errors.New(fmt.Sprintf("missing object entity type: %s::%s", namespace, *entity.Id)) @@ -128,13 +136,13 @@ func (v *Validator) checkAction(policy hexapolicy.PolicyInfo) []error { namespace := entity.GetNamespace(v.defNamespace) schema, ok := v.namespaces[namespace] if !ok { - errs = append(errs, errors.New(fmt.Sprintf("invalid PIM namespace (%s)", namespace))) + errs = append(errs, errors.New(fmt.Sprintf("invalid namespace \"%s\"", namespace))) continue } // Check that the action exists: - actionType, ok := schema.Actions[*entity.Id] + actionType, ok := schema.Actions[entity.GetId()] if !ok { - errs = append(errs, errors.New(fmt.Sprintf("invalid action type: %s:Action:%s", namespace, *entity.Id))) + errs = append(errs, errors.New(fmt.Sprintf("invalid action \"%s\"", entity.String()))) continue } @@ -146,6 +154,125 @@ func (v *Validator) checkAction(policy hexapolicy.PolicyInfo) []error { return errs } +var specialEntities []string = []string{"subject", "action", "resource", "principal"} + +func (v *Validator) checkOperand(operand types.Value) (string, error) { + switch value := operand.(type) { + case types.Entity: + namespace := value.GetNamespace(v.defNamespace) + schema, ok := v.namespaces[namespace] + if !ok { + return "error", errors.New(fmt.Sprintf("invalid namespace \"%s\" for %s", namespace, value.String())) + } + + eTypeId := value.GetType() + if !slices.Contains(specialEntities, strings.ToLower(*value.Id)) { // checks for subject, principal, action, resource + _, ok = schema.EntityTypes[eTypeId] + if !ok { + return "error", errors.New(fmt.Sprintf("invalid condition entity type: %s", value.String())) + } + + if value.IsPath() { + attr := schema.FindAttrType(value) + if attr == nil { + return "error", errors.New(fmt.Sprintf("invalid condition attribute: %s", value.String())) + } + return attr.Type, nil + } + } + return policyInfoModel.TypeRecord, nil + + case types.String: + return policyInfoModel.TypeString, nil + case types.Boolean: + return policyInfoModel.TypeBool, nil + case types.Date: + return policyInfoModel.TypeDate, nil + case types.Numeric: + return policyInfoModel.TypeLong, nil + } + return "error", errors.New("invalid operand") +} + +func (v *Validator) checkExpression(expression parser.Expression) []error { + var errs []error + switch exp := expression.(type) { + case parser.AttributeExpression: + lType, err := v.checkOperand(exp.AttributePath) + if err != nil { + errs = append(errs, err) + } + rType := "na" + if exp.CompareValue != nil { + rType, err = v.checkOperand(exp.CompareValue) + if err != nil { + errs = append(errs, err) + } + } + if errs != nil { + break + } + + switch exp.Operator { + case parser.PR: + // do nothing + case parser.EQ, parser.NE, parser.GT, parser.GE, parser.LT, parser.LE: + // can only compare like types + if !strings.EqualFold(lType, rType) { + errs = append(errs, errors.New(fmt.Sprintf("expression \"%s\" has mis-matched attribute types: %s and %s", expression.String(), lType, rType))) + } + case parser.SW, parser.EW: + if !strings.EqualFold(lType, policyInfoModel.TypeString) { + errs = append(errs, errors.New(fmt.Sprintf("expression \"%s\" requires String comparators (%s is %s)", expression.String(), exp.AttributePath.String(), lType))) + } + if !strings.EqualFold(rType, policyInfoModel.TypeString) { + errs = append(errs, errors.New(fmt.Sprintf("expression \"%s\" requires String comparators (%s is %s)", expression.String(), exp.CompareValue.String(), rType))) + } + case parser.CO, parser.IN: + if !strings.EqualFold(lType, policyInfoModel.TypeRecord) && !strings.EqualFold(rType, policyInfoModel.TypeString) { + errs = append(errs, errors.New(fmt.Sprintf("expression \"%s\" requires an Entity or String comparator (%s is %s)", expression.String(), exp.AttributePath.String(), lType))) + } + if !strings.EqualFold(rType, policyInfoModel.TypeRecord) && !strings.EqualFold(lType, policyInfoModel.TypeString) { + errs = append(errs, errors.New(fmt.Sprintf("expression \"%s\" requires an Entity or String comparator (%s is %s)", expression.String(), exp.CompareValue.String(), rType))) + } + + } + + case parser.ValuePathExpression: + // TODO Need to verify + // 1. Main attribute is valid + // 2. Each attribute in the filter is valid + // 3. if specified, the sub-attribute is valid + // 4. The comparison is valid. + errs = append(errs, errors.New(fmt.Sprintf("valuePath expressions \"%s\" are not supported", exp.String()))) + + } + return errs +} + +func (v *Validator) checkConditions(policy hexapolicy.PolicyInfo) []error { + var errs []error + if policy.Condition == nil { + return nil + } + + ast, err := policy.Condition.Ast() + if err != nil { + errs = append(errs, err) + } else { + expressions := conditions.FindEntityUses(ast) + for _, exp := range expressions { + + expressionErrs := v.checkExpression(exp) + if expressionErrs != nil { + errs = append(errs, expressionErrs...) + } + } + } + + return errs +} + func (v *Validator) checkAppliesTo(actionNamespace string, appliesTo policyInfoModel.AppliesType, policy hexapolicy.PolicyInfo) []error { var errs []error // Check Principals @@ -176,7 +303,7 @@ func (v *Validator) checkAppliesTo(actionNamespace string, appliesTo policyInfoM } } if !contains { - errs = append(errs, errors.New(fmt.Sprintf("invalid principal type (%s:%s), must be one of %+q", namespace, entity.GetType(), principals))) + errs = append(errs, errors.New(fmt.Sprintf("policy cannot be applied to subject \"%s\", must be one of %+q", entity.String(), principals))) } } @@ -203,7 +330,7 @@ func (v *Validator) checkAppliesTo(actionNamespace string, appliesTo policyInfoM } } if !contains { - errs = append(errs, errors.New(fmt.Sprintf("invalid object type (%s:%s), must be one of %+q", namespace, entity.GetType(), resTypes))) + errs = append(errs, errors.New(fmt.Sprintf("policy cannot be applied to object type \"%s\", must be one of %+q", policy.Object.String(), resTypes))) } } diff --git a/pkg/hexapolicy/pimValidate/validate_test.go b/pkg/hexapolicy/pimValidate/validate_test.go index f5dc3f2..a25afea 100644 --- a/pkg/hexapolicy/pimValidate/validate_test.go +++ b/pkg/hexapolicy/pimValidate/validate_test.go @@ -15,10 +15,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestValidate_BadSchema(t *testing.T) { + badBytes := []byte("{ unparsable}") + + validator, err := NewValidator(badBytes, "WrongApp") + assert.Error(t, err) + assert.Nil(t, validator) +} + func TestValidate_Policy(t *testing.T) { _, file, _, _ := runtime.Caller(0) testDirectory := filepath.Join(filepath.Dir(file), "../../../", "models/policyInfoModel/test") - photoSchemaFile := filepath.Join(testDirectory, "photoSchema.json") + photoSchemaFile := filepath.Join(testDirectory, "cmvSchemaTest.json") photoBytes, err := os.ReadFile(photoSchemaFile) assert.NoError(t, err) validator, err := NewValidator(photoBytes, "PhotoApp") @@ -97,8 +105,8 @@ func TestValidate_Policy(t *testing.T) { "object": "Photo:VacationPhoto.jpg" }`, wantErrs: []error{ - errors.New(fmt.Sprintf("invalid subject entity type: %s:%s", "PhotoApp", "Admins")), - errors.New(fmt.Sprintf("invalid principal type (%s:%s), must be one of %+q", "PhotoApp", "Admins", []string{"User", "UserGroup"})), + errors.New("invalid subject entity type: PhotoApp:Admins"), + errors.New("policy cannot be applied to subject \"Admins:alice\", must be one of [\"User\" \"UserGroup\"]"), }, }, { @@ -114,8 +122,8 @@ func TestValidate_Policy(t *testing.T) { "object": "VacationPhoto.jpg" }`, wantErrs: []error{ - errors.New(fmt.Sprintf("missing object entity type: %s::%s", "PhotoApp", "VacationPhoto.jpg")), - errors.New(fmt.Sprintf("invalid object type (%s:%s), must be one of %+q", "PhotoApp", "", []string{"Photo"})), + errors.New("missing object entity type: PhotoApp::VacationPhoto.jpg"), + errors.New("policy cannot be applied to object type \"VacationPhoto.jpg\", must be one of [\"Photo\"]"), }, }, // The following test confirms that the use of the type "Action" isn't actually necessary. Note if action @@ -147,10 +155,10 @@ func TestValidate_Policy(t *testing.T) { "object": "BadApp:Photo:VacationPhoto.jpg" }`, wantErrs: []error{ - errors.New(fmt.Sprintf("invalid subject PIM namespace (%s)", "BadApp")), - errors.New(fmt.Sprintf("invalid object PIM namespace (%s)", "BadApp")), - errors.New(fmt.Sprintf("invalid principal type (%s:%s), must be one of %+q", "BadApp", "User", []string{"User", "UserGroup"})), - errors.New(fmt.Sprintf("invalid object type (%s:%s), must be one of %+q", "BadApp", "Photo", []string{"Photo"})), + errors.New("invalid subject namespace \"BadApp\""), + errors.New("invalid object namespace \"BadApp\""), + errors.New("policy cannot be applied to subject \"BadApp:User:alice\", must be one of [\"User\" \"UserGroup\"]"), + errors.New("policy cannot be applied to object type \"BadApp:Photo:VacationPhoto.jpg\", must be one of [\"Photo\"]"), }, }, { @@ -166,8 +174,8 @@ func TestValidate_Policy(t *testing.T) { "object": "Photo:VacationPhoto.jpg" }`, wantErrs: []error{ - errors.New(fmt.Sprintf("invalid action type: %s:Action:%s", "PhotoApp", "badAction")), - errors.New(fmt.Sprintf("invalid PIM namespace (%s)", "BadNamespace")), + errors.New(fmt.Sprintf("invalid action \"%s\"", "Action:badAction")), + errors.New(fmt.Sprintf("invalid namespace \"%s\"", "BadNamespace")), }, }, { @@ -184,6 +192,253 @@ func TestValidate_Policy(t *testing.T) { }`, wantErrs: nil, }, + {name: "Condition ", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "PhotoApp:User:name pr and User:userId ew \"Emp\"", + "action": "allow" + } +}`, + wantErrs: nil, + }, + {name: "Condition Parse Error", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "PhotoApp:User:name and User:userId ew \"Emp\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("invalid condition: Unsupported comparison operator: User:userId"), + }, + }, + {name: "Condition Bad Type", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:userId gt 1234", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("expression \"User:userId gt 1234\" has mis-matched attribute types: String and Long"), + }, + }, + {name: "Bad Condition Namespace", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "Todo:User:userId gt \"a\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("invalid namespace \"Todo\" for Todo:User:userId"), + }, + }, + {name: "Bad Condition Entity", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "BadUser:userId gt \"a\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("invalid condition entity type: BadUser:userId"), + }, + }, + {name: "Undefined attribute", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:badAttr gt \"a\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("invalid condition attribute: User:badAttr"), + }, + }, + {name: "Date and bool", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:lastReviewed gt 1985-04-12T23:20:50.52Z and User:isEmployee eq true", + "action": "allow" + } +}`, + wantErrs: nil, + }, + {name: "Quoted Date", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:lastReviewed gt \"1985-04-12T23:20:50.52Z\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("expression \"User:lastReviewed gt \"1985-04-12T23:20:50.52Z\"\" has mis-matched attribute types: Date and String"), + }, + }, + {name: "SW-EW non-string", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:lastReviewed sw \"1985\" and User:userId ew 123", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("expression \"User:lastReviewed sw \"1985\"\" requires String comparators (User:lastReviewed is Date)"), + errors.New("expression \"User:userId ew 123\" requires String comparators (123 is Long)"), + }, + }, + {name: "IN-CO non-string", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:lastReviewed in UserGroup:\"abc\" and UserGroup:\"admins\" co 123", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("expression \"User:lastReviewed in UserGroup:\"abc\"\" requires an Entity or String comparator (User:lastReviewed is Date)"), + errors.New("expression \"UserGroup:\"admins\" co 123\" requires an Entity or String comparator (123 is Long)"), + }, + }, + {name: "ValuePath", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "Photo:VacationPhoto.jpg", + "condition": { + "rule": "User:emails[type eq \"work\"].value ew \"@example.com\"", + "action": "allow" + } +}`, + wantErrs: []error{ + errors.New("valuePath expressions \"User:emails[type eq \"work\"].value ew \"@example.com\"\" are not supported"), + }, + }, + {name: "Invalid Object Type", + idql: `{ + "meta": { + "version": "0.7" + }, + "subjects": [ + "User:alice" + ], + "actions": [ + "Action:viewPhoto" + ], + "object": "BadPhoto:VacationPhoto.jpg" +}`, + wantErrs: []error{ + errors.New("invalid object entity type: PhotoApp:BadPhoto"), + errors.New("policy cannot be applied to object type \"BadPhoto:VacationPhoto.jpg\", must be one of [\"Photo\"]"), + }, + }, } for _, tt := range tests { @@ -192,6 +447,8 @@ func TestValidate_Policy(t *testing.T) { if err := json.Unmarshal([]byte(tt.idql), &policy); err != nil { assert.Fail(t, err.Error()) } + assert.NotNil(t, policy, "Check test policy was parsed") + assert.NotNil(t, policy.Subjects, "Test policy should have subjects if valid") errs := validator.ValidatePolicy(policy) if tt.wantErrs == nil { if errs != nil { @@ -218,6 +475,9 @@ func TestPolicySet(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, idql) + testId := "TestPolicy0" + idql.Policies[0].Meta.PolicyId = &testId + photoSchemaFile := filepath.Join(testDirectory, "photoSchema.json") photoSchemaBytes, err := os.ReadFile(photoSchemaFile) assert.NoError(t, err) @@ -233,7 +493,7 @@ func TestPolicySet(t *testing.T) { report = validator.ValidatePolicies(*idql) assert.NotNil(t, report) - perrs, ok := report["Policy-0"] + perrs, ok := report["Policy-"+testId] assert.True(t, ok) assert.Equal(t, 2, len(perrs)) } diff --git a/pkg/hexapolicy/types/boolean.go b/pkg/hexapolicy/types/boolean.go index c73f0b3..dcfa2a5 100644 --- a/pkg/hexapolicy/types/boolean.go +++ b/pkg/hexapolicy/types/boolean.go @@ -14,7 +14,7 @@ func NewBoolean(value string) Value { } func (e Boolean) OperandType() int { - return RelTypeBool + return TypeBool } func (e Boolean) Value() interface{} { diff --git a/pkg/hexapolicy/types/date.go b/pkg/hexapolicy/types/date.go index f9b1390..5c5527a 100644 --- a/pkg/hexapolicy/types/date.go +++ b/pkg/hexapolicy/types/date.go @@ -15,7 +15,7 @@ func NewDate(value string) (Value, error) { } func (d Date) OperandType() int { - return RelTypeDate + return TypeDate } func (d Date) Value() interface{} { diff --git a/pkg/hexapolicy/types/entity.go b/pkg/hexapolicy/types/entity.go index e9b13e5..5c7bc75 100644 --- a/pkg/hexapolicy/types/entity.go +++ b/pkg/hexapolicy/types/entity.go @@ -5,6 +5,7 @@ package types import ( "fmt" + "strconv" "strings" ) @@ -28,7 +29,7 @@ type Entity struct { } func (e Entity) OperandType() int { - return RelTypeVariable + return TypeVariable } // ParseEntity takes a string value from an IDQL Subject, Action, or Object parses @@ -137,6 +138,24 @@ func ParseEntity(value string) *Entity { } } +// IsPath returns true if the id is unquoted. A quoted item is considered an entity Id +func (e Entity) IsPath() bool { + return !strings.HasPrefix(*e.Id, "\"") +} + +// GetId returns the main id with quotes removed +func (e Entity) GetId() string { + if e.Id == nil { + return "" + } + val := *e.Id + if !strings.HasPrefix(val, "\"") { + return val + } + ret, _ := strconv.Unquote(*e.Id) + return ret +} + func (e Entity) String() string { switch e.Type { case RelTypeAny: diff --git a/pkg/hexapolicy/types/numeric.go b/pkg/hexapolicy/types/numeric.go index 7347991..86c4fc8 100644 --- a/pkg/hexapolicy/types/numeric.go +++ b/pkg/hexapolicy/types/numeric.go @@ -26,5 +26,5 @@ func (n Numeric) String() string { } func (n Numeric) OperandType() int { - return RelTypeNumber + return TypeNumber } diff --git a/pkg/hexapolicy/types/string.go b/pkg/hexapolicy/types/string.go index 61dfbf7..7d42da2 100644 --- a/pkg/hexapolicy/types/string.go +++ b/pkg/hexapolicy/types/string.go @@ -17,7 +17,7 @@ func NewString(value string) Value { } func (s String) OperandType() int { - return RelTypeString + return TypeString } func (s String) Value() interface{} { diff --git a/pkg/hexapolicy/types/types_test.go b/pkg/hexapolicy/types/types_test.go index ff8329f..9cbb96f 100644 --- a/pkg/hexapolicy/types/types_test.go +++ b/pkg/hexapolicy/types/types_test.go @@ -14,7 +14,7 @@ func TestBoolean(t *testing.T) { assert.IsType(t, Boolean{}, value) - assert.Equal(t, RelTypeBool, value.OperandType()) + assert.Equal(t, TypeBool, value.OperandType()) assert.Equal(t, true, value.Value()) assert.Equal(t, "true", value.String()) @@ -25,7 +25,7 @@ func TestString(t *testing.T) { assert.NoError(t, err) assert.IsType(t, String{}, value) - assert.Equal(t, RelTypeString, value.OperandType()) + assert.Equal(t, TypeString, value.OperandType()) assert.Equal(t, "1234 quick brown fox", value.Value()) assert.Equal(t, "\"1234 quick brown fox\"", value.String()) } @@ -34,7 +34,7 @@ func TestNumeric(t *testing.T) { value, err := ParseValue("365") assert.NoError(t, err) assert.IsType(t, Numeric{}, value) - assert.Equal(t, RelTypeNumber, value.OperandType()) + assert.Equal(t, TypeNumber, value.OperandType()) assert.Equal(t, float64(365), value.Value()) assert.Equal(t, "365", value.String()) @@ -49,7 +49,7 @@ func TestDate(t *testing.T) { value, err := ParseValue("2011-05-13T04:42:34Z") assert.NoError(t, err) assert.IsType(t, Date{}, value) - assert.Equal(t, RelTypeDate, value.OperandType()) + assert.Equal(t, TypeDate, value.OperandType()) assert.Equal(t, "2011-05-13T04:42:34Z", value.String()) date, _ := time.Parse(time.RFC3339, "2011-05-13T04:42:34Z") assert.Equal(t, date, value.Value()) @@ -59,7 +59,7 @@ func TestEntity(t *testing.T) { value, err := ParseValue("user:name.surname") assert.NoError(t, err) assert.IsType(t, Entity{}, value) - assert.Equal(t, RelTypeVariable, value.OperandType()) + assert.Equal(t, TypeVariable, value.OperandType()) assert.Equal(t, "user:name.surname", value.String()) assert.Equal(t, "user", value.(Entity).GetType()) } diff --git a/pkg/hexapolicy/types/value.go b/pkg/hexapolicy/types/value.go index 622f266..332f90d 100644 --- a/pkg/hexapolicy/types/value.go +++ b/pkg/hexapolicy/types/value.go @@ -8,11 +8,11 @@ import ( ) const ( - RelTypeVariable = iota - RelTypeString - RelTypeNumber - RelTypeDate - RelTypeBool + TypeVariable = iota + TypeString + TypeNumber + TypeDate + TypeBool ) type Value interface {