snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
13
schema/.snapshots/TestLexer-suite=snapshots-comments.json
Normal file
13
schema/.snapshots/TestLexer-suite=snapshots-comments.json
Normal file
@@ -0,0 +1,13 @@
|
||||
[
|
||||
"/**/",
|
||||
"/** doc comment\n * content\n * more content\n */",
|
||||
"class",
|
||||
"'name'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"// Block comment",
|
||||
"/*\n\täny ünicöde characterß???\n\t*/",
|
||||
"}",
|
||||
"EOF"
|
||||
]
|
||||
3
schema/.snapshots/TestLexer-suite=snapshots-empty.json
Normal file
3
schema/.snapshots/TestLexer-suite=snapshots-empty.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"EOF"
|
||||
]
|
||||
142
schema/.snapshots/TestLexer-suite=snapshots-full_class.json
Normal file
142
schema/.snapshots/TestLexer-suite=snapshots-full_class.json
Normal file
@@ -0,0 +1,142 @@
|
||||
[
|
||||
"class",
|
||||
"'File'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"'related'",
|
||||
":",
|
||||
"{",
|
||||
"'parents'",
|
||||
":",
|
||||
"'File'",
|
||||
"[",
|
||||
"]",
|
||||
"'viewers'",
|
||||
":",
|
||||
"'User'",
|
||||
"[",
|
||||
"]",
|
||||
"'owners'",
|
||||
":",
|
||||
"'User'",
|
||||
"[",
|
||||
"]",
|
||||
"'siblings'",
|
||||
":",
|
||||
"'File'",
|
||||
"[",
|
||||
"]",
|
||||
"}",
|
||||
"'permits'",
|
||||
"=",
|
||||
"{",
|
||||
"'view'",
|
||||
":",
|
||||
"(",
|
||||
"ctx",
|
||||
":",
|
||||
"'Context'",
|
||||
")",
|
||||
":",
|
||||
"'boolean'",
|
||||
"=\u003e",
|
||||
"this",
|
||||
".",
|
||||
"'related'",
|
||||
".",
|
||||
"'parents'",
|
||||
".",
|
||||
"'some'",
|
||||
"(",
|
||||
"'p'",
|
||||
"=\u003e",
|
||||
"'p'",
|
||||
".",
|
||||
"'permits'",
|
||||
".",
|
||||
"'view'",
|
||||
"(",
|
||||
"ctx",
|
||||
")",
|
||||
")",
|
||||
"||",
|
||||
"this",
|
||||
".",
|
||||
"'related'",
|
||||
".",
|
||||
"'viewers'",
|
||||
".",
|
||||
"'includes'",
|
||||
"(",
|
||||
"ctx",
|
||||
".",
|
||||
"'subject'",
|
||||
")",
|
||||
"||",
|
||||
"!",
|
||||
"this",
|
||||
".",
|
||||
"'related'",
|
||||
".",
|
||||
"'owners'",
|
||||
".",
|
||||
"'includes'",
|
||||
"(",
|
||||
"ctx",
|
||||
".",
|
||||
"'subject'",
|
||||
")",
|
||||
",",
|
||||
"'edit'",
|
||||
":",
|
||||
"(",
|
||||
"ctx",
|
||||
":",
|
||||
"'Context'",
|
||||
")",
|
||||
"=\u003e",
|
||||
"this",
|
||||
".",
|
||||
"'related'",
|
||||
".",
|
||||
"'owners'",
|
||||
".",
|
||||
"'includes'",
|
||||
"(",
|
||||
"ctx",
|
||||
".",
|
||||
"'subject'",
|
||||
")",
|
||||
",",
|
||||
"'rename'",
|
||||
":",
|
||||
"(",
|
||||
"ctx",
|
||||
":",
|
||||
"'Context'",
|
||||
")",
|
||||
"=\u003e",
|
||||
"this",
|
||||
".",
|
||||
"'related'",
|
||||
".",
|
||||
"'siblings'",
|
||||
".",
|
||||
"'some'",
|
||||
"(",
|
||||
"'s'",
|
||||
"=\u003e",
|
||||
"'s'",
|
||||
".",
|
||||
"'permits'",
|
||||
".",
|
||||
"'edit'",
|
||||
"(",
|
||||
"ctx",
|
||||
")",
|
||||
")",
|
||||
"}",
|
||||
"}",
|
||||
"EOF"
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
"'import'",
|
||||
"{",
|
||||
"'Foo'",
|
||||
",",
|
||||
"'Bar'",
|
||||
",",
|
||||
"'Baz'",
|
||||
"}",
|
||||
"'from'",
|
||||
"'@ory/keto-...'",
|
||||
"EOF"
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
"class",
|
||||
"'name'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"'metadata'",
|
||||
"=",
|
||||
"{",
|
||||
"'id'",
|
||||
":",
|
||||
"'123'",
|
||||
"}",
|
||||
"}",
|
||||
"EOF"
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
"class",
|
||||
"'X'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"'related'",
|
||||
":",
|
||||
"{",
|
||||
"'relation'",
|
||||
":",
|
||||
"'SubjectSet'",
|
||||
"\u003c",
|
||||
"'R'",
|
||||
",",
|
||||
"'someProp'",
|
||||
"\u003e",
|
||||
"[",
|
||||
"]",
|
||||
"}",
|
||||
"}",
|
||||
"EOF"
|
||||
]
|
||||
39
schema/.snapshots/TestLexer-suite=snapshots-two_classes.json
Normal file
39
schema/.snapshots/TestLexer-suite=snapshots-two_classes.json
Normal file
@@ -0,0 +1,39 @@
|
||||
[
|
||||
"class",
|
||||
"'user'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"}",
|
||||
"class",
|
||||
"'document'",
|
||||
"implements",
|
||||
"'Namespace'",
|
||||
"{",
|
||||
"'related'",
|
||||
":",
|
||||
"{",
|
||||
"'owners'",
|
||||
":",
|
||||
"'user'",
|
||||
"[",
|
||||
"]",
|
||||
"'editors'",
|
||||
":",
|
||||
"'user'",
|
||||
"[",
|
||||
"]",
|
||||
"'viewers'",
|
||||
":",
|
||||
"'user'",
|
||||
"[",
|
||||
"]",
|
||||
"'parent'",
|
||||
":",
|
||||
"'document'",
|
||||
"[",
|
||||
"]",
|
||||
"}",
|
||||
"}",
|
||||
"EOF"
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"Resource": [
|
||||
{
|
||||
"name": "admins",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Role",
|
||||
"relation": "member"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "supervisors",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Role",
|
||||
"relation": "member"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "annotators",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Role",
|
||||
"relation": "member"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "medicalAnnotators",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Role",
|
||||
"relation": "member"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "read",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "admins",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "annotators",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "medicalAnnotators",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "supervisors",
|
||||
"computed_subject_set_relation": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "read"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "admins",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "annotators",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "medicalAnnotators",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "supervisors",
|
||||
"computed_subject_set_relation": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "admins",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "annotators",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "supervisors",
|
||||
"computed_subject_set_relation": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "approve",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "admins",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "supervisors",
|
||||
"computed_subject_set_relation": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "admins",
|
||||
"computed_subject_set_relation": "member"
|
||||
},
|
||||
{
|
||||
"relation": "supervisors",
|
||||
"computed_subject_set_relation": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Role": [
|
||||
{
|
||||
"name": "member",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Role"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
169
schema/.snapshots/TestParser-suite=snapshots-full_example.json
Normal file
169
schema/.snapshots/TestParser-suite=snapshots-full_example.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"File": [
|
||||
{
|
||||
"name": "parents",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "File"
|
||||
},
|
||||
{
|
||||
"namespace": "Folder"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "viewers",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
},
|
||||
{
|
||||
"namespace": "Group",
|
||||
"relation": "members"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "owners",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
},
|
||||
{
|
||||
"namespace": "Group",
|
||||
"relation": "members"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "siblings",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "File"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{
|
||||
"relation": "parents",
|
||||
"computed_subject_set_relation": "viewers"
|
||||
},
|
||||
{
|
||||
"relation": "parents",
|
||||
"computed_subject_set_relation": "view"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relation": "viewers"
|
||||
},
|
||||
{
|
||||
"relation": "viewers"
|
||||
},
|
||||
{
|
||||
"relation": "viewers"
|
||||
},
|
||||
{
|
||||
"relation": "owners"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "owners"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "not",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"inverted": {
|
||||
"relation": "owners"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rename",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "siblings",
|
||||
"computed_subject_set_relation": "edit"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Folder": [
|
||||
{
|
||||
"name": "parents",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "File"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "viewers",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Group",
|
||||
"relation": "members"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "viewers"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Group": [
|
||||
{
|
||||
"name": "members",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
},
|
||||
{
|
||||
"namespace": "Group"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"User": [
|
||||
{
|
||||
"name": "manager",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"Document": [
|
||||
{
|
||||
"name": "owners"
|
||||
},
|
||||
{
|
||||
"name": "editors"
|
||||
},
|
||||
{
|
||||
"name": "viewers"
|
||||
},
|
||||
{
|
||||
"name": "parent"
|
||||
},
|
||||
{
|
||||
"name": "rename",
|
||||
"rewrite": {
|
||||
"set_operation": 0,
|
||||
"children": [
|
||||
{
|
||||
"relation": "siblings",
|
||||
"computed_userset_relation": "edit"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"rewrite": {
|
||||
"set_operation": 0,
|
||||
"children": [
|
||||
{
|
||||
"relation": "owners"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"rewrite": {
|
||||
"set_operation": 0,
|
||||
"children": [
|
||||
{
|
||||
"relation": "parents",
|
||||
"computed_userset_relation": "view"
|
||||
},
|
||||
{
|
||||
"relation": "viewers"
|
||||
},
|
||||
{
|
||||
"relation": "owners"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"User": null
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"File": [
|
||||
{
|
||||
"name": "viewers",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "blocklist1",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "blocklist2",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "User"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"rewrite": {
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{
|
||||
"inverted": {
|
||||
"relation": "viewers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"inverted": {
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{
|
||||
"relation": "blocklist1"
|
||||
},
|
||||
{
|
||||
"relation": "blocklist2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"User": null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"Resource": [
|
||||
{
|
||||
"name": "scope.relation",
|
||||
"types": [
|
||||
{
|
||||
"namespace": "Resource"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "scope.action_0",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "scope.relation",
|
||||
"computed_subject_set_relation": "scope.action_1"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope.action_1",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "scope.relation",
|
||||
"computed_subject_set_relation": "scope.relation"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope.action_2",
|
||||
"rewrite": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"relation": "scope.action_0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
112
schema/handler.go
Normal file
112
schema/handler.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
opl "github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1"
|
||||
"github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1/oplconnect"
|
||||
"github.com/ory/keto/internal/x"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
handlerDependencies interface {
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
x.HandlerOptionsProvider
|
||||
}
|
||||
Handler struct {
|
||||
oplconnect.UnimplementedSyntaxServiceHandler
|
||||
d handlerDependencies
|
||||
}
|
||||
)
|
||||
|
||||
const RouteBase = "/opl/syntax/check"
|
||||
|
||||
func NewHandler(d handlerDependencies) *Handler {
|
||||
return &Handler{d: d}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterSyntaxRoutes(r httprouterx.Router) {
|
||||
r.POST(RouteBase, h.postCheckOplSyntax)
|
||||
r.Handle(oplconnect.NewSyntaxServiceHandler(h, h.d.HandlerOptions()...))
|
||||
}
|
||||
|
||||
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
|
||||
return []protoreflect.FileDescriptor{opl.File_ory_keto_opl_v1alpha1_syntax_service_proto}
|
||||
}
|
||||
|
||||
func (h *Handler) Check(_ context.Context, request *connect.Request[opl.CheckRequest]) (*connect.Response[opl.CheckResponse], error) {
|
||||
_, parseErrors := Parse(string(request.Msg.GetContent()))
|
||||
apiErrors := make([]*opl.ParseError, len(parseErrors))
|
||||
for i, e := range parseErrors {
|
||||
apiErrors[i] = e.ToProto()
|
||||
}
|
||||
return connect.NewResponse(&opl.CheckResponse{ParseErrors: apiErrors}), nil
|
||||
}
|
||||
|
||||
// Check OPL Syntax Request Parameters
|
||||
//
|
||||
// swagger:parameters checkOplSyntax
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type checkOplSyntax struct {
|
||||
// in: body
|
||||
Body checkOplSyntaxBody
|
||||
}
|
||||
|
||||
// Ory Permission Language Document
|
||||
//
|
||||
// swagger:model checkOplSyntaxBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type checkOplSyntaxBody string
|
||||
|
||||
// swagger:route POST /opl/syntax/check relationship checkOplSyntax
|
||||
//
|
||||
// # Check the syntax of an OPL file
|
||||
//
|
||||
// The OPL file is expected in the body of the request.
|
||||
//
|
||||
// Consumes:
|
||||
// - text/plain
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkOplSyntaxResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-admin-medium
|
||||
func (h *Handler) postCheckOplSyntax(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithError(err.Error())))
|
||||
return
|
||||
}
|
||||
_, parseErrors := Parse(string(data))
|
||||
apiErrors := make([]*ketoapi.ParseError, len(parseErrors))
|
||||
for i, e := range parseErrors {
|
||||
apiErrors[i] = e.ToAPI()
|
||||
}
|
||||
h.d.Writer().Write(w, r, &ketoapi.CheckOPLSyntaxResponse{Errors: apiErrors})
|
||||
}
|
||||
60
schema/handler_test.go
Normal file
60
schema/handler_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
opl "github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/schema"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func TestNewHandler(t *testing.T) {
|
||||
reg := driver.NewSqliteTestRegistry(t)
|
||||
h := schema.NewHandler(reg)
|
||||
|
||||
r := httprouterx.NewRouterPublic()
|
||||
h.RegisterSyntaxRoutes(r)
|
||||
|
||||
ts, clientTLS := driver.HTTP2TestServer(t, r)
|
||||
|
||||
t.Run("proto=REST", func(t *testing.T) {
|
||||
t.Run("method=POST /opl/syntax/check", func(t *testing.T) {
|
||||
response, err := ts.Client().Post(
|
||||
ts.URL+"/opl/syntax/check",
|
||||
"text/plain",
|
||||
bytes.NewBufferString("/* comment???"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(body))
|
||||
assert.Contains(t, gjson.GetBytes(body, "errors.0.message").String(), "unclosed comment")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("proto=gRPC", func(t *testing.T) {
|
||||
conn, err := grpc.NewClient(ts.Listener.Addr().String(), grpc.WithTransportCredentials(clientTLS))
|
||||
require.NoError(t, err)
|
||||
|
||||
client := opl.NewSyntaxServiceClient(conn)
|
||||
|
||||
t.Run("method=Syntax.Check", func(t *testing.T) {
|
||||
response, err := client.Check(t.Context(), &opl.CheckRequest{
|
||||
Content: []byte("/* comment???"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response.ParseErrors[0].Message, "unclosed comment")
|
||||
})
|
||||
})
|
||||
}
|
||||
52
schema/itemtype_string.go
Normal file
52
schema/itemtype_string.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by "stringer -type=itemType -trimprefix item -linecomment"; DO NOT EDIT.
|
||||
|
||||
package schema
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[itemError-0]
|
||||
_ = x[itemEOF-1]
|
||||
_ = x[itemIdentifier-2]
|
||||
_ = x[itemComment-3]
|
||||
_ = x[itemStringLiteral-4]
|
||||
_ = x[itemKeywordClass-5]
|
||||
_ = x[itemKeywordImplements-6]
|
||||
_ = x[itemKeywordThis-7]
|
||||
_ = x[itemKeywordCtx-8]
|
||||
_ = x[itemOperatorAnd-9]
|
||||
_ = x[itemOperatorOr-10]
|
||||
_ = x[itemOperatorNot-11]
|
||||
_ = x[itemOperatorAssign-12]
|
||||
_ = x[itemOperatorArrow-13]
|
||||
_ = x[itemOperatorDot-14]
|
||||
_ = x[itemOperatorColon-15]
|
||||
_ = x[itemOperatorComma-16]
|
||||
_ = x[itemSemicolon-17]
|
||||
_ = x[itemTypeUnion-18]
|
||||
_ = x[itemParenLeft-19]
|
||||
_ = x[itemParenRight-20]
|
||||
_ = x[itemBraceLeft-21]
|
||||
_ = x[itemBraceRight-22]
|
||||
_ = x[itemBracketLeft-23]
|
||||
_ = x[itemBracketRight-24]
|
||||
_ = x[itemAngledLeft-25]
|
||||
_ = x[itemAngledRight-26]
|
||||
}
|
||||
|
||||
const _itemType_name = "ErrorEOFIdentifierCommentStringLiteralKeywordClassKeywordImplementsKeywordThisKeywordCtx\"&&\"\"||\"\"!\"\"=\"\"=>\"\".\"\":\"\",\"\";\"\"|\"\"(\"\")\"\"{\"\"}\"\"[\"\"]\"\"<\"\">\""
|
||||
|
||||
var _itemType_index = [...]uint8{0, 5, 8, 18, 25, 38, 50, 67, 78, 88, 92, 96, 99, 102, 106, 109, 112, 115, 118, 121, 124, 127, 130, 133, 136, 139, 142, 145}
|
||||
|
||||
func (i itemType) String() string {
|
||||
if i < 0 || i >= itemType(len(_itemType_index)-1) {
|
||||
return "itemType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _itemType_name[_itemType_index[i]:_itemType_index[i+1]]
|
||||
}
|
||||
341
schema/lexer.go
Normal file
341
schema/lexer.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// The lexer is inspired by Rob Pike's talk at
|
||||
// https://www.youtube.com/watch?v=HxaD_trXwRE.
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type (
|
||||
item struct {
|
||||
Typ itemType // Type of item.
|
||||
Val string // Value of item.
|
||||
Start, End int // Start and end position of item.
|
||||
}
|
||||
itemType int
|
||||
|
||||
// stateFn represents the state of the scanner as a function that returns
|
||||
// the next state.
|
||||
stateFn func(*lexer) stateFn
|
||||
|
||||
// lexer holds the state of the scanner.
|
||||
lexer struct {
|
||||
name string // the name of the input; used only for error reports.
|
||||
input string // the string being scanned.
|
||||
state stateFn // the next lexing function to enter
|
||||
pos int // current position in the input.
|
||||
start int // start position of this item.
|
||||
width int // width of last rune read from input.
|
||||
items chan item // channel of scanned items.
|
||||
}
|
||||
)
|
||||
|
||||
//go:generate stringer -type=itemType -trimprefix item -linecomment
|
||||
const (
|
||||
// error occurred; value is text of error
|
||||
itemError itemType = iota
|
||||
// end of input
|
||||
itemEOF
|
||||
|
||||
// identifier; value is string
|
||||
itemIdentifier
|
||||
// comment; value is string
|
||||
itemComment
|
||||
// string literal; value is string
|
||||
itemStringLiteral
|
||||
|
||||
// keywords
|
||||
itemKeywordClass
|
||||
itemKeywordImplements
|
||||
itemKeywordThis
|
||||
itemKeywordCtx
|
||||
|
||||
// operators
|
||||
itemOperatorAnd // "&&"
|
||||
itemOperatorOr // "||"
|
||||
itemOperatorNot // "!"
|
||||
itemOperatorAssign // "="
|
||||
itemOperatorArrow // "=>"
|
||||
itemOperatorDot // "."
|
||||
itemOperatorColon // ":"
|
||||
itemOperatorComma // ","
|
||||
|
||||
// misc characters
|
||||
itemSemicolon // ";"
|
||||
itemTypeUnion // "|"
|
||||
|
||||
// brackets
|
||||
itemParenLeft // "("
|
||||
itemParenRight // ")"
|
||||
itemBraceLeft // "{"
|
||||
itemBraceRight // "}"
|
||||
itemBracketLeft // "["
|
||||
itemBracketRight // "]"
|
||||
itemAngledLeft // "<"
|
||||
itemAngledRight // ">"
|
||||
)
|
||||
|
||||
// string classes
|
||||
const (
|
||||
spaces = "\t\n\v\f\r "
|
||||
digits = "0123456789"
|
||||
letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"
|
||||
)
|
||||
|
||||
const eof rune = -1 // rune indicating end of file
|
||||
|
||||
func (i item) String() string {
|
||||
switch i.Typ {
|
||||
case itemError:
|
||||
return "error: " + i.Val
|
||||
case itemEOF:
|
||||
return "EOF"
|
||||
case itemIdentifier, itemStringLiteral:
|
||||
if len(i.Val) > 10 {
|
||||
return fmt.Sprintf("'%.10s...'", i.Val)
|
||||
}
|
||||
return fmt.Sprintf("'%s'", i.Val)
|
||||
}
|
||||
return i.Val
|
||||
}
|
||||
|
||||
// Lex creates a new scanner for the input string.
|
||||
func Lex(name, input string) *lexer {
|
||||
l := &lexer{
|
||||
name: name,
|
||||
input: input,
|
||||
state: lexCode,
|
||||
items: make(chan item, 20),
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// next returns the next rune in the input.
|
||||
func (l *lexer) next() (r rune) {
|
||||
if l.pos >= len(l.input) {
|
||||
l.width = 0
|
||||
return eof
|
||||
}
|
||||
r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
|
||||
l.pos += l.width
|
||||
return r
|
||||
}
|
||||
|
||||
// peek returns but does not consume the next rune in the input.
|
||||
func (l *lexer) peek() rune {
|
||||
r := l.next()
|
||||
l.backup()
|
||||
return r
|
||||
}
|
||||
|
||||
// backup steps back one rune. Can only be called once per call of next.
|
||||
func (l *lexer) backup() {
|
||||
l.pos -= l.width
|
||||
}
|
||||
|
||||
// emit passes an item back to the client. The value is cloned so that
|
||||
// long-lived tokens do not pin the whole input in memory.
|
||||
func (l *lexer) emit(t itemType) {
|
||||
l.items <- item{t, strings.Clone(l.input[l.start:l.pos]), l.start, l.pos}
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
// ignore skips over the pending input before this point.
|
||||
func (l *lexer) ignore() {
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
// accept consumes the next rune if it's from the valid set.
|
||||
func (l *lexer) accept(valid string) bool {
|
||||
if strings.ContainsRune(valid, l.next()) {
|
||||
return true
|
||||
}
|
||||
l.backup()
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptRun consumes a run of runes from the valid set.
|
||||
func (l *lexer) acceptRun(valid string) {
|
||||
for strings.ContainsRune(valid, l.next()) {
|
||||
}
|
||||
l.backup()
|
||||
}
|
||||
|
||||
// error returns an error token and terminates the scan by passing
|
||||
// back a nil pointer that will be the next state, terminating l.run.
|
||||
func (l *lexer) errorf(format string, args ...interface{}) stateFn {
|
||||
location := fmt.Sprintf("at %q: ", l.input[l.pos:])
|
||||
l.items <- item{itemError, location + fmt.Sprintf(format, args...), l.start, l.pos}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextItem returns the next item from the input.
|
||||
func (l *lexer) nextItem() item {
|
||||
for {
|
||||
select {
|
||||
case item := <-l.items:
|
||||
return item
|
||||
default:
|
||||
if l.state == nil {
|
||||
return item{itemError, "broken state", 0, 0}
|
||||
}
|
||||
l.state = l.state(l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextNonCommentItem returns the next item from the input that is not a comment.
|
||||
func (l *lexer) nextNonCommentItem() (item item) {
|
||||
for item = l.nextItem(); item.Typ == itemComment; item = l.nextItem() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// scanIdentifier scans an identifier.
|
||||
func (l *lexer) scanIdentifier() bool {
|
||||
if !l.accept(letters) {
|
||||
return false
|
||||
}
|
||||
l.acceptRun(letters + digits)
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *lexer) scanCommentBegin() (bool, stateFn) {
|
||||
if strings.HasPrefix(l.input[l.pos:], "//") {
|
||||
l.pos += 2
|
||||
return true, lexLineComment
|
||||
}
|
||||
if strings.HasPrefix(l.input[l.pos:], "/*") {
|
||||
l.pos += 2
|
||||
return true, lexBlockComment
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var oneRuneTokens = map[rune]itemType{
|
||||
':': itemOperatorColon,
|
||||
'.': itemOperatorDot,
|
||||
'(': itemParenLeft,
|
||||
')': itemParenRight,
|
||||
'[': itemBracketLeft,
|
||||
']': itemBracketRight,
|
||||
'{': itemBraceLeft,
|
||||
'}': itemBraceRight,
|
||||
'<': itemAngledLeft,
|
||||
'>': itemAngledRight,
|
||||
'=': itemOperatorAssign,
|
||||
',': itemOperatorComma,
|
||||
';': itemSemicolon,
|
||||
'|': itemTypeUnion,
|
||||
'!': itemOperatorNot,
|
||||
}
|
||||
|
||||
var multiRuneTokens = map[string]itemType{
|
||||
"=>": itemOperatorArrow,
|
||||
"||": itemOperatorOr,
|
||||
"&&": itemOperatorAnd,
|
||||
}
|
||||
|
||||
var keywords = map[string]itemType{
|
||||
"class": itemKeywordClass,
|
||||
"implements": itemKeywordImplements,
|
||||
"this": itemKeywordThis,
|
||||
"ctx": itemKeywordCtx,
|
||||
}
|
||||
|
||||
func lexCode(l *lexer) stateFn {
|
||||
l.acceptRun(spaces)
|
||||
l.ignore()
|
||||
|
||||
r := l.peek()
|
||||
if r == eof {
|
||||
l.emit(itemEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Two-rune tokens must be matched first
|
||||
for token, typ := range multiRuneTokens {
|
||||
if strings.HasPrefix(l.input[l.pos:], token) {
|
||||
l.pos += len(token)
|
||||
l.emit(typ)
|
||||
return lexCode
|
||||
}
|
||||
}
|
||||
|
||||
if found, lexComment := l.scanCommentBegin(); found {
|
||||
return lexComment
|
||||
}
|
||||
|
||||
if itemType, found := oneRuneTokens[r]; found {
|
||||
l.next()
|
||||
l.emit(itemType)
|
||||
return lexCode
|
||||
}
|
||||
|
||||
if strings.ContainsRune(`'"`, r) {
|
||||
return lexStringLiteral
|
||||
}
|
||||
|
||||
if l.scanIdentifier() {
|
||||
if kwType, found := keywords[l.input[l.start:l.pos]]; found {
|
||||
l.emit(kwType)
|
||||
} else {
|
||||
l.emit(itemIdentifier)
|
||||
}
|
||||
return lexCode
|
||||
}
|
||||
|
||||
return l.errorf("unexpected token %c", r)
|
||||
}
|
||||
|
||||
func lexLineComment(l *lexer) stateFn {
|
||||
for {
|
||||
switch l.next() {
|
||||
case '\n', eof:
|
||||
l.backup()
|
||||
l.emit(itemComment)
|
||||
return lexCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lexBlockComment(l *lexer) stateFn {
|
||||
for r := l.peek(); ; r = l.next() {
|
||||
if r == eof {
|
||||
return l.errorf("unclosed comment")
|
||||
}
|
||||
if strings.HasPrefix(l.input[l.pos:], "*/") {
|
||||
l.pos += 2
|
||||
l.emit(itemComment)
|
||||
return lexCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lexStringLiteral scans a string literal.
|
||||
func lexStringLiteral(l *lexer) stateFn {
|
||||
r := l.next()
|
||||
l.ignore()
|
||||
|
||||
loop:
|
||||
for {
|
||||
switch l.next() {
|
||||
case eof:
|
||||
return l.errorf("unclosed string literal")
|
||||
case r:
|
||||
l.backup()
|
||||
break loop
|
||||
}
|
||||
}
|
||||
l.emit(itemStringLiteral)
|
||||
l.next()
|
||||
l.ignore()
|
||||
|
||||
return lexCode
|
||||
}
|
||||
133
schema/lexer_test.go
Normal file
133
schema/lexer_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/snapshotx"
|
||||
)
|
||||
|
||||
var lexingErrorTestCases = []struct{ name, input string }{
|
||||
{"open comment", "/*"},
|
||||
{"open string literal", "'"},
|
||||
{"non-token", "ü"},
|
||||
{"mixed quotes", `"invalid_literal'`},
|
||||
}
|
||||
|
||||
var lexableTestCases = []struct{ name, input string }{
|
||||
{"empty", ""},
|
||||
{"import statement", "import { Foo, Bar, Baz } from '@ory/keto-namespace-types'"},
|
||||
{"single class", `
|
||||
class name implements Namespace {
|
||||
metadata = {
|
||||
id: "123"
|
||||
}
|
||||
}
|
||||
`},
|
||||
{"comments", `
|
||||
/**/
|
||||
|
||||
/** doc comment
|
||||
* content
|
||||
* more content
|
||||
*/
|
||||
class name implements Namespace {
|
||||
// Block comment
|
||||
/*
|
||||
äny ünicöde characterß???
|
||||
*/
|
||||
}
|
||||
`},
|
||||
{"two classes", `
|
||||
class user implements Namespace { }
|
||||
|
||||
class document implements Namespace {
|
||||
|
||||
related: {
|
||||
owners: user[]
|
||||
editors: user[]
|
||||
viewers: user[]
|
||||
parent: document[]
|
||||
}
|
||||
}
|
||||
`},
|
||||
{"full class", `
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
parents: File[]
|
||||
viewers: User[]
|
||||
owners: User[]
|
||||
siblings: File[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.parents.some(p => p.permits.view(ctx)) ||
|
||||
this.related.viewers.includes(ctx.subject) ||
|
||||
!this.related.owners.includes(ctx.subject),
|
||||
|
||||
edit: (ctx: Context) => this.related.owners.includes(ctx.subject),
|
||||
|
||||
rename: (ctx: Context) => this.related.siblings.some(s => s.permits.edit(ctx))
|
||||
}
|
||||
}
|
||||
`},
|
||||
{"subject sets", `
|
||||
class X implements Namespace {
|
||||
related: {
|
||||
relation: SubjectSet<R, "someProp">[]
|
||||
}
|
||||
}
|
||||
`},
|
||||
}
|
||||
|
||||
func TestLexer(t *testing.T) {
|
||||
t.Run("suite=snapshots", func(t *testing.T) {
|
||||
for _, tc := range lexableTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := Lex(tc.name, tc.input)
|
||||
var items []string
|
||||
for {
|
||||
item := l.nextItem()
|
||||
items = append(items, item.String())
|
||||
|
||||
if item.Typ == itemError {
|
||||
t.Fail()
|
||||
break
|
||||
}
|
||||
if item.Typ == itemEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Logf("Tokens:\n%s", strings.Join(items, "\n"))
|
||||
snapshotx.SnapshotT(t, items)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("suite=errors", func(t *testing.T) {
|
||||
for _, tc := range lexingErrorTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := Lex(tc.name, tc.input)
|
||||
var items []string
|
||||
for {
|
||||
item := l.nextItem()
|
||||
items = append(items, item.String())
|
||||
|
||||
if item.Typ == itemError {
|
||||
break
|
||||
}
|
||||
if item.Typ == itemEOF {
|
||||
t.Fatal("reached EOF, but expected error")
|
||||
}
|
||||
}
|
||||
l.next()
|
||||
t.Logf("Tokens:\n%s", strings.Join(items, "\n"))
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
14
schema/limits.go
Normal file
14
schema/limits.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
const (
|
||||
// tupleToSubjectSetTypeCheckMaxDepth controls the maximum number of recursions
|
||||
// for looking up the types of SubjectSet<Namespace, "relation">.
|
||||
tupleToSubjectSetTypeCheckMaxDepth = 10
|
||||
|
||||
// expressionNestingMaxDepth is the maximum number of nested '(' and '!' in
|
||||
// a single 'permits'.
|
||||
expressionNestingMaxDepth = 10
|
||||
)
|
||||
129
schema/parse_errors.go
Normal file
129
schema/parse_errors.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
opl "github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
ParseError struct {
|
||||
msg string
|
||||
item item
|
||||
p *parser
|
||||
}
|
||||
)
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *ParseError) Error() string {
|
||||
var s strings.Builder
|
||||
start := e.toSrcPos(e.item.Start)
|
||||
end := e.toSrcPos(e.item.End)
|
||||
rows := e.rows()
|
||||
startLineIdx := max(start.Line-2, 0)
|
||||
errorLineIdx := max(start.Line-1, 0)
|
||||
|
||||
_, _ = fmt.Fprintf(&s, "error from %d:%d to %d:%d: %s\n\n",
|
||||
start.Line, start.Col,
|
||||
end.Line, end.Col,
|
||||
e.msg)
|
||||
|
||||
if len(rows) < start.Line {
|
||||
s.WriteString("meta error: could not find source position in input\n")
|
||||
return s.String()
|
||||
}
|
||||
|
||||
for line := startLineIdx; line <= errorLineIdx; line++ {
|
||||
_, _ = fmt.Fprintf(&s, "%4d | %s\n", line, rows[line])
|
||||
}
|
||||
s.WriteString(" | ")
|
||||
for i, r := range rows[errorLineIdx] {
|
||||
switch {
|
||||
case start.Col == i:
|
||||
s.WriteRune('^')
|
||||
case start.Col <= i && i <= end.Col-1:
|
||||
s.WriteRune('~')
|
||||
case unicode.IsSpace(r):
|
||||
s.WriteRune(r)
|
||||
default:
|
||||
s.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
s.WriteRune('\n')
|
||||
|
||||
if errorLineIdx+1 < len(rows) {
|
||||
_, _ = fmt.Fprintf(&s, "%4d | %s\n", errorLineIdx+1, rows[errorLineIdx+1])
|
||||
s.WriteRune('\n')
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (e *ParseError) ToAPI() *ketoapi.ParseError {
|
||||
return &ketoapi.ParseError{
|
||||
Message: e.msg,
|
||||
Start: e.toSrcPos(e.item.Start),
|
||||
End: e.toSrcPos(e.item.End),
|
||||
}
|
||||
}
|
||||
func (e *ParseError) ToProto() *opl.ParseError {
|
||||
start := e.toSrcPos(e.item.Start)
|
||||
end := e.toSrcPos(e.item.End)
|
||||
return &opl.ParseError{
|
||||
Message: e.msg,
|
||||
Start: &opl.SourcePosition{
|
||||
Line: clampUint32(start.Line),
|
||||
Column: clampUint32(start.Col),
|
||||
},
|
||||
End: &opl.SourcePosition{
|
||||
Line: clampUint32(end.Line),
|
||||
Column: clampUint32(end.Col),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toSrcPos converts the given position in the input to a Line and column
|
||||
// number.
|
||||
func (e *ParseError) toSrcPos(pos int) (srcPos ketoapi.SourcePosition) {
|
||||
srcPos.Line = 1
|
||||
for _, c := range e.p.lexer.input {
|
||||
srcPos.Col++
|
||||
pos--
|
||||
if pos <= 0 {
|
||||
break
|
||||
}
|
||||
if c == '\n' {
|
||||
srcPos.Line++
|
||||
srcPos.Col = 0
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func clampUint32(v int) uint32 {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case uint64(v) > math.MaxUint32:
|
||||
return math.MaxUint32
|
||||
default:
|
||||
return uint32(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ParseError) rows() []string {
|
||||
return strings.Split(e.p.lexer.input, "\n")
|
||||
}
|
||||
550
schema/parser.go
Normal file
550
schema/parser.go
Normal file
@@ -0,0 +1,550 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
internalNamespace "github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
)
|
||||
|
||||
type (
|
||||
namespace = internalNamespace.Namespace
|
||||
|
||||
parser struct {
|
||||
lexer *lexer // lexer to get tokens from
|
||||
namespaces []namespace // list of parsed namespaces
|
||||
namespace namespace // current namespace
|
||||
errors []*ParseError // errors encountered during parsing
|
||||
fatal bool // parser encountered a fatal error
|
||||
lookahead *item // lookahead token
|
||||
checks []typeCheck // checks to perform on the namespace
|
||||
}
|
||||
)
|
||||
|
||||
func Parse(input string) ([]namespace, []*ParseError) {
|
||||
p := &parser{
|
||||
lexer: Lex("input", input),
|
||||
}
|
||||
return p.parse()
|
||||
}
|
||||
|
||||
func (p *parser) next() (item item) {
|
||||
if p.lookahead != nil {
|
||||
item = *p.lookahead
|
||||
p.lookahead = nil
|
||||
} else {
|
||||
return p.lexer.nextNonCommentItem()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *parser) peek() item {
|
||||
if p.lookahead == nil {
|
||||
i := p.lexer.nextNonCommentItem()
|
||||
p.lookahead = &i
|
||||
return i
|
||||
}
|
||||
return *p.lookahead
|
||||
}
|
||||
|
||||
func (p *parser) parse() ([]namespace, []*ParseError) {
|
||||
loop:
|
||||
for !p.fatal {
|
||||
switch item := p.next(); item.Typ {
|
||||
case itemEOF:
|
||||
break loop
|
||||
case itemError:
|
||||
p.addFatal(item, "fatal: %s", item.Val)
|
||||
case itemKeywordClass:
|
||||
p.parseClass()
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.errors) == 0 {
|
||||
p.typeCheck()
|
||||
}
|
||||
|
||||
return p.namespaces, p.errors
|
||||
}
|
||||
|
||||
func (p *parser) addFatal(item item, format string, a ...interface{}) {
|
||||
p.addErr(item, format, a...)
|
||||
p.fatal = true
|
||||
}
|
||||
|
||||
func (p *parser) addErr(item item, format string, a ...interface{}) {
|
||||
err := &ParseError{
|
||||
msg: fmt.Sprintf(format, a...),
|
||||
item: item,
|
||||
p: p,
|
||||
}
|
||||
p.errors = append(p.errors, err)
|
||||
}
|
||||
|
||||
type matcher func(p *parser) (matched bool)
|
||||
|
||||
// optional optionally matches the first argument of tokens in the input. If
|
||||
// matched, the tokens are consumed. If the first token matched, all other
|
||||
// tokens must match as well.
|
||||
func optional(tokens ...string) matcher {
|
||||
return func(p *parser) bool {
|
||||
if len(tokens) == 0 {
|
||||
return true
|
||||
}
|
||||
first := tokens[0]
|
||||
if p.peek().Val == first {
|
||||
p.next()
|
||||
for _, token := range tokens[1:] {
|
||||
i := p.next()
|
||||
if i.Val != token {
|
||||
p.addFatal(i, "expected %q, got %q", token, i.Val)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// match for the next tokens in the input.
|
||||
//
|
||||
// A token is matched depending on the type:
|
||||
// For string arguments, the input token must match the given string exactly.
|
||||
// For *string arguments, the input token must be an identifier, and the value
|
||||
// of the identifier will be written to the *string.
|
||||
// For *item arguments, the input token will be written to the pointer.
|
||||
func (p *parser) match(tokens ...interface{}) (matched bool) {
|
||||
if p.fatal {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, token := range tokens {
|
||||
switch token := token.(type) {
|
||||
case string:
|
||||
i := p.next()
|
||||
if i.Val != token {
|
||||
p.addFatal(i, "expected %q, got %q", token, i.Val)
|
||||
return false
|
||||
}
|
||||
case *string:
|
||||
i := p.next()
|
||||
if i.Typ != itemIdentifier && i.Typ != itemStringLiteral {
|
||||
p.addFatal(i, "expected identifier, got %s", i.Typ)
|
||||
return false
|
||||
}
|
||||
*token = i.Val
|
||||
case *item:
|
||||
*token = p.next()
|
||||
case matcher:
|
||||
if !token(p) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected token type %T", token))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type itemPredicate func(item) bool
|
||||
|
||||
func is(typ itemType) itemPredicate {
|
||||
return func(item item) bool {
|
||||
return item.Typ == typ
|
||||
}
|
||||
}
|
||||
|
||||
// matchIf matches the tokens iff. the predicate is true.
|
||||
func (p *parser) matchIf(predicate itemPredicate, tokens ...interface{}) (matched bool) {
|
||||
if p.fatal {
|
||||
return false
|
||||
}
|
||||
if !predicate(p.peek()) {
|
||||
return false
|
||||
}
|
||||
return p.match(tokens...)
|
||||
}
|
||||
|
||||
// parseClass parses a class. The "class" token was already consumed.
|
||||
func (p *parser) parseClass() {
|
||||
var name string
|
||||
p.match(&name, "implements", "Namespace", "{")
|
||||
p.namespace = namespace{Name: name}
|
||||
|
||||
for !p.fatal {
|
||||
switch item := p.next(); {
|
||||
case item.Typ == itemBraceRight:
|
||||
p.namespaces = append(p.namespaces, p.namespace)
|
||||
return
|
||||
case item.Val == "related":
|
||||
p.parseRelated()
|
||||
case item.Val == "permits":
|
||||
p.parsePermits()
|
||||
case item.Typ == itemSemicolon:
|
||||
continue
|
||||
default:
|
||||
p.addFatal(item, "expected 'permits' or 'related', got %q", item.Val)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseRelated() {
|
||||
p.match(":", "{")
|
||||
for !p.fatal {
|
||||
switch item := p.next(); item.Typ {
|
||||
|
||||
case itemSemicolon:
|
||||
continue
|
||||
|
||||
case itemBraceRight:
|
||||
return
|
||||
|
||||
case itemIdentifier, itemStringLiteral:
|
||||
relation := item.Val
|
||||
var types []ast.RelationType
|
||||
p.match(":")
|
||||
|
||||
switch item := p.next(); {
|
||||
case item.Val == "Array":
|
||||
p.match("<")
|
||||
types = append(types, p.parseTypeUnion(itemAngledRight)...)
|
||||
case item.Val == "SubjectSet":
|
||||
types = append(types, p.matchSubjectSet())
|
||||
p.match("[", "]", optional(","))
|
||||
case item.Typ == itemParenLeft:
|
||||
types = append(types, p.parseTypeUnion(itemParenRight)...)
|
||||
p.match("[", "]", optional(","))
|
||||
default:
|
||||
types = append(types, ast.RelationType{Namespace: item.Val})
|
||||
p.addCheck(checkNamespaceExists(item))
|
||||
p.match("[", "]", optional(","))
|
||||
}
|
||||
|
||||
p.namespace.Relations = append(p.namespace.Relations, ast.Relation{
|
||||
Name: relation,
|
||||
Types: types,
|
||||
})
|
||||
|
||||
default:
|
||||
p.addFatal(item, "expected identifier or '}', got %s %q", item.Typ.String(), item.Val)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) matchSubjectSet() ast.RelationType {
|
||||
var namespace, relation item
|
||||
p.match("<", &namespace, ",", &relation, ">")
|
||||
p.addCheck(checkNamespaceHasRelation(namespace, relation))
|
||||
return ast.RelationType{Namespace: namespace.Val, Relation: relation.Val}
|
||||
}
|
||||
|
||||
func (p *parser) parseTypeUnion(endToken itemType) (types []ast.RelationType) {
|
||||
for !p.fatal {
|
||||
var identifier item
|
||||
p.match(&identifier)
|
||||
if identifier.Val == "SubjectSet" {
|
||||
types = append(types, p.matchSubjectSet())
|
||||
} else {
|
||||
types = append(types, ast.RelationType{Namespace: identifier.Val})
|
||||
p.addCheck(checkNamespaceExists(identifier))
|
||||
}
|
||||
switch item := p.next(); item.Typ {
|
||||
case endToken:
|
||||
return
|
||||
case itemTypeUnion:
|
||||
default:
|
||||
p.addFatal(item, "expected '|', got %q", item.Val)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *parser) parsePermits() {
|
||||
p.match("=", "{")
|
||||
for !p.fatal {
|
||||
switch item := p.next(); item.Typ {
|
||||
|
||||
case itemBraceRight:
|
||||
return
|
||||
|
||||
case itemIdentifier, itemStringLiteral:
|
||||
permission := item.Val
|
||||
p.match(
|
||||
":", "(", "ctx", optional(":", "Context"), ")",
|
||||
optional(":", "boolean"), "=>",
|
||||
)
|
||||
|
||||
rewrite := simplifyExpression(p.parsePermissionExpressions(itemOperatorComma, expressionNestingMaxDepth))
|
||||
if rewrite == nil {
|
||||
return
|
||||
}
|
||||
p.namespace.Relations = append(p.namespace.Relations,
|
||||
ast.Relation{
|
||||
Name: permission,
|
||||
SubjectSetRewrite: rewrite,
|
||||
})
|
||||
|
||||
default:
|
||||
p.addFatal(item, "expected identifier or '}', got %s %q", item.Typ.String(), item.Val)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parsePermissionExpressions(finalToken itemType, depth int) *ast.SubjectSetRewrite {
|
||||
if depth <= 0 {
|
||||
p.addFatal(p.peek(),
|
||||
"expression nested too deeply; maximal nesting depth is %d",
|
||||
expressionNestingMaxDepth)
|
||||
return nil
|
||||
}
|
||||
var root *ast.SubjectSetRewrite
|
||||
|
||||
// We only expect an expression in the beginning and after a binary
|
||||
// operator.
|
||||
expectExpression := true
|
||||
|
||||
// TODO(hperl): Split this into two state machines: One that parses an
|
||||
// expression or expression group; and one that parses a binary operator.
|
||||
for !p.fatal {
|
||||
switch item := p.peek(); item.Typ {
|
||||
|
||||
// A "(" starts a new expression group that is parsed recursively.
|
||||
case itemParenLeft:
|
||||
p.next() // consume paren
|
||||
child := p.parsePermissionExpressions(itemParenRight, depth-1)
|
||||
if child == nil {
|
||||
return nil
|
||||
}
|
||||
root = addChild(root, child)
|
||||
expectExpression = false
|
||||
|
||||
case finalToken:
|
||||
p.next() // consume final token
|
||||
return root
|
||||
|
||||
case itemBraceRight:
|
||||
// We don't consume the '}' here, to allow `parsePermits` to consume
|
||||
// it.
|
||||
return root
|
||||
|
||||
case itemOperatorAnd, itemOperatorOr:
|
||||
p.next() // consume operator
|
||||
|
||||
// A nil root means that we saw a binary expression before the first
|
||||
// expression.
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
newRoot := &ast.SubjectSetRewrite{
|
||||
Operation: setOperation(item.Typ),
|
||||
Children: []ast.Child{root},
|
||||
}
|
||||
root = newRoot
|
||||
expectExpression = true
|
||||
|
||||
// A "not" creates an AST node where the children are either a
|
||||
// single expression, or a list of expressions grouped by "()".
|
||||
case itemOperatorNot:
|
||||
p.next() // consume operator
|
||||
child := p.parseNotExpression(depth - 1)
|
||||
if child == nil {
|
||||
return nil
|
||||
}
|
||||
root = addChild(root, child)
|
||||
expectExpression = false
|
||||
|
||||
default:
|
||||
if !expectExpression {
|
||||
// Two expressions can't follow each other directly, they must
|
||||
// be separated by a binary operator.
|
||||
p.addFatal(item, "did not expect another expression")
|
||||
return nil
|
||||
}
|
||||
child := p.parsePermissionExpression()
|
||||
if child == nil {
|
||||
return nil
|
||||
}
|
||||
root = addChild(root, child)
|
||||
expectExpression = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) parseNotExpression(depth int) ast.Child {
|
||||
if depth <= 0 {
|
||||
p.addFatal(p.peek(),
|
||||
"expression nested too deeply; maximal nesting depth is %d",
|
||||
expressionNestingMaxDepth)
|
||||
return nil
|
||||
}
|
||||
|
||||
var child ast.Child
|
||||
if item := p.peek(); item.Typ == itemParenLeft {
|
||||
p.next() // consume paren
|
||||
child = p.parsePermissionExpressions(itemParenRight, depth-1)
|
||||
} else {
|
||||
child = p.parsePermissionExpression()
|
||||
}
|
||||
if child == nil {
|
||||
return nil
|
||||
}
|
||||
return &ast.InvertResult{Child: child}
|
||||
}
|
||||
|
||||
func addChild(root *ast.SubjectSetRewrite, child ast.Child) *ast.SubjectSetRewrite {
|
||||
if root == nil {
|
||||
return child.AsRewrite()
|
||||
} else {
|
||||
root.Children = append(root.Children, child)
|
||||
return root
|
||||
}
|
||||
}
|
||||
|
||||
func setOperation(typ itemType) ast.Operator {
|
||||
switch typ {
|
||||
case itemOperatorAnd:
|
||||
return ast.OperatorAnd
|
||||
case itemOperatorOr:
|
||||
return ast.OperatorOr
|
||||
}
|
||||
panic("not reached")
|
||||
}
|
||||
|
||||
func (p *parser) matchPropertyAccess(propertyName any) bool {
|
||||
return p.matchIf(is(itemBracketLeft), "[", propertyName, "]") || p.match(".", propertyName)
|
||||
}
|
||||
|
||||
func (p *parser) parsePermissionExpression() (child ast.Child) {
|
||||
var name, verb item
|
||||
|
||||
if !p.match("this", ".", &verb) {
|
||||
return
|
||||
}
|
||||
if !p.matchPropertyAccess(&name) {
|
||||
return
|
||||
}
|
||||
|
||||
switch verb.Val {
|
||||
case "related":
|
||||
if !p.match(".") {
|
||||
return
|
||||
}
|
||||
switch item := p.next(); item.Val {
|
||||
case "traverse":
|
||||
child = p.parseTupleToSubjectSet(name)
|
||||
case "includes":
|
||||
child = p.parseComputedSubjectSet(name)
|
||||
default:
|
||||
p.addFatal(item, "expected 'traverse' or 'includes', got %q", item.Val)
|
||||
}
|
||||
|
||||
case "permits":
|
||||
if !p.match("(", "ctx", ")") {
|
||||
return
|
||||
}
|
||||
p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, name))
|
||||
return &ast.ComputedSubjectSet{Relation: name.Val}
|
||||
|
||||
default:
|
||||
p.addFatal(verb, "expected 'related' or 'permits', got %q", verb.Val)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *parser) parseTupleToSubjectSet(relation item) (rewrite ast.Child) {
|
||||
var (
|
||||
subjectSetRel string
|
||||
arg, verb item
|
||||
)
|
||||
if !p.match("(") {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case p.matchIf(is(itemParenLeft), "(", &arg, ")"):
|
||||
case p.match(&arg):
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
p.match("=>", arg.Val, ".", &verb)
|
||||
|
||||
switch verb.Val {
|
||||
case "related":
|
||||
if !p.matchPropertyAccess(&subjectSetRel) {
|
||||
return nil
|
||||
}
|
||||
p.match(
|
||||
".", "includes", "(", "ctx", ".", "subject",
|
||||
optional(","), ")", optional(","), ")",
|
||||
)
|
||||
p.addCheck(checkAllRelationsTypesHaveRelation(
|
||||
&p.namespace, relation, subjectSetRel,
|
||||
))
|
||||
case "permits":
|
||||
if !p.matchPropertyAccess(&subjectSetRel) {
|
||||
return nil
|
||||
}
|
||||
p.match("(", "ctx", ")", ")")
|
||||
p.addCheck(checkAllRelationsTypesHaveRelation(
|
||||
&p.namespace, relation, subjectSetRel,
|
||||
))
|
||||
default:
|
||||
p.addFatal(verb, "expected 'related' or 'permits', got %q", verb)
|
||||
return nil
|
||||
}
|
||||
p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, relation))
|
||||
return &ast.TupleToSubjectSet{
|
||||
Relation: relation.Val,
|
||||
ComputedSubjectSetRelation: subjectSetRel,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseComputedSubjectSet(relation item) (rewrite ast.Child) {
|
||||
if !p.match("(", "ctx", ".", "subject", ")") {
|
||||
return nil
|
||||
}
|
||||
p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, relation))
|
||||
return &ast.ComputedSubjectSet{Relation: relation.Val}
|
||||
}
|
||||
|
||||
// simplifyExpression rewrites the expression to use n-ary set operations
|
||||
// instead of binary ones.
|
||||
func simplifyExpression(root *ast.SubjectSetRewrite) *ast.SubjectSetRewrite {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
var newChildren []ast.Child
|
||||
for _, child := range root.Children {
|
||||
switch ch := child.(type) {
|
||||
case *ast.SubjectSetRewrite:
|
||||
simplifyExpression(ch)
|
||||
if len(ch.Children) == 1 || ch.Operation == root.Operation {
|
||||
// Flatten the child to parent, when:
|
||||
// - it's a single-element wrapper (AND(OR(A)) ≡ AND(A))
|
||||
// - shares the root's operation (OR(OR(A,B),C) ≡ OR(A,B,C)).
|
||||
newChildren = append(newChildren, ch.Children...)
|
||||
continue
|
||||
}
|
||||
newChildren = append(newChildren, child)
|
||||
case *ast.InvertResult:
|
||||
if innerCh, ok := ch.Child.(*ast.SubjectSetRewrite); ok {
|
||||
simplifyExpression(innerCh)
|
||||
}
|
||||
newChildren = append(newChildren, child)
|
||||
default:
|
||||
// Leaf node or different operation with multiple children; keep as-is.
|
||||
newChildren = append(newChildren, child)
|
||||
}
|
||||
}
|
||||
root.Children = newChildren
|
||||
|
||||
return root
|
||||
}
|
||||
9
schema/parser_fuzzer.go
Normal file
9
schema/parser_fuzzer.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
func LibfuzzerFuzzParser(data []byte) int {
|
||||
Parse(string(data))
|
||||
return 0
|
||||
}
|
||||
421
schema/parser_test.go
Normal file
421
schema/parser_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/snapshotx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
)
|
||||
|
||||
var parserErrorTestCases = []struct{ name, input string }{
|
||||
{"lexer error", "/* unclosed comment"},
|
||||
{
|
||||
"syntax and type errors",
|
||||
`
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
parents: (File | Folder)[]
|
||||
viewers: (User | SubjectSet<Group, "members">)[]
|
||||
owners: (User | SubjectSet<Group, "members">)[]
|
||||
siblings: File[]
|
||||
}
|
||||
|
||||
SYNTAX ERROR
|
||||
|
||||
// Some comment
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
(
|
||||
this.related.parents.traverse((p) =>
|
||||
p.related.viewers.includes(ctx.subject),
|
||||
) &&
|
||||
this.related.parents.traverse(p => p.permits.view(ctx)) ) ||
|
||||
(this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewers.includes(ctx.subject) ) ||
|
||||
this.related.owners.includes(ctx.subject),
|
||||
|
||||
edit: (ctx: Context) => this.related.owners.includes(ctx.subject),
|
||||
|
||||
not: (ctx: Context) => !this.related.owners.includes(ctx.subject),
|
||||
|
||||
rename: (ctx: Context) =>
|
||||
this.related.siblings.traverse(s => s.permits.edit(ctx)),
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{"parser error", `
|
||||
class Resource implements Namespace {
|
||||
permits = {
|
||||
update: (ctx: Context) => ||
|
||||
this.related.annotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
`},
|
||||
}
|
||||
|
||||
var parserTestCases = []struct {
|
||||
name, input string
|
||||
}{
|
||||
{"full example", `
|
||||
import { Namespace, SubjectSet, FooBar, Anything } from '@ory/keto-namespace-types'
|
||||
|
||||
class User implements Namespace {
|
||||
related: {
|
||||
manager: User[];
|
||||
}
|
||||
}
|
||||
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
members: (User | Group)[];
|
||||
};
|
||||
}
|
||||
|
||||
class Folder implements Namespace {
|
||||
related: {
|
||||
parents: Array<File>
|
||||
viewers: Array<SubjectSet<Group, "members">>
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean => this.related.viewers.includes(ctx.subject),
|
||||
}
|
||||
}
|
||||
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
parents: Array<File | Folder>
|
||||
viewers: (User | SubjectSet<Group, "members">)[]
|
||||
"owners": (User | SubjectSet<Group, "members">)[]
|
||||
siblings: File[]
|
||||
}
|
||||
|
||||
// Some comment
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
(
|
||||
this.related.parents.traverse((p) /* comment */ =>
|
||||
p.related.viewers.includes(ctx.subject),
|
||||
) && // comment
|
||||
this.related.parents.traverse(p => p.permits.view(ctx)) ) ||
|
||||
(this.related.viewers.includes(ctx.subject) || // some comment
|
||||
this.related.viewers.includes(ctx.subject) || /* another comment */
|
||||
this.related.viewers.includes(ctx.subject) ) ||
|
||||
this.related.owners.includes(ctx.subject),
|
||||
|
||||
'edit': (ctx: Context) => this.related.owners.includes(ctx.subject),
|
||||
|
||||
not: (ctx: Context) => !this.related.owners.includes(ctx.subject),
|
||||
|
||||
rename: (ctx: Context) =>
|
||||
this.related.siblings.traverse(s => s.permits.edit(ctx)),
|
||||
}
|
||||
}
|
||||
`},
|
||||
{
|
||||
"advanced typescript syntax",
|
||||
`
|
||||
import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types';
|
||||
|
||||
class Role implements Namespace {
|
||||
related: {
|
||||
member: Role[]
|
||||
}
|
||||
}
|
||||
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
admins: SubjectSet<Role, 'member'>[],
|
||||
supervisors: SubjectSet<Role, 'member'>[],
|
||||
annotators: SubjectSet<Role, 'member'>[],
|
||||
medicalAnnotators: SubjectSet<Role, 'member'>[],
|
||||
};
|
||||
|
||||
permits = {
|
||||
read: (ctx: Context) => this.related.admins.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.annotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.medicalAnnotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
|
||||
comment: (ctx: Context) => this.permits.read(ctx),
|
||||
|
||||
update: (ctx: Context) => this.related.admins.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.annotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.medicalAnnotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
|
||||
create: (ctx: Context) => this.related.admins.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.annotators.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
|
||||
approve: (ctx: Context) => this.related.admins.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
|
||||
delete: (ctx: Context) => this.related.admins.traverse((role) => role.related.member.includes(ctx.subject)) ||
|
||||
this.related.supervisors.traverse((role) => role.related.member.includes(ctx.subject)),
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{"quoted property names", `
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
"scope.relation": Resource[]
|
||||
}
|
||||
permits = {
|
||||
"scope.action_0": (ctx: Context) => this.related["scope.relation"].traverse((r) => r.permits["scope.action_1"](ctx)),
|
||||
"scope.action_1": (ctx: Context) => this.related["scope.relation"].traverse((r) => r.related["scope.relation"].includes(ctx.subject)),
|
||||
"scope.action_2": (ctx: Context) => this.permits["scope.action_0"](ctx),
|
||||
}
|
||||
}`},
|
||||
{"inverted relationship not case", `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
blocklist1: User[]
|
||||
blocklist2: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) =>
|
||||
!this.related.viewers.includes(ctx.subject) &&
|
||||
(!(
|
||||
this.related.blocklist1.includes(ctx.subject) &&
|
||||
this.related.blocklist2.includes(ctx.subject)
|
||||
))
|
||||
}
|
||||
}
|
||||
`},
|
||||
}
|
||||
|
||||
func TestParser(t *testing.T) {
|
||||
t.Run("suite=snapshots", func(t *testing.T) {
|
||||
for _, tc := range parserTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ns, errs := Parse(tc.input)
|
||||
if len(errs) > 0 {
|
||||
for _, err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
t.Logf("namespaces:\n%+v", ns)
|
||||
nsMap := make(map[string][]ast.Relation)
|
||||
for _, n := range ns {
|
||||
nsMap[n.Name] = n.Relations
|
||||
}
|
||||
snapshotx.SnapshotT(t, nsMap)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("suite=errors", func(t *testing.T) {
|
||||
for _, tc := range parserErrorTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, errs := Parse(tc.input)
|
||||
assert.Len(t, errs, 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzParser(f *testing.F) {
|
||||
for _, tc := range lexableTestCases {
|
||||
f.Add(tc.input)
|
||||
}
|
||||
for _, tc := range parserTestCases {
|
||||
f.Add(tc.input)
|
||||
}
|
||||
|
||||
f.Fuzz(func(_ *testing.T, input string) {
|
||||
Parse(input)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_simplify(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input, expected *ast.SubjectSetRewrite
|
||||
}{
|
||||
{"empty", nil, nil},
|
||||
{
|
||||
name: "merge all unions",
|
||||
input: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
&ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "C"},
|
||||
&ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "D"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
&ast.ComputedSubjectSet{Relation: "C"},
|
||||
&ast.ComputedSubjectSet{Relation: "D"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// ( ( (A || B) ) ) = A || B
|
||||
name: "merges OR children",
|
||||
input: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
},
|
||||
},
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// ( ( A && ((B)) ) ) = A && B
|
||||
name: "merges AND children",
|
||||
input: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
},
|
||||
},
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A AND !!B = A AND !!B
|
||||
name: "preserve double NOT",
|
||||
input: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.InvertResult{
|
||||
Child: &ast.InvertResult{
|
||||
Child: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.InvertResult{
|
||||
Child: &ast.InvertResult{
|
||||
Child: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A AND NOT(B OR (C)) -> A AND NOT(B OR C)
|
||||
name: "simplifies the expression inside the inverted relationship",
|
||||
input: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.InvertResult{
|
||||
Child: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
&ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "C"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorAnd,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "A"},
|
||||
&ast.InvertResult{
|
||||
Child: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{Relation: "B"},
|
||||
&ast.ComputedSubjectSet{Relation: "C"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, simplifyExpression(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
string("class A implements Namespace{permits={A:(ctx)=>||")
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
string("class A implements Namespace{permits={A:(ctx)=>this.related.A.traverse(p=>p.permits")
|
||||
130
schema/typechecks.go
Normal file
130
schema/typechecks.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import "github.com/ory/keto/internal/namespace/ast"
|
||||
|
||||
type (
|
||||
namespaceQuery []namespace
|
||||
relationQuery []ast.Relation
|
||||
typeCheck func(p *parser)
|
||||
)
|
||||
|
||||
func (p *parser) query() namespaceQuery {
|
||||
return p.namespaces
|
||||
}
|
||||
|
||||
func (ns namespaceQuery) find(name string) (*namespace, bool) {
|
||||
for _, n := range ns {
|
||||
if n.Name == name {
|
||||
return &n, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (ns namespaceQuery) findRelation(namespace, relation string) (*ast.Relation, bool) {
|
||||
n, ok := ns.find(namespace)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return relationQuery(n.Relations).find(relation)
|
||||
}
|
||||
|
||||
func (rs relationQuery) find(name string) (*ast.Relation, bool) {
|
||||
for _, r := range rs {
|
||||
if r.Name == name {
|
||||
return &r, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (p *parser) typeCheck() {
|
||||
for _, check := range p.checks {
|
||||
check(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) addCheck(check typeCheck) {
|
||||
p.checks = append(p.checks, check)
|
||||
}
|
||||
|
||||
// checkNamespace checks that the there exists a namespace with the given name.
|
||||
func checkNamespaceExists(namespace item) typeCheck {
|
||||
return func(p *parser) {
|
||||
if _, ok := namespaceQuery(p.namespaces).find(namespace.Val); ok {
|
||||
return
|
||||
}
|
||||
p.addErr(namespace, "namespace %q was not declared", namespace.Val)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNamespaceHasRelation checks that 1. there exists the given namespace,
|
||||
// and 2. that there exists the given relation in that namespace.
|
||||
func checkNamespaceHasRelation(namespace, relation item) typeCheck {
|
||||
return func(p *parser) {
|
||||
if n, ok := namespaceQuery(p.namespaces).find(namespace.Val); ok {
|
||||
if _, ok := relationQuery(n.Relations).find(relation.Val); ok {
|
||||
return
|
||||
}
|
||||
p.addErr(relation,
|
||||
"namespace %q did not declare relation %q",
|
||||
namespace.Val, relation.Val)
|
||||
return
|
||||
}
|
||||
p.addErr(namespace, "namespace %q was not declared", namespace.Val)
|
||||
}
|
||||
}
|
||||
|
||||
// checkCurrentNamespaceHasRelation checks that the give relation exists in the
|
||||
// current namespace.
|
||||
func checkCurrentNamespaceHasRelation(current *namespace, relation item) typeCheck {
|
||||
namespace := current.Name
|
||||
return func(p *parser) {
|
||||
if n, ok := namespaceQuery(p.namespaces).find(namespace); ok {
|
||||
if _, ok := relationQuery(n.Relations).find(relation.Val); ok {
|
||||
return
|
||||
}
|
||||
p.addErr(relation,
|
||||
"namespace %q did not declare relation %q",
|
||||
namespace, relation.Val)
|
||||
return
|
||||
}
|
||||
p.addErr(relation, "namespace %q was not declared", namespace)
|
||||
}
|
||||
}
|
||||
|
||||
func checkAllRelationsTypesHaveRelation(current *namespace, relationType item, relation string) typeCheck {
|
||||
namespace := current.Name
|
||||
return func(p *parser) {
|
||||
recursiveCheckAllRelationsTypesHaveRelation(p, relationType, namespace, relationType.Val, relation, tupleToSubjectSetTypeCheckMaxDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func recursiveCheckAllRelationsTypesHaveRelation(p *parser, item item, namespace string, relationType string, relation string, depth int) {
|
||||
if depth < 0 {
|
||||
p.addErr(item, "could not typecheck deeply nested SubjectSet further")
|
||||
return
|
||||
}
|
||||
r, ok := namespaceQuery(p.namespaces).findRelation(namespace, relationType)
|
||||
if !ok {
|
||||
p.addErr(item, "relation %q was not declared in namespace %q",
|
||||
relationType, namespace)
|
||||
return
|
||||
}
|
||||
for _, t := range r.Types {
|
||||
if t.Relation == "" {
|
||||
if _, ok := p.query().findRelation(t.Namespace, relation); !ok {
|
||||
p.addErr(item, "relation %q was not declared in namespace %q",
|
||||
relation, t.Namespace)
|
||||
}
|
||||
} else {
|
||||
// Type is a subject set, we need to recursively check if the type has
|
||||
// the required relation.
|
||||
recursiveCheckAllRelationsTypesHaveRelation(
|
||||
p, item, t.Namespace, t.Relation, relation, depth-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user