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

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

View 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"
]

View File

@@ -0,0 +1,3 @@
[
"EOF"
]

View 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"
]

View File

@@ -0,0 +1,13 @@
[
"'import'",
"{",
"'Foo'",
",",
"'Bar'",
",",
"'Baz'",
"}",
"'from'",
"'@ory/keto-...'",
"EOF"
]

View File

@@ -0,0 +1,16 @@
[
"class",
"'name'",
"implements",
"'Namespace'",
"{",
"'metadata'",
"=",
"{",
"'id'",
":",
"'123'",
"}",
"}",
"EOF"
]

View File

@@ -0,0 +1,23 @@
[
"class",
"'X'",
"implements",
"'Namespace'",
"{",
"'related'",
":",
"{",
"'relation'",
":",
"'SubjectSet'",
"\u003c",
"'R'",
",",
"'someProp'",
"\u003e",
"[",
"]",
"}",
"}",
"EOF"
]

View File

@@ -0,0 +1,39 @@
[
"class",
"'user'",
"implements",
"'Namespace'",
"{",
"}",
"class",
"'document'",
"implements",
"'Namespace'",
"{",
"'related'",
":",
"{",
"'owners'",
":",
"'user'",
"[",
"]",
"'editors'",
":",
"'user'",
"[",
"]",
"'viewers'",
":",
"'user'",
"[",
"]",
"'parent'",
":",
"'document'",
"[",
"]",
"}",
"}",
"EOF"
]

View File

@@ -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"
}
]
}
]
}

View 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"
}
]
}
]
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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))
})
}
}

View File

@@ -0,0 +1,2 @@
go test fuzz v1
string("class A implements Namespace{permits={A:(ctx)=>||")

View File

@@ -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
View 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)
}
}
}