@memberjunction/server 5.37.0 → 5.38.0
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/README.md +12 -12
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +16 -12
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +97 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +553 -13
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -14
- package/dist/index.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +5 -4
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +6 -18
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +6 -59
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/package.json +70 -70
- package/src/__tests__/resolverBase.rls.test.ts +224 -0
- package/src/agents/skip-sdk.ts +16 -13
- package/src/generated/generated.ts +388 -14
- package/src/index.ts +62 -17
- package/src/resolvers/QueryResolver.ts +7 -6
- package/src/resolvers/QuerySystemUserResolver.ts +11 -25
- package/src/resolvers/RunAIAgentResolver.ts +6 -66
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ResolverBase.getRowLevelSecurityWhereClause.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the resolver's RLS path delegates correctly to the centralized
|
|
5
|
+
* exemption check in EntityInfo.GetUserRowLevelSecurityWhereClause. This was
|
|
6
|
+
* the code path that caused the bug: single-record GraphQL resolvers applied
|
|
7
|
+
* RLS filters even when the user held a role that exempted them.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// ─── Hoisted mocks ────────────────────────────────────────────────────────
|
|
12
|
+
const { mockUserCacheUsers } = vi.hoisted(() => ({
|
|
13
|
+
mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Stub external deps before imports
|
|
17
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
|
|
18
|
+
SQLServerDataProvider: class {},
|
|
19
|
+
UserCache: {
|
|
20
|
+
get Users() { return mockUserCacheUsers; },
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('cloudevents', () => ({
|
|
25
|
+
CloudEvent: class {},
|
|
26
|
+
httpTransport: () => () => undefined,
|
|
27
|
+
emitterFor: () => () => undefined,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('type-graphql', () => ({
|
|
31
|
+
Resolver: () => () => undefined,
|
|
32
|
+
Mutation: () => () => undefined,
|
|
33
|
+
Query: () => () => undefined,
|
|
34
|
+
Subscription: () => () => undefined,
|
|
35
|
+
Ctx: () => () => undefined,
|
|
36
|
+
Arg: () => () => undefined,
|
|
37
|
+
PubSub: () => () => undefined,
|
|
38
|
+
Root: () => () => undefined,
|
|
39
|
+
ObjectType: () => () => undefined,
|
|
40
|
+
InputType: () => () => undefined,
|
|
41
|
+
Field: () => () => undefined,
|
|
42
|
+
FieldResolver: () => () => undefined,
|
|
43
|
+
Int: () => undefined,
|
|
44
|
+
Float: () => undefined,
|
|
45
|
+
registerEnumType: () => undefined,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock('graphql', () => ({
|
|
49
|
+
GraphQLError: class extends Error {
|
|
50
|
+
constructor(msg: string) { super(msg); }
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('mssql', () => ({}));
|
|
55
|
+
|
|
56
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
57
|
+
GetAPIKeyEngine: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('@memberjunction/encryption', () => ({
|
|
61
|
+
EncryptionEngine: { Instance: {} },
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock('@memberjunction/graphql-dataprovider', () => ({
|
|
65
|
+
FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock('../generic/PubSubManager.js', () => ({
|
|
69
|
+
PubSubManager: class { static Instance = { publish: vi.fn() }; },
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock('../generic/PushStatusResolver.js', () => ({
|
|
73
|
+
PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
|
|
74
|
+
PushStatusNotification: class {},
|
|
75
|
+
PushStatusResolver: class {},
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock('../generic/CacheInvalidationResolver.js', () => ({
|
|
79
|
+
CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock('../generic/RunViewResolver.js', () => ({
|
|
83
|
+
RunViewByIDInput: class {},
|
|
84
|
+
RunViewByNameInput: class {},
|
|
85
|
+
RunDynamicViewInput: class {},
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
vi.mock('../generic/DeleteOptionsInput.js', () => ({
|
|
89
|
+
DeleteOptionsInput: class {},
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
vi.mock('../types.js', () => ({
|
|
93
|
+
RunViewGenericParams: class {},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
vi.mock('@memberjunction/core', async () => {
|
|
97
|
+
const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
|
|
98
|
+
return {
|
|
99
|
+
...actual,
|
|
100
|
+
LogError: vi.fn(),
|
|
101
|
+
LogStatus: vi.fn(),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
vi.mock('@memberjunction/core-entities', () => ({}));
|
|
106
|
+
|
|
107
|
+
// ─── Import after mocks ──────────────────────────────────────────────────
|
|
108
|
+
import { ResolverBase } from '../generic/ResolverBase';
|
|
109
|
+
import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction/core';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subclass that exposes the protected getRowLevelSecurityWhereClause for testing.
|
|
113
|
+
*/
|
|
114
|
+
class TestResolver extends ResolverBase {
|
|
115
|
+
public TestGetRLSWhereClause(
|
|
116
|
+
provider: DatabaseProviderBase,
|
|
117
|
+
entityName: string,
|
|
118
|
+
userPayload: { email: string },
|
|
119
|
+
type: string,
|
|
120
|
+
returnPrefix: string,
|
|
121
|
+
): string {
|
|
122
|
+
return (this as unknown as {
|
|
123
|
+
getRowLevelSecurityWhereClause: (
|
|
124
|
+
p: DatabaseProviderBase, e: string, u: { email: string }, t: string, r: string,
|
|
125
|
+
) => string;
|
|
126
|
+
}).getRowLevelSecurityWhereClause(provider, entityName, userPayload, type, returnPrefix);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function makeEntity(name: string, opts: {
|
|
133
|
+
exempt: boolean;
|
|
134
|
+
rlsClause: string;
|
|
135
|
+
}): EntityInfo {
|
|
136
|
+
return {
|
|
137
|
+
Name: name,
|
|
138
|
+
UserExemptFromRowLevelSecurity: () => opts.exempt,
|
|
139
|
+
GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
|
|
140
|
+
} as unknown as EntityInfo;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeProvider(entities: EntityInfo[]): DatabaseProviderBase {
|
|
144
|
+
return {
|
|
145
|
+
Entities: entities,
|
|
146
|
+
} as unknown as DatabaseProviderBase;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('ResolverBase.getRowLevelSecurityWhereClause', () => {
|
|
152
|
+
let resolver: TestResolver;
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
resolver = new TestResolver();
|
|
156
|
+
mockUserCacheUsers.length = 0;
|
|
157
|
+
mockUserCacheUsers.push({
|
|
158
|
+
Email: 'test@example.com',
|
|
159
|
+
ID: 'user-1',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns empty string when user is exempt from RLS', () => {
|
|
164
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: "OwnerID = 'user-1'" });
|
|
165
|
+
const provider = makeProvider([entity]);
|
|
166
|
+
|
|
167
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
168
|
+
provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(result).toBe('');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns RLS clause when user is NOT exempt', () => {
|
|
175
|
+
const entity = makeEntity('Test Entity', { exempt: false, rlsClause: "AND OwnerID = 'user-1'" });
|
|
176
|
+
const provider = makeProvider([entity]);
|
|
177
|
+
|
|
178
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
179
|
+
provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result).toContain('OwnerID');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('throws when entity not found', () => {
|
|
186
|
+
const provider = makeProvider([]);
|
|
187
|
+
|
|
188
|
+
expect(() => resolver.TestGetRLSWhereClause(
|
|
189
|
+
provider, 'Nonexistent Entity', { email: 'test@example.com' }, 'Read', 'AND',
|
|
190
|
+
)).toThrow('Entity Nonexistent Entity not found');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('throws when user not found in UserCache', () => {
|
|
194
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
|
|
195
|
+
const provider = makeProvider([entity]);
|
|
196
|
+
mockUserCacheUsers.length = 0; // Clear cache
|
|
197
|
+
|
|
198
|
+
expect(() => resolver.TestGetRLSWhereClause(
|
|
199
|
+
provider, 'Test Entity', { email: 'unknown@example.com' }, 'Read', 'AND',
|
|
200
|
+
)).toThrow('User unknown@example.com not found');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('matches entity name case-insensitively', () => {
|
|
204
|
+
const entity = makeEntity('MJ: AI Prompt Runs', { exempt: true, rlsClause: '' });
|
|
205
|
+
const provider = makeProvider([entity]);
|
|
206
|
+
|
|
207
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
208
|
+
provider, 'mj: ai prompt runs', { email: 'test@example.com' }, 'Read', 'AND',
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(result).toBe('');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('matches user email case-insensitively', () => {
|
|
215
|
+
const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
|
|
216
|
+
const provider = makeProvider([entity]);
|
|
217
|
+
|
|
218
|
+
const result = resolver.TestGetRLSWhereClause(
|
|
219
|
+
provider, 'Test Entity', { email: 'TEST@EXAMPLE.COM' }, 'Read', 'AND',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(result).toBe('');
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/agents/skip-sdk.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from '@memberjunction/skip-types';
|
|
26
26
|
import { DataContext } from '@memberjunction/data-context';
|
|
27
27
|
import { IMetadataProvider, UserInfo, LogStatus, LogError, Metadata, RunQuery, RunView, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
|
|
28
|
-
import { MJConversationDetailEntity } from '@memberjunction/core-entities';
|
|
28
|
+
import { MJConversationDetailEntity, QueryEngine } from '@memberjunction/core-entities';
|
|
29
29
|
import { request as httpRequest } from 'http';
|
|
30
30
|
import { request as httpsRequest } from 'https';
|
|
31
31
|
import { gzip as gzipCompress, createGunzip } from 'zlib';
|
|
@@ -483,15 +483,15 @@ export class SkipSDK {
|
|
|
483
483
|
* Build saved queries for Skip
|
|
484
484
|
*/
|
|
485
485
|
private buildQueries(status: "Pending" | "In-Review" | "Approved" | "Rejected" | "Obsolete" = 'Approved'): SkipQueryInfo[] {
|
|
486
|
-
const
|
|
487
|
-
const approvedQueries =
|
|
486
|
+
const qe = QueryEngine.Instance;
|
|
487
|
+
const approvedQueries = qe.Queries.filter((q) => q.Status === status);
|
|
488
488
|
|
|
489
489
|
return approvedQueries.map((q) => ({
|
|
490
490
|
ID: q.ID,
|
|
491
491
|
Name: q.Name,
|
|
492
492
|
Description: q.Description,
|
|
493
493
|
Category: q.Category,
|
|
494
|
-
CategoryPath: this.buildQueryCategoryPath(
|
|
494
|
+
CategoryPath: this.buildQueryCategoryPath(qe, q.CategoryID),
|
|
495
495
|
CategoryID: q.CategoryID,
|
|
496
496
|
SQL: q.SQL,
|
|
497
497
|
Status: q.Status,
|
|
@@ -501,7 +501,7 @@ export class SkipSDK {
|
|
|
501
501
|
EmbeddingModelID: q.EmbeddingModelID,
|
|
502
502
|
EmbeddingModelName: q.EmbeddingModel,
|
|
503
503
|
TechnicalDescription: q.TechnicalDescription,
|
|
504
|
-
Fields: q.
|
|
504
|
+
Fields: qe.GetQueryFields(q.ID).map((f) => ({
|
|
505
505
|
ID: f.ID,
|
|
506
506
|
QueryID: f.QueryID,
|
|
507
507
|
Name: f.Name,
|
|
@@ -517,7 +517,7 @@ export class SkipSDK {
|
|
|
517
517
|
IsSummary: f.IsSummary,
|
|
518
518
|
SummaryDescription: f.SummaryDescription
|
|
519
519
|
})),
|
|
520
|
-
Parameters: q.
|
|
520
|
+
Parameters: qe.GetQueryParameters(q.ID).map((p) => ({
|
|
521
521
|
ID: p.ID,
|
|
522
522
|
QueryID: p.QueryID,
|
|
523
523
|
Name: p.Name,
|
|
@@ -528,12 +528,15 @@ export class SkipSDK {
|
|
|
528
528
|
SampleValue: p.SampleValue,
|
|
529
529
|
ValidationFilters: p.ValidationFilters
|
|
530
530
|
})),
|
|
531
|
-
Entities: q.
|
|
531
|
+
Entities: qe.QueryEntities.filter(e => UUIDsEqual(e.QueryID, q.ID)).map((e) => ({
|
|
532
532
|
ID: e.ID,
|
|
533
533
|
QueryID: e.QueryID,
|
|
534
534
|
EntityID: e.EntityID,
|
|
535
535
|
Entity: e.Entity
|
|
536
536
|
})),
|
|
537
|
+
SQLDialectID: q.SQLDialectID,
|
|
538
|
+
UsesTemplate: q.UsesTemplate,
|
|
539
|
+
IsApproved: q.IsApproved,
|
|
537
540
|
CacheEnabled: q.CacheEnabled,
|
|
538
541
|
CacheMaxSize: q.CacheMaxSize,
|
|
539
542
|
CacheTTLMinutes: q.CacheTTLMinutes,
|
|
@@ -544,11 +547,11 @@ export class SkipSDK {
|
|
|
544
547
|
/**
|
|
545
548
|
* Recursively build category path for a query
|
|
546
549
|
*/
|
|
547
|
-
private buildQueryCategoryPath(
|
|
548
|
-
const cat =
|
|
550
|
+
private buildQueryCategoryPath(qe: QueryEngine, categoryID: string): string {
|
|
551
|
+
const cat = qe.Categories.find((c) => UUIDsEqual(c.ID, categoryID));
|
|
549
552
|
if (!cat) return '';
|
|
550
553
|
if (!cat.ParentID) return cat.Name;
|
|
551
|
-
const parentPath = this.buildQueryCategoryPath(
|
|
554
|
+
const parentPath = this.buildQueryCategoryPath(qe, cat.ParentID);
|
|
552
555
|
return parentPath ? `${parentPath}/${cat.Name}` : cat.Name;
|
|
553
556
|
}
|
|
554
557
|
|
|
@@ -560,11 +563,11 @@ export class SkipSDK {
|
|
|
560
563
|
* across all queries, not just approved ones.
|
|
561
564
|
*/
|
|
562
565
|
private buildQueryCatalog(): SkipQueryCatalogEntry[] {
|
|
563
|
-
const
|
|
566
|
+
const qe = QueryEngine.Instance;
|
|
564
567
|
|
|
565
|
-
return
|
|
568
|
+
return qe.Queries.map((q) => ({
|
|
566
569
|
Name: q.Name,
|
|
567
|
-
CategoryPath: this.buildQueryCategoryPath(
|
|
570
|
+
CategoryPath: this.buildQueryCategoryPath(qe, q.CategoryID)
|
|
568
571
|
}));
|
|
569
572
|
}
|
|
570
573
|
|