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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ When preparing a new release:
1. Review the `[Unreleased]` section and ensure all notable changes are documented.
2. Change the `[Unreleased]` heading to the new version number and current date (e.g., `## [1.0.0] - YYYY-MM-DD`).
3. Create a new empty `## [Unreleased]` section at the top of the file, with `### Added`, `### Changed`, `### Fixed`, etc., as needed.

- Consistent token-scope enforcement across transfer, user, audit, and admin
surfaces (`admin:read` scope, service-boundary checks, `POST /api/transfers/bulk`).
See `docs/SCOPE_MATRIX.md`. Malformed and missing transfer ids now share one
non-enumerating `404 Transfer not found` response.
4. Update the `version` field in `package.json` to match the new version.
5. Commit these changes with a conventional commit message (e.g., `chore: release v1.0.0`).
6. Tag the commit with the new version (e.g., `git tag v1.0.0`) and push the changes and tags to the repository.
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,20 @@ Authorization: Bearer <token>

### Scopes

Canonical catalog: `src/config/scopes.js`. Full route matrix: [`docs/SCOPE_MATRIX.md`](docs/SCOPE_MATRIX.md).

| Scope | Grants access to |
|-------|-----------------|
| `transfers:read` | `GET /api/transfers`, `GET /api/transfers/stats`, `GET /api/transfers/:id` |
| `transfers:write` | `POST /api/transfers`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` |
| `transfers:write` | `POST /api/transfers`, `POST /api/transfers/bulk`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` |
| `users:read` | `GET /api/users`, `GET /api/users/:id` |
| `users:write` | `POST /api/users` |
| `audit:read` | `GET /api/audit` |
| `admin:read` | `GET /api/admin/diagnostics` (also granted by the legacy `ADMIN_API_KEY` / `X-Admin-Token`) |

Scopes are enforced at the route **and** again at the service boundary. Missing,
malformed, and unknown transfer ids share one `404 Transfer not found` response
so callers cannot enumerate identifiers by status or message shape.

### Public endpoints (no token required)

Expand All @@ -90,7 +97,7 @@ for local development only — rotate before deploying):

| Demo token | Scopes |
|------------|--------|
| `test-token-admin` | all scopes |
| `test-token-admin` | all scopes (including `admin:read`) |
| `test-token-readonly` | `transfers:read`, `users:read`, `audit:read` |
| `test-token-transfers` | `transfers:read`, `transfers:write` |

Expand Down
49 changes: 49 additions & 0 deletions docs/SCOPE_MATRIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Scope matrix

Canonical scope strings live in `src/config/scopes.js` (`SCOPES`, `SCOPE_MATRIX`).
Route middleware (`requireScope` / `adminAuth`) is the first gate; service helpers
in `src/utils/authz.js` re-check the same scopes so a forgotten middleware cannot
expose a privileged mutation.

| Method | Path | Required scopes | Surface |
|--------|------|-----------------|---------|
| GET | `/api/transfers` | `transfers:read` | list |
| GET | `/api/transfers/stats` | `transfers:read` | direct |
| GET | `/api/transfers/:id` | `transfers:read` | direct |
| POST | `/api/transfers` | `transfers:write` | direct |
| POST | `/api/transfers/bulk` | `transfers:write` | bulk |
| POST | `/api/transfers/:id/claim` | `transfers:write` | direct |
| POST | `/api/transfers/:id/cancel` | `transfers:write` | direct |
| POST | `/api/transfers/:id/archive` | `transfers:write` | direct |
| POST | `/api/transfers/:id/unarchive` | `transfers:write` | direct |
| GET | `/api/users` | `users:read` | list |
| GET | `/api/users/:id` | `users:read` | direct |
| POST | `/api/users` | `users:write` | direct |
| GET | `/api/audit` | `audit:read` | list |
| GET | `/api/admin/diagnostics` | `admin:read` | admin |

## Admin credentials

`GET /api/admin/diagnostics` accepts either:

1. `Authorization: Bearer <api-token>` whose catalog entry includes `admin:read`, or
2. Legacy `X-Admin-Token: <ADMIN_API_KEY>` / `Authorization: Bearer <ADMIN_API_KEY>`,
which is mapped onto `[admin:read]` so the path stays scope-gated.

## Non-enumeration

Transfer lookups (direct and bulk) return the same `404 Transfer not found`
envelope for malformed ids and for unknown well-formed ids. Bulk per-id failures
use a stable `error.code` of `not_found` in both cases.

## Bulk mutations

