diff --git a/README.md b/README.md index f29389ad8..3210a115a 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ Permify Go Report Card  Permify Licence  Permify Discord Channel  - Permify Release  - Permify Commit Activity  - GitHub Workflow Status  - Scrutinizer code quality (GitHub/Bitbucket)  + Permify Release  + Permify Commit Activity  + GitHub Workflow Status  + Scrutinizer code quality (GitHub/Bitbucket)  Codecov  - Gurubase - Ask AI + Gurubase - Ask AI

![permify-centralized](https://github.com/user-attachments/assets/124eaa43-5d33-423d-a258-5d6f4afbc774) diff --git a/go.work.sum b/go.work.sum index bbccad77c..22cc06c52 100644 --- a/go.work.sum +++ b/go.work.sum @@ -119,6 +119,7 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/IBM/sarama v1.43.1/go.mod h1:GG5q1RURtDNPz8xxJs3mgX6Ytak8Z9eLhAkJPObe2xE= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -139,11 +140,13 @@ github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFP github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= @@ -198,6 +201,7 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPO github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/timandy/routine v1.1.6/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= @@ -210,6 +214,7 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= diff --git a/internal/schema/linked_schema_test.go b/internal/schema/linked_schema_test.go index 44e1206b2..9c99db360 100644 --- a/internal/schema/linked_schema_test.go +++ b/internal/schema/linked_schema_test.go @@ -1822,4 +1822,481 @@ var _ = Describe("linked schema", func() { })) }) }) + + Context("BuildRelationPathChain", func() { + It("should find direct relation path", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("document", "user") + + Expect(err).ShouldNot(HaveOccurred()) + Expect(path).Should(HaveLen(1)) + Expect(path[0].Type).Should(Equal("document")) + Expect(path[0].Relation).Should(Equal("viewer")) + }) + + It("should find multi-hop path", func() { + sch, err := parser.NewParser(` + entity user {} + entity organization { + relation admin @user + } + entity document { + relation org @organization + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("document", "user") + + Expect(err).ShouldNot(HaveOccurred()) + Expect(path).Should(HaveLen(2)) + Expect(path[0].Type).Should(Equal("document")) + Expect(path[0].Relation).Should(Equal("org")) + Expect(path[1].Type).Should(Equal("organization")) + Expect(path[1].Relation).Should(Equal("admin")) + }) + + It("should return error when no path found", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + entity isolated { + relation owner @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("document", "isolated") + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(Equal("no path found between entity types")) + Expect(path).Should(BeNil()) + }) + + It("should find path in complex multi-level schema", func() { + sch, err := parser.NewParser(` + entity user {} + entity organization { + relation admin @user + } + entity container { + relation parent @organization + } + entity document { + relation container @container + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("document", "user") + + Expect(err).ShouldNot(HaveOccurred()) + Expect(path).Should(HaveLen(3)) + Expect(path[0].Type).Should(Equal("document")) + Expect(path[0].Relation).Should(Equal("container")) + Expect(path[1].Type).Should(Equal("container")) + Expect(path[1].Relation).Should(Equal("parent")) + Expect(path[2].Type).Should(Equal("organization")) + Expect(path[2].Relation).Should(Equal("admin")) + }) + + It("should handle self-referential relation (same source and target entity type)", func() { + sch, err := parser.NewParser(` + entity user {} + entity account { + relation admin @user @account#admin + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("account", "account") + + Expect(err).ShouldNot(HaveOccurred()) + Expect(path).Should(HaveLen(1)) + Expect(path[0].Type).Should(Equal("account")) + Expect(path[0].Relation).Should(Equal("admin")) + }) + + It("should handle non-existent source entity", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("nonexistent", "user") + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(Equal("no path found between entity types")) + Expect(path).Should(BeNil()) + }) + + It("should handle non-existent target entity", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + path, err := g.BuildRelationPathChain("document", "nonexistent") + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(Equal("no path found between entity types")) + Expect(path).Should(BeNil()) + }) + }) + + Context("GetSubjectRelationForPathWalk", func() { + It("should return empty string for simple relation without relation name", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("document", "viewer", "user") + + Expect(relation).Should(Equal("")) + }) + + It("should return relation for complex relation with relation name", func() { + sch, err := parser.NewParser(` + entity user {} + entity group { + relation member @user + } + entity document { + relation viewer @user @group#member + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("document", "viewer", "group") + + Expect(relation).Should(Equal("member")) + }) + + It("should return empty string for non-existent entity", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("nonexistent", "viewer", "user") + + Expect(relation).Should(Equal("")) + }) + + It("should return empty string for non-existent relation", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("document", "nonexistent", "user") + + Expect(relation).Should(Equal("")) + }) + + It("should return empty string when target entity type not found in relation references", func() { + sch, err := parser.NewParser(` + entity user {} + entity organization { + relation admin @user + } + entity document { + relation viewer @user + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("document", "viewer", "organization") + + Expect(relation).Should(Equal("")) + }) + + It("should return correct relation for multiple relation references", func() { + sch, err := parser.NewParser(` + entity user {} + entity group { + relation member @user + relation admin @user + } + entity document { + relation viewer @user @group#member @group#admin + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + relation := g.GetSubjectRelationForPathWalk("document", "viewer", "group") + + Expect(relation).Should(Equal("member")) + }) + }) + + Context("Attribute Reference", func() { + It("should return AttributeLinkedEntrance for direct attribute reference", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + attribute public boolean + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + schema := NewSchemaFromEntityAndRuleDefinitions(a, nil) + // Manually set attribute as reference to test the attribute reference case + docDef := schema.EntityDefinitions["document"] + docDef.References["public"] = base.EntityDefinition_REFERENCE_ATTRIBUTE + + g := NewLinkedGraph(schema) + + ent, err := g.LinkedEntrances(&base.Entrance{ + Type: "document", + Value: "public", + }, &base.Entrance{ + Type: "user", + Value: "", + }) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(ent).Should(Equal([]*LinkedEntrance{ + { + Kind: AttributeLinkedEntrance, + TargetEntrance: &base.Entrance{ + Type: "document", + Value: "public", + }, + TupleSetRelation: "", + }, + })) + }) + + It("should return error when attribute not found", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + attribute public boolean + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + schema := NewSchemaFromEntityAndRuleDefinitions(a, nil) + // Manually set nonexistent attribute as reference to test the attribute reference case + // This will trigger the attribute reference case but attribute won't be found + docDef := schema.EntityDefinitions["document"] + docDef.References["nonexistent_attribute"] = base.EntityDefinition_REFERENCE_ATTRIBUTE + + g := NewLinkedGraph(schema) + + ent, err := g.LinkedEntrances(&base.Entrance{ + Type: "document", + Value: "nonexistent_attribute", + }, &base.Entrance{ + Type: "user", + Value: "", + }) + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(Equal("attribute not found")) + Expect(ent).Should(BeNil()) + }) + + It("should return AttributeLinkedEntrance for multiple attributes", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + attribute public boolean + attribute published boolean + attribute archived boolean + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + schema := NewSchemaFromEntityAndRuleDefinitions(a, nil) + // Manually set attribute as reference to test the attribute reference case + docDef := schema.EntityDefinitions["document"] + docDef.References["published"] = base.EntityDefinition_REFERENCE_ATTRIBUTE + + g := NewLinkedGraph(schema) + + ent, err := g.LinkedEntrances(&base.Entrance{ + Type: "document", + Value: "published", + }, &base.Entrance{ + Type: "user", + Value: "", + }) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(ent).Should(Equal([]*LinkedEntrance{ + { + Kind: AttributeLinkedEntrance, + TargetEntrance: &base.Entrance{ + Type: "document", + Value: "published", + }, + TupleSetRelation: "", + }, + })) + }) + + It("should return AttributeLinkedEntrance for different attribute types", func() { + sch, err := parser.NewParser(` + entity user {} + entity document { + relation viewer @user + attribute public boolean + attribute count integer + attribute name string + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + schema := NewSchemaFromEntityAndRuleDefinitions(a, nil) + // Manually set attribute as reference to test the attribute reference case + docDef := schema.EntityDefinitions["document"] + docDef.References["count"] = base.EntityDefinition_REFERENCE_ATTRIBUTE + + g := NewLinkedGraph(schema) + + ent, err := g.LinkedEntrances(&base.Entrance{ + Type: "document", + Value: "count", + }, &base.Entrance{ + Type: "user", + Value: "", + }) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(ent).Should(Equal([]*LinkedEntrance{ + { + Kind: AttributeLinkedEntrance, + TargetEntrance: &base.Entrance{ + Type: "document", + Value: "count", + }, + TupleSetRelation: "", + }, + })) + }) + }) })