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
182 changes: 182 additions & 0 deletions docs/backend/RATE_LIMITING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Rate Limiting & Abuse Guard

**Location:** `src/middleware/rateLimiter.ts` + `src/lib/rateLimitStore.ts`
**Feature branch:** `feature/backend-10-rate-limiting-and-abuse-guard`

---

## Overview

This document describes the rate-limiting and abuse-guard system implemented for TalentTrust's public API. The implementation uses a **sliding-window counter** algorithm with an **adaptive abuse guard** that applies exponential back-off blocks to repeat offenders.

---

## Architecture

```
Request
┌──────────────────────────────────────────────┐
│ rateLimiterMiddleware (Express middleware) │
│ │
│ 1. Extract key (IP / custom keyFn) │
│ 2. Check hard-block → 429 if blocked │
│ 3. Sliding-window counter (RateLimitStore) │
│ 4. Limit exceeded? │
│ ├─ No → set headers, call next() │
│ └─ Yes → check abuse threshold │
│ ├─ Below → 429 + Retry-After │
│ └─ At/above → hard-block + 429 │
└──────────────────────────────────────────────┘
```

---

## Algorithm

### Sliding Window Counter

- Each unique key (default: client IP) gets a counter and a `windowStart` timestamp.
- On each request, if `now - windowStart > windowMs`, the window resets to `now` with `count = 0`.
- `count` is incremented before the limit check, so the check is `count > maxRequests`.

### Abuse Guard

| Event | Action |
|---|---|
| `count > maxRequests` | Violation recorded |
| violations in `blockWindowMs` reaches `abuseThreshold` | Key is hard-blocked for `blockDurationMs` |
| Subsequent abuse after unblocking | Block duration **doubles** (exponential back-off), capped at `maxBlockDurationMs` |

---

## Configuration Reference

All options are passed to `createRateLimiter(config)`.
Environment variables (set in `.env` or process environment) override defaults for the `index.ts` instance.

| Option | Env var | Default | Description |
|---|---|---|---|
| `maxRequests` | `RATE_LIMIT_MAX_REQUESTS` | `100` | Max requests per window |
| `windowMs` | `RATE_LIMIT_WINDOW_MS` | `60000` | Window size in ms |
| `abuseThreshold` | `RATE_LIMIT_ABUSE_THRESHOLD` | `5` | Violations before hard-block |
| `blockWindowMs` | – | `300000` | Observation window for violations |
| `blockDurationMs` | `RATE_LIMIT_BLOCK_MS` | `600000` | Initial block duration |
| `maxBlockDurationMs` | – | `86400000` | Maximum block duration (24 h) |
| `keyFn` | – | IP extraction | Custom key derivation function |
| `sendHeaders` | – | `true` | Emit `X-RateLimit-*` headers |
| `store` | – | new instance | Shared `RateLimitStore` |

---

## Response Headers

| Header | When | Value |
|---|---|---|
| `X-RateLimit-Limit` | Always (if `sendHeaders`) | Configured `maxRequests` |
| `X-RateLimit-Remaining` | Always (if `sendHeaders`) | Requests left in window |
| `X-RateLimit-Reset` | Always (if `sendHeaders`) | Seconds until window resets |
| `Retry-After` | 429 responses | Seconds to wait |
| `X-RateLimit-Blocked` | Hard-block 429 | `"true"` |

---

## Response Bodies (429)

**Rate limit exceeded (not yet blocked):**
```json
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 42 second(s).",
"retryAfter": 42
}
```

**Abuse guard – hard block:**
```json
{
"error": "Too Many Requests",
"message": "Abuse detected. Your access has been temporarily blocked.",
"retryAfter": 600
}
```

---

## Security Notes

1. **Key hashing** – Raw IP addresses are never stored; the store uses SHA-256 hashes. This prevents PII leaking in heap snapshots or memory dumps.
2. **X-Forwarded-For trust** – The default `keyFn` takes the *first* value from `X-Forwarded-For`. In production behind a single reverse proxy, set `app.set('trust proxy', 1)` and supply a `keyFn` that uses `req.ip` to prevent clients spoofing multiple XFF values.
3. **No external dependency** – The store is fully in-process. For multi-instance deployments, replace `RateLimitStore` with a Redis-backed adapter and share it via the `store` option.
4. **Health endpoint excluded** – `/health` is intentionally not rate-limited so load-balancer probes and monitoring agents are never blocked.
5. **Exponential back-off** – Repeat offenders face doubling block durations, significantly raising the cost of sustained abuse.

---

## Threat Model

