Skip to content

Commit e2766d9

Browse files
NikAiyerMastra Code (anthropic/claude-opus-4-7)
andcommitted
fix(core,server): close child-ID cross-dataset leak on Dataset handle
Address CodeRabbit round-2 review on MASTRA-4438: - Dataset.getItem / getItemHistory / getExperiment / listExperimentResults now verify the returned child row's parent datasetId matches this.id before returning. Prevents a caller with a valid handle to dataset A from reading item/experiment rows belonging to dataset B via a caller-supplied child id. - Dataset.updateExperimentResult / deleteExperiment throw NOT_FOUND on ownership mismatch, matching the existing deleteItem contract. - Dataset.listItems now forwards this.#scope via ListDatasetItemsInput.filters instead of an extra preflight roundtrip. - Server handler tests: assert HTTP 404 status on scope-mismatch (not just HTTPException) and add projectId-mismatch cases for update + delete routes. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
1 parent dd0dd10 commit e2766d9

3 files changed

Lines changed: 221 additions & 34 deletions

File tree

packages/core/src/datasets/__tests__/dataset.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,4 +567,81 @@ describe('Dataset', () => {
567567
expect('items' in (result as any)).toBe(true);
568568
expect((result as any).items.length).toBe(2);
569569
});
570+
571+
// ---------------------------------------------------------------------------
572+
// Ownership gates: reject child records that belong to a different dataset
573+
// even when the caller holds a valid handle to the current dataset.
574+
// ---------------------------------------------------------------------------
575+
576+
it('getItem returns null when the item belongs to a different dataset', async () => {
577+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
578+
const otherItem = await datasetsStorage.addItem({ datasetId: other.id, input: { x: 1 } });
579+
const leaked = await ds.getItem({ itemId: otherItem.id });
580+
expect(leaked).toBeNull();
581+
});
582+
583+
it('getItemHistory filters out rows that belong to a different dataset', async () => {
584+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
585+
const otherItem = await datasetsStorage.addItem({ datasetId: other.id, input: { x: 1 } });
586+
const rows = await ds.getItemHistory({ itemId: otherItem.id });
587+
expect(rows).toEqual([]);
588+
});
589+
590+
it('getExperiment returns null when the experiment belongs to a different dataset', async () => {
591+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
592+
const otherExp = await experimentsStorage.createExperiment({
593+
datasetId: other.id,
594+
datasetVersion: 0,
595+
targetType: 'agent',
596+
targetId: 'inline',
597+
totalItems: 0,
598+
});
599+
const leaked = await ds.getExperiment({ experimentId: otherExp.id });
600+
expect(leaked).toBeNull();
601+
});
602+
603+
it('deleteExperiment throws NOT_FOUND when the experiment belongs to a different dataset', async () => {
604+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
605+
const otherExp = await experimentsStorage.createExperiment({
606+
datasetId: other.id,
607+
datasetVersion: 0,
608+
targetType: 'agent',
609+
targetId: 'inline',
610+
totalItems: 0,
611+
});
612+
await expect(ds.deleteExperiment({ experimentId: otherExp.id })).rejects.toThrow(MastraError);
613+
// Original experiment should still exist under its true dataset
614+
const stillThere = await experimentsStorage.getExperimentById({ id: otherExp.id });
615+
expect(stillThere?.id).toBe(otherExp.id);
616+
});
617+
618+
it('listExperimentResults throws NOT_FOUND when experiment belongs to a different dataset', async () => {
619+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
620+
const otherExp = await experimentsStorage.createExperiment({
621+
datasetId: other.id,
622+
datasetVersion: 0,
623+
targetType: 'agent',
624+
targetId: 'inline',
625+
totalItems: 0,
626+
});
627+
await expect(ds.listExperimentResults({ experimentId: otherExp.id })).rejects.toThrow(MastraError);
628+
});
629+
630+
it('updateExperimentResult throws when experiment belongs to a different dataset', async () => {
631+
const other = await datasetsStorage.createDataset({ name: 'Other DS' });
632+
const otherExp = await experimentsStorage.createExperiment({
633+
datasetId: other.id,
634+
datasetVersion: 0,
635+
targetType: 'agent',
636+
targetId: 'inline',
637+
totalItems: 0,
638+
});
639+
await expect(
640+
ds.updateExperimentResult({ id: 'any-result-id', experimentId: otherExp.id, status: 'reviewed' }),
641+
).rejects.toThrow(MastraError);
642+
});
643+
644+
it('updateExperimentResult rejects when called without experimentId', async () => {
645+
await expect(ds.updateExperimentResult({ id: 'any-result-id', status: 'reviewed' })).rejects.toThrow(MastraError);
646+
});
570647
});

