Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
2746 lines
71 KiB
Go
2746 lines
71 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cortezaproject/corteza/server/pkg/envoyx"
|
|
"github.com/cortezaproject/corteza/server/pkg/filter"
|
|
"github.com/cortezaproject/corteza/server/pkg/ql"
|
|
"github.com/cortezaproject/corteza/server/pkg/revisions"
|
|
"github.com/spf13/cast"
|
|
|
|
"github.com/cortezaproject/corteza/server/pkg/dal"
|
|
"github.com/cortezaproject/corteza/server/pkg/locale"
|
|
|
|
"github.com/cortezaproject/corteza/server/compose/dalutils"
|
|
"github.com/cortezaproject/corteza/server/compose/service/event"
|
|
"github.com/cortezaproject/corteza/server/compose/service/values"
|
|
"github.com/cortezaproject/corteza/server/compose/types"
|
|
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
|
"github.com/cortezaproject/corteza/server/pkg/auth"
|
|
"github.com/cortezaproject/corteza/server/pkg/corredor"
|
|
"github.com/cortezaproject/corteza/server/pkg/envoy/resource"
|
|
"github.com/cortezaproject/corteza/server/pkg/errors"
|
|
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
|
"github.com/cortezaproject/corteza/server/store"
|
|
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
|
)
|
|
|
|
const (
|
|
IMPORT_ON_ERROR_SKIP = "SKIP"
|
|
IMPORT_ON_ERROR_FAIL = "FAIL"
|
|
IMPORT_ERROR_MAX_INDEX_COUNT = 500000
|
|
)
|
|
|
|
type (
|
|
record struct {
|
|
dal dalDater
|
|
|
|
actionlog actionlog.Recorder
|
|
|
|
ac recordAccessController
|
|
eventbus eventDispatcher
|
|
|
|
store store.Storer
|
|
|
|
namespace namespaceFinder
|
|
module moduleFinder
|
|
|
|
opt RecordOptions
|
|
|
|
revisions *recordRevisions
|
|
|
|
formatter recordValuesFormatter
|
|
sanitizer recordValuesSanitizer
|
|
validator recordValuesValidator
|
|
dupDetector recordValuesDupDetector
|
|
}
|
|
|
|
recordValuesFormatter interface {
|
|
Run(*types.Module, types.RecordValueSet) types.RecordValueSet
|
|
}
|
|
|
|
recordValuesSanitizer interface {
|
|
Run(*types.Module, types.RecordValueSet) types.RecordValueSet
|
|
RunXSS(*types.Module, types.RecordValueSet) types.RecordValueSet
|
|
}
|
|
|
|
recordValuesValidator interface {
|
|
Run(context.Context, store.Storer, *types.Module, *types.Record) *types.RecordValueErrorSet
|
|
UniqueChecker(fn values.UniqueChecker)
|
|
RecordRefChecker(fn values.ReferenceChecker)
|
|
UserRefChecker(fn values.ReferenceChecker)
|
|
}
|
|
|
|
recordValuesDupDetector interface {
|
|
CheckDuplication(context.Context, types.DeDupRuleSet, types.Record, types.RecordSet) (*types.RecordValueErrorSet, error)
|
|
}
|
|
|
|
recordValueAccessController interface {
|
|
CanReadRecordValueOnModuleField(context.Context, *types.ModuleField) bool
|
|
CanUpdateRecordValueOnModuleField(context.Context, *types.ModuleField) bool
|
|
}
|
|
|
|
recordManageOwnerAccessController interface {
|
|
CanManageOwnerOnRecord(context.Context, *types.Record) bool
|
|
CanCreateOwnedRecordOnModule(context.Context, *types.Module) bool
|
|
}
|
|
|
|
recordAccessController interface {
|
|
CanCreateRecordOnModule(context.Context, *types.Module) bool
|
|
CanSearchRecordsOnModule(context.Context, *types.Module) bool
|
|
CanReadNamespace(context.Context, *types.Namespace) bool
|
|
CanReadModule(context.Context, *types.Module) bool
|
|
CanReadRecord(context.Context, *types.Record) bool
|
|
CanUpdateRecord(context.Context, *types.Record) bool
|
|
CanDeleteRecord(context.Context, *types.Record) bool
|
|
CanUndeleteRecord(context.Context, *types.Record) bool
|
|
CanSearchRevisionsOnRecord(context.Context, *types.Record) bool
|
|
|
|
recordManageOwnerAccessController
|
|
recordValueAccessController
|
|
}
|
|
|
|
moduleFinder interface {
|
|
Find(ctx context.Context, filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error)
|
|
}
|
|
|
|
namespaceFinder interface {
|
|
Find(context.Context, types.NamespaceFilter) (types.NamespaceSet, types.NamespaceFilter, error)
|
|
}
|
|
|
|
RecordService interface {
|
|
FindByID(ctx context.Context, namespaceID, moduleID, recordID uint64) (*types.Record, *types.RecordValueErrorSet, error)
|
|
|
|
Report(ctx context.Context, namespaceID, moduleID uint64, metrics, dimensions, filter string) (any, error)
|
|
Find(ctx context.Context, filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error)
|
|
FindN(ctx context.Context, filter types.RecordFilter) (set types.RecordSet, stats map[string]types.RecordSummary, f types.RecordFilter, err error)
|
|
SearchSensitive(ctx context.Context) (set []types.SensitiveRecordSet, err error)
|
|
SearchRevisions(ctx context.Context, namespaceID, moduleID, recordID uint64, sorting filter.Sorting) (dal.Iterator, error)
|
|
RecordExport(context.Context, types.RecordFilter) error
|
|
RecordImport(context.Context, error) error
|
|
|
|
Create(ctx context.Context, record *types.Record) (*types.Record, *types.RecordValueErrorSet, error)
|
|
Update(ctx context.Context, record *types.Record) (*types.Record, *types.RecordValueErrorSet, error)
|
|
Bulk(ctx context.Context, skipFailed bool, oo ...*types.RecordBulkOperation) ([]types.RecordBulkOperationResult, error)
|
|
|
|
BulkModifyByFilter(ctx context.Context, f types.RecordFilter, values types.RecordValueSet, operation types.OperationType) (err error)
|
|
|
|
Validate(ctx context.Context, rec *types.Record) error
|
|
|
|
DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error
|
|
UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error
|
|
|
|
Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, sortingField, sortingValue, sortingFilter, valueField, value string) error
|
|
|
|
Iterator(ctx context.Context, f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error)
|
|
|
|
TriggerScript(ctx context.Context, namespaceID, moduleID, recordID uint64, rvs types.RecordValueSet, script string) (*types.Module, *types.Record, error)
|
|
}
|
|
|
|
synteticRecordDataGen interface {
|
|
LoremIpsumSentence(int) string
|
|
IntRange(int, int) int
|
|
DigitN(uint) string
|
|
Email() string
|
|
URL() string
|
|
Date() time.Time
|
|
Number(int, int) int
|
|
Street() string
|
|
City() string
|
|
Country() string
|
|
}
|
|
|
|
RecordOptions struct {
|
|
LimitRecords int
|
|
}
|
|
|
|
RecordImportSession struct {
|
|
Name string `json:"-"`
|
|
SessionID uint64 `json:"sessionID,string"`
|
|
UserID uint64 `json:"userID,string"`
|
|
NamespaceID uint64 `json:"namespaceID,string"`
|
|
ModuleID uint64 `json:"moduleID,string"`
|
|
|
|
OnError string `json:"onError"`
|
|
Fields map[string]string `json:"fields"`
|
|
Key string `json:"key"`
|
|
Progress *RecordImportProgress `json:"progress"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
Resources []resource.Interface `json:"-"`
|
|
Providers []envoyx.Provider `json:"-"`
|
|
}
|
|
|
|
RecordImportProgress struct {
|
|
StartedAt *time.Time `json:"startedAt"`
|
|
FinishedAt *time.Time `json:"finishedAt"`
|
|
EntryCount uint64 `json:"entryCount"`
|
|
Completed uint64 `json:"completed"`
|
|
Failed uint64 `json:"failed"`
|
|
FailReason string `json:"failReason,omitempty"`
|
|
|
|
FailLog *FailLog `json:"failLog,omitempty"`
|
|
}
|
|
|
|
FailLog struct {
|
|
// Records holds an array of record indexes
|
|
Records RecordIndex `json:"records"`
|
|
RecordsTruncated bool `json:"recordsTruncated"`
|
|
// Errors specifies a map of occurred errors & the number of
|
|
Errors ErrorIndex `json:"errors"`
|
|
}
|
|
|
|
RecordIndex []int
|
|
ErrorIndex map[string]int
|
|
|
|
recordReportEntry map[string]any
|
|
)
|
|
|
|
func Record(opts RecordOptions) *record {
|
|
svc := &record{
|
|
actionlog: DefaultActionlog,
|
|
ac: DefaultAccessControl,
|
|
eventbus: eventbus.Service(),
|
|
store: DefaultStore,
|
|
dal: dal.Service(),
|
|
|
|
namespace: DefaultNamespace,
|
|
module: DefaultModule,
|
|
|
|
opt: opts,
|
|
|
|
revisions: &recordRevisions{revisions.Service(dal.Service())},
|
|
|
|
formatter: values.Formatter(),
|
|
sanitizer: values.Sanitizer(),
|
|
dupDetector: types.DeDup(),
|
|
}
|
|
|
|
svc.validator = defaultValidator(svc)
|
|
|
|
return svc
|
|
}
|
|
|
|
func defaultValidator(svc RecordService) recordValuesValidator {
|
|
// Initialize validator and setup all checkers it needs
|
|
validator := values.Validator()
|
|
|
|
validator.UniqueChecker(func(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, m *types.Module) (uint64, error) {
|
|
if v.Ref == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
// @todo re-implement record-value ref lookup through DAL
|
|
panic("implement me")
|
|
})
|
|
|
|
validator.RecordRefChecker(func(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, m *types.Module) (bool, error) {
|
|
if svc == nil && v.Ref == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
var (
|
|
referencedModuleID = f.Options.Uint64("moduleID")
|
|
|
|
r, _, err = svc.FindByID(ctx, f.NamespaceID, referencedModuleID, v.Ref)
|
|
)
|
|
|
|
return r != nil, err
|
|
})
|
|
|
|
validator.UserRefChecker(func(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, m *types.Module) (bool, error) {
|
|
r, err := store.LookupUserByID(ctx, s, v.Ref)
|
|
return r != nil, err
|
|
})
|
|
|
|
validator.FileRefChecker(func(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, m *types.Module) (bool, error) {
|
|
if v.Ref == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
r, err := store.LookupComposeAttachmentByID(ctx, s, v.Ref)
|
|
return r != nil, err
|
|
})
|
|
|
|
return validator
|
|
}
|
|
|
|
// lookup fn() orchestrates record lookup, namespace preload and check
|
|
func (svc record) lookup(ctx context.Context, namespaceID, moduleID uint64, lookup func(*types.Module, *recordActionProps) (*types.Record, error)) (r *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID}}
|
|
)
|
|
|
|
err = func() error {
|
|
if ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
if r, err = lookup(m, aProps); errors.IsNotFound(err) {
|
|
return RecordErrNotFound()
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setRecord(r)
|
|
|
|
if !svc.ac.CanReadRecord(ctx, r) {
|
|
return RecordErrNotAllowedToRead()
|
|
}
|
|
|
|
ComposeRecordFilterAC(ctx, svc.ac, m, r)
|
|
|
|
r.SetModule(m)
|
|
r.Values = svc.sanitizer.RunXSS(m, r.Values)
|
|
|
|
dd, err = svc.DupDetection(ctx, m, r)
|
|
|
|
// Attach meta ID to each value error for FE identification
|
|
if r != nil && dd != nil {
|
|
dd.SetMetaID(r.ID)
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
return r, dd, svc.recordAction(ctx, aProps, RecordActionLookup, err)
|
|
}
|
|
|
|
func (svc record) FindByID(ctx context.Context, namespaceID, moduleID, recordID uint64) (r *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
return svc.lookup(ctx, namespaceID, moduleID, func(m *types.Module, props *recordActionProps) (*types.Record, error) {
|
|
props.record.ID = recordID
|
|
|
|
return dalutils.ComposeRecordsFind(ctx, svc.dal, m, recordID)
|
|
})
|
|
}
|
|
|
|
// Report generates report for a given module using metrics, dimensions and filter
|
|
// @note will eventually be removed in favor of the system report endpoints
|
|
func (svc record) Report(ctx context.Context, namespaceID, moduleID uint64, metrics, dimensions, f string) (_ any, err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID}}
|
|
|
|
iter dal.Iterator
|
|
reportItems = make([]recordReportEntry, 0, 16)
|
|
)
|
|
|
|
err = func() error {
|
|
if ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
if !svc.ac.CanSearchRecordsOnModule(ctx, m) {
|
|
return RecordErrNotAllowedToSearch()
|
|
}
|
|
|
|
pp, agg, err := recordReportToDalPipeline(m, metrics, dimensions, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Run it
|
|
iter, err = svc.dal.Run(ctx, pp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer iter.Close()
|
|
|
|
for iter.Next(ctx) {
|
|
item := recordReportEntry{}
|
|
err = iter.Scan(item)
|
|
recordReportCorrectTypes(agg, item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reportItems = append(reportItems, item)
|
|
}
|
|
return iter.Err()
|
|
}()
|
|
|
|
return reportItems, svc.recordAction(ctx, aProps, RecordActionReport, err)
|
|
}
|
|
|
|
func (svc record) Find(ctx context.Context, filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error) {
|
|
var (
|
|
m *types.Module
|
|
aProps = &recordActionProps{filter: &filter}
|
|
)
|
|
|
|
err = func() error {
|
|
if m, err = loadModule(ctx, svc.store, filter.NamespaceID, filter.ModuleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !svc.ac.CanSearchRecordsOnModule(ctx, m) {
|
|
return RecordErrNotAllowedToSearch()
|
|
}
|
|
|
|
// Validate field-level permissions for filter query
|
|
if err = svc.validateFilterFieldPermissions(ctx, svc.ac, m, filter.Query); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate field-level permissions for sort expression
|
|
if err = svc.validateSortFieldPermissions(ctx, svc.ac, m, filter.Sort); err != nil {
|
|
return err
|
|
}
|
|
|
|
filter.Check = ComposeRecordFilterChecker(ctx, svc.ac, m)
|
|
|
|
if set, f, err = dalutils.ComposeRecordsList(ctx, svc.dal, m, filter); err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = set.Walk(func(r *types.Record) error {
|
|
r.SetModule(m)
|
|
r.Values = svc.sanitizer.RunXSS(m, r.Values)
|
|
return nil
|
|
})
|
|
|
|
ComposeRecordFilterAC(ctx, svc.ac, m, set...)
|
|
|
|
return nil
|
|
}()
|
|
|
|
return set, f, svc.recordAction(ctx, aProps, RecordActionSearch, err)
|
|
}
|
|
|
|
func (svc record) FindN(ctx context.Context, filter types.RecordFilter) (set types.RecordSet, stats map[string]types.RecordSummary, f types.RecordFilter, err error) {
|
|
var (
|
|
m *types.Module
|
|
aProps = &recordActionProps{filter: &filter}
|
|
)
|
|
|
|
err = func() error {
|
|
if m, err = loadModule(ctx, svc.store, filter.NamespaceID, filter.ModuleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !svc.ac.CanSearchRecordsOnModule(ctx, m) {
|
|
return RecordErrNotAllowedToSearch()
|
|
}
|
|
|
|
// Validate field-level permissions for filter query
|
|
if err = svc.validateFilterFieldPermissions(ctx, svc.ac, m, filter.Query); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate field-level permissions for sort expression
|
|
if err = svc.validateSortFieldPermissions(ctx, svc.ac, m, filter.Sort); err != nil {
|
|
return err
|
|
}
|
|
|
|
filter.Check = ComposeRecordFilterChecker(ctx, svc.ac, m)
|
|
|
|
if set, stats, f, err = dalutils.ComposeRecordsListN(ctx, svc.dal, m, filter); err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = set.Walk(func(r *types.Record) error {
|
|
r.SetModule(m)
|
|
r.Values = svc.sanitizer.RunXSS(m, r.Values)
|
|
return nil
|
|
})
|
|
|
|
ComposeRecordFilterAC(ctx, svc.ac, m, set...)
|
|
|
|
return nil
|
|
}()
|
|
|
|
return set, stats, f, svc.recordAction(ctx, aProps, RecordActionSearch, err)
|
|
}
|
|
|
|
// SearchSensitive returns stripped down records for all namespaces/modules where fields define a sensitivity level
|
|
func (svc record) SearchSensitive(ctx context.Context) (set []types.SensitiveRecordSet, err error) {
|
|
var (
|
|
namespaces types.NamespaceSet
|
|
modules types.ModuleSet
|
|
|
|
aProps = &recordActionProps{}
|
|
|
|
userID = auth.GetIdentityFromContext(ctx).Identity()
|
|
)
|
|
|
|
err = func() error {
|
|
// Get namespaces
|
|
namespaces, _, err = svc.namespace.Find(ctx, types.NamespaceFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, namespace := range namespaces {
|
|
// Get corresponding modules
|
|
modules, _, err = svc.module.Find(ctx, types.ModuleFilter{NamespaceID: namespace.ID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, module := range modules {
|
|
// Get sensitive record data
|
|
aux, err := svc.searchSensitive(ctx, userID, namespace, module, types.RecordFilter{
|
|
ModuleID: module.ID,
|
|
NamespaceID: namespace.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(aux.Records) == 0 {
|
|
continue
|
|
}
|
|
|
|
set = append(set, aux)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
return set, svc.recordAction(ctx, aProps, RecordActionSearchSensitive, err)
|
|
}
|
|
|
|
func (svc record) searchSensitive(ctx context.Context, userID uint64, namespace *types.Namespace, module *types.Module, filter types.RecordFilter) (out types.SensitiveRecordSet, err error) {
|
|
out = types.SensitiveRecordSet{
|
|
Namespace: namespace,
|
|
Module: module,
|
|
}
|
|
|
|
// Force the query to only show owned records
|
|
// @todo allow additional querying
|
|
filter.Query = fmt.Sprintf("ownedBy='%d'", userID)
|
|
|
|
rr, _, err := svc.Find(ctx, filter)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, r := range rr {
|
|
vv := make([]map[string]any, 0, len(r.Values))
|
|
|
|
for _, f := range module.Fields {
|
|
// Skip the ones with no privacy
|
|
// @todo allow the request to specify what level we wish to see
|
|
if !f.IsSensitive() {
|
|
continue
|
|
}
|
|
|
|
values := make([]any, 0, 2)
|
|
for _, v := range r.Values.FilterByName(f.Name) {
|
|
values = append(values, v.Value)
|
|
}
|
|
|
|
// Make value
|
|
vv = append(vv, map[string]any{
|
|
"name": f.Name,
|
|
"kind": f.Kind,
|
|
"isMulti": f.Multi,
|
|
"value": values,
|
|
})
|
|
}
|
|
|
|
if len(vv) == 0 {
|
|
continue
|
|
}
|
|
|
|
out.Records = append(out.Records, types.SensitiveRecord{
|
|
RecordID: r.ID,
|
|
Values: vv,
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// SearchRevisions returns iterator for revisions of a record
|
|
func (svc record) SearchRevisions(ctx context.Context, namespaceID, moduleID, recordID uint64, sorting filter.Sorting) (dal.Iterator, error) {
|
|
var (
|
|
aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID, ModuleID: moduleID, ID: recordID}}
|
|
|
|
rec *types.Record
|
|
iter dal.Iterator
|
|
mod *types.Module
|
|
ns *types.Namespace
|
|
)
|
|
|
|
err := func() (err error) {
|
|
ns, mod, rec, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
aProps.setModule(mod)
|
|
aProps.setNamespace(ns)
|
|
aProps.setRecord(rec)
|
|
|
|
if !mod.Config.RecordRevisions.Enabled {
|
|
return RecordErrRevisionsDisabledOnModule()
|
|
}
|
|
|
|
if !svc.ac.CanSearchRevisionsOnRecord(ctx, rec) {
|
|
return RecordErrNotAllowedToSearchRevisions()
|
|
}
|
|
|
|
iter, err = svc.revisions.search(ctx, rec, sorting)
|
|
return
|
|
}()
|
|
|
|
return iter, svc.recordAction(ctx, aProps, RecordActionSearchRevisions, err)
|
|
}
|
|
|
|
func (svc record) RecordImport(ctx context.Context, err error) error {
|
|
return svc.recordAction(ctx, &recordActionProps{}, RecordActionImport, err)
|
|
}
|
|
|
|
// RecordExport records that the export has occurred
|
|
func (svc record) RecordExport(ctx context.Context, f types.RecordFilter) (err error) {
|
|
return svc.recordAction(ctx, &recordActionProps{filter: &f}, RecordActionExport, err)
|
|
}
|
|
|
|
// Bulk handles provided set of bulk record operations.
|
|
// It's able to create, update or delete records in a single transaction.
|
|
func (svc record) Bulk(ctx context.Context, skipFailed bool, oo ...*types.RecordBulkOperation) (rr []types.RecordBulkOperationResult, err error) {
|
|
var pr *types.Record
|
|
rr = make([]types.RecordBulkOperationResult, len(oo))
|
|
|
|
err = func() error {
|
|
// pre-verify all
|
|
for _, p := range oo {
|
|
switch p.Operation {
|
|
case types.OperationTypeCreate,
|
|
types.OperationTypeUpdate,
|
|
types.OperationTypeDelete,
|
|
types.OperationTypePatch:
|
|
// ok
|
|
default:
|
|
return RecordErrUnknownBulkOperation(&recordActionProps{bulkOperation: string(p.Operation)})
|
|
}
|
|
}
|
|
|
|
var (
|
|
dupErrors = &types.RecordValueErrorSet{}
|
|
|
|
action func(props ...*recordActionProps) *recordAction
|
|
r *types.Record
|
|
|
|
aProp = &recordActionProps{}
|
|
)
|
|
|
|
for i, p := range oo {
|
|
var (
|
|
valueErrors *types.RecordValueErrorSet
|
|
)
|
|
|
|
r = p.Record
|
|
// Fetchthe requested record; primarily used for ops which don't need a base
|
|
if p.RecordID != 0 {
|
|
r, valueErrors, err = svc.FindByID(ctx, p.NamespaceID, p.ModuleID, p.RecordID)
|
|
// This one can't be recovered
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if p.Operation == types.OperationTypePatch {
|
|
r.Values = p.Record.Values
|
|
}
|
|
|
|
rr[i] = types.RecordBulkOperationResult{
|
|
Record: r,
|
|
ValueError: valueErrors,
|
|
Error: err,
|
|
}
|
|
} else {
|
|
rr[i] = types.RecordBulkOperationResult{
|
|
Record: r,
|
|
}
|
|
}
|
|
|
|
aProp.setRecord(r)
|
|
|
|
// Handle any pre processing, such as defining parent recordID.
|
|
if p.LinkBy != "" {
|
|
// As is, we can use the first record as the master record.
|
|
// This is valid, since we do not allow this, if the master record is not defined
|
|
rv := &types.RecordValue{
|
|
Name: p.LinkBy,
|
|
}
|
|
if pr != nil {
|
|
rv.Value = strconv.FormatUint(rr[0].Record.ID, 10)
|
|
rv.Ref = rr[0].Record.ID
|
|
}
|
|
r.Values = r.Values.Set(rv)
|
|
}
|
|
|
|
switch p.Operation {
|
|
case types.OperationTypeCreate:
|
|
action = RecordActionCreate
|
|
r, dupErrors, err = svc.create(ctx, r)
|
|
|
|
case types.OperationTypeUpdate:
|
|
action = RecordActionUpdate
|
|
r, dupErrors, err = svc.update(ctx, r)
|
|
|
|
case types.OperationTypeDelete:
|
|
action = RecordActionDelete
|
|
r, err = svc.delete(ctx, r.NamespaceID, r.ModuleID, r.ID)
|
|
|
|
case types.OperationTypePatch:
|
|
action = RecordActionPatch
|
|
r, dupErrors, err = svc.patch(ctx, r, r.Values)
|
|
}
|
|
|
|
aProp.setChanged(r)
|
|
|
|
// Attach meta ID to each value error for FE identification
|
|
if !dupErrors.HasStrictErrors() && r != nil {
|
|
dupErrors.SetMetaID(r.ID)
|
|
}
|
|
rr[i].DuplicationError = dupErrors
|
|
|
|
if rve := types.IsRecordValueErrorSet(err); rve != nil {
|
|
if valueErrors == nil {
|
|
valueErrors = &types.RecordValueErrorSet{}
|
|
}
|
|
|
|
// Attach additional meta to each value error for FE identification
|
|
for _, re := range rve.Set {
|
|
if re.Meta == nil {
|
|
continue
|
|
}
|
|
|
|
if p.ID != "" {
|
|
re.Meta["id"] = p.ID
|
|
}
|
|
|
|
valueErrors.Push(re)
|
|
}
|
|
|
|
// log record value error for this record
|
|
_ = svc.recordAction(ctx, aProp, action, err)
|
|
|
|
rr[i].ValueError = valueErrors
|
|
|
|
// Clear the current error
|
|
err = nil
|
|
|
|
// do not return errors just yet, values on other records from the payload (if any)
|
|
// might have errors too
|
|
continue
|
|
}
|
|
|
|
_ = svc.recordAction(ctx, aProp, action, err)
|
|
if !skipFailed && err != nil {
|
|
return err
|
|
} else {
|
|
rr[i].Error = err
|
|
err = nil
|
|
}
|
|
|
|
if pr == nil {
|
|
pr = r
|
|
}
|
|
}
|
|
|
|
var ee = &types.RecordValueErrorSet{}
|
|
for _, r := range rr {
|
|
if !r.ValueError.IsValid() {
|
|
ee.Merge(r.ValueError)
|
|
}
|
|
if r.DuplicationError.HasStrictErrors() {
|
|
ee.Merge(r.DuplicationError)
|
|
}
|
|
}
|
|
if !skipFailed && !ee.IsValid() {
|
|
// Any errors gathered?
|
|
return RecordErrValueInput().Wrap(ee)
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
if len(oo) == 1 {
|
|
// was not really a bulk operation, and we already recorded the action
|
|
// inside transaction loop
|
|
return rr, err
|
|
} else {
|
|
// when doing bulk op (updating and/or creating more than one record at once),
|
|
// we already log action for each operation
|
|
//
|
|
// to log the fact that the bulk op was done, we do one additional recording
|
|
// without any props
|
|
return rr, svc.recordAction(ctx, &recordActionProps{}, RecordActionBulk, err)
|
|
}
|
|
}
|
|
|
|
// BulkModifyByFilter performs bulk record operations based on the provided filter query.
|
|
// It's able to update, delete or undelete records in a single transaction.
|
|
func (svc record) BulkModifyByFilter(ctx context.Context, f types.RecordFilter, values types.RecordValueSet, operation types.OperationType) (err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
r *types.Record
|
|
records types.RecordSet
|
|
recordFilter types.RecordFilter
|
|
|
|
aProps = &recordActionProps{
|
|
namespace: &types.Namespace{ID: f.NamespaceID},
|
|
module: &types.Module{ID: f.ModuleID},
|
|
}
|
|
action func(props ...*recordActionProps) *recordAction
|
|
|
|
valueError *types.RecordValueErrorSet
|
|
)
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
// load both the namespace and module
|
|
if ns, m, err = loadModuleCombo(ctx, s, f.NamespaceID, f.ModuleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
f.Limit = 500
|
|
|
|
// performing a batched search for IDs, processing them in batches of 500 for update.
|
|
for {
|
|
records, recordFilter, err = svc.Find(ctx, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, r = range records {
|
|
aProps.setRecord(r)
|
|
|
|
switch operation {
|
|
case types.OperationTypePatch:
|
|
action = RecordActionPatch
|
|
r, valueError, err = svc.patch(ctx, r, values)
|
|
case types.OperationTypeDelete:
|
|
action = RecordActionDelete
|
|
r, err = svc.processDelete(ctx, r, ns, m)
|
|
case types.OperationTypeUndelete:
|
|
action = RecordActionUndelete
|
|
r, err = svc.processUndelete(ctx, r, ns, m)
|
|
}
|
|
|
|
aProps.setChanged(r)
|
|
|
|
if valueError != nil && !valueError.IsValid() {
|
|
return RecordErrValueInput().Wrap(valueError)
|
|
}
|
|
|
|
_ = svc.recordAction(ctx, aProps, action, err)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if recordFilter.NextPage == nil {
|
|
break
|
|
}
|
|
|
|
f.NextPage = recordFilter.NextPage
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Raw create function that is responsible for value validation, event dispatching
|
|
// and creation.
|
|
func (svc record) create(ctx context.Context, new *types.Record) (rec *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
aProps = &recordActionProps{record: new}
|
|
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
|
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
)
|
|
|
|
ns, m, err = loadModuleCombo(ctx, svc.store, new.NamespaceID, new.ModuleID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
// check the records limit per namespace
|
|
if err = svc.checkLimit(ctx, m); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if !svc.ac.CanCreateRecordOnModule(ctx, m) {
|
|
return nil, dd, RecordErrNotAllowedToCreate()
|
|
}
|
|
|
|
if err = RecordValueSanitization(m, new.Values); err != nil {
|
|
return
|
|
}
|
|
|
|
var (
|
|
rve *types.RecordValueErrorSet
|
|
)
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
new.SetModule(m)
|
|
|
|
{
|
|
// handle deDup error/warnings
|
|
dd, err = svc.DupDetection(ctx, m, new)
|
|
|
|
// handle input payload errors
|
|
if rve = svc.procCreate(ctx, invokerID, m, new); !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
// record value errors from dup detection
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeCreate(new, nil, m, ns, rve, nil)); err != nil {
|
|
return
|
|
} else if !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
}
|
|
|
|
new.Values = RecordValueDefaults(m, new.Values)
|
|
|
|
// Handle payload from automation scripts
|
|
if rve = svc.procCreate(ctx, invokerID, m, new); !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
aProps.setChanged(new)
|
|
|
|
if err = dalutils.ComposeRecordCreate(ctx, svc.dal, m, new); err != nil {
|
|
return
|
|
}
|
|
|
|
// store revision
|
|
if m.Config.RecordRevisions.Enabled {
|
|
if err = svc.revisions.created(ctx, new); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
new.SetModule(m)
|
|
|
|
// At this point we can return the value
|
|
rec = new
|
|
|
|
{
|
|
new.Values = svc.formatter.Run(m, new.Values)
|
|
_ = svc.eventbus.WaitFor(ctx, event.RecordAfterCreateImmutable(new, nil, m, ns, nil, nil))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (svc record) checkLimit(ctx context.Context, m *types.Module) (err error) {
|
|
if svc.opt.LimitRecords == 0 {
|
|
return nil
|
|
}
|
|
|
|
// get the number of records count for the namespace
|
|
recordsCount, err := dalutils.ComposeRecordsCount(ctx, svc.dal, m, types.RecordFilter{
|
|
NamespaceID: m.NamespaceID,
|
|
ModuleID: m.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if recordsCount >= uint(svc.opt.LimitRecords) {
|
|
return RecordErrMaxRecordsReached()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordValueSanitization does basic field and format validation
|
|
//
|
|
// Received values must fit the data model: on unknown fields
|
|
// or multi/single value mismatch we return an error
|
|
//
|
|
// Record value errors is intentionally NOT used here; if input fails here
|
|
// we can assume that form builder (or whatever it was that assembled the record values)
|
|
// was misconfigured and will most likely failed to properly parse the
|
|
// record value errors payload too
|
|
func RecordValueSanitization(m *types.Module, vv types.RecordValueSet) (err error) {
|
|
var (
|
|
aProps = &recordActionProps{}
|
|
numeric = regexp.MustCompile(`^[1-9](\d*)$`)
|
|
)
|
|
|
|
err = vv.Walk(func(v *types.RecordValue) error {
|
|
var field = m.Fields.FindByName(v.Name)
|
|
if field == nil {
|
|
return RecordErrFieldNotFound(aProps.setField(v.Name))
|
|
}
|
|
|
|
if field.IsRef() {
|
|
if v.Value == "" || v.Value == "0" {
|
|
return nil
|
|
}
|
|
|
|
if !numeric.MatchString(v.Value) {
|
|
return RecordErrInvalidReferenceFormat(aProps.setField(v.Name).setValue(v.Value))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Make sure there are no multi values in a non-multi value fields
|
|
err = m.Fields.Walk(func(field *types.ModuleField) error {
|
|
if !field.Multi && len(vv.FilterByName(field.Name)) > 1 {
|
|
return RecordErrInvalidValueStructure(aProps.setField(field.Name))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func SetRecordOwner(ctx context.Context, ac recordManageOwnerAccessController, s store.Storer, old, upd *types.Record, invoker uint64) *types.RecordValueErrorSet {
|
|
if upd == nil {
|
|
// no-op
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
curOwner uint64
|
|
updOwner = upd.OwnedBy
|
|
)
|
|
|
|
if old != nil {
|
|
curOwner = old.OwnedBy
|
|
}
|
|
|
|
updOwner = CalcRecordOwner(curOwner, updOwner, invoker)
|
|
|
|
var (
|
|
mkError = func(kind, tkey string) *types.RecordValueErrorSet {
|
|
return &types.RecordValueErrorSet{Set: []types.RecordValueError{{
|
|
Kind: kind,
|
|
Meta: map[string]interface{}{"field": "", "value": updOwner},
|
|
Message: locale.Global().T(ctx, "compose", tkey),
|
|
}}}
|
|
}
|
|
)
|
|
|
|
if old == nil && updOwner != invoker {
|
|
// Can be set on new records
|
|
if !ac.CanCreateOwnedRecordOnModule(ctx, upd.GetModule()) {
|
|
return mkError("accessDenied", "record.errors.ownershipChangeDenied")
|
|
}
|
|
}
|
|
|
|
if old != nil && curOwner != updOwner {
|
|
// Can ownership be changed on existing record?
|
|
if !ac.CanManageOwnerOnRecord(ctx, upd) {
|
|
return mkError("accessDenied", "record.errors.ownershipChangeDenied")
|
|
}
|
|
}
|
|
|
|
if _, err := store.LookupUserByID(ctx, s, updOwner); err != nil {
|
|
if errors.IsNotFound(err) {
|
|
return mkError("invalidValue", "record.errors.invalidOwner")
|
|
} else {
|
|
return mkError("internal", "record.errors.store")
|
|
}
|
|
}
|
|
|
|
upd.OwnedBy = updOwner
|
|
return nil
|
|
}
|
|
|
|
func CalcRecordOwner(current, new, invoker uint64) uint64 {
|
|
if invoker == 0 {
|
|
// invoker is, for some reason 0,
|
|
// use current owner as invoker
|
|
invoker = current
|
|
}
|
|
|
|
if new == 0 {
|
|
// if new owner is not set, use invoker
|
|
return invoker
|
|
}
|
|
|
|
// keep owner unchanged
|
|
return new
|
|
}
|
|
|
|
// @todo: ?? this might be a good place for detection too
|
|
func RecordValueUpdateOpCheck(ctx context.Context, ac recordValueAccessController, m *types.Module, vv types.RecordValueSet) *types.RecordValueErrorSet {
|
|
rve := &types.RecordValueErrorSet{}
|
|
if ac == nil {
|
|
return rve
|
|
}
|
|
|
|
_ = vv.Walk(func(v *types.RecordValue) error {
|
|
f := m.Fields.FindByName(v.Name)
|
|
|
|
// when f is nil, the module field was deleted so we shouldn't do any AC
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
|
|
if v.IsUpdated() && !ac.CanUpdateRecordValueOnModuleField(ctx, f) {
|
|
rve.Push(types.RecordValueError{
|
|
Kind: "updateDenied",
|
|
Meta: map[string]interface{}{"field": v.Name},
|
|
Message: locale.Global().T(ctx, "compose", "record-field.errors.updateDenied"),
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return rve
|
|
}
|
|
|
|
func RecordPreparer(ctx context.Context, s store.Storer, ss recordValuesSanitizer, vv recordValuesValidator, ff recordValuesFormatter, m *types.Module, new *types.Record) *types.RecordValueErrorSet {
|
|
// Before values are processed further and
|
|
// sent to automation scripts (if any)
|
|
// we need to make sure it does not get un-sanitized data
|
|
new.Values = ss.Run(m, new.Values)
|
|
|
|
rve := &types.RecordValueErrorSet{}
|
|
values.Expression(ctx, m, new, nil, rve)
|
|
|
|
if !rve.IsValid() {
|
|
return rve
|
|
}
|
|
|
|
// Run validation of the updated records
|
|
rve = vv.Run(ctx, s, m, new)
|
|
if !rve.IsValid() {
|
|
return rve
|
|
}
|
|
|
|
// Cleanup the values
|
|
new.Values = new.Values.GetClean()
|
|
|
|
// Formatting
|
|
new.Values = ff.Run(m, new.Values)
|
|
|
|
return nil
|
|
}
|
|
|
|
func RecordValueDefaults(m *types.Module, vv types.RecordValueSet) (out types.RecordValueSet) {
|
|
out = vv
|
|
|
|
for _, f := range m.Fields {
|
|
if f.DefaultValue == nil {
|
|
continue
|
|
}
|
|
|
|
for i, dv := range f.DefaultValue {
|
|
// Default values on field are (might be) without field name and place
|
|
if !out.Has(f.Name, uint(i)) {
|
|
out = append(out, &types.RecordValue{
|
|
Name: f.Name,
|
|
Value: dv.Value,
|
|
Place: uint(i),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Raw update function that is responsible for value validation, event dispatching
|
|
// and update.
|
|
func (svc record) update(ctx context.Context, upd *types.Record) (rec *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
aProps = &recordActionProps{record: upd}
|
|
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
|
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
old *types.Record
|
|
)
|
|
|
|
if upd.ID == 0 {
|
|
return nil, dd, RecordErrInvalidID()
|
|
}
|
|
|
|
ns, m, old, err = loadRecordCombo(ctx, svc.store, svc.dal, upd.NamespaceID, upd.ModuleID, upd.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
aProps.setRecord(old)
|
|
|
|
if !svc.ac.CanUpdateRecord(ctx, old) {
|
|
return nil, dd, RecordErrNotAllowedToUpdate()
|
|
}
|
|
|
|
// Test if stale (update has an older version of data)
|
|
if isStale(upd.UpdatedAt, old.UpdatedAt, old.CreatedAt) {
|
|
return nil, dd, RecordErrStaleData()
|
|
}
|
|
|
|
if err = RecordValueSanitization(m, upd.Values); err != nil {
|
|
return
|
|
}
|
|
|
|
var (
|
|
rve *types.RecordValueErrorSet
|
|
)
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
upd.SetModule(m)
|
|
old.SetModule(m)
|
|
|
|
{
|
|
// handle deDup error/warnings
|
|
dd, err = svc.DupDetection(ctx, m, upd)
|
|
|
|
// handle input payload errors
|
|
if rve = svc.procUpdate(ctx, invokerID, m, upd, old); !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
// record value errors from dup detection
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Scripts can (besides simple error value) return complex record value error set
|
|
// that is passed back to the UI or any other API consumer
|
|
//
|
|
// rve (record-validation-errorset) struct is passed so it can be
|
|
// used & filled by automation scripts
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUpdate(upd, old, m, ns, rve, nil)); err != nil {
|
|
return
|
|
} else if !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
}
|
|
|
|
// Handle payload from automation scripts
|
|
if rve = svc.procUpdate(ctx, invokerID, m, upd, old); !rve.IsValid() {
|
|
return nil, dd, RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
aProps.setChanged(upd)
|
|
|
|
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, upd); err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.Config.RecordRevisions.Enabled {
|
|
// Prepare record revision for update
|
|
if err = svc.revisions.updated(ctx, upd, old); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, dd, err
|
|
}
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
upd.SetModule(m)
|
|
old.SetModule(m)
|
|
|
|
// Final value cleanup
|
|
// These (clean) values are returned (and sent to after-update handler)
|
|
upd.Values = upd.Values.GetClean()
|
|
|
|
// At this point we can return the value
|
|
rec = upd
|
|
|
|
{
|
|
// Before we pass values to automation scripts, they should be formatted
|
|
upd.Values = svc.formatter.Run(m, upd.Values)
|
|
_ = svc.eventbus.WaitFor(ctx, event.RecordAfterUpdateImmutable(upd, old, m, ns, nil, nil))
|
|
}
|
|
return
|
|
}
|
|
|
|
// patch prepares a payload for the update function and utilizes that
|
|
func (svc record) patch(ctx context.Context, upd *types.Record, values types.RecordValueSet) (rec *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
old *types.Record
|
|
m *types.Module
|
|
)
|
|
|
|
if upd.ID == 0 {
|
|
return nil, dd, RecordErrInvalidID()
|
|
}
|
|
|
|
_, m, old, err = loadRecordCombo(ctx, svc.store, svc.dal, upd.NamespaceID, upd.ModuleID, upd.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Create an update version from the old
|
|
//
|
|
// In case the record has any multi-value fields, they need to be removed
|
|
// since they'll be replaced with new ones.
|
|
upd = old.Clone()
|
|
// - figure out what fields are multi value
|
|
mvFields := make(map[string]bool)
|
|
for _, f := range m.Fields {
|
|
if f.Multi {
|
|
mvFields[f.Name] = true
|
|
}
|
|
}
|
|
// - figure out what fields need to be truncated (if a multi value field is not)
|
|
// present in the payload, it should not be truncated
|
|
truncate := make(map[string]bool)
|
|
for _, v := range values {
|
|
if mvFields[v.Name] {
|
|
truncate[v.Name] = true
|
|
}
|
|
}
|
|
// - truncate updated multi value fields
|
|
newValues := types.RecordValueSet{}
|
|
for _, v := range upd.Values {
|
|
if truncate[v.Name] {
|
|
continue
|
|
}
|
|
newValues = append(newValues, v)
|
|
}
|
|
upd.Values = newValues
|
|
|
|
values = svc.formatter.Run(m, values)
|
|
for _, v := range values {
|
|
err = upd.SetValue(v.Name, v.Place, v.Value)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
upd.Values.SetUpdatedFlag(true)
|
|
|
|
return svc.update(ctx, upd)
|
|
}
|
|
|
|
func (svc record) Create(ctx context.Context, new *types.Record) (rec *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
aProps = &recordActionProps{record: new}
|
|
)
|
|
|
|
err = func() error {
|
|
rec, dd, err = svc.create(ctx, new)
|
|
aProps.setRecord(rec)
|
|
return err
|
|
}()
|
|
|
|
return rec, dd, svc.recordAction(ctx, aProps, RecordActionCreate, err)
|
|
}
|
|
|
|
// Runs value sanitization, sets values that should be used
|
|
// and validates the final result
|
|
//
|
|
// This logic is kept in a utility function - it's used in the beginning
|
|
// of the creation procedure and after results are back from the automation scripts
|
|
//
|
|
// Both these points introduce external data that need to be checked fully in the same manner
|
|
func (svc record) procCreate(ctx context.Context, invokerID uint64, m *types.Module, new *types.Record) (rve *types.RecordValueErrorSet) {
|
|
new.Values.SetUpdatedFlag(true)
|
|
|
|
new.Values.Walk(func(v *types.RecordValue) error {
|
|
f := m.Fields.FindByName(v.Name)
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
|
|
d := f.DefaultValue.Get("", v.Place)
|
|
if d == nil {
|
|
// just so that we do not miss any defaults that MIGHT have
|
|
// field name set to it
|
|
// this is highly unlikely but it does not hurt to try
|
|
d = f.DefaultValue.Get(v.Name, v.Place)
|
|
}
|
|
|
|
// Mark as updated ONLY if value set is different from the default one
|
|
v.Updated = d == nil || d.Value != v.Value
|
|
|
|
return nil
|
|
})
|
|
|
|
// Reset values to new record
|
|
// to make sure nobody slips in something we do not want
|
|
new.ID = nextID()
|
|
new.Revision = 1
|
|
new.CreatedBy = invokerID
|
|
new.CreatedAt = *nowUTC()
|
|
new.UpdatedAt = nil
|
|
new.UpdatedBy = 0
|
|
new.DeletedAt = nil
|
|
new.DeletedBy = 0
|
|
|
|
if err := SetRecordOwner(ctx, svc.ac, svc.store, nil, new, invokerID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if rve = RecordValueUpdateOpCheck(ctx, svc.ac, m, new.Values); !rve.IsValid() {
|
|
return
|
|
}
|
|
|
|
rve = RecordPreparer(ctx, svc.store, svc.sanitizer, svc.validator, svc.formatter, m, new)
|
|
return rve
|
|
}
|
|
|
|
func (svc record) Update(ctx context.Context, upd *types.Record) (rec *types.Record, dd *types.RecordValueErrorSet, err error) {
|
|
var (
|
|
aProps = &recordActionProps{record: upd}
|
|
)
|
|
|
|
err = func() error {
|
|
rec, dd, err = svc.update(ctx, upd)
|
|
aProps.setRecord(rec)
|
|
return err
|
|
}()
|
|
|
|
return rec, dd, svc.recordAction(ctx, aProps, RecordActionUpdate, err)
|
|
}
|
|
|
|
// Runs value sanitization, copies values that should updated
|
|
// and validates the final result
|
|
//
|
|
// This logic is kept in a utility function - it's used in the beginning
|
|
// of the update procedure and after results are back from the automation scripts
|
|
//
|
|
// Both these points introduce external data that need to be checked fully in the same manner
|
|
func (svc record) procUpdate(ctx context.Context, invokerID uint64, m *types.Module, upd *types.Record, old *types.Record) (rve *types.RecordValueErrorSet) {
|
|
upd.Revision = old.Revision + 1
|
|
|
|
// Mark all values as updated (new)
|
|
upd.Values.SetUpdatedFlag(true)
|
|
|
|
// First sanitization
|
|
//
|
|
// Before values are merged with existing data and
|
|
// sent to automation scripts (if any)
|
|
// we need to make sure it does not get sanitized data
|
|
upd.Values = svc.sanitizer.Run(m, upd.Values)
|
|
|
|
if upd.Meta == nil {
|
|
// meta set to nil means we need to keep the old values!
|
|
upd.Meta = old.Meta
|
|
}
|
|
|
|
// Copy values to updated record
|
|
// to make sure nobody slips in something we do not want
|
|
upd.CreatedAt = old.CreatedAt
|
|
upd.CreatedBy = old.CreatedBy
|
|
upd.UpdatedAt = nowUTC()
|
|
upd.UpdatedBy = invokerID
|
|
upd.DeletedAt = old.DeletedAt
|
|
upd.DeletedBy = old.DeletedBy
|
|
|
|
if rve = SetRecordOwner(ctx, svc.ac, svc.store, old, upd, invokerID); !rve.IsValid() {
|
|
return
|
|
}
|
|
|
|
upd.Values = old.Values.Merge(m.Fields, upd.Values, func(f *types.ModuleField) bool {
|
|
return svc.ac.CanUpdateRecordValueOnModuleField(ctx, m.Fields.FindByName(f.Name))
|
|
})
|
|
|
|
if rve = RecordValueUpdateOpCheck(ctx, svc.ac, m, upd.Values); !rve.IsValid() {
|
|
return
|
|
}
|
|
|
|
rve = RecordPreparer(ctx, svc.store, svc.sanitizer, svc.validator, svc.formatter, m, upd)
|
|
return
|
|
}
|
|
|
|
func (svc record) recordInfoUpdate(ctx context.Context, r *types.Record) {
|
|
r.UpdatedAt = now()
|
|
r.UpdatedBy = auth.GetIdentityFromContext(ctx).Identity()
|
|
}
|
|
|
|
func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID uint64) (del *types.Record, err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
)
|
|
|
|
if namespaceID == 0 {
|
|
return nil, RecordErrInvalidNamespaceID()
|
|
}
|
|
if moduleID == 0 {
|
|
return nil, RecordErrInvalidModuleID()
|
|
}
|
|
if recordID == 0 {
|
|
return nil, RecordErrInvalidID()
|
|
}
|
|
|
|
ns, m, del, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return svc.processDelete(ctx, del, ns, m)
|
|
}
|
|
|
|
func (svc record) processDelete(ctx context.Context, del *types.Record, namespace *types.Namespace, module *types.Module) (record *types.Record, err error) {
|
|
var (
|
|
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
|
)
|
|
|
|
if !svc.ac.CanDeleteRecord(ctx, del) {
|
|
return nil, RecordErrNotAllowedToDelete()
|
|
}
|
|
|
|
del.DeletedAt = nowUTC()
|
|
del.DeletedBy = invokerID
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
del.SetModule(module)
|
|
|
|
// deleted, revision need to be set when RecordBeforeDelete is triggered
|
|
del.DeletedAt = nowUTC()
|
|
del.DeletedBy = invokerID
|
|
del.Revision = del.Revision + 1
|
|
|
|
{
|
|
// Calling before-record-delete scripts
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeDelete(nil, del, module, namespace, nil, nil)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if module.Config.RecordRevisions.Enabled {
|
|
// Prepare record revision for update
|
|
if err = svc.revisions.softDeleted(ctx, del); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
if err = dalutils.ComposeRecordSoftDelete(ctx, svc.dal, module, del); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
del.SetModule(module)
|
|
|
|
{
|
|
_ = svc.eventbus.WaitFor(ctx, event.RecordAfterDeleteImmutable(nil, del, module, namespace, nil, nil))
|
|
}
|
|
|
|
return del, nil
|
|
}
|
|
|
|
func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID uint64) (undel *types.Record, err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
)
|
|
|
|
ns, m, undel, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return svc.processUndelete(ctx, undel, ns, m)
|
|
}
|
|
|
|
func (svc record) processUndelete(ctx context.Context, undel *types.Record, namespace *types.Namespace, module *types.Module) (record *types.Record, err error) {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !svc.ac.CanUndeleteRecord(ctx, undel) {
|
|
return nil, RecordErrNotAllowedToUndelete()
|
|
}
|
|
|
|
undel.DeletedAt = nil
|
|
undel.DeletedBy = 0
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
undel.SetModule(module)
|
|
|
|
undel.DeletedAt = nil
|
|
undel.DeletedBy = 0
|
|
undel.Revision = undel.Revision + 1
|
|
|
|
{
|
|
// Calling before-record-undelete scripts
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUndelete(nil, undel, module, namespace, nil, nil)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if module.Config.RecordRevisions.Enabled {
|
|
// Prepare record revision for update
|
|
if err = svc.revisions.undeleted(ctx, undel); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
if err = dalutils.ComposeRecordUndelete(ctx, svc.dal, module, undel); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ensure module ref is set before running through records workflows and scripts
|
|
undel.SetModule(module)
|
|
|
|
{
|
|
_ = svc.eventbus.WaitFor(ctx, event.RecordAfterUndeleteImmutable(nil, undel, module, namespace, nil, nil))
|
|
}
|
|
|
|
return undel, nil
|
|
}
|
|
|
|
// DeleteByID removes one or more records (all from the same module and namespace)
|
|
//
|
|
// Before and after each record is deleted beforeDelete and afterDelete events are emitted
|
|
// If beforeRecord aborts the action it does so for that specific record only
|
|
func (svc record) DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordIDs ...uint64) (err error) {
|
|
var (
|
|
aProps = &recordActionProps{
|
|
namespace: &types.Namespace{ID: namespaceID},
|
|
module: &types.Module{ID: moduleID},
|
|
}
|
|
|
|
isBulkDelete = len(recordIDs) > 1
|
|
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
r *types.Record
|
|
)
|
|
|
|
err = func() error {
|
|
if namespaceID == 0 {
|
|
return RecordErrInvalidNamespaceID()
|
|
}
|
|
if moduleID == 0 {
|
|
return RecordErrInvalidModuleID()
|
|
}
|
|
|
|
ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
return nil
|
|
}()
|
|
|
|
if err != nil {
|
|
return svc.recordAction(ctx, aProps, RecordActionDelete, err)
|
|
}
|
|
|
|
for _, recordID := range recordIDs {
|
|
err := func() (err error) {
|
|
r, err = svc.delete(ctx, namespaceID, moduleID, recordID)
|
|
aProps.setRecord(r)
|
|
|
|
// Record each record deletion action
|
|
return svc.recordAction(ctx, aProps, RecordActionDelete, err)
|
|
}()
|
|
|
|
// We'll not break for failed delete,
|
|
// if we are deleting records in bulk.
|
|
if err != nil && !isBulkDelete {
|
|
return err
|
|
}
|
|
|
|
}
|
|
|
|
// all errors (if any) were recorded
|
|
// and in case of error for a non-bulk record deletion
|
|
// error is already returned
|
|
return nil
|
|
}
|
|
|
|
func (svc record) UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordIDs ...uint64) (err error) {
|
|
var (
|
|
aProps = &recordActionProps{
|
|
namespace: &types.Namespace{ID: namespaceID},
|
|
module: &types.Module{ID: moduleID},
|
|
}
|
|
|
|
isBulkUndelete = len(recordIDs) > 1
|
|
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
r *types.Record
|
|
)
|
|
|
|
err = func() error {
|
|
if namespaceID == 0 {
|
|
return RecordErrInvalidNamespaceID()
|
|
}
|
|
if moduleID == 0 {
|
|
return RecordErrInvalidModuleID()
|
|
}
|
|
|
|
ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
|
|
return nil
|
|
}()
|
|
|
|
if err != nil {
|
|
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
|
|
}
|
|
|
|
for _, recordID := range recordIDs {
|
|
err := func() (err error) {
|
|
r, err = svc.undelete(ctx, namespaceID, moduleID, recordID)
|
|
if err != nil {
|
|
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
|
|
}
|
|
aProps.setRecord(r)
|
|
|
|
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
|
|
}()
|
|
|
|
if err != nil && !isBulkUndelete {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc record) Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, posField, position, filter, grpField, group string) (err error) {
|
|
var (
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
r *types.Record
|
|
|
|
aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID, ModuleID: moduleID, ID: recordID}}
|
|
|
|
reorderingRecords bool
|
|
)
|
|
|
|
err = func() error {
|
|
ns, m, r, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
aProps.setNamespace(ns)
|
|
aProps.setModule(m)
|
|
aProps.setRecord(r)
|
|
|
|
if !svc.ac.CanUpdateRecord(ctx, r) {
|
|
return RecordErrNotAllowedToUpdate()
|
|
}
|
|
|
|
old := r.Clone()
|
|
|
|
// Dispatch beforeOrganize event
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeOrganize(r, old, m, ns, nil, nil)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if posField != "" {
|
|
reorderingRecords = true
|
|
|
|
// Position field checks
|
|
{
|
|
// Check field existence and permissions
|
|
// check if numeric -- we cannot reorder on any other field type
|
|
sf := m.Fields.FindByName(posField)
|
|
if sf == nil {
|
|
return RecordErrMissingPositionField()
|
|
}
|
|
|
|
aProps.setPositionField(sf)
|
|
|
|
if !sf.IsNumeric() {
|
|
return RecordErrInvalidPositionFieldKind()
|
|
}
|
|
|
|
if sf.Multi {
|
|
return RecordErrInvalidPositionFieldConfigMultiValue()
|
|
}
|
|
|
|
if !svc.ac.CanUpdateRecordValueOnModuleField(ctx, sf) {
|
|
return RecordErrNotAllowedToUpdate()
|
|
}
|
|
}
|
|
|
|
// Value checks
|
|
{
|
|
if !regexp.MustCompile(`^[0-9]+$`).MatchString(position) {
|
|
return RecordErrInvalidPositionValueType()
|
|
}
|
|
}
|
|
|
|
// Set new position
|
|
if err = r.SetValue(posField, 0, position); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if grpField != "" {
|
|
// Group field checks
|
|
{
|
|
vf := m.Fields.FindByName(grpField)
|
|
if vf == nil {
|
|
return RecordErrMissingGroupField()
|
|
}
|
|
|
|
aProps.setGroupField(vf)
|
|
|
|
if vf.Multi {
|
|
return RecordErrInvalidGroupFieldConfigMultiValue()
|
|
}
|
|
|
|
if !svc.ac.CanUpdateRecordValueOnModuleField(ctx, vf) {
|
|
return RecordErrNotAllowedToUpdate()
|
|
}
|
|
}
|
|
|
|
// Set new value
|
|
if err = r.SetValue(grpField, 0, group); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
svc.recordInfoUpdate(ctx, r)
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
if reorderingRecords {
|
|
var (
|
|
recordOrderPlace uint64
|
|
)
|
|
|
|
// If we already have filter, wrap it in parenthesis
|
|
if filter != "" {
|
|
filter = fmt.Sprintf("(%s) AND ", filter)
|
|
}
|
|
|
|
if recordOrderPlace, err = strconv.ParseUint(position, 0, 64); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Assemble record filter:
|
|
// We are interested only in records that have value of a sorting field greater than
|
|
// the place we're moving our record to.
|
|
// and sort the set with sorting field
|
|
reorderFilter := types.RecordFilter{
|
|
ModuleID: moduleID,
|
|
NamespaceID: namespaceID,
|
|
}
|
|
reorderFilter.Query = fmt.Sprintf("%s(%s >= %d)", filter, posField, recordOrderPlace)
|
|
if err = reorderFilter.Sort.Set(posField); err != nil {
|
|
return err
|
|
}
|
|
|
|
var iter dal.Iterator
|
|
if iter, _, err = dalutils.ComposeRecordsIterator(ctx, svc.dal, m, reorderFilter); err != nil {
|
|
return err
|
|
}
|
|
|
|
const updateChunkSize = 300
|
|
updateChunk := make(types.RecordSet, 0, updateChunkSize)
|
|
err = dalutils.WalkIterator(ctx, iter, m, func(r *types.Record) (err error) {
|
|
recordOrderPlace++
|
|
if err = r.SetValue(posField, 0, strconv.FormatUint(recordOrderPlace, 10)); err != nil {
|
|
return
|
|
}
|
|
|
|
// Update in chunks so we don't update one at a time and we don't keep
|
|
// too much data in memory.
|
|
updateChunk = append(updateChunk, r)
|
|
if len(updateChunk) >= updateChunkSize {
|
|
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, updateChunk...); err != nil {
|
|
return
|
|
}
|
|
updateChunk = make(types.RecordSet, 0, updateChunkSize)
|
|
}
|
|
|
|
return
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Assure the last chunk is handled
|
|
if len(updateChunk) > 0 {
|
|
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, updateChunk...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dispatch afterOrganize event
|
|
if err = svc.eventbus.WaitFor(ctx, event.RecordAfterOrganize(r, old, m, ns, nil, nil)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}()
|
|
|
|
return svc.recordAction(ctx, aProps, RecordActionOrganize, err)
|
|
}
|
|
|
|
func (svc record) Validate(ctx context.Context, rec *types.Record) error {
|
|
if m, err := loadModule(ctx, svc.store, rec.NamespaceID, rec.ModuleID); err != nil {
|
|
return err
|
|
} else {
|
|
rec.Values = values.Sanitizer().Run(m, rec.Values)
|
|
return values.Validator().Run(ctx, svc.store, m, rec)
|
|
}
|
|
}
|
|
|
|
// TriggerScript loads requested record sanitizes and validates values and passes all to the automation script
|
|
//
|
|
// For backward compatibility (of controllers), it returns module+record
|
|
func (svc record) TriggerScript(ctx context.Context, namespaceID, moduleID, recordID uint64, rvs types.RecordValueSet, script string) (*types.Module, *types.Record, error) {
|
|
var (
|
|
ns, m, r, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
original := r.Clone()
|
|
r.Values = values.Sanitizer().Run(m, rvs)
|
|
validated := values.Validator().Run(ctx, svc.store, m, r)
|
|
|
|
err = corredor.Service().Exec(ctx, script, event.RecordOnManual(r, original, m, ns, validated, nil))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return m, r, nil
|
|
}
|
|
|
|
// Iterator loads and iterates through list of records
|
|
//
|
|
// For each record, RecordOnIteration is generated and passed to fn()
|
|
// to be then passed to automation script that invoked the iteration
|
|
//
|
|
// No other triggers (before/after update/delete/create) are fired when (if)
|
|
// records are changed
|
|
//
|
|
// action arg enables one of the following scenarios:
|
|
// - clone: make new record (unless aborted)
|
|
// - update: update records (unless aborted)
|
|
// - delete: delete records (unless aborted)
|
|
// - default: only iterates over records, records are not changed, return value is ignored
|
|
//
|
|
// Iterator can be invoked only when defined in corredor script:
|
|
//
|
|
// return default {
|
|
// iterator (each) {
|
|
// return each({
|
|
// resourceType: 'compose:record',
|
|
// // action: 'update',
|
|
// filter: {
|
|
// namespace: '122709101053521922',
|
|
// module: '122709116471783426',
|
|
// query: 'Status = "foo"',
|
|
// sort: 'Status DESC',
|
|
// limit: 3,
|
|
// },
|
|
// })
|
|
// },
|
|
//
|
|
// // this is required in case of a deferred iterator
|
|
// // security: { runAs: .... } }
|
|
//
|
|
// // exec gets called for every record found by iterator
|
|
// exec () { ... }
|
|
// }
|
|
func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error) {
|
|
var (
|
|
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
|
|
|
ns *types.Namespace
|
|
m *types.Module
|
|
iter dal.Iterator
|
|
|
|
aProps = &recordActionProps{}
|
|
)
|
|
|
|
err = func() error {
|
|
ns, m, err = loadModuleCombo(ctx, svc.store, f.NamespaceID, f.ModuleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
iter, _, err = dalutils.ComposeRecordsIterator(ctx, svc.dal, m, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = dalutils.WalkIterator(ctx, iter, m, func(rec *types.Record) (err error) {
|
|
switch action {
|
|
case "clone":
|
|
if !svc.ac.CanCreateRecordOnModule(ctx, m) {
|
|
return RecordErrNotAllowedToCreate()
|
|
}
|
|
|
|
case "update":
|
|
if !svc.ac.CanUpdateRecord(ctx, rec) {
|
|
return RecordErrNotAllowedToUpdate()
|
|
}
|
|
|
|
case "delete":
|
|
if !svc.ac.CanDeleteRecord(ctx, rec) {
|
|
return RecordErrNotAllowedToDelete()
|
|
}
|
|
case "undelete":
|
|
if !svc.ac.CanUndeleteRecord(ctx, rec) {
|
|
return RecordErrNotAllowedToUndelete()
|
|
}
|
|
}
|
|
recordableAction := RecordActionIteratorIteration
|
|
|
|
if !svc.ac.CanReadRecord(ctx, rec) {
|
|
return RecordErrNotAllowedToRead()
|
|
}
|
|
|
|
err = func() error {
|
|
if err = fn(ctx, event.RecordOnIteration(rec, nil, m, ns, nil, nil)); err != nil {
|
|
if errors.Is(err, corredor.ScriptExecAborted) {
|
|
// When script was softly aborted (return false),
|
|
// proceed with iteration but do not clone, update or delete
|
|
// current record!
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch action {
|
|
case "clone":
|
|
recordableAction = RecordActionIteratorClone
|
|
|
|
// Assign defaults (only on missing values)
|
|
rec.Values = RecordValueDefaults(m, rec.Values)
|
|
|
|
// Handle payload from automation scripts
|
|
if rve := svc.procCreate(ctx, invokerID, m, rec); !rve.IsValid() {
|
|
return RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
return dalutils.ComposeRecordCreate(ctx, svc.dal, m, rec)
|
|
})
|
|
case "update":
|
|
recordableAction = RecordActionIteratorUpdate
|
|
|
|
// Handle input payload
|
|
if rve := svc.procUpdate(ctx, invokerID, m, rec, rec); !rve.IsValid() {
|
|
return RecordErrValueInput().Wrap(rve)
|
|
}
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
return dalutils.ComposeRecordUpdate(ctx, svc.dal, m, rec)
|
|
})
|
|
case "delete":
|
|
recordableAction = RecordActionIteratorDelete
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
rec.DeletedAt = nowUTC()
|
|
rec.DeletedBy = invokerID
|
|
return dalutils.ComposeRecordSoftDelete(ctx, svc.dal, m, rec)
|
|
})
|
|
case "undelete":
|
|
recordableAction = RecordActionIteratorUndelete
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
|
rec.DeletedAt = nil
|
|
rec.DeletedBy = 0
|
|
return dalutils.ComposeRecordUndelete(ctx, svc.dal, m, rec)
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
// record iteration action and
|
|
// break the loop in case of an error
|
|
_ = svc.recordAction(ctx, aProps, recordableAction, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
return svc.recordAction(ctx, aProps, RecordActionIteratorInvoked, err)
|
|
}
|
|
|
|
// DupDetection check for any duplicate records and returns error for strict duplication
|
|
func (svc record) DupDetection(ctx context.Context, m *types.Module, rec *types.Record) (out *types.RecordValueErrorSet, err error) {
|
|
if m == nil || rec == nil {
|
|
return
|
|
}
|
|
|
|
// @todo: improve per records duplicate detection,
|
|
// since it is bit too extreme besides we do have bulk operations
|
|
var (
|
|
records types.RecordSet
|
|
|
|
rProps = &recordActionProps{}
|
|
config = m.Config.RecordDeDup
|
|
)
|
|
|
|
if len(config.Rules) > 0 && config.Rules.Validate() == nil {
|
|
records, _, err = svc.Find(ctx, types.RecordFilter{
|
|
ModuleID: m.ID,
|
|
NamespaceID: m.NamespaceID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
out, err = svc.dupDetector.CheckDuplication(ctx, config.Rules, *rec, records)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
rProps.setValueErrors(out)
|
|
|
|
// Error out if duplicate record exist
|
|
if (config.Strict && !out.IsValid()) || out.HasStrictErrors() {
|
|
return out, types.IsRecordValueErrorSet(out)
|
|
} else {
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (svc record) CreateSynthetic(ctx context.Context, src synteticRecordDataGen, mod *types.Module, total uint) error {
|
|
if !svc.ac.CanCreateRecordOnModule(ctx, mod) {
|
|
return RecordErrNotAllowedToCreate()
|
|
}
|
|
|
|
const (
|
|
maxRetries = 10
|
|
|
|
preloadUsers = 100
|
|
preloadRecords = 100
|
|
)
|
|
|
|
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
|
var (
|
|
retry uint
|
|
synth *types.Record
|
|
|
|
uu systemTypes.UserSet
|
|
rr types.RecordSet
|
|
|
|
refUsers []uint64
|
|
refRecords map[uint64][]uint64
|
|
)
|
|
|
|
refRecords = make(map[uint64][]uint64)
|
|
|
|
for _, f := range mod.Fields {
|
|
|
|
switch strings.ToLower(f.Kind) {
|
|
case "user":
|
|
if len(refUsers) == 0 {
|
|
// preload 100 users
|
|
flt := systemTypes.UserFilter{}
|
|
flt.Limit = preloadUsers
|
|
if uu, _, err = store.SearchUsers(ctx, s, flt); err != nil {
|
|
return
|
|
}
|
|
refUsers = uu.IDs()
|
|
}
|
|
|
|
case "record":
|
|
refModID := f.Options.Uint64("moduleID")
|
|
|
|
if len(refRecords[refModID]) == 0 {
|
|
// preload 100 records
|
|
flt := types.RecordFilter{}
|
|
flt.Limit = preloadRecords
|
|
if rr, _, err = dalutils.ComposeRecordsList(ctx, svc.dal, mod, flt); err != nil {
|
|
return
|
|
}
|
|
|
|
refRecords[refModID] = rr.IDs()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
for total > 0 || maxRetries < retry {
|
|
synth = syntheticRecord(src, refUsers, refRecords, mod)
|
|
|
|
if err = RecordValueSanitization(mod, synth.Values); err != nil {
|
|
return
|
|
}
|
|
|
|
if err = dalutils.ComposeRecordCreate(ctx, svc.dal, mod, synth); err != nil {
|
|
return
|
|
}
|
|
|
|
total--
|
|
}
|
|
|
|
return
|
|
})
|
|
}
|
|
|
|
func syntheticRecord(src synteticRecordDataGen, userRefs []uint64, recRefs map[uint64][]uint64, mod *types.Module) (r *types.Record) {
|
|
var (
|
|
randUser = func() uint64 {
|
|
if len(userRefs) == 0 {
|
|
return 0
|
|
}
|
|
|
|
return userRefs[src.IntRange(0, len(userRefs)-1)]
|
|
}
|
|
)
|
|
|
|
r = &types.Record{
|
|
ID: nextID(),
|
|
ModuleID: mod.ID,
|
|
NamespaceID: mod.NamespaceID,
|
|
|
|
// Make sure all users are created in the past
|
|
CreatedAt: time.Now().Add(time.Hour * time.Duration(src.Number(100000, 1000000)*-1)),
|
|
CreatedBy: randUser(),
|
|
|
|
Values: syntheticRecordValues(src, userRefs, recRefs, mod),
|
|
|
|
Meta: map[string]any{"synthetic": true},
|
|
}
|
|
|
|
if src.Number(0, 1) > 0 {
|
|
aux := time.Now().Add(time.Hour * time.Duration(src.Number(100, 100000)*-1))
|
|
r.UpdatedAt = &aux
|
|
r.UpdatedBy = randUser()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func syntheticRecordValues(src synteticRecordDataGen, userRefs []uint64, recRefs map[uint64][]uint64, mod *types.Module) (rvs types.RecordValueSet) {
|
|
var (
|
|
fname string
|
|
valuesPerField uint
|
|
val *types.RecordValue
|
|
|
|
pickRandomID = func(vals []uint64) string {
|
|
if len(vals) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// picks random value from vals
|
|
return strconv.FormatUint(vals[src.IntRange(0, len(vals)-1)], 10)
|
|
}
|
|
)
|
|
|
|
fields:
|
|
for _, f := range mod.Fields {
|
|
valuesPerField = 1
|
|
fname = strings.ToLower(f.Name)
|
|
|
|
if f.Multi {
|
|
valuesPerField = uint(src.Number(0, 10))
|
|
}
|
|
|
|
for p := uint(0); p < valuesPerField; p++ {
|
|
val = &types.RecordValue{
|
|
Name: f.Name,
|
|
Place: p,
|
|
}
|
|
|
|
switch strings.ToLower(f.Kind) {
|
|
|
|
case "bool":
|
|
val.Value = fmt.Sprintf("%d", src.Number(0, 1))
|
|
|
|
case "datetime":
|
|
// @todo respect present/past
|
|
// @todo respect date/time
|
|
val.Value = src.Date().Format(time.RFC3339)
|
|
|
|
case "email":
|
|
val.Value = src.Email()
|
|
|
|
case "file":
|
|
continue fields
|
|
|
|
case "string":
|
|
fname = strings.ToLower(f.Name)
|
|
switch {
|
|
case strings.Contains(fname, "name"),
|
|
strings.Contains(fname, "title"),
|
|
strings.Contains(fname, "label"):
|
|
val.Value = src.LoremIpsumSentence(src.Number(3, 5))
|
|
case strings.Contains(fname, "street"):
|
|
val.Value = src.Street()
|
|
case strings.Contains(fname, "city"):
|
|
val.Value = src.Street()
|
|
case strings.Contains(fname, "country"):
|
|
val.Value = src.Country()
|
|
case strings.Contains(fname, "desc"),
|
|
strings.Contains(fname, "note"):
|
|
val.Value = src.LoremIpsumSentence(src.Number(10, 100))
|
|
default:
|
|
val.Value = src.LoremIpsumSentence(src.Number(4, 40))
|
|
}
|
|
|
|
case "number":
|
|
val.Value = src.DigitN(5)
|
|
|
|
case "record":
|
|
refModID := f.Options.Uint64("moduleID")
|
|
|
|
if refModID == 0 && len(recRefs[refModID]) == 0 {
|
|
continue fields
|
|
}
|
|
|
|
val.Value = pickRandomID(recRefs[refModID])
|
|
|
|
case "select":
|
|
// val.Value = src.Select(f.Options)
|
|
continue fields
|
|
|
|
case "url":
|
|
val.Value = src.URL()
|
|
|
|
case "user":
|
|
val.Value = pickRandomID(userRefs)
|
|
|
|
default:
|
|
continue fields
|
|
}
|
|
|
|
rvs = append(rvs, val)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (svc record) RemoveSynthetic(ctx context.Context, mod *types.Module) (err error) {
|
|
// not a mistake, we do not need or want to check if user can be deleted
|
|
if !svc.ac.CanCreateRecordOnModule(ctx, mod) {
|
|
return RecordErrNotAllowedToCreate()
|
|
}
|
|
|
|
var (
|
|
f = types.RecordFilter{Meta: map[string]any{"synthetic": true}}
|
|
rr []*types.Record
|
|
)
|
|
|
|
f.Limit = 1000
|
|
|
|
for {
|
|
rr, _, err = dalutils.ComposeRecordsList(ctx, svc.dal, mod, f)
|
|
if err != nil || len(rr) == 0 {
|
|
return
|
|
}
|
|
|
|
if err = dalutils.ComposeRecordDelete(ctx, svc.dal, mod, rr...); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func ComposeRecordFilterChecker(ctx context.Context, ac recordAccessController, m *types.Module) func(*types.Record) (bool, error) {
|
|
return func(rec *types.Record) (bool, error) {
|
|
// Setting module right before we do access control
|
|
//
|
|
// Why?
|
|
// - Access control can use one of the contextual roles
|
|
// - Contextual role can use expression that accesses values
|
|
// - Record's values are only exported into expression's scope when
|
|
// module is set on record at the time when Dict() fn is called.
|
|
rec.SetModule(m)
|
|
|
|
if !ac.CanReadRecord(ctx, rec) {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
// checks record-value-read access permissions for all module fields and removes unreadable fields from all records
|
|
func ComposeRecordFilterAC(ctx context.Context, ac recordValueAccessController, m *types.Module, rr ...*types.Record) {
|
|
var (
|
|
readableFields = map[string]bool{}
|
|
)
|
|
|
|
for _, f := range m.Fields {
|
|
readableFields[f.Name] = ac.CanReadRecordValueOnModuleField(ctx, f)
|
|
}
|
|
|
|
for _, r := range rr {
|
|
r.Values, _ = r.Values.Filter(func(v *types.RecordValue) (bool, error) {
|
|
return readableFields[v.Name], nil
|
|
})
|
|
}
|
|
}
|
|
|
|
// loadRecordCombo Loads namespace, module and record
|
|
func loadRecordCombo(ctx context.Context, s store.Storer, dal dalDater, namespaceID, moduleID, recordID uint64) (ns *types.Namespace, m *types.Module, r *types.Record, err error) {
|
|
if recordID == 0 {
|
|
return nil, nil, nil, RecordErrInvalidID()
|
|
}
|
|
|
|
if ns, m, err = loadModuleCombo(ctx, s, namespaceID, moduleID); err != nil {
|
|
return
|
|
}
|
|
|
|
if r, err = dalutils.ComposeRecordsFind(ctx, dal, m, recordID); err != nil {
|
|
return
|
|
}
|
|
|
|
if r.ModuleID != moduleID {
|
|
return nil, nil, nil, RecordErrInvalidModuleID()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// loadRecord loads record
|
|
//
|
|
// function uses global DAL service to load records
|
|
// this is because we need to be able to call it from AccessControl service
|
|
// that does not have DAL
|
|
func loadRecord(ctx context.Context, s store.Storer, namespaceID, moduleID, recordID uint64) (res *types.Record, err error) {
|
|
_, _, res, err = loadRecordCombo(ctx, s, dal.Service(), namespaceID, moduleID, recordID)
|
|
return
|
|
}
|
|
|
|
func recordReportToDalPipeline(m *types.Module, metrics, dimensions, f string) (pp dal.Pipeline, _ *dal.Aggregate, err error) {
|
|
// Map dimension to the aggregate group
|
|
// @note we only ever used a single dimension so this is ok
|
|
auxDim := dal.AggregateAttr{
|
|
Identifier: "dimension_0",
|
|
RawExpr: dimensions,
|
|
Key: true,
|
|
}
|
|
|
|
ff := m.Fields.FindByName(dimensions)
|
|
if ff != nil {
|
|
auxDim.MultiValue = ff.Multi
|
|
auxDim.Label = ff.Label
|
|
}
|
|
|
|
dim := []dal.AggregateAttr{auxDim}
|
|
oo := filter.SortExprSet{{Column: dim[0].Identifier}}
|
|
|
|
// Map metrics to the aggregate attrs
|
|
// - count is always present
|
|
mms := []dal.AggregateAttr{
|
|
{
|
|
Identifier: "count",
|
|
RawExpr: "count(ID)",
|
|
Type: &dal.TypeNumber{},
|
|
},
|
|
}
|
|
|
|
// - other requested metrices
|
|
if len(metrics) > 0 {
|
|
mm := strings.Split(metrics, ",")
|
|
for _, m := range mm {
|
|
m = strings.TrimSpace(m)
|
|
pts := strings.Split(m, " AS ")
|
|
expr := strings.TrimSpace(pts[0])
|
|
ident := expr
|
|
if len(pts) > 1 {
|
|
ident = strings.TrimSpace(pts[1])
|
|
}
|
|
|
|
mms = append(mms, dal.AggregateAttr{
|
|
Identifier: ident,
|
|
RawExpr: expr,
|
|
Type: &dal.TypeNumber{},
|
|
})
|
|
}
|
|
}
|
|
|
|
agg := &dal.Aggregate{
|
|
Ident: "agg",
|
|
RelSource: "ds",
|
|
Group: dim,
|
|
OutAttributes: mms,
|
|
Filter: filter.Generic(
|
|
filter.WithOrderBy(oo),
|
|
),
|
|
}
|
|
|
|
// Build the pipeline
|
|
pp = dal.Pipeline{
|
|
&dal.Datasource{
|
|
Ident: "ds",
|
|
Filter: filter.Generic(filter.WithExpression(f), filter.WithStateConstraint("deletedAt", filter.StateExcluded)),
|
|
ModelRef: dal.ModelRef{
|
|
ConnectionID: m.Config.DAL.ConnectionID,
|
|
ResourceID: m.ID,
|
|
ResourceType: types.ModuleResourceType,
|
|
},
|
|
},
|
|
agg,
|
|
}
|
|
|
|
return pp, agg, pp.LinkSteps()
|
|
}
|
|
|
|
// recordReportCorrectTypes assures the types are casted to what Compose charts expect
|
|
// @todo this is a temporary solution
|
|
//
|
|
// This addresses:
|
|
// - output metrics are presented as numbers
|
|
// - dimenssion values are presented as strings for ID and ref., float for number, string for rest
|
|
func recordReportCorrectTypes(def *dal.Aggregate, entry recordReportEntry) {
|
|
for _, a := range def.OutAttributes {
|
|
if _, ok := entry[a.Identifier]; !ok {
|
|
continue
|
|
}
|
|
// Metrics are currently always numbers so we don't need to be fancy
|
|
entry[a.Identifier] = cast.ToFloat64(entry[a.Identifier])
|
|
}
|
|
|
|
for _, a := range def.Group {
|
|
if _, ok := entry[a.Identifier]; !ok {
|
|
continue
|
|
}
|
|
|
|
switch a.Type.(type) {
|
|
case *dal.TypeNumber:
|
|
entry[a.Identifier] = cast.ToFloat64(entry[a.Identifier])
|
|
return
|
|
|
|
case *dal.TypeText:
|
|
entry[a.Identifier] = cast.ToString(entry[a.Identifier])
|
|
return
|
|
|
|
case *dal.TypeID, *dal.TypeRef:
|
|
entry[a.Identifier] = cast.ToString(entry[a.Identifier])
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ei ErrorIndex) Add(err string) {
|
|
if _, has := ei[err]; has {
|
|
ei[err]++
|
|
} else {
|
|
ei[err] = 1
|
|
}
|
|
}
|
|
|
|
func (ri RecordIndex) MarshalJSON() ([]byte, error) {
|
|
sort.Ints(ri)
|
|
|
|
rr := make([][]int, 0, len(ri))
|
|
start := -1
|
|
crt := -1
|
|
|
|
for i := 0; i < len(ri); i++ {
|
|
if start == -1 {
|
|
start = ri[i]
|
|
crt = ri[i]
|
|
continue
|
|
}
|
|
|
|
// If the index increases for more then 1, the set is complete
|
|
if ri[i]-crt > 1 {
|
|
rr = append(rr, []int{start, crt})
|
|
start = ri[i]
|
|
}
|
|
|
|
crt = ri[i]
|
|
}
|
|
|
|
rr = append(rr, []int{start, crt})
|
|
return json.Marshal(rr)
|
|
}
|
|
|
|
func (rr recordReportEntry) SetValue(n string, pos uint, v any) error {
|
|
if pos > 0 {
|
|
// When aggregated, multi value fields are collapsed into a single value
|
|
// so we don't need to support multi values here
|
|
panic("impossible case")
|
|
}
|
|
|
|
rr[n] = v
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateFilterFieldPermissions checks if the user is allowed to filter requested fields
|
|
func (svc record) validateFilterFieldPermissions(ctx context.Context, ac recordValueAccessController, m *types.Module, query string) error {
|
|
if query == "" {
|
|
return nil
|
|
}
|
|
|
|
// Parse the filter query to extract field symbols
|
|
parser := ql.NewParser()
|
|
ast, err := parser.Parse(query)
|
|
if err != nil {
|
|
// If parsing fails, let it pass through - the DAL layer will handle the error
|
|
// @todo not sure if I am a fan of this
|
|
return nil
|
|
}
|
|
|
|
symbols := ast.CollectUniqueSymbols()
|
|
if len(symbols) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Build a set of module field names for quick lookup
|
|
moduleFieldNames := make(map[string]bool)
|
|
for _, f := range m.Fields {
|
|
moduleFieldNames[f.Name] = true
|
|
}
|
|
|
|
// System fields that are always filterable (not module-specific fields)
|
|
systemFields := svc.systemExprFields()
|
|
|
|
// Check each symbol in the filter query
|
|
for _, symbol := range symbols {
|
|
// Skip system fields as they're always accessible
|
|
if systemFields[symbol] {
|
|
continue
|
|
}
|
|
|
|
// Skip symbols that aren't module fields (could be functions, operators, etc.)
|
|
if !moduleFieldNames[symbol] {
|
|
continue
|
|
}
|
|
|
|
// Find the field and check permission
|
|
field := m.Fields.FindByName(symbol)
|
|
if field == nil {
|
|
continue
|
|
}
|
|
|
|
if !ac.CanReadRecordValueOnModuleField(ctx, field) {
|
|
return RecordErrNotAllowedToFilterByField(&recordActionProps{field: symbol})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateFilterFieldPermissions checks if the user is allowed to sort requested fields
|
|
func (svc record) validateSortFieldPermissions(ctx context.Context, ac recordValueAccessController, m *types.Module, sort filter.SortExprSet) error {
|
|
if len(sort) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Build a set of module field names for quick lookup
|
|
moduleFieldNames := make(map[string]bool)
|
|
for _, f := range m.Fields {
|
|
moduleFieldNames[f.Name] = true
|
|
}
|
|
|
|
// System fields that are always sortable
|
|
systemFields := svc.systemExprFields()
|
|
|
|
for _, sort := range sort {
|
|
fieldName := sort.Column
|
|
|
|
// Skip system fields
|
|
if systemFields[fieldName] {
|
|
continue
|
|
}
|
|
|
|
// Skip non-module fields
|
|
if !moduleFieldNames[fieldName] {
|
|
continue
|
|
}
|
|
|
|
// Find the field and check permission
|
|
field := m.Fields.FindByName(fieldName)
|
|
if field == nil {
|
|
continue
|
|
}
|
|
|
|
if !ac.CanReadRecordValueOnModuleField(ctx, field) {
|
|
return RecordErrNotAllowedToSortByField(&recordActionProps{field: fieldName})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc record) systemExprFields() map[string]bool {
|
|
return map[string]bool{
|
|
"recordID": true,
|
|
"id": true,
|
|
"ID": true,
|
|
"moduleID": true,
|
|
"namespaceID": true,
|
|
"ownedBy": true,
|
|
"createdAt": true,
|
|
"createdBy": true,
|
|
"updatedAt": true,
|
|
"updatedBy": true,
|
|
"deletedAt": true,
|
|
"deletedBy": true,
|
|
}
|
|
}
|