| Threat | Mitigation |
|---|---|
| DDoS from single IP | Hard-block after `abuseThreshold` violations; exponential back-off |
| IP spoofing via XFF | Use `trust proxy` + `req.ip`-based `keyFn` in production |
| Memory exhaustion | Background sweep purges expired entries every `windowMs` |
| Heap dump leaking IPs | Keys stored as SHA-256 hashes |
| Clock manipulation | All timing via `Date.now()`; block expiry checked on every request |

---

## Usage Examples

### Default (IP-based, applied to all `/api/` routes)

```ts
import { createRateLimiter } from './middleware/rateLimiter';

const limiter = createRateLimiter(); // 100 req/min, 5-violation block
app.use('/api/', limiter);
```

### Stricter limits for an auth endpoint

```ts
const authLimiter = createRateLimiter({
maxRequests: 5,
windowMs: 60_000,
abuseThreshold: 3,
blockDurationMs: 3_600_000, // 1 hour
});
app.post('/api/v1/auth/login', authLimiter, loginHandler);
```

### API-key-scoped limiting

```ts
const apiKeyLimiter = createRateLimiter({
maxRequests: 1000,
windowMs: 60_000,
keyFn: (req) => req.headers['x-api-key'] as string ?? req.ip ?? 'unknown',
});
```

### Shared store across multiple limiter instances

```ts
import { RateLimitStore } from './utils/rateLimitStore';

const store = new RateLimitStore();
const readLimiter = createRateLimiter({ maxRequests: 200, store });
const writeLimiter = createRateLimiter({ maxRequests: 50, store });
```

---

## Running Tests

```bash
npm install
npm test # run all tests with coverage report
npm run test:coverage # explicit coverage run
```

Expected output: ≥ 95 % coverage on branches, functions, lines, and statements for `src/middleware/rateLimiter.ts` and `src/utils/rateLimitStore.ts`.
213 changes: 213 additions & 0 deletions docs/backend/ROLE_BASED_AUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Role-Based Authorization — Technical Reference

> **Branch:** `feature/backend-06-role-based-authorization`
> **Scope:** `Talenttrust/Talenttrust-Backend`

---

## Overview

The authorization system enforces role-scoped permissions across all Talenttrust API endpoints. It is implemented as a three-layer stack:

```
HTTP Request
requireAuth ← verifies JWT with jsonwebtoken, attaches req.user
requireRole ← coarse-grained: is the user's role in the allowed set?
OR
requirePermission ← fine-grained: does the matrix allow this action? ownOnly?
Route Handler
```

---

## Roles

| Role | Description |
|--------------|----------------------------------------------------------|
| `admin` | Full platform access. No ownership restrictions. |
| `client` | Posts jobs, manages contracts, makes payments. |
| `freelancer` | Browses jobs, submits proposals, fulfils contracts. |

Role values are validated against a readonly `ALL_ROLES` allowlist on every request. Unknown strings — including plausible forgeries like `"superadmin"` or `"ADMIN"` — are always rejected with HTTP 401. The `exp` claim is enforced by `jsonwebtoken` automatically.

---

## Permission Matrix

Permissions are declared as a readonly constant array (`PERMISSION_MATRIX`) in `src/types/roles.ts`. Each entry is:

```ts
{
role: Role; // "admin" | "client" | "freelancer"
resource: Resource; // "jobs" | "proposals" | "contracts" | ...
action: Action; // "create" | "read" | "update" | "delete" | "list"
ownOnly?: boolean; // if true, user.id must equal the record's owner id
}
```

### Quick-reference (non-admin roles)

| Resource | Action | client | freelancer |
|-------------|----------|-----------------|-----------------|
| jobs | create | ✅ | ❌ |
| jobs | read | ✅ | ✅ |
| jobs | update | ✅ (own only) | ❌ |
| jobs | delete | ✅ (own only) | ❌ |
| jobs | list | ✅ | ✅ |
| proposals | create | ❌ | ✅ |
| proposals | read | ✅ (own only) | ✅ (own only) |
| proposals | update | ❌ | ✅ (own only) |
| proposals | delete | ❌ | ✅ (own only) |
| contracts | create | ✅ | ❌ |
| contracts | read | ✅ (own only) | ✅ (own only) |
| payments | create | ✅ | ❌ |
| payments | read | ✅ (own only) | ✅ (own only) |
| reviews | create | ✅ | ✅ |
| reviews | update | ✅ (own only) | ✅ (own only) |
| reports | * | ❌ | ❌ |
| users | * | ❌ | ❌ |
| settings | read | ✅ (own only) | ✅ (own only) |
| settings | update | ✅ (own only) | ✅ (own only) |

