Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: smarter transactions and authorization for built-in actions #1347

Merged
merged 6 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions integration/testdata/functions_custom/schema.keel
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,33 @@ model Person {
}

actions {
read countName(name) returns (CountResponse)
read countNameAdvanced(AdvancedSearchInput) returns (CountResponse)
write createAndCount(name) returns (CountResponse)
write createManyAndCount(CreateManyInput) returns (CountResponse)
read people(PeopleInput) returns (PeopleResponse)
read customPersonSearch(CustomPersonSearchInput) returns (CustomPersonSearchResponse)
read customSearch(Any) returns (Any)
write bulkPersonUpload(BulkPersonUpload) returns (BulkPersonUpload)
read noInputs() returns (Any)
read countName(name) returns (CountResponse) {
@permission(expression: true)
}
read countNameAdvanced(AdvancedSearchInput) returns (CountResponse) {
@permission(expression: true)
}
write createAndCount(name) returns (CountResponse) {
@permission(expression: true)
}
write createManyAndCount(CreateManyInput) returns (CountResponse) {
@permission(expression: true)
}
read people(PeopleInput) returns (PeopleResponse) {
@permission(expression: true)
}
read customPersonSearch(CustomPersonSearchInput) returns (CustomPersonSearchResponse) {
@permission(expression: true)
}
read customSearch(Any) returns (Any) {
@permission(expression: true)
}
write bulkPersonUpload(BulkPersonUpload) returns (BulkPersonUpload) {
@permission(expression: true)
}
read noInputs() returns (Any) {
@permission(expression: true)
}
}

