Skip to content
Merged
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ Demo tokens for testing:
- `demo-admin-token` - Admin user with full access
- `demo-user-token` - Regular user with limited access

## Authentication & Authorization

The API uses **Role-Based Access Control (RBAC)** with four roles: `admin`,
`freelancer`, `client`, and `guest`. Protected endpoints require a
`Bearer <token>` header.

See [docs/backend/authentication-authorization.md](docs/backend/authentication-authorization.md)
for the full access control matrix, architecture, and security notes.

## Contributing

1. Fork the repo and create a branch: `git checkout -b feature/<ticket>-description`
Expand Down
106 changes: 106 additions & 0 deletions docs/backend/authentication-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Authentication & Authorization – Backend Documentation

## Overview

TalentTrust Backend uses **Role-Based Access Control (RBAC)** to protect API
endpoints. Every protected request must include a valid bearer token that
encodes a user identity and role. The system then checks the role against an
**Access Control Matrix** before granting access.

## Architecture

```
┌──────────┐ ┌────────────────────┐ ┌──────────────────┐ ┌─────────┐
│ Client │────▶│ authenticateMiddleware │────▶│ requirePermission │────▶│ Handler │
└──────────┘ └────────────────────┘ └──────────────────┘ └─────────┘
│ 401 │ 403
▼ ▼
Reject request Reject request
```

### Modules

| Module | File | Purpose |
|--------|------|---------|
| Roles | `src/auth/roles.ts` | Defines roles, resources, actions, and the ACL matrix |
| Authorize | `src/auth/authorize.ts` | Pure `isAllowed(role, resource, action)` function |
| Authenticate | `src/auth/authenticate.ts` | Token decode/create helpers + Express middleware |
| Middleware | `src/auth/middleware.ts` | `requirePermission(resource, action)` factory |
| Barrel | `src/auth/index.ts` | Public re-exports |

## Roles

| Role | Description |
|------|-------------|
| `admin` | Full platform access |
| `freelancer` | Create/view own contracts and disputes, read users/reputation |
| `client` | Create/read/update contracts, create/read disputes |
| `guest` | Read-only access to public endpoints (health) |

## Access Control Matrix

| Resource \ Role | admin | freelancer | client | guest |
|-----------------|-------|------------|--------|-------|
| **contracts** | CRUD | CR | CRU | — |
| **users** | CRUD | R | R | — |
| **reputation** | RU | R | R | — |
| **disputes** | CRUD | CR | CR | — |
| **health** | R | R | R | R |

> **Deny-by-default**: Any role/resource/action combination not explicitly
> listed is denied.

## Authentication Flow

1. Client sends `Authorization: Bearer <token>` header.
2. `authenticateMiddleware` extracts the token after `Bearer `.
3. Token is base64-decoded and parsed as JSON: `{ userId, role }`.
4. Role is validated against `VALID_ROLES`.
5. On success, `req.user` is populated; on failure, 401 is returned.

### Token Format (test/dev)

```
Base64( JSON.stringify({ userId: "u1", role: "freelancer" }) )
```

> **Production note**: Replace with JWT / OAuth2 with cryptographic signature
> verification.

## Authorization Flow

1. `requirePermission(resource, action)` reads `req.user.role`.
2. Calls `isAllowed(role, resource, action)` against the matrix.
3. Returns 403 if denied; calls `next()` if allowed.

## Security Notes

- **Deny-by-default** — unknown roles, resources, or actions are always denied.
- **No privilege escalation** — the matrix is a compile-time constant; it
cannot be mutated at runtime.
- **Input validation** — empty strings and unexpected types are rejected.
- **Separation of concerns** — authentication (identity) and authorization
(permission) are separate middleware layers.
- **Threat scenario coverage** — tests validate: missing headers, malformed
tokens, unknown roles, privilege escalation attempts, and every cell of the
access control matrix.

## Testing

```bash
# Run all tests
npm test

# Run with coverage
npx jest --coverage
```

### Test Suites

| Suite | File | Type | Cases |
|-------|------|------|-------|
| Roles structure | `src/auth/__tests__/roles.test.ts` | Unit | Matrix integrity checks |
| Authorization logic | `src/auth/__tests__/authorize.test.ts` | Unit | Exhaustive positive/negative matrix |
| Authentication | `src/auth/__tests__/authenticate.test.ts` | Unit | Token handling + middleware |
| Permission middleware | `src/auth/__tests__/middleware.test.ts` | Unit | 401/403/next() paths |
| API integration | `src/__tests__/integration.test.ts` | Integration | Full HTTP request/response |
260 changes: 260 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**
* Integration tests for the TalentTrust API.
*
* Exercises every protected endpoint through the full middleware stack
* (authenticate → requirePermission → handler) using supertest.
*
* Test categories:
* 1. Public endpoints (no auth required).
* 2. Positive cases – authenticated role with sufficient permission.
* 3. Negative cases – missing auth, wrong role, insufficient permission.
* 4. Edge cases – malformed headers, tampered tokens.
*/

import request from 'supertest';
import { app } from '../index';
import { createToken } from '../auth';

// ---- helpers ----

const adminToken = createToken('admin-1', 'admin');
const freelancerToken = createToken('freelancer-1', 'freelancer');
const clientToken = createToken('client-1', 'client');
const guestToken = createToken('guest-1', 'guest');

function bearer(token: string) {
return `Bearer ${token}`;
}

// ---- Public endpoints ----

describe('GET /health (public)', () => {
it('should return 200 without auth', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok', service: 'talenttrust-backend' });
});
});

