Files
upstream-corteza/server/compose/envoy/record_datasource.go
upstream-mirror 7e385fc126
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
snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:22:23 +05:00

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)
}
}
}