@permission(
Expand Down
12 changes: 9 additions & 3 deletions integration/testdata/functions_http/schema.keel
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
model TestModel {
actions {
read withQueryParams(Any) returns (Any)
read withHeaders(Any) returns (Any)
read withStatus(Any) returns (Any)
read withQueryParams(Any) returns (Any) {
@permission(expression: true)
}
read withHeaders(Any) returns (Any) {
@permission(expression: true)
}
read withStatus(Any) returns (Any) {
@permission(expression: true)
}
}
}
4 changes: 2 additions & 2 deletions runtime/actions/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,15 @@ func ResetPassword(scope *Scope, input map[string]any) error {

identityModel := proto.FindModel(scope.Schema.Models, parser.ImplicitIdentityModelName)

query := NewQuery(scope.Context, identityModel)
query := NewQuery(identityModel)
err = query.Where(Field("id"), Equals, Value(identityId))
if err != nil {
return err
}

query.AddWriteValue(Field("password"), Value(string(hashedPassword)))

affected, err := query.UpdateStatement().Execute(scope.Context)
affected, err := query.UpdateStatement(scope.Context).Execute(scope.Context)
if err != nil {
return err
}
Expand Down
6 changes: 5 additions & 1 deletion runtime/actions/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ func authorise(scope *Scope, permissions []*proto.PermissionRule, input map[stri
// TryResolveAuthorisationEarly will attempt to check authorisation early without row-based querying.
// This will take into account logical conditions and multiple expression and role permission attributes.
func TryResolveAuthorisationEarly(scope *Scope, permissions []*proto.PermissionRule) (canResolveAll bool, authorised bool, err error) {
if len(permissions) == 0 {
return true, false, nil
}

hasDatabaseCheck := false
canResolveAll = false
for _, permission := range permissions {
Expand Down Expand Up @@ -190,7 +194,7 @@ func resolveRolePermissionRule(ctx context.Context, schema *proto.Schema, permis

func GeneratePermissionStatement(scope *Scope, permissions []*proto.PermissionRule, input map[string]any, idsToAuthorise []string) (*Statement, error) {
permissions = proto.PermissionsWithExpression(permissions)
query := NewQuery(scope.Context, scope.Model, WithJoinType(JoinTypeLeft))
query := NewQuery(scope.Model, WithJoinType(JoinTypeLeft))

// We should never have an empty list of permissions as this is checked
// higher up in the code path, but just to be safe
Expand Down
11 changes: 11 additions & 0 deletions runtime/actions/authorization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,17 @@ var authorisationTestCases = []authorisationTestCase{
earlyAuth: CouldNotAuthoriseEarly(),
identity: unverifiedIdentity,
},
{
name: "early_evaluate_no_permissions",
keelSchema: `
model Thing {
actions {
create createThing()
}
}`,
actionName: "createThing",
earlyAuth: AuthorisationDeniedEarly(),
},
{
name: "early_evaluate_create_op",
keelSchema: `
Expand Down
68 changes: 46 additions & 22 deletions runtime/actions/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/teamkeel/keel/db"
"github.com/teamkeel/keel/proto"
"github.com/teamkeel/keel/runtime/common"
)

Expand All @@ -13,33 +14,56 @@ func Create(scope *Scope, input map[string]any) (res map[string]any, err error)
return nil, err
}

err = database.Transaction(scope.Context, func(ctx context.Context) error {
scope := scope.WithContext(ctx)
query := NewQuery(scope.Context, scope.Model)
permissions := proto.PermissionsForAction(scope.Schema, scope.Action)

// Generate the SQL statement
statement, err := GenerateCreateStatement(query, scope, input)
if err != nil {
return err
}
// Attempt to resolve permissions early; i.e. before row-based database querying.
canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions)
if err != nil {
return nil, err
}

// Generate the SQL statement
query := NewQuery(scope.Model)
statement, err := GenerateCreateStatement(query, scope, input)
if err != nil {
return nil, err
}

// Execute database request, expecting a single result
switch {
case canResolveEarly && !authorised:
err = common.NewPermissionError()
case canResolveEarly && authorised:
// Execute database request without starting a transaction or performing any row-based authorization
res, err = statement.ExecuteToSingle(scope.Context)
if err != nil {
return err
}
case !canResolveEarly:
err = database.Transaction(scope.Context, func(ctx context.Context) error {
scope := scope.WithContext(ctx)
query := NewQuery(scope.Model)

// Generate the SQL statement
statement, err := GenerateCreateStatement(query, scope, input)
if err != nil {
return err
}

isAuthorised, err := AuthoriseAction(scope, input, []map[string]any{res})
if err != nil {
return err
}
// Execute database request, expecting a single result
res, err = statement.ExecuteToSingle(scope.Context)
if err != nil {
return err
}

if !isAuthorised {
return common.NewPermissionError()
}
isAuthorised, err := AuthoriseAction(scope, input, []map[string]any{res})
if err != nil {
return err
}

return nil
})
if !isAuthorised {
return common.NewPermissionError()
}

return nil
})
}

return res, err
}
Expand All @@ -58,5 +82,5 @@ func GenerateCreateStatement(query *QueryBuilder, scope *Scope, input map[string
// Return the inserted row
query.AppendReturning(AllFields())

return query.InsertStatement(), nil
return query.InsertStatement(scope.Context), nil
}
64 changes: 33 additions & 31 deletions runtime/actions/delete.go
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
package actions

import (
"context"
"errors"

"github.com/teamkeel/keel/db"
"github.com/teamkeel/keel/proto"
"github.com/teamkeel/keel/runtime/common"
)

func Delete(scope *Scope, input map[string]any) (res *string, err error) {
database, err := db.GetDatabase(scope.Context)
// Attempt to resolve permissions early; i.e. before row-based database querying.
permissions := proto.PermissionsForAction(scope.Schema, scope.Action)
canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions)
if err != nil {
return nil, err
}

err = database.Transaction(scope.Context, func(ctx context.Context) error {
query := NewQuery(scope.Context, scope.Model)
// Generate the SQL statement
query := NewQuery(scope.Model)
statement, err := GenerateDeleteStatement(query, scope, input)
if err != nil {
return nil, err
}

// Generate the SQL statement
statement, err := GenerateDeleteStatement(query, scope, input)
if err != nil {
return err
}
var row map[string]any

switch {
case canResolveEarly && !authorised:
return nil, common.NewPermissionError()
case !canResolveEarly:
query.AppendSelect(IdField())
query.AppendDistinctOn(IdField())
rows, err := query.SelectStatement().ExecuteToSingle(scope.Context)
if err != nil {
return err
return nil, err
}

rowsToAuthorise := []map[string]any{}
Expand All @@ -37,33 +42,30 @@ func Delete(scope *Scope, input map[string]any) (res *string, err error) {

isAuthorised, err := AuthoriseAction(scope, input, rowsToAuthorise)
if err != nil {
return err
return nil, err
}

if !isAuthorised {
return common.NewPermissionError()
}

// Execute database request
row, err := statement.ExecuteToSingle(scope.Context)
if err != nil {
return err
return nil, common.NewPermissionError()
}
}

if row == nil {
return common.NewNotFoundError()
}
// Execute database request, expecting a single result
row, err = statement.ExecuteToSingle(scope.Context)
if err != nil {
return nil, err
}

id, ok := row["id"].(string)
if !ok {
return errors.New("could not parse id key")
}
if row == nil {
return nil, common.NewNotFoundError()
}

res = &id
return nil
})
id, ok := row["id"].(string)
if !ok {
return nil, errors.New("could not parse id key")
}

return res, err
return &id, err
}

func GenerateDeleteStatement(query *QueryBuilder, scope *Scope, input map[string]any) (*Statement, error) {
Expand All @@ -79,5 +81,5 @@ func GenerateDeleteStatement(query *QueryBuilder, scope *Scope, input map[string

query.AppendReturning(Field("id"))

return query.DeleteStatement(), nil
return query.DeleteStatement(scope.Context), nil
}
2 changes: 1 addition & 1 deletion runtime/actions/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func generateQueryOperand(resolver *expressions.OperandResolver, args map[string

model := proto.FindModel(resolver.Schema.Models, strcase.ToCamel(fragments[0]))
ctxScope := NewModelScope(resolver.Context, model, resolver.Schema)
query := NewQuery(resolver.Context, model)
query := NewQuery(model)

identityId := ""
if auth.IsAuthenticated(resolver.Context) {
Expand Down
18 changes: 16 additions & 2 deletions runtime/actions/get.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
package actions

import "github.com/teamkeel/keel/runtime/common"
import (
"github.com/teamkeel/keel/proto"
"github.com/teamkeel/keel/runtime/common"
)

func Get(scope *Scope, input map[string]any) (map[string]any, error) {
query := NewQuery(scope.Context, scope.Model)
permissions := proto.PermissionsForAction(scope.Schema, scope.Action)

// Attempt to resolve permissions early; i.e. before row-based database querying.
canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions)
if err != nil {
return nil, err
}
if canResolveEarly && !authorised {
davenewza marked this conversation as resolved.
Show resolved Hide resolved
return nil, common.NewPermissionError()
}

query := NewQuery(scope.Model)

// Generate the SQL statement
statement, err := GenerateGetStatement(query, scope, input)
Expand Down
Loading
Loading