```http
POST /api/transfers/bulk
Authorization: Bearer <token with transfers:write>
Content-Type: application/json

{ "action": "claim" | "cancel" | "archive" | "unarchive", "ids": ["txn_…", …] }
```

Up to 50 ids per call. The response is `{ results: [{ id, ok, transfer? | error? }] }`.
11 changes: 9 additions & 2 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,16 @@ const config = {
}
// Default tokens for demo purposes
return {
'test-token-admin': ['transfers:read', 'transfers:write', 'users:read', 'users:write', 'audit:read'],
'test-token-admin': [
'transfers:read',
'transfers:write',
'users:read',
'users:write',
'audit:read',
'admin:read',
],
'test-token-readonly': ['transfers:read', 'users:read', 'audit:read'],
'test-token-transfers': ['transfers:read', 'transfers:write']
'test-token-transfers': ['transfers:read', 'transfers:write'],
};
})(),
};
Expand Down
73 changes: 73 additions & 0 deletions src/config/scopes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

/**
* Canonical action/resource scope catalog.
*
* Every secured route and service boundary must require one of these values.
* Unknown scope strings are rejected at configuration time so a typo cannot
* silently open an endpoint.
*
* Format: `<resource>:<action>`
*/

const SCOPES = Object.freeze({
TRANSFERS_READ: 'transfers:read',
TRANSFERS_WRITE: 'transfers:write',
USERS_READ: 'users:read',
USERS_WRITE: 'users:write',
AUDIT_READ: 'audit:read',
ADMIN_READ: 'admin:read',
});

/** All known scope strings, for validation and docs. */
const ALL_SCOPES = Object.freeze(Object.values(SCOPES));

/**
* Route → required scopes matrix. Used by docs and the scope-matrix test so
* the documented contract cannot drift from the mounted routers.
*
* `anyOf` is reserved for future OR-semantics; today every entry is AND.
*
* @type {ReadonlyArray<{ method: string, path: string, scopes: string[], surface: string }>}
*/
const SCOPE_MATRIX = Object.freeze([
{ method: 'GET', path: '/api/transfers', scopes: [SCOPES.TRANSFERS_READ], surface: 'list' },
{ method: 'GET', path: '/api/transfers/stats', scopes: [SCOPES.TRANSFERS_READ], surface: 'direct' },
{ method: 'GET', path: '/api/transfers/:id', scopes: [SCOPES.TRANSFERS_READ], surface: 'direct' },
{ method: 'POST', path: '/api/transfers', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/claim', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/cancel', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/archive', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/unarchive', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/bulk', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'bulk' },
{ method: 'GET', path: '/api/users', scopes: [SCOPES.USERS_READ], surface: 'list' },
{ method: 'GET', path: '/api/users/:id', scopes: [SCOPES.USERS_READ], surface: 'direct' },
{ method: 'POST', path: '/api/users', scopes: [SCOPES.USERS_WRITE], surface: 'direct' },
{ method: 'GET', path: '/api/audit', scopes: [SCOPES.AUDIT_READ], surface: 'list' },
{ method: 'GET', path: '/api/admin/diagnostics', scopes: [SCOPES.ADMIN_READ], surface: 'admin' },
]);

/**
* Assert every entry in `scopes` is a known catalog value.
* @param {string[]} scopes
* @param {string} [label]
* @returns {string[]}
*/
function assertKnownScopes(scopes, label = 'scope') {
if (!Array.isArray(scopes)) {
throw new Error(`${label} must be an array of scope strings`);
}
for (const scope of scopes) {
if (!ALL_SCOPES.includes(scope)) {
throw new Error(`Unknown ${label}: ${scope}`);
}
}
return scopes;
}

module.exports = {
SCOPES,
ALL_SCOPES,
SCOPE_MATRIX,
assertKnownScopes,
};
11 changes: 11 additions & 0 deletions src/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
const config = require('../config');
const userService = require('../services/userService');
const transferService = require('../services/transferService');
const { SCOPES, assertScopes, authFromRequest } = require('../utils/authz');

/**
* GET /api/admin/diagnostics
* Returns system diagnostics, active configurations, and usage statistics.
*
* Requires the explicit `admin:read` scope (enforced at the route by adminAuth
* and re-checked here so the privileged aggregate cannot be reached through a
* future helper that forgets the middleware).
*/
function getDiagnostics(req, res) {
const auth = authFromRequest(req);
assertScopes(auth, SCOPES.ADMIN_READ);

// Admin diagnostics aggregates across tenants; call services without a
// caller auth context so the read-scope gates do not reject an admin-only
// token that intentionally has no transfers:read / users:read grants.
const transferStats = transferService.getStats();
const userCount = userService.listUsers().length;

Expand Down
6 changes: 4 additions & 2 deletions src/controllers/auditController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const auditService = require('../services/auditService');
const { buildHistoryPage } = require('../utils/historyPage');
const { authFromRequest } = require('../utils/authz');

/**
* Audit log controllers.
Expand All @@ -20,14 +21,15 @@ function listAuditEntries(req, res) {
: String(req.query.resourceId);

const filters = { resourceId };
const auth = authFromRequest(req);

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, ...args, auth }),
countTotal: () => auditService.countEntries(resourceId, auth),
resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId),
});

Expand Down
32 changes: 23 additions & 9 deletions src/controllers/transferController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const transferService = require('../services/transferService');
const { buildHistoryPage } = require('../utils/historyPage');
const idempotencyService = require('../services/idempotencyService');
const ApiError = require('../utils/ApiError');
const { authFromRequest } = require('../utils/authz');

/** Upper bound on a client-supplied key, so the map cannot be grown without limit. */
const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
Expand Down Expand Up @@ -64,7 +65,7 @@ function createTransfer(req, res) {
actor: req.token,
key,
fingerprint,
});
}, authFromRequest(req));

