From d82f09e260a57a8aff8fa79908defa5f40330abb Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:05:04 +0200 Subject: [PATCH 1/6] feat: add permission condition coverage details --- pkg/cmd/coverage.go | 46 +++ pkg/development/coverage/coverage.go | 419 +++++++++++++++++++++- pkg/development/coverage/coverage_test.go | 90 ++++- 3 files changed, 550 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/coverage.go b/pkg/cmd/coverage.go index ae67e468c..5b5b80d94 100644 --- a/pkg/cmd/coverage.go +++ b/pkg/cmd/coverage.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "sort" "github.com/gookit/color" "github.com/spf13/cobra" @@ -131,6 +132,8 @@ func DisplayCoverageInfo(schemaCoverageInfo cov.SchemaCoverageInfo) { } } + displayConditionCoverage(entityCoverageInfo) + fmt.Printf(" coverage relationships percentage:") if entityCoverageInfo.CoverageRelationshipsPercent <= 50 { @@ -159,3 +162,46 @@ func DisplayCoverageInfo(schemaCoverageInfo cov.SchemaCoverageInfo) { } } } + +func displayConditionCoverage(entityCoverageInfo cov.EntityCoverageInfo) { + if len(entityCoverageInfo.PermissionConditionCoverage) == 0 { + return + } + + fmt.Printf(" permission condition coverage:\n") + + scenarioNames := make([]string, 0, len(entityCoverageInfo.PermissionConditionCoverage)) + for scenarioName := range entityCoverageInfo.PermissionConditionCoverage { + scenarioNames = append(scenarioNames, scenarioName) + } + sort.Strings(scenarioNames) + + for _, scenarioName := range scenarioNames { + permissionCoverages := entityCoverageInfo.PermissionConditionCoverage[scenarioName] + if len(permissionCoverages) == 0 { + continue + } + + fmt.Printf(" %s:\n", scenarioName) + + permissionNames := make([]string, 0, len(permissionCoverages)) + for permissionName := range permissionCoverages { + permissionNames = append(permissionNames, permissionName) + } + sort.Strings(permissionNames) + + for _, permissionName := range permissionNames { + conditionCoverage := permissionCoverages[permissionName] + fmt.Printf(" %s:", permissionName) + if conditionCoverage.CoveragePercent <= 50 { + color.Danger.Printf(" %d%%\n", conditionCoverage.CoveragePercent) + } else { + color.Success.Printf(" %d%%\n", conditionCoverage.CoveragePercent) + } + + for _, component := range conditionCoverage.UncoveredComponents { + fmt.Printf(" - %s %s\n", component.Type, component.Name) + } + } + } +} diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index def29a3a0..ff61eef88 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -3,6 +3,8 @@ package coverage import ( "fmt" "slices" + "sort" + "strings" "github.com/Permify/permify/pkg/attribute" "github.com/Permify/permify/pkg/development/file" @@ -20,6 +22,29 @@ type SchemaCoverageInfo struct { TotalAssertionsCoverage int } +const ( + componentRelation = "relation" + componentAttribute = "attribute" + componentTupleToUserset = "tuple_to_userset" + componentCall = "call" + componentPermission = "permission" +) + +// ConditionComponent represents a single leaf component in a permission condition tree. +type ConditionComponent struct { + Name string + Type string +} + +// ConditionCoverageInfo represents condition component coverage for one permission. +type ConditionCoverageInfo struct { + PermissionName string + AllComponents []ConditionComponent + CoveredComponents []ConditionComponent + UncoveredComponents []ConditionComponent + CoveragePercent int +} + // EntityCoverageInfo represents coverage information for a single entity type EntityCoverageInfo struct { EntityName string @@ -32,6 +57,8 @@ type EntityCoverageInfo struct { UncoveredAssertions map[string][]string CoverageAssertionsPercent map[string]int + + PermissionConditionCoverage map[string]map[string]ConditionCoverageInfo } // SchemaCoverage represents the expected coverage for a schema entity @@ -78,7 +105,7 @@ func Run(shape file.Shape) SchemaCoverageInfo { } refs := extractSchemaReferences(definitions) - entityCoverageInfos := calculateEntityCoverages(refs, shape) + entityCoverageInfos := calculateEntityCoverages(refs, shape, definitions) return buildSchemaCoverageInfo(entityCoverageInfos) } @@ -162,11 +189,16 @@ func extractAssertions(entity *base.EntityDefinition) []string { } // 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{} + definitionsByEntity := make(map[string]*base.EntityDefinition, len(definitions)) + + for _, definition := range definitions { + definitionsByEntity[definition.GetName()] = definition + } for _, ref := range refs { - entityCoverageInfo := calculateEntityCoverage(ref, shape) + entityCoverageInfo := calculateEntityCoverage(ref, shape, definitionsByEntity[ref.EntityName]) entityCoverageInfos = append(entityCoverageInfos, entityCoverageInfo) } @@ -174,7 +206,7 @@ func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape) []EntityC } // calculateEntityCoverage calculates coverage for a single entity -func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverageInfo { +func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape, entityDefinition *base.EntityDefinition) EntityCoverageInfo { entityCoverageInfo := newEntityCoverageInfo(ref.EntityName) // Calculate relationships coverage @@ -215,6 +247,16 @@ func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverag ref.Assertions, uncovered, ) + + conditionCoverage := calculateConditionCoverage( + entityDefinition, + scenario, + shape.Relationships, + shape.Attributes, + ) + if len(conditionCoverage) > 0 { + entityCoverageInfo.PermissionConditionCoverage[scenario.Name] = conditionCoverage + } } return entityCoverageInfo @@ -230,6 +272,7 @@ func newEntityCoverageInfo(entityName string) EntityCoverageInfo { UncoveredAssertions: make(map[string][]string), CoverageRelationshipsPercent: 0, CoverageAttributesPercent: 0, + PermissionConditionCoverage: make(map[string]map[string]ConditionCoverageInfo), } } @@ -418,6 +461,374 @@ func extractCoveredAssertions(entityName string, checks []file.Check, filters [] return covered } +// calculateConditionCoverage analyzes asserted permissions in a scenario and reports which +// leaf components inside each permission condition have no matching test data. +func calculateConditionCoverage( + entityDefinition *base.EntityDefinition, + scenario file.Scenario, + relationships []string, + attributes []string, +) map[string]ConditionCoverageInfo { + result := make(map[string]ConditionCoverageInfo) + if entityDefinition == nil { + return result + } + + assertedPermissions := extractAssertedPermissions(entityDefinition.GetName(), scenario) + if len(assertedPermissions) == 0 { + return result + } + + coverageData := newComponentCoverageData(relationships, attributes, scenario) + + for _, permissionName := range sortedPermissionNames(assertedPermissions) { + permission, ok := entityDefinition.GetPermissions()[permissionName] + if !ok || permission.GetChild() == nil { + continue + } + + components := extractConditionComponents(entityDefinition, permission.GetChild(), map[string]bool{ + permissionName: true, + }) + components = uniqueConditionComponents(components) + if len(components) == 0 { + continue + } + + targets := assertedPermissions[permissionName] + covered := make([]ConditionComponent, 0, len(components)) + uncovered := make([]ConditionComponent, 0) + + for _, component := range components { + if coverageData.isComponentCovered(entityDefinition.GetName(), component, targets) { + covered = append(covered, component) + } else { + uncovered = append(uncovered, component) + } + } + + result[permissionName] = ConditionCoverageInfo{ + PermissionName: permissionName, + AllComponents: components, + CoveredComponents: covered, + UncoveredComponents: uncovered, + CoveragePercent: calculateCoveragePercent(conditionComponentNames(components), conditionComponentNames(uncovered)), + } + } + + return result +} + +type assertionTarget struct { + EntityID string + EntityIDOnly bool +} + +// extractAssertedPermissions returns permissions asserted for an entity and the concrete +// entity IDs they were asserted against when the scenario provides them. +func extractAssertedPermissions(entityName string, scenario file.Scenario) map[string][]assertionTarget { + asserted := make(map[string][]assertionTarget) + + for _, check := range scenario.Checks { + entity, err := tuple.E(check.Entity) + if err != nil || entity.GetType() != entityName { + continue + } + + for permission := range check.Assertions { + asserted[permission] = appendAssertionTarget(asserted[permission], assertionTarget{ + EntityID: entity.GetId(), + EntityIDOnly: true, + }) + } + } + + for _, filter := range scenario.EntityFilters { + if filter.EntityType != entityName { + continue + } + + for permission := range filter.Assertions { + asserted[permission] = appendAssertionTarget(asserted[permission], assertionTarget{}) + } + } + + return asserted +} + +func appendAssertionTarget(targets []assertionTarget, target assertionTarget) []assertionTarget { + for _, existing := range targets { + if existing == target { + return targets + } + } + return append(targets, target) +} + +func sortedPermissionNames(asserted map[string][]assertionTarget) []string { + names := make([]string, 0, len(asserted)) + for name := range asserted { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func extractConditionComponents( + entityDefinition *base.EntityDefinition, + child *base.Child, + visitedPermissions map[string]bool, +) []ConditionComponent { + if child == nil { + return nil + } + + if leaf := child.GetLeaf(); leaf != nil { + return leafToComponents(entityDefinition, leaf, visitedPermissions) + } + + if rewrite := child.GetRewrite(); rewrite != nil { + components := []ConditionComponent{} + for _, child := range rewrite.GetChildren() { + components = append(components, extractConditionComponents(entityDefinition, child, visitedPermissions)...) + } + return components + } + + return nil +} + +func leafToComponents( + entityDefinition *base.EntityDefinition, + leaf *base.Leaf, + visitedPermissions map[string]bool, +) []ConditionComponent { + if computedUserSet := leaf.GetComputedUserSet(); computedUserSet != nil { + name := computedUserSet.GetRelation() + if entityDefinition.GetReferences()[name] == base.EntityDefinition_REFERENCE_PERMISSION { + nestedPermission := entityDefinition.GetPermissions()[name] + if nestedPermission != nil && !visitedPermissions[name] { + visitedPermissions[name] = true + components := extractConditionComponents(entityDefinition, nestedPermission.GetChild(), visitedPermissions) + delete(visitedPermissions, name) + return components + } + + return []ConditionComponent{{ + Name: name, + Type: componentPermission, + }} + } + + return []ConditionComponent{{ + Name: name, + Type: componentRelation, + }} + } + + if tupleToUserset := leaf.GetTupleToUserSet(); tupleToUserset != nil { + tupleSetRelation := "" + if tupleToUserset.GetTupleSet() != nil { + tupleSetRelation = tupleToUserset.GetTupleSet().GetRelation() + } + + computedRelation := "" + if tupleToUserset.GetComputed() != nil { + computedRelation = tupleToUserset.GetComputed().GetRelation() + } + + return []ConditionComponent{{ + Name: strings.Join(nonEmptyStrings(tupleSetRelation, computedRelation), "."), + Type: componentTupleToUserset, + }} + } + + if computedAttribute := leaf.GetComputedAttribute(); computedAttribute != nil { + return []ConditionComponent{{ + Name: computedAttribute.GetName(), + Type: componentAttribute, + }} + } + + if call := leaf.GetCall(); call != nil { + components := []ConditionComponent{{ + Name: call.GetRuleName(), + Type: componentCall, + }} + + for _, argument := range call.GetArguments() { + if computedAttribute := argument.GetComputedAttribute(); computedAttribute != nil { + components = append(components, ConditionComponent{ + Name: computedAttribute.GetName(), + Type: componentAttribute, + }) + } + } + + return components + } + + return nil +} + +func nonEmptyStrings(values ...string) []string { + filtered := make([]string, 0, len(values)) + for _, value := range values { + if value != "" { + filtered = append(filtered, value) + } + } + return filtered +} + +func uniqueConditionComponents(components []ConditionComponent) []ConditionComponent { + seen := make(map[ConditionComponent]bool, len(components)) + unique := make([]ConditionComponent, 0, len(components)) + + for _, component := range components { + if component.Name == "" || seen[component] { + continue + } + seen[component] = true + unique = append(unique, component) + } + + return unique +} + +func conditionComponentNames(components []ConditionComponent) []string { + names := make([]string, 0, len(components)) + for _, component := range components { + names = append(names, fmt.Sprintf("%s:%s", component.Type, component.Name)) + } + return names +} + +type componentCoverageData struct { + relationships map[string]map[string]map[string]bool + attributes map[string]map[string]map[string]bool +} + +func newComponentCoverageData(relationships, attributes []string, scenario file.Scenario) componentCoverageData { + data := componentCoverageData{ + relationships: make(map[string]map[string]map[string]bool), + attributes: make(map[string]map[string]map[string]bool), + } + + for _, relationship := range relationships { + data.addRelationship(relationship) + } + + for _, attribute := range attributes { + data.addAttribute(attribute) + } + + for _, check := range scenario.Checks { + data.addContext(check.Context) + } + + for _, filter := range scenario.EntityFilters { + data.addContext(filter.Context) + } + + for _, filter := range scenario.SubjectFilters { + data.addContext(filter.Context) + } + + return data +} + +func (data componentCoverageData) addContext(context file.Context) { + for _, relationship := range context.Tuples { + data.addRelationship(relationship) + } + + for _, attribute := range context.Attributes { + data.addAttribute(attribute) + } +} + +func (data componentCoverageData) addRelationship(relationship string) { + tup, err := tuple.Tuple(relationship) + if err != nil { + return + } + + entityType := tup.GetEntity().GetType() + relation := tup.GetRelation() + entityID := tup.GetEntity().GetId() + + if data.relationships[entityType] == nil { + data.relationships[entityType] = make(map[string]map[string]bool) + } + if data.relationships[entityType][relation] == nil { + data.relationships[entityType][relation] = make(map[string]bool) + } + data.relationships[entityType][relation][entityID] = true +} + +func (data componentCoverageData) addAttribute(attributeString string) { + attr, err := attribute.Attribute(attributeString) + if err != nil { + return + } + + entityType := attr.GetEntity().GetType() + attributeName := attr.GetAttribute() + entityID := attr.GetEntity().GetId() + + if data.attributes[entityType] == nil { + data.attributes[entityType] = make(map[string]map[string]bool) + } + if data.attributes[entityType][attributeName] == nil { + data.attributes[entityType][attributeName] = make(map[string]bool) + } + data.attributes[entityType][attributeName][entityID] = true +} + +func (data componentCoverageData) isComponentCovered(entityName string, component ConditionComponent, targets []assertionTarget) bool { + switch component.Type { + case componentRelation: + return data.hasRelationship(entityName, component.Name, targets) + case componentTupleToUserset: + tupleSetRelation, _, _ := strings.Cut(component.Name, ".") + return data.hasRelationship(entityName, tupleSetRelation, targets) + case componentAttribute: + return data.hasAttribute(entityName, component.Name, targets) + case componentCall: + return true + case componentPermission: + return false + default: + return false + } +} + +func (data componentCoverageData) hasRelationship(entityName, relation string, targets []assertionTarget) bool { + return hasCoverageForTargets(data.relationships[entityName][relation], targets) +} + +func (data componentCoverageData) hasAttribute(entityName, attributeName string, targets []assertionTarget) bool { + return hasCoverageForTargets(data.attributes[entityName][attributeName], targets) +} + +func hasCoverageForTargets(coveredEntityIDs map[string]bool, targets []assertionTarget) bool { + if len(coveredEntityIDs) == 0 { + return false + } + + for _, target := range targets { + if !target.EntityIDOnly { + return true + } + if coveredEntityIDs[target.EntityID] { + return true + } + } + + return false +} + // formatRelationship formats a relationship string func formatRelationship(entityName, relationName, subjectType, subjectRelation string) string { if subjectRelation != "" { diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index 17c4315a4..7e485ec7e 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -247,7 +247,86 @@ var _ = Describe("coverage", func() { Expect(sci.EntityCoverageInfo[3].CoverageAssertionsPercent["scenario 1"]).Should(Equal(0)) }) - It("Case 3: Facebook Groups", func() { + It("Case 3: Permission Condition Components", func() { + sci := Run(file.Shape{ + Schema: ` + entity user {} + + entity system { + relation viewer @user + permission view = viewer + } + + entity company { + relation maintainer @user + permission maintain = maintainer + } + + entity organization { + relation maintainer @user + permission maintain = maintainer + } + + entity team { + relation viewer @user + permission view = viewer + } + + entity document { + relation system @system + relation partner @user + relation viewer @user + relation company @company + relation organization @organization + relation team @team + relation denied @user + + attribute is_public boolean + attribute is_partner boolean + + permission view = system.view or ((is_public or (is_partner and partner) or (viewer or company.maintain or organization.maintain or team.view)) not denied) + }`, + Relationships: []string{ + "document:1#system@system:1", + "system:1#viewer@user:1", + }, + Scenarios: []file.Scenario{ + { + Name: "system branch only", + Description: "covers the system branch without covering the rest of the view condition", + Checks: []file.Check{ + { + Entity: "document:1", + Subject: "user:1", + Assertions: map[string]bool{ + "view": true, + }, + }, + }, + }, + }, + }) + + documentCoverage := findEntityCoverage(sci, "document") + conditionCoverage := documentCoverage.PermissionConditionCoverage["system branch only"]["view"] + + Expect(conditionCoverage.CoveragePercent).Should(Equal(11)) + Expect(conditionComponentNames(conditionCoverage.CoveredComponents)).Should(Equal([]string{ + "tuple_to_userset:system.view", + })) + Expect(isSameArray(conditionComponentNames(conditionCoverage.UncoveredComponents), []string{ + "attribute:is_public", + "attribute:is_partner", + "relation:partner", + "relation:viewer", + "tuple_to_userset:company.maintain", + "tuple_to_userset:organization.maintain", + "tuple_to_userset:team.view", + "relation:denied", + })).Should(Equal(true)) + }) + + It("Case 4: Facebook Groups", func() { sci := Run(file.Shape{ Schema: ` entity user {} @@ -530,3 +609,12 @@ func isSameArray(a, b []string) bool { return true } + +func findEntityCoverage(sci SchemaCoverageInfo, entityName string) EntityCoverageInfo { + for _, entityCoverage := range sci.EntityCoverageInfo { + if entityCoverage.EntityName == entityName { + return entityCoverage + } + } + return EntityCoverageInfo{} +} From 202ccc14e3e09af979d662afe0acdf4ff9279e02 Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:14:14 +0200 Subject: [PATCH 2/6] docs: clarify condition coverage helpers --- pkg/development/coverage/coverage.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index ff61eef88..ebd3df9fa 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -519,6 +519,7 @@ func calculateConditionCoverage( return result } +// assertionTarget identifies whether coverage must match a concrete entity ID. type assertionTarget struct { EntityID string EntityIDOnly bool @@ -556,6 +557,7 @@ func extractAssertedPermissions(entityName string, scenario file.Scenario) map[s return asserted } +// appendAssertionTarget adds a target once so repeated assertions do not skew coverage. func appendAssertionTarget(targets []assertionTarget, target assertionTarget) []assertionTarget { for _, existing := range targets { if existing == target { @@ -565,6 +567,7 @@ func appendAssertionTarget(targets []assertionTarget, target assertionTarget) [] return append(targets, target) } +// sortedPermissionNames returns deterministic permission iteration order for reports. func sortedPermissionNames(asserted map[string][]assertionTarget) []string { names := make([]string, 0, len(asserted)) for name := range asserted { @@ -574,6 +577,7 @@ func sortedPermissionNames(asserted map[string][]assertionTarget) []string { return names } +// extractConditionComponents walks a compiled permission tree and returns its leaf components. func extractConditionComponents( entityDefinition *base.EntityDefinition, child *base.Child, @@ -598,6 +602,7 @@ func extractConditionComponents( return nil } +// leafToComponents maps a compiled leaf to the condition components it exercises. func leafToComponents( entityDefinition *base.EntityDefinition, leaf *base.Leaf, @@ -671,6 +676,7 @@ func leafToComponents( return nil } +// nonEmptyStrings filters empty strings before joining component names. func nonEmptyStrings(values ...string) []string { filtered := make([]string, 0, len(values)) for _, value := range values { @@ -681,6 +687,7 @@ func nonEmptyStrings(values ...string) []string { return filtered } +// uniqueConditionComponents de-duplicates components while preserving expression order. func uniqueConditionComponents(components []ConditionComponent) []ConditionComponent { seen := make(map[ConditionComponent]bool, len(components)) unique := make([]ConditionComponent, 0, len(components)) @@ -696,6 +703,7 @@ func uniqueConditionComponents(components []ConditionComponent) []ConditionCompo return unique } +// conditionComponentNames formats components for percentage calculation and tests. func conditionComponentNames(components []ConditionComponent) []string { names := make([]string, 0, len(components)) for _, component := range components { @@ -704,11 +712,13 @@ func conditionComponentNames(components []ConditionComponent) []string { return names } +// componentCoverageData indexes shape and scenario data by entity type, component name, and entity ID. type componentCoverageData struct { relationships map[string]map[string]map[string]bool attributes map[string]map[string]map[string]bool } +// newComponentCoverageData builds the scenario-local component coverage lookup. func newComponentCoverageData(relationships, attributes []string, scenario file.Scenario) componentCoverageData { data := componentCoverageData{ relationships: make(map[string]map[string]map[string]bool), @@ -738,6 +748,7 @@ func newComponentCoverageData(relationships, attributes []string, scenario file. return data } +// addContext folds contextual tuples and attributes into the component coverage lookup. func (data componentCoverageData) addContext(context file.Context) { for _, relationship := range context.Tuples { data.addRelationship(relationship) @@ -748,6 +759,7 @@ func (data componentCoverageData) addContext(context file.Context) { } } +// addRelationship records a relationship as available test data for a component. func (data componentCoverageData) addRelationship(relationship string) { tup, err := tuple.Tuple(relationship) if err != nil { @@ -767,6 +779,7 @@ func (data componentCoverageData) addRelationship(relationship string) { data.relationships[entityType][relation][entityID] = true } +// addAttribute records an attribute as available test data for a component. func (data componentCoverageData) addAttribute(attributeString string) { attr, err := attribute.Attribute(attributeString) if err != nil { @@ -786,6 +799,7 @@ func (data componentCoverageData) addAttribute(attributeString string) { data.attributes[entityType][attributeName][entityID] = true } +// isComponentCovered checks whether the asserted scenario contains data for a component. func (data componentCoverageData) isComponentCovered(entityName string, component ConditionComponent, targets []assertionTarget) bool { switch component.Type { case componentRelation: @@ -796,22 +810,27 @@ func (data componentCoverageData) isComponentCovered(entityName string, componen case componentAttribute: return data.hasAttribute(entityName, component.Name, targets) case componentCall: + // Rule bodies are opaque to static coverage; attribute arguments are tracked separately. return true case componentPermission: + // Recursive same-entity permission references are guarded by visitedPermissions upstream. return false default: return false } } +// hasRelationship checks whether a relation appears for any asserted target entity. func (data componentCoverageData) hasRelationship(entityName, relation string, targets []assertionTarget) bool { return hasCoverageForTargets(data.relationships[entityName][relation], targets) } +// hasAttribute checks whether an attribute appears for any asserted target entity. func (data componentCoverageData) hasAttribute(entityName, attributeName string, targets []assertionTarget) bool { return hasCoverageForTargets(data.attributes[entityName][attributeName], targets) } +// hasCoverageForTargets matches type-level filters or concrete check entity IDs. func hasCoverageForTargets(coveredEntityIDs map[string]bool, targets []assertionTarget) bool { if len(coveredEntityIDs) == 0 { return false From 305574b6288d7dad426c68d88f55bd9e6af65b68 Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:18:09 +0200 Subject: [PATCH 3/6] fix: verify tuple-to-userset condition coverage --- pkg/cmd/coverage.go | 8 + pkg/cmd/flags/coverage.go | 3 + pkg/development/coverage/coverage.go | 198 ++++++++++++++++++++-- pkg/development/coverage/coverage_test.go | 2 + 4 files changed, 194 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/coverage.go b/pkg/cmd/coverage.go index 5b5b80d94..9c0b8b101 100644 --- a/pkg/cmd/coverage.go +++ b/pkg/cmd/coverage.go @@ -29,6 +29,7 @@ func NewCoverageCommand() *cobra.Command { f.Int("coverage-relationships", 0, "the min coverage for relationships") f.Int("coverage-attributes", 0, "the min coverage for attributes") f.Int("coverage-assertions", 0, "the min coverage for assertions") + f.Int("coverage-conditions", 0, "the min coverage for permission condition components") // register flags for coverage command.PreRun = func(cmd *cobra.Command, args []string) { @@ -50,6 +51,7 @@ func coverage() func(cmd *cobra.Command, args []string) error { coverageRelationships := viper.GetInt("coverage-relationships") // Min relationships coverage coverageAttributes := viper.GetInt("coverage-attributes") coverageAssertions := viper.GetInt("coverage-assertions") // Min assertions coverage + coverageConditions := viper.GetInt("coverage-conditions") // Create decoder from URL // create a new decoder from the url decoder, err := file.NewDecoderFromURL(u) @@ -106,6 +108,12 @@ func coverage() func(cmd *cobra.Command, args []string) error { color.Danger.Println("FAILED") os.Exit(1) } + + if schemaCoverageInfo.TotalConditionsCoverage < coverageConditions { + color.Danger.Printf("permission condition coverage < %d%%\n", coverageConditions) + color.Danger.Println("FAILED") + os.Exit(1) + } return nil } } diff --git a/pkg/cmd/flags/coverage.go b/pkg/cmd/flags/coverage.go index cf05b9687..5057a3cb9 100644 --- a/pkg/cmd/flags/coverage.go +++ b/pkg/cmd/flags/coverage.go @@ -16,4 +16,7 @@ func RegisterCoverageFlags(flags *pflag.FlagSet) { if err := viper.BindPFlag("coverage-assertions", flags.Lookup("coverage-assertions")); err != nil { panic(err) } + if err := viper.BindPFlag("coverage-conditions", flags.Lookup("coverage-conditions")); err != nil { + panic(err) + } } diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index ebd3df9fa..1057733c8 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -20,6 +20,7 @@ type SchemaCoverageInfo struct { TotalRelationshipsCoverage int TotalAttributesCoverage int TotalAssertionsCoverage int + TotalConditionsCoverage int } const ( @@ -32,8 +33,10 @@ const ( // ConditionComponent represents a single leaf component in a permission condition tree. type ConditionComponent struct { - Name string - Type string + Name string + Type string + TupleSetRelation string + ComputedRelation string } // ConditionCoverageInfo represents condition component coverage for one permission. @@ -198,7 +201,7 @@ func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape, definitio } for _, ref := range refs { - entityCoverageInfo := calculateEntityCoverage(ref, shape, definitionsByEntity[ref.EntityName]) + entityCoverageInfo := calculateEntityCoverage(ref, shape, definitionsByEntity[ref.EntityName], definitionsByEntity) entityCoverageInfos = append(entityCoverageInfos, entityCoverageInfo) } @@ -206,7 +209,12 @@ func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape, definitio } // calculateEntityCoverage calculates coverage for a single entity -func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape, entityDefinition *base.EntityDefinition) EntityCoverageInfo { +func calculateEntityCoverage( + ref SchemaCoverage, + shape file.Shape, + entityDefinition *base.EntityDefinition, + definitionsByEntity map[string]*base.EntityDefinition, +) EntityCoverageInfo { entityCoverageInfo := newEntityCoverageInfo(ref.EntityName) // Calculate relationships coverage @@ -250,6 +258,7 @@ func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape, entityDefinit conditionCoverage := calculateConditionCoverage( entityDefinition, + definitionsByEntity, scenario, shape.Relationships, shape.Attributes, @@ -320,13 +329,14 @@ func findUncoveredAssertions(entityName string, expected []string, checks []file // buildSchemaCoverageInfo builds the final SchemaCoverageInfo with total coverage func buildSchemaCoverageInfo(entityCoverageInfos []EntityCoverageInfo) SchemaCoverageInfo { - relationshipsCoverage, attributesCoverage, assertionsCoverage := calculateTotalCoverage(entityCoverageInfos) + relationshipsCoverage, attributesCoverage, assertionsCoverage, conditionsCoverage := calculateTotalCoverage(entityCoverageInfos) return SchemaCoverageInfo{ EntityCoverageInfo: entityCoverageInfos, TotalRelationshipsCoverage: relationshipsCoverage, TotalAttributesCoverage: attributesCoverage, TotalAssertionsCoverage: assertionsCoverage, + TotalConditionsCoverage: conditionsCoverage, } } @@ -342,7 +352,7 @@ func calculateCoveragePercent(totalElements, uncoveredElements []string) int { } // calculateTotalCoverage calculates average coverage percentages across all entities -func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) { +func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int, int) { var ( totalRelationships int totalCoveredRelationships int @@ -350,6 +360,8 @@ func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) { totalCoveredAttributes int totalAssertions int totalCoveredAssertions int + totalConditions int + totalCoveredConditions int ) for _, entity := range entities { @@ -363,11 +375,19 @@ func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) { totalAssertions++ totalCoveredAssertions += assertionPercent } + + for _, scenarioConditionCoverage := range entity.PermissionConditionCoverage { + for _, conditionCoverage := range scenarioConditionCoverage { + totalConditions++ + totalCoveredConditions += conditionCoverage.CoveragePercent + } + } } return calculateAverageCoverage(totalRelationships, totalCoveredRelationships), calculateAverageCoverage(totalAttributes, totalCoveredAttributes), - calculateAverageCoverage(totalAssertions, totalCoveredAssertions) + calculateAverageCoverage(totalAssertions, totalCoveredAssertions), + calculateAverageCoverage(totalConditions, totalCoveredConditions) } // calculateAverageCoverage calculates average coverage with zero-division guard @@ -465,6 +485,7 @@ func extractCoveredAssertions(entityName string, checks []file.Check, filters [] // leaf components inside each permission condition have no matching test data. func calculateConditionCoverage( entityDefinition *base.EntityDefinition, + definitionsByEntity map[string]*base.EntityDefinition, scenario file.Scenario, relationships []string, attributes []string, @@ -479,7 +500,7 @@ func calculateConditionCoverage( return result } - coverageData := newComponentCoverageData(relationships, attributes, scenario) + coverageData := newComponentCoverageData(relationships, attributes, scenario, definitionsByEntity) for _, permissionName := range sortedPermissionNames(assertedPermissions) { permission, ok := entityDefinition.GetPermissions()[permissionName] @@ -643,8 +664,10 @@ func leafToComponents( } return []ConditionComponent{{ - Name: strings.Join(nonEmptyStrings(tupleSetRelation, computedRelation), "."), - Type: componentTupleToUserset, + Name: strings.Join(nonEmptyStrings(tupleSetRelation, computedRelation), "."), + Type: componentTupleToUserset, + TupleSetRelation: tupleSetRelation, + ComputedRelation: computedRelation, }} } @@ -714,15 +737,31 @@ func conditionComponentNames(components []ConditionComponent) []string { // componentCoverageData indexes shape and scenario data by entity type, component name, and entity ID. type componentCoverageData struct { - relationships map[string]map[string]map[string]bool - attributes map[string]map[string]map[string]bool + relationships map[string]map[string]map[string]bool + relationshipTargets map[string]map[string]map[string][]relationshipTarget + attributes map[string]map[string]map[string]bool + definitionsByEntity map[string]*base.EntityDefinition +} + +// relationshipTarget identifies the entity reached by a relationship tuple. +type relationshipTarget struct { + EntityType string + EntityID string + Relation string } // newComponentCoverageData builds the scenario-local component coverage lookup. -func newComponentCoverageData(relationships, attributes []string, scenario file.Scenario) componentCoverageData { +func newComponentCoverageData( + relationships, + attributes []string, + scenario file.Scenario, + definitionsByEntity map[string]*base.EntityDefinition, +) componentCoverageData { data := componentCoverageData{ - relationships: make(map[string]map[string]map[string]bool), - attributes: make(map[string]map[string]map[string]bool), + relationships: make(map[string]map[string]map[string]bool), + relationshipTargets: make(map[string]map[string]map[string][]relationshipTarget), + attributes: make(map[string]map[string]map[string]bool), + definitionsByEntity: definitionsByEntity, } for _, relationship := range relationships { @@ -769,6 +808,11 @@ func (data componentCoverageData) addRelationship(relationship string) { entityType := tup.GetEntity().GetType() relation := tup.GetRelation() entityID := tup.GetEntity().GetId() + target := relationshipTarget{ + EntityType: tup.GetSubject().GetType(), + EntityID: tup.GetSubject().GetId(), + Relation: tup.GetSubject().GetRelation(), + } if data.relationships[entityType] == nil { data.relationships[entityType] = make(map[string]map[string]bool) @@ -777,6 +821,14 @@ func (data componentCoverageData) addRelationship(relationship string) { data.relationships[entityType][relation] = make(map[string]bool) } data.relationships[entityType][relation][entityID] = true + + if data.relationshipTargets[entityType] == nil { + data.relationshipTargets[entityType] = make(map[string]map[string][]relationshipTarget) + } + if data.relationshipTargets[entityType][relation] == nil { + data.relationshipTargets[entityType][relation] = make(map[string][]relationshipTarget) + } + data.relationshipTargets[entityType][relation][entityID] = append(data.relationshipTargets[entityType][relation][entityID], target) } // addAttribute records an attribute as available test data for a component. @@ -801,12 +853,20 @@ func (data componentCoverageData) addAttribute(attributeString string) { // isComponentCovered checks whether the asserted scenario contains data for a component. func (data componentCoverageData) isComponentCovered(entityName string, component ConditionComponent, targets []assertionTarget) bool { + return data.isComponentCoveredWithVisited(entityName, component, targets, map[string]bool{}) +} + +func (data componentCoverageData) isComponentCoveredWithVisited( + entityName string, + component ConditionComponent, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { switch component.Type { case componentRelation: return data.hasRelationship(entityName, component.Name, targets) case componentTupleToUserset: - tupleSetRelation, _, _ := strings.Cut(component.Name, ".") - return data.hasRelationship(entityName, tupleSetRelation, targets) + return data.hasTupleToUserset(entityName, component, targets, visitedPermissions) case componentAttribute: return data.hasAttribute(entityName, component.Name, targets) case componentCall: @@ -820,6 +880,110 @@ func (data componentCoverageData) isComponentCovered(entityName string, componen } } +// hasTupleToUserset checks both the local tuple-set edge and the referenced computed relation. +func (data componentCoverageData) hasTupleToUserset( + entityName string, + component ConditionComponent, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + tupleSetRelation := component.TupleSetRelation + computedRelation := component.ComputedRelation + if tupleSetRelation == "" { + tupleSetRelation, computedRelation, _ = strings.Cut(component.Name, ".") + } + + reachedTargets := data.relationshipTargetsFor(entityName, tupleSetRelation, targets) + if len(reachedTargets) == 0 { + return false + } + + if computedRelation == "" { + return true + } + + for _, reachedTarget := range reachedTargets { + if data.isComputedRelationCovered(reachedTarget, computedRelation, visitedPermissions) { + return true + } + } + + return false +} + +// relationshipTargetsFor returns related entities for check targets or all entities for filters. +func (data componentCoverageData) relationshipTargetsFor(entityName, relation string, targets []assertionTarget) []relationshipTarget { + targetsByEntityID := data.relationshipTargets[entityName][relation] + if len(targetsByEntityID) == 0 { + return nil + } + + reachedTargets := []relationshipTarget{} + for _, target := range targets { + if !target.EntityIDOnly { + for _, values := range targetsByEntityID { + reachedTargets = append(reachedTargets, values...) + } + continue + } + reachedTargets = append(reachedTargets, targetsByEntityID[target.EntityID]...) + } + + return reachedTargets +} + +// isComputedRelationCovered verifies the computed side of a tuple-to-userset component. +func (data componentCoverageData) isComputedRelationCovered( + reachedTarget relationshipTarget, + computedRelation string, + visitedPermissions map[string]bool, +) bool { + targets := []assertionTarget{{ + EntityID: reachedTarget.EntityID, + EntityIDOnly: true, + }} + entityDefinition := data.definitionsByEntity[reachedTarget.EntityType] + if entityDefinition == nil { + return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + } + + switch entityDefinition.GetReferences()[computedRelation] { + case base.EntityDefinition_REFERENCE_RELATION: + return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + case base.EntityDefinition_REFERENCE_ATTRIBUTE: + return data.hasAttribute(reachedTarget.EntityType, computedRelation, targets) + case base.EntityDefinition_REFERENCE_PERMISSION: + key := formatAssertion(reachedTarget.EntityType, computedRelation) + if visitedPermissions[key] { + return false + } + + permission := entityDefinition.GetPermissions()[computedRelation] + if permission == nil || permission.GetChild() == nil { + return false + } + + visitedPermissions[key] = true + defer delete(visitedPermissions, key) + + components := uniqueConditionComponents(extractConditionComponents(entityDefinition, permission.GetChild(), map[string]bool{ + computedRelation: true, + })) + if len(components) == 0 { + return false + } + + for _, component := range components { + if !data.isComponentCoveredWithVisited(reachedTarget.EntityType, component, targets, visitedPermissions) { + return false + } + } + return true + default: + return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + } +} + // hasRelationship checks whether a relation appears for any asserted target entity. func (data componentCoverageData) hasRelationship(entityName, relation string, targets []assertionTarget) bool { return hasCoverageForTargets(data.relationships[entityName][relation], targets) diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index 7e485ec7e..bb0f40dd2 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -288,6 +288,7 @@ var _ = Describe("coverage", func() { }`, Relationships: []string{ "document:1#system@system:1", + "document:1#company@company:1", "system:1#viewer@user:1", }, Scenarios: []file.Scenario{ @@ -311,6 +312,7 @@ var _ = Describe("coverage", func() { conditionCoverage := documentCoverage.PermissionConditionCoverage["system branch only"]["view"] Expect(conditionCoverage.CoveragePercent).Should(Equal(11)) + Expect(sci.TotalConditionsCoverage).Should(Equal(11)) Expect(conditionComponentNames(conditionCoverage.CoveredComponents)).Should(Equal([]string{ "tuple_to_userset:system.view", })) From 4238198f4c35a70793686f4cd1e72fbbbae04be5 Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:20:47 +0200 Subject: [PATCH 4/6] refactor: trim tuple target coverage data --- pkg/development/coverage/coverage.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index 1057733c8..ebbe95657 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -747,7 +747,6 @@ type componentCoverageData struct { type relationshipTarget struct { EntityType string EntityID string - Relation string } // newComponentCoverageData builds the scenario-local component coverage lookup. @@ -811,7 +810,6 @@ func (data componentCoverageData) addRelationship(relationship string) { target := relationshipTarget{ EntityType: tup.GetSubject().GetType(), EntityID: tup.GetSubject().GetId(), - Relation: tup.GetSubject().GetRelation(), } if data.relationships[entityType] == nil { From cef8fb66cfed054b0d4cebf1e6fba6922f407a88 Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:38:19 +0200 Subject: [PATCH 5/6] fix: preserve computed permission rewrite coverage --- pkg/development/coverage/coverage.go | 170 +++++++++++++++++++--- pkg/development/coverage/coverage_test.go | 65 ++++++++- 2 files changed, 211 insertions(+), 24 deletions(-) diff --git a/pkg/development/coverage/coverage.go b/pkg/development/coverage/coverage.go index ebbe95657..29603e221 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -940,46 +940,170 @@ func (data componentCoverageData) isComputedRelationCovered( EntityID: reachedTarget.EntityID, EntityIDOnly: true, }} - entityDefinition := data.definitionsByEntity[reachedTarget.EntityType] + + return data.isComputedUserSetCovered(reachedTarget.EntityType, computedRelation, targets, visitedPermissions) +} + +func (data componentCoverageData) isComputedUserSetCovered( + entityName, + relation string, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + entityDefinition := data.definitionsByEntity[entityName] if entityDefinition == nil { - return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + return data.hasRelationship(entityName, relation, targets) } - switch entityDefinition.GetReferences()[computedRelation] { + switch entityDefinition.GetReferences()[relation] { case base.EntityDefinition_REFERENCE_RELATION: - return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + return data.hasRelationship(entityName, relation, targets) case base.EntityDefinition_REFERENCE_ATTRIBUTE: - return data.hasAttribute(reachedTarget.EntityType, computedRelation, targets) + return data.hasAttribute(entityName, relation, targets) case base.EntityDefinition_REFERENCE_PERMISSION: - key := formatAssertion(reachedTarget.EntityType, computedRelation) - if visitedPermissions[key] { - return false - } + return data.isPermissionCovered(entityName, relation, targets, visitedPermissions) + default: + return data.hasRelationship(entityName, relation, targets) + } +} - permission := entityDefinition.GetPermissions()[computedRelation] - if permission == nil || permission.GetChild() == nil { - return false - } +func (data componentCoverageData) isPermissionCovered( + entityName, + permissionName string, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + key := formatAssertion(entityName, permissionName) + if visitedPermissions[key] { + return false + } - visitedPermissions[key] = true - defer delete(visitedPermissions, key) + entityDefinition := data.definitionsByEntity[entityName] + if entityDefinition == nil { + return false + } - components := uniqueConditionComponents(extractConditionComponents(entityDefinition, permission.GetChild(), map[string]bool{ - computedRelation: true, - })) - if len(components) == 0 { + permission := entityDefinition.GetPermissions()[permissionName] + if permission == nil || permission.GetChild() == nil { + return false + } + + visitedPermissions[key] = true + defer delete(visitedPermissions, key) + + return data.isPermissionChildCovered(entityName, permission.GetChild(), targets, visitedPermissions) +} + +func (data componentCoverageData) isPermissionChildCovered( + entityName string, + child *base.Child, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + if child == nil { + return false + } + + if leaf := child.GetLeaf(); leaf != nil { + return data.isPermissionLeafCovered(entityName, leaf, targets, visitedPermissions) + } + + if rewrite := child.GetRewrite(); rewrite != nil { + return data.isPermissionRewriteCovered(entityName, rewrite, targets, visitedPermissions) + } + + return false +} + +func (data componentCoverageData) isPermissionRewriteCovered( + entityName string, + rewrite *base.Rewrite, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + children := rewrite.GetChildren() + if len(children) == 0 { + return false + } + + switch rewrite.GetRewriteOperation() { + case base.Rewrite_OPERATION_UNION: + for _, child := range children { + if data.isPermissionChildCovered(entityName, child, targets, visitedPermissions) { + return true + } + } + return false + case base.Rewrite_OPERATION_INTERSECTION: + for _, child := range children { + if !data.isPermissionChildCovered(entityName, child, targets, visitedPermissions) { + return false + } + } + return true + case base.Rewrite_OPERATION_EXCLUSION: + if len(children) <= 1 || !data.isPermissionChildCovered(entityName, children[0], targets, visitedPermissions) { return false } - - for _, component := range components { - if !data.isComponentCoveredWithVisited(reachedTarget.EntityType, component, targets, visitedPermissions) { + for _, child := range children[1:] { + if data.isPermissionChildCovered(entityName, child, targets, visitedPermissions) { return false } } return true default: - return data.hasRelationship(reachedTarget.EntityType, computedRelation, targets) + return false + } +} + +func (data componentCoverageData) isPermissionLeafCovered( + entityName string, + leaf *base.Leaf, + targets []assertionTarget, + visitedPermissions map[string]bool, +) bool { + if computedUserSet := leaf.GetComputedUserSet(); computedUserSet != nil { + return data.isComputedUserSetCovered(entityName, computedUserSet.GetRelation(), targets, visitedPermissions) + } + + if tupleToUserset := leaf.GetTupleToUserSet(); tupleToUserset != nil { + tupleSetRelation := "" + if tupleToUserset.GetTupleSet() != nil { + tupleSetRelation = tupleToUserset.GetTupleSet().GetRelation() + } + + computedRelation := "" + if tupleToUserset.GetComputed() != nil { + computedRelation = tupleToUserset.GetComputed().GetRelation() + } + + return data.hasTupleToUserset(entityName, ConditionComponent{ + Name: strings.Join(nonEmptyStrings(tupleSetRelation, computedRelation), "."), + Type: componentTupleToUserset, + TupleSetRelation: tupleSetRelation, + ComputedRelation: computedRelation, + }, targets, visitedPermissions) + } + + if computedAttribute := leaf.GetComputedAttribute(); computedAttribute != nil { + return data.hasAttribute(entityName, computedAttribute.GetName(), targets) + } + + if call := leaf.GetCall(); call != nil { + return data.isCallCovered(entityName, call, targets) + } + + return false +} + +func (data componentCoverageData) isCallCovered(entityName string, call *base.Call, targets []assertionTarget) bool { + for _, argument := range call.GetArguments() { + if computedAttribute := argument.GetComputedAttribute(); computedAttribute != nil && + !data.hasAttribute(entityName, computedAttribute.GetName(), targets) { + return false + } } + return true } // hasRelationship checks whether a relation appears for any asserted target entity. diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index bb0f40dd2..50bb5a8e6 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -328,7 +328,70 @@ var _ = Describe("coverage", func() { })).Should(Equal(true)) }) - It("Case 4: Facebook Groups", func() { + It("Case 4: Tuple-to-userset Permission Rewrite Semantics", func() { + sci := Run(file.Shape{ + Schema: ` + entity user {} + + entity folder { + relation admin @user + relation member @user + + permission access = admin or member + permission strict = admin and member + permission safe = admin not member + } + + entity document { + relation parent @folder + + permission view = parent.access + permission strict_view = parent.strict + permission safe_view = parent.safe + }`, + Relationships: []string{ + "document:1#parent@folder:1", + "folder:1#admin@user:1", + }, + Scenarios: []file.Scenario{ + { + Name: "rewrite semantics", + Description: "covers tuple-to-userset computed permissions according to their rewrites", + Checks: []file.Check{ + { + Entity: "document:1", + Subject: "user:1", + Assertions: map[string]bool{ + "view": true, + "strict_view": false, + "safe_view": true, + }, + }, + }, + }, + }, + }) + + documentCoverage := findEntityCoverage(sci, "document") + conditionCoverage := documentCoverage.PermissionConditionCoverage["rewrite semantics"] + + Expect(conditionCoverage["view"].CoveragePercent).Should(Equal(100)) + Expect(conditionComponentNames(conditionCoverage["view"].CoveredComponents)).Should(Equal([]string{ + "tuple_to_userset:parent.access", + })) + + Expect(conditionCoverage["strict_view"].CoveragePercent).Should(Equal(0)) + Expect(conditionComponentNames(conditionCoverage["strict_view"].UncoveredComponents)).Should(Equal([]string{ + "tuple_to_userset:parent.strict", + })) + + Expect(conditionCoverage["safe_view"].CoveragePercent).Should(Equal(100)) + Expect(conditionComponentNames(conditionCoverage["safe_view"].CoveredComponents)).Should(Equal([]string{ + "tuple_to_userset:parent.safe", + })) + }) + + It("Case 5: Facebook Groups", func() { sci := Run(file.Shape{ Schema: ` entity user {} From 203feb6efd52a7f360c095b6608bb185a6570ecc Mon Sep 17 00:00:00 2001 From: hieu-lee Date: Mon, 11 May 2026 07:53:47 +0200 Subject: [PATCH 6/6] test: fail fast on missing coverage entity --- pkg/development/coverage/coverage_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index 50bb5a8e6..5e069d014 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -681,5 +681,6 @@ func findEntityCoverage(sci SchemaCoverageInfo, entityName string) EntityCoverag return entityCoverage } } + Fail("entity coverage for " + entityName + " not found") return EntityCoverageInfo{} }