diff --git a/contrib/namespace-type-lib/index.d.ts b/contrib/namespace-type-lib/index.d.ts index bb159ee35..99addf362 100644 --- a/contrib/namespace-type-lib/index.d.ts +++ b/contrib/namespace-type-lib/index.d.ts @@ -7,7 +7,14 @@ declare interface Boolean {} declare interface String {} declare interface Number {} declare interface Function {} -declare interface Object {} +declare interface Object { + /** + * Placeholder to support `this.equals(ctx.subject)` + * + * @param element essentially `ctx.subject` + */ + equals(element: never): boolean +} declare interface IArguments {} declare interface RegExp {} diff --git a/internal/check/rewrites.go b/internal/check/rewrites.go index 9b2008af5..12b89a966 100644 --- a/internal/check/rewrites.go +++ b/internal/check/rewrites.go @@ -122,6 +122,11 @@ func (e *Engine) checkSubjectSetRewrite( Tuple: *tuple, Type: ketoapi.TreeNodeNot, }, e.checkInverted(ctx, tuple, c, restDepth))) + case *ast.SubjectEqualsObject: + checks = append(checks, checkgroup.WithEdge(checkgroup.Edge{ + Tuple: *tuple, + Type: ketoapi.TreeNodeLeaf, + }, e.checkSubjectEqualsObject(ctx, tuple, restDepth))) default: return checkNotImplemented @@ -175,6 +180,11 @@ func (e *Engine) checkInverted( Tuple: *tuple, Type: ketoapi.TreeNodeNot, }, e.checkInverted(ctx, tuple, c, restDepth)) + case *ast.SubjectEqualsObject: + check = checkgroup.WithEdge(checkgroup.Edge{ + Tuple: *tuple, + Type: ketoapi.TreeNodeLeaf, + }, e.checkSubjectEqualsObject(ctx, tuple, restDepth)) default: return checkNotImplemented @@ -199,6 +209,49 @@ func (e *Engine) checkInverted( } } +// checkSubjectEqualsObject verifies that the subject and object are the same. +// +// Checks that the subject and object refer to the same entity. The check +// is performed by creating a subject from the object based on what the tuple subject type is. +// If the tuple subject is a SubjectSet, the subject's Namespace is used with the object. If the +// tuple subject is a SubjectID, the object's ID is used as a SubjectID. +// The object-subject and tuple subject are compared using Subject.Equals. This was added to support +// `this == ctx.subject` for identity permission cases. See https://github.com/ory/keto/issues/1204 +func (e *Engine) checkSubjectEqualsObject( + _ context.Context, + r *relationTuple, + restDepth int, +) checkgroup.CheckFunc { + if restDepth < 0 { + e.d.Logger().Debug("reached max-depth, therefore this query will not be further expanded") + return checkgroup.UnknownMemberFunc + } + + e.d.Logger(). + WithField("request", r.String()). + Trace("check subject equals object") + + var objAsSubj relationtuple.Subject + switch r.Subject.(type) { + case *relationtuple.SubjectSet: + objAsSubj = &relationtuple.SubjectSet{ + Namespace: r.Namespace, + Object: r.Object, + } + case *relationtuple.SubjectID: + objAsSubj = &relationtuple.SubjectID{ + ID: r.Object, + } + default: + return checkgroup.UnknownMemberFunc + } + if r.Subject.Equals(objAsSubj) { + return checkgroup.IsMemberFunc + } + + return checkgroup.NotMemberFunc +} + // checkComputedSubjectSet rewrites the relation tuple to use the subject-set relation // instead of the relation from the tuple. // diff --git a/internal/check/rewrites_test.go b/internal/check/rewrites_test.go index ba629338c..a0e6a69ad 100644 --- a/internal/check/rewrites_test.go +++ b/internal/check/rewrites_test.go @@ -61,20 +61,29 @@ var namespaces = []*namespace.Namespace{ {Name: "read", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ + &ast.SubjectEqualsObject{}, &ast.ComputedSubjectSet{Relation: "viewer"}, &ast.ComputedSubjectSet{Relation: "owner"}}}}, {Name: "update", SubjectSetRewrite: &ast.SubjectSetRewrite{ Children: ast.Children{ + &ast.SubjectEqualsObject{}, &ast.ComputedSubjectSet{Relation: "owner"}}}}, {Name: "delete", SubjectSetRewrite: &ast.SubjectSetRewrite{ - Operation: ast.OperatorAnd, + Operation: ast.OperatorOr, Children: ast.Children{ - &ast.ComputedSubjectSet{Relation: "owner"}, - &ast.TupleToSubjectSet{ - Relation: "level", - ComputedSubjectSetRelation: "member"}}}}, + &ast.SubjectSetRewrite{ + Operation: ast.OperatorAnd, + Children: ast.Children{ + &ast.ComputedSubjectSet{Relation: "owner"}, + &ast.TupleToSubjectSet{ + Relation: "level", + ComputedSubjectSetRelation: "member"}, + }, + }, + &ast.SubjectEqualsObject{}, + }}}, }}, {Name: "acl", Relations: []ast.Relation{ @@ -192,9 +201,18 @@ func TestUsersetRewrites(t *testing.T) { query: "resource:topsecret#delete@mark", expected: checkgroup.ResultIsMember, // mark is both editor and has correct level expectedPaths: []path{ - {"*", "resource:topsecret#delete@mark", "level:superadmin#member@mark"}, - {"*", "resource:topsecret#delete@mark", "resource:topsecret#owner@mark", "group:editors#member@mark"}, + {"*", "*", "resource:topsecret#delete@mark", "level:superadmin#member@mark"}, + {"*", "*", "resource:topsecret#delete@mark", "resource:topsecret#owner@mark", "group:editors#member@mark"}, }, + }, { + query: "resource:topsecret#delete@topsecret", + expected: checkgroup.ResultIsMember, // topsecret may delete topsecret + }, { + query: "resource:topsecret#update@topsecret", + expected: checkgroup.ResultIsMember, // topsecret may update topsecret + }, { + query: "resource:topsecret#read@topsecret", + expected: checkgroup.ResultIsMember, // topsecret may read topsecret }, { query: "resource:topsecret#update@mike", expected: checkgroup.ResultIsMember, // mike owns the resource diff --git a/internal/namespace/ast/ast_definitions.go b/internal/namespace/ast/ast_definitions.go index cb7200ffb..61ec9af1d 100644 --- a/internal/namespace/ast/ast_definitions.go +++ b/internal/namespace/ast/ast_definitions.go @@ -31,6 +31,8 @@ type ( AsRewrite() *SubjectSetRewrite } + SubjectEqualsObject struct{} + ComputedSubjectSet struct { Relation string `json:"relation"` } @@ -69,3 +71,6 @@ func (t *TupleToSubjectSet) AsRewrite() *SubjectSetRewrite { func (i *InvertResult) AsRewrite() *SubjectSetRewrite { return &SubjectSetRewrite{Children: []Child{i}} } +func (e *SubjectEqualsObject) AsRewrite() *SubjectSetRewrite { + return &SubjectSetRewrite{Children: []Child{e}} +} diff --git a/internal/schema/.snapshots/TestParser-suite=snapshots-this.equals(ctx.subject).json b/internal/schema/.snapshots/TestParser-suite=snapshots-this.equals(ctx.subject).json new file mode 100644 index 000000000..164036cb0 --- /dev/null +++ b/internal/schema/.snapshots/TestParser-suite=snapshots-this.equals(ctx.subject).json @@ -0,0 +1,47 @@ +{ + "AccessToken": null, + "Account": [ + { + "name": "token", + "types": [ + { + "namespace": "AccessToken" + } + ] + }, + { + "name": "admin_token", + "types": [ + { + "namespace": "AccessToken" + } + ] + }, + { + "name": "edit", + "rewrite": { + "operator": "or", + "children": [ + {}, + { + "relation": "admin_token" + } + ] + } + }, + { + "name": "view", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "edit" + }, + { + "relation": "token" + } + ] + } + } + ] +} diff --git a/internal/schema/.snapshots/TestParser-suite=snapshots-this_==_ctx.subject.json b/internal/schema/.snapshots/TestParser-suite=snapshots-this_==_ctx.subject.json new file mode 100644 index 000000000..164036cb0 --- /dev/null +++ b/internal/schema/.snapshots/TestParser-suite=snapshots-this_==_ctx.subject.json @@ -0,0 +1,47 @@ +{ + "AccessToken": null, + "Account": [ + { + "name": "token", + "types": [ + { + "namespace": "AccessToken" + } + ] + }, + { + "name": "admin_token", + "types": [ + { + "namespace": "AccessToken" + } + ] + }, + { + "name": "edit", + "rewrite": { + "operator": "or", + "children": [ + {}, + { + "relation": "admin_token" + } + ] + } + }, + { + "name": "view", + "rewrite": { + "operator": "or", + "children": [ + { + "relation": "edit" + }, + { + "relation": "token" + } + ] + } + } + ] +} diff --git a/internal/schema/itemtype_string.go b/internal/schema/itemtype_string.go index c38fa384f..b268c1267 100644 --- a/internal/schema/itemtype_string.go +++ b/internal/schema/itemtype_string.go @@ -24,25 +24,26 @@ func _() { _ = 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] + _ = x[itemOperatorEquals-13] + _ = x[itemOperatorArrow-14] + _ = x[itemOperatorDot-15] + _ = x[itemOperatorColon-16] + _ = x[itemOperatorComma-17] + _ = x[itemSemicolon-18] + _ = x[itemTypeUnion-19] + _ = x[itemParenLeft-20] + _ = x[itemParenRight-21] + _ = x[itemBraceLeft-22] + _ = x[itemBraceRight-23] + _ = x[itemBracketLeft-24] + _ = x[itemBracketRight-25] + _ = x[itemAngledLeft-26] + _ = x[itemAngledRight-27] } -const _itemType_name = "ErrorEOFIdentifierCommentStringLiteralKeywordClassKeywordImplementsKeywordThisKeywordCtx\"&&\"\"||\"\"!\"\"=\"\"=>\"\".\"\":\"\",\"\";\"\"|\"\"(\"\")\"\"{\"\"}\"\"[\"\"]\"\"<\"\">\"" +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} +var _itemType_index = [...]uint8{0, 5, 8, 18, 25, 38, 50, 67, 78, 88, 92, 96, 99, 102, 106, 110, 113, 116, 119, 122, 125, 128, 131, 134, 137, 140, 143, 146, 149} func (i itemType) String() string { if i < 0 || i >= itemType(len(_itemType_index)-1) { diff --git a/internal/schema/lexer.go b/internal/schema/lexer.go index 7a365d9a1..1153925e3 100644 --- a/internal/schema/lexer.go +++ b/internal/schema/lexer.go @@ -61,6 +61,7 @@ const ( itemOperatorOr // "||" itemOperatorNot // "!" itemOperatorAssign // "=" + itemOperatorEquals // "==" itemOperatorArrow // "=>" itemOperatorDot // "." itemOperatorColon // ":" @@ -236,6 +237,7 @@ var oneRuneTokens = map[rune]itemType{ } var multiRuneTokens = map[string]itemType{ + "==": itemOperatorEquals, "=>": itemOperatorArrow, "||": itemOperatorOr, "&&": itemOperatorAnd, diff --git a/internal/schema/parser.go b/internal/schema/parser.go index 36e8dcd04..2b5f93147 100644 --- a/internal/schema/parser.go +++ b/internal/schema/parser.go @@ -423,14 +423,23 @@ func (p *parser) matchPropertyAccess(propertyName any) bool { func (p *parser) parsePermissionExpression() (child ast.Child) { var name, verb item - if !p.match("this", ".", &verb) { + switch { + case !p.match("this"): + return + case p.matchIf(is(itemOperatorEquals), "==", "ctx", ".", "subject"): + return &ast.SubjectEqualsObject{} + case !p.match(".", &verb): return } - if !p.matchPropertyAccess(&name) { + // failfast if verb.Val == "equals" so the matchPropertyAccess does not happen + if verb.Val != "equals" && !p.matchPropertyAccess(&name) { return } switch verb.Val { + case "equals": + p.match("(", "ctx", ".", "subject", ")") + return &ast.SubjectEqualsObject{} case "related": if !p.match(".") { return diff --git a/internal/schema/parser_test.go b/internal/schema/parser_test.go index 4a21d8d92..6a8380486 100644 --- a/internal/schema/parser_test.go +++ b/internal/schema/parser_test.go @@ -167,6 +167,48 @@ class Resource implements Namespace { "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), } +}`}, {"this == ctx.subject", ` +import {Context, Namespace} from "@ory/keto-namespace-types" + +class AccessToken implements Namespace { +} + +class Account implements Namespace { + related: { + token: AccessToken[] + admin_token: AccessToken[] + } + + permits = { + edit: (ctx: Context): boolean => + this == ctx.subject || + this.related.admin_token.includes(ctx.subject), + + view: (ctx: Context): boolean => + this.permits.edit(ctx) || + this.related.token.includes(ctx.subject), + } +}`}, {"this.equals(ctx.subject)", ` +import {Context, Namespace} from "@ory/keto-namespace-types" + +class AccessToken implements Namespace { +} + +class Account implements Namespace { + related: { + token: AccessToken[] + admin_token: AccessToken[] + } + + permits = { + edit: (ctx: Context): boolean => + this.equals(ctx.subject) || + this.related.admin_token.includes(ctx.subject), + + view: (ctx: Context): boolean => + this.permits.edit(ctx) || + this.related.token.includes(ctx.subject), + } }`}, }