packages/core/src/datasets/dataset.ts

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ export class Dataset {
123123
}
124124
}
125125

126+
/**
127+
* Ownership gate: verifies a child record's `datasetId` matches `this.id`.
128+
* Prevents a valid scoped handle from reading/mutating child records
129+
* (items, experiments, results) that live under a different dataset — even
130+
* one in the same tenant. Returns `null` when the record is missing or
131+
* belongs to a different dataset, so callers can either return null or
132+
* translate to NOT_FOUND depending on their contract.
133+
*/
134+
#ownsChild<T extends { datasetId?: string | null }>(record: T | null | undefined): T | null {
135+
if (!record) return null;
136+
if (record.datasetId !== this.id) return null;
137+
return record;
138+
}
139+
126140
// ---------------------------------------------------------------------------
127141
// Dataset metadata
128142
// ---------------------------------------------------------------------------
@@ -209,7 +223,8 @@ export class Dataset {
209223
async getItem(args: { itemId: string; version?: number }): Promise<DatasetItem | null> {
210224
await this.#assertScope();
211225
const store = await this.#getDatasetsStore();
212-
return store.getItemById({ id: args.itemId, datasetVersion: args.version });
226+
const item = await store.getItemById({ id: args.itemId, datasetVersion: args.version });
227+
return this.#ownsChild(item);
213228
}
214229

215230
/**
@@ -224,15 +239,17 @@ export class Dataset {
224239
| DatasetItem[]
225240
| { items: DatasetItem[]; pagination: { total: number; page: number; perPage: number | false; hasMore: boolean } }
226241
> {
227-
await this.#assertScope();
228242
const store = await this.#getDatasetsStore();
229243
if (args?.version) {
244+
// getItemsByVersion is keyed by datasetId — gate via scoped parent existence
245+
await this.#assertScope();
230246
return store.getItemsByVersion({ datasetId: this.id, version: args.version });
231247
}
232248
return store.listItems({
233249
datasetId: this.id,
234250
search: args?.search,
235251
pagination: { page: args?.page ?? 0, perPage: args?.perPage ?? 20 },
252+
filters: this.#scope,
236253
});
237254
}
238255

@@ -287,7 +304,11 @@ export class Dataset {
287304
async getItemHistory(args: { itemId: string }): Promise<DatasetItemRow[]> {
288305
await this.#assertScope();
289306
const store = await this.#getDatasetsStore();
290-
return store.getItemHistory(args.itemId);
307+
const rows = await store.getItemHistory(args.itemId);
308+
// Ownership gate: SCD-2 history is keyed only by item id — filter out any
309+
// rows that don't belong to this dataset so a known cross-dataset item id
310+
// cannot leak history through a valid scoped handle.
311+
return rows.filter(row => row.datasetId === this.id);
291312
}
292313

293314
// ---------------------------------------------------------------------------
@@ -391,41 +412,74 @@ export class Dataset {
391412
});
392413
}
393414

415+
/**
416+
* Verify the experiment belongs to this dataset (and, by extension, to the
417+
* handle's tenancy scope which was enforced when the handle was minted).
418+
* Throws NOT_FOUND on missing or cross-dataset experiments so cross-tenant
419+
* mutation via a valid scoped handle + a known foreign experimentId is
420+
* rejected.
421+
*/
422+
async #assertExperimentOwnership(experimentId: string): Promise<void> {
423+
const experimentsStore = await this.#getExperimentsStore();
424+
const experiment = await experimentsStore.getExperimentById({ id: experimentId });
425+
if (!experiment || experiment.datasetId !== this.id) {
426+
throw new MastraError({
427+
id: 'EXPERIMENT_NOT_FOUND',
428+
text: `Experiment not found: ${experimentId}`,
429+
domain: 'STORAGE',
430+
category: 'USER',
431+
});
432+
}
433+
}
434+
394435
/**
395436
* Get a specific experiment (run) by ID.
396437
*/
397438
async getExperiment(args: { experimentId: string }) {
398439
await this.#assertScope();
399440
const experimentsStore = await this.#getExperimentsStore();
400-
return experimentsStore.getExperimentById({ id: args.experimentId });
441+
const experiment = await experimentsStore.getExperimentById({ id: args.experimentId });
442+
if (!experiment || experiment.datasetId !== this.id) return null;
443+
return experiment;
401444
}
402445

