From b365bf4a1b86c6c1a6253ba642f4b662bf635cb9 Mon Sep 17 00:00:00 2001 From: blessuselessk <261668912+blessuselessk@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:09:46 -0600 Subject: [PATCH 1/2] feat: integrate scenario-specific relationships and attributes in schema files Add support for defining relationships and attributes within individual scenarios, in addition to the existing global definitions. This enables more focused and contextual testing while maintaining backward compatibility. Changes: - Add Relationships and Attributes fields to Scenario struct - Process scenario-specific relationships/attributes in development engine (RunWithShape) and CLI validate command before running checks - Update coverage calculations to aggregate global and scenario-specific relationships/attributes for accurate coverage reporting - Add test cases verifying scenario-specific coverage calculations - Update example YAML schemas (custom-roles, banking-system, user-groups, organizations-hierarchies) to demonstrate the new capability Fixes #838 --- assets/example-shapes/banking-system.yaml | 16 ++- assets/example-shapes/custom-roles.yaml | 9 +- .../organizations-hierarchies.yaml | 11 ++ assets/example-shapes/user-groups.yaml | 28 +++++ pkg/cmd/validate.go | 74 +++++++++++ pkg/development/coverage/coverage.go | 22 +++- pkg/development/coverage/coverage_test.go | 118 ++++++++++++++++++ pkg/development/development.go | 86 +++++++++++++ pkg/development/file/shape.go | 8 ++ 9 files changed, 363 insertions(+), 9 deletions(-) diff --git a/assets/example-shapes/banking-system.yaml b/assets/example-shapes/banking-system.yaml index 50207295a..d6c397130 100644 --- a/assets/example-shapes/banking-system.yaml +++ b/assets/example-shapes/banking-system.yaml @@ -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 \ No newline at end of file diff --git a/assets/example-shapes/custom-roles.yaml b/assets/example-shapes/custom-roles.yaml index 85b76bcf9..5f352ccd9 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" 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..10721fe30 100644 --- a/pkg/development/coverage/coverage.go +++ b/pkg/development/coverage/coverage.go @@ -177,22 +177,36 @@ func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape) []EntityC func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverageInfo { entityCoverageInfo := newEntityCoverageInfo(ref.EntityName) - // Calculate relationships coverage + // 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...) + } + + // Calculate relationships coverage using combined set entityCoverageInfo.UncoveredRelationships = findUncoveredRelationships( ref.EntityName, ref.Relationships, - shape.Relationships, + allRelationships, ) entityCoverageInfo.CoverageRelationshipsPercent = calculateCoveragePercent( ref.Relationships, entityCoverageInfo.UncoveredRelationships, ) - // Calculate attributes coverage + // Collect all attributes: global + scenario-specific + allAttributes := make([]string, len(shape.Attributes)) + copy(allAttributes, shape.Attributes) + for _, scenario := range shape.Scenarios { + allAttributes = append(allAttributes, scenario.Attributes...) + } + + // Calculate attributes coverage using combined set entityCoverageInfo.UncoveredAttributes = findUncoveredAttributes( ref.EntityName, ref.Attributes, - shape.Attributes, + allAttributes, ) entityCoverageInfo.CoverageAttributesPercent = calculateCoveragePercent( ref.Attributes, diff --git a/pkg/development/coverage/coverage_test.go b/pkg/development/coverage/coverage_test.go index 17c4315a4..4675ea94b 100644 --- a/pkg/development/coverage/coverage_test.go +++ b/pkg/development/coverage/coverage_test.go @@ -505,6 +505,124 @@ 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{}, + }, + }, + }) + + // The scenario-specific relationship "repository:1#parent@organization:1" + // should be included in coverage calculations + 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)) + // 1 out of 3 relationships covered = 33% + 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{}, + }, + }, + }) + + // The scenario-specific attribute should be included in coverage + 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"` From c1efe79e1d86f2568dc7fd664daad06ed5fffa6a Mon Sep 17 00:00:00 2001 From: blessuselessk <261668912+blessuselessk@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:40:44 -0600 Subject: [PATCH 2/2] refactor: extract helper functions and clean up scenario data after each scenario Address CodeRabbit review feedback: 1. Fix scenario data persistence: Relationships and attributes written for a scenario are now deleted after that scenario's checks complete, preventing pollution of subsequent scenarios. 2. Reduce code duplication: Extract writeRelationships, writeAttributes, deleteRelationships, and deleteAttributes helper functions in both development.go and validate.go. The repeated parse-validate-write logic for global and scenario-specific data now shares the same code path. --- pkg/cmd/validate.go | 287 +++++++++++++++--------------- pkg/development/development.go | 313 +++++++++++++++------------------ 2 files changed, 282 insertions(+), 318 deletions(-) diff --git a/pkg/cmd/validate.go b/pkg/cmd/validate.go index 074e4cacc..f7b3fa8f0 100644 --- a/pkg/cmd/validate.go +++ b/pkg/cmd/validate.go @@ -62,6 +62,133 @@ func (l *ErrList) Print() { color.Danger.Println("FAILED") } +// writeRelationships validates and writes relationship tuples to the datastore. +// Returns the written tuples (for cleanup) and whether any errors occurred. +func writeRelationships( + ctx context.Context, + dev *development.Development, + relationships []string, + version string, + list *ErrList, + indent string, +) []*base.Tuple { + var written []*base.Tuple + for _, t := range relationships { + tup, err := tuple.Tuple(t) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf("%sfail: %s\n", indent, 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("%sfail: %s\n", indent, validationError(err.Error())) + continue + } + + if err = serverValidation.ValidateTuple(definition, tup); err != nil { + list.Add(err.Error()) + color.Danger.Printf("%sfail: %s\n", indent, validationError(err.Error())) + continue + } + + if _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()); err != nil { + list.Add(fmt.Sprintf("%s failed %s", t, err.Error())) + color.Danger.Println(fmt.Sprintf("%sfail: %s failed %s", indent, t, validationError(err.Error()))) + continue + } + + color.Success.Println(fmt.Sprintf("%ssuccess: %s ", indent, t)) + written = append(written, tup) + } + return written +} + +// writeAttributes validates and writes attributes to the datastore. +// Returns the written attributes (for cleanup) and whether any errors occurred. +func writeAttributes( + ctx context.Context, + dev *development.Development, + attributes []string, + version string, + list *ErrList, + indent string, +) []*base.Attribute { + var written []*base.Attribute + for _, a := range attributes { + attr, err := attribute.Attribute(a) + if err != nil { + list.Add(err.Error()) + color.Danger.Printf("%sfail: %s\n", indent, 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("%sfail: %s\n", indent, validationError(err.Error())) + continue + } + + if err = serverValidation.ValidateAttribute(definition, attr); err != nil { + list.Add(err.Error()) + color.Danger.Printf("%sfail: %s\n", indent, validationError(err.Error())) + continue + } + + if _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)); err != nil { + list.Add(fmt.Sprintf("%s failed %s", a, err.Error())) + color.Danger.Println(fmt.Sprintf("%sfail: %s failed %s", indent, a, validationError(err.Error()))) + continue + } + + color.Success.Println(fmt.Sprintf("%ssuccess: %s ", indent, a)) + written = append(written, attr) + } + return written +} + +// deleteRelationships removes previously written relationship tuples from the datastore. +func deleteRelationships(ctx context.Context, dev *development.Development, tuples []*base.Tuple) error { + for _, tup := range tuples { + _, err := dev.Container.DW.Delete(ctx, "t1", &base.TupleFilter{ + Entity: &base.EntityFilter{ + Type: tup.GetEntity().GetType(), + Ids: []string{tup.GetEntity().GetId()}, + }, + Relation: tup.GetRelation(), + Subject: &base.SubjectFilter{ + Type: tup.GetSubject().GetType(), + Ids: []string{tup.GetSubject().GetId()}, + Relation: tup.GetSubject().GetRelation(), + }, + }, &base.AttributeFilter{}) + if err != nil { + return err + } + } + return nil +} + +// deleteAttributes removes previously written attributes from the datastore. +func deleteAttributes(ctx context.Context, dev *development.Development, attrs []*base.Attribute) error { + for _, attr := range attrs { + _, err := dev.Container.DW.Delete(ctx, "t1", &base.TupleFilter{}, &base.AttributeFilter{ + Entity: &base.EntityFilter{ + Type: attr.GetEntity().GetType(), + Ids: []string{attr.GetEntity().GetId()}, + }, + Attributes: []string{attr.GetAttribute()}, + }) + if err != nil { + return err + } + } + return nil +} + // validate returns a function that validates authorization model with assertions func validate() func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { @@ -144,87 +271,13 @@ func validate() func(cmd *cobra.Command, args []string) error { color.Success.Println(" success") } - // if debug is true, print relationships are creating with color blue + // Write global relationships color.Notice.Println("relationships are creating... 🚀") + writeRelationships(ctx, dev, s.Relationships, version, list, " ") - // Iterate over all relationships in the subject - for _, t := range s.Relationships { - // Convert each relationship to a Tuple - var tup *base.Tuple - tup, err = tuple.Tuple(t) - // If an error occurs during the conversion, add the error message to the list and continue to the next iteration - if err != nil { - list.Add(err.Error()) - continue - } - - // Retrieve the entity definition associated with the tuple's entity type - definition, _, err := dev.Container.SR.ReadEntityDefinition(ctx, "t1", tup.GetEntity().GetType(), version) - // If an error occurs while reading the entity definition, return the error - if err != nil { - return err - } - - // Validate the tuple using the entity definition - err = serverValidation.ValidateTuple(definition, tup) - // If an error occurs during validation, return the error - if err != nil { - return err - } - - // Write the validated tuple to the database - _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()) - // If an error occurs while writing to the database, add an error message to the list, log the error and continue to the next iteration - 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 - } - - // If the tuple was successfully written to the database, log a success message - color.Success.Println(fmt.Sprintf(" success: %s ", t)) - } - - // if debug is true, print attributes are creating with color blue + // Write global attributes color.Notice.Println("attributes are creating... 🚀") - - // Iterate over all attributes in the subject - for _, a := range s.Attributes { - // Convert each attribute to an Attribute - var attr *base.Attribute - attr, err = attribute.Attribute(a) - // If an error occurs during the conversion, add the error message to the list and continue to the next iteration - if err != nil { - list.Add(err.Error()) - continue - } - - // Retrieve the entity definition associated with the attribute's entity type - definition, _, err := dev.Container.SR.ReadEntityDefinition(ctx, "t1", attr.GetEntity().GetType(), version) - // If an error occurs while reading the entity definition, return the error - if err != nil { - return err - } - - // Validate the attribute using the entity definition - err = serverValidation.ValidateAttribute(definition, attr) - // If an error occurs during validation, return the error - if err != nil { - return err - } - - // Write the validated attribute to the database - _, err = dev.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)) - // If an error occurs while writing to the database, add an error message to the list, log the error and continue to the next iteration - 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 - } - - // If the attribute was successfully written to the database, log a success message - color.Success.Println(fmt.Sprintf(" success: %s ", a)) - } + writeAttributes(ctx, dev, s.Attributes, version, list, " ") // if debug is true, print checking assertions with color blue color.Notice.Println("checking scenarios... 🚀") @@ -234,77 +287,17 @@ func validate() func(cmd *cobra.Command, args []string) error { color.Notice.Printf("%v.scenario: %s - %s\n", sn+1, scenario.Name, scenario.Description) // Write scenario-specific relationships if any are defined + var writtenTuples []*base.Tuple 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)) - } + writtenTuples = writeRelationships(ctx, dev, scenario.Relationships, version, list, " ") } // Write scenario-specific attributes if any are defined + var writtenAttrs []*base.Attribute 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)) - } + writtenAttrs = writeAttributes(ctx, dev, scenario.Attributes, version, list, " ") } // Start log output for checks @@ -543,6 +536,16 @@ func validate() func(cmd *cobra.Command, args []string) error { } } } + + // Clean up scenario-specific data so it doesn't pollute subsequent scenarios + if err := deleteRelationships(ctx, dev, writtenTuples); err != nil { + list.Add(fmt.Sprintf("scenario %d cleanup relationships: %s", sn+1, err.Error())) + color.Danger.Printf(" fail: cleanup relationships: %s\n", validationError(err.Error())) + } + if err := deleteAttributes(ctx, dev, writtenAttrs); err != nil { + list.Add(fmt.Sprintf("scenario %d cleanup attributes: %s", sn+1, err.Error())) + color.Danger.Printf(" fail: cleanup attributes: %s\n", validationError(err.Error())) + } } // If the error list is not empty, there were some errors during processing. diff --git a/pkg/development/development.go b/pkg/development/development.go index da591d8ee..52575feef 100644 --- a/pkg/development/development.go +++ b/pkg/development/development.go @@ -111,6 +111,115 @@ type Error struct { Message string `json:"message"` } +// writeRelationships validates and writes relationship tuples to the datastore. +// It returns the parsed tuples (for later cleanup) and any errors encountered. +func (c *Development) writeRelationships(ctx context.Context, version string, relationships []string, errType string, errKey any) (written []*v1.Tuple, errors []Error) { + for _, t := range relationships { + tup, err := tuple.Tuple(t) + if err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "relationship", t, err)}) + continue + } + + definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", tup.GetEntity().GetType(), version) + if err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "relationship", t, err)}) + continue + } + + if err = validation.ValidateTuple(definition, tup); err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "relationship", t, err)}) + continue + } + + if _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()); err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "relationship", t, err)}) + continue + } + + written = append(written, tup) + } + return written, errors +} + +// writeAttributes validates and writes attributes to the datastore. +// It returns the parsed attributes (for later cleanup) and any errors encountered. +func (c *Development) writeAttributes(ctx context.Context, version string, attributes []string, errType string, errKey any) (written []*v1.Attribute, errors []Error) { + for _, a := range attributes { + attr, err := attribute.Attribute(a) + if err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "attribute", a, err)}) + continue + } + + definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", attr.GetEntity().GetType(), version) + if err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "attribute", a, err)}) + continue + } + + if err = validation.ValidateAttribute(definition, attr); err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "attribute", a, err)}) + continue + } + + if _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)); err != nil { + errors = append(errors, Error{Type: errType, Key: errKey, Message: formatErrMsg(errType, "attribute", a, err)}) + continue + } + + written = append(written, attr) + } + return written, errors +} + +// deleteRelationships removes previously written relationship tuples from the datastore. +func (c *Development) deleteRelationships(ctx context.Context, tuples []*v1.Tuple) error { + for _, tup := range tuples { + _, err := c.Container.DW.Delete(ctx, "t1", &v1.TupleFilter{ + Entity: &v1.EntityFilter{ + Type: tup.GetEntity().GetType(), + Ids: []string{tup.GetEntity().GetId()}, + }, + Relation: tup.GetRelation(), + Subject: &v1.SubjectFilter{ + Type: tup.GetSubject().GetType(), + Ids: []string{tup.GetSubject().GetId()}, + Relation: tup.GetSubject().GetRelation(), + }, + }, &v1.AttributeFilter{}) + if err != nil { + return err + } + } + return nil +} + +// deleteAttributes removes previously written attributes from the datastore. +func (c *Development) deleteAttributes(ctx context.Context, attrs []*v1.Attribute) error { + for _, attr := range attrs { + _, err := c.Container.DW.Delete(ctx, "t1", &v1.TupleFilter{}, &v1.AttributeFilter{ + Entity: &v1.EntityFilter{ + Type: attr.GetEntity().GetType(), + Ids: []string{attr.GetEntity().GetId()}, + }, + Attributes: []string{attr.GetAttribute()}, + }) + if err != nil { + return err + } + } + return nil +} + +// formatErrMsg formats an error message, prefixing with kind info for scenario errors. +func formatErrMsg(errType, kind, item string, err error) string { + if errType == "scenarios" { + return fmt.Sprintf("%s: %s: %s", kind, item, err.Error()) + } + return err.Error() +} + func (c *Development) Run(ctx context.Context, shape map[string]interface{}) (errors []Error) { // Marshal the shape map into YAML format out, err := yaml.Marshal(shape) @@ -186,187 +295,23 @@ func (c *Development) RunWithShape(ctx context.Context, shape *file.Shape) (erro return errors } - // Each item in the Relationships slice is processed individually - for _, t := range shape.Relationships { - tup, err := tuple.Tuple(t) - if err != nil { - errors = append(errors, Error{ - Type: "relationships", - Key: t, - Message: err.Error(), - }) - continue - } - - // Read the schema definition for this relationship - definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", tup.GetEntity().GetType(), version) - if err != nil { - errors = append(errors, Error{ - Type: "relationships", - Key: t, - Message: err.Error(), - }) - continue - } - - // Validate the relationship tuple against the schema definition - err = validation.ValidateTuple(definition, tup) - if err != nil { - errors = append(errors, Error{ - Type: "relationships", - Key: t, - Message: err.Error(), - }) - continue - } - - // Write the relationship to the database - _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(tup), database.NewAttributeCollection()) - // Continue to the next relationship if an error occurred - if err != nil { - errors = append(errors, Error{ - Type: "relationships", - Key: t, - Message: err.Error(), - }) - continue - } - } + // Write global relationships + _, errs := c.writeRelationships(ctx, version, shape.Relationships, "relationships", "") + errors = append(errors, errs...) - // Each item in the Attributes slice is processed individually - for _, a := range shape.Attributes { - attr, err := attribute.Attribute(a) - if err != nil { - errors = append(errors, Error{ - Type: "attributes", - Key: a, - Message: err.Error(), - }) - continue - } - - // Read the schema definition for this attribute - definition, _, err := c.Container.SR.ReadEntityDefinition(ctx, "t1", attr.GetEntity().GetType(), version) - if err != nil { - errors = append(errors, Error{ - Type: "attributes", - Key: a, - Message: err.Error(), - }) - continue - } - - // Validate the attribute against the schema definition - err = validation.ValidateAttribute(definition, attr) - if err != nil { - errors = append(errors, Error{ - Type: "attributes", - Key: a, - Message: err.Error(), - }) - continue - } - - // Write the attribute to the database - _, err = c.Container.DW.Write(ctx, "t1", database.NewTupleCollection(), database.NewAttributeCollection(attr)) - // Continue to the next attribute if an error occurred - if err != nil { - errors = append(errors, Error{ - Type: "attributes", - Key: a, - Message: err.Error(), - }) - continue - } - } + // Write global attributes + _, errs = c.writeAttributes(ctx, version, shape.Attributes, "attributes", "") + errors = append(errors, errs...) // 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 - } + // Write scenario-specific relationships + writtenTuples, errs := c.writeRelationships(ctx, version, scenario.Relationships, "scenarios", i) + errors = append(errors, errs...) - 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 - } - } + // Write scenario-specific attributes + writtenAttrs, errs := c.writeAttributes(ctx, version, scenario.Attributes, "scenarios", i) + errors = append(errors, errs...) // Each Check in the current scenario is processed for _, check := range scenario.Checks { @@ -601,6 +546,22 @@ func (c *Development) RunWithShape(ctx context.Context, shape *file.Shape) (erro } } } + + // Clean up scenario-specific data so it doesn't pollute subsequent scenarios + if err := c.deleteRelationships(ctx, writtenTuples); err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("cleanup relationships: %s", err.Error()), + }) + } + if err := c.deleteAttributes(ctx, writtenAttrs); err != nil { + errors = append(errors, Error{ + Type: "scenarios", + Key: i, + Message: fmt.Sprintf("cleanup attributes: %s", err.Error()), + }) + } } return errors