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..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... 🚀") @@ -233,6 +286,20 @@ 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 + var writtenTuples []*base.Tuple + if len(scenario.Relationships) > 0 { + color.Notice.Println(" scenario relationships:") + 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:") + writtenAttrs = writeAttributes(ctx, dev, scenario.Attributes, version, list, " ") + } + // Start log output for checks color.Notice.Println(" checks:") @@ -469,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/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..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,102 +295,24 @@ 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 - } - } - - // 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 - } + // Write global relationships + _, errs := c.writeRelationships(ctx, version, shape.Relationships, "relationships", "") + errors = append(errors, errs...) - // 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 + writtenTuples, errs := c.writeRelationships(ctx, version, scenario.Relationships, "scenarios", i) + errors = append(errors, errs...) + + // 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 { entity, err := tuple.E(check.Entity) @@ -515,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 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"`