From ec303a60ed3defb0bb30d9f158b29052fa384d44 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Wed, 14 Jun 2023 13:13:21 +0300 Subject: [PATCH] [#2271] added dao.CanAccessRecord() helper --- CHANGELOG.md | 4 +- daos/record.go | 51 ++++++++++++++++++ daos/record_test.go | 123 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78992bba..df708910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD ## v0.17.0-WIP - Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta). @@ -26,10 +25,11 @@ - Added `subscriptions.Client.Unset()` helper to remove a single cached item from the client store. -- (@todo docs) Added query by filter record `Dao` helpers: +- (@todo docs) Added rule and filter record `Dao` helpers: ``` app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10) app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true") + app.Dao().CanAccessRecord(record, requestData, rule) ``` - (@todo docs) Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks. diff --git a/daos/record.go b/daos/record.go index 8d91eea2..c94a7d54 100644 --- a/daos/record.go +++ b/daos/record.go @@ -475,6 +475,57 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername( return username } +// CanAccessRecord checks if a record is allowed to be accessed by the +// specified requestData and accessRule. +// +// Always return false on invalid access rule or db error. +// +// Example: +// +// requestData := apis.RequestData(c /* echo.Context */) +// record, _ := dao.FindRecordById("example", "RECORD_ID") +// // custom rule +// // or use one of the record collection's, eg. record.Collection().ViewRule +// rule := types.Pointer("@request.auth.id != '' || status = 'public'") +// +// canAccess := dao.CanAccessRecord(record, requestData, rule) +func (dao *Dao) CanAccessRecord(record *models.Record, requestData *models.RequestData, accessRule *string) bool { + if requestData.Admin != nil { + // admins can access everything + return true + } + + if accessRule == nil { + // only admins can access this record + return false + } + + if *accessRule == "" { + return true // empty public rule, aka. everyone can access + } + + var exists bool + + query := dao.RecordQuery(record.Collection()). + Select("(1)"). + AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id}) + + // parse and apply the access rule filter + resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true) + expr, err := search.FilterData(*accessRule).BuildExpr(resolver) + if err != nil { + return false + } + resolver.UpdateQuery(query) + query.AndWhere(expr) + + if err := query.Limit(1).Row(&exists); err != nil { + return false + } + + return exists +} + // SaveRecord persists the provided Record model in the database. // // If record.IsNew() is true, the method will perform a create, otherwise an update. diff --git a/daos/record_test.go b/daos/record_test.go index 09ae281b..cf18368b 100644 --- a/daos/record_test.go +++ b/daos/record_test.go @@ -582,6 +582,129 @@ func TestFindFirstRecordByFilter(t *testing.T) { } } +func TestCanAccessRecord(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + admin, err := app.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi") + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + name string + record *models.Record + requestData *models.RequestData + rule *string + expected bool + }{ + { + "as admin with nil rule", + record, + &models.RequestData{ + Admin: admin, + }, + nil, + true, + }, + { + "as admin with non-empty rule", + record, + &models.RequestData{ + Admin: admin, + }, + types.Pointer("id = ''"), // the filter rule should be ignored + true, + }, + { + "as guest with nil rule", + record, + &models.RequestData{}, + nil, + false, + }, + { + "as guest with empty rule", + nil, + &models.RequestData{}, + types.Pointer(""), + true, + }, + { + "as guest with mismatched rule", + record, + &models.RequestData{}, + types.Pointer("@request.auth.id != ''"), + false, + }, + { + "as guest with matched rule", + record, + &models.RequestData{ + Data: map[string]any{"test": 1}, + }, + types.Pointer("@request.auth.id != '' || @request.data.test = 1"), + true, + }, + { + "as auth record with nil rule", + record, + &models.RequestData{ + AuthRecord: authRecord, + }, + nil, + false, + }, + { + "as auth record with empty rule", + nil, + &models.RequestData{ + AuthRecord: authRecord, + }, + types.Pointer(""), + true, + }, + { + "as auth record with mismatched rule", + record, + &models.RequestData{ + AuthRecord: authRecord, + Data: map[string]any{"test": 1}, + }, + types.Pointer("@request.auth.id != '' && @request.data.test > 1"), + false, + }, + { + "as auth record with matched rule", + record, + &models.RequestData{ + AuthRecord: authRecord, + Data: map[string]any{"test": 2}, + }, + types.Pointer("@request.auth.id != '' && @request.data.test > 1"), + true, + }, + } + + for _, s := range scenarios { + result := app.Dao().CanAccessRecord(s.record, s.requestData, s.rule) + + if result != s.expected { + t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result) + } + } +} + func TestIsRecordValueUnique(t *testing.T) { app, _ := tests.NewTestApp() defer app.Cleanup()