diff --git a/assets/example-shapes/banking-system.yaml b/assets/example-shapes/banking-system.yaml index 50207295a..b1eed9139 100644 --- a/assets/example-shapes/banking-system.yaml +++ b/assets/example-shapes/banking-system.yaml @@ -5,7 +5,7 @@ schema: |- // Defines a relation where an account has an owner of type 'user'. relation owner @user - + // Attribute to store the balance information of the account. attribute balance integer @@ -53,4 +53,18 @@ scenarios: data: amount: 3000 assertions: - withdraw: false \ No newline at end of file + withdraw: false + - name: "Account 2 Owner Withdrawal with Scenario-Specific Attributes" + description: "Tests 'steven' can withdraw from 'account:2' using scenario-specific balance attribute." + attributes: + - account:2$balance|integer:6000 + checks: + - entity: "account:2" + subject: "user:steven" + context: + tuples: [] + attributes: [] + data: + amount: 2000 + assertions: + withdraw: true diff --git a/assets/example-shapes/custom-roles.yaml b/assets/example-shapes/custom-roles.yaml index 85b76bcf9..225387772 100644 --- a/assets/example-shapes/custom-roles.yaml +++ b/assets/example-shapes/custom-roles.yaml @@ -24,12 +24,9 @@ relationships: - dashboard:project-progress#view@role:admin#assignee - dashboard:project-progress#view@role:member#assignee - dashboard:project-progress#edit@role:admin#assignee - - task:website-design-review#view@role:admin#assignee - - task:website-design-review#view@role:member#assignee - - task:website-design-review#edit@role:admin#assignee - role:member#assignee@user:1 -attributes: +attributes: scenarios: - name: "User Dashboard View Permissions for project-progress" @@ -41,6 +38,10 @@ scenarios: view: true - name: "Role-Based Permissions for 'website-design-review' Task" description: "Evaluates the access rights for 'website-design-review' task based on roles. The admin role should have both view and edit permissions, whereas the member role should only have view permission." + relationships: + - task:website-design-review#view@role:admin#assignee + - task:website-design-review#view@role:member#assignee + - task:website-design-review#edit@role:admin#assignee checks: - entity: "task:website-design-review" subject: "role:admin#assignee" @@ -51,4 +52,4 @@ scenarios: subject: "role:member#assignee" assertions: view: true - edit: false \ No newline at end of file + edit: false diff --git a/assets/example-shapes/organizations-hierarchies.yaml b/assets/example-shapes/organizations-hierarchies.yaml index 6b16a9594..f31590202 100644 --- a/assets/example-shapes/organizations-hierarchies.yaml +++ b/assets/example-shapes/organizations-hierarchies.yaml @@ -30,6 +30,7 @@ attributes: scenarios: - name: admin_access_test + description: "Verifies admin user can edit but not delete a repository they don't own." checks: - entity: repository:1234 subject: user:5678 @@ -45,3 +46,13 @@ scenarios: delete: false entity_filters: [] subject_filters: [] + - name: owner_access_test + description: "Verifies repository owner has full permissions using scenario-specific relationships." + relationships: + - "repository:1234#owner@user:9999" + checks: + - entity: repository:1234 + subject: user:9999 + assertions: + edit: true + delete: true diff --git a/assets/example-shapes/user-groups.yaml b/assets/example-shapes/user-groups.yaml index 5e1f4971c..bc897a0d5 100644 --- a/assets/example-shapes/user-groups.yaml +++ b/assets/example-shapes/user-groups.yaml @@ -43,8 +43,36 @@ schema: |- } relationships: + - "organization:1#admin@user:1" - "team:1#owner@user:1" + - "team:1#org@organization:1" attributes: scenarios: + - name: "Team Owner Permissions" + description: "Verifies that team owner (user:1) has edit, delete, and remove_user permissions on the team." + checks: + - entity: "team:1" + subject: "user:1" + assertions: + edit: true + delete: true + remove_user: true + - name: "Team Member Project Access" + description: "Verifies project access for a team member added via scenario-specific relationships." + relationships: + - "team:1#member@user:2" + - "project:1#team@team:1" + - "project:1#org@organization:1" + checks: + - entity: "project:1" + subject: "user:2" + assertions: + view: true + edit: true + delete: true + - entity: "project:1" + subject: "user:1" + assertions: + view: true diff --git a/pkg/cmd/validate.go b/pkg/cmd/validate.go index 3c246f99b..074e4cacc 100644 --- a/pkg/cmd/validate.go +++ b/pkg/cmd/validate.go @@ -233,6 +233,80 @@ func validate() func(cmd *cobra.Command, args []string) error { for sn, scenario := range s.Scenarios { color.Notice.Printf("%v.scenario: %s - %s\n", sn+1, scenario.Name, scenario.Description) + // Write scenario-specific relationships if any are defined + if len(scenario.Relationships) > 0 { + color.Notice.Println(" scenario relationships:") + for _, t := range scenario.Relationships { + var tup *base.Tuple + tup, err = tuple.Tuple(t) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + definition, _, err := dev.Container.SR.ReadEntityDefinition(ctx, "t1", tup.GetEntity().GetType(), version) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + err = serverValidation.ValidateTuple(definition, tup) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()) + if err != nil { + list.Add(fmt.Sprintf("%s failed %s", t, err.Error())) + color.Danger.Println(fmt.Sprintf(" fail: %s failed %s", t, validationError(err.Error()))) + continue + } + + color.Success.Println(fmt.Sprintf(" success: %s ", t)) + } + } + + // Write scenario-specific attributes if any are defined + if len(scenario.Attributes) > 0 { + color.Notice.Println(" scenario attributes:") + for _, a := range scenario.Attributes { + var attr *base.Attribute + attr, err = attribute.Attribute(a) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + definition, _, err := dev.Container.SR.ReadEntityDefinition(ctx, "t1", attr.GetEntity().GetType(), version) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + err = serverValidation.ValidateAttribute(definition, attr) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf(" fail: %s\n", validationError(err.Error())) + continue + } + + _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)) + if err != nil { + list.Add(fmt.Sprintf("%s failed %s", a, err.Error())) + color.Danger.Println(fmt.Sprintf(" fail: %s failed %s", a, validationError(err.Error()))) + continue + } + + color.Success.Println(fmt.Sprintf(" success: %s ", a)) + } + } + // Start log output for checks color.Notice.Println(" checks:") diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index def29a3a0..92d557d15 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -12,7 +12,6 @@ import ( "github.com/Permify/permify/pkg/tuple" ) -// SchemaCoverageInfo represents the overall coverage information for a schema type SchemaCoverageInfo struct { EntityCoverageInfo []EntityCoverageInfo TotalRelationshipsCoverage int @@ -20,7 +19,6 @@ type SchemaCoverageInfo struct { TotalAssertionsCoverage int } -// EntityCoverageInfo represents coverage information for a single entity type EntityCoverageInfo struct { EntityName string @@ -32,36 +30,23 @@ type EntityCoverageInfo struct { UncoveredAssertions map[string][]string CoverageAssertionsPercent map[string]int + + PermissionConditionCoverage map[string]map[string]*ConditionCoverageInfo +} + +type ConditionCoverageInfo struct { + PermissionName string + AllComponents []ConditionComponent + CoveredComponents []ConditionComponent + UncoveredComponents []ConditionComponent + CoveragePercent int +} + +type ConditionComponent struct { + Name string + Type string } -// SchemaCoverage represents the expected coverage for a schema entity -// -// Example schema: -// -// entity user {} -// -// entity organization { -// relation admin @user -// relation member @user -// } -// -// entity repository { -// relation parent @organization -// relation owner @user @organization#admin -// permission edit = parent.admin or owner -// permission delete = owner -// } -// -// Expected relationships coverage: -// - organization#admin@user -// - organization#member@user -// - repository#parent@organization -// - repository#owner@user -// - repository#owner@organization#admin -// -// Expected assertions coverage: -// - repository#edit -// - repository#delete type SchemaCoverage struct { EntityName string Relationships []string @@ -69,36 +54,28 @@ type SchemaCoverage struct { Assertions []string } -// Run analyzes the coverage of relationships, attributes, and assertions -// for a given schema shape and returns the coverage information func Run(shape file.Shape) SchemaCoverageInfo { definitions, err := parseAndCompileSchema(shape.Schema) if err != nil { return SchemaCoverageInfo{} } - refs := extractSchemaReferences(definitions) - entityCoverageInfos := calculateEntityCoverages(refs, shape) - + entityCoverageInfos := calculateEntityCoverages(refs, shape, definitions) return buildSchemaCoverageInfo(entityCoverageInfos) } -// parseAndCompileSchema parses and compiles the schema into entity definitions func parseAndCompileSchema(schema string) ([]*base.EntityDefinition, error) { p, err := parser.NewParser(schema).Parse() if err != nil { return nil, err } - definitions, _, err := compiler.NewCompiler(true, p).Compile() if err != nil { return nil, err } - return definitions, nil } -// extractSchemaReferences extracts all coverage references from entity definitions func extractSchemaReferences(definitions []*base.EntityDefinition) []SchemaCoverage { refs := make([]SchemaCoverage, len(definitions)) for idx, entityDef := range definitions { @@ -107,178 +84,144 @@ func extractSchemaReferences(definitions []*base.EntityDefinition) []SchemaCover return refs } -// extractEntityReferences extracts relationships, attributes, and assertions from an entity definition func extractEntityReferences(entity *base.EntityDefinition) SchemaCoverage { - coverage := SchemaCoverage{ + return SchemaCoverage{ EntityName: entity.GetName(), Relationships: extractRelationships(entity), Attributes: extractAttributes(entity), Assertions: extractAssertions(entity), } - return coverage } -// extractRelationships extracts all relationship references from an entity func extractRelationships(entity *base.EntityDefinition) []string { relationships := []string{} - for _, relation := range entity.GetRelations() { for _, reference := range relation.GetRelationReferences() { - formatted := formatRelationship( - entity.GetName(), - relation.GetName(), - reference.GetType(), - reference.GetRelation(), - ) + formatted := formatRelationship(entity.GetName(), relation.GetName(), reference.GetType(), reference.GetRelation()) relationships = append(relationships, formatted) } } - return relationships } -// extractAttributes extracts all attribute references from an entity func extractAttributes(entity *base.EntityDefinition) []string { attributes := []string{} - for _, attr := range entity.GetAttributes() { formatted := formatAttribute(entity.GetName(), attr.GetName()) attributes = append(attributes, formatted) } - return attributes } -// extractAssertions extracts all permission/assertion references from an entity func extractAssertions(entity *base.EntityDefinition) []string { assertions := []string{} - for _, permission := range entity.GetPermissions() { formatted := formatAssertion(entity.GetName(), permission.GetName()) assertions = append(assertions, formatted) } - return assertions } -// calculateEntityCoverages calculates coverage for all entities -func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape) []EntityCoverageInfo { +func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape, definitions []*base.EntityDefinition) []EntityCoverageInfo { entityCoverageInfos := []EntityCoverageInfo{} - + defMap := make(map[string]*base.EntityDefinition, len(definitions)) + for _, def := range definitions { + defMap[def.GetName()] = def + } for _, ref := range refs { - entityCoverageInfo := calculateEntityCoverage(ref, shape) + entityCoverageInfo := calculateEntityCoverage(ref, shape, defMap[ref.EntityName]) entityCoverageInfos = append(entityCoverageInfos, entityCoverageInfo) } - return entityCoverageInfos } -// calculateEntityCoverage calculates coverage for a single entity -func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverageInfo { +func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape, entityDef *base.EntityDefinition) EntityCoverageInfo { entityCoverageInfo := newEntityCoverageInfo(ref.EntityName) - // Calculate relationships coverage - entityCoverageInfo.UncoveredRelationships = findUncoveredRelationships( - ref.EntityName, - ref.Relationships, - shape.Relationships, - ) - entityCoverageInfo.CoverageRelationshipsPercent = calculateCoveragePercent( - ref.Relationships, - entityCoverageInfo.UncoveredRelationships, - ) - - // Calculate attributes coverage - entityCoverageInfo.UncoveredAttributes = findUncoveredAttributes( - ref.EntityName, - ref.Attributes, - shape.Attributes, - ) - entityCoverageInfo.CoverageAttributesPercent = calculateCoveragePercent( - ref.Attributes, - entityCoverageInfo.UncoveredAttributes, - ) - - // Calculate assertions coverage for each scenario + // Collect all relationships: global + scenario-specific + allRelationships := make([]string, len(shape.Relationships)) + copy(allRelationships, shape.Relationships) + for _, scenario := range shape.Scenarios { + allRelationships = append(allRelationships, scenario.Relationships...) + } + + entityCoverageInfo.UncoveredRelationships = findUncoveredRelationships(ref.EntityName, ref.Relationships, allRelationships) + entityCoverageInfo.CoverageRelationshipsPercent = calculateCoveragePercent(ref.Relationships, entityCoverageInfo.UncoveredRelationships) + + // Collect all attributes: global + scenario-specific + allAttributes := make([]string, len(shape.Attributes)) + copy(allAttributes, shape.Attributes) for _, scenario := range shape.Scenarios { - uncovered := findUncoveredAssertions( - ref.EntityName, - ref.Assertions, - scenario.Checks, - scenario.EntityFilters, - ) - // Only add to UncoveredAssertions if there are uncovered assertions + allAttributes = append(allAttributes, scenario.Attributes...) + } + + entityCoverageInfo.UncoveredAttributes = findUncoveredAttributes(ref.EntityName, ref.Attributes, allAttributes) + entityCoverageInfo.CoverageAttributesPercent = calculateCoveragePercent(ref.Attributes, entityCoverageInfo.UncoveredAttributes) + + for _, scenario := range shape.Scenarios { + uncovered := findUncoveredAssertions(ref.EntityName, ref.Assertions, scenario.Checks, scenario.EntityFilters) if len(uncovered) > 0 { entityCoverageInfo.UncoveredAssertions[scenario.Name] = uncovered } - entityCoverageInfo.CoverageAssertionsPercent[scenario.Name] = calculateCoveragePercent( - ref.Assertions, - uncovered, - ) + entityCoverageInfo.CoverageAssertionsPercent[scenario.Name] = calculateCoveragePercent(ref.Assertions, uncovered) + if entityDef != nil { + scenarioRelationships := append(shape.Relationships, scenario.Relationships...) + scenarioAttributes := append(shape.Attributes, scenario.Attributes...) + conditionCoverage := calculateConditionCoverage(ref.EntityName, entityDef, scenario, scenarioRelationships, scenarioAttributes) + if len(conditionCoverage) > 0 { + entityCoverageInfo.PermissionConditionCoverage[scenario.Name] = conditionCoverage + } + } } - return entityCoverageInfo } -// newEntityCoverageInfo creates a new EntityCoverageInfo with initialized fields func newEntityCoverageInfo(entityName string) EntityCoverageInfo { return EntityCoverageInfo{ - EntityName: entityName, - UncoveredRelationships: []string{}, - UncoveredAttributes: []string{}, - CoverageAssertionsPercent: make(map[string]int), - UncoveredAssertions: make(map[string][]string), - CoverageRelationshipsPercent: 0, - CoverageAttributesPercent: 0, + EntityName: entityName, + UncoveredRelationships: []string{}, + UncoveredAttributes: []string{}, + CoverageAssertionsPercent: make(map[string]int), + UncoveredAssertions: make(map[string][]string), + PermissionConditionCoverage: make(map[string]map[string]*ConditionCoverageInfo), } } -// findUncoveredRelationships finds relationships that are not covered in the shape func findUncoveredRelationships(entityName string, expected, actual []string) []string { covered := extractCoveredRelationships(entityName, actual) uncovered := []string{} - for _, relationship := range expected { if !slices.Contains(covered, relationship) { uncovered = append(uncovered, relationship) } } - return uncovered } -// findUncoveredAttributes finds attributes that are not covered in the shape func findUncoveredAttributes(entityName string, expected, actual []string) []string { covered := extractCoveredAttributes(entityName, actual) uncovered := []string{} - for _, attr := range expected { if !slices.Contains(covered, attr) { uncovered = append(uncovered, attr) } } - return uncovered } -// findUncoveredAssertions finds assertions that are not covered in the shape func findUncoveredAssertions(entityName string, expected []string, checks []file.Check, filters []file.EntityFilter) []string { covered := extractCoveredAssertions(entityName, checks, filters) uncovered := []string{} - for _, assertion := range expected { if !slices.Contains(covered, assertion) { uncovered = append(uncovered, assertion) } } - return uncovered } -// buildSchemaCoverageInfo builds the final SchemaCoverageInfo with total coverage func buildSchemaCoverageInfo(entityCoverageInfos []EntityCoverageInfo) SchemaCoverageInfo { relationshipsCoverage, attributesCoverage, assertionsCoverage := calculateTotalCoverage(entityCoverageInfos) - return SchemaCoverageInfo{ EntityCoverageInfo: entityCoverageInfos, TotalRelationshipsCoverage: relationshipsCoverage, @@ -287,47 +230,32 @@ func buildSchemaCoverageInfo(entityCoverageInfos []EntityCoverageInfo) SchemaCov } } -// calculateCoveragePercent calculates coverage percentage based on total and uncovered elements func calculateCoveragePercent(totalElements, uncoveredElements []string) int { totalCount := len(totalElements) if totalCount == 0 { return 100 } - coveredCount := totalCount - len(uncoveredElements) return (coveredCount * 100) / totalCount } -// calculateTotalCoverage calculates average coverage percentages across all entities func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) { - var ( - totalRelationships int - totalCoveredRelationships int - totalAttributes int - totalCoveredAttributes int - totalAssertions int - totalCoveredAssertions int - ) - + var totalRelationships, totalCoveredRelationships, totalAttributes, totalCoveredAttributes, totalAssertions, totalCoveredAssertions int for _, entity := range entities { totalRelationships++ totalCoveredRelationships += entity.CoverageRelationshipsPercent - totalAttributes++ totalCoveredAttributes += entity.CoverageAttributesPercent - for _, assertionPercent := range entity.CoverageAssertionsPercent { totalAssertions++ totalCoveredAssertions += assertionPercent } } - return calculateAverageCoverage(totalRelationships, totalCoveredRelationships), calculateAverageCoverage(totalAttributes, totalCoveredAttributes), calculateAverageCoverage(totalAssertions, totalCoveredAssertions) } -// calculateAverageCoverage calculates average coverage with zero-division guard func calculateAverageCoverage(total, covered int) int { if total == 0 { return 100 @@ -335,90 +263,63 @@ func calculateAverageCoverage(total, covered int) int { return covered / total } -// extractCoveredRelationships extracts covered relationships for a given entity from the shape func extractCoveredRelationships(entityName string, relationships []string) []string { covered := []string{} - for _, relationship := range relationships { tup, err := tuple.Tuple(relationship) if err != nil { continue } - if tup.GetEntity().GetType() != entityName { continue } - - formatted := formatRelationship( - tup.GetEntity().GetType(), - tup.GetRelation(), - tup.GetSubject().GetType(), - tup.GetSubject().GetRelation(), - ) + formatted := formatRelationship(tup.GetEntity().GetType(), tup.GetRelation(), tup.GetSubject().GetType(), tup.GetSubject().GetRelation()) covered = append(covered, formatted) } - return covered } -// extractCoveredAttributes extracts covered attributes for a given entity from the shape func extractCoveredAttributes(entityName string, attributes []string) []string { covered := []string{} - for _, attrStr := range attributes { a, err := attribute.Attribute(attrStr) if err != nil { continue } - if a.GetEntity().GetType() != entityName { continue } - formatted := formatAttribute(a.GetEntity().GetType(), a.GetAttribute()) covered = append(covered, formatted) } - return covered } -// extractCoveredAssertions extracts covered assertions for a given entity from checks and filters func extractCoveredAssertions(entityName string, checks []file.Check, filters []file.EntityFilter) []string { covered := []string{} - - // Extract from checks for _, check := range checks { entity, err := tuple.E(check.Entity) if err != nil { continue } - if entity.GetType() != entityName { continue } - for permission := range check.Assertions { - formatted := formatAssertion(entity.GetType(), permission) - covered = append(covered, formatted) + covered = append(covered, formatAssertion(entity.GetType(), permission)) } } - - // Extract from entity filters for _, filter := range filters { if filter.EntityType != entityName { continue } - for permission := range filter.Assertions { - formatted := formatAssertion(filter.EntityType, permission) - covered = append(covered, formatted) + covered = append(covered, formatAssertion(filter.EntityType, permission)) } } - return covered } -// formatRelationship formats a relationship string func formatRelationship(entityName, relationName, subjectType, subjectRelation string) string { if subjectRelation != "" { return fmt.Sprintf("%s#%s@%s#%s", entityName, relationName, subjectType, subjectRelation) @@ -426,12 +327,186 @@ func formatRelationship(entityName, relationName, subjectType, subjectRelation s return fmt.Sprintf("%s#%s@%s", entityName, relationName, subjectType) } -// formatAttribute formats an attribute string func formatAttribute(entityName, attributeName string) string { return fmt.Sprintf("%s#%s", entityName, attributeName) } -// formatAssertion formats an assertion/permission string func formatAssertion(entityName, permissionName string) string { return fmt.Sprintf("%s#%s", entityName, permissionName) } + +func calculateConditionCoverage(entityName string, entityDef *base.EntityDefinition, scenario file.Scenario, relationships []string, attributes []string) map[string]*ConditionCoverageInfo { + result := make(map[string]*ConditionCoverageInfo) + assertedPermissions := extractAssertedPermissions(entityName, scenario) + for _, perm := range entityDef.GetPermissions() { + permName := perm.GetName() + if _, ok := assertedPermissions[permName]; !ok { + continue + } + components := extractConditionComponents(perm.GetChild()) + if len(components) == 0 { + continue + } + coveredRelations := buildCoveredRelationSet(entityName, relationships) + coveredAttrs := buildCoveredAttributeSet(entityName, attributes) + for _, check := range scenario.Checks { + entity, err := tuple.E(check.Entity) + if err != nil || entity.GetType() != entityName { + continue + } + for _, ctxTuple := range check.Context.Tuples { + tup, err := tuple.Tuple(ctxTuple) + if err != nil { + continue + } + if tup.GetEntity().GetType() == entityName { + coveredRelations[tup.GetRelation()] = true + } + } + for _, ctxAttr := range check.Context.Attributes { + a, err := attribute.Attribute(ctxAttr) + if err != nil { + continue + } + if a.GetEntity().GetType() == entityName { + coveredAttrs[a.GetAttribute()] = true + } + } + } + var covered, uncovered []ConditionComponent + for _, comp := range components { + if isComponentCovered(comp, coveredRelations, coveredAttrs) { + covered = append(covered, comp) + } else { + uncovered = append(uncovered, comp) + } + } + coveragePercent := 100 + if len(components) > 0 { + coveragePercent = (len(covered) * 100) / len(components) + } + result[permName] = &ConditionCoverageInfo{ + PermissionName: permName, + AllComponents: components, + CoveredComponents: covered, + UncoveredComponents: uncovered, + CoveragePercent: coveragePercent, + } + } + return result +} + +func extractAssertedPermissions(entityName string, scenario file.Scenario) map[string]bool { + asserted := make(map[string]bool) + for _, check := range scenario.Checks { + entity, err := tuple.E(check.Entity) + if err != nil || entity.GetType() != entityName { + continue + } + for permName := range check.Assertions { + asserted[permName] = true + } + } + for _, filter := range scenario.EntityFilters { + if filter.EntityType != entityName { + continue + } + for permName := range filter.Assertions { + asserted[permName] = true + } + } + return asserted +} + +func extractConditionComponents(child *base.Child) []ConditionComponent { + if child == nil { + return nil + } + if leaf := child.GetLeaf(); leaf != nil { + comp := leafToComponent(leaf) + if comp.Name != "" { + return []ConditionComponent{comp} + } + return nil + } + if rewrite := child.GetRewrite(); rewrite != nil { + var components []ConditionComponent + for _, ch := range rewrite.GetChildren() { + components = append(components, extractConditionComponents(ch)...) + } + return components + } + return nil +} + +func leafToComponent(leaf *base.Leaf) ConditionComponent { + if cus := leaf.GetComputedUserSet(); cus != nil { + return ConditionComponent{Name: cus.GetRelation(), Type: "relation"} + } + if ttus := leaf.GetTupleToUserSet(); ttus != nil { + tupleRel := "" + if ts := ttus.GetTupleSet(); ts != nil { + tupleRel = ts.GetRelation() + } + computedRel := "" + if c := ttus.GetComputed(); c != nil { + computedRel = c.GetRelation() + } + return ConditionComponent{Name: fmt.Sprintf("%s.%s", tupleRel, computedRel), Type: "tuple_to_userset"} + } + if ca := leaf.GetComputedAttribute(); ca != nil { + return ConditionComponent{Name: ca.GetName(), Type: "attribute"} + } + if call := leaf.GetCall(); call != nil { + return ConditionComponent{Name: fmt.Sprintf("call:%s", call.GetRuleName()), Type: "call"} + } + return ConditionComponent{} +} + +func buildCoveredRelationSet(entityName string, relationships []string) map[string]bool { + covered := make(map[string]bool) + for _, rel := range relationships { + tup, err := tuple.Tuple(rel) + if err != nil { + continue + } + if tup.GetEntity().GetType() == entityName { + covered[tup.GetRelation()] = true + } + } + return covered +} + +func buildCoveredAttributeSet(entityName string, attrs []string) map[string]bool { + covered := make(map[string]bool) + for _, attrStr := range attrs { + a, err := attribute.Attribute(attrStr) + if err != nil { + continue + } + if a.GetEntity().GetType() == entityName { + covered[a.GetAttribute()] = true + } + } + return covered +} + +func isComponentCovered(comp ConditionComponent, coveredRelations, coveredAttrs map[string]bool) bool { + switch comp.Type { + case "relation": + return coveredRelations[comp.Name] + case "tuple_to_userset": + for i, ch := range comp.Name { + if ch == '.' { + return coveredRelations[comp.Name[:i]] + } + } + return false + case "attribute": + return coveredAttrs[comp.Name] + case "call": + return true + default: + return false + } +} diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index 17c4315a4..95f5d2371 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -505,6 +505,120 @@ var _ = Describe("coverage", func() { Expect(isSameArray(sci.EntityCoverageInfo[8].UncoveredAssertions["scenario 1"], []string{})).Should(Equal(true)) Expect(sci.EntityCoverageInfo[8].CoverageAssertionsPercent["scenario 1"]).Should(Equal(100)) }) + + It("Case 4: Scenario-Specific Relationships Coverage", func() { + sci := Run(file.Shape{ + Schema: ` + entity user {} + + entity organization { + relation admin @user + relation member @user + } + + entity repository { + relation parent @organization + relation owner @user @organization#admin + + permission edit = parent.admin or owner + permission delete = owner + }`, + Relationships: []string{ + "organization:1#admin@user:1", + }, + Scenarios: []file.Scenario{ + { + Name: "scenario with extra relationships", + Description: "Tests coverage with scenario-specific relationships", + Relationships: []string{ + "repository:1#parent@organization:1", + }, + Checks: []file.Check{ + { + Entity: "repository:1", + Subject: "user:1", + Assertions: map[string]bool{ + "edit": true, + }, + }, + }, + EntityFilters: []file.EntityFilter{}, + }, + { + Name: "scenario without extra relationships", + Description: "Tests coverage without scenario-specific relationships", + Checks: []file.Check{ + { + Entity: "repository:1", + Subject: "user:1", + Assertions: map[string]bool{ + "edit": true, + }, + }, + }, + EntityFilters: []file.EntityFilter{}, + }, + }, + }) + + Expect(sci.EntityCoverageInfo[2].EntityName).Should(Equal("repository")) + Expect(isSameArray(sci.EntityCoverageInfo[2].UncoveredRelationships, []string{ + "repository#owner@user", + "repository#owner@organization#admin", + })).Should(Equal(true)) + Expect(sci.EntityCoverageInfo[2].CoverageRelationshipsPercent).Should(Equal(33)) + }) + + It("Case 5: Scenario-Specific Attributes Coverage", func() { + sci := Run(file.Shape{ + Schema: ` + entity user {} + + entity account { + relation owner @user + + attribute balance integer + + permission withdraw = check_balance(balance) and owner + } + + rule check_balance(balance integer) { + (balance >= context.data.amount) && (context.data.amount <= 5000) + }`, + Relationships: []string{ + "account:1#owner@user:1", + }, + Attributes: []string{}, + Scenarios: []file.Scenario{ + { + Name: "scenario with attributes", + Description: "Tests coverage with scenario-specific attributes", + Attributes: []string{ + "account:1$balance|integer:4000", + }, + Checks: []file.Check{ + { + Entity: "account:1", + Subject: "user:1", + Context: file.Context{ + Data: map[string]interface{}{ + "amount": 3000, + }, + }, + Assertions: map[string]bool{ + "withdraw": true, + }, + }, + }, + EntityFilters: []file.EntityFilter{}, + }, + }, + }) + + Expect(sci.EntityCoverageInfo[1].EntityName).Should(Equal("account")) + Expect(sci.EntityCoverageInfo[1].UncoveredAttributes).Should(Equal([]string{})) + Expect(sci.EntityCoverageInfo[1].CoverageAttributesPercent).Should(Equal(100)) + }) }) }) diff --git a/pkg/development/development.go b/pkg/development/development.go index 04df30379..da591d8ee 100644 --- a/pkg/development/development.go +++ b/pkg/development/development.go @@ -282,6 +282,92 @@ func (c *Development) RunWithShape(ctx context.Context, shape *file.Shape) (erro // Each item in the Scenarios slice is processed individually for i, scenario := range shape.Scenarios { + // Write scenario-specific relationships if any are defined + for _, t := range scenario.Relationships { + tup, err := tuple.Tuple(t) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("relationship: %s: %s", t, err.Error()), + }) + continue + } + + definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", tup.GetEntity().GetType(), version) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("relationship: %s: %s", t, err.Error()), + }) + continue + } + + err = validation.ValidateTuple(definition, tup) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("relationship: %s: %s", t, err.Error()), + }) + continue + } + + _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("relationship: %s: %s", t, err.Error()), + }) + continue + } + } + + // Write scenario-specific attributes if any are defined + for _, a := range scenario.Attributes { + attr, err := attribute.Attribute(a) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("attribute: %s: %s", a, err.Error()), + }) + continue + } + + definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", attr.GetEntity().GetType(), version) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("attribute: %s: %s", a, err.Error()), + }) + continue + } + + err = validation.ValidateAttribute(definition, attr) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("attribute: %s: %s", a, err.Error()), + }) + continue + } + + _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)) + if err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("attribute: %s: %s", a, err.Error()), + }) + continue + } + } + // Each Check in the current scenario is processed for _, check := range scenario.Checks { entity, err := tuple.E(check.Entity) diff --git a/pkg/development/file/shape.go b/pkg/development/file/shape.go index d9057f318..67b7b1b97 100644 --- a/pkg/development/file/shape.go +++ b/pkg/development/file/shape.go @@ -25,6 +25,14 @@ type Scenario struct { // Description is a string that provides a brief explanation of the scenario. Description string `yaml:"description"` + // Relationships is a slice of strings representing scenario-specific authorization relationships. + // These are written in addition to the global relationships defined in the Shape. + Relationships []string `yaml:"relationships"` + + // Attributes is a slice of strings representing scenario-specific authorization attributes. + // These are written in addition to the global attributes defined in the Shape. + Attributes []string `yaml:"attributes"` + // Checks is a slice of Check structs that represent the authorization checks to be performed. Checks []Check `yaml:"checks"`