403446
/**
404447
* List results for a specific experiment.
405448
*/
406449
async listExperimentResults(args: { experimentId: string; page?: number; perPage?: number }) {
407-
await this.#assertScope();
450+
await this.#assertExperimentOwnership(args.experimentId);
408451
const experimentsStore = await this.#getExperimentsStore();
409452
return experimentsStore.listExperimentResults({
410453
experimentId: args.experimentId,
411454
pagination: { page: args?.page ?? 0, perPage: args?.perPage ?? 20 },
412455
});
413456
}
414457

415-
/**
416-
* Delete an experiment (run) by ID.
417-
*/
418458
/**
419459
* Update an experiment result's status or tags.
420460
*/
421461
async updateExperimentResult(input: UpdateExperimentResultInput) {
422-
await this.#assertScope();
462+
// The result's parent experiment must belong to this dataset. If the
463+
// caller supplied `experimentId`, verify ownership on that; otherwise we
464+
// cannot bind the update to this dataset and must reject.
465+
if (!input.experimentId) {
466+
throw new MastraError({
467+
id: 'EXPERIMENT_RESULT_MISSING_EXPERIMENT_ID',
468+
text: 'updateExperimentResult requires experimentId when called via a Dataset handle',
469+
domain: 'STORAGE',
470+
category: 'USER',
471+
});
472+
}
473+
await this.#assertExperimentOwnership(input.experimentId);
423474
const experimentsStore = await this.#getExperimentsStore();
424475
return experimentsStore.updateExperimentResult(input);
425476
}
426477

478+
/**
479+
* Delete an experiment (run) by ID.
480+
*/
427481
async deleteExperiment(args: { experimentId: string }) {
428-
await this.#assertScope();
482+
await this.#assertExperimentOwnership(args.experimentId);
429483
const experimentsStore = await this.#getExperimentsStore();
430484
return experimentsStore.deleteExperiment({ id: args.experimentId });
431485
}

packages/server/src/server/handlers/datasets.test.ts

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,16 @@ describe('Datasets Handlers', () => {
112112
projectId: 'proj_1',
113113
});
114114

115-
await expect(
116-
GET_DATASET_ROUTE.handler({
117-
...createTestServerContext({ mastra }),
118-
datasetId: created.id,
119-
organizationId: 'org_b',
120-
} as any),
121-
).rejects.toThrow(HTTPException);
115+
const err = await GET_DATASET_ROUTE.handler({
116+
...createTestServerContext({ mastra }),
117+
datasetId: created.id,
118+
organizationId: 'org_b',
119+
} as any).then(
120+
() => null,
121+
(e: unknown) => e,
122+
);
123+
expect(err).toBeInstanceOf(HTTPException);
124+
expect((err as HTTPException).status).toBe(404);
122125
});
123126