Admin has unrestricted access to every resource and action (no `ownOnly` entries).

---

## Middleware API

### `requireAuth`

```ts
import { requireAuth } from "./middleware/authorization";

router.get("/jobs", requireAuth, handler);
```

Validates `Authorization: Bearer <token>` using `jwt.verify()` against `JWT_SECRET` (from `process.env`). Reads `sub`, `email`, and `role` directly from the decoded payload. On success, attaches `req.user: AuthenticatedUser`. Responds 401 on any failure, including expired tokens (distinguished by the message `"Token has expired."`).

**Required JWT payload:**
```json
{
"sub": "<userId>",
"email": "<userEmail>",
"role": "admin" | "client" | "freelancer",
"exp": <unix timestamp>
}
```

**Environment variable required:** `JWT_SECRET` — the HMAC secret used to sign and verify all tokens.

---

### `requireRole(...roles)`

```ts
import { requireRole } from "./middleware/authorization";

// Admin only
router.get("/admin/reports", requireAuth, requireRole("admin"), handler);

// Admin or client
router.get("/contracts", requireAuth, requireRole("admin", "client"), handler);
```

Coarse-grained check. Responds 403 when `req.user.role` is not in the allowed list.
Must come after `requireAuth`.

---

### `requirePermission(resource, action, [resolver])`

```ts
import { requirePermission } from "./middleware/authorization";

// No ownership check needed (all matching permissions are unrestricted for the role)
router.get("/jobs", requireAuth, requirePermission("jobs", "list"), handler);

// Ownership check — resolver fetches the record's owner id from the DB
router.patch(
"/jobs/:id",
requireAuth,
requirePermission("jobs", "update", (req) => jobService.getOwnerId(req.params.id)),
handler,
);
```

Fine-grained check against `PERMISSION_MATRIX`.

| Resolver return value | Outcome |
|-----------------------|--------------------------------------------------|
| `string` (owner id) | Ownership comparison runs; grant or 403 |
| `null` | Record not found → **404** (hides existence) |
| throws | Server error → **500** |
| omitted | ownOnly permissions are always denied |

Responds 403 on denial. Must come after `requireAuth`.

---

## Security Notes

### Threat Model

| Threat | Mitigation |
|---------------------------------|---------------------------------------------------------------------------------|
| Token forgery / manipulation | Tokens verified cryptographically by `jwt.verify()` with `JWT_SECRET` (HS256); any tampered payload breaks the HMAC signature and is rejected before claims are read. |
| Token replay after expiry | `jwt.verify()` enforces the `exp` claim automatically; expired tokens are rejected with a distinct 401 message. |
| Privilege escalation via role | Role values are compared against a readonly constant allowlist after decode — not a DB query that could be manipulated. |
| Horizontal privilege escalation | `ownOnly` evaluated against a **DB-sourced** owner id, never against request input. |
| Resource existence leakage | When the resolver returns `null` the middleware returns 404 (not 403), preventing attackers from enumerating which records exist. |
| Internal error leakage | Catch blocks return the minimum safe error string; stack traces and internal ids are never surfaced. |
| 401 vs 403 confusion | 401 = "who are you?", 403 = "I know who you are, but no". Both are applied correctly throughout. |

### What is NOT handled here

- Rate limiting (should be applied at the gateway or a separate middleware).
- Input validation / sanitisation (use a schema validator like `zod` before the auth middleware).
- Audit logging (instrument the route handlers or a separate middleware after auth passes).

---

## File Map

```
src/
├── lib/
│ └── types.ts ← ALL_ROLES, PERMISSION_MATRIX, AuthenticatedUser
├── lib/
│ ├── authorization.ts ← Pure engine: isAuthorized(), isValidRole()
│ ├── data.ts ← PERMISSION_MATRIX
│ ├── types.ts ← ALL_ROLES, AuthenticatedRequest, User
├── middleware/
│ ├── authorization.ts ← requireAuth, requireRole, requirePermission
│ └── __tests__/
│ └── authorization.test.ts ← Integration tests (supertest + real jwt.sign tokens)
└── routes/
└── index.ts ← Reference wiring for all protected routes
```

---

## Running Tests

```bash
npm install
npm test # all tests
npm run test:coverage # with HTML coverage report
npm run test:ci # CI mode: serial, 95% threshold enforced, fails on threshold miss
```

Coverage thresholds (enforced in `jest.config.ts`):

| Metric | Threshold |
|------------|-----------|
| Branches | 95% |
| Functions | 95% |
| Lines | 95% |
| Statements | 95% |
Loading
Loading