// ---- GET /api/v1/contracts ----

describe('GET /api/v1/contracts', () => {
// Positive cases
it('admin can read contracts', async () => {
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', bearer(adminToken));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('contracts');
});

it('freelancer can read contracts', async () => {
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', bearer(freelancerToken));
expect(res.status).toBe(200);
});

it('client can read contracts', async () => {
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', bearer(clientToken));
expect(res.status).toBe(200);
});

// Negative cases
it('guest is denied access (403)', async () => {
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', bearer(guestToken));
expect(res.status).toBe(403);
});

it('unauthenticated request returns 401', async () => {
const res = await request(app).get('/api/v1/contracts');
expect(res.status).toBe(401);
});
});

// ---- POST /api/v1/contracts ----

describe('POST /api/v1/contracts', () => {
it('admin can create contracts', async () => {
const res = await request(app)
.post('/api/v1/contracts')
.set('Authorization', bearer(adminToken))
.send({ title: 'New Contract' });
expect(res.status).toBe(201);
});

it('freelancer can create contracts', async () => {
const res = await request(app)
.post('/api/v1/contracts')
.set('Authorization', bearer(freelancerToken))
.send({ title: 'New Contract' });
expect(res.status).toBe(201);
});

it('client can create contracts', async () => {
const res = await request(app)
.post('/api/v1/contracts')
.set('Authorization', bearer(clientToken))
.send({ title: 'New Contract' });
expect(res.status).toBe(201);
});

it('guest cannot create contracts (403)', async () => {
const res = await request(app)
.post('/api/v1/contracts')
.set('Authorization', bearer(guestToken))
.send({ title: 'New Contract' });
expect(res.status).toBe(403);
});
});

// ---- GET /api/v1/users ----

describe('GET /api/v1/users', () => {
it('admin can read users', async () => {
const res = await request(app)
.get('/api/v1/users')
.set('Authorization', bearer(adminToken));
expect(res.status).toBe(200);
});

it('freelancer can read users', async () => {
const res = await request(app)
.get('/api/v1/users')
.set('Authorization', bearer(freelancerToken));
expect(res.status).toBe(200);
});

it('guest cannot read users (403)', async () => {
const res = await request(app)
.get('/api/v1/users')
.set('Authorization', bearer(guestToken));
expect(res.status).toBe(403);
});
});

// ---- DELETE /api/v1/users/:id ----

describe('DELETE /api/v1/users/:id', () => {
it('admin can delete users', async () => {
const res = await request(app)
.delete('/api/v1/users/42')
.set('Authorization', bearer(adminToken));
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleted: '42' });
});

it('freelancer cannot delete users (403)', async () => {
const res = await request(app)
.delete('/api/v1/users/42')
.set('Authorization', bearer(freelancerToken));
expect(res.status).toBe(403);
});

it('client cannot delete users (403)', async () => {
const res = await request(app)
.delete('/api/v1/users/42')
.set('Authorization', bearer(clientToken));
expect(res.status).toBe(403);
});

it('guest cannot delete users (403)', async () => {
const res = await request(app)
.delete('/api/v1/users/42')
.set('Authorization', bearer(guestToken));
expect(res.status).toBe(403);
});
});

// ---- GET /api/v1/disputes ----

describe('GET /api/v1/disputes', () => {
it('admin can read disputes', async () => {
const res = await request(app)
.get('/api/v1/disputes')
.set('Authorization', bearer(adminToken));
expect(res.status).toBe(200);
});

it('freelancer can read disputes', async () => {
const res = await request(app)
.get('/api/v1/disputes')
.set('Authorization', bearer(freelancerToken));
expect(res.status).toBe(200);
});

it('guest cannot read disputes (403)', async () => {
const res = await request(app)
.get('/api/v1/disputes')
.set('Authorization', bearer(guestToken));
expect(res.status).toBe(403);
});
});

// ---- DELETE /api/v1/disputes/:id ----

describe('DELETE /api/v1/disputes/:id', () => {
it('admin can delete disputes', async () => {
const res = await request(app)
.delete('/api/v1/disputes/99')
.set('Authorization', bearer(adminToken));
expect(res.status).toBe(200);
});

it('freelancer cannot delete disputes (403)', async () => {
const res = await request(app)
.delete('/api/v1/disputes/99')
.set('Authorization', bearer(freelancerToken));
expect(res.status).toBe(403);
});

it('client cannot delete disputes (403)', async () => {
const res = await request(app)
.delete('/api/v1/disputes/99')
.set('Authorization', bearer(clientToken));
expect(res.status).toBe(403);
});
});

// ---- Edge-case / security scenarios ----

describe('Security edge cases', () => {
it('should return 401 for Bearer with no token value', async () => {
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', 'Bearer ');
expect(res.status).toBe(401);
});

it('should return 401 for malformed JSON in token', async () => {
const bad = Buffer.from('not json').toString('base64');
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', `Bearer ${bad}`);
expect(res.status).toBe(401);
});

it('should return 401 for token with unknown role', async () => {
const bad = Buffer.from(JSON.stringify({ userId: 'u1', role: 'superadmin' })).toString('base64');
const res = await request(app)
.get('/api/v1/contracts')
.set('Authorization', `Bearer ${bad}`);
expect(res.status).toBe(401);
});

it('should return 401 for completely missing Authorization header', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(401);
});

it('should return 401 for non-Bearer scheme', async () => {
const res = await request(app)
.get('/api/v1/users')
.set('Authorization', 'Basic dXNlcjpwYXNz');
expect(res.status).toBe(401);
});
});
Loading
Loading