124127
it('returns 404 when projectId does not match', async () => {
@@ -128,14 +131,17 @@ describe('Datasets Handlers', () => {
128131
projectId: 'proj_1',
129132
});
130133

131-
await expect(
132-
GET_DATASET_ROUTE.handler({
133-
...createTestServerContext({ mastra }),
134-
datasetId: created.id,
135-
organizationId: 'org_a',
136-
projectId: 'proj_2',
137-
} as any),
138-
).rejects.toThrow(HTTPException);
134+
const err = await GET_DATASET_ROUTE.handler({
135+
...createTestServerContext({ mastra }),
136+
datasetId: created.id,
137+
organizationId: 'org_a',
138+
projectId: 'proj_2',
139+
} as any).then(
140+
() => null,
141+
(e: unknown) => e,
142+
);
143+
expect(err).toBeInstanceOf(HTTPException);
144+
expect((err as HTTPException).status).toBe(404);
139145
});
140146
});
141147

@@ -158,27 +164,55 @@ describe('Datasets Handlers', () => {
158164
expect(result.name).toBe('After');
159165
});
160166

161-
it('rejects update when organizationId does not match', async () => {
167+
it('rejects update with 404 when organizationId does not match', async () => {
162168
const created = await mastra.datasets.create({
163169
name: 'Before',
164170
organizationId: 'org_a',
165171
projectId: 'proj_1',
166172
});
167173

168-
await expect(
169-
UPDATE_DATASET_ROUTE.handler({
170-
...createTestServerContext({ mastra }),
171-
datasetId: created.id,
172-
organizationId: 'org_b',
173-
name: 'After',
174-
} as any),
175-
).rejects.toThrow(HTTPException);
174+
const err = await UPDATE_DATASET_ROUTE.handler({
175+
...createTestServerContext({ mastra }),
176+
datasetId: created.id,
177+
organizationId: 'org_b',
178+
name: 'After',
179+
} as any).then(
180+
() => null,
181+
(e: unknown) => e,
182+
);
183+
expect(err).toBeInstanceOf(HTTPException);
184+
expect((err as HTTPException).status).toBe(404);
176185

177186
// dataset unchanged
178187
const untouched = await mastra.datasets.get({ id: created.id });
179188
const details = await untouched.getDetails();
180189
expect(details.name).toBe('Before');
181190
});
191+
192+
it('rejects update with 404 when projectId does not match', async () => {
193+
const created = await mastra.datasets.create({
194+
name: 'Before',
195+
organizationId: 'org_a',
196+
projectId: 'proj_1',
197+
});
198+
199+
const err = await UPDATE_DATASET_ROUTE.handler({
200+
...createTestServerContext({ mastra }),
201+
datasetId: created.id,
202+
organizationId: 'org_a',
203+
projectId: 'proj_2',
204+
name: 'After',
205+
} as any).then(
206+
() => null,
207+
(e: unknown) => e,
208+
);
209+
expect(err).toBeInstanceOf(HTTPException);
210+
expect((err as HTTPException).status).toBe(404);
211+
212+
const untouched = await mastra.datasets.get({ id: created.id });
213+
const details = await untouched.getDetails();
214+
expect(details.name).toBe('Before');
215+
});
182216
});
183217

184218
describe('DELETE_DATASET_ROUTE tenancy', () => {
@@ -226,6 +260,28 @@ describe('Datasets Handlers', () => {
226260
expect(details.id).toBe(created.id);
227261
expect(details.organizationId).toBe('org_a');
228262
});
263+
264+
it('silently no-ops delete when projectId does not match and dataset remains', async () => {
265+
const created = await mastra.datasets.create({
266+
name: 'Guarded',
267+
organizationId: 'org_a',
268+
projectId: 'proj_1',
269+
});
270+
271+
const result = (await DELETE_DATASET_ROUTE.handler({
272+
...createTestServerContext({ mastra }),
273+
datasetId: created.id,
274+
organizationId: 'org_a',
275+
projectId: 'proj_2',
276+
} as any)) as any;
277+
278+
expect(result.success).toBe(true);
279+
280+
const survivor = await mastra.datasets.get({ id: created.id });
281+
const details = await survivor.getDetails();
282+
expect(details.id).toBe(created.id);
283+
expect(details.projectId).toBe('proj_1');
284+
});
229285
});
230286

231287
describe('item tool mocks', () => {

0 commit comments

Comments
 (0)