@memberjunction/server 6.1.5 → 6.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.js.map +1 -0
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +6 -3
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +15 -11
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -11
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +3417 -3428
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +95 -11
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +186 -76
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/isolatedProvider.d.ts +20 -0
- package/dist/isolatedProvider.d.ts.map +1 -0
- package/dist/isolatedProvider.js +22 -0
- package/dist/isolatedProvider.js.map +1 -0
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +46 -0
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +11 -48
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +13 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +6 -2
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SessionManager.test.ts +94 -17
- package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
- package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLinkService.consume.test.ts +6 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUsers.test.ts +62 -5
- package/src/agentSessions/SessionManager.ts +23 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +6 -3
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +14 -10
- package/src/generated/graphql-schemas/__mj.ts +3417 -3428
- package/src/generic/CacheInvalidationResolver.ts +74 -19
- package/src/generic/ResolverBase.ts +207 -80
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +9 -1
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +49 -1
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +11 -52
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/SyncDataResolver.ts +6 -2
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +29 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DatabaseProviderBase, Metadata } from '@memberjunction/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A fresh Read-Write provider with its OWN transaction state, sharing the server's connection pool.
|
|
5
|
+
*
|
|
6
|
+
* A provider's ambient transaction (`BeginTransaction` / `CommitTransaction` / `RollbackTransaction`)
|
|
7
|
+
* is a field on the provider INSTANCE. Two units of work that open transactions on one instance nest
|
|
8
|
+
* into each other, and every write anyone else makes through that instance while one is open lands
|
|
9
|
+
* inside it (MJ#2140; `entityTransactionScope.ts`). The process-global `Metadata.Provider` is shared
|
|
10
|
+
* by every request, scheduled job and background service, so transactional work must never run on
|
|
11
|
+
* it: one transaction left open there silently swallows every later global-provider write until the
|
|
12
|
+
* process restarts.
|
|
13
|
+
*
|
|
14
|
+
* GraphQL resolvers already get a per-request provider from `AppContext.providers`. Code that runs
|
|
15
|
+
* outside one, such as the auth flow before the context exists, calls this.
|
|
16
|
+
*
|
|
17
|
+
* Cheap: it is the global provider's `CreateIndependentInstance()` (SQL Server and PostgreSQL), which
|
|
18
|
+
* reuses the already-loaded metadata and the shared pool.
|
|
19
|
+
*/
|
|
20
|
+
export async function CreateIsolatedProvider(): Promise<DatabaseProviderBase> {
|
|
21
|
+
return (Metadata.Provider as DatabaseProviderBase).CreateIndependentInstance(); // global-provider-ok: borrows only its pool and metadata, never its transaction state
|
|
22
|
+
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import type { RequestHandler } from 'express';
|
|
11
11
|
import type { PreRunViewHook, PreSaveHook } from '@memberjunction/core';
|
|
12
|
-
import { Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
|
|
12
|
+
import { DatabaseProviderBase, Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
|
|
13
|
+
import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
14
|
+
import { IsBalancedSQLFragment } from '@memberjunction/sql-parser';
|
|
13
15
|
import type { MultiTenancyConfig } from '../config.js';
|
|
14
16
|
import type { UserPayload } from '../types.js';
|
|
15
17
|
import { CloneUserForSessionContext } from '../auth/sessionUserClone.js';
|
|
@@ -147,6 +149,25 @@ function isAdminUser(user: UserInfo, adminRoles: string[]): boolean {
|
|
|
147
149
|
);
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/** Dialects to read an ExtraFilter with when the active provider's dialect cannot be resolved. */
|
|
153
|
+
const ALL_FILTER_DIALECTS: SQLParserDialect[] = [new SQLServerDialect(), new PostgreSQLDialect()];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Throws unless the caller's ExtraFilter stays inside the parentheses the tenant predicate is
|
|
157
|
+
* ANDed after. A filter such as `1=1) OR (1=1` would close them early and leave the tenant
|
|
158
|
+
* predicate on one side of an OR the caller controls.
|
|
159
|
+
*/
|
|
160
|
+
function assertExtraFilterBalanced(extraFilter: string): void {
|
|
161
|
+
const provider = Metadata.Provider; // global-provider-ok: same rationale as isEntityScoped above — hooks carry no per-request provider
|
|
162
|
+
const dialects = provider instanceof DatabaseProviderBase ? [provider.Dialect] : ALL_FILTER_DIALECTS;
|
|
163
|
+
for (const dialect of dialects) {
|
|
164
|
+
const check = IsBalancedSQLFragment(extraFilter, dialect);
|
|
165
|
+
if (!check.IsBalanced) {
|
|
166
|
+
throw new Error(`[MultiTenancy] Invalid ExtraFilter: ${check.Reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
/**
|
|
151
172
|
* Creates a PreRunViewHook that auto-injects tenant WHERE clauses
|
|
152
173
|
* into RunView queries for scoped entities.
|
|
@@ -192,7 +213,8 @@ export function createTenantPreRunViewHook(config: MultiTenancyConfig): PreRunVi
|
|
|
192
213
|
|
|
193
214
|
// Inject the tenant filter
|
|
194
215
|
if (params.ExtraFilter && typeof params.ExtraFilter === 'string' && params.ExtraFilter.trim().length > 0) {
|
|
195
|
-
|
|
216
|
+
assertExtraFilterBalanced(params.ExtraFilter);
|
|
217
|
+
params.ExtraFilter = `(${params.ExtraFilter}) AND (${tenantFilter})`;
|
|
196
218
|
} else {
|
|
197
219
|
params.ExtraFilter = tenantFilter;
|
|
198
220
|
}
|
|
@@ -85,8 +85,10 @@ export async function resolveWidgetGuestRunContext(
|
|
|
85
85
|
* A scoped anonymous session (`IsMagicLinkAnonymous` + `MagicLinkScope.ResourceID`) holds only the
|
|
86
86
|
* narrow relay grants its invite's role carries — deliberately NOT the AI run entities, whose rows
|
|
87
87
|
* leak the rendered system prompt. Unlike {@link resolveWidgetGuestRunContext} no widget instance is
|
|
88
|
-
* required:
|
|
89
|
-
*
|
|
88
|
+
* required: the realtime target is the session config's `targetAgentID`, which the owner can edit, so
|
|
89
|
+
* the relay re-checks `CanRun` on it for the CALLER before this elevation. Direct actions are refused
|
|
90
|
+
* for these callers before elevation, so only the delegated agent run and the run-entity writes
|
|
91
|
+
* execute as the system user.
|
|
90
92
|
*
|
|
91
93
|
* PUBLIC WEB-WIDGET guests are deliberately EXCLUDED (returned unchanged): their seeded role writes
|
|
92
94
|
* run rows under the guest principal, which the `Widget Guest: Own Agent Runs` RLS read filter
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { Resolver, Mutation, Arg, Ctx } from "type-graphql";
|
|
2
2
|
import { ActionEngineServer } from "@memberjunction/actions";
|
|
3
3
|
import { EntityActionEngineServer } from "@memberjunction/actions";
|
|
4
|
-
import { Metadata, UserInfo, BaseEntity, CompositeKey, KeyValuePair, LogError } from "@memberjunction/core";
|
|
5
|
-
import { ActionParam, ActionResult } from "@memberjunction/actions-base";
|
|
4
|
+
import { Metadata, UserInfo, BaseEntity, CompositeKey, KeyValuePair, LogError, RunView, DatabaseProviderBase } from "@memberjunction/core";
|
|
5
|
+
import { ActionParam, ActionResult, MJActionEntityExtended } from "@memberjunction/actions-base";
|
|
6
|
+
import { UserCache } from "@memberjunction/generic-database-provider";
|
|
6
7
|
import { Field, InputType, ObjectType } from "type-graphql";
|
|
7
8
|
import { KeyValuePairInput } from "../generic/KeyValuePairInput.js";
|
|
8
9
|
import { AppContext, ProviderInfo } from "../types.js";
|
|
9
|
-
import { CopyScalarsAndArrays, UUIDsEqual } from "@memberjunction/global";
|
|
10
|
+
import { CopyScalarsAndArrays, EscapeSQLString, UUIDsEqual } from "@memberjunction/global";
|
|
10
11
|
import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
|
|
11
12
|
import { ResolverBase } from "../generic/ResolverBase.js";
|
|
13
|
+
import { IsScopeLimitedPrincipal } from "../auth/scopeLimitedPrincipal.js";
|
|
14
|
+
import { EvaluateActionRunAuthorization, IsOwner } from "../auth/actionAuthorization.js";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Input type for action parameters
|
|
@@ -55,8 +58,8 @@ export class RunActionInput {
|
|
|
55
58
|
Params?: ActionParamInput[];
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
61
|
+
* Ignored. A run started through this API always writes an action execution log row; the field
|
|
62
|
+
* remains so that clients which send it still validate.
|
|
60
63
|
*/
|
|
61
64
|
@Field(() => Boolean, { nullable: true })
|
|
62
65
|
SkipActionLog?: boolean;
|
|
@@ -174,7 +177,13 @@ export class ActionResultOutput {
|
|
|
174
177
|
@Resolver()
|
|
175
178
|
export class ActionResolver extends ResolverBase {
|
|
176
179
|
/**
|
|
177
|
-
* Mutation for running an action
|
|
180
|
+
* Mutation for running an action.
|
|
181
|
+
*
|
|
182
|
+
* Scope-limited sessions are refused. Any other caller must be authorized for the action — see
|
|
183
|
+
* {@link EvaluateActionRunAuthorization}: an Owner may run any action; anyone else needs a role that
|
|
184
|
+
* holds an authorization linked to the action through `MJ: Action Authorizations`, or, when the
|
|
185
|
+
* action has no such link, an action class that authorizes its caller itself. The run is always
|
|
186
|
+
* logged.
|
|
178
187
|
* @param input The input parameters for running the action
|
|
179
188
|
* @param ctx The GraphQL context containing user authentication information
|
|
180
189
|
* @returns The result of running the action
|
|
@@ -185,20 +194,16 @@ export class ActionResolver extends ResolverBase {
|
|
|
185
194
|
@Ctx() ctx: AppContext
|
|
186
195
|
): Promise<ActionResultOutput> {
|
|
187
196
|
try {
|
|
197
|
+
const user = this.getRunActionPrincipal(ctx);
|
|
198
|
+
|
|
188
199
|
// Check API key scope authorization for action execution
|
|
189
200
|
await this.CheckAPIKeyScopeAuthorization('action:execute', input.ActionID, ctx.userPayload);
|
|
190
201
|
|
|
191
|
-
// Get the user from context
|
|
192
|
-
const user = ctx.userPayload.userRecord;
|
|
193
|
-
if (!user) {
|
|
194
|
-
throw new Error("User is not authenticated");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
202
|
// Initialize the action engine
|
|
198
203
|
await ActionEngineServer.Instance.Config(false, user);
|
|
199
204
|
|
|
200
|
-
// Get the action by ID
|
|
201
205
|
const action = this.findActionById(input.ActionID);
|
|
206
|
+
await this.assertCanRunAction(action, user, ctx.providers);
|
|
202
207
|
|
|
203
208
|
// Parse the parameters
|
|
204
209
|
const params = this.parseActionParameters(input.Params);
|
|
@@ -206,7 +211,7 @@ export class ActionResolver extends ResolverBase {
|
|
|
206
211
|
// Run the action — thread the request-scoped provider so provider-bound
|
|
207
212
|
// actions (e.g. the semantic find-* / Search Entity actions, which call
|
|
208
213
|
// params.Provider.SearchEntity) run against the caller's metadata layer.
|
|
209
|
-
const result = await this.executeAction(action, user, params, ctx.providers
|
|
214
|
+
const result = await this.executeAction(action, user, params, ctx.providers);
|
|
210
215
|
|
|
211
216
|
// Return the result
|
|
212
217
|
return this.createActionResult(result);
|
|
@@ -215,6 +220,66 @@ export class ActionResolver extends ResolverBase {
|
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The authenticated caller of RunAction. A scope-limited session is refused: its confinement exists
|
|
225
|
+
* only as row-level-security filters, and an action can reach data and services those filters never
|
|
226
|
+
* cover.
|
|
227
|
+
* @throws Error when there is no user or the session is scope-limited
|
|
228
|
+
*/
|
|
229
|
+
private getRunActionPrincipal(ctx: AppContext): UserInfo {
|
|
230
|
+
const user = ctx.userPayload?.userRecord;
|
|
231
|
+
if (!user) {
|
|
232
|
+
throw new Error("User is not authenticated");
|
|
233
|
+
}
|
|
234
|
+
if (IsScopeLimitedPrincipal(user)) {
|
|
235
|
+
throw new Error("RunAction is not permitted for scope-limited sessions");
|
|
236
|
+
}
|
|
237
|
+
return user;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Refuses the run unless the caller is authorized for the action — see
|
|
242
|
+
* {@link EvaluateActionRunAuthorization}. The `MJ: Action Authorizations` rows, the authorization
|
|
243
|
+
* tree and the role grants all come from the request's provider, or all from the global provider
|
|
244
|
+
* when the request carries none.
|
|
245
|
+
* @throws Error when the caller may not run the action
|
|
246
|
+
*/
|
|
247
|
+
private async assertCanRunAction(action: MJActionEntityExtended, user: UserInfo, providers: Array<ProviderInfo>): Promise<void> {
|
|
248
|
+
if (IsOwner(user)) {
|
|
249
|
+
return; // an Owner may run any action, so nothing needs to be read
|
|
250
|
+
}
|
|
251
|
+
const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
252
|
+
const actionAuthorizationIDs = await this.loadActionAuthorizationIDs(action.ID, provider);
|
|
253
|
+
const metadata = provider ?? Metadata.Provider; // global-provider-ok: fallback only when the request carries no provider
|
|
254
|
+
const actionAuthorizesCaller = ActionEngineServer.Instance.ActionAuthorizesCaller(action);
|
|
255
|
+
const decision = EvaluateActionRunAuthorization(user, actionAuthorizationIDs, metadata, actionAuthorizesCaller);
|
|
256
|
+
if (!decision.Allowed) {
|
|
257
|
+
throw new Error(`Not authorized to run action ${action.ID}: ${decision.Reason}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The AuthorizationID of each `MJ: Action Authorizations` row for the action. Read as the system
|
|
263
|
+
* user: this is the server's own authorization decision, so it must not depend on whether the
|
|
264
|
+
* caller can read that entity.
|
|
265
|
+
*/
|
|
266
|
+
private async loadActionAuthorizationIDs(actionID: string, provider: DatabaseProviderBase | null): Promise<string[]> {
|
|
267
|
+
const systemUser = UserCache.Instance.GetSystemUser();
|
|
268
|
+
if (!systemUser) {
|
|
269
|
+
throw new Error("System user not found");
|
|
270
|
+
}
|
|
271
|
+
const result = await new RunView(provider).RunView<{ AuthorizationID: string }>({
|
|
272
|
+
EntityName: 'MJ: Action Authorizations',
|
|
273
|
+
ExtraFilter: `ActionID='${EscapeSQLString(actionID)}'`,
|
|
274
|
+
Fields: ['AuthorizationID'],
|
|
275
|
+
ResultType: 'simple'
|
|
276
|
+
}, systemUser);
|
|
277
|
+
if (!result.Success) {
|
|
278
|
+
throw new Error(`Could not read the authorizations for action ${actionID}: ${result.ErrorMessage}`);
|
|
279
|
+
}
|
|
280
|
+
return result.Results.map(row => row.AuthorizationID);
|
|
281
|
+
}
|
|
282
|
+
|
|
218
283
|
/**
|
|
219
284
|
* Finds an action by its ID
|
|
220
285
|
* @param actionID The ID of the action to find
|
|
@@ -222,7 +287,7 @@ export class ActionResolver extends ResolverBase {
|
|
|
222
287
|
* @throws Error if the action is not found
|
|
223
288
|
* @private
|
|
224
289
|
*/
|
|
225
|
-
private findActionById(actionID: string):
|
|
290
|
+
private findActionById(actionID: string): MJActionEntityExtended {
|
|
226
291
|
const action = ActionEngineServer.Instance.Actions.find(a => UUIDsEqual(a.ID, actionID));
|
|
227
292
|
if (!action) {
|
|
228
293
|
throw new Error(`Action with ID ${actionID} not found`);
|
|
@@ -264,20 +329,18 @@ export class ActionResolver extends ResolverBase {
|
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
/**
|
|
267
|
-
* Executes an action
|
|
332
|
+
* Executes an action. The run is always logged: a client cannot switch off the action execution log.
|
|
268
333
|
* @param action The action to execute
|
|
269
334
|
* @param user The user context
|
|
270
335
|
* @param params The action parameters
|
|
271
|
-
* @param skipActionLog Whether to skip action logging
|
|
272
336
|
* @returns The action result
|
|
273
337
|
* @private
|
|
274
338
|
*/
|
|
275
339
|
private async executeAction(
|
|
276
|
-
action:
|
|
340
|
+
action: MJActionEntityExtended,
|
|
277
341
|
user: UserInfo,
|
|
278
342
|
params: ActionParam[],
|
|
279
|
-
providers: Array<ProviderInfo
|
|
280
|
-
skipActionLog?: boolean
|
|
343
|
+
providers: Array<ProviderInfo>
|
|
281
344
|
): Promise<ActionResult> {
|
|
282
345
|
// Resolve the request-scoped provider. Prefer read-write (actions may mutate);
|
|
283
346
|
// fall back to read-only, then to the engine's global-Metadata default when no
|
|
@@ -287,7 +350,7 @@ export class ActionResolver extends ResolverBase {
|
|
|
287
350
|
Action: action,
|
|
288
351
|
ContextUser: user,
|
|
289
352
|
Params: params,
|
|
290
|
-
SkipActionLog:
|
|
353
|
+
SkipActionLog: false,
|
|
291
354
|
Filters: [],
|
|
292
355
|
Provider: provider
|
|
293
356
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
|
|
2
2
|
import { LogError } from '@memberjunction/core';
|
|
3
|
-
import type { RunQueryResult } from '@memberjunction/core';
|
|
3
|
+
import type { DatabaseProviderBase, RunQueryResult, UserInfo } from '@memberjunction/core';
|
|
4
|
+
import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
5
|
+
import type { AdhocSQLAuthorizer } from '@memberjunction/actions';
|
|
4
6
|
import { AppContext } from '../types.js';
|
|
5
7
|
import { configInfo } from '../config.js';
|
|
6
8
|
import { GetReadOnlyProvider } from '../util.js';
|
|
@@ -38,6 +40,10 @@ class AdhocQueryInput {
|
|
|
38
40
|
*
|
|
39
41
|
* Security:
|
|
40
42
|
* - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
|
|
43
|
+
* - Authorizes the RENDERED SQL: every table reference must be an entity base view the caller
|
|
44
|
+
* reads unscoped (CanRead, no row-level filter, no denied fields). Entities with row/field
|
|
45
|
+
* policy for this user are refused — raw SQL cannot apply that policy, so those reads belong
|
|
46
|
+
* on RunView.
|
|
41
47
|
* - Read-only provider only (no fallback to read-write)
|
|
42
48
|
* - Configurable timeout (default 30s), enforced by the database
|
|
43
49
|
* - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
|
|
@@ -68,6 +74,10 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
68
74
|
return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
|
|
69
75
|
}
|
|
70
76
|
const contextUser = context.userPayload?.userRecord;
|
|
77
|
+
const refusal = this.AuthorizeRenderedSQL(input.SQL, provider, contextUser);
|
|
78
|
+
if (refusal) {
|
|
79
|
+
return this.buildErrorResult(refusal, Date.now() - startTime);
|
|
80
|
+
}
|
|
71
81
|
const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
|
|
72
82
|
// A negative offset must not reach paging, where it would turn paging off.
|
|
73
83
|
const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
|
|
@@ -86,6 +96,29 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
/**
|
|
100
|
+
* SECURITY: authorizes the READ itself, against the SQL that will actually run. The provider
|
|
101
|
+
* validates that the SQL is a single read statement but places no restriction on WHAT is read.
|
|
102
|
+
* Rendering comes first because composition (`{{query:"..."}}`) and templates can introduce
|
|
103
|
+
* table references the raw input never named. Every table reference must be an entity base
|
|
104
|
+
* view the caller can read UNSCOPED — CanRead with no row-level filter and no denied fields —
|
|
105
|
+
* because raw SQL applies neither (see assertFullQueryUsesReadableEntityViews). The provider
|
|
106
|
+
* renders the same input the same way and only wraps it in server-built paging and a count,
|
|
107
|
+
* so the rendered SQL is the full set of reads. Returns the refusal message, or null.
|
|
108
|
+
*
|
|
109
|
+
* `provider` is the read-only provider the SQL will run on; it supplies the platform and the
|
|
110
|
+
* entity metadata. {@link CreateAdhocSQLAuthorizer} exposes this check to the action engine.
|
|
111
|
+
*/
|
|
112
|
+
public AuthorizeRenderedSQL(sqlText: string, provider: DatabaseProviderBase, contextUser: UserInfo | undefined): string | null {
|
|
113
|
+
try {
|
|
114
|
+
const rendered = RenderPipeline.Run(sqlText, { Platform: provider.PlatformKey, ContextUser: contextUser, RequireReadStatement: true });
|
|
115
|
+
this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, provider, contextUser, 'ad-hoc SQL');
|
|
116
|
+
return null;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return err instanceof Error ? err.message : String(err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
/** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
|
|
90
123
|
private toResultType(
|
|
91
124
|
result: RunQueryResult,
|
|
@@ -131,3 +164,18 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
131
164
|
};
|
|
132
165
|
}
|
|
133
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The ad-hoc SQL checks MJServer registers with the action engine, so that Run Ad-hoc Query applies
|
|
170
|
+
* the same checks as {@link AdhocQueryResolver.ExecuteAdhocQuery}: the rendered SQL must be one read
|
|
171
|
+
* statement that reads only entity views the caller may read in full, and the timeout is limited by
|
|
172
|
+
* the server's request timeout.
|
|
173
|
+
* @param requestTimeoutMs the server's request timeout in milliseconds; 0 or less means no limit
|
|
174
|
+
*/
|
|
175
|
+
export function CreateAdhocSQLAuthorizer(requestTimeoutMs: number): AdhocSQLAuthorizer {
|
|
176
|
+
const resolver = new AdhocQueryResolver();
|
|
177
|
+
return {
|
|
178
|
+
Authorize: (sql, provider, contextUser) => resolver.AuthorizeRenderedSQL(sql, provider, contextUser),
|
|
179
|
+
ClampTimeoutSeconds: (requestedSeconds) => ClampAdhocTimeoutSeconds(requestedSeconds, requestTimeoutMs),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
|
|
2
|
-
import { AppContext } from '../types.js';
|
|
3
|
-
import { LogError, Metadata } from '@memberjunction/core';
|
|
2
|
+
import { AppContext, UserPayload } from '../types.js';
|
|
3
|
+
import { DatabaseProviderBase, DatasetItemFilterType, DatasetItemResultType, LogError, Metadata, UserInfo } from '@memberjunction/core';
|
|
4
4
|
import { GetReadOnlyProvider } from '../util.js';
|
|
5
5
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
6
|
|
|
@@ -34,9 +34,64 @@ export class DatasetItemFilterTypeGQL {
|
|
|
34
34
|
Filter: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Request handling shared by the dataset queries. Every read runs as the session's user, so the
|
|
39
|
+
* provider applies that user's entity read permission and row-level security per item, and every
|
|
40
|
+
* client ItemFilter passes the same AST screen as a RunView ExtraFilter before a provider sees it.
|
|
41
|
+
*/
|
|
42
|
+
export abstract class DatasetResolverBase extends ResolverBase {
|
|
43
|
+
/** The session's user. A dataset read never runs without one: the provider applies no read checks then. */
|
|
44
|
+
protected RequireDatasetUser(userPayload: UserPayload): UserInfo {
|
|
45
|
+
const user = this.GetUserFromPayload(userPayload);
|
|
46
|
+
if (!user) {
|
|
47
|
+
throw new Error('Dataset request refused: the session resolves to no user');
|
|
48
|
+
}
|
|
49
|
+
return user;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Screens every client ItemFilter: one statement, read-only, and subqueries only over entity base
|
|
54
|
+
* views the user can read. Throws on the first refusal. Returns the filters without null entries.
|
|
55
|
+
*/
|
|
56
|
+
protected ScreenItemFilters(
|
|
57
|
+
itemFilters: DatasetItemFilterTypeGQL[] | undefined,
|
|
58
|
+
provider: DatabaseProviderBase,
|
|
59
|
+
user: UserInfo,
|
|
60
|
+
): DatasetItemFilterType[] | undefined {
|
|
61
|
+
if (!itemFilters) return undefined;
|
|
62
|
+
itemFilters.forEach((itemFilter, index) => {
|
|
63
|
+
if (itemFilter) {
|
|
64
|
+
this.assertClientClauseUsesEntityBaseViews(itemFilter.Filter, `ItemFilters[${index}].Filter`, provider, user);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return itemFilters.filter((itemFilter) => itemFilter != null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Applies the API encryption policy to each item's rows, as every other GraphQL read path does:
|
|
72
|
+
* an encrypted field the entity does not allow decrypted over the API leaves as ciphertext or the
|
|
73
|
+
* sentinel. Works on copies, because the rows can be the server cache's own frozen objects.
|
|
74
|
+
*/
|
|
75
|
+
protected async ProtectEncryptedFields(
|
|
76
|
+
items: DatasetItemResultType[],
|
|
77
|
+
provider: DatabaseProviderBase,
|
|
78
|
+
user: UserInfo,
|
|
79
|
+
): Promise<DatasetItemResultType[]> {
|
|
80
|
+
return Promise.all(
|
|
81
|
+
items.map(async (item) => {
|
|
82
|
+
const entity = provider.EntityByName(item.EntityName);
|
|
83
|
+
if (!item.Results?.length || !entity?.EncryptedFields.some((field) => !field.AllowDecryptInAPI)) {
|
|
84
|
+
return item;
|
|
85
|
+
}
|
|
86
|
+
const rows = item.Results.map((row: Record<string, unknown>) => ({ ...row }));
|
|
87
|
+
return { ...item, Results: await this.ArrayFilterEncryptedFieldsForAPI(item.EntityName, rows, user, provider, 'Name') };
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
37
92
|
|
|
38
93
|
@Resolver(DatasetResultType)
|
|
39
|
-
export class DatasetResolverExtended extends
|
|
94
|
+
export class DatasetResolverExtended extends DatasetResolverBase {
|
|
40
95
|
@Query(() => DatasetResultType)
|
|
41
96
|
async GetDatasetByName(
|
|
42
97
|
@Arg('DatasetName', () => String) DatasetName: string,
|
|
@@ -45,10 +100,12 @@ export class DatasetResolverExtended extends ResolverBase {
|
|
|
45
100
|
) {
|
|
46
101
|
// Check API key scope authorization for dataset read
|
|
47
102
|
await this.CheckAPIKeyScopeAuthorization('dataset:read', DatasetName, userPayload);
|
|
103
|
+
const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
|
|
104
|
+
const user = this.RequireDatasetUser(userPayload);
|
|
105
|
+
const itemFilters = this.ScreenItemFilters(ItemFilters, md, user);
|
|
48
106
|
|
|
49
107
|
try {
|
|
50
|
-
const
|
|
51
|
-
const result = await md.GetDatasetByName(DatasetName, ItemFilters);
|
|
108
|
+
const result = await md.GetDatasetByName(DatasetName, itemFilters, user);
|
|
52
109
|
if (result) {
|
|
53
110
|
return {
|
|
54
111
|
DatasetID: result.DatasetID,
|
|
@@ -56,7 +113,7 @@ export class DatasetResolverExtended extends ResolverBase {
|
|
|
56
113
|
Success: result.Success,
|
|
57
114
|
Status: result.Status,
|
|
58
115
|
LatestUpdateDate: result.LatestUpdateDate,
|
|
59
|
-
Results: JSON.stringify(result.Results),
|
|
116
|
+
Results: JSON.stringify(await this.ProtectEncryptedFields(result.Results, md, user)),
|
|
60
117
|
};
|
|
61
118
|
} else {
|
|
62
119
|
throw new Error('Error retrieving Dataset: ' + DatasetName);
|
|
@@ -90,7 +147,7 @@ export class DatasetStatusResultType {
|
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
@Resolver(DatasetStatusResultType)
|
|
93
|
-
export class DatasetStatusResolver extends
|
|
150
|
+
export class DatasetStatusResolver extends DatasetResolverBase {
|
|
94
151
|
@Query(() => DatasetStatusResultType)
|
|
95
152
|
async GetDatasetStatusByName(
|
|
96
153
|
@Arg('DatasetName', () => String) DatasetName: string,
|
|
@@ -99,10 +156,12 @@ export class DatasetStatusResolver extends ResolverBase {
|
|
|
99
156
|
) {
|
|
100
157
|
// Check API key scope authorization for dataset read
|
|
101
158
|
await this.CheckAPIKeyScopeAuthorization('dataset:read', DatasetName, userPayload);
|
|
159
|
+
const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
|
|
160
|
+
const user = this.RequireDatasetUser(userPayload);
|
|
161
|
+
const itemFilters = this.ScreenItemFilters(ItemFilters, md, user);
|
|
102
162
|
|
|
103
163
|
try {
|
|
104
|
-
const
|
|
105
|
-
const result = await md.GetDatasetStatusByName(DatasetName, ItemFilters);
|
|
164
|
+
const result = await md.GetDatasetStatusByName(DatasetName, itemFilters, user);
|
|
106
165
|
if (result) {
|
|
107
166
|
return {
|
|
108
167
|
DatasetID: result.DatasetID,
|
|
@@ -131,12 +190,13 @@ export class DatasetStatusResolver extends ResolverBase {
|
|
|
131
190
|
@Ctx() { providers, userPayload }: AppContext,
|
|
132
191
|
): Promise<DatasetStatusResultType[]> {
|
|
133
192
|
const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
|
|
193
|
+
const user = this.RequireDatasetUser(userPayload);
|
|
134
194
|
const results: DatasetStatusResultType[] = [];
|
|
135
195
|
|
|
136
196
|
// Execute all status checks in parallel
|
|
137
197
|
const statusPromises = DatasetNames.map(async (name) => {
|
|
138
198
|
await this.CheckAPIKeyScopeAuthorization('dataset:read', name, userPayload);
|
|
139
|
-
return md.GetDatasetStatusByName(name);
|
|
199
|
+
return md.GetDatasetStatusByName(name, undefined, user);
|
|
140
200
|
});
|
|
141
201
|
|
|
142
202
|
const statuses = await Promise.all(statusPromises);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CompositeKey, DatabaseProviderBase
|
|
1
|
+
import { CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
|
|
2
2
|
import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
|
|
3
3
|
import { AppContext, UserPayload } from '../types.js';
|
|
4
4
|
import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
|
|
@@ -36,20 +36,14 @@ export class EntityRecordNameResult {
|
|
|
36
36
|
* Resolves a record's display NAME from its primary key — the lookup behind every foreign-key
|
|
37
37
|
* link, breadcrumb and picker label in the UI.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* form-field FK control falls through to this query precisely WHEN the joined display column is
|
|
44
|
-
* denied, so the one case the fallback exists for was the case that leaked.
|
|
39
|
+
* Entity-level read permission is checked here ({@link ResolverBase.CheckUserReadPermissions}).
|
|
40
|
+
* Field- and row-level security are applied by the provider's lookup, for the acting user passed
|
|
41
|
+
* to it: when any field the name is built from is withheld, it answers with no name, and it reads
|
|
42
|
+
* only rows the user's read row filter allows. Without an acting user there is no lookup.
|
|
45
43
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* A field-level denial answers `Success: false` with no name rather than throwing. Callers
|
|
51
|
-
* already treat "no name" as "show the primary key", which is the intended degradation — and it
|
|
52
|
-
* keeps this query from becoming a probe that distinguishes "restricted" from "missing".
|
|
44
|
+
* A withheld name answers `Success: false` with the same status as a record that does not exist.
|
|
45
|
+
* Callers already treat "no name" as "show the primary key", and the identical answer keeps this
|
|
46
|
+
* query from becoming a probe that distinguishes "restricted" from "missing".
|
|
53
47
|
*/
|
|
54
48
|
@Resolver(EntityRecordNameResult)
|
|
55
49
|
export class EntityRecordNameResolver extends ResolverBase {
|
|
@@ -91,53 +85,18 @@ export class EntityRecordNameResolver extends ResolverBase {
|
|
|
91
85
|
const e = md.Entities.find((e) => e.Name === EntityName);
|
|
92
86
|
if (e) {
|
|
93
87
|
const contextUser = userPayload ? this.GetUserFromPayload(userPayload) : undefined;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
`[FieldSecurity] Suppressed GetEntityRecordName for '${EntityName}': ` +
|
|
97
|
-
`the name field is not readable by this user`
|
|
98
|
-
);
|
|
99
|
-
return {
|
|
100
|
-
Success: false,
|
|
101
|
-
Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
|
|
102
|
-
CompositeKey: pk,
|
|
103
|
-
EntityName,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
// Pass the acting user through: the provider signature accepts it, and omitting it ran the
|
|
107
|
-
// underlying lookup with no identity at all — so row-level security had nothing to filter on
|
|
108
|
-
// either. The field-level gate above is the column half; this is the row half.
|
|
109
|
-
const recordName = await md.GetEntityRecordName(e.Name, pk, contextUser);
|
|
88
|
+
// The acting user is what field- and row-level security are applied for.
|
|
89
|
+
const recordName = contextUser ? await md.GetEntityRecordName(e.Name, pk, contextUser) : '';
|
|
110
90
|
if (recordName) return { Success: true, Status: 'OK', CompositeKey: pk, RecordName: recordName, EntityName };
|
|
111
91
|
else
|
|
112
92
|
return {
|
|
113
93
|
Success: false,
|
|
114
|
-
Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue
|
|
94
|
+
Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
|
|
115
95
|
CompositeKey: pk,
|
|
116
96
|
EntityName,
|
|
117
97
|
};
|
|
118
98
|
} else return { Success: false, Status: `Entity ${EntityName} not found`, CompositeKey: pk, EntityName };
|
|
119
99
|
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Whether field security withholds this entity's NAME field from the user.
|
|
123
|
-
*
|
|
124
|
-
* Resolves the name field the same way the provider's own lookup does — the designated
|
|
125
|
-
* `IsNameField`, else a field literally called `Name` — so the check cannot drift from the
|
|
126
|
-
* value it is guarding. Fails OPEN only where field security does not apply at all (flag off,
|
|
127
|
-
* no user, no name field), matching every other FLS gate.
|
|
128
|
-
*/
|
|
129
|
-
protected IsNameFieldDeniedToUser(entity: EntityInfo, contextUser?: UserInfo): boolean {
|
|
130
|
-
if (!entity?.EnableFieldLevelSecurity || !contextUser) {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
const nameField =
|
|
134
|
-
entity.Fields?.find(f => f.IsNameField) ??
|
|
135
|
-
entity.Fields?.find(f => f.Name?.trim().toLowerCase() === 'name');
|
|
136
|
-
if (!nameField) {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
return entity.GetDeniedReadFields(contextUser).has(nameField.Name.trim().toLowerCase());
|
|
140
|
-
}
|
|
141
100
|
}
|
|
142
101
|
|
|
143
102
|
export default EntityRecordNameResolver;
|
|
@@ -5,7 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
|
|
|
5
5
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
6
|
import { GetAIAPIKey } from '@memberjunction/ai';
|
|
7
7
|
import { VectorDBBase } from '@memberjunction/ai-vectordb';
|
|
8
|
-
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
8
|
+
import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
9
9
|
|
|
10
10
|
/* ───── GraphQL types ───── */
|
|
11
11
|
|
|
@@ -144,7 +144,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
|
|
|
144
144
|
const rv = new RunView();
|
|
145
145
|
const result = await rv.RunView<MJEntityDocumentEntity>({
|
|
146
146
|
EntityName: 'MJ: Entity Documents',
|
|
147
|
-
ExtraFilter: `ID='${entityDocumentID}'`,
|
|
147
|
+
ExtraFilter: `ID='${EscapeSQLString(entityDocumentID)}'`,
|
|
148
148
|
ResultType: 'entity_object',
|
|
149
149
|
}, contextUser);
|
|
150
150
|
|