diff --git a/README.md b/README.md index 662dd94..86ae5a8 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ npm start | `npm test` | Run Jest tests | | `npm run lint` | Run ESLint | +## Authentication & Authorization + +The API uses **Role-Based Access Control (RBAC)** with four roles: `admin`, +`freelancer`, `client`, and `guest`. Protected endpoints require a +`Bearer ` 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 from `main`. diff --git a/docs/backend/authentication-authorization.md b/docs/backend/authentication-authorization.md new file mode 100644 index 0000000..0fc6c20 --- /dev/null +++ b/docs/backend/authentication-authorization.md @@ -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 ` 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 | diff --git a/package-lock.json b/package-lock.json index 6392ca6..a384dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" @@ -963,6 +965,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1084,6 +1109,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1165,6 +1197,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1178,7 +1217,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1251,6 +1289,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1386,6 +1448,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1603,7 +1679,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1855,6 +1930,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1905,6 +2003,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1983,6 +2088,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2012,6 +2127,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -2138,6 +2264,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2290,6 +2432,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2345,6 +2494,41 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2572,6 +2756,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2914,7 +3114,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4623,6 +4822,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4788,7 +5071,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4937,7 +5219,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 4522e40..6614da1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts new file mode 100644 index 0000000..b76f25b --- /dev/null +++ b/src/__tests__/integration.test.ts @@ -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); + }); +}); diff --git a/src/auth/__tests__/authenticate.test.ts b/src/auth/__tests__/authenticate.test.ts new file mode 100644 index 0000000..75a768e --- /dev/null +++ b/src/auth/__tests__/authenticate.test.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for authentication helpers: `decodeToken`, `createToken`, + * and `authenticateMiddleware`. + * + * Covers: + * - Valid token creation and decoding round-trip. + * - Malformed / missing tokens. + * - Tokens with invalid roles. + * - Middleware behavior (sets req.user or returns 401). + */ + +import { decodeToken, createToken, authenticateMiddleware, AuthenticatedRequest } from '../authenticate'; +import { Response, NextFunction } from 'express'; + +// ---- helpers to mock Express objects ---- + +function mockReq(headers: Record = {}): AuthenticatedRequest { + return { headers } as AuthenticatedRequest; +} + +function mockRes(): Response { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res as Response; +} + +function mockNext(): NextFunction { + return jest.fn(); +} + +// ---- decodeToken ---- + +describe('decodeToken', () => { + it('should decode a valid token', () => { + const token = createToken('u1', 'freelancer'); + const payload = decodeToken(token); + expect(payload).toEqual({ userId: 'u1', role: 'freelancer' }); + }); + + it('should return null for non-base64 input', () => { + expect(decodeToken('not-valid!!!')).toBeNull(); + }); + + it('should return null for base64 that is not JSON', () => { + const token = Buffer.from('just a string').toString('base64'); + expect(decodeToken(token)).toBeNull(); + }); + + it('should return null when userId is missing', () => { + const token = Buffer.from(JSON.stringify({ role: 'admin' })).toString('base64'); + expect(decodeToken(token)).toBeNull(); + }); + + it('should return null when role is missing', () => { + const token = Buffer.from(JSON.stringify({ userId: 'u1' })).toString('base64'); + expect(decodeToken(token)).toBeNull(); + }); + + it('should return null when role is invalid', () => { + const token = Buffer.from(JSON.stringify({ userId: 'u1', role: 'superuser' })).toString('base64'); + expect(decodeToken(token)).toBeNull(); + }); + + it('should return null when userId is empty string', () => { + const token = Buffer.from(JSON.stringify({ userId: '', role: 'admin' })).toString('base64'); + expect(decodeToken(token)).toBeNull(); + }); + + it('should return null for empty string token', () => { + expect(decodeToken('')).toBeNull(); + }); +}); + +// ---- createToken ---- + +describe('createToken', () => { + it('should produce a base64 string', () => { + const token = createToken('u1', 'admin'); + // Should not throw when decoded + const raw = Buffer.from(token, 'base64').toString('utf-8'); + expect(JSON.parse(raw)).toEqual({ userId: 'u1', role: 'admin' }); + }); + + it('round-trip: createToken → decodeToken', () => { + for (const role of ['admin', 'freelancer', 'client', 'guest'] as const) { + const token = createToken(`user-${role}`, role); + expect(decodeToken(token)).toEqual({ userId: `user-${role}`, role }); + } + }); +}); + +// ---- authenticateMiddleware ---- + +describe('authenticateMiddleware', () => { + it('should return 401 when Authorization header is missing', () => { + const req = mockReq(); + const res = mockRes(); + const next = mockNext(); + + authenticateMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing or invalid Authorization header' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when Authorization header does not start with Bearer', () => { + const req = mockReq({ authorization: 'Basic abc123' }); + const res = mockRes(); + const next = mockNext(); + + authenticateMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when token is invalid', () => { + const req = mockReq({ authorization: 'Bearer garbage-data' }); + const res = mockRes(); + const next = mockNext(); + + authenticateMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should set req.user and call next for valid token', () => { + const token = createToken('u42', 'client'); + const req = mockReq({ authorization: `Bearer ${token}` }); + const res = mockRes(); + const next = mockNext(); + + authenticateMiddleware(req, res, next); + + expect(req.user).toEqual({ userId: 'u42', role: 'client' }); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 401 when bearer token contains an invalid role', () => { + const badToken = Buffer.from(JSON.stringify({ userId: 'u1', role: 'hacker' })).toString('base64'); + const req = mockReq({ authorization: `Bearer ${badToken}` }); + const res = mockRes(); + const next = mockNext(); + + authenticateMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auth/__tests__/authorize.test.ts b/src/auth/__tests__/authorize.test.ts new file mode 100644 index 0000000..6e246bf --- /dev/null +++ b/src/auth/__tests__/authorize.test.ts @@ -0,0 +1,94 @@ +/** + * Unit tests for `isAllowed` — the core authorization function. + * + * Tests the full access control matrix exhaustively with both positive + * (allowed) and negative (denied) cases for every role-resource-action + * combination. + */ + +import { isAllowed } from '../authorize'; +import { Role, Resource, Action, ACCESS_CONTROL_MATRIX, VALID_ROLES } from '../roles'; + +const ALL_RESOURCES: Resource[] = ['contracts', 'users', 'reputation', 'disputes', 'health']; +const ALL_ACTIONS: Action[] = ['create', 'read', 'update', 'delete']; + +describe('isAllowed – exhaustive positive/negative matrix', () => { + /** + * Generate test cases from the matrix to cover every cell. + * For each role × resource × action, the expected result is derived + * directly from the matrix. + */ + for (const role of VALID_ROLES) { + describe(`role: ${role}`, () => { + for (const resource of ALL_RESOURCES) { + for (const action of ALL_ACTIONS) { + const allowed = + ACCESS_CONTROL_MATRIX[role][resource]?.includes(action) ?? false; + + if (allowed) { + it(`ALLOW ${action} on ${resource}`, () => { + expect(isAllowed(role, resource, action)).toBe(true); + }); + } else { + it(`DENY ${action} on ${resource}`, () => { + expect(isAllowed(role, resource, action)).toBe(false); + }); + } + } + } + }); + } +}); + +describe('isAllowed – edge cases (deny-by-default)', () => { + it('should deny an unknown role', () => { + // Cast to bypass type checks — simulates runtime bad data. + expect(isAllowed('hacker' as Role, 'contracts', 'read')).toBe(false); + }); + + it('should deny an unknown resource for a valid role', () => { + expect(isAllowed('admin', 'secrets' as Resource, 'read')).toBe(false); + }); + + it('should deny an unknown action for a valid role and resource', () => { + expect(isAllowed('admin', 'contracts', 'execute' as Action)).toBe(false); + }); + + it('should deny when role is empty string', () => { + expect(isAllowed('' as Role, 'contracts', 'read')).toBe(false); + }); +}); + +describe('isAllowed – specific business-logic scenarios', () => { + it('admin can delete disputes', () => { + expect(isAllowed('admin', 'disputes', 'delete')).toBe(true); + }); + + it('freelancer cannot delete disputes', () => { + expect(isAllowed('freelancer', 'disputes', 'delete')).toBe(false); + }); + + it('client cannot delete contracts', () => { + expect(isAllowed('client', 'contracts', 'delete')).toBe(false); + }); + + it('guest cannot read contracts', () => { + expect(isAllowed('guest', 'contracts', 'read')).toBe(false); + }); + + it('guest can read health', () => { + expect(isAllowed('guest', 'health', 'read')).toBe(true); + }); + + it('freelancer can create contracts', () => { + expect(isAllowed('freelancer', 'contracts', 'create')).toBe(true); + }); + + it('client can update contracts', () => { + expect(isAllowed('client', 'contracts', 'update')).toBe(true); + }); + + it('freelancer cannot update contracts', () => { + expect(isAllowed('freelancer', 'contracts', 'update')).toBe(false); + }); +}); diff --git a/src/auth/__tests__/middleware.test.ts b/src/auth/__tests__/middleware.test.ts new file mode 100644 index 0000000..633821f --- /dev/null +++ b/src/auth/__tests__/middleware.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for `requirePermission` middleware. + * + * Covers: + * - 401 when req.user is missing. + * - 403 when the role lacks the required permission. + * - next() called when permission is granted. + */ + +import { requirePermission } from '../middleware'; +import { AuthenticatedRequest } from '../authenticate'; +import { Response, NextFunction } from 'express'; + +function mockReq(user?: { userId: string; role: string }): AuthenticatedRequest { + const req = { user } as AuthenticatedRequest; + return req; +} + +function mockRes(): Response { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res as Response; +} + +function mockNext(): NextFunction { + return jest.fn(); +} + +describe('requirePermission middleware', () => { + it('should return 401 when req.user is not set', () => { + const mw = requirePermission('contracts', 'read'); + const req = mockReq(undefined); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Not authenticated' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 when role lacks permission', () => { + const mw = requirePermission('contracts', 'delete'); + const req = mockReq({ userId: 'u1', role: 'freelancer' }); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden: insufficient permissions' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next when role has permission', () => { + const mw = requirePermission('contracts', 'read'); + const req = mockReq({ userId: 'u1', role: 'admin' }); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 403 for guest accessing contracts', () => { + const mw = requirePermission('contracts', 'read'); + const req = mockReq({ userId: 'g1', role: 'guest' }); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should allow admin to delete users', () => { + const mw = requirePermission('users', 'delete'); + const req = mockReq({ userId: 'a1', role: 'admin' }); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('should deny freelancer from deleting users', () => { + const mw = requirePermission('users', 'delete'); + const req = mockReq({ userId: 'f1', role: 'freelancer' }); + const res = mockRes(); + const next = mockNext(); + + mw(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); +}); diff --git a/src/auth/__tests__/roles.test.ts b/src/auth/__tests__/roles.test.ts new file mode 100644 index 0000000..1a9a2db --- /dev/null +++ b/src/auth/__tests__/roles.test.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for the access control matrix defined in `roles.ts`. + * + * Validates: + * - Every role is present in the matrix. + * - Action arrays contain only valid values. + * - No duplicate actions per resource. + */ + +import { ACCESS_CONTROL_MATRIX, VALID_ROLES, Role, Resource, Action } from '../roles'; + +const ALL_RESOURCES: Resource[] = ['contracts', 'users', 'reputation', 'disputes', 'health']; +const ALL_ACTIONS: Action[] = ['create', 'read', 'update', 'delete']; + +describe('Access Control Matrix – structural integrity', () => { + it('should define permissions for every valid role', () => { + for (const role of VALID_ROLES) { + expect(ACCESS_CONTROL_MATRIX).toHaveProperty(role); + } + }); + + it('should only contain valid actions', () => { + for (const role of VALID_ROLES) { + const resources = ACCESS_CONTROL_MATRIX[role]; + for (const [, actions] of Object.entries(resources)) { + for (const action of actions as Action[]) { + expect(ALL_ACTIONS).toContain(action); + } + } + } + }); + + it('should not have duplicate actions for any role-resource pair', () => { + for (const role of VALID_ROLES) { + const resources = ACCESS_CONTROL_MATRIX[role]; + for (const [resource, actions] of Object.entries(resources)) { + const unique = new Set(actions as Action[]); + expect(unique.size).toBe((actions as Action[]).length); + } + } + }); + + it('should only reference valid resources', () => { + for (const role of VALID_ROLES) { + const resources = Object.keys(ACCESS_CONTROL_MATRIX[role]); + for (const res of resources) { + expect(ALL_RESOURCES).toContain(res); + } + } + }); +}); + +describe('Access Control Matrix – role-specific permissions', () => { + describe('admin role', () => { + const perms = ACCESS_CONTROL_MATRIX['admin']; + + it('should have full CRUD on contracts', () => { + expect(perms.contracts).toEqual(expect.arrayContaining(['create', 'read', 'update', 'delete'])); + }); + + it('should have full CRUD on users', () => { + expect(perms.users).toEqual(expect.arrayContaining(['create', 'read', 'update', 'delete'])); + }); + + it('should have full CRUD on disputes', () => { + expect(perms.disputes).toEqual(expect.arrayContaining(['create', 'read', 'update', 'delete'])); + }); + + it('should be able to read and update reputation', () => { + expect(perms.reputation).toEqual(expect.arrayContaining(['read', 'update'])); + }); + }); + + describe('freelancer role', () => { + const perms = ACCESS_CONTROL_MATRIX['freelancer']; + + it('should be able to create and read contracts', () => { + expect(perms.contracts).toEqual(expect.arrayContaining(['create', 'read'])); + }); + + it('should NOT be able to update or delete contracts', () => { + expect(perms.contracts).not.toContain('update'); + expect(perms.contracts).not.toContain('delete'); + }); + + it('should NOT have access to modify users', () => { + expect(perms.users).not.toContain('create'); + expect(perms.users).not.toContain('update'); + expect(perms.users).not.toContain('delete'); + }); + }); + + describe('client role', () => { + const perms = ACCESS_CONTROL_MATRIX['client']; + + it('should be able to create, read, and update contracts', () => { + expect(perms.contracts).toEqual(expect.arrayContaining(['create', 'read', 'update'])); + }); + + it('should NOT be able to delete contracts', () => { + expect(perms.contracts).not.toContain('delete'); + }); + + it('should NOT be able to modify users', () => { + expect(perms.users).not.toContain('create'); + expect(perms.users).not.toContain('update'); + expect(perms.users).not.toContain('delete'); + }); + }); + + describe('guest role', () => { + const perms = ACCESS_CONTROL_MATRIX['guest']; + + it('should only have read access to health', () => { + expect(perms.health).toEqual(['read']); + }); + + it('should NOT have any contract permissions', () => { + expect(perms.contracts).toBeUndefined(); + }); + + it('should NOT have any user permissions', () => { + expect(perms.users).toBeUndefined(); + }); + + it('should NOT have any dispute permissions', () => { + expect(perms.disputes).toBeUndefined(); + }); + + it('should NOT have any reputation permissions', () => { + expect(perms.reputation).toBeUndefined(); + }); + }); +}); diff --git a/src/auth/authenticate.ts b/src/auth/authenticate.ts new file mode 100644 index 0000000..5fe2ac6 --- /dev/null +++ b/src/auth/authenticate.ts @@ -0,0 +1,97 @@ +/** + * @module authenticate + * @description Authentication middleware and helpers for TalentTrust. + * + * Uses a simple Bearer-token scheme backed by a shared secret (for demo / + * test purposes). In production this would be replaced with JWT / OAuth2. + * + * Tokens are expected in the `Authorization` header: + * Authorization: Bearer + * + * The token payload is a base64-encoded JSON string: + * { "userId": "u1", "role": "freelancer" } + * + * Security notes: + * - Tokens are validated for structure, not cryptographic signature + * (acceptable for tests; production should use JWTs). + * - Missing or malformed tokens result in 401 Unauthorized. + * - Role validity is checked against VALID_ROLES. + */ + +import { Request, Response, NextFunction } from 'express'; +import { Role, VALID_ROLES } from './roles'; + +/** Shape of the decoded token payload. */ +export interface TokenPayload { + userId: string; + role: Role; +} + +/** Express request extended with authenticated user info. */ +export interface AuthenticatedRequest extends Request { + user?: TokenPayload; +} + +/** + * Decode and validate a bearer token string. + * + * @param token - The raw base64-encoded token. + * @returns The decoded payload, or `null` if invalid. + */ +export function decodeToken(token: string): TokenPayload | null { + try { + const json = Buffer.from(token, 'base64').toString('utf-8'); + const parsed = JSON.parse(json); + if ( + typeof parsed.userId !== 'string' || + !parsed.userId || + typeof parsed.role !== 'string' || + !(VALID_ROLES as readonly string[]).includes(parsed.role) + ) { + return null; + } + return { userId: parsed.userId, role: parsed.role as Role }; + } catch { + return null; + } +} + +/** + * Helper to create a valid bearer token for testing. + * + * @param userId - User identifier. + * @param role - Role to encode. + * @returns Base64-encoded token string. + */ +export function createToken(userId: string, role: Role): string { + return Buffer.from(JSON.stringify({ userId, role })).toString('base64'); +} + +/** + * Express middleware that extracts and validates the bearer token. + * On success, attaches `req.user` with `{ userId, role }`. + * On failure, responds with 401. + */ +export function authenticateMiddleware( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +): void { + const header = req.headers.authorization; + + if (!header || !header.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid Authorization header' }); + return; + } + + const token = header.slice(7); + const payload = decodeToken(token); + + if (!payload) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + + req.user = payload; + next(); +} diff --git a/src/auth/authorize.ts b/src/auth/authorize.ts new file mode 100644 index 0000000..742bd8a --- /dev/null +++ b/src/auth/authorize.ts @@ -0,0 +1,35 @@ +/** + * @module authorize + * @description Core authorization logic for TalentTrust. + * + * Provides `isAllowed` — a pure function that checks whether a given role + * is permitted to perform a specific action on a resource, based on the + * access control matrix defined in `roles.ts`. + * + * Security notes: + * - Unknown roles are denied by default (deny-by-default). + * - Unknown resources or actions are denied by default. + * - No runtime mutation of the matrix is permitted from this module. + */ + +import { Role, Resource, Action, ACCESS_CONTROL_MATRIX } from './roles'; + +/** + * Check whether a role is permitted to perform an action on a resource. + * + * @param role - The user's role. + * @param resource - The target resource. + * @param action - The requested action. + * @returns `true` if the action is allowed, `false` otherwise. + */ +export function isAllowed(role: Role, resource: Resource, action: Action): boolean { + const permissions = ACCESS_CONTROL_MATRIX[role]; + if (!permissions) { + return false; + } + const actions = permissions[resource]; + if (!actions) { + return false; + } + return actions.includes(action); +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..2a0becd --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,10 @@ +export { Role, Resource, Action, ACCESS_CONTROL_MATRIX, VALID_ROLES } from './roles'; +export { isAllowed } from './authorize'; +export { + TokenPayload, + AuthenticatedRequest, + decodeToken, + createToken, + authenticateMiddleware, +} from './authenticate'; +export { requirePermission } from './middleware'; diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..29d5d1f --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,43 @@ +/** + * @module middleware + * @description Express middleware that enforces the access control matrix. + * + * Usage: + * app.get('/api/v1/contracts', authenticate, requirePermission('contracts', 'read'), handler); + * + * The `requirePermission` factory returns middleware that checks the + * authenticated user's role against the access control matrix. + * + * Security notes: + * - Must be placed AFTER `authenticateMiddleware` in the middleware chain. + * - Responds with 403 Forbidden when the role lacks the required permission. + * - Responds with 401 if `req.user` is missing (in case authenticate was skipped). + */ + +import { Response, NextFunction } from 'express'; +import { Resource, Action } from './roles'; +import { AuthenticatedRequest } from './authenticate'; +import { isAllowed } from './authorize'; + +/** + * Factory that returns Express middleware enforcing a specific permission. + * + * @param resource - The resource being accessed. + * @param action - The action being performed. + * @returns Express middleware function. + */ +export function requirePermission(resource: Resource, action: Action) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ error: 'Not authenticated' }); + return; + } + + if (!isAllowed(req.user.role, resource, action)) { + res.status(403).json({ error: 'Forbidden: insufficient permissions' }); + return; + } + + next(); + }; +} diff --git a/src/auth/roles.ts b/src/auth/roles.ts new file mode 100644 index 0000000..44eb164 --- /dev/null +++ b/src/auth/roles.ts @@ -0,0 +1,56 @@ +/** + * @module roles + * @description Defines the role-based access control (RBAC) model for TalentTrust. + * + * Roles: + * - admin: Full platform access — manages users, contracts, disputes. + * - freelancer: Can create/view own contracts, submit work, view own reputation. + * - client: Can create/view own contracts, approve/reject deliverables. + * - guest: Read-only access to public endpoints (health, public listings). + * + * Resources: + * contracts, users, reputation, disputes, health + * + * Actions: + * create, read, update, delete + */ + +export type Role = 'admin' | 'freelancer' | 'client' | 'guest'; + +export type Resource = 'contracts' | 'users' | 'reputation' | 'disputes' | 'health'; + +export type Action = 'create' | 'read' | 'update' | 'delete'; + +/** + * Access control matrix. + * Maps each role to the set of allowed actions per resource. + */ +export const ACCESS_CONTROL_MATRIX: Record>> = { + admin: { + contracts: ['create', 'read', 'update', 'delete'], + users: ['create', 'read', 'update', 'delete'], + reputation: ['read', 'update'], + disputes: ['create', 'read', 'update', 'delete'], + health: ['read'], + }, + freelancer: { + contracts: ['create', 'read'], + users: ['read'], + reputation: ['read'], + disputes: ['create', 'read'], + health: ['read'], + }, + client: { + contracts: ['create', 'read', 'update'], + users: ['read'], + reputation: ['read'], + disputes: ['create', 'read'], + health: ['read'], + }, + guest: { + health: ['read'], + }, +}; + +/** All valid roles in the system. */ +export const VALID_ROLES: readonly Role[] = ['admin', 'freelancer', 'client', 'guest'] as const; diff --git a/src/index.ts b/src/index.ts index dd2fd8f..bc63197 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,81 @@ import express, { Request, Response } from 'express'; +import { + authenticateMiddleware, + requirePermission, + AuthenticatedRequest, +} from './auth'; const app = express(); const PORT = process.env.PORT || 3001; app.use(express.json()); +// --- Public routes --- + app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'talenttrust-backend' }); }); -app.get('/api/v1/contracts', (_req: Request, res: Response) => { - res.json({ contracts: [] }); -}); +// --- Protected routes --- -app.listen(PORT, () => { - console.log(`TalentTrust API listening on http://localhost:${PORT}`); -}); +app.get( + '/api/v1/contracts', + authenticateMiddleware, + requirePermission('contracts', 'read'), + (_req: AuthenticatedRequest, res: Response) => { + res.json({ contracts: [] }); + }, +); + +app.post( + '/api/v1/contracts', + authenticateMiddleware, + requirePermission('contracts', 'create'), + (_req: AuthenticatedRequest, res: Response) => { + res.status(201).json({ contract: { id: 'new' } }); + }, +); + +app.get( + '/api/v1/users', + authenticateMiddleware, + requirePermission('users', 'read'), + (_req: AuthenticatedRequest, res: Response) => { + res.json({ users: [] }); + }, +); + +app.delete( + '/api/v1/users/:id', + authenticateMiddleware, + requirePermission('users', 'delete'), + (req: AuthenticatedRequest, res: Response) => { + res.json({ deleted: req.params.id }); + }, +); + +app.get( + '/api/v1/disputes', + authenticateMiddleware, + requirePermission('disputes', 'read'), + (_req: AuthenticatedRequest, res: Response) => { + res.json({ disputes: [] }); + }, +); + +app.delete( + '/api/v1/disputes/:id', + authenticateMiddleware, + requirePermission('disputes', 'delete'), + (req: AuthenticatedRequest, res: Response) => { + res.json({ deleted: req.params.id }); + }, +); + +export { app }; + +if (require.main === module) { + app.listen(PORT, () => { + console.log(`TalentTrust API listening on http://localhost:${PORT}`); + }); +}