Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ When preparing a new release:

### Added

- Tamper-evident audit log: hash-chained `prevHash` / `entryHash` metadata,
`GET /api/audit/integrity` for authorized operators, and attribution fields
(`actor`, `scope`, `target`, `correlationId`, `outcome`, redacted `changes`).
Duplicate outcome events for the same privileged mutation (same action,
target, correlation id, and outcome) are suppressed. `GET /api/audit`
accepts `action`, `scope`, `outcome`, `correlationId`, and `actor` filters.
Secrets in changes are redacted at write time. Archive/unarchive now emit
outcome events. Optional `AUDIT_ACTOR_SECRET` (falls back to
`PAGINATION_CURSOR_SECRET`) fingerprints actors without storing raw tokens.

- Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass
`?cursor=` (with optional `?order=asc|desc`) to page by an indexed position
instead of a row offset; responses carry a `pageInfo` block with
Expand Down
46 changes: 41 additions & 5 deletions src/controllers/auditController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,69 @@ const { buildHistoryPage } = require('../utils/historyPage');
* Audit log controllers.
*/

/**
* Collect optional attribution / outcome filters from the query string.
* Empty strings are treated as absent so `?action=` does not over-filter.
* @param {import('express').Request} req
* @returns {{ action: string|null, scope: string|null, outcome: string|null,
* correlationId: string|null, actor: string|null }}
*/
function readFilters(req) {
const pick = (name) => {
const raw = req.query[name];
if (raw == null || raw === '') return null;
return String(raw);
};

return {
action: pick('action'),
scope: pick('scope'),
outcome: pick('outcome'),
correlationId: pick('correlationId'),
actor: pick('actor'),
};
}

/**
* GET /api/audit
* Return audit log entries, newest first by default.
*
* Supports ?resourceId= to filter by resource, ?order=asc|desc, ?limit=, and
* either ?cursor= (stable under concurrent writes) or the legacy ?offset=.
* Supports ?resourceId=, ?action=, ?scope=, ?outcome=, ?correlationId=,
* ?actor=, ?order=asc|desc, ?limit=, and either ?cursor= or legacy ?offset=.
* Responses only ever contain redacted changes — secrets are stripped at write.
*/
function listAuditEntries(req, res) {
const resourceId = req.query.resourceId == null || req.query.resourceId === ''
? null
: String(req.query.resourceId);

const filters = { resourceId };
const attribution = readFilters(req);
const filters = { resourceId, ...attribution };

const { items, envelope } = buildHistoryPage({
req,
collection: 'audit',
filters,
defaultOrder: 'desc',
query: (args) => auditService.queryEntries({ resourceId, ...args }),
countTotal: () => auditService.countEntries(resourceId),
query: (args) => auditService.queryEntries({ resourceId, ...attribution, ...args }),
countTotal: () => auditService.countEntries(resourceId, attribution),
resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId),
});

res.json({ ...envelope, entries: items });
}

/**
* GET /api/audit/integrity
* Report whether the hash chain is intact. Authorized operators use this to
* detect silent tampering without reading every entry's payload.
*/
function getIntegrity(req, res) {
const report = auditService.verifyIntegrity();
res.json(report);
}

module.exports = {
getIntegrity,
listAuditEntries,
};
8 changes: 4 additions & 4 deletions src/controllers/transferController.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ function getTransfer(req, res) {
* Mark a transfer as claimed by the recipient.
*/
function claimTransfer(req, res) {
const transfer = transferService.claimTransfer(req.params.id, req.id);
const transfer = transferService.claimTransfer(req.params.id, req.id, req.token);
res.json(transfer);
}

Expand All @@ -144,7 +144,7 @@ function claimTransfer(req, res) {
* Cancel a pending transfer.
*/
function cancelTransfer(req, res) {
const transfer = transferService.cancelTransfer(req.params.id, req.id);
const transfer = transferService.cancelTransfer(req.params.id, req.id, req.token);
res.json(transfer);
}

Expand All @@ -153,7 +153,7 @@ function cancelTransfer(req, res) {
* Archive a transfer, hiding it from default list results.
*/
function archiveTransfer(req, res) {
const transfer = transferService.archiveTransfer(req.params.id);
const transfer = transferService.archiveTransfer(req.params.id, req.id, req.token);
res.json(transfer);
}

Expand All @@ -162,7 +162,7 @@ function archiveTransfer(req, res) {
* Unarchive a transfer, restoring it to default list results.
*/
function unarchiveTransfer(req, res) {
const transfer = transferService.unarchiveTransfer(req.params.id);
const transfer = transferService.unarchiveTransfer(req.params.id, req.id, req.token);
res.json(transfer);
}

Expand Down
2 changes: 1 addition & 1 deletion src/controllers/userController.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function getUser(req, res) {
* Create a new user.
*/
function createUser(req, res) {
const user = userService.createUser(req.body, req.id);
const user = userService.createUser(req.body, req.id, req.token);
res.status(201).json(user);
}

Expand Down
10 changes: 9 additions & 1 deletion src/routes/auditRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ const auditController = require('../controllers/auditController');

const router = express.Router();

// GET /api/audit/integrity
// Hash-chain verification for authorized operators (requires audit:read).
router.get(
'/integrity',
requireScope(['audit:read']),
asyncHandler(auditController.getIntegrity)
);

// GET /api/audit
// Lists audit log entries (newest first). Supports ?resourceId= and ?limit=/?offset=.
// Lists audit log entries (newest first). Supports resource and attribution filters.
router.get('/', requireScope(['audit:read']), asyncHandler(auditController.listAuditEntries));

module.exports = router;
Loading