// A replay answers 201 with the original transfer, exactly as the first call
// did. Replaying the stored result means replaying all of it; downgrading the
Expand Down Expand Up @@ -100,13 +101,14 @@ function listTransfers(req, res) {
archived,
});

const auth = authFromRequest(req);
const { items, envelope } = buildHistoryPage({
req,
collection: 'transfers',
filters,
defaultOrder: 'asc',
query: (args) => transferService.queryTransfers({ ...filters, ...args }),
countTotal: () => transferService.listTransfers(filters).length,
query: (args) => transferService.queryTransfers({ ...filters, ...args, auth }),
countTotal: () => transferService.listTransfers(filters, auth).length,
resolvePosition: (seq) => transferService.positionKeyAt(seq),
});

Expand All @@ -118,15 +120,15 @@ function listTransfers(req, res) {
* Return aggregate transfer statistics.
*/
function getStats(req, res) {
res.json(transferService.getStats());
res.json(transferService.getStats(authFromRequest(req)));
}

/**
* GET /api/transfers/:id
* Fetch a single transfer by id.
*/
function getTransfer(req, res) {
const transfer = transferService.getTransferOrThrow(req.params.id);
const transfer = transferService.getTransferOrThrow(req.params.id, authFromRequest(req));
res.json(transfer);
}

Expand All @@ -135,7 +137,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, authFromRequest(req));
res.json(transfer);
}

Expand All @@ -144,7 +146,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, authFromRequest(req));
res.json(transfer);
}

Expand All @@ -153,7 +155,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, authFromRequest(req));
res.json(transfer);
}

Expand All @@ -162,10 +164,21 @@ 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, authFromRequest(req));
res.json(transfer);
}

/**
* POST /api/transfers/bulk
* Apply one write action to many transfer ids.
*/
function bulkMutate(req, res) {
const action = req.body && req.body.action;
const ids = req.body && req.body.ids;
const result = transferService.bulkMutate(action, ids, req.id, authFromRequest(req));
res.json(result);
}

module.exports = {
createTransfer,
listTransfers,
Expand All @@ -175,4 +188,5 @@ module.exports = {
cancelTransfer,
archiveTransfer,
unarchiveTransfer,
bulkMutate,
};
7 changes: 4 additions & 3 deletions src/controllers/userController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const userService = require('../services/userService');
const { parsePagination } = require('../utils/pagination');
const { authFromRequest } = require('../utils/authz');

/**
* User controllers.
Expand All @@ -12,7 +13,7 @@ const { parsePagination } = require('../utils/pagination');
* List users with limit/offset pagination.
*/
function listUsers(req, res) {
const all = userService.listUsers();
const all = userService.listUsers(authFromRequest(req));
const { limit, offset } = parsePagination(req.query);
const users = all.slice(offset, offset + limit);
res.json({ total: all.length, count: users.length, limit, offset, users });
Expand All @@ -23,7 +24,7 @@ function listUsers(req, res) {
* Fetch a single user by id.
*/
function getUser(req, res) {
const user = userService.getUserOrThrow(req.params.id);
const user = userService.getUserOrThrow(req.params.id, authFromRequest(req));
res.json(user);
}

Expand All @@ -32,7 +33,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, authFromRequest(req));
res.status(201).json(user);
}

Expand Down
Loading