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
654 lines
15 KiB
Go
654 lines
15 KiB
Go
package envoy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/cortezaproject/corteza/server/compose/dalutils"
|
|
"github.com/cortezaproject/corteza/server/compose/types"
|
|
"github.com/cortezaproject/corteza/server/pkg/dal"
|
|
"github.com/cortezaproject/corteza/server/pkg/envoyx"
|
|
"github.com/cortezaproject/corteza/server/pkg/envoyx/datasource"
|
|
"github.com/cortezaproject/corteza/server/pkg/errors"
|
|
"github.com/cortezaproject/corteza/server/pkg/filter"
|
|
"github.com/cortezaproject/corteza/server/pkg/logger"
|
|
"github.com/cortezaproject/corteza/server/store"
|
|
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
|
"github.com/modern-go/reflect2"
|
|
"github.com/spf13/cast"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type (
|
|
// RecordDatasource provides a mechanism for you to access large
|
|
// record datasets optimally
|
|
RecordDatasource struct {
|
|
Mapping envoyx.DatasourceMapping
|
|
Provider envoyx.Provider
|
|
|
|
multivalues map[string]bool
|
|
datetimeFields map[string]bool
|
|
|
|
CheckExisting func(ctx context.Context, ref ...[]string) ([]uint64, error)
|
|
|
|
currentIndex int
|
|
|
|
// Index to map from ref to ID
|
|
// @todo we might need to flush these to the disc in case a huge dataset is passed in
|
|
refToID map[string]uint64
|
|
// @todo might be worth putting both into one map; not sure how much space we'd save up
|
|
existingIDs map[uint64]bool
|
|
}
|
|
|
|
// iteratorProvider is a wrapper around the dal.Iterator to conform to the
|
|
// envoy.Provider interface
|
|
iteratorProvider struct {
|
|
iter dal.Iterator
|
|
|
|
// Items related to ref resolution
|
|
// @todo can be removed when reworked
|
|
resolveRefs bool
|
|
includeRefID bool
|
|
relMods map[string]refModWrap
|
|
dal dal.FullService
|
|
|
|
// Access control for field-level permissions
|
|
ac recordValueAccessController
|
|
mod *types.Module
|
|
readableFields map[string]bool
|
|
|
|
rows []datasource.RawRecord
|
|
buffIndex int
|
|
done bool
|
|
|
|
// User ref resolution
|
|
store store.Storer
|
|
userFields map[string]bool
|
|
}
|
|
|
|
refModWrap struct {
|
|
modLvl1 *types.Module
|
|
labelLvl1 string
|
|
|
|
modLvl2 *types.Module
|
|
labelLvl2 string
|
|
}
|
|
|
|
// recordValueAccessController interface for checking field-level read permissions
|
|
recordValueAccessController interface {
|
|
CanReadRecordValueOnModuleField(context.Context, *types.ModuleField) bool
|
|
}
|
|
)
|
|
|
|
const (
|
|
bufferPullChunkSize = int(100)
|
|
)
|
|
|
|
func mkIteratorProvider(ctx context.Context, ac recordValueAccessController, s store.Storer, dl dal.FullService, iter dal.Iterator, mod *types.Module, resolveRefs bool, includeRefID bool) (out *iteratorProvider, err error) {
|
|
out = &iteratorProvider{
|
|
iter: iter,
|
|
dal: dl,
|
|
resolveRefs: resolveRefs,
|
|
includeRefID: includeRefID,
|
|
|
|
ac: ac,
|
|
mod: mod,
|
|
}
|
|
|
|
// Pre-compute readable fields based on access control permissions
|
|
// This avoids checking permissions on every record iteration
|
|
if !reflect2.IsNil(ac) {
|
|
out.readableFields = make(map[string]bool)
|
|
for _, f := range mod.Fields {
|
|
// Ensure NamespaceID is set on the field (it may not be loaded from store)
|
|
// RBAC checks require the full resource path including namespace
|
|
if f.NamespaceID == 0 {
|
|
f.NamespaceID = mod.NamespaceID
|
|
}
|
|
out.readableFields[f.Name] = ac.CanReadRecordValueOnModuleField(ctx, f)
|
|
}
|
|
}
|
|
|
|
refMods := make(map[string]refModWrap)
|
|
userFields := make(map[string]bool)
|
|
|
|
for _, name := range []string{"createdBy", "updatedBy", "ownedBy", "deletedBy"} {
|
|
userFields[name] = true
|
|
}
|
|
|
|
for _, f := range mod.Fields {
|
|
switch f.Kind {
|
|
case "Record":
|
|
refMods[f.Name], err = mkRecordRefWrap(ctx, s, f)
|
|
if err != nil {
|
|
return
|
|
}
|
|
case "User":
|
|
userFields[f.Name] = true
|
|
}
|
|
}
|
|
|
|
out.relMods = refMods
|
|
out.store = s
|
|
out.userFields = userFields
|
|
|
|
return
|
|
}
|
|
|
|
// ResolvedFields returns field names that will have a companion "X ID" column when includeRefID is enabled.
|
|
func (ip *iteratorProvider) ResolvedFields() []string {
|
|
if !ip.resolveRefs || !ip.includeRefID {
|
|
return nil
|
|
}
|
|
|
|
out := make([]string, 0, len(ip.userFields)+len(ip.relMods))
|
|
for f := range ip.userFields {
|
|
out = append(out, f)
|
|
}
|
|
|
|
for f := range ip.relMods {
|
|
out = append(out, f)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// ResolvedFields returns field names that will have a companion "X value" column.
|
|
func (rd *RecordDatasource) ResolvedFields() []string {
|
|
if ip, ok := rd.Provider.(*iteratorProvider); ok {
|
|
return ip.ResolvedFields()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (rd *RecordDatasource) SetProvider(s envoyx.Provider) bool {
|
|
if rd.Mapping.SourceIdent != s.Ident() {
|
|
return false
|
|
}
|
|
|
|
rd.Provider = s
|
|
return true
|
|
}
|
|
|
|
func (rd *RecordDatasource) Next(ctx context.Context, out datasource.RawRecord) (ident []string, more bool, err error) {
|
|
rowCache := make(datasource.RawRecord)
|
|
|
|
more, err = rd.Provider.Next(ctx, rowCache)
|
|
if err != nil || !more {
|
|
return
|
|
}
|
|
|
|
rd.applyMapping(rowCache, out)
|
|
|
|
if len(rd.Mapping.KeyField) == 0 {
|
|
ident = append(ident, strconv.FormatInt(int64(rd.currentIndex), 10))
|
|
} else {
|
|
for _, k := range rd.Mapping.KeyField {
|
|
ident = append(ident, strings.Join(rowCache[k].Values, ","))
|
|
}
|
|
}
|
|
|
|
rd.currentIndex++
|
|
|
|
return
|
|
}
|
|
|
|
func (rd *iteratorProvider) SetConfigs(map[string]any) error {
|
|
return nil
|
|
}
|
|
|
|
func (rd *RecordDatasource) Reset(ctx context.Context) (err error) {
|
|
rd.currentIndex = 0
|
|
return rd.Provider.Reset(ctx)
|
|
}
|
|
|
|
func (rd *RecordDatasource) applyMapping(in, out datasource.RawRecord) {
|
|
if len(rd.Mapping.Mapping.Map) == 0 {
|
|
if !rd.Mapping.Defaultable {
|
|
return
|
|
}
|
|
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return
|
|
}
|
|
|
|
if rd.Mapping.Defaultable {
|
|
rd.applyMappingWithDefaults(in, out)
|
|
} else {
|
|
rd.applyMappingWoDefaults(in, out)
|
|
}
|
|
}
|
|
|
|
func (rd *RecordDatasource) applyMappingWithDefaults(in, out datasource.RawRecord) {
|
|
maps := make(map[string]envoyx.MapEntry)
|
|
for k, v := range rd.Mapping.Mapping.Map {
|
|
maps[k] = v
|
|
}
|
|
|
|
for k, v := range in {
|
|
if m, ok := maps[k]; ok {
|
|
if m.Skip {
|
|
continue
|
|
}
|
|
out[m.Field] = v
|
|
} else {
|
|
out[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func (rd *RecordDatasource) applyMappingWoDefaults(in, out datasource.RawRecord) {
|
|
for _, m := range rd.Mapping.Mapping.Map {
|
|
if m.Skip {
|
|
continue
|
|
}
|
|
|
|
out[m.Field] = in[m.Column]
|
|
}
|
|
}
|
|
|
|
func (rd *RecordDatasource) ResolveRef(ref ...any) (out uint64, err error) {
|
|
idents, err := cast.ToStringSliceE(ref)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for i, ident := range idents {
|
|
idents[i] = strings.Replace(ident, "-", "_", -1)
|
|
}
|
|
|
|
out = rd.refToID[strings.Join(idents, "-")]
|
|
return
|
|
}
|
|
|
|
func (rd *RecordDatasource) ResolveRefS(ref ...string) (out uint64, err error) {
|
|
aux := make([]any, len(ref))
|
|
for i, r := range ref {
|
|
aux[i] = r
|
|
}
|
|
|
|
return rd.ResolveRef(aux...)
|
|
}
|
|
|
|
// @todo this should be replaced by some smarter structure
|
|
func (rd *RecordDatasource) AddRef(id uint64, idents ...string) {
|
|
for i, ident := range idents {
|
|
idents[i] = strings.Replace(ident, "-", "_", -1)
|
|
}
|
|
|
|
rd.refToID[strings.Join(idents, "-")] = id
|
|
}
|
|
|
|
func (ip *iteratorProvider) Next(ctx context.Context, out datasource.RawRecord) (more bool, err error) {
|
|
if ip.resolveRefs {
|
|
return ip.nextResolved(ctx, out)
|
|
}
|
|
|
|
return ip.next(ctx, out)
|
|
}
|
|
|
|
func (ip *iteratorProvider) next(ctx context.Context, out datasource.RawRecord) (more bool, err error) {
|
|
rowCache := make(datasource.RawRecord)
|
|
|
|
if !ip.iter.Next(ctx) {
|
|
return false, ip.iter.Err()
|
|
}
|
|
|
|
err = ip.iter.Scan(rowCache)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Apply field-level access control filtering
|
|
ip.filterUnreadableFields(rowCache)
|
|
|
|
for k, v := range rowCache {
|
|
out[k] = v
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (ip *iteratorProvider) nextResolved(ctx context.Context, out datasource.RawRecord) (more bool, err error) {
|
|
if ip.done && ip.buffIndex >= len(ip.rows) {
|
|
return false, nil
|
|
}
|
|
|
|
if ip.buffIndex == len(ip.rows) {
|
|
|
|
// pull chunk
|
|
ip.rows = make([]datasource.RawRecord, 0)
|
|
ip.buffIndex = 0
|
|
|
|
for i := 0; i < bufferPullChunkSize; i++ {
|
|
rowCache := make(datasource.RawRecord)
|
|
if !ip.iter.Next(ctx) {
|
|
ip.done = true
|
|
|
|
err := ip.iter.Err()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
err = ip.iter.Scan(rowCache)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
ip.rows = append(ip.rows, rowCache)
|
|
}
|
|
|
|
if len(ip.rows) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
// resolve stuff
|
|
err = ip.resolveReferences(ctx, ip.dal)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = ip.resolveUsers(ctx, ip.store)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
rowCache := ip.rows[ip.buffIndex]
|
|
ip.buffIndex++
|
|
|
|
// Apply field-level access control filtering
|
|
ip.filterUnreadableFields(rowCache)
|
|
|
|
for k, v := range rowCache {
|
|
out[k] = v
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (ip *iteratorProvider) resolveReferences(ctx context.Context, ds dal.FullService) (err error) {
|
|
// @todo I'll need to chunk these up for to reduce DB query count...
|
|
|
|
for i, cacheRecord := range ip.rows {
|
|
|
|
for refField, refWrap := range ip.relMods {
|
|
value := cacheRecord[refField]
|
|
if len(value.Values) == 0 {
|
|
continue
|
|
}
|
|
|
|
aux := []string{}
|
|
for _, v := range value.Values {
|
|
aux = append(aux, fmt.Sprintf("recordID=%s", v))
|
|
}
|
|
|
|
var relRecords types.RecordSet
|
|
|
|
resLab := refWrap.labelLvl1
|
|
qq := fmt.Sprintf("(%s)", strings.Join(aux, " OR "))
|
|
relRecords, _, err = dalutils.ComposeRecordsList(ctx, ds, refWrap.modLvl1, types.RecordFilter{
|
|
Query: qq,
|
|
Paging: filter.Paging{
|
|
Limit: uint(len(aux)),
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// lvl 2 nesting; current max lvl
|
|
if refWrap.modLvl2 != nil {
|
|
resLab = refWrap.labelLvl2
|
|
|
|
// Iterate related records and collect lvl 2 identifiers
|
|
aux := []string{}
|
|
for _, rec := range relRecords {
|
|
for _, v := range rec.Values.FilterByName(refWrap.labelLvl1) {
|
|
aux = append(aux, fmt.Sprintf("recordID=%s", v.Value))
|
|
}
|
|
}
|
|
|
|
qq := fmt.Sprintf("(%s)", strings.Join(aux, " OR "))
|
|
relRecords, _, err = dalutils.ComposeRecordsList(ctx, ds, refWrap.modLvl2, types.RecordFilter{
|
|
Query: qq,
|
|
Paging: filter.Paging{
|
|
Limit: uint(len(aux)),
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Build a map from recordID to resolved label for ordered assignment
|
|
labelByID := make(map[string]string, len(relRecords))
|
|
for _, rec := range relRecords {
|
|
if rec.Values == nil {
|
|
continue
|
|
}
|
|
v := rec.Values.Get(resLab, 0)
|
|
if v == nil {
|
|
continue
|
|
}
|
|
labelByID[strconv.FormatUint(rec.ID, 10)] = v.Value
|
|
}
|
|
|
|
for j, origID := range value.Values {
|
|
label, ok := labelByID[origID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ip.includeRefID {
|
|
cacheRecord.SetValue(fmt.Sprintf("%s ID", refField), uint(j), origID)
|
|
}
|
|
cacheRecord.SetValue(refField, uint(j), label)
|
|
}
|
|
|
|
ip.rows[i] = cacheRecord
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// @todo consider omitting these from the interface since they're not always needed
|
|
func (ip *iteratorProvider) Reset(ctx context.Context) (err error) {
|
|
return
|
|
}
|
|
|
|
// @todo consider omitting these from the interface since they're not always needed
|
|
func (ip *iteratorProvider) Ident() (out string) {
|
|
return
|
|
}
|
|
|
|
// @todo consider omitting these from the interface since they're not always needed
|
|
func (ip *iteratorProvider) SetIdent(string) {
|
|
}
|
|
|
|
func mkRecordRefWrap(ctx context.Context, s store.Storer, f *types.ModuleField) (wrap refModWrap, err error) {
|
|
relModID := f.Options.UInt64("moduleID")
|
|
if relModID == 0 {
|
|
return
|
|
}
|
|
|
|
var relMod *types.Module
|
|
relMod, err = s.LookupComposeModuleByID(ctx, relModID)
|
|
if err != nil {
|
|
// Referenced module may have been deleted or made inaccessible since the
|
|
// field was configured. Skip ref resolution for this field instead of
|
|
// failing the entire export — the column is still emitted, just unresolved.
|
|
if errors.IsNotFound(err) {
|
|
logger.Default().Warn(
|
|
"skipping record ref resolution: referenced module not found",
|
|
zap.String("field", f.Name),
|
|
zap.Uint64("moduleID", relModID),
|
|
)
|
|
err = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
relMod.Fields, _, err = s.SearchComposeModuleFields(ctx, types.ModuleFieldFilter{
|
|
ModuleID: []uint64{relMod.ID},
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
wrap = refModWrap{
|
|
modLvl1: relMod,
|
|
labelLvl1: f.Options.String("labelField"),
|
|
}
|
|
|
|
if f.Options.String("recordLabelField") != "" {
|
|
// Two-level label resolution: the labelField on the related module must
|
|
// itself be a Record field pointing to another module. If that chain is
|
|
// broken (labelField renamed/removed, target module missing), skip the
|
|
// nested resolution instead of aborting the export.
|
|
labelFieldName := f.Options.String("labelField")
|
|
nestedRef := wrap.modLvl1.Fields.FindByName(labelFieldName)
|
|
if nestedRef == nil {
|
|
logger.Default().Warn(
|
|
"skipping nested record ref resolution: labelField not found on related module",
|
|
zap.String("field", f.Name),
|
|
zap.String("labelField", labelFieldName),
|
|
zap.Uint64("relatedModuleID", relMod.ID),
|
|
)
|
|
return
|
|
}
|
|
nestedModID := nestedRef.Options.UInt64("moduleID")
|
|
if nestedModID == 0 {
|
|
logger.Default().Warn(
|
|
"skipping nested record ref resolution: labelField has no moduleID option",
|
|
zap.String("field", f.Name),
|
|
zap.String("labelField", labelFieldName),
|
|
)
|
|
return
|
|
}
|
|
|
|
wrap.modLvl2, err = s.LookupComposeModuleByID(ctx, nestedModID)
|
|
if err != nil {
|
|
if errors.IsNotFound(err) {
|
|
logger.Default().Warn(
|
|
"skipping nested record ref resolution: nested module not found",
|
|
zap.String("field", f.Name),
|
|
zap.Uint64("nestedModuleID", nestedModID),
|
|
)
|
|
err = nil
|
|
}
|
|
return
|
|
}
|
|
wrap.labelLvl2 = f.Options.String("recordLabelField")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (ip *iteratorProvider) resolveUsers(ctx context.Context, s store.Storer) (err error) {
|
|
if len(ip.userFields) == 0 || s == nil {
|
|
return
|
|
}
|
|
|
|
// Collect unique user IDs across the current chunk
|
|
seen := make(map[string]bool)
|
|
for _, row := range ip.rows {
|
|
for fieldName := range ip.userFields {
|
|
for _, val := range row[fieldName].Values {
|
|
if val != "" && val != "0" {
|
|
seen[val] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(seen) == 0 {
|
|
return
|
|
}
|
|
|
|
ids := make([]string, 0, len(seen))
|
|
for id := range seen {
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
uu, _, err := store.SearchUsers(ctx, s, systemTypes.UserFilter{
|
|
UserID: ids,
|
|
Paging: filter.Paging{Limit: 0},
|
|
Deleted: filter.StateInclusive,
|
|
Suspended: filter.StateInclusive,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
labels := make(map[string]string, len(uu))
|
|
for _, u := range uu {
|
|
labels[strconv.FormatUint(u.ID, 10)] = userLabel(u)
|
|
}
|
|
|
|
for i, row := range ip.rows {
|
|
for fieldName := range ip.userFields {
|
|
v := row[fieldName]
|
|
if len(v.Values) == 0 {
|
|
continue
|
|
}
|
|
for j, val := range v.Values {
|
|
if val == "0" || val == "" {
|
|
row.SetValue(fieldName, uint(j), "")
|
|
continue
|
|
}
|
|
if label, ok := labels[val]; ok {
|
|
if ip.includeRefID {
|
|
row.SetValue(fmt.Sprintf("%s ID", fieldName), uint(j), val)
|
|
}
|
|
row.SetValue(fieldName, uint(j), label)
|
|
}
|
|
}
|
|
}
|
|
ip.rows[i] = row
|
|
}
|
|
return
|
|
}
|
|
|
|
func userLabel(u *systemTypes.User) string {
|
|
if u.Name != "" {
|
|
return u.Name
|
|
}
|
|
if u.Email != "" {
|
|
return u.Email
|
|
}
|
|
if u.Handle != "" {
|
|
return u.Handle
|
|
}
|
|
return strconv.FormatUint(u.ID, 10)
|
|
}
|
|
|
|
// filterUnreadableFields removes values the given user does not have access to
|
|
func (ip *iteratorProvider) filterUnreadableFields(rowCache datasource.RawRecord) {
|
|
if reflect2.IsNil(ip.ac) {
|
|
return
|
|
}
|
|
|
|
for fieldName := range rowCache {
|
|
// Skip system fields (ID, createdAt, etc.) - these are always readable
|
|
// System fields are lowercase and don't have associated module field definitions
|
|
if _, hasField := ip.readableFields[fieldName]; !hasField {
|
|
// Field not in module definition, likely a system field - keep it
|
|
continue
|
|
}
|
|
|
|
// Check if user can read this field
|
|
if !ip.readableFields[fieldName] {
|
|
delete(rowCache, fieldName)
|
|
}
|
|
}
|
|
}
|