ProtectedAfterProtectedAfterProtectedAfterProtectedapplyApplies an inbound RestoreContext___ blob to a server-side BaseEntity.
Mirrors the OldValues___ pattern — the client-side BaseEntity's
_restoreContext doesn't traverse the network, so the server must
reconstruct it from the mutation input before calling Save().
Returns true when context was applied; false when no context was on the input.
ProtectedArrayFilters encrypted fields for an array of data objects. fieldKey is as for
FilterEncryptedFieldsForAPI.
Optionalprovider: IMetadataProviderProtectedArrayArray form of MapFieldNamesToCodeNames. Returns a NEW array of NEW objects; neither the input array nor its rows are modified. Both matter: the cache freezes the array as well as the rows it contains, so collecting the mapped copies (rather than mapping in place and returning the original) is what makes this safe on cache-served input.
OptionalcontextUser: UserInfoOptionalprovider: IMetadataProviderProtectedassertSECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
ValidateUserProvidedSQLClause still blocks stacked statements, DML, comments, UNION
and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
use IN (SELECT … FROM <entity base view>) — that is a legitimate ExtraFilter.
This screen uses @memberjunction/sql-parser (same wrap as EDS assertReadOnlyClause):
wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
allow a FROM only when it is an entity BaseView. Base tables (Meeting, __mj.User)
and catalogs are rejected. Server-internal RunView callers get only the provider's
statement-separator check, which cannot refuse a legitimate filter.
The screen is ClientClauseScreen in @memberjunction/generic-database-provider, shared
with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
Before that parse, the fragment must be balanced on its own (IsBalancedSQLFragment). The
provider and the tenant hook splice it inside parentheses and AND their own filters after
it, so a fragment that closes those parentheses (1=1) OR (1=1) would leave row-level
security, API-key row filters and tenant scope on one side of an OR the caller controls.
The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
A backslash inside a quoted string or identifier is refused as well: the parser reads \'
inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
there, so the text after it could be code that the parse-based checks never saw.
RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
into the view, and RunView removes denied fields from its own result only. A subquery reads a
base view directly with neither, so the screen refuses one over any entity the user reads under
a row filter or with denied fields (ClientClauseScreen.AssertUnscopedRead), the queried
entity included.
Optionalprovider: IMetadataProviderOptionaluser: UserInfoOptionaloptions: { LeaveUnparseableToProvider?: boolean }ProtectedassertSECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
to the SELECT-only validation the caller performs, every table reference must resolve to an
entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
CTE that is in scope there (StatementRelation.IsCTEReference): unqualified, declared by its
own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
for the SQL parser is refused before parsing (ClientClauseScreen.AssertNoIdentifierAliasNames).
Fails closed: no user, or an unresolvable reference, refuses.
The references come from a walk of the whole parse tree
(ClientClauseScreen.CollectStatementReads). The statement is refused when that walk does not
account for every SELECT in the text, or when a FROM item names no table (a table function or
a row list).
Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with row-level security and denied-field projection, and raw SQL applies neither. Rather than try to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter or a denied field for the caller — those reads belong on RunView. For the remaining entities CanRead genuinely means "every row and field of the view".
ProtectedattachIS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
primary key, because in IS-A the shared key is the relationship and the child has no key of
its own to mint. NewRecord() has just reset the whole chain to "new", so left alone the
parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
violation at best, a silently duplicated parent at worst).
When a child create carries a complete primary key, bind the new child to the existing parent
row with AttachToParent (#3825): it loads the parent chain, so the parent saves as an UPDATE
and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
NewRecord() built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
no key, never gets past the guards.
true when the child was attached to an existing parent row.
ProtectedBeforeProtectedBeforeProtectedBeforeProtectedBuildThe CodeNames this user may read on this entity, for the field-security transport key — or null when there is nothing to state (no denials, so every field is readable and the client needs no help distinguishing a withheld null from a genuine one).
Carries entity field NAMES, not CodeNames and not the _mj__ transport shape. The client
consumes this after it has already reversed the transport mapping, and it matches names
against EntityFieldInfo.Name — the same key the denied set itself is built from. Sending
the one shape both sides already agree on avoids a second mapping that could drift.
ProtectedCheckChecks API key scope authorization. Only performs check if request was authenticated via API key (apiKeyHash present in userPayload). For OAuth/JWT auth, this is a no-op.
The scope path (e.g., 'entity:read', 'agent:execute')
The resource name (e.g., entity name, agent name)
The user payload from context
ProtectedCheckOptionalprovider: IMetadataProviderProtectedClientConverts one client-sent old value (always a string on the wire; dates as epoch milliseconds, the GraphQL Timestamp form) to the field's TypeScript type, so it compares equal to the stored value. Both the OldValues comparison and the load-from-OldValues path use it: without it a date old value becomes an Invalid Date and an unchanged date field reads as edited.
ProtectedcreateProtectedCreateProtectedcreateProtectedDeleteProtectedEmitProtectedFilterFilters encrypted field values before sending to the API client.
For each encrypted field in the entity:
Name of the entity
The data object containing field values
User context for encryption operations
Optionalprovider: IMetadataProviderWhat the object's keys are: field CodeNames (GraphQL transport rows, the default) or field Names (raw provider rows, such as dataset item results)
The filtered data object
ProtectedfindOptionalItemFilters: DatasetItemFilterTypeGQL[]Batch version: fetch status for multiple datasets in a single round-trip. Reduces N separate GetDatasetStatusByName calls to 1 network request.
ProtectedgetProtectedGetProtectedGetProtectedhasChecks whether oldValues (input.OldValues___) contains every writable in-table field of
entityInfo (plus primary keys).
With the delta-only Save() wire protocol, GraphQLDataProvider.Save() sends only primary keys
and dirty writable fields in vars.input and OldValues___ on normal updates. Hydrating an
entity directly from a partial-delta OldValues___ via entityObject.LoadFromData(oldValues)
(without first loading the authoritative row via InnerLoad) would leave every untouched column
NotLoaded = true (or null), causing spUpdate or custom Validate() hooks to see incomplete
state. Returning false for partial deltas ensures MustLoadTruthFromDatabase routes
delta updates through InnerLoad(cKey) + TestAndSetClientOldValuesToDBValues, even on
entities with TrackRecordChanges === false.
OptionaloldValues: { Key: string; Value: unknown }[]ProtectedListenProtectedLoadLoads a single external-data-source-backed entity record by primary key and returns it in GraphQL field-name (CodeName) shape, or null if not found.
External entities (Entity.ExternalDataSourceID set) have no MJ base view or sproc — their
data is proxied live from a remote system — so the generated single-record resolver cannot run
SELECT * FROM <baseView>. Instead it loads through a BaseEntity object, whose InnerLoad
the data provider dispatches to the external read router's LoadExternalRecord (a composite-key
aware, quoted, parameter-bound single-record lookup), applying the same RLS gate and field
post-processing (decryption / datetime normalization) as the MJ-DB path. The caller is
responsible for the CheckUserReadPermissions gate beforehand.
ProtectedMapOptionalcontextUser: UserInfoOptionalprovider: IMetadataProviderOptionaldeniedReadFields: Set<string>OptionalrecordChangeProjector: RecordChangeFieldSecurityProjectorProtectedMustWhether UpdateRecord must hydrate the entity from the DATABASE rather than from the
client's OldValues___.
The OldValues___ path exists as an optimization when a client supplies the complete pre-save
record (e.g. IgnoreDirtyState: true) and nothing else requires the database's authoritative prior
state. Under the delta-only Save() wire protocol, normal updates send only primary keys and dirty
writable fields in OldValues___, so hasCompleteOldValues returns false and forces the
database load (InnerLoad) so untouched columns retain their stored values.
EnableFieldLevelSecurity is the security-critical one, and it is NOT redundant with
hasDeniedReadFields. That flag reports READ denials, and the canonical FLS configuration is
Read Allow + Update Deny — which leaves it false. Such a caller would be hydrated from its own
OldValues___, and a value it supplies there for an update-denied field arrives through
LoadFromData, which the EntityField setter records as that field's INITIAL value. The
field is therefore not dirty, BaseEntity.CheckFieldLevelUpdatePermissions only rejects
field.Dirty && denied, and GenerateSaveSQL sends every IsSPParameter field regardless of
dirtiness (it skips only NotLoaded). The fabricated value reached spUpdate having passed
every check: pinning a value in OldValues___ was a write to a field the caller may not write.
Forcing the truth-load closes that rather than relocating it. The entity is hydrated by
InnerLoad from the real row, and TestAndSetClientOldValuesToDBValues reads the client's
OldValues only to detect concurrent-edit overlap before ending in SetMany(clientNewValues) —
it never applies them to the entity. An update-denied field can then only become dirty by being
named in the mutation input itself, which is precisely the case that check does catch.
The flag comes after the cheap terms, so the extra load lands only on entities that have the feature switched on, which is almost none of them.
MJ: Record Changes always loads from the database: its server class allows an update only when
Comments is the one dirty field, and "dirty" compared against client-supplied OldValues lets a
caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
nothing else forces the load.
MJ: Data Context Items always loads from the database for the same reason: its server class
lets only an administrator save an item that holds SQL, and decides that from the item's Type
and SQL. Hydrated from OldValues, a client could leave both out, and the stored procedure would
keep the stored SQL item while the rule saw none.
MJ: Conversation Details always loads for the same reason: its write gate decides from the stored
row (who wrote a person's message, its role, the owner's rating), and an OldValues-hydrated entity
would let a grantee claim to be a message's author while rewriting it. See
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.
A caller under an Update row filter (a role's Update RLS filter or an API-key row filter) always loads from the database too (#4919). The save's post-image check skips its query when no column the filter reads is dirty, and dirtiness measured against client OldValues is the caller's to choose: claiming the new value of a filter column as its old value made it clean, so the update moved the row outside the caller's scope without the check ever running. Evaluated last: it builds the filter clause, and only callers with such a filter pay for the load. A missing user (a user-cache miss) also loads: with no user the filter can't be ruled out.
ProtectedpackageProtectedprimaryThe entity's primary key as supplied on a mutation input, or null when any part of it is missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty string counts as missing, the same as null — it is never a valid key value, and a GUID or string key that is "" identifies no row, so the create proceeds as a whole-chain create rather than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier) is NOT swallowed: the load throws and the mutation fails loudly.
ProtectedProtectApplies the API encryption policy to each item's rows, as every other GraphQL read path does: an encrypted field the entity does not allow decrypted over the API leaves as ciphertext or the sentinel. Works on copies, because the rows can be the server cache's own frozen objects.
ProtectedPublishPublishes a CACHE_INVALIDATION event to connected browser clients after a successful entity save or delete. Includes the originSessionId so the originating browser can skip redundant re-fetches (it already handled the event locally).
The row itself rides along ONLY for entities opted in via
cacheSettings.recordDataBroadcastEntities — this event reaches every connected client
unfiltered, so the row would otherwise be readable by sessions that could not read the record.
ProtectedPublishPublishes a push-status update to the client on PUSH_STATUS_UPDATES_TOPIC, stamping the
authenticated owner's user ID from userPayload so the subscription filter can bind delivery
to identity (see B49 / statusUpdatesFilter). The ergonomic wrapper every resolver should use
instead of calling pubSub.publish on the topic directly — it makes omitting identity
impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
publishStatusUpdate() function directly with an explicit ownerUserId.
ProtectedRequireThe session's user. A dataset read never runs without one: the provider applies no read checks then.
ProtectedReverseReverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object.
For example, _mj__integration_SyncStatus is mapped back to __mj_integration_SyncStatus.
Also reverse-maps keys inside the OldValues___ array if present.
This is the inverse of MapFieldNamesToCodeNames and must be called before passing
GraphQL input to entity SetMany() or field lookups.
ProtectedRunOptimized RunViewGenericInternal implementation with:
Optionalaggregates: AggregateExpression[]OptionalafterKey: CompositeKeyOptionalbypassCache: booleanOptionaldataSource: "Live" | "Materialized"OptionalincludeBinaryFields: booleanProtectedRunOptimized implementation that:
ProtectedsafeProtectedscreenApplies assertClientClauseUsesEntityBaseViews to every client-supplied clause a view request can carry that is actually a SQL fragment. GraphQL entry points (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through RunViewGenericInternal / RunViewsGenericInternal.
🚨 UserSearchString is deliberately NOT screened here (#4392). It is not a clause — it is
the free text a person typed into a search box, and it never reaches SQL as a fragment.
GenericDatabaseProvider.createViewUserSearchSQL builds the predicate itself from
IncludeInUserSearchAPI metadata and lands the text only as a literal, doubling single
quotes and escaping LIKE metacharacters with an explicit ESCAPE. Running it through a
screen that parses its argument as SQL and fails closed rejected every term that is not
coincidentally valid SQL: Marcus Chen parses as nothing, O'Leary as an unterminated
literal — so essentially every real name search returned 0 rows.
The provider-level denylist does NOT back this up — it was removed from UserSearchString
in the same change, for the same reason (it refused Union Pacific). What protects the value
is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
The single place that is not true is a field carrying UserSearchParamFormatAPI, whose
admin-authored format may splice the term in unquoted; createViewUserSearchSQL re-applies
ValidateUserProvidedSQLClause for exactly those entities.
Each Aggregates[].expression gets the same screen, except that text the parser cannot read
goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
aggregate text: it runs the SQL that CheckAggregateExpression rebuilds from a single
aggregate call over the entity's columns, and refuses everything else per aggregate.
Optionalprovider: IMetadataProviderOptionaluser: UserInfoProtectedScreenScreens every client ItemFilter: one statement, read-only, and subqueries only over entity base views the user can read. Throws on the first refusal. Returns the filters without null entries.
ProtectedStripField-level security guard for the update path.
Every denied-read field is stripped — new values AND OldValues. A field the user cannot read was absent from every payload that client ever received, so any value coming back for it is the transport's invention rather than user intent, and applying it would silently overwrite the real column. Stripping is what makes "load a record, edit an unrelated field, save" safe for a restricted user.
There is no split by update permission: Read is required for Update, so a user denied read is denied update too and denied-read ∩ denied-update is just denied-read.
Silent narrowing, not rejection — consistent with the output projection the client already experiences, and with the ambiguous-error rule (naming the field would confirm it exists and is restricted).
Returns true when the user has a non-empty denied-read set on this entity. The caller must then hydrate the entity from the DATABASE (never from client OldValues), so denied fields hold true values that an omitted key leaves untouched.
ProtectedStripRefuses a client-sent MJ: Record Changes payload column from a caller who carries field
denials, and forces the update onto the load-truth-from-DB branch.
This is the write half of the audit-trail projection, and without it that projection would
itself destroy audit history. A restricted caller is served a NARROWED ChangesJSON /
FullRecordJSON and no ChangesDescription. Those values hydrate a client-side entity as
ordinary loaded values, and GenerateSaveSQL writes EVERY IsSPParameter field rather than
only dirty ones — so a user who edits Comments on that record and saves would silently
overwrite the stored payload with the narrowed one they were shown. The row would keep looking
like a complete audit entry while the pruned fields were gone for everyone, permanently.
The rule is the one field security already applies elsewhere: a value a client could not have seen in full is not user intent, it is a transport artifact, and it is ignored. Here that is widened from "fields the caller cannot read" to "the audit payload of a caller who carries any denial", because the column is readable — it is its CONTENTS that were narrowed, and which row's target entity did the narrowing is not knowable from the input alone.
Returning true routes the caller through InnerLoad, so the stored values are restored from
the database before the save. Legitimate payload writes are unaffected: they happen
server-side (SnapshotBuilder, replay) and never through this resolver.
Optionalprovider: IMetadataProviderProtectedTestProtectedUpdate
Request handling shared by the dataset queries. Every read runs as the session's user, so the provider applies that user's entity read permission and row-level security per item, and every client ItemFilter passes the same AST screen as a RunView ExtraFilter before a provider sees it.