diff --git a/internal/check/engine.go b/internal/check/engine.go index 263bfe62e..d3ab101f9 100644 --- a/internal/check/engine.go +++ b/internal/check/engine.go @@ -14,6 +14,7 @@ import ( "github.com/ory/keto/internal/driver/config" "github.com/ory/keto/internal/namespace" "github.com/ory/keto/internal/namespace/ast" + "github.com/ory/keto/internal/persistence" "github.com/ory/keto/internal/relationtuple" "github.com/ory/keto/internal/x" "github.com/ory/keto/internal/x/graph" @@ -31,6 +32,7 @@ type ( relationtuple.ManagerProvider config.Provider x.LoggerProvider + Persister() persistence.Persister } EngineOpt func(*Engine) @@ -159,11 +161,32 @@ func (e *Engine) checkDirect(r *relationTuple, restDepth int) checkgroup.CheckFu e.d.Logger(). WithField("request", r.String()). Trace("check direct") + q := r.ToQuery() + if w := argsFromCtx(ctx); w != nil && len(w.Args) == 1 { + // fill subject with the passed argument + if v, ok := w.Args[0].(ast.StringLiteralArg); ok { + u, err := e.d.Persister().MapStringsToUUIDs(ctx, v.Value(ctx)) + if err != nil { + resultCh <- checkgroup.Result{ + Membership: checkgroup.NotMember, + } + e.d.Logger().WithField("request", r.String()).Error("failed to check direct", err) + return + } + q.Subject = &relationtuple.SubjectID{ID: u[0]} + } + } if rels, _, err := e.d.RelationTupleManager().GetRelationTuples( ctx, - r.ToQuery(), + q, x.WithSize(1), ); err == nil && len(rels) > 0 { + if q.Subject != r.Subject { + // fix the Tree + t := *r + t.Subject = q.Subject + r = &t + } resultCh <- checkgroup.Result{ Membership: checkgroup.IsMember, Tree: &ketoapi.Tree[*relationtuple.RelationTuple]{ @@ -195,11 +218,26 @@ func (e *Engine) checkIsAllowed(ctx context.Context, r *relationTuple, restDepth WithField("request", r.String()). Trace("check is allowed") + relation, err := e.astRelationFor(ctx, r) + if w := argsFromCtx(ctx); w != nil && len(w.Args) > 0 && len(relation.Params) > 0 { + // map arg-name to value + for i, p := range relation.Params { + if p != "ctx" { + if w.Mapping == nil { + w.Mapping = make(map[string]ast.Arg) + } + w.Mapping[p] = w.Args[i] + } + } + } + g := checkgroup.New(ctx) - g.Add(e.checkDirect(r, restDepth-1)) - g.Add(e.checkExpandSubject(r, restDepth)) + // do not make checks for faked helper relations + if relation == nil || len(relation.Params) == 1 { + g.Add(e.checkDirect(r, restDepth-1)) + g.Add(e.checkExpandSubject(r, restDepth)) + } - relation, err := e.astRelationFor(ctx, r) if err != nil { g.Add(checkgroup.ErrorFunc(err)) } else if relation != nil && relation.SubjectSetRewrite != nil { @@ -231,6 +269,7 @@ func (e *Engine) astRelationFor(ctx context.Context, r *relationTuple) (*ast.Rel for _, rel := range ns.Relations { if rel.Name == r.Relation { + r.Formula = &rel return &rel, nil } } diff --git a/internal/check/engine_test.go b/internal/check/engine_test.go index b421d65c5..22ca23c6d 100644 --- a/internal/check/engine_test.go +++ b/internal/check/engine_test.go @@ -15,6 +15,7 @@ import ( "github.com/ory/keto/internal/driver" "github.com/ory/keto/internal/driver/config" "github.com/ory/keto/internal/namespace" + "github.com/ory/keto/internal/persistence" "github.com/ory/keto/internal/relationtuple" "github.com/ory/keto/internal/x" "github.com/ory/keto/ketoapi" @@ -42,6 +43,22 @@ func newDepsProvider(t testing.TB, namespaces []*namespace.Namespace, pageOpts . } } +func (d *deps) Persister() persistence.Persister { + return persister{} +} + +type persister struct { + persistence.Persister +} + +func (p persister) MapStringsToUUIDs(ctx context.Context, s ...string) ([]uuid.UUID, error) { + u := make([]uuid.UUID, len(s)) + for i, v := range s { + u[i] = toUUID(v) + } + return u, nil +} + func toUUID(s string) uuid.UUID { return uuid.NewV5(uuid.Nil, s) } diff --git a/internal/check/rewrites.go b/internal/check/rewrites.go index a1b36fa9b..8df196769 100644 --- a/internal/check/rewrites.go +++ b/internal/check/rewrites.go @@ -183,6 +183,7 @@ func (e *Engine) checkComputedSubjectSet( WithField("computed subjectSet relation", subjectSet.Relation). Trace("check computed subjectSet") + ctx = wrapArgs(ctx, subjectSet.Args) return e.checkIsAllowed( ctx, &relationTuple{ @@ -227,6 +228,7 @@ func (e *Engine) checkTupleToSubjectSet( tuples []*relationTuple err error ) + ctx = wrapArgs(ctx, subjectSet.Args) g := checkgroup.New(ctx) for nextPage = "x"; nextPage != "" && !g.Done(); prevPage = nextPage { tuples, nextPage, err = e.d.RelationTupleManager().GetRelationTuples( @@ -261,3 +263,34 @@ func (e *Engine) checkTupleToSubjectSet( resultCh <- g.Result() } } + +type argsCtxKey struct{} + +func newCtxWithArgs(ctx context.Context, args *ArgsWrapper) context.Context { + return context.WithValue(ctx, argsCtxKey{}, args) +} + +func argsFromCtx(ctx context.Context) *ArgsWrapper { + args, _ := ctx.Value(argsCtxKey{}).(*ArgsWrapper) + return args +} + +type ArgsWrapper struct { + Args []ast.Arg + Mapping map[string]ast.Arg +} + +func wrapArgs(ctx context.Context, args []ast.Arg) context.Context { + w := argsFromCtx(ctx) + if w != nil { + // replace named-args with real values + a := append([]ast.Arg{}, args...) + for i, p := range a { + if _, ok := p.(ast.NamedArg); ok { + a[i] = w.Mapping[p.Value(ctx)] + } + } + args = a + } + return newCtxWithArgs(ctx, &ArgsWrapper{Args: args}) +} diff --git a/internal/check/rewrites_test.go b/internal/check/rewrites_test.go index bdff9c665..5c2afc457 100644 --- a/internal/check/rewrites_test.go +++ b/internal/check/rewrites_test.go @@ -17,6 +17,7 @@ import ( "github.com/ory/keto/internal/namespace" "github.com/ory/keto/internal/namespace/ast" "github.com/ory/keto/internal/relationtuple" + "github.com/ory/keto/internal/schema" "github.com/ory/keto/ketoapi" ) @@ -24,12 +25,14 @@ var namespaces = []*namespace.Namespace{ {Name: "doc", Relations: []ast.Relation{ { - Name: "owner"}, + Name: "owner", Params: []string{"subject"}}, { Name: "editor", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{&ast.ComputedSubjectSet{ - Relation: "owner"}}}}, + Relation: "owner"}}}, + Params: []string{"ctx"}, + }, { Name: "viewer", SubjectSetRewrite: &ast.SubjectSetRewrite{ @@ -38,35 +41,41 @@ var namespaces = []*namespace.Namespace{ Relation: "editor"}, &ast.TupleToSubjectSet{ Relation: "parent", - ComputedSubjectSetRelation: "viewer"}}}}, + ComputedSubjectSetRelation: "viewer"}}}, + Params: []string{"ctx"}, + }, }}, {Name: "users"}, {Name: "group", - Relations: []ast.Relation{{Name: "member"}}, + Relations: []ast.Relation{{Name: "member", Params: []string{"subject"}}}, }, {Name: "level", - Relations: []ast.Relation{{Name: "member"}}, + Relations: []ast.Relation{{Name: "member", Params: []string{"subject"}}}, }, {Name: "resource", Relations: []ast.Relation{ - {Name: "level"}, + {Name: "level", Params: []string{"subject"}}, {Name: "viewer", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ - &ast.TupleToSubjectSet{Relation: "owner", ComputedSubjectSetRelation: "member"}}}}, + &ast.TupleToSubjectSet{Relation: "owner", ComputedSubjectSetRelation: "member"}}}, + Params: []string{"ctx"}}, {Name: "owner", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ - &ast.TupleToSubjectSet{Relation: "owner", ComputedSubjectSetRelation: "member"}}}}, + &ast.TupleToSubjectSet{Relation: "owner", ComputedSubjectSetRelation: "member"}}}, + Params: []string{"ctx"}}, {Name: "read", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ &ast.ComputedSubjectSet{Relation: "viewer"}, - &ast.ComputedSubjectSet{Relation: "owner"}}}}, + &ast.ComputedSubjectSet{Relation: "owner"}}}, + Params: []string{"ctx"}}, {Name: "update", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ - &ast.ComputedSubjectSet{Relation: "owner"}}}}, + &ast.ComputedSubjectSet{Relation: "owner"}}}, + Params: []string{"ctx"}}, {Name: "delete", SubjectSetRewrite: &ast.SubjectSetRewrite{ Operation: ast.OperatorAnd, @@ -74,19 +83,21 @@ var namespaces = []*namespace.Namespace{ &ast.ComputedSubjectSet{Relation: "owner"}, &ast.TupleToSubjectSet{ Relation: "level", - ComputedSubjectSetRelation: "member"}}}}, + ComputedSubjectSetRelation: "member"}}}, + Params: []string{"ctx"}}, }}, {Name: "acl", Relations: []ast.Relation{ - {Name: "allow"}, - {Name: "deny"}, + {Name: "allow", Params: []string{"subject"}}, + {Name: "deny", Params: []string{"subject"}}, {Name: "access", SubjectSetRewrite: &ast.SubjectSetRewrite{ Operation: ast.OperatorAnd, Children: ast.Children{ &ast.ComputedSubjectSet{Relation: "allow"}, &ast.InvertResult{ - Child: &ast.ComputedSubjectSet{Relation: "deny"}}}}}}}, + Child: &ast.ComputedSubjectSet{Relation: "deny"}}}}, + Params: []string{"ctx"}}}}, } func insertFixtures(t testing.TB, m relationtuple.Manager, tuples []string) { @@ -294,3 +305,140 @@ func hasPath(t *testing.T, path path, tree *ketoapi.Tree[*relationtuple.Relation } return false } + +var rawnsWithStringParameter = ` +import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'; + +class Perm implements Namespace {} +class User implements Namespace {} +class Role implements Namespace { + related: { + perms: (Perm | SubjectSet)[] + owners: Group[] + policies: Policy[] + } + + permits = { + has_perm: (ctx: Context, perm: string): boolean => this.related.perms.includes(perm), + check: (ctx: Context, perm: string): boolean => this.related.owners.traverse((p) => p.permits.check(ctx, perm)) || + this.related.policies.traverse((p) => p.permits.check(ctx, perm)), + get: (ctx: Context): boolean => this.permits.check(ctx, "roles.get"), + } + } + + class Policy implements Namespace { + related: { + roles: Role[] + users: (User | SubjectSet)[] + policies: Policy[] + } + + permits = { + has_perm: (ctx: Context, perm: string): boolean => this.related.users.includes(ctx.subject) && + this.related.roles.traverse((p) => p.permits.has_perm(ctx, perm)), + check: (ctx: Context, perm: string): boolean => this.related.policies.traverse((p) => p.permits.has_perm(ctx, perm)), + get: (ctx: Context): boolean => this.permits.check(ctx, "policies.get"), + } + } + + class Group implements Namespace { + related: { + parents: Group[] + members: (User | SubjectSet)[] + policies: Policy[] + } + + permits = { + check: (ctx: Context, perm: string): boolean => this.related.parents.traverse((p) => p.permits.check(ctx, perm)) || + this.related.policies.traverse((p) => p.permits.has_perm(ctx, perm)), + } + } +` + +func TestUsersetRewritesWithStringParameter(t *testing.T) { + nss, err := schema.Parse(rawnsWithStringParameter) + require.True(t, err == nil) + namespaces := make([]*namespace.Namespace, len(nss)) + for i, ns := range nss { + ns := ns + namespaces[i] = &ns + } + + reg := newDepsProvider(t, namespaces) + reg.Logger().Logger.SetLevel(logrus.TraceLevel) + + insertFixtures(t, reg.RelationTupleManager(), []string{ + "Role:viewer#perms@roles.get", + "Role:viewer#owners@Group:editors#", + "Role:viewer#policies@Policy:role_viewer1#", + + "Policy:role_viewer1#roles@Role:viewer#", + "Policy:role_viewer1#users@User:bob#", + "Policy:role_viewer1#policies@Policy:role_viewer1#", + + "Group:editors#members@User:mark#", + "Group:editors#policies@Policy:role_viewer2#", + + "Policy:role_viewer2#roles@Role:viewer#", + "Policy:role_viewer2#users@User:sandy#", + }) + + testCases := []struct { + query string + expected checkgroup.Result + expectedPaths []path + }{{ + query: "Role:viewer#get@User:sandy", + expected: checkgroup.ResultIsMember, + }, { + query: "Role:viewer#get@User:bob", + expected: checkgroup.ResultIsMember, + }, { + query: "Role:viewer#get@User:mark", + expected: checkgroup.ResultNotMember, + }, { + query: "Policy:role_viewer1#get@User:bob", + expected: checkgroup.ResultNotMember, + }} + + t.Run("suite=testcases", func(t *testing.T) { + ctx := context.Background() + e := check.NewEngine(reg) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + for _, tc := range testCases { + t.Run("case="+tc.query, func(t *testing.T) { + rt := tupleFromString(t, tc.query) + + res := e.CheckRelationTuple(ctx, rt, 100) + require.NoError(t, res.Err) + t.Logf("tree:\n%s", res.Tree) + assert.Equal(t, tc.expected.Membership, res.Membership) + + if len(tc.expectedPaths) > 0 { + for _, path := range tc.expectedPaths { + assertPath(t, path, res.Tree) + } + } + }) + } + }) + + t.Run("suite=one worker", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + e := check.NewEngine(reg) + // Currently we always only use one worker. + //check.WithPool( + //checkgroup.NewPool( + // checkgroup.WithContext(ctx), + // checkgroup.WithWorkers(1), + //)), + + rt := tupleFromString(t, "Role:viewer#get@User:sandy") + res := e.CheckRelationTuple(ctx, rt, 100) + require.NoError(t, res.Err) + assert.Equal(t, checkgroup.ResultIsMember.Membership, res.Membership) + }) +} diff --git a/internal/namespace/ast/ast_definitions.go b/internal/namespace/ast/ast_definitions.go index b9c049863..deed4727f 100644 --- a/internal/namespace/ast/ast_definitions.go +++ b/internal/namespace/ast/ast_definitions.go @@ -3,13 +3,17 @@ package ast -import "encoding/json" +import ( + "context" + "encoding/json" +) type ( Relation struct { Name string `json:"name"` Types []RelationType `json:"types,omitempty"` SubjectSetRewrite *SubjectSetRewrite `json:"rewrite,omitempty"` + Params []string `json:"params,omitempty"` } RelationType struct { @@ -31,13 +35,19 @@ type ( AsRewrite() *SubjectSetRewrite } + Arg interface { + Value(ctx context.Context) string + } + ComputedSubjectSet struct { Relation string `json:"relation"` + Args []Arg `json:"args,omitempty"` } TupleToSubjectSet struct { Relation string `json:"relation"` ComputedSubjectSetRelation string `json:"computed_subject_set_relation"` + Args []Arg `json:"args,omitempty"` } // InvertResult inverts the check result of the child. @@ -69,3 +79,26 @@ func (t *TupleToSubjectSet) AsRewrite() *SubjectSetRewrite { func (i *InvertResult) AsRewrite() *SubjectSetRewrite { return &SubjectSetRewrite{Children: []Child{i}} } + +// concrete argument types + +type NamedArg string + +func (p NamedArg) Value(ctx context.Context) string { + return string(p) +} + +type StringLiteralArg string + +func (p StringLiteralArg) Value(ctx context.Context) string { + return string(p) +} + +var ContextArg = contextArg(0) +var CtxSubjectArg = contextArg(1) + +type contextArg int + +func (p contextArg) Value(ctx context.Context) string { + panic("should not reach here") +} diff --git a/internal/relationtuple/definitions.go b/internal/relationtuple/definitions.go index 8218d407a..61b5de5c1 100644 --- a/internal/relationtuple/definitions.go +++ b/internal/relationtuple/definitions.go @@ -10,6 +10,7 @@ import ( "github.com/gofrs/uuid" + "github.com/ory/keto/internal/namespace/ast" "github.com/ory/keto/internal/x" "github.com/ory/keto/ketoapi" rts "github.com/ory/keto/proto/ory/keto/relation_tuples/v1alpha2" @@ -51,6 +52,8 @@ type ( Object uuid.UUID `json:"object"` Relation string `json:"relation"` Subject Subject `json:"subject"` + + Formula *ast.Relation `json:"-"` } InternalRelationTuples []*RelationTuple SubjectSet struct { diff --git a/internal/schema/.snapshots/TestParser-suite=snapshots-advanced_typescript_syntax.json b/internal/schema/.snapshots/TestParser-suite=snapshots-advanced_typescript_syntax.json index 83413403a..2beb8f6c4 100644 --- a/internal/schema/.snapshots/TestParser-suite=snapshots-advanced_typescript_syntax.json +++ b/internal/schema/.snapshots/TestParser-suite=snapshots-advanced_typescript_syntax.json @@ -7,6 +7,9 @@ "namespace": "Role", "relation": "member" } + ], + "params": [ + "subject" ] }, { @@ -16,6 +19,9 @@ "namespace": "Role", "relation": "member" } + ], + "params": [ + "subject" ] }, { @@ -25,6 +31,9 @@ "namespace": "Role", "relation": "member" } + ], + "params": [ + "subject" ] }, { @@ -34,6 +43,9 @@ "namespace": "Role", "relation": "member" } + ], + "params": [ + "subject" ] }, { @@ -43,22 +55,37 @@ "children": [ { "relation": "admins", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "annotators", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "medicalAnnotators", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "supervisors", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "comment", @@ -66,10 +93,16 @@ "operator": "or", "children": [ { - "relation": "read" + "relation": "read", + "args": [ + 0 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "update", @@ -78,22 +111,37 @@ "children": [ { "relation": "admins", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "annotators", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "medicalAnnotators", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "supervisors", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "create", @@ -102,18 +150,30 @@ "children": [ { "relation": "admins", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "annotators", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "supervisors", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "approve", @@ -122,14 +182,23 @@ "children": [ { "relation": "admins", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "supervisors", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "delete", @@ -138,14 +207,23 @@ "children": [ { "relation": "admins", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] }, { "relation": "supervisors", - "computed_subject_set_relation": "member" + "computed_subject_set_relation": "member", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] } ], "Role": [ @@ -155,6 +233,9 @@ { "namespace": "Role" } + ], + "params": [ + "subject" ] } ] diff --git a/internal/schema/.snapshots/TestParser-suite=snapshots-full_example.json b/internal/schema/.snapshots/TestParser-suite=snapshots-full_example.json index a37b90afa..37c7cb069 100644 --- a/internal/schema/.snapshots/TestParser-suite=snapshots-full_example.json +++ b/internal/schema/.snapshots/TestParser-suite=snapshots-full_example.json @@ -9,6 +9,9 @@ { "namespace": "Folder" } + ], + "params": [ + "subject" ] }, { @@ -21,6 +24,9 @@ "namespace": "Group", "relation": "members" } + ], + "params": [ + "subject" ] }, { @@ -33,6 +39,9 @@ "namespace": "Group", "relation": "members" } + ], + "params": [ + "subject" ] }, { @@ -41,6 +50,9 @@ { "namespace": "File" } + ], + "params": [ + "subject" ] }, { @@ -56,30 +68,51 @@ "children": [ { "relation": "parents", - "computed_subject_set_relation": "viewers" + "computed_subject_set_relation": "viewers", + "args": [ + 1 + ] } ] }, { "relation": "parents", - "computed_subject_set_relation": "view" + "computed_subject_set_relation": "view", + "args": [ + 0 + ] } ] }, { - "relation": "viewers" + "relation": "viewers", + "args": [ + 1 + ] }, { - "relation": "viewers" + "relation": "viewers", + "args": [ + 1 + ] }, { - "relation": "viewers" + "relation": "viewers", + "args": [ + 1 + ] }, { - "relation": "owners" + "relation": "owners", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "edit", @@ -87,10 +120,16 @@ "operator": "or", "children": [ { - "relation": "owners" + "relation": "owners", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "not", @@ -99,11 +138,17 @@ "children": [ { "inverted": { - "relation": "owners" + "relation": "owners", + "args": [ + 1 + ] } } ] - } + }, + "params": [ + "ctx" + ] }, { "name": "rename", @@ -112,10 +157,16 @@ "children": [ { "relation": "siblings", - "computed_subject_set_relation": "edit" + "computed_subject_set_relation": "edit", + "args": [ + 0 + ] } ] - } + }, + "params": [ + "ctx" + ] } ], "Folder": [ @@ -125,6 +176,9 @@ { "namespace": "File" } + ], + "params": [ + "subject" ] }, { @@ -134,6 +188,9 @@ "namespace": "Group", "relation": "members" } + ], + "params": [ + "subject" ] }, { @@ -142,10 +199,16 @@ "operator": "or", "children": [ { - "relation": "viewers" + "relation": "viewers", + "args": [ + 1 + ] } ] - } + }, + "params": [ + "ctx" + ] } ], "Group": [ @@ -158,6 +221,9 @@ { "namespace": "Group" } + ], + "params": [ + "subject" ] } ], @@ -168,6 +234,9 @@ { "namespace": "User" } + ], + "params": [ + "subject" ] } ] diff --git a/internal/schema/.snapshots/TestParser-suite=snapshots-full_example_with_string_parameters.json b/internal/schema/.snapshots/TestParser-suite=snapshots-full_example_with_string_parameters.json new file mode 100644 index 000000000..36525521c --- /dev/null +++ b/internal/schema/.snapshots/TestParser-suite=snapshots-full_example_with_string_parameters.json @@ -0,0 +1,282 @@ +{ + "Group": [ + { + "name": "parents", + "types": [ + { + "namespace": "Group" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "members", + "types": [ + { + "namespace": "User" + }, + { + "namespace": "Group", + "relation": "members" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "policies", + "types": [ + { + "namespace": "Policy" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "check", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "parents", + "computed_subject_set_relation": "check", + "args": [ + 0, + "perm" + ] + }, + { + "relation": "policies", + "computed_subject_set_relation": "has_perm", + "args": [ + 0, + "perm" + ] + } + ] + }, + "params": [ + "ctx", + "perm" + ] + } + ], + "Perm": null, + "Policy": [ + { + "name": "roles", + "types": [ + { + "namespace": "Role" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "users", + "types": [ + { + "namespace": "User" + }, + { + "namespace": "Group", + "relation": "members" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "policies", + "types": [ + { + "namespace": "Policy" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "has_perm", + "rewrite": { + "operator": "and", + "children": [ + { + "operator": "or", + "children": [ + { + "relation": "users", + "args": [ + 1 + ] + } + ] + }, + { + "relation": "roles", + "computed_subject_set_relation": "has_perm", + "args": [ + 0, + "perm" + ] + } + ] + }, + "params": [ + "ctx", + "perm" + ] + }, + { + "name": "check", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "policies", + "computed_subject_set_relation": "has_perm", + "args": [ + 0, + "perm" + ] + } + ] + }, + "params": [ + "ctx", + "perm" + ] + }, + { + "name": "get", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "check", + "args": [ + 0, + "policies.get" + ] + } + ] + }, + "params": [ + "ctx" + ] + } + ], + "Role": [ + { + "name": "perms", + "types": [ + { + "namespace": "Perm" + }, + { + "namespace": "Role", + "relation": "perms" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "owners", + "types": [ + { + "namespace": "Group" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "policies", + "types": [ + { + "namespace": "Policy" + } + ], + "params": [ + "subject" + ] + }, + { + "name": "has_perm", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "perms", + "args": [ + "perm" + ] + } + ] + }, + "params": [ + "ctx", + "perm" + ] + }, + { + "name": "check", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "owners", + "computed_subject_set_relation": "check", + "args": [ + 0, + "perm" + ] + }, + { + "relation": "policies", + "computed_subject_set_relation": "check", + "args": [ + 0, + "perm" + ] + } + ] + }, + "params": [ + "ctx", + "perm" + ] + }, + { + "name": "get", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "check", + "args": [ + 0, + "roles.get" + ] + } + ] + }, + "params": [ + "ctx" + ] + } + ], + "User": null +} diff --git a/internal/schema/parser.go b/internal/schema/parser.go index 513a49808..3bcf81870 100644 --- a/internal/schema/parser.go +++ b/internal/schema/parser.go @@ -14,19 +14,22 @@ 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 + 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 + curParams []string // current permit parameters + params map[string][]string // all permits parameters } ) func Parse(input string) ([]namespace, []*ParseError) { p := &parser{ - lexer: Lex("input", input), + lexer: Lex("input", input), + params: make(map[string][]string), } return p.parse() } @@ -212,8 +215,9 @@ func (p *parser) parseRelated() { } p.match("[", "]", optional(",")) p.namespace.Relations = append(p.namespace.Relations, ast.Relation{ - Name: relation, - Types: types, + Name: relation, + Types: types, + Params: []string{"subject"}, }) default: p.addFatal(item, "expected identifier or '}', got %q", item.Val) @@ -253,17 +257,29 @@ func (p *parser) parseTypeUnion() (types []ast.RelationType) { func (p *parser) parsePermits() { p.match("=", "{") for !p.fatal { - switch item := p.next(); item.Typ { + switch it := p.next(); it.Typ { case itemBraceRight: return case itemIdentifier: - permission := item.Val - p.match( - ":", "(", "ctx", optional(":", "Context"), ")", - optional(":", "boolean"), "=>", - ) + permission := it.Val + p.curParams = []string{"ctx"} + if !p.match(":", "(", "ctx", optional(":", "Context")) { + return + } + + loop: + for { + i := &item{} + switch { + case p.matchIf(is(itemOperatorComma), i): + case p.matchIf(is(itemIdentifier), i, ":", "string"): + p.curParams = append(p.curParams, i.Val) + case p.match(")", it, optional(":", "boolean"), "=>"): + break loop + } + } rewrite := simplifyExpression(p.parsePermissionExpressions(itemOperatorComma, expressionNestingMaxDepth)) if rewrite == nil { @@ -273,10 +289,12 @@ func (p *parser) parsePermits() { ast.Relation{ Name: permission, SubjectSetRewrite: rewrite, + Params: p.curParams, }) + p.params[p.namespace.Name+":"+permission] = p.curParams default: - p.addFatal(item, "expected identifier or '}', got %q", item.Val) + p.addFatal(it, "expected identifier or '}', got %q", it.Val) return } } @@ -419,11 +437,7 @@ func (p *parser) parsePermissionExpression() (child ast.Child) { } case "permits": - if !p.match("(", "ctx", ")") { - return - } - p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, name)) - return &ast.ComputedSubjectSet{Relation: name.Val} + return p.parsePermitCall(name) default: p.addFatal(verb, "expected 'related' or 'permits', got %q", verb.Val) @@ -450,17 +464,19 @@ func (p *parser) parseTupleToSubjectSet(relation item) (rewrite ast.Child) { } p.match("=>", arg.Val, ".", &verb) + var args []ast.Arg switch verb.Val { case "related": - p.match( - ".", &subjectSetRel, ".", "includes", "(", "ctx", ".", "subject", - optional(","), ")", optional(","), ")", - ) + p.match(".", &subjectSetRel, ".", "includes", "(") + args = p.parseArgs(verb) + p.match(optional(","), ")") p.addCheck(checkAllRelationsTypesHaveRelation( &p.namespace, relation, subjectSetRel, )) case "permits": - p.match(".", &subjectSetRel, "(", "ctx", ")", ")") + p.match(".", &subjectSetRel, "(", "ctx", optional(",")) + args = append([]ast.Arg{ast.ContextArg}, p.parseArgs(verb)...) + p.match(optional(","), ")") p.addCheck(checkAllRelationsTypesHaveRelation( &p.namespace, relation, subjectSetRel, )) @@ -472,15 +488,67 @@ func (p *parser) parseTupleToSubjectSet(relation item) (rewrite ast.Child) { return &ast.TupleToSubjectSet{ Relation: relation.Val, ComputedSubjectSetRelation: subjectSetRel, + Args: args, } } +func (p *parser) parseArgs(relation item) (args []ast.Arg) { + it := &item{Typ: itemOperatorComma} +loop: + for { + preTyp := it.Typ + cases: + switch { + case p.matchIf(is(itemOperatorComma), it): + case p.matchIf(is(itemStringLiteral), it): + args = append(args, ast.StringLiteralArg(it.Val)) + case p.matchIf(is(itemIdentifier), it): + id := it.Val + for _, s := range p.curParams { + if s == id { + args = append(args, ast.NamedArg(id)) + break cases + } + } + p.addFatal(relation, "undeclared name %q", id) + return nil + case p.matchIf(is(itemParenRight), it): + break loop + case p.match("ctx", ".", "subject"): + it.Typ = itemIdentifier + args = append(args, ast.CtxSubjectArg) + } + if (preTyp == itemOperatorComma) == (it.Typ == itemOperatorComma) { + p.addFatal(relation, "unexpected consecutive %v", it.Typ) + return nil + } + } + return +} + func (p *parser) parseComputedSubjectSet(relation item) (rewrite ast.Child) { - if !p.match("(", "ctx", ".", "subject", ")") { + if !p.match("(") { + return nil + } + args := p.parseArgs(relation) + if p.fatal { + return nil + } + p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, relation)) + return &ast.ComputedSubjectSet{Relation: relation.Val, Args: args} +} + +func (p *parser) parsePermitCall(relation item) (rewrite ast.Child) { + if !p.match("(", "ctx", optional(",")) { + return nil + } + args := p.parseArgs(relation) + if p.fatal { return nil } + args = append([]ast.Arg{ast.ContextArg}, args...) p.addCheck(checkCurrentNamespaceHasRelation(&p.namespace, relation)) - return &ast.ComputedSubjectSet{Relation: relation.Val} + return &ast.ComputedSubjectSet{Relation: relation.Val, Args: args} } // simplifyExpression rewrites the expression to use n-ary set operations diff --git a/internal/schema/parser_test.go b/internal/schema/parser_test.go index 56c0b6645..ec2f10069 100644 --- a/internal/schema/parser_test.go +++ b/internal/schema/parser_test.go @@ -151,6 +151,54 @@ class Resource implements Namespace { }; } `}, + {"full example with string parameters", ` + import { Namespace, SubjectSet, Context } from '@ory/keto-namespace-types'; + + class Perm implements Namespace {} + class User implements Namespace {} + class Role implements Namespace { + related: { + perms: (Perm | SubjectSet)[] + owners: Group[] + policies: Policy[] + } + + permits = { + has_perm: (ctx: Context, perm: string): boolean => this.related.perms.includes(perm), + check: (ctx: Context, perm: string): boolean => this.related.owners.traverse((p) => p.permits.check(ctx, perm)) || + this.related.policies.traverse((p) => p.permits.check(ctx, perm)), + get: (ctx: Context): boolean => this.permits.check(ctx, "roles.get"), + } + } + + class Policy implements Namespace { + related: { + roles: Role[] + users: (User | SubjectSet)[] + policies: Policy[] + } + + permits = { + has_perm: (ctx: Context, perm: string): boolean => this.related.users.includes(ctx.subject) && + this.related.roles.traverse((p) => p.permits.has_perm(ctx, perm)), + check: (ctx: Context, perm: string): boolean => this.related.policies.traverse((p) => p.permits.has_perm(ctx, perm)), + get: (ctx: Context): boolean => this.permits.check(ctx, "policies.get"), + } + } + + class Group implements Namespace { + related: { + parents: Group[] + members: (User | SubjectSet)[] + policies: Policy[] + } + + permits = { + check: (ctx: Context, perm: string): boolean => this.related.parents.traverse((p) => p.permits.check(ctx, perm)) || + this.related.policies.traverse((p) => p.permits.has_perm(ctx, perm)), + } + } + `}, } func TestParser(t *testing.T) {