Skip to content

Commit 3eefddf

Browse files
Merge pull request #9823 from kajalpareek-lab/compliance-downgrade
block COMPLIANCE to GOVERNANCE retention mode downgrade
2 parents 67d53e8 + 5475cc6 commit 3eefddf

3 files changed

Lines changed: 259 additions & 24 deletions

File tree

src/server/object_services/object_server.js

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -332,52 +332,68 @@ async function get_object_retention(req) {
332332
};
333333
}
334334
/**
335-
*
336335
* put_object_retention
337-
*
338336
*/
339337
async function put_object_retention(req) {
340338
dbg.log1('put_object_retention:', req.rpc_params);
341-
342339
throw_if_maintenance(req);
343340
load_bucket(req);
344341
const obj = await find_object_md(req);
345342
const info = get_object_info(obj, { role: req.role });
346-
347343
if (req.role !== 'admin') {
348344
throw new RpcError('UNAUTHORIZED');
349345
}
350346
if (!req.bucket.object_lock_configuration || req.bucket.object_lock_configuration.object_lock_enabled !== 'Enabled') {
351347
throw new RpcError('INVALID_REQUEST');
352348
}
353-
if (info.lock_settings && info.lock_settings.retention &&
354-
(new Date(req.rpc_params.retention.retain_until_date) < new Date(info.lock_settings.retention.retain_until_date) ||
355-
!req.rpc_params.retention)) {
356-
357-
if ((info.lock_settings.retention.mode === 'GOVERNANCE' && (!req.rpc_params.bypass_governance || req.role !== 'admin')) ||
358-
info.lock_settings.retention.mode === 'COMPLIANCE') {
359-
dbg.error('put object retention failed due object retention mode', obj);
360-
throw new RpcError('OBJECT_LOCKED',
361-
'Access Denied because object protected by object lock.');
362-
}
363-
}
364-
let legal_hold;
365-
if (info.lock_settings && info.lock_settings.legal_hold) {
366-
legal_hold = { status: info.lock_settings.legal_hold.status };
367-
}
349+
const current_retention = info.lock_settings?.retention;
350+
const new_retention = req.rpc_params.retention;
351+
_throw_if_retention_update_forbidden({
352+
key: obj.key,
353+
obj_id: info.obj_id,
354+
current_retention,
355+
new_retention,
356+
bypass_governance: Boolean(req.rpc_params.bypass_governance),
357+
});
358+
const legal_hold_status = info.lock_settings?.legal_hold?.status;
359+
const legal_hold = legal_hold_status ? { status: legal_hold_status } : undefined;
368360
await MDStore.instance().update_object_by_id(
369361
obj._id, {
370362
lock_settings: {
371363
retention: {
372-
mode: req.rpc_params.retention.mode,
373-
retain_until_date: req.rpc_params.retention.retain_until_date,
364+
mode: new_retention.mode,
365+
retain_until_date: new_retention.retain_until_date,
374366
},
375367
legal_hold,
376368
}
377369
}, undefined, undefined
378370
);
379371
}
380372

373+
function _throw_if_retention_update_forbidden({ key, obj_id, current_retention, new_retention, bypass_governance } = {}) {
374+
if (!current_retention) return;
375+
const current_retain_until = new Date(current_retention.retain_until_date);
376+
const log_ctx = { key, obj_id, current_retention, new_retention };
377+
// Active COMPLIANCE cannot change mode, even when retain-until stays the same or extends.
378+
if (new_retention &&
379+
current_retention.mode === 'COMPLIANCE' &&
380+
current_retain_until > new Date() &&
381+
new_retention.mode !== 'COMPLIANCE') {
382+
dbg.error('put object retention failed: cannot change active COMPLIANCE mode', log_ctx);
383+
throw new RpcError('OBJECT_LOCKED',
384+
'Access Denied because object protected by object lock.');
385+
}
386+
const date_shortened = !new_retention ||
387+
new Date(new_retention.retain_until_date) < current_retain_until;
388+
if (!date_shortened) return;
389+
if ((current_retention.mode === 'GOVERNANCE' && !bypass_governance) ||
390+
current_retention.mode === 'COMPLIANCE') {
391+
dbg.error('put object retention failed due object retention mode', log_ctx);
392+
throw new RpcError('OBJECT_LOCKED',
393+
'Access Denied because object protected by object lock.');
394+
}
395+
}
396+
381397
const ZERO_SIZE_ETAG = crypto.createHash('md5').digest('hex');
382398

383399
/**

src/test/integration_tests/api/s3/test_s3_worm.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -745,20 +745,58 @@ mocha.describe('s3 worm', function() {
745745
});
746746

747747
mocha.it('should fail to change compliance mode to governance', async function() {
748-
const futureDate = new Date();
749-
futureDate.setDate(futureDate.getDate() + 30);
748+
// Previous test extended COMPLIANCE retain-until to +60 days.
749+
// Use +90 here (after that date) so this assert fails on mode change
750+
// (COMPLIANCE -> GOVERNANCE), not on "cannot shorten COMPLIANCE".
751+
const longer_retain_until_date = new Date();
752+
longer_retain_until_date.setDate(longer_retain_until_date.getDate() + 90);
750753

751754
await assert_throws_async(s3_owner.putObjectRetention({
752755
Bucket: BKT1,
753756
Key: COMPLIANCE_KEY,
754757
VersionId: compliance_version_id,
755758
Retention: {
756759
Mode: 'GOVERNANCE',
757-
RetainUntilDate: futureDate
760+
RetainUntilDate: longer_retain_until_date
758761
},
759762
BypassGovernanceRetention: true
760763
}), 'AccessDenied', 'Access Denied because object protected by object lock.');
761764
});
765+
766+
mocha.it('should allow changing governance mode to compliance', async function() {
767+
const GOVERNANCE_KEY = 'governance-to-compliance-test';
768+
const retain_until = new Date();
769+
retain_until.setDate(retain_until.getDate() + 30);
770+
771+
const put_res = await s3_owner.putObject({
772+
Bucket: BKT1,
773+
Key: GOVERNANCE_KEY,
774+
Body: file_body,
775+
ContentType: 'text/plain',
776+
ObjectLockMode: 'GOVERNANCE',
777+
ObjectLockRetainUntilDate: retain_until,
778+
});
779+
780+
const res = await s3_owner.putObjectRetention({
781+
Bucket: BKT1,
782+
Key: GOVERNANCE_KEY,
783+
VersionId: put_res.VersionId,
784+
Retention: {
785+
Mode: 'COMPLIANCE',
786+
RetainUntilDate: retain_until,
787+
},
788+
BypassGovernanceRetention: true,
789+
});
790+
delete res.$metadata;
791+
assert.deepEqual(res, {});
792+
793+
const conf = await s3_owner.getObjectRetention({
794+
Bucket: BKT1,
795+
Key: GOVERNANCE_KEY,
796+
VersionId: put_res.VersionId,
797+
});
798+
assert.equal(conf.Retention.Mode, 'COMPLIANCE');
799+
});
762800
});
763801

764802
mocha.describe('legal hold and retention independence', function() {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/* Copyright (C) 2026 NooBaa */
2+
3+
'use strict';
4+
5+
const { VERSIONING } = require('../../../common/constants').S3;
6+
const SensitiveString = require('../../../util/sensitive_string');
7+
const system_utils = require('../../../server/utils/system_utils');
8+
const { MDStore } = require('../../../server/object_services/md_store');
9+
const object_server = require('../../../server/object_services/object_server');
10+
11+
describe('object_lock - put_object_retention', () => {
12+
const system_store = require('../../../server/system_services/system_store').get_instance();
13+
14+
let mdstore_instance_stub;
15+
let update_object_by_id_stub;
16+
const bucket_id = 'bucket_id_retention';
17+
const system_id = 'system_id_retention';
18+
19+
function make_obj({ mode, retain_until_date }) {
20+
const retain = retain_until_date || new Date(Date.now() + 7 * 24 * 3600 * 1000);
21+
return {
22+
_id: {
23+
toHexString: () => 'objidhex',
24+
getTimestamp: () => new Date(),
25+
},
26+
system: system_id,
27+
bucket: bucket_id,
28+
key: 'locked-key',
29+
size: 1,
30+
lock_settings: {
31+
retention: {
32+
mode,
33+
retain_until_date: retain,
34+
},
35+
},
36+
};
37+
}
38+
39+
function make_req({ mode, retain_until_date, bypass_governance }) {
40+
return {
41+
role: 'admin',
42+
system: {
43+
_id: system_id,
44+
buckets_by_name: {
45+
'test-bucket': {
46+
_id: bucket_id,
47+
name: 'test-bucket',
48+
versioning: VERSIONING.ENABLED,
49+
object_lock_configuration: { object_lock_enabled: 'Enabled' },
50+
},
51+
},
52+
},
53+
rpc_params: {
54+
bucket: new SensitiveString('test-bucket'),
55+
key: 'locked-key',
56+
bypass_governance,
57+
retention: {
58+
mode,
59+
retain_until_date,
60+
},
61+
},
62+
};
63+
}
64+
65+
beforeEach(() => {
66+
mdstore_instance_stub = {
67+
find_object_latest: jest.fn(),
68+
update_object_by_id: jest.fn().mockResolvedValue(undefined),
69+
get_object_version_id: jest.fn().mockReturnValue('v1'),
70+
make_md_id: jest.fn(),
71+
};
72+
update_object_by_id_stub = mdstore_instance_stub.update_object_by_id;
73+
jest.spyOn(MDStore, 'instance').mockReturnValue(mdstore_instance_stub);
74+
jest.spyOn(system_utils, 'system_in_maintenance').mockReturnValue(false);
75+
if (!system_store.data) {
76+
system_store.data = {};
77+
}
78+
system_store.data.get_by_id = jest.fn().mockReturnValue({
79+
name: new SensitiveString('test-bucket'),
80+
versioning: VERSIONING.ENABLED,
81+
});
82+
});
83+
84+
afterEach(() => {
85+
jest.restoreAllMocks();
86+
});
87+
88+
test('rejects COMPLIANCE to GOVERNANCE downgrade with same retain date', async () => {
89+
const retain_until = new Date(Date.now() + 15 * 24 * 3600 * 1000);
90+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
91+
make_obj({ mode: 'COMPLIANCE', retain_until_date: retain_until })
92+
);
93+
const req = make_req({
94+
mode: 'GOVERNANCE',
95+
retain_until_date: retain_until,
96+
bypass_governance: true,
97+
});
98+
99+
await expect(object_server.put_object_retention(req))
100+
.rejects.toMatchObject({ rpc_code: 'OBJECT_LOCKED' });
101+
expect(update_object_by_id_stub).not.toHaveBeenCalled();
102+
});
103+
104+
test('rejects COMPLIANCE to GOVERNANCE downgrade with longer retain date', async () => {
105+
const current_until = new Date(Date.now() + 15 * 24 * 3600 * 1000);
106+
const longer_until = new Date(Date.now() + 90 * 24 * 3600 * 1000);
107+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
108+
make_obj({ mode: 'COMPLIANCE', retain_until_date: current_until })
109+
);
110+
const req = make_req({
111+
mode: 'GOVERNANCE',
112+
retain_until_date: longer_until,
113+
bypass_governance: true,
114+
});
115+
116+
await expect(object_server.put_object_retention(req))
117+
.rejects.toMatchObject({ rpc_code: 'OBJECT_LOCKED' });
118+
expect(update_object_by_id_stub).not.toHaveBeenCalled();
119+
});
120+
121+
test('allows GOVERNANCE to COMPLIANCE upgrade with same retain date', async () => {
122+
const retain_until = new Date(Date.now() + 15 * 24 * 3600 * 1000);
123+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
124+
make_obj({ mode: 'GOVERNANCE', retain_until_date: retain_until })
125+
);
126+
const req = make_req({
127+
mode: 'COMPLIANCE',
128+
retain_until_date: retain_until,
129+
});
130+
131+
await expect(object_server.put_object_retention(req)).resolves.toBeUndefined();
132+
expect(update_object_by_id_stub).toHaveBeenCalled();
133+
});
134+
135+
test('allows extending COMPLIANCE retention while keeping COMPLIANCE mode', async () => {
136+
const current_until = new Date(Date.now() + 15 * 24 * 3600 * 1000);
137+
const longer_until = new Date(Date.now() + 60 * 24 * 3600 * 1000);
138+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
139+
make_obj({ mode: 'COMPLIANCE', retain_until_date: current_until })
140+
);
141+
const req = make_req({
142+
mode: 'COMPLIANCE',
143+
retain_until_date: longer_until,
144+
});
145+
146+
await expect(object_server.put_object_retention(req)).resolves.toBeUndefined();
147+
expect(update_object_by_id_stub).toHaveBeenCalled();
148+
});
149+
150+
test('allows setting GOVERNANCE after COMPLIANCE retention has expired', async () => {
151+
const expired_until = new Date(Date.now() - 24 * 3600 * 1000);
152+
const new_until = new Date(Date.now() + 7 * 24 * 3600 * 1000);
153+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
154+
make_obj({ mode: 'COMPLIANCE', retain_until_date: expired_until })
155+
);
156+
const req = make_req({
157+
mode: 'GOVERNANCE',
158+
retain_until_date: new_until,
159+
});
160+
161+
await expect(object_server.put_object_retention(req)).resolves.toBeUndefined();
162+
expect(update_object_by_id_stub).toHaveBeenCalled();
163+
});
164+
165+
test('rejects shortening active COMPLIANCE retention', async () => {
166+
const current_until = new Date(Date.now() + 15 * 24 * 3600 * 1000);
167+
const shorter_until = new Date(Date.now() + 1 * 24 * 3600 * 1000);
168+
mdstore_instance_stub.find_object_latest.mockResolvedValue(
169+
make_obj({ mode: 'COMPLIANCE', retain_until_date: current_until })
170+
);
171+
const req = make_req({
172+
mode: 'COMPLIANCE',
173+
retain_until_date: shorter_until,
174+
bypass_governance: true,
175+
});
176+
177+
await expect(object_server.put_object_retention(req))
178+
.rejects.toMatchObject({ rpc_code: 'OBJECT_LOCKED' });
179+
expect(update_object_by_id_stub).not.toHaveBeenCalled();
180+
});
181+
});

0 commit comments

Comments
 (0)