Skip to content

Commit ce1577e

Browse files
committed
fix(V2): enforce ownership for staged mutations
Guard V2 UPDATE and DELETE staging paths at the staging model boundary so records owned by another organization cannot be modified or retargeted.
1 parent 1edb9df commit ce1577e

11 files changed

Lines changed: 622 additions & 11 deletions

docs/cadt_rpc_api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ The CADT RPC API is exposed by default on port 31310. This document will give ex
2727

2828
If using a `CADT_API_KEY` append `--header 'x-api-key: <your-api-key-here>'` to your `curl` request.
2929

30+
## V2 Ownership Note
31+
32+
For the V2 API, update and delete requests can only stage mutations for records owned by the home organization. See the V2 API guide for details on `orgUid` ownership and child-record ownership resolution.
33+
3034
## Commands
3135

3236
- [`organizations`](#organizations)

docs/cadt_rpc_api_v2.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ For tables with a direct `orgUid` column (project, unit, methodology, program, s
4646

4747
For child tables (location, estimation, rating, co_benefit, validation, verification, project_methodology, stakeholder_projects, unit_label, issuance, aef_t2-t5), this filters by the parent project's or unit's `orgUid` through an automatic JOIN.
4848

49+
### Ownership Restrictions
50+
51+
V2 `PUT` and `DELETE` requests can only stage mutations for records owned by the home organization. For tables with a direct `orgUid` column, the record's `orgUid` must match the home organization. For child and relationship tables, ownership is resolved through the referenced owner records, such as project, unit, program, methodology, label, stakeholder, and AEF parent records.
52+
53+
Requests that attempt to update, delete, or retarget a staged mutation to another organization's record are rejected with a `Restricted data` error.
54+
4955
### Pagination
5056

5157
All GET list endpoints require `page` and `limit` query parameters to prevent unbounded response sizes.

src/models/v2/staging-v2.model.js

Lines changed: 252 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
createXlsFromSequelizeResults,
1717
transformFullXslsToChangeList,
1818
} from '../../utils/xls.js';
19-
import { formatModelAssociationName } from '../../utils/model-utils.js';
2019
import { getV2PrimaryKeyField } from '../../utils/v2-primary-key-utils.js';
2120

2221
import ModelTypes from './staging-v2.modeltypes.js';
@@ -52,6 +51,7 @@ class StagingV2 extends Model {
5251
static changes = new rxjs.Subject();
5352

5453
static async create(values, options) {
54+
await StagingV2.assertMutationOwnedByHomeOrg(values, options);
5555
StagingV2.changes.next(['staging']);
5656
const result = await super.create(values, options);
5757

@@ -64,6 +64,9 @@ class StagingV2 extends Model {
6464
}
6565

6666
static async bulkCreate(values, options) {
67+
for (const value of values) {
68+
await StagingV2.assertMutationOwnedByHomeOrg(value, options);
69+
}
6770
StagingV2.changes.next(['staging']);
6871
const result = await super.bulkCreate(values, options);
6972

@@ -84,6 +87,7 @@ class StagingV2 extends Model {
8487
}
8588

8689
static async upsert(values, options) {
90+
await StagingV2.assertMutationOwnedByHomeOrg(values, options);
8791
StagingV2.changes.next(['staging']);
8892
const result = await super.upsert(values, options);
8993

@@ -94,6 +98,7 @@ class StagingV2 extends Model {
9498
}
9599

96100
static async update(values, options) {
101+
await StagingV2.assertUpdatedMutationOwnedByHomeOrg(values, options);
97102
const result = await super.update(values, options);
98103

99104
// Small delay for WAL visibility
@@ -102,6 +107,249 @@ class StagingV2 extends Model {
102107
return result;
103108
}
104109

110+
static async assertUpdatedMutationOwnedByHomeOrg(values, options) {
111+
const canRetargetMutation =
112+
Object.prototype.hasOwnProperty.call(values ?? {}, 'data') ||
113+
Object.prototype.hasOwnProperty.call(values ?? {}, 'table') ||
114+
Object.prototype.hasOwnProperty.call(values ?? {}, 'action') ||
115+
Object.prototype.hasOwnProperty.call(values ?? {}, 'is_transfer');
116+
117+
if (!canRetargetMutation || !options?.where) return;
118+
119+
const stagingRecords = await StagingV2.findAll({
120+
where: options.where,
121+
transaction: options?.transaction,
122+
});
123+
124+
for (const stagingRecord of stagingRecords) {
125+
await StagingV2.assertMutationOwnedByHomeOrg({
126+
uuid: values.uuid ?? stagingRecord.uuid,
127+
table: values.table ?? stagingRecord.table,
128+
action: values.action ?? stagingRecord.action,
129+
data: values.data ?? stagingRecord.data,
130+
is_transfer: values.is_transfer ?? stagingRecord.is_transfer,
131+
}, options);
132+
}
133+
}
134+
135+
static getMutationGuardModelMap() {
136+
return {
137+
program: ProgramV2,
138+
methodology: MethodologyV2,
139+
project: ProjectV2,
140+
validation: ValidationV2,
141+
verification: VerificationV2,
142+
issuance: IssuanceV2,
143+
unit: UnitV2,
144+
location: LocationV2,
145+
estimation: EstimationV2,
146+
rating: RatingV2,
147+
co_benefit: CoBenefitV2,
148+
project_methodology: ProjectMethodologyV2,
149+
stakeholder: StakeholderV2,
150+
stakeholder_projects: StakeholderProjectV2,
151+
label: LabelV2,
152+
unit_label: UnitLabelV2,
153+
aef_t1_submission: AefT1SubmissionV2,
154+
aef_t5_authorized_entities: AefT5AuthorizedEntitiesV2,
155+
aef_t2_authorizations: AefT2AuthorizationsV2,
156+
aef_t3_actions: AefT3ActionsV2,
157+
aef_t4_holdings: AefT4HoldingsV2,
158+
};
159+
}
160+
161+
static getOwnershipParentModels() {
162+
return [
163+
['cadTrustProjectId', ProjectV2],
164+
['cadTrustUnitId', UnitV2],
165+
['cadTrustProgramId', ProgramV2],
166+
['cadTrustMethodologyId', MethodologyV2],
167+
['cadTrustValidationId', ValidationV2],
168+
['cadTrustVerificationId', VerificationV2],
169+
['cadTrustIssuanceId', IssuanceV2],
170+
['cadTrustLocationId', LocationV2],
171+
['cadTrustStakeholderId', StakeholderV2],
172+
['cadTrustLabelId', LabelV2],
173+
['cadTrustProjectMethodologyId', ProjectMethodologyV2],
174+
['cadTrustAefT1SubmissionId', AefT1SubmissionV2],
175+
['cadTrustAefT5AuthorizedEntitiesId', AefT5AuthorizedEntitiesV2],
176+
['cadTrustAefT2AuthorizationsId', AefT2AuthorizationsV2],
177+
];
178+
}
179+
180+
static getRecordField(record, fieldName) {
181+
const plainRecord = typeof record?.get === 'function'
182+
? record.get({ plain: true })
183+
: record?.dataValues ?? record;
184+
185+
return plainRecord?.[fieldName] ?? plainRecord?.[_.snakeCase(fieldName)];
186+
}
187+
188+
static hasOwnershipFields(record, table) {
189+
const primaryKeyField = getV2PrimaryKeyField(table);
190+
const primaryKeyApiField = primaryKeyField?.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
191+
192+
return Boolean(
193+
StagingV2.getRecordField(record, 'orgUid') ||
194+
StagingV2.getOwnershipParentModels()
195+
.filter(([fieldName]) => fieldName !== primaryKeyApiField)
196+
.some(([fieldName]) => (
197+
StagingV2.getRecordField(record, fieldName) !== undefined
198+
)),
199+
);
200+
}
201+
202+
static async collectOwnerOrgUids(
203+
record,
204+
options = {},
205+
visited = new Set(),
206+
depth = 0,
207+
includeParentsForDirectOwner = false,
208+
unresolvedFields = [],
209+
excludedFields = new Set(),
210+
) {
211+
if (!record || depth > 10) return [];
212+
213+
const orgUid = StagingV2.getRecordField(record, 'orgUid');
214+
if (orgUid && !includeParentsForDirectOwner) return [orgUid];
215+
216+
const ownerOrgUids = orgUid ? [orgUid] : [];
217+
for (const [fieldName, ModelClass] of StagingV2.getOwnershipParentModels()) {
218+
if (excludedFields.has(fieldName)) continue;
219+
220+
const parentId = StagingV2.getRecordField(record, fieldName);
221+
if (!parentId) continue;
222+
223+
const visitedKey = `${ModelClass.name}:${parentId}`;
224+
if (visited.has(visitedKey)) continue;
225+
visited.add(visitedKey);
226+
227+
const parentRecord = await ModelClass.findByPk(parentId, {
228+
transaction: options?.transaction,
229+
});
230+
if (!parentRecord) {
231+
unresolvedFields.push(fieldName);
232+
continue;
233+
}
234+
235+
ownerOrgUids.push(
236+
...(await StagingV2.collectOwnerOrgUids(
237+
parentRecord,
238+
options,
239+
visited,
240+
depth + 1,
241+
includeParentsForDirectOwner,
242+
unresolvedFields,
243+
new Set(),
244+
)),
245+
);
246+
}
247+
248+
return [...new Set(ownerOrgUids)];
249+
}
250+
251+
static async assertOwnerOrgUidsAreHome(
252+
ownerOrgUids,
253+
table,
254+
requireOwner = false,
255+
unresolvedFields = [],
256+
) {
257+
if (unresolvedFields.length > 0) {
258+
throw new Error(
259+
`Restricted data: cannot determine the owner of this ${table} record from ${unresolvedFields.join(', ')}. Only the home organization can modify this record.`,
260+
);
261+
}
262+
263+
if (ownerOrgUids.length === 0) {
264+
if (!requireOwner) return;
265+
266+
throw new Error(
267+
`Restricted data: cannot determine the owner of this ${table} record. Only the home organization can modify this record.`,
268+
);
269+
}
270+
271+
const homeOrg = await OrganizationsV2.findOne({
272+
where: { is_home: true },
273+
raw: true,
274+
});
275+
const nonHomeOrgUid = ownerOrgUids.find((orgUid) => orgUid !== homeOrg?.org_uid);
276+
277+
if (!homeOrg || nonHomeOrgUid) {
278+
throw new Error(
279+
`Restricted data: cannot modify this ${table} record with orgUid '${nonHomeOrgUid}'. Only the home organization can modify this record.`,
280+
);
281+
}
282+
}
283+
284+
static async assertMutationOwnedByHomeOrg(values, options) {
285+
if (!['UPDATE', 'DELETE'].includes(values?.action) || values?.is_transfer) {
286+
return;
287+
}
288+
289+
const ModelClass = StagingV2.getMutationGuardModelMap()[values.table];
290+
if (!ModelClass) return;
291+
292+
const primaryKeyField = getV2PrimaryKeyField(values.table);
293+
if (!primaryKeyField) return;
294+
const primaryKeyApiField = primaryKeyField.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
295+
296+
const parsedData = Array.isArray(values.data)
297+
? values.data
298+
: JSON.parse(values.data || '[]');
299+
const records = Array.isArray(parsedData) ? parsedData : [parsedData];
300+
301+
for (const recordData of records) {
302+
const primaryKeyValue =
303+
recordData[primaryKeyField] ??
304+
recordData[primaryKeyApiField] ??
305+
values.uuid;
306+
307+
const existingRecord = primaryKeyValue
308+
? await ModelClass.findByPk(primaryKeyValue, {
309+
transaction: options?.transaction,
310+
})
311+
: null;
312+
313+
if (existingRecord) {
314+
await StagingV2.assertOwnerOrgUidsAreHome(
315+
await StagingV2.collectOwnerOrgUids(existingRecord, options),
316+
values.table,
317+
true,
318+
);
319+
} else if (values.action !== 'UPDATE') {
320+
continue;
321+
}
322+
323+
if (values.action === 'UPDATE') {
324+
const unresolvedFields = [];
325+
const payloadHasOwnershipFields = StagingV2.hasOwnershipFields(
326+
recordData,
327+
values.table,
328+
);
329+
if (!existingRecord && !payloadHasOwnershipFields) {
330+
throw new Error(
331+
`Restricted data: cannot determine the owner of this ${values.table} record. Only the home organization can modify this record.`,
332+
);
333+
}
334+
335+
await StagingV2.assertOwnerOrgUidsAreHome(
336+
await StagingV2.collectOwnerOrgUids(
337+
recordData,
338+
options,
339+
new Set(),
340+
0,
341+
true,
342+
unresolvedFields,
343+
new Set([primaryKeyApiField]),
344+
),
345+
values.table,
346+
payloadHasOwnershipFields,
347+
unresolvedFields,
348+
);
349+
}
350+
}
351+
}
352+
105353
/**
106354
* Separates staging data into action groups (INSERT, UPDATE, DELETE)
107355
*
@@ -316,7 +564,7 @@ class StagingV2 extends Model {
316564
// Fetch original record if model mapping exists
317565
const modelInfo = tableToModelMap[table];
318566
if (modelInfo) {
319-
const [ModelClass, primaryKeyField, hasAssociations] = modelInfo;
567+
const [ModelClass, primaryKeyField] = modelInfo;
320568
let original;
321569

322570
try {
@@ -364,6 +612,7 @@ class StagingV2 extends Model {
364612
static async pushToDataLayer(tableToPush, comment, author, ids = []) {
365613
const commitStartTime = Date.now();
366614
const memoryBefore = process.memoryUsage();
615+
let stagedRecords = [];
367616
const monitor = {
368617
rpcCount: 0,
369618
modelTimings: {},
@@ -391,7 +640,7 @@ class StagingV2 extends Model {
391640
...(ids && Array.isArray(ids) && ids.length > 0 ? { uuid: { [Op.in]: ids } } : {}),
392641
};
393642

394-
const stagedRecords = await StagingV2.findAll({
643+
stagedRecords = await StagingV2.findAll({
395644
where: whereClause,
396645
raw: true,
397646
});

tests/v2/integration/aef-t1-submission-v2.spec.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import app from '../../../src/server.js';
44
import { prepareV2Db } from '../../../src/database/v2/index.js';
55
import { AefT1SubmissionV2, StagingV2 } from '../../../src/models/v2/index.js';
66
import { v4 as uuidv4 } from 'uuid';
7-
import { createV2TestHomeOrg } from '../utils/v2-test-helpers.js';
7+
import { createV2TestHomeOrg, getV2HomeOrgId } from '../utils/v2-test-helpers.js';
88

99
describe('AEF-T1-Submission V2 Integration Tests', function () {
1010
this.timeout(300000); // 5 minute timeout for comprehensive tests
@@ -548,12 +548,14 @@ describe('AEF-T1-Submission V2 Integration Tests', function () {
548548
}
549549
if (stagingRecord) {
550550
await stagingRecord.update({ committed: true });
551+
const homeOrgId = await getV2HomeOrgId();
551552
await AefT1SubmissionV2.create({
552553
cadTrustAefT1SubmissionId: createdAefT1SubmissionId,
553554
aefT1SubmissionParty: 'AEF-T1 to Update',
554555
aefT1SubmissionVersion: '1.0',
555556
aefT1SubmissionReportYear: 2024,
556557
aefT1SubmissionSubmissionDate: '2024-01-15',
558+
orgUid: homeOrgId,
557559
});
558560
// Clean up committed staging record to avoid pending commits errors
559561
await stagingRecord.destroy();
@@ -622,12 +624,14 @@ describe('AEF-T1-Submission V2 Integration Tests', function () {
622624
}
623625
if (stagingRecord) {
624626
await stagingRecord.update({ committed: true });
627+
const homeOrgId = await getV2HomeOrgId();
625628
await AefT1SubmissionV2.create({
626629
cadTrustAefT1SubmissionId: createdAefT1SubmissionId,
627630
aefT1SubmissionParty: 'AEF-T1 to Delete',
628631
aefT1SubmissionVersion: '1.0',
629632
aefT1SubmissionReportYear: 2024,
630633
aefT1SubmissionSubmissionDate: '2024-01-15',
634+
orgUid: homeOrgId,
631635
});
632636
// Clean up committed staging record to avoid pending commits errors
633637
await stagingRecord.destroy();

0 commit comments

Comments
 (0)