Skip to content

Commit c9332ac

Browse files
committed
CLDSRV-890: Log lifecycle expiration events in server access logs
Lifecycle expiration deletions were suppressed from access logs. Add an expiration flag to distinguish them from other backbeat operations. For lifecycle expiration events: - Operation: S3.EXPIRE.OBJECT - Requester: ScalityS3LifecycleService - ClientIP/httpCode/timing/auth: undefined (log-courier will write - or "-")
1 parent 029b436 commit c9332ac

File tree

3 files changed

+264
-3
lines changed

3 files changed

+264
-3
lines changed

lib/routes/routeBackbeat.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ function _respondWithHeaders(response, payload, extraHeaders, log, callback) {
117117
if (response.serverAccessLog) {
118118
// eslint-disable-next-line no-param-reassign
119119
response.serverAccessLog.bytesSent = Buffer.byteLength(body);
120+
// eslint-disable-next-line no-param-reassign
121+
response.serverAccessLog.endTurnAroundTime = process.hrtime.bigint();
120122
}
121123
response.writeHead(200, httpHeaders);
122124
response.end(body, 'utf8', () => {
@@ -843,6 +845,10 @@ function putObject(request, response, log, callback) {
843845
}
844846

845847
function deleteObjectFromExpiration(request, response, userInfo, log, callback) {
848+
if (request.serverAccessLog) {
849+
// eslint-disable-next-line no-param-reassign
850+
request.serverAccessLog.expiration = true;
851+
}
846852
return objectDeleteInternal(userInfo, request, log, true, err => {
847853
if (err) {
848854
log.error('error deleting object from expiration', {
@@ -1693,6 +1699,10 @@ function routeBackbeat(clientIP, request, response, log) {
16931699
(userInfo, authorizationResults, next) => handleAuthorizationResults(
16941700
request, authorizationResults, apiMethods[0], undefined, log, err => next(err, userInfo)),
16951701
(userInfo, next) => {
1702+
if (request.serverAccessLog) {
1703+
// eslint-disable-next-line no-param-reassign
1704+
request.serverAccessLog.startTurnAroundTime = process.hrtime.bigint();
1705+
}
16961706
// TODO: understand why non-object requests (batchdelete) were not authenticated
16971707
if (!isObjectRequest) {
16981708
return routeNonObjectRequest(request, response, userInfo, log, next);

lib/utilities/serverAccessLogger.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ function getOperation(req) {
288288
const resourceType = methodToResType[req.apiMethod];
289289

290290
if (req.serverAccessLog && req.serverAccessLog.backbeat) {
291+
if (req.serverAccessLog.expiration) {
292+
return 'S3.EXPIRE.OBJECT';
293+
}
291294
return `REST.${req.method}.BACKBEAT`;
292295
}
293296

@@ -511,9 +514,9 @@ function buildLogEntry(req, params, options) {
511514

512515
// Scality server access logs extra fields
513516
logFormatVersion: SERVER_ACCESS_LOG_FORMAT_VERSION,
514-
// If backbeat is enabled, we set loggingEnabled to false
515-
// to prevent backbeat requests from getting to log courier.
516-
loggingEnabled: params.backbeat ? false : (params.enabled ?? undefined),
517+
// For non-expiration backbeat requests, force loggingEnabled
518+
// to false to prevent delivery to log courier.
519+
loggingEnabled: (params.backbeat && !params.expiration) ? false : (params.enabled ?? undefined),
517520
loggingTargetBucket: params.loggingEnabled?.TargetBucket ?? undefined,
518521
loggingTargetPrefix: params.loggingEnabled?.TargetPrefix ?? undefined,
519522
awsAccessKeyID: authInfo?.getAccessKey() ?? undefined,
@@ -599,6 +602,26 @@ function logServerAccess(req, res) {
599602
objectKey: params.objectKey,
600603
});
601604

605+
// Lifecycle expiration is an internal operation.
606+
// Null out HTTP-layer fields to match AWS access log format.
607+
if (params.expiration) {
608+
logEntry.requester = 'ScalityS3LifecycleService';
609+
logEntry.clientIP = undefined;
610+
logEntry.requestURI = undefined;
611+
logEntry.httpCode = undefined;
612+
logEntry.bytesSent = undefined;
613+
logEntry.totalTime = undefined;
614+
logEntry.turnAroundTime = undefined;
615+
logEntry.signatureVersion = undefined;
616+
logEntry.cipherSuite = undefined;
617+
logEntry.authenticationType = undefined;
618+
logEntry.hostHeader = undefined;
619+
logEntry.tlsVersion = undefined;
620+
logEntry.referer = undefined;
621+
logEntry.userAgent = undefined;
622+
logEntry.versionId = undefined;
623+
logEntry.awsAccessKeyID = undefined;
624+
}
602625

603626
if (params.internalLogRequestQueue && params.internalLogRequestQueue.length > 0) {
604627
if (logEntry.operation === 'REST.POST.MULTI_OBJECT_DELETE') {

tests/unit/utils/serverAccessLogger.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,16 @@ describe('serverAccessLogger utility functions', () => {
276276
// Should return BACKBEAT instead of UNKNOWN
277277
assert.strictEqual(result, 'REST.GET.BACKBEAT');
278278
});
279+
280+
it('should return S3.EXPIRE.OBJECT for lifecycle expiration requests', () => {
281+
const req = {
282+
method: 'DELETE',
283+
apiMethod: 'objectDelete',
284+
serverAccessLog: { backbeat: true, expiration: true },
285+
};
286+
const result = getOperation(req);
287+
assert.strictEqual(result, 'S3.EXPIRE.OBJECT');
288+
});
279289
});
280290

281291
describe('getRequester', () => {
@@ -1401,6 +1411,224 @@ describe('serverAccessLogger utility functions', () => {
14011411
assert.strictEqual(loggedData.loggingTargetBucket, 'log-bucket');
14021412
assert.strictEqual(loggedData.loggingTargetPrefix, 'logs/');
14031413
});
1414+
1415+
it('should pass through loggingEnabled for lifecycle expiration', () => {
1416+
setServerAccessLogger(mockLogger);
1417+
const req = {
1418+
serverAccessLog: {
1419+
backbeat: true,
1420+
expiration: true,
1421+
enabled: true,
1422+
loggingEnabled: {
1423+
TargetBucket: 'log-bucket',
1424+
TargetPrefix: 'logs/',
1425+
},
1426+
},
1427+
method: 'DELETE',
1428+
apiMethod: 'objectDelete',
1429+
headers: {},
1430+
socket: {},
1431+
};
1432+
const res = {
1433+
serverAccessLog: {},
1434+
getHeader: () => null,
1435+
};
1436+
1437+
logServerAccess(req, res);
1438+
1439+
assert.strictEqual(mockLogger.write.callCount, 1);
1440+
const loggedData = JSON.parse(mockLogger.write.firstCall.args[0].trim());
1441+
assert.strictEqual(loggedData.loggingEnabled, true);
1442+
assert.strictEqual(loggedData.loggingTargetBucket, 'log-bucket');
1443+
assert.strictEqual(loggedData.loggingTargetPrefix, 'logs/');
1444+
assert.strictEqual(loggedData.operation, 'S3.EXPIRE.OBJECT');
1445+
});
1446+
1447+
it('should force loggingEnabled false for non-expiration backbeat', () => {
1448+
setServerAccessLogger(mockLogger);
1449+
const req = {
1450+
serverAccessLog: {
1451+
backbeat: true,
1452+
enabled: true,
1453+
loggingEnabled: {
1454+
TargetBucket: 'log-bucket',
1455+
TargetPrefix: 'logs/',
1456+
},
1457+
},
1458+
method: 'PUT',
1459+
apiMethod: 'objectPut',
1460+
headers: {},
1461+
socket: {},
1462+
};
1463+
const res = {
1464+
serverAccessLog: {},
1465+
getHeader: () => null,
1466+
};
1467+
1468+
logServerAccess(req, res);
1469+
1470+
assert.strictEqual(mockLogger.write.callCount, 1);
1471+
const loggedData = JSON.parse(mockLogger.write.firstCall.args[0].trim());
1472+
assert.strictEqual(loggedData.loggingEnabled, false);
1473+
});
1474+
1475+
it('should not deliver lifecycle expiration log when bucket has no logging config', () => {
1476+
setServerAccessLogger(mockLogger);
1477+
const req = {
1478+
serverAccessLog: {
1479+
backbeat: true,
1480+
expiration: true,
1481+
enabled: false,
1482+
},
1483+
method: 'DELETE',
1484+
apiMethod: 'objectDelete',
1485+
headers: {},
1486+
socket: {},
1487+
};
1488+
const res = {
1489+
serverAccessLog: {},
1490+
getHeader: () => null,
1491+
};
1492+
1493+
logServerAccess(req, res);
1494+
1495+
assert.strictEqual(mockLogger.write.callCount, 1);
1496+
const loggedData = JSON.parse(mockLogger.write.firstCall.args[0].trim());
1497+
assert.strictEqual(loggedData.loggingEnabled, false);
1498+
});
1499+
1500+
it('should produce a complete log entry for lifecycle expiration', () => {
1501+
setServerAccessLogger(mockLogger);
1502+
const authInfo = {
1503+
getAccountDisplayName: () => 'lifecycleAccount',
1504+
getCanonicalID: () => 'lifecycle-canonical-id',
1505+
isRequesterPublicUser: () => false,
1506+
isRequesterAnIAMUser: () => false,
1507+
getAuthVersion: () => 'AWS4-HMAC-SHA256',
1508+
getAuthType: () => 'REST-HEADER',
1509+
getAccessKey: () => 'lifecycle-access-key',
1510+
};
1511+
const startTime = process.hrtime.bigint();
1512+
const startTurnAroundTime = startTime + 1_000_000n;
1513+
const endTurnAroundTime = startTurnAroundTime + 50_000_000n;
1514+
const onFinishEndTime = startTime + 100_000_000n;
1515+
const onCloseEndTime = startTime + 110_000_000n;
1516+
1517+
const req = {
1518+
serverAccessLog: {
1519+
backbeat: true,
1520+
expiration: true,
1521+
enabled: true,
1522+
loggingEnabled: {
1523+
TargetBucket: 'log-bucket',
1524+
TargetPrefix: 'access-logs/',
1525+
},
1526+
bucketOwner: 'bucket-owner-id',
1527+
bucketName: 'source-bucket',
1528+
objectKey: 'expired-object.txt',
1529+
authInfo,
1530+
analyticsAction: 'deleteObjectFromExpiration',
1531+
analyticsAccountName: 'lifecycleAccount',
1532+
analyticsUserName: '',
1533+
startTime,
1534+
startTimeUnixMS: Date.now(),
1535+
startTurnAroundTime,
1536+
onFinishEndTime,
1537+
onCloseEndTime,
1538+
objectSize: 1024,
1539+
analyticsBytesDeleted: 1024,
1540+
},
1541+
method: 'DELETE',
1542+
apiMethod: 'objectDelete',
1543+
headers: { host: 'source-bucket.s3.amazonaws.com' },
1544+
socket: {},
1545+
};
1546+
const res = {
1547+
serverAccessLog: {
1548+
endTurnAroundTime,
1549+
},
1550+
statusCode: 200,
1551+
getHeader: () => null,
1552+
};
1553+
1554+
logServerAccess(req, res);
1555+
1556+
assert.strictEqual(mockLogger.write.callCount, 1);
1557+
const loggedData = JSON.parse(mockLogger.write.firstCall.args[0].trim());
1558+
1559+
// Core expiration fields
1560+
assert.strictEqual(loggedData.operation, 'S3.EXPIRE.OBJECT');
1561+
assert.strictEqual(loggedData.loggingEnabled, true);
1562+
assert.strictEqual(loggedData.loggingTargetBucket, 'log-bucket');
1563+
assert.strictEqual(loggedData.loggingTargetPrefix, 'access-logs/');
1564+
1565+
// Bucket and object info
1566+
assert.strictEqual(loggedData.bucketOwner, 'bucket-owner-id');
1567+
assert.strictEqual(loggedData.bucketName, 'source-bucket');
1568+
assert.strictEqual(loggedData.objectKey, 'expired-object.txt');
1569+
1570+
// Requester is the lifecycle service, not the auth identity
1571+
assert.strictEqual(loggedData.requester, 'ScalityS3LifecycleService');
1572+
1573+
// Object size
1574+
assert.strictEqual(loggedData.objectSize, 1024);
1575+
assert.strictEqual(loggedData.bytesDeleted, 1024);
1576+
1577+
// HTTP-layer fields are null (internal operation, not an HTTP request)
1578+
assert.strictEqual(loggedData.clientIP, undefined);
1579+
assert.strictEqual(loggedData.requestURI, undefined);
1580+
assert.strictEqual(loggedData.httpCode, undefined);
1581+
assert.strictEqual(loggedData.bytesSent, undefined);
1582+
assert.strictEqual(loggedData.totalTime, undefined);
1583+
assert.strictEqual(loggedData.turnAroundTime, undefined);
1584+
assert.strictEqual(loggedData.signatureVersion, undefined);
1585+
assert.strictEqual(loggedData.authenticationType, undefined);
1586+
assert.strictEqual(loggedData.hostHeader, undefined);
1587+
assert.strictEqual(loggedData.awsAccessKeyID, undefined);
1588+
});
1589+
1590+
it('should log lifecycle expiration with error code on failure', () => {
1591+
setServerAccessLogger(mockLogger);
1592+
const req = {
1593+
serverAccessLog: {
1594+
backbeat: true,
1595+
expiration: true,
1596+
enabled: true,
1597+
loggingEnabled: {
1598+
TargetBucket: 'log-bucket',
1599+
TargetPrefix: 'logs/',
1600+
},
1601+
bucketOwner: 'bucket-owner-id',
1602+
bucketName: 'source-bucket',
1603+
objectKey: 'expired-object.txt',
1604+
startTime: process.hrtime.bigint(),
1605+
startTimeUnixMS: Date.now(),
1606+
},
1607+
method: 'DELETE',
1608+
apiMethod: 'objectDelete',
1609+
headers: {},
1610+
socket: {},
1611+
};
1612+
const res = {
1613+
serverAccessLog: {
1614+
errorCode: 'NoSuchKey',
1615+
endTurnAroundTime: process.hrtime.bigint(),
1616+
},
1617+
statusCode: 404,
1618+
getHeader: () => null,
1619+
};
1620+
1621+
logServerAccess(req, res);
1622+
1623+
assert.strictEqual(mockLogger.write.callCount, 1);
1624+
const loggedData = JSON.parse(mockLogger.write.firstCall.args[0].trim());
1625+
assert.strictEqual(loggedData.operation, 'S3.EXPIRE.OBJECT');
1626+
assert.strictEqual(loggedData.loggingEnabled, true);
1627+
assert.strictEqual(loggedData.requester, 'ScalityS3LifecycleService');
1628+
assert.strictEqual(loggedData.errorCode, 'NoSuchKey');
1629+
assert.strictEqual(loggedData.httpCode, undefined);
1630+
});
1631+
14041632
});
14051633
});
14061634

0 commit comments

Comments
 (0)