Compare commits

...

4 Commits

61 changed files with 3020 additions and 45 deletions

View File

@ -2,3 +2,13 @@ NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
CORS_ORIGINS=http://localhost:3001
SESSION_TTL_MINUTES=480
RECOVERY_TTL_MINUTES=15
# Optional recovery email: configure all five values together.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=replace_me
# SMTP_PASSWORD=replace_me
# SMTP_FROM=support@example.com
# RECOVERY_URL=https://shop.example.com/reset

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary

View File

@ -0,0 +1,11 @@
## Change
Describe the user-visible behavior and relevant permission boundaries.
## Validation
List the checks run, migration upgrade tests, and any unavailable external verification.
## Release
Mention migrations, configuration changes and compatible rollback steps. Include no secrets.

View File

@ -0,0 +1,32 @@
name: Backend quality
on:
push:
branches: [main, 'feat/**', 'fix/**']
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: mani
POSTGRES_PASSWORD: ci_only_password
POSTGRES_DB: mani_ci
options: >-
--health-cmd "pg_isready -U mani -d mani_ci"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgresql://mani:ci_only_password@postgres:5432/mani_ci
TEST_DATABASE_URL: postgresql://mani:ci_only_password@postgres:5432/mani_ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm install --global pnpm@11.19.0
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: node scripts/verify-migrations.mjs
- run: pnpm check

View File

@ -1,6 +1,6 @@
# Mani Candles backend
Phase 1A implements the service foundation. It does not yet expose commerce or identity APIs.
Phase 1A provides the service foundation. Phase 1B adds staff account provisioning, authentication, sessions, recovery, configurable RBAC, and audit records. Commerce APIs follow in Phase 1C.
## Setup
@ -27,3 +27,5 @@ Development: run `pnpm dev` to compile on changes and `pnpm start:watch` in a se
- `docs`: delivery roadmap and engineering workflow.
Production must use a managed secret store, TLS termination, a restricted database account, backups, and the deployment migration command. Do not expose this foundation as a completed commerce platform.
Identity: see [API contract](docs/identity-api.md) and [setup/release guide](docs/identity-operations.md).

61
docs/identity-api.md Normal file
View File

@ -0,0 +1,61 @@
# Phase 1B: identity and access API
All paths below start with `/api/v1`. Protected endpoints require `Authorization: Bearer <accessToken>`. There are no public account-creation or owner-creation endpoints. Staff account provisioning is available to authorized administrators; storefront self-registration and verified email onboarding can be added with the customer milestone.
## Endpoints
| Method | Path | Permission | Request / result |
| ------ | ---------------------- | ------------------ | ------------------------------------------------------------------- |
| POST | /auth/login | Public; throttled | organizationId, email, password → accessToken, tokenType, expiresAt |
| GET | /auth/me | Authenticated | userId, organizationId, sessionId, effective permissions |
| POST | /auth/logout | Authenticated | Revoke current session; 204 |
| POST | /auth/logout-all | Authenticated | Revoke all current user sessions; 204 |
| POST | /auth/recovery/request | Public; throttled | organizationId, email → generic 202; 503 if SMTP is unconfigured |
| POST | /auth/recovery/reset | Public; throttled | token, password → 204; token expires and can be consumed once |
| GET | /users | users.read | Paginated safe user records |
| POST | /users | users.create | name, email, password → PENDING account |
| PATCH | /users/:id/status | users.approve | status: ACTIVE or SUSPENDED |
| PATCH | /users/:id/roles | users.roles.assign | roleIds: UUID array; replaces assignments |
| GET | /roles/permissions | roles.read | Current permission catalogue |
| GET | /roles | roles.read | Paginated roles |
| POST | /roles | roles.manage | name, permissions |
| PATCH | /roles/:id | roles.manage | Complete replacement of name and permissions |
| GET | /audit-events | audit.read | Paginated append-only organization audit history |
List queries accept `limit` (1100, default 25) and `offset` (010000, default 0). Unknown fields, malformed UUIDs, duplicate role assignments/permissions and unknown permissions are rejected. Errors use Nest's JSON error contract: 400 invalid input, 401 unauthenticated, 403 denied, 404 missing/out-of-scope target, 409 duplicate record, 429 throttled, 503 unavailable.
Example login:
```json
{
"organizationId": "<UUID returned by bootstrap>",
"email": "owner@example.com",
"password": "<your passphrase>"
}
```
## Access rules
Organization scope comes from the persisted session, never from an administration request body. Email uniqueness is per organization and normalized to lowercase. New accounts have no roles and cannot sign in until approved. Multiple roles combine their permissions. Permissions are reloaded on every request and rechecked inside administration transactions.
Administrators cannot grant permissions they do not hold, modify a more privileged role, change their own role assignments, assign the system Owner role, or change the owner's approval state. A single installation owner is created by the CLI; API-supplied owner flags are rejected. Suspending a user revokes sessions and recovery tokens; reactivation does not restore them.
An organization represents an access boundary, not a customer or supplier by default. No organization onboarding API, SaaS subscription isolation, MFA, SSO or social login is included in this milestone.
## Passwords, sessions and recovery
Passwords require 15128 characters and use salted asynchronous scrypt (N=32768, r=8, p=3). Session and recovery secrets use 256 bits of randomness; only SHA-256 token digests are persisted. Sessions expire after the configured absolute lifetime. No refresh-token flow is needed for this opaque-session design; clients sign in again after expiry.
Recovery links put the token in the URL fragment; the future frontend must read it, submit it to the reset endpoint and remove it from browser history. Never log request bodies or authorization headers at the reverse proxy. Recovery resets invalidate all sessions and other recovery tokens in the same transaction. A failed SMTP send discards that token and logs a sanitized failure; users can request again after the throttle window.
Recovery delivery is synchronous in this milestone. The response body does not reveal account existence, but response timing is not constant. Durable queued delivery and retries belong to the notification milestone before a public storefront launch. No real emails are sent by the test suite.
## Audit and abuse protection
Successful logins, known-account login failures, logout, password recovery/reset, user provisioning/approval/suspension and role changes are audited. Mutations and audit inserts share a transaction. Audit rows accept no arbitrary metadata and database triggers reject updates/deletes. Deploy with a restricted application database role; database owners can bypass database controls.
Auth routes share a database-backed IP limit of 30 requests/minute. Login also permits 10 attempts/account/15 minutes; recovery requests permit 3/account/15 minutes. Limits persist across instances. Proxy trust is disabled: behind a proxy, IP throttling groups requests under its address until an explicitly trusted proxy policy is configured. Use an edge rate limiter as well before public launch.
Expired sessions, recovery tokens and rate-limit rows can be removed by a controlled housekeeping job using their indexed expiry columns. Audit retention requires an explicit archival policy and privileged maintenance procedure; the API never deletes audits.
References: [Node crypto](https://nodejs.org/api/crypto.html), [Nodemailer SMTP](https://nodemailer.com/smtp).

View File

@ -0,0 +1,39 @@
# Identity setup and release
## Database and owner bootstrap
1. Set DATABASE_URL for the intended development PostgreSQL database.
2. Install with the lockfile, run `pnpm db:generate`, then `pnpm db:deploy`.
3. Run `pnpm check` and `pnpm build`.
4. Supply ORGANIZATION_NAME, OWNER_NAME, OWNER_EMAIL and OWNER_PASSWORD through environment variables. The password must be 15128 characters. Do not pass secrets in CLI arguments or commit them.
5. Run `pnpm bootstrap:owner`. It prints only organizationId and userId. Remove bootstrap environment secrets after use.
6. Start the application and use the returned organizationId to log in.
Bootstrap is a one-time installation operation protected by a transaction advisory lock and a unique owner index. A repeat invocation fails without creating partial records. Never bootstrap a production owner using test credentials. There is no default password.
## Optional recovery email
Set all of SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM and RECOVERY_URL together. SMTP_PORT defaults to 587; port 465 uses implicit TLS. TLS is mandatory. RECOVERY_URL must be an HTTPS frontend reset page without credentials, a query string or a fragment.
Until SMTP is configured, recovery requests return 503 for every account. The rest of identity remains usable. SESSION_TTL_MINUTES defaults to 480 (510080 allowed); RECOVERY_TTL_MINUTES defaults to 15 (560 allowed). SMTP settings are validated at startup but connectivity is only exercised on delivery.
No production SMTP account, reset frontend page, deployment environment or live owner was provisioned in this development task.
## Migrations and release checks
- 202609080002_identity_access adds users, roles, organization-safe assignments, sessions, recovery tokens, audit events and durable rate-limit buckets.
- 202609080003_identity_integrity adds normalized-email and single-owner constraints plus append-only audit triggers.
- Existing organization rows are retained. The migrations add no default user or credential.
- Custom SQL constraints/triggers are intentional and cannot all be represented in the Prisma model. Their migration tests must remain enabled.
- Run `node scripts/verify-migrations.mjs` against a disposable PostgreSQL database to verify deploy, status and model drift.
- Back up the target database, run deploy once per release, then verify readiness and the identity smoke flow. Do not use destructive down migrations to remove identity data. Roll back application binaries only when schema-compatible; otherwise apply a forward fix.
## Automated checks
`pnpm check` runs formatting, schema validation, strict type checks, unit tests, integration tests with coverage and production compilation. Tests use an embedded PostgreSQL engine by default and apply every checked-in migration, including upgrading a seeded foundation organization.
For native PostgreSQL tests, set TEST_DATABASE_URL to a disposable PostgreSQL administrator connection. Each fixture creates and drops a randomly named test database. Never supply production credentials. The concurrent recovery test runs only on native PostgreSQL because the embedded engine does not reproduce independent PostgreSQL connection concurrency.
The Gitea workflow targets a Docker-capable act runner labelled ubuntu-latest, with Actions enabled and internet access for dependency installation. It provides PostgreSQL 17, runs native tests and verifies migrations. Runner availability, remote CI results and branch-protection rules must be checked on the repository host; committing a workflow does not enable these settings automatically.
Before public launch, complete the reset frontend, SMTP smoke test, queued notification delivery, trusted proxy policy, operational housekeeping, and native database CI. Customer self-registration is separate from staff provisioning.

View File

@ -4,8 +4,8 @@ Source: Mani Candles Commerce Platform specification and project pack created in
## Phase 1: Foundation and core commerce
- 1A: service bootstrap, configuration, database lifecycle, initial organization migration, health API, test/build baseline, team workflow.
- 1B: authentication, sessions, recovery, users, configurable RBAC, organization access, approval status, audit events. Test denied access and cross-organization access.
- 1A (implemented): service bootstrap, configuration, database lifecycle, initial organization migration, health API, test/build baseline, team workflow.
- 1B (implemented; native CI and deployment configuration pending): authentication, sessions, recovery, users, configurable RBAC, organization access, approval status, audit events. Test denied access and cross-organization access.
- 1C: catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock reconciliation.
- 1D: cart, checkout, orders, coupons and pricing snapshots. Test money precision, discount eligibility, retries and transaction rollback.
- 1E: verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation.
@ -32,4 +32,4 @@ Small cohesive files; no duplicated business rules; unit tests for success, fail
## Decisions still needed
Git author identity; remote connectivity; provider accounts; deployment target and PostgreSQL service; tax/invoice and retention rules; stock location model; approval thresholds; Etsy sync direction; launch feature cut. Resolve each before its dependent milestone.
Provider accounts; deployment target and PostgreSQL service; tax/invoice and retention rules; stock location model; approval thresholds; Etsy sync direction; launch feature cut. Resolve each before its dependent milestone.

View File

@ -1,7 +1,16 @@
# Verification record
# Verification record — Phase 1B
Phase 1A: 19 tests pass across configuration, database lifecycle, and HTTP health/security behavior. Formatting, Prisma schema validation, TypeScript checking and production compilation pass. Coverage excludes generated client, module declarations and bootstrap: 100% statements/lines/functions, 85.71% branches.
Completed locally:
Tests use mocked database connectivity. A real PostgreSQL instance is not installed/configured in this workspace. Run `node scripts/verify-migrations.mjs` with DATABASE_URL pointing to a disposable PostgreSQL database; it applies migrations, checks status and checks schema drift. Repeat against a restored prior-release snapshot for future releases. This script applies migrations and must never target an unapproved production database.
- 86 passing tests across 13 suites; one native PostgreSQL concurrency test is intentionally skipped without TEST_DATABASE_URL.
- 100% statements, lines and functions; 86.11% branches for hand-written application code. Generated code, Nest module declarations and CLI/HTTP entrypoint wrappers are excluded. Bootstrap business logic is tested.
- Formatting, Prisma schema validation, strict TypeScript/unused-code checks and production compilation pass.
- All three migrations execute through Prisma migrate deploy; migrate status reports up to date and schema diff reports no drift against a disposable embedded PostgreSQL engine.
- Migration tests preserve a pre-existing organization row, reject cross-organization role assignments, enforce normalized email and one owner, and prevent audit updates/deletes.
- API tests cover pending/suspended accounts, revoked/expired sessions, current permissions, privilege escalation, recovery replay, throttling and transaction rollback.
SSH remote access failed host-key verification. No remote history was fetched and no commits were pushed. The feature branch is provisional until remote history is checked. Git author is configured locally as mihir <motiyanimihir@gmail.com>. Do not merge an unrelated root history into an existing repository; fetch and base the feature work on its default branch first.
Native PostgreSQL concurrency and the remote Gitea workflow have not been verified locally. The workflow provisions PostgreSQL 17 and enables the native test path. A Docker-capable Gitea runner and Actions access are required.
Recovery email is tested using a mock SMTP adapter; no external email was sent. Live SMTP, a frontend recovery page and production deployment/owner bootstrap remain environment setup tasks. Recovery email is synchronous pending the later notification queue milestone.
Git identity: mihir <motiyanimihir@gmail.com>. Work is based on the fetched main branch and lives on feat/identity-access.

View File

@ -7,6 +7,7 @@ module.exports = {
'src/**/*.ts',
'!src/generated/**',
'!src/main.ts',
'!src/cli/**',
'!src/**/*.module.ts',
],
coverageThreshold: {

View File

@ -10,8 +10,8 @@
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"dev": "tsc -p tsconfig.build.json --watch",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand",
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --coverage",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
@ -21,7 +21,9 @@
"db:deploy": "prisma migrate deploy",
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"start:watch": "node --watch dist/main.js",
"db:status": "prisma migrate status"
"db:status": "prisma migrate status",
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
"db:test": "node scripts/test-migrations.mjs"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
@ -31,17 +33,23 @@
"@prisma/client": "^7.0.0",
"dotenv": "^17.0.0",
"helmet": "^8.0.0",
"nodemailer": "^10.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"zod": "^4.0.0"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite-socket": "^0.2.11",
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.23.1",
"@types/supertest": "^6.0.0",
"jest": "^30.0.0",
"pg": "^8.23.0",
"prettier": "^3.0.0",
"prisma": "^7.0.0",
"supertest": "^7.0.0",

View File

@ -29,6 +29,9 @@ importers:
helmet:
specifier: ^8.0.0
version: 8.3.0
nodemailer:
specifier: ^10.0.1
version: 10.0.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -39,6 +42,12 @@ importers:
specifier: ^4.0.0
version: 4.5.4
devDependencies:
'@electric-sql/pglite':
specifier: ^0.5.8
version: 0.5.8
'@electric-sql/pglite-socket':
specifier: ^0.2.11
version: 0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8)
'@nestjs/testing':
specifier: ^11.1.0
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)
@ -51,12 +60,21 @@ importers:
'@types/node':
specifier: ^24.0.0
version: 24.13.3
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/pg':
specifier: ^8.23.1
version: 8.23.1
'@types/supertest':
specifier: ^6.0.0
version: 6.0.3
jest:
specifier: ^30.0.0
version: 30.5.1(@types/node@24.13.3)(supports-color@8.1.1)
pg:
specifier: ^8.23.0
version: 8.23.0
prettier:
specifier: ^3.0.0
version: 3.9.6
@ -243,12 +261,60 @@ packages:
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@electric-sql/pglite-age@0.0.9':
resolution: {integrity: sha512-IdNy5P5nxwKVeSPLjDLXkp/eYffH5roympM7fTG+g9ehzBrDnQ9TT2tfCJ2vRVcOSPQBp4oURzfRE19cs/x8ZA==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_hashids@0.0.9':
resolution: {integrity: sha512-NffsZH+FaRLp0WzsYG35BW/wOHLsTs325zjgp+PnKlWeGyW523Mbw4EZ1Awm02C3QWBU9p8Y2Fk02Al5NpRGUg==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_ivm@0.0.9':
resolution: {integrity: sha512-mbTcDLQyzeOG4rX6LLHhWDPJeQUv2xHvayKtf4oE7xujWEGWEDUEJP1+U0pXXELtIoWiUzzPBWjzKY1mRisqkQ==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_textsearch@0.0.10':
resolution: {integrity: sha512-tsL1rTYU4tzIC44htL7/0nS6C2XC5Srz0tfxU1LJelCqNIBkpFI9Esw1JbuVCaQKUHGDbPgAz8JYavNxwbuRVw==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_uuidv7@0.0.9':
resolution: {integrity: sha512-n/JbzoQMF9jFKFxGLRIzUfC1O4V2vTE0Pt3uoAO6/EP7dX5m1h4zBdezQAcT2JFostD0YWUdzHj9iYvcbUGM1A==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pgtap@0.0.9':
resolution: {integrity: sha512-VIyclfOawUN0IJROKiCnrVxsZQEG7tixgiC8c8nxp9TlBhI79MZqotoqlqvdPzGHmjWZlcgJ7tC8InOcy+2A/A==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pgvector@0.0.9':
resolution: {integrity: sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==}
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-socket@0.1.3':
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
hasBin: true
peerDependencies:
'@electric-sql/pglite': 0.4.3
'@electric-sql/pglite-socket@0.2.11':
resolution: {integrity: sha512-DKnJBf7+5zFCXXGZAGUvGoWKetPPvaQ7fLSGVqP5onzgBhV9vvTcHigireMROMglgovvhoXHaMOKqt69/vts4A==}
hasBin: true
peerDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-age': 0.0.9
'@electric-sql/pglite-pg_hashids': 0.0.9
'@electric-sql/pglite-pg_ivm': 0.0.9
'@electric-sql/pglite-pg_textsearch': 0.0.10
'@electric-sql/pglite-pg_uuidv7': 0.0.9
'@electric-sql/pglite-pgtap': 0.0.9
'@electric-sql/pglite-pgvector': 0.0.9
'@electric-sql/pglite-tools@0.3.3':
resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==}
peerDependencies:
@ -257,6 +323,9 @@ packages:
'@electric-sql/pglite@0.4.3':
resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==}
'@electric-sql/pglite@0.5.8':
resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@ -936,6 +1005,9 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@ -2138,6 +2210,10 @@ packages:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
nodemailer@10.0.1:
resolution: {integrity: sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==}
engines: {node: '>=20.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -2936,16 +3012,57 @@ snapshots:
'@borewit/text-codec@0.2.2': {}
'@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)':
dependencies:
'@electric-sql/pglite': 0.4.3
'@electric-sql/pglite-socket@0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8)':
dependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite-age': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pg_hashids': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pg_ivm': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pg_textsearch': 0.0.10(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pg_uuidv7': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pgtap': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-pgvector': 0.0.9(@electric-sql/pglite@0.5.8)
'@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)':
dependencies:
'@electric-sql/pglite': 0.4.3
'@electric-sql/pglite@0.4.3': {}
'@electric-sql/pglite@0.5.8': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@ -3666,6 +3783,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 24.13.3
'@types/pg@8.23.1':
dependencies:
'@types/node': 24.13.3
@ -5041,6 +5162,8 @@ snapshots:
node-releases@2.0.54: {}
nodemailer@10.0.1: {}
normalize-path@3.0.0: {}
npm-run-path@4.0.1:

View File

@ -0,0 +1,142 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"email" VARCHAR(254) NOT NULL,
"name" VARCHAR(160) NOT NULL,
"password_hash" VARCHAR(256) NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'PENDING',
"is_owner" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(80) NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"permissions" TEXT[],
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"user_id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"token_hash" CHAR(64) NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "recovery_tokens" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"token_hash" CHAR(64) NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "recovery_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_events" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"actor_id" UUID,
"action" VARCHAR(80) NOT NULL,
"target_id" UUID,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rate_limits" (
"key" CHAR(64) NOT NULL,
"hits" INTEGER NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE INDEX "users_organization_id_created_at_id_idx" ON "users"("organization_id", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "users_organization_id_email_key" ON "users"("organization_id", "email");
-- CreateIndex
CREATE UNIQUE INDEX "users_id_organization_id_key" ON "users"("id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "roles_organization_id_name_key" ON "roles"("organization_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "roles_id_organization_id_key" ON "roles"("id", "organization_id");
-- CreateIndex
CREATE INDEX "user_roles_role_id_organization_id_idx" ON "user_roles"("role_id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "recovery_tokens_token_hash_key" ON "recovery_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "recovery_tokens_user_id_idx" ON "recovery_tokens"("user_id");
-- CreateIndex
CREATE INDEX "recovery_tokens_expires_at_idx" ON "recovery_tokens"("expires_at");
-- CreateIndex
CREATE INDEX "audit_events_organization_id_created_at_id_idx" ON "audit_events"("organization_id", "created_at", "id");
-- CreateIndex
CREATE INDEX "rate_limits_expires_at_idx" ON "rate_limits"("expires_at");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "roles" ADD CONSTRAINT "roles_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_organization_id_fkey" FOREIGN KEY ("role_id", "organization_id") REFERENCES "roles"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recovery_tokens" ADD CONSTRAINT "recovery_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
-- Database-level invariants complement HTTP validation.
ALTER TABLE users ADD CONSTRAINT users_email_normalized CHECK (email = lower(email));
CREATE UNIQUE INDEX users_single_owner ON users (is_owner) WHERE is_owner = true;
-- Application-facing audit history is append-only. A dedicated DBA retention
-- procedure is required if records ever need to be archived or removed.
CREATE FUNCTION reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Audit events are append-only';
END;
$$;
CREATE TRIGGER audit_events_append_only
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();

View File

@ -3,17 +3,101 @@ generator client {
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
// The owning business. Supplier/customer organizations belong to later modules.
enum UserStatus {
PENDING
ACTIVE
SUSPENDED
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(160)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
users User[]
roles Role[]
auditEvents AuditEvent[]
@@map("organizations")
}
model User {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
email String @db.VarChar(254)
name String @db.VarChar(160)
passwordHash String @map("password_hash") @db.VarChar(256)
status UserStatus @default(PENDING)
isOwner Boolean @default(false) @map("is_owner")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
roles UserRole[]
sessions Session[]
recoveryTokens RecoveryToken[]
@@unique([organizationId, email])
@@unique([id, organizationId])
@@index([organizationId, createdAt, id])
@@map("users")
}
model Role {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(80)
isSystem Boolean @default(false) @map("is_system")
permissions String[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
users UserRole[]
@@unique([organizationId, name])
@@unique([id, organizationId])
@@map("roles")
}
model UserRole {
userId String @map("user_id") @db.Uuid
roleId String @map("role_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
role Role @relation(fields: [roleId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([userId, roleId])
@@index([roleId, organizationId])
@@map("user_roles")
}
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash") @db.Char(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
model RecoveryToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash") @db.Char(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("recovery_tokens")
}
model AuditEvent {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
actorId String? @map("actor_id") @db.Uuid
action String @db.VarChar(80)
targetId String? @map("target_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
@@index([organizationId, createdAt, id])
@@map("audit_events")
}
model RateLimit {
key String @id @db.Char(64)
hits Int
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
@@index([expiresAt])
@@map("rate_limits")
}

View File

@ -0,0 +1,24 @@
import { PGlite } from '@electric-sql/pglite';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
import { spawn } from 'node:child_process';
const db = await PGlite.create();
const server = new PGLiteSocketServer({ db, port: 0, host: '127.0.0.1' });
await server.start();
try {
const status = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['scripts/verify-migrations.mjs'], {
stdio: 'inherit',
env: {
...process.env,
DATABASE_URL: `postgresql://postgres:postgres@${server.getServerConn()}/postgres`,
},
});
child.once('error', reject);
child.once('exit', (code) => resolve(code ?? 1));
});
process.exitCode = status;
} finally {
await server.stop();
await db.close();
}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module';
import { HealthModule } from './health/health.module';
@Module({ imports: [EnvironmentModule, HealthModule] })
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
export class AppModule {}

View File

@ -0,0 +1,40 @@
import 'reflect-metadata';
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { z } from 'zod';
import { AppModule } from '../app.module';
import { BootstrapService } from '../identity/bootstrap.service';
import { createUserSchema } from '../identity/identity.schemas';
async function main() {
const input = createUserSchema.safeParse({
email: process.env.OWNER_EMAIL,
name: process.env.OWNER_NAME,
password: process.env.OWNER_PASSWORD,
});
const name = z
.string()
.trim()
.min(1)
.max(160)
.safeParse(process.env.ORGANIZATION_NAME);
if (!input.success || !name.success)
throw new Error('Invalid bootstrap configuration');
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
try {
const result = await app
.get(BootstrapService)
.createOwner(name.data, input.data);
console.log(JSON.stringify(result));
} finally {
await app.close();
}
}
void main().catch(() => {
console.error(
'Bootstrap failed. Check configuration, database availability, and whether an owner already exists.',
);
process.exitCode = 1;
});

View File

@ -0,0 +1,18 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { z } from 'zod';
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: z.ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Invalid request',
fields: [
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
],
});
}
return result.data;
}
}

View File

@ -1,6 +1,9 @@
import { identityEnvironmentShape, validateSmtp } from './identity-environment';
import { z } from 'zod';
const schema = z.object({
const schema = z
.object({
...identityEnvironmentShape,
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
@ -26,7 +29,8 @@ const schema = z.object({
}),
),
),
});
})
.superRefine(validateSmtp);
export type Environment = z.infer<typeof schema>;

View File

@ -0,0 +1,53 @@
import { z } from 'zod';
export const identityEnvironmentShape = {
SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(10080).default(480),
RECOVERY_TTL_MINUTES: z.coerce.number().int().min(5).max(60).default(15),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASSWORD: z.string().min(1).optional(),
SMTP_FROM: z.email().optional(),
RECOVERY_URL: z
.url()
.refine((value) => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return (
url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.hash &&
!url.search
);
})
.optional(),
};
export function validateSmtp(
input: {
SMTP_HOST?: string;
SMTP_USER?: string;
SMTP_PASSWORD?: string;
SMTP_FROM?: string;
RECOVERY_URL?: string;
},
context: z.RefinementCtx,
) {
const fields = [
'SMTP_HOST',
'SMTP_USER',
'SMTP_PASSWORD',
'SMTP_FROM',
'RECOVERY_URL',
] as const;
if (fields.some((field) => input[field] !== undefined)) {
for (const field of fields) {
if (!input[field])
context.addIssue({
code: 'custom',
path: [field],
message: 'Required for recovery email',
});
}
}
}

View File

@ -1,6 +1,8 @@
import { Public } from '../identity/access.decorator';
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service';
@Public()
@Controller('health')
export class HealthController {
constructor(private readonly health: HealthService) {}

View File

@ -0,0 +1,17 @@
import {
SetMetadata,
createParamDecorator,
ExecutionContext,
} from '@nestjs/common';
import type { AuthenticatedRequest } from './identity.types';
import type { Permission } from './permissions';
export const PUBLIC_ROUTE = Symbol('PUBLIC_ROUTE');
export const REQUIRED_PERMISSION = Symbol('REQUIRED_PERMISSION');
export const Public = () => SetMetadata(PUBLIC_ROUTE, true);
export const RequirePermission = (permission: Permission) =>
SetMetadata(REQUIRED_PERMISSION, permission);
export const CurrentPrincipal = createParamDecorator(
(_: unknown, context: ExecutionContext) =>
context.switchToHttp().getRequest<AuthenticatedRequest>().principal,
);

View File

@ -0,0 +1,37 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SessionStore } from './session.store';
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
import type { AuthenticatedRequest } from './identity.types';
@Injectable()
export class AccessGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionStore,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride<boolean>(PUBLIC_ROUTE, targets))
return true;
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
request.headers.authorization ?? '',
);
if (!match) throw new UnauthorizedException();
request.principal = await this.sessions.authenticate(match[1]);
const permission = this.reflector.getAllAndOverride<string>(
REQUIRED_PERMISSION,
targets,
);
if (permission && !request.principal.permissions.includes(permission))
throw new ForbiddenException();
return true;
}
}

View File

@ -0,0 +1,45 @@
import {
ConflictException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { Prisma } from '../generated/prisma/client';
import { readPrincipal } from './session.store';
import type { Principal } from './identity.types';
import type { Permission } from './permissions';
@Injectable()
export class AccessStore {
constructor(private readonly db: DatabaseService) {}
async mutate<T>(
actor: Principal,
permission: Permission,
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
): Promise<T> {
try {
return await this.db.$transaction(async (tx) => {
// Serialize administration within an organization and recheck permissions after locking.
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
const current = await readPrincipal(tx, actor.sessionId);
if (
current.organizationId !== actor.organizationId ||
!current.permissions.includes(permission)
) {
throw new ForbiddenException();
}
return work(tx, current);
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(
'A record with these details already exists',
);
}
throw error;
}
}
}

View File

@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { pageSchema, type PageInput } from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('audit-events')
export class AuditController {
constructor(private readonly db: DatabaseService) {}
@Get()
@RequirePermission('audit.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.db.auditEvent.findMany({
where: { organizationId: actor.organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: page.limit,
skip: page.offset,
});
}
}

14
src/identity/audit.ts Normal file
View File

@ -0,0 +1,14 @@
import type { Prisma } from '../generated/prisma/client';
export function recordAudit(
tx: Prisma.TransactionClient,
organizationId: string,
actorId: string | null,
action: string,
targetId: string | null = null,
) {
// Deliberately accept no arbitrary payload: passwords and tokens cannot enter audit data.
return tx.auditEvent.create({
data: { organizationId, actorId, action, targetId },
});
}

View File

@ -0,0 +1,14 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { RateLimitService } from './rate-limit.service';
@Injectable()
export class AuthRateGuard implements CanActivate {
constructor(private readonly limits: RateLimitService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
// Express proxy trust remains disabled; never trust arbitrary forwarded IP headers.
await this.limits.consume(`auth-ip:${request.ip}`, 30, 60);
return true;
}
}

View File

@ -0,0 +1,50 @@
import {
Body,
Controller,
Get,
Header,
HttpCode,
Post,
UseGuards,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { AuthService } from './auth.service';
import { AuthStore } from './auth.store';
import { AuthRateGuard } from './auth-rate.guard';
import { CurrentPrincipal, Public } from './access.decorator';
import { loginSchema, type LoginInput } from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('auth')
@UseGuards(AuthRateGuard)
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly store: AuthStore,
) {}
@Public()
@Post('login')
@HttpCode(200)
@Header('Cache-Control', 'no-store')
login(@Body(new SchemaPipe(loginSchema)) input: LoginInput) {
return this.auth.login(input);
}
@Get('me')
@Header('Cache-Control', 'no-store')
me(@CurrentPrincipal() principal: Principal) {
return principal;
}
@Post('logout')
@HttpCode(204)
logout(@CurrentPrincipal() principal: Principal) {
return this.store.logout(principal, false);
}
@Post('logout-all')
@HttpCode(204)
logoutAll(@CurrentPrincipal() principal: Principal) {
return this.store.logout(principal, true);
}
}

View File

@ -0,0 +1,44 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
import { AuthStore } from './auth.store';
import { PasswordService } from './password.service';
import { RateLimitService } from './rate-limit.service';
import { issueToken } from './tokens';
import type { LoginInput } from './identity.schemas';
@Injectable()
export class AuthService {
constructor(
private readonly store: AuthStore,
private readonly passwords: PasswordService,
private readonly limits: RateLimitService,
@Inject(ENVIRONMENT) private readonly env: Environment,
) {}
async login(input: LoginInput) {
await this.limits.consume(
`login:${input.organizationId}:${input.email}`,
10,
900,
);
const user = await this.store.findUser(input.organizationId, input.email);
const valid = user
? await this.passwords.verify(input.password, user.passwordHash)
: (await this.passwords.dummyVerify(input.password), false);
if (!valid || !user || user.status !== 'ACTIVE') {
if (user) await this.store.failedLogin(user.organizationId, user.id);
throw new UnauthorizedException('Invalid credentials');
}
const { token, tokenHash } = issueToken();
const expiresAt = new Date(
Date.now() + this.env.SESSION_TTL_MINUTES * 60_000,
);
await this.store.createSession(
user.id,
user.passwordHash,
tokenHash,
expiresAt,
);
return { accessToken: token, tokenType: 'Bearer', expiresAt };
}
}

View File

@ -0,0 +1,57 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
@Injectable()
export class AuthStore {
constructor(private readonly db: DatabaseService) {}
findUser(organizationId: string, email: string) {
return this.db.user.findUnique({
where: { organizationId_email: { organizationId, email } },
});
}
async createSession(
userId: string,
expectedHash: string,
tokenHash: string,
expiresAt: Date,
) {
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash)
throw new UnauthorizedException();
await tx.session.create({ data: { userId, tokenHash, expiresAt } });
await recordAudit(
tx,
user.organizationId,
user.id,
'auth.login',
user.id,
);
});
}
async logout(principal: Principal, all: boolean) {
await this.db.$transaction(async (tx) => {
await tx.session.deleteMany({
where: all ? { userId: principal.userId } : { id: principal.sessionId },
});
await recordAudit(
tx,
principal.organizationId,
principal.userId,
all ? 'auth.logout_all' : 'auth.logout',
);
});
}
async failedLogin(organizationId: string, userId: string) {
await recordAudit(
this.db,
organizationId,
userId,
'auth.login_failed',
userId,
);
}
}

View File

@ -0,0 +1,59 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { PasswordService } from './password.service';
import { PERMISSIONS } from './permissions';
import { recordAudit } from './audit';
import type { CreateUserInput } from './identity.schemas';
@Injectable()
export class BootstrapService {
constructor(
private readonly db: DatabaseService,
private readonly passwords: PasswordService,
) {}
async createOwner(organizationName: string, input: CreateUserInput) {
const passwordHash = await this.passwords.hash(input.password);
return this.db.$transaction(async (tx) => {
// One-time installation bootstrap, serialized across processes.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
if (await tx.user.count({ where: { isOwner: true } }))
throw new ConflictException('Owner already exists');
const organization = await tx.organization.create({
data: { name: organizationName },
});
const role = await tx.role.create({
data: {
organizationId: organization.id,
name: 'Owner',
isSystem: true,
permissions: [...PERMISSIONS],
},
});
const user = await tx.user.create({
data: {
organizationId: organization.id,
email: input.email,
name: input.name,
passwordHash,
isOwner: true,
status: 'ACTIVE',
},
});
await tx.userRole.create({
data: {
userId: user.id,
roleId: role.id,
organizationId: organization.id,
},
});
await recordAudit(
tx,
organization.id,
user.id,
'installation.bootstrapped',
user.id,
);
return { organizationId: organization.id, userId: user.id };
});
}
}

View File

@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { DatabaseModule } from '../database/database.module';
import { AccessGuard } from './access.guard';
import { AccessStore } from './access.store';
import { AuthRateGuard } from './auth-rate.guard';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthStore } from './auth.store';
import { AuditController } from './audit.controller';
import { BootstrapService } from './bootstrap.service';
import { PasswordService } from './password.service';
import { RateLimitService } from './rate-limit.service';
import { RecoveryController } from './recovery.controller';
import { RecoveryMailer } from './recovery-mailer';
import { RecoveryService } from './recovery.service';
import { RecoveryStore } from './recovery.store';
import { RoleStore } from './role.store';
import { RolesController } from './roles.controller';
import { SessionStore } from './session.store';
import { UserStore } from './user.store';
import { UsersController } from './users.controller';
@Module({
imports: [DatabaseModule],
controllers: [
AuthController,
RecoveryController,
UsersController,
RolesController,
AuditController,
],
providers: [
AccessStore,
AuthRateGuard,
AuthService,
AuthStore,
BootstrapService,
PasswordService,
RateLimitService,
RecoveryMailer,
RecoveryService,
RecoveryStore,
RoleStore,
SessionStore,
UserStore,
{ provide: APP_GUARD, useClass: AccessGuard },
],
exports: [BootstrapService],
})
export class IdentityModule {}

View File

@ -0,0 +1,55 @@
import { z } from 'zod';
import { PERMISSIONS } from './permissions';
export const email = z
.email()
.max(254)
.transform((value) => value.toLowerCase());
export const password = z.string().min(15).max(128);
const name = z.string().trim().min(1).max(160);
export const loginSchema = z
.object({
organizationId: z.uuid(),
email,
password: z.string().min(1).max(128),
})
.strict();
export const recoveryRequestSchema = loginSchema.omit({ password: true });
export const recoveryResetSchema = z
.object({
token: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
password,
})
.strict();
export const createUserSchema = z.object({ email, name, password }).strict();
export const statusSchema = z
.object({ status: z.enum(['ACTIVE', 'SUSPENDED']) })
.strict();
export const roleSchema = z
.object({
name: z.string().trim().min(1).max(80),
permissions: z
.array(z.enum(PERMISSIONS))
.max(PERMISSIONS.length)
.refine((values) => new Set(values).size === values.length),
})
.strict();
export const assignmentsSchema = z
.object({
roleIds: z
.array(z.uuid())
.max(20)
.refine((ids) => new Set(ids).size === ids.length),
})
.strict();
export const pageSchema = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(25),
offset: z.coerce.number().int().min(0).max(10000).default(0),
})
.strict();
export type LoginInput = z.infer<typeof loginSchema>;
export type RecoveryInput = z.infer<typeof recoveryRequestSchema>;
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type RoleInput = z.infer<typeof roleSchema>;
export type PageInput = z.infer<typeof pageSchema>;

View File

@ -0,0 +1,10 @@
import type { Request } from 'express';
export interface Principal {
userId: string;
organizationId: string;
sessionId: string;
permissions: string[];
}
export interface AuthenticatedRequest extends Request {
principal: Principal;
}

View File

@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const COST = 32768;
function derive(password: string, salt: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(
password,
salt,
64,
{ N: COST, r: 8, p: 3, maxmem: 64 * 1024 * 1024 },
(error, key) => (error ? reject(error) : resolve(key)),
);
});
}
@Injectable()
export class PasswordService {
async hash(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const key = await derive(password, salt);
return `scrypt-v1$${salt}$${key.toString('hex')}`;
}
async verify(password: string, encoded: string): Promise<boolean> {
const [version, salt, hash, extra] = encoded.split('$');
if (
version !== 'scrypt-v1' ||
!/^[a-f0-9]{32}$/.test(salt ?? '') ||
!/^[a-f0-9]{128}$/.test(hash ?? '') ||
extra !== undefined
)
return false;
return timingSafeEqual(
await derive(password, salt),
Buffer.from(hash, 'hex'),
);
}
// Equal-cost work for an unknown account; no dummy credentials are usable.
async dummyVerify(password: string): Promise<void> {
await derive(password, '00000000000000000000000000000000');
}
}

View File

@ -0,0 +1,10 @@
export const PERMISSIONS = [
'users.read',
'users.create',
'users.approve',
'users.roles.assign',
'roles.read',
'roles.manage',
'audit.read',
] as const;
export type Permission = (typeof PERMISSIONS)[number];

View File

@ -0,0 +1,25 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { hashToken } from './tokens';
@Injectable()
export class RateLimitService {
constructor(private readonly db: DatabaseService) {}
async consume(key: string, limit: number, seconds: number): Promise<void> {
const digest = hashToken(key);
const [row] = await this.db.$queryRaw<Array<{ hits: number }>>`
INSERT INTO rate_limits (key, hits, expires_at)
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
ON CONFLICT (key) DO UPDATE SET
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END,
expires_at = CASE WHEN rate_limits.expires_at <= NOW()
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
RETURNING hits
`;
if (row.hits > limit)
throw new HttpException(
'Too many requests',
HttpStatus.TOO_MANY_REQUESTS,
);
}
}

View File

@ -0,0 +1,47 @@
import {
Inject,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { createTransport } from 'nodemailer';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
@Injectable()
export class RecoveryMailer {
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
assertConfigured(): void {
if (!this.env.SMTP_HOST)
throw new ServiceUnavailableException(
'Password recovery is not configured',
);
}
async send(email: string, token: string): Promise<void> {
this.assertConfigured();
const transport = createTransport({
host: this.env.SMTP_HOST,
port: this.env.SMTP_PORT,
secure: this.env.SMTP_PORT === 465,
requireTLS: true,
auth: { user: this.env.SMTP_USER!, pass: this.env.SMTP_PASSWORD! },
connectionTimeout: 5000,
greetingTimeout: 5000,
socketTimeout: 10000,
disableFileAccess: true,
disableUrlAccess: true,
});
const url = new URL(this.env.RECOVERY_URL!);
// Fragment avoids putting the secret into ordinary HTTP access logs.
url.hash = new URLSearchParams({ token }).toString();
try {
await transport.sendMail({
from: this.env.SMTP_FROM!,
to: email,
subject: 'Reset your Mani Candles password',
text: `A password reset was requested for your account. Open ${url.toString()} within ${this.env.RECOVERY_TTL_MINUTES} minutes. If this was not you, ignore this message.`,
});
} finally {
transport.close();
}
}
}

View File

@ -0,0 +1,39 @@
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { Public } from './access.decorator';
import { AuthRateGuard } from './auth-rate.guard';
import { RecoveryService } from './recovery.service';
import {
recoveryRequestSchema,
recoveryResetSchema,
type RecoveryInput,
} from './identity.schemas';
@Public()
@UseGuards(AuthRateGuard)
@Controller('auth/recovery')
export class RecoveryController {
constructor(private readonly recovery: RecoveryService) {}
@Post('request')
@HttpCode(202)
async request(
@Body(new SchemaPipe(recoveryRequestSchema)) input: RecoveryInput,
) {
await this.recovery.request(input);
return {
message:
'If the account is eligible, recovery instructions will be sent.',
};
}
@Post('reset')
@HttpCode(204)
reset(
@Body(new SchemaPipe(recoveryResetSchema))
input: {
token: string;
password: string;
},
) {
return this.recovery.reset(input.token, input.password);
}
}

View File

@ -0,0 +1,53 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
import { AuthStore } from './auth.store';
import { RecoveryStore } from './recovery.store';
import { RecoveryMailer } from './recovery-mailer';
import { RateLimitService } from './rate-limit.service';
import { PasswordService } from './password.service';
import { hashToken, issueToken } from './tokens';
import type { RecoveryInput } from './identity.schemas';
@Injectable()
export class RecoveryService {
private readonly logger = new Logger(RecoveryService.name);
constructor(
private readonly users: AuthStore,
private readonly store: RecoveryStore,
private readonly mailer: RecoveryMailer,
private readonly limits: RateLimitService,
private readonly passwords: PasswordService,
@Inject(ENVIRONMENT) private readonly env: Environment,
) {}
async request(input: RecoveryInput): Promise<void> {
this.mailer.assertConfigured();
await this.limits.consume(
`recovery:${input.organizationId}:${input.email}`,
3,
900,
);
const user = await this.users.findUser(input.organizationId, input.email);
if (!user || user.status !== 'ACTIVE') return;
const { token, tokenHash } = issueToken();
const created = await this.store.create(
user.id,
tokenHash,
new Date(Date.now() + this.env.RECOVERY_TTL_MINUTES * 60_000),
);
if (!created) return;
try {
await this.mailer.send(user.email, token);
} catch {
await this.store.discard(tokenHash);
// SMTP errors may contain credentials and addresses; do not log the raw error.
this.logger.error('Password recovery delivery failed');
}
}
async reset(token: string, password: string): Promise<void> {
await this.store.reset(
hashToken(token),
await this.passwords.hash(password),
);
}
}

View File

@ -0,0 +1,59 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit';
@Injectable()
export class RecoveryStore {
constructor(private readonly db: DatabaseService) {}
async create(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<boolean> {
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
if (user.status !== 'ACTIVE') return false;
await tx.recoveryToken.deleteMany({ where: { userId } });
await tx.recoveryToken.create({ data: { userId, tokenHash, expiresAt } });
await recordAudit(
tx,
user.organizationId,
null,
'auth.recovery_requested',
userId,
);
return true;
});
}
async discard(tokenHash: string) {
await this.db.recoveryToken.deleteMany({ where: { tokenHash } });
}
async reset(tokenHash: string, passwordHash: string) {
await this.db.$transaction(async (tx) => {
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
if (!token)
throw new BadRequestException('Invalid or expired recovery token');
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({
where: { id: token.userId },
});
const consumed = await tx.recoveryToken.deleteMany({
where: { id: token.id, expiresAt: { gt: new Date() } },
});
if (consumed.count !== 1 || user.status !== 'ACTIVE') {
throw new BadRequestException('Invalid or expired recovery token');
}
await tx.user.update({ where: { id: user.id }, data: { passwordHash } });
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });
await tx.session.deleteMany({ where: { userId: user.id } });
await recordAudit(
tx,
user.organizationId,
user.id,
'auth.password_reset',
user.id,
);
});
}
}

104
src/identity/role.store.ts Normal file
View File

@ -0,0 +1,104 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
import type { PageInput, RoleInput } from './identity.schemas';
function ensureGrantable(actor: Principal, permissions: string[]) {
if (
permissions.some((permission) => !actor.permissions.includes(permission))
) {
throw new ForbiddenException('Cannot grant permissions you do not hold');
}
}
@Injectable()
export class RoleStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, page: PageInput) {
return this.db.role.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
save(actor: Principal, input: RoleInput, id?: string) {
return this.access.mutate(actor, 'roles.manage', async (tx, current) => {
ensureGrantable(current, input.permissions);
if (id) {
const role = await tx.role.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!role) throw new NotFoundException();
if (role.isSystem)
throw new ForbiddenException('System role is immutable');
ensureGrantable(current, role.permissions);
}
const role = id
? await tx.role.update({ where: { id }, data: input })
: await tx.role.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'role.updated' : 'role.created',
role.id,
);
return role;
});
}
assign(actor: Principal, userId: string, roleIds: string[]) {
return this.access.mutate(
actor,
'users.roles.assign',
async (tx, current) => {
const user = await tx.user.findFirst({
where: { id: userId, organizationId: actor.organizationId },
include: { roles: { include: { role: true } } },
});
if (!user) throw new NotFoundException();
if (user.isOwner || user.id === current.userId)
throw new ForbiddenException('Cannot change these role assignments');
ensureGrantable(
current,
user.roles.flatMap((assignment) => assignment.role.permissions),
);
const roles = await tx.role.findMany({
where: { id: { in: roleIds }, organizationId: actor.organizationId },
});
if (roles.length !== roleIds.length) throw new NotFoundException();
if (roles.some((role) => role.isSystem))
throw new ForbiddenException('System role cannot be assigned');
ensureGrantable(
current,
roles.flatMap((role) => role.permissions),
);
await tx.userRole.deleteMany({ where: { userId } });
await tx.userRole.createMany({
data: roleIds.map((roleId) => ({
userId,
roleId,
organizationId: actor.organizationId,
})),
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'user.roles_assigned',
userId,
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { RoleStore } from './role.store';
import { PERMISSIONS } from './permissions';
import {
pageSchema,
roleSchema,
type PageInput,
type RoleInput,
} from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('roles')
export class RolesController {
constructor(private readonly roles: RoleStore) {}
@Get('permissions')
@RequirePermission('roles.read')
permissions() {
return PERMISSIONS;
}
@Get()
@RequirePermission('roles.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.roles.list(actor, page);
}
@Post()
@RequirePermission('roles.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(roleSchema)) input: RoleInput,
) {
return this.roles.save(actor, input);
}
@Patch(':id')
@RequirePermission('roles.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(roleSchema)) input: RoleInput,
) {
return this.roles.save(actor, input, id);
}
}

View File

@ -0,0 +1,42 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { Prisma } from '../generated/prisma/client';
import { hashToken } from './tokens';
import type { Principal } from './identity.types';
export const userAccess = { roles: { include: { role: true } } } as const;
export async function readPrincipal(
tx: Prisma.TransactionClient,
sessionId: string,
): Promise<Principal> {
const session = await tx.session.findUnique({
where: { id: sessionId },
include: { user: { include: userAccess } },
});
if (
!session ||
session.expiresAt <= new Date() ||
session.user.status !== 'ACTIVE'
) {
throw new UnauthorizedException();
}
return {
userId: session.userId,
organizationId: session.user.organizationId,
sessionId,
permissions: [
...new Set(session.user.roles.flatMap((item) => item.role.permissions)),
],
};
}
@Injectable()
export class SessionStore {
constructor(private readonly db: DatabaseService) {}
async authenticate(token: string): Promise<Principal> {
const session = await this.db.session.findUnique({
where: { tokenHash: hashToken(token) },
});
if (!session) throw new UnauthorizedException();
return readPrincipal(this.db, session.id);
}
}

8
src/identity/tokens.ts Normal file
View File

@ -0,0 +1,8 @@
import { createHash, randomBytes } from 'node:crypto';
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function issueToken(): { token: string; tokenHash: string } {
const token = randomBytes(32).toString('base64url');
return { token, tokenHash: hashToken(token) };
}

View File

@ -0,0 +1,91 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { PasswordService } from './password.service';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
import type { CreateUserInput, PageInput } from './identity.schemas';
const publicUser = {
id: true,
email: true,
name: true,
status: true,
isOwner: true,
createdAt: true,
} as const;
@Injectable()
export class UserStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
private readonly passwords: PasswordService,
) {}
list(actor: Principal, page: PageInput) {
return this.db.user.findMany({
where: { organizationId: actor.organizationId },
select: publicUser,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: page.limit,
skip: page.offset,
});
}
async create(actor: Principal, input: CreateUserInput) {
const passwordHash = await this.passwords.hash(input.password);
return this.access.mutate(actor, 'users.create', async (tx) => {
const user = await tx.user.create({
data: {
organizationId: actor.organizationId,
email: input.email,
name: input.name,
passwordHash,
},
select: publicUser,
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'user.created',
user.id,
);
return user;
});
}
async setStatus(
actor: Principal,
id: string,
status: 'ACTIVE' | 'SUSPENDED',
) {
return this.access.mutate(actor, 'users.approve', async (tx) => {
const user = await tx.user.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!user) throw new NotFoundException();
if (user.isOwner || user.id === actor.userId)
throw new ForbiddenException('Cannot change this account status');
const updated = await tx.user.update({
where: { id },
data: { status },
select: publicUser,
});
if (status === 'SUSPENDED') {
await tx.session.deleteMany({ where: { userId: id } });
await tx.recoveryToken.deleteMany({ where: { userId: id } });
}
await recordAudit(
tx,
actor.organizationId,
actor.userId,
`user.${status.toLowerCase()}`,
id,
);
return updated;
});
}
}

View File

@ -0,0 +1,67 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { UserStore } from './user.store';
import { RoleStore } from './role.store';
import {
createUserSchema,
statusSchema,
assignmentsSchema,
pageSchema,
type CreateUserInput,
type PageInput,
} from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('users')
export class UsersController {
constructor(
private readonly users: UserStore,
private readonly roles: RoleStore,
) {}
@Get()
@RequirePermission('users.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.users.list(actor, page);
}
@Post()
@RequirePermission('users.create')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(createUserSchema)) input: CreateUserInput,
) {
return this.users.create(actor, input);
}
@Patch(':id/status')
@RequirePermission('users.approve')
status(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(statusSchema))
input: { status: 'ACTIVE' | 'SUSPENDED' },
) {
return this.users.setStatus(actor, id, input.status);
}
@Patch(':id/roles')
@RequirePermission('users.roles.assign')
async assign(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(assignmentsSchema)) input: { roleIds: string[] },
) {
await this.roles.assign(actor, id, input.roleIds);
return { id };
}
}

127
test/admin-policy.spec.ts Normal file
View File

@ -0,0 +1,127 @@
import { UserStore } from '../src/identity/user.store';
import { RoleStore } from '../src/identity/role.store';
import { AccessStore } from '../src/identity/access.store';
import { DatabaseService } from '../src/database/database.service';
import { PasswordService } from '../src/identity/password.service';
import type { Principal } from '../src/identity/identity.types';
import type { Prisma } from '../src/generated/prisma/client';
describe('administration use-case policy', () => {
const actor: Principal = {
userId: 'actor',
organizationId: 'org',
sessionId: 'session',
permissions: ['roles.manage'],
};
const tx = {
user: { create: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
role: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
},
auditEvent: { create: jest.fn() },
session: { deleteMany: jest.fn() },
recoveryToken: { deleteMany: jest.fn() },
userRole: { deleteMany: jest.fn(), createMany: jest.fn() },
};
const access = { mutate: jest.fn() };
const passwords = { hash: jest.fn() };
const users = new UserStore(
{} as DatabaseService,
access as unknown as AccessStore,
passwords as unknown as PasswordService,
);
const roles = new RoleStore(
{} as DatabaseService,
access as unknown as AccessStore,
);
beforeEach(() => {
jest.resetAllMocks();
access.mutate.mockImplementation(
(
_actor,
_permission,
work: (tx: Prisma.TransactionClient, current: Principal) => unknown,
) => work(tx as unknown as Prisma.TransactionClient, actor),
);
});
it('creates pending users without passing plaintext to persistence', async () => {
passwords.hash.mockResolvedValue('encoded');
tx.user.create.mockResolvedValue({ id: 'new' });
await users.create(actor, {
email: 'a@example.com',
name: 'A',
password: 'secret',
});
expect(tx.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: {
organizationId: 'org',
email: 'a@example.com',
name: 'A',
passwordHash: 'encoded',
},
}),
);
expect(tx.auditEvent.create).toHaveBeenCalledWith({
data: {
organizationId: 'org',
actorId: 'actor',
action: 'user.created',
targetId: 'new',
},
});
});
it('hides users outside the organization scope', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
'Not Found',
);
expect(tx.user.update).not.toHaveBeenCalled();
});
it('revokes sessions and recovery on suspension', async () => {
tx.user.findFirst.mockResolvedValue({ id: 'employee', isOwner: false });
await users.setStatus(actor, 'employee', 'SUSPENDED');
expect(tx.session.deleteMany).toHaveBeenCalledWith({
where: { userId: 'employee' },
});
expect(tx.recoveryToken.deleteMany).toHaveBeenCalledWith({
where: { userId: 'employee' },
});
});
it('prevents modifying an existing role more powerful than the actor', async () => {
tx.role.findFirst.mockResolvedValue({
isSystem: false,
permissions: ['audit.read'],
});
await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'role'),
).rejects.toThrow('Cannot grant');
expect(tx.role.update).not.toHaveBeenCalled();
});
it('allows only known role targets in the actor organization', async () => {
tx.role.findFirst.mockResolvedValue(null);
await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
).rejects.toThrow('Not Found');
});
it('does not remove a target user permissions the actor cannot grant', async () => {
tx.user.findFirst.mockResolvedValue({
id: 'employee',
isOwner: false,
roles: [{ role: { permissions: ['audit.read'] } }],
});
await expect(roles.assign(actor, 'employee', [])).rejects.toThrow(
'Cannot grant',
);
expect(tx.userRole.deleteMany).not.toHaveBeenCalled();
});
it('hides missing assignment targets', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
'Not Found',
);
});
});

68
test/auth-service.spec.ts Normal file
View File

@ -0,0 +1,68 @@
import { AuthService } from '../src/identity/auth.service';
import { AuthStore } from '../src/identity/auth.store';
import { PasswordService } from '../src/identity/password.service';
import { RateLimitService } from '../src/identity/rate-limit.service';
import { parseEnvironment } from '../src/config/environment';
describe('login policy', () => {
const store = {
findUser: jest.fn(),
createSession: jest.fn(),
failedLogin: jest.fn(),
};
const passwords = { verify: jest.fn(), dummyVerify: jest.fn() };
const limits = { consume: jest.fn() };
const service = new AuthService(
store as unknown as AuthStore,
passwords as unknown as PasswordService,
limits as unknown as RateLimitService,
parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }),
);
const input = {
organizationId: 'organization',
email: 'user@example.com',
password: 'password',
};
beforeEach(() => {
jest.resetAllMocks();
store.findUser.mockResolvedValue({
id: 'user',
organizationId: 'organization',
status: 'ACTIVE',
passwordHash: 'hash',
});
passwords.verify.mockResolvedValue(true);
});
it('passes only the token digest to persistence', async () => {
const result = await service.login(input);
expect(store.createSession).toHaveBeenCalledWith(
'user',
'hash',
expect.stringMatching(/^[a-f0-9]{64}$/),
expect.any(Date),
);
expect(JSON.stringify(store.createSession.mock.calls)).not.toContain(
result.accessToken,
);
});
it('does not create sessions for unapproved users', async () => {
store.findUser.mockResolvedValue({
id: 'user',
status: 'PENDING',
organizationId: 'organization',
passwordHash: 'hash',
});
await expect(service.login(input)).rejects.toThrow('Invalid credentials');
expect(store.createSession).not.toHaveBeenCalled();
});
it('does equivalent hashing work for unknown users', async () => {
store.findUser.mockResolvedValue(null);
await expect(service.login(input)).rejects.toThrow('Invalid credentials');
expect(passwords.dummyVerify).toHaveBeenCalledWith(input.password);
});
it('stops before querying users when throttled', async () => {
limits.consume.mockRejectedValue(new Error('limited'));
await expect(service.login(input)).rejects.toThrow('limited');
expect(store.findUser).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,68 @@
import 'reflect-metadata';
import { testDatabase } from './test-database';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
import { ENVIRONMENT } from '../../src/config/environment.module';
import { parseEnvironment } from '../../src/config/environment';
import { DatabaseService } from '../../src/database/database.service';
import { BootstrapService } from '../../src/identity/bootstrap.service';
import { RecoveryMailer } from '../../src/identity/recovery-mailer';
import { configureApp } from '../../src/configure-app';
export const ownerPassword = 'correct horse battery staple';
export async function identityApp() {
const database = await testDatabase();
const env = parseEnvironment({
DATABASE_URL: database.connectionUrl,
NODE_ENV: 'test',
});
const mailer = {
assertConfigured: jest.fn(),
send: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(ENVIRONMENT)
.useValue(env)
.overrideProvider(RecoveryMailer)
.useValue(mailer)
.compile();
const app = module.createNestApplication();
configureApp(app, env);
await app.init();
const db = app.get(DatabaseService);
const owner = await app.get(BootstrapService).createOwner('Mani Candles', {
email: 'owner@example.com',
name: 'Owner',
password: ownerPassword,
});
const api = () => request(app.getHttpServer());
const login = async (
email = 'owner@example.com',
password = ownerPassword,
organizationId = owner.organizationId,
) =>
api().post('/api/v1/auth/login').send({ organizationId, email, password });
const response = await login();
if (response.status !== 200)
throw new Error(`Fixture login failed: ${response.status}`);
const token = response.body.accessToken as string;
return {
app,
db,
owner,
token,
api,
login,
mailer,
async clearLimits() {
await db.rateLimit.deleteMany();
},
async close() {
await app.close();
await database.close();
},
};
}
export type IdentityApp = Awaited<ReturnType<typeof identityApp>>;

View File

@ -0,0 +1,70 @@
import { randomBytes } from 'node:crypto';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { Client } from 'pg';
import { PGlite } from '@electric-sql/pglite';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
export const legacyOrganizationId = '00000000-0000-4000-8000-000000000001';
export async function testDatabase() {
let connectionUrl: string;
let execute: (sql: string) => Promise<unknown>;
let close: () => Promise<void>;
if (process.env.TEST_DATABASE_URL) {
// CI supplies a disposable PostgreSQL administrator URL, never a production URL.
const administrator = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await administrator.connect();
const name = 'mani_test_' + randomBytes(8).toString('hex');
await administrator.query(`CREATE DATABASE "${name}"`);
const url = new URL(process.env.TEST_DATABASE_URL);
url.pathname = '/' + name;
connectionUrl = url.toString();
const client = new Client({ connectionString: connectionUrl });
await client.connect();
execute = (sql) => client.query(sql);
close = async () => {
await client.end();
await administrator.query(`DROP DATABASE "${name}" WITH (FORCE)`);
await administrator.end();
};
} else {
const pg = await PGlite.create();
const server = new PGLiteSocketServer({
db: pg,
port: 0,
host: '127.0.0.1',
maxConnections: 1,
});
await server.start();
connectionUrl = `postgresql://postgres:postgres@${server.getServerConn()}/postgres`;
execute = (sql) => pg.exec(sql);
close = async () => {
await server.stop();
await pg.close();
};
}
try {
const migrations = (await readdir('prisma/migrations'))
.filter((path) => /^\d/.test(path))
.sort();
for (const path of migrations) {
await execute(
await readFile(
join('prisma/migrations', path, 'migration.sql'),
'utf8',
),
);
if (path === '202609080001_create_organizations') {
await execute(
`INSERT INTO organizations (id, name, updated_at) VALUES ('${legacyOrganizationId}', 'Existing organization', NOW())`,
);
}
}
return { connectionUrl, close, execute };
} catch (error) {
await close();
throw error;
}
}

160
test/identity-auth.spec.ts Normal file
View File

@ -0,0 +1,160 @@
import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { hashToken } from '../src/identity/tokens';
describe('authentication with migrated PostgreSQL engine', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('stores only hashed session tokens and never exposes password hashes', async () => {
const session = await ctx.db.session.findUniqueOrThrow({
where: { tokenHash: hashToken(ctx.token) },
});
expect(session.tokenHash).not.toBe(ctx.token);
const me = await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(me.body.organizationId).toBe(ctx.owner.organizationId);
expect(me.text).not.toContain('password');
expect(me.headers['cache-control']).toBe('no-store');
});
it('rejects missing, malformed, unknown and expired sessions', async () => {
await ctx.api().get('/api/v1/users').expect(401);
await ctx
.api()
.get('/api/v1/users')
.set('Authorization', 'Bearer bad')
.expect(401);
await ctx
.api()
.get('/api/v1/users')
.auth('a'.repeat(43), { type: 'bearer' })
.expect(401);
const login = await ctx.login();
await ctx.db.session.update({
where: { tokenHash: hashToken(login.body.accessToken) },
data: { expiresAt: new Date(0) },
});
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
});
it('returns the same credential error for unknown email and wrong password', async () => {
const missing = await ctx.login('missing@example.com');
const wrong = await ctx.login('owner@example.com', 'wrong password');
expect(missing.status).toBe(401);
expect(wrong.body).toEqual(missing.body);
});
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
const response = await ctx
.api()
.post('/api/v1/auth/login')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
password: 'SECRET',
isOwner: true,
})
.expect(400);
expect(response.text).not.toContain('SECRET');
});
it('revokes a session on logout', async () => {
const login = await ctx.login();
const bearer = login.body.accessToken;
await ctx
.api()
.post('/api/v1/auth/logout')
.auth(bearer, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(bearer, { type: 'bearer' })
.expect(401);
});
it('limits repeated attempts per account in durable storage', async () => {
for (let i = 0; i < 10; i++)
expect((await ctx.login('unknown@example.com')).status).toBe(401);
expect((await ctx.login('unknown@example.com')).status).toBe(429);
}, 15000);
it('enforces IP throttling even for malformed input', async () => {
for (let i = 0; i < 30; i++)
await ctx.api().post('/api/v1/auth/login').send({}).expect(400);
await ctx.api().post('/api/v1/auth/login').send({}).expect(429);
});
it('recovery consumes a token once and revokes existing sessions', async () => {
await ctx
.api()
.post('/api/v1/auth/recovery/request')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
})
.expect(202);
const recoveryToken = ctx.mailer.send.mock.calls.at(-1)![1] as string;
const newPassword = 'a replacement secure passphrase';
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(204);
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(400);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(401);
expect((await ctx.login('owner@example.com', ownerPassword)).status).toBe(
401,
);
const login = await ctx.login('owner@example.com', newPassword);
expect(login.status).toBe(200);
ctx.token = login.body.accessToken;
});
it('revokes all sessions and records security events without secrets', async () => {
const login = await ctx.login(
'owner@example.com',
'a replacement secure passphrase',
);
await ctx
.api()
.post('/api/v1/auth/logout-all')
.auth(ctx.token, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
const events = await ctx.db.auditEvent.findMany();
expect(events.map((event) => event.action)).toEqual(
expect.arrayContaining([
'auth.login',
'auth.login_failed',
'auth.password_reset',
'auth.logout_all',
]),
);
expect(JSON.stringify(events)).not.toContain(ctx.token);
expect(JSON.stringify(events)).not.toContain(ownerPassword);
});
});

View File

@ -0,0 +1,46 @@
import { parseEnvironment } from '../src/config/environment';
describe('identity environment', () => {
const base = { DATABASE_URL: 'postgresql://localhost/mani' };
const smtp = {
SMTP_HOST: 'smtp.example.com',
SMTP_USER: 'user',
SMTP_PASSWORD: 'secret',
SMTP_FROM: 'support@example.com',
RECOVERY_URL: 'https://shop.example.com/reset',
};
it('validates complete SMTP and lifetime configuration', () => {
expect(
parseEnvironment({ ...base, ...smtp, SESSION_TTL_MINUTES: '60' }),
).toMatchObject({ SESSION_TTL_MINUTES: 60, SMTP_PORT: 587 });
});
it('rejects partially configured SMTP without exposing secrets', () => {
expect(() =>
parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' }),
).toThrow('SMTP_HOST');
try {
parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' });
} catch (error) {
expect(String(error)).not.toContain('sensitive');
}
});
it.each([
'http://shop.example.com/reset',
'https://shop.example.com/reset?token=x',
'https://user:pass@shop.example.com/reset',
'https://shop.example.com/reset#token',
'not-url',
])('rejects unsafe recovery URLs %s', (url) => {
expect(() =>
parseEnvironment({ ...base, ...smtp, RECOVERY_URL: url }),
).toThrow('RECOVERY_URL');
});
it.each([{ SESSION_TTL_MINUTES: '0' }, { RECOVERY_TTL_MINUTES: '61' }])(
'bounds expiry settings %j',
(values) => {
expect(() => parseEnvironment({ ...base, ...values })).toThrow(
'Invalid environment',
);
},
);
});

View File

@ -0,0 +1,140 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { AuthStore } from '../src/identity/auth.store';
import { RecoveryStore } from '../src/identity/recovery.store';
import { AccessStore } from '../src/identity/access.store';
import { RateLimitService } from '../src/identity/rate-limit.service';
import { SessionStore } from '../src/identity/session.store';
import { issueToken, hashToken } from '../src/identity/tokens';
describe('identity transactional failure paths', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
it('does not issue a session using an obsolete password hash', async () => {
const issued = issueToken();
await expect(
ctx.app
.get(AuthStore)
.createSession(
ctx.owner.userId,
'outdated',
issued.tokenHash,
new Date(Date.now() + 60000),
),
).rejects.toThrow();
expect(
await ctx.db.session.findUnique({
where: { tokenHash: issued.tokenHash },
}),
).toBeNull();
});
it('rejects a session whose account is no longer active even without explicit revocation', async () => {
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'SUSPENDED' },
});
await expect(
ctx.app.get(SessionStore).authenticate(ctx.token),
).rejects.toThrow();
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'ACTIVE' },
});
});
it('rejects expired recovery tokens without changing credentials', async () => {
const issued = issueToken();
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issued.tokenHash, new Date(0));
await expect(
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
).rejects.toThrow('Invalid or expired');
expect((await ctx.login()).status).toBe(200);
});
it('rechecks account eligibility when storing and consuming recovery tokens', async () => {
const issued = issueToken();
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issued.tokenHash, new Date(Date.now() + 60000));
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'SUSPENDED' },
});
expect(
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issueToken().tokenHash, new Date()),
).toBe(false);
await expect(
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
).rejects.toThrow();
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'ACTIVE' },
});
});
it('resets expired rate-limit windows', async () => {
const limits = ctx.app.get(RateLimitService);
await limits.consume('test-bucket', 1, 60);
await expect(limits.consume('test-bucket', 1, 60)).rejects.toThrow(
'Too many',
);
await ctx.db.rateLimit.update({
where: { key: hashToken('test-bucket') },
data: { expiresAt: new Date(0) },
});
await expect(limits.consume('test-bucket', 1, 60)).resolves.toBeUndefined();
});
it('rechecks current permissions inside administration transactions', async () => {
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
const ownerRole = await ctx.db.role.findFirstOrThrow({
where: { isSystem: true },
});
const original = ownerRole.permissions;
await ctx.db.role.update({
where: { id: ownerRole.id },
data: { permissions: [] },
});
const work = jest.fn();
await expect(
ctx.app.get(AccessStore).mutate(actor, 'users.create', work),
).rejects.toThrow();
expect(work).not.toHaveBeenCalled();
await ctx.db.role.update({
where: { id: ownerRole.id },
data: { permissions: original },
});
});
it('preserves state when an audited transaction fails', async () => {
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
await expect(
ctx.app.get(AccessStore).mutate(actor, 'roles.manage', async (tx) => {
await tx.role.create({
data: {
organizationId: actor.organizationId,
name: 'Rolled back',
permissions: [],
},
});
throw new Error('audit unavailable');
}),
).rejects.toThrow('audit unavailable');
expect(await ctx.db.role.count({ where: { name: 'Rolled back' } })).toBe(0);
});
it('uses the same public recovery response for an unknown account', async () => {
const response = await ctx
.api()
.post('/api/v1/auth/recovery/request')
.send({
organizationId: ctx.owner.organizationId,
email: 'unknown@example.com',
})
.expect(202);
expect(response.body.message).toContain('If the account is eligible');
});
});

215
test/identity-rbac.spec.ts Normal file
View File

@ -0,0 +1,215 @@
import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { BootstrapService } from '../src/identity/bootstrap.service';
describe('organization-scoped administration', () => {
let ctx: IdentityApp;
let userId: string;
let roleId: string;
let userToken: string;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
const auth = () => ({ type: 'bearer' as const });
it('bootstraps exactly one owner', async () => {
await expect(
ctx.app.get(BootstrapService).createOwner('Other', {
email: 'other@example.com',
name: 'Other',
password: ownerPassword,
}),
).rejects.toThrow('Owner already exists');
expect(await ctx.db.user.count({ where: { isOwner: true } })).toBe(1);
});
it('creates pending accounts with normalized emails and rejects duplicates', async () => {
const payload = {
name: 'Employee',
email: 'EMPLOYEE@example.com',
password: ownerPassword,
};
const response = await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(201);
userId = response.body.id;
expect(response.body.status).toBe('PENDING');
expect(response.body.email).toBe('employee@example.com');
expect(response.text).not.toContain('passwordHash');
await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(409);
expect((await ctx.login('employee@example.com')).status).toBe(401);
});
it('approves accounts but leaves them without implicit permissions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
const login = await ctx.login('employee@example.com');
expect(login.status).toBe(200);
userToken = login.body.accessToken;
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('creates and assigns roles and reflects permission changes on existing sessions', async () => {
const role = await ctx
.api()
.post('/api/v1/roles')
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: ['users.read'] })
.expect(201);
roleId = role.body.id;
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [roleId] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(200);
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: [] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('prevents delegated administrators from granting permissions they do not hold', async () => {
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({
name: 'Delegated',
permissions: ['roles.manage', 'users.roles.assign'],
})
.expect(200);
await ctx
.api()
.post('/api/v1/roles')
.auth(userToken, auth())
.send({ name: 'Escalation', permissions: ['audit.read'] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(userToken, auth())
.send({ roleIds: [] })
.expect(403);
});
it('protects owner accounts and system roles', async () => {
const systemRole = await ctx.db.role.findFirstOrThrow({
where: { isSystem: true },
});
await ctx
.api()
.patch(`/api/v1/roles/${systemRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Changed', permissions: [] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${ctx.owner.userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [systemRole.id] })
.expect(403);
});
it('rejects cross-organization targets and database-level cross-organization assignments', async () => {
const other = await ctx.db.organization.create({ data: { name: 'Other' } });
const otherRole = await ctx.db.role.create({
data: { organizationId: other.id, name: 'External', permissions: [] },
});
await ctx
.api()
.patch(`/api/v1/roles/${otherRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Hijacked', permissions: [] })
.expect(404);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [otherRole.id] })
.expect(404);
await expect(
ctx.db.userRole.create({
data: {
userId,
roleId: otherRole.id,
organizationId: ctx.owner.organizationId,
},
}),
).rejects.toThrow();
const roles = await ctx
.api()
.get('/api/v1/roles')
.auth(ctx.token, auth())
.expect(200);
expect(
roles.body.some((role: { id: string }) => role.id === otherRole.id),
).toBe(false);
});
it('suspends accounts and permanently revokes their sessions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
expect((await ctx.login('employee@example.com')).status).toBe(401);
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
});
it('lists permissions and audit events with pagination validation', async () => {
await ctx
.api()
.get('/api/v1/roles/permissions')
.auth(ctx.token, auth())
.expect(200);
const events = await ctx
.api()
.get('/api/v1/audit-events?limit=2')
.auth(ctx.token, auth())
.expect(200);
expect(events.body).toHaveLength(2);
expect(
events.body.every(
(event: { organizationId: string }) =>
event.organizationId === ctx.owner.organizationId,
),
).toBe(true);
await ctx
.api()
.get('/api/v1/users?limit=1000')
.auth(ctx.token, auth())
.expect(400);
});
});

View File

@ -0,0 +1,99 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { legacyOrganizationId } from './helpers/test-database';
import { RecoveryStore } from '../src/identity/recovery.store';
import { issueToken } from '../src/identity/tokens';
describe('migration integrity', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
it('preserves organization rows from the foundation migration', async () => {
expect(
await ctx.db.organization.findUnique({
where: { id: legacyOrganizationId },
}),
).toMatchObject({ name: 'Existing organization' });
});
it('enforces normalized emails in the database', async () => {
await expect(
ctx.db.user.create({
data: {
organizationId: ctx.owner.organizationId,
email: 'UPPER@example.com',
name: 'Bad',
passwordHash: 'hash',
},
}),
).rejects.toThrow();
});
it('prevents a second owner even when the API is bypassed', async () => {
await expect(
ctx.db.user.create({
data: {
organizationId: ctx.owner.organizationId,
email: 'second@example.com',
name: 'Second',
passwordHash: 'hash',
isOwner: true,
},
}),
).rejects.toThrow();
});
it('rejects audit modification and deletion', async () => {
const audit = await ctx.db.auditEvent.findFirstOrThrow();
await expect(
ctx.db.auditEvent.update({
where: { id: audit.id },
data: { action: 'tampered' },
}),
).rejects.toThrow();
await expect(
ctx.db.auditEvent.delete({ where: { id: audit.id } }),
).rejects.toThrow();
expect(
await ctx.db.auditEvent.findUnique({ where: { id: audit.id } }),
).not.toBeNull();
});
it('discards failed-delivery recovery tokens', async () => {
const token = issueToken();
const store = ctx.app.get(RecoveryStore);
await store.create(
ctx.owner.userId,
token.tokenHash,
new Date(Date.now() + 60000),
);
await store.discard(token.tokenHash);
expect(
await ctx.db.recoveryToken.findUnique({
where: { tokenHash: token.tokenHash },
}),
).toBeNull();
});
const nativeOnly = process.env.TEST_DATABASE_URL ? it : it.skip;
nativeOnly(
'allows exactly one winner for concurrent recovery token consumption on native PostgreSQL',
async () => {
const token = issueToken();
const store = ctx.app.get(RecoveryStore);
await store.create(
ctx.owner.userId,
token.tokenHash,
new Date(Date.now() + 60000),
);
const results = await Promise.allSettled([
store.reset(token.tokenHash, 'hash-one'),
store.reset(token.tokenHash, 'hash-two'),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(
results.filter((result) => result.status === 'rejected'),
).toHaveLength(1);
},
);
});

50
test/password.spec.ts Normal file
View File

@ -0,0 +1,50 @@
import { PasswordService } from '../src/identity/password.service';
import { issueToken, hashToken } from '../src/identity/tokens';
import { createUserSchema, roleSchema } from '../src/identity/identity.schemas';
describe('passwords and token primitives', () => {
const passwords = new PasswordService();
it('salts equal passwords independently and verifies them', async () => {
const first = await passwords.hash('a secure long passphrase');
const second = await passwords.hash('a secure long passphrase');
expect(first).not.toBe(second);
expect(await passwords.verify('a secure long passphrase', first)).toBe(
true,
);
expect(await passwords.verify('wrong', first)).toBe(false);
await passwords.dummyVerify('anything');
});
it.each([
'',
'bcrypt$bad',
'scrypt-v1$x$x',
'scrypt-v1$' + 'a'.repeat(32) + '$' + 'a'.repeat(128) + '$extra',
])('fails closed for malformed stored hash %s', async (value) => {
expect(await passwords.verify('password', value)).toBe(false);
});
it('issues random URL-safe tokens and one-way digests', () => {
const first = issueToken();
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(first.tokenHash).toMatch(/^[a-f0-9]{64}$/);
expect(hashToken(first.token)).toBe(first.tokenHash);
expect(issueToken().token).not.toBe(first.token);
});
it('validates long passphrases, known permissions and duplicate permissions', () => {
expect(
createUserSchema.safeParse({
email: 'a@example.com',
name: 'A',
password: 'short',
}).success,
).toBe(false);
expect(
roleSchema.safeParse({ name: 'Role', permissions: ['anything'] }).success,
).toBe(false);
expect(
roleSchema.safeParse({
name: 'Role',
permissions: ['users.read', 'users.read'],
}).success,
).toBe(false);
});
});

View File

@ -0,0 +1,66 @@
import { createTransport } from 'nodemailer';
import { RecoveryMailer } from '../src/identity/recovery-mailer';
import { parseEnvironment } from '../src/config/environment';
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
describe('recovery SMTP adapter', () => {
const sendMail = jest.fn();
const close = jest.fn();
const env = parseEnvironment({
DATABASE_URL: 'postgresql://localhost/mani',
SMTP_HOST: 'smtp.example.com',
SMTP_USER: 'user',
SMTP_PASSWORD: 'secret',
SMTP_FROM: 'support@example.com',
RECOVERY_URL: 'https://shop.example.com/reset',
});
beforeEach(() => {
jest.clearAllMocks();
jest
.mocked(createTransport)
.mockReturnValue({ sendMail, close } as unknown as ReturnType<
typeof createTransport
>);
});
it('fails explicitly when recovery is not configured', () => {
expect(() =>
new RecoveryMailer(
parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }),
).assertConfigured(),
).toThrow('not configured');
});
it('requires TLS and delivers a fragment-based reset link', async () => {
sendMail.mockResolvedValue({});
await new RecoveryMailer(env).send('person@example.com', 'token');
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({
requireTLS: true,
secure: false,
disableFileAccess: true,
disableUrlAccess: true,
}),
);
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'person@example.com',
text: expect.stringContaining(
'https://shop.example.com/reset#token=token',
),
}),
);
expect(close).toHaveBeenCalled();
});
it('closes transport even when sending fails and supports implicit TLS', async () => {
sendMail.mockRejectedValue(new Error('delivery failed'));
await expect(
new RecoveryMailer({ ...env, SMTP_PORT: 465 }).send(
'person@example.com',
'token',
),
).rejects.toThrow();
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: true }),
);
expect(close).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,65 @@
import { Logger } from '@nestjs/common';
import { RecoveryService } from '../src/identity/recovery.service';
import { AuthStore } from '../src/identity/auth.store';
import { RecoveryStore } from '../src/identity/recovery.store';
import { RecoveryMailer } from '../src/identity/recovery-mailer';
import { RateLimitService } from '../src/identity/rate-limit.service';
import { PasswordService } from '../src/identity/password.service';
import { parseEnvironment } from '../src/config/environment';
import { hashToken } from '../src/identity/tokens';
describe('password recovery policy', () => {
const users = { findUser: jest.fn() };
const store = { create: jest.fn(), discard: jest.fn(), reset: jest.fn() };
const mailer = { assertConfigured: jest.fn(), send: jest.fn() };
const limits = { consume: jest.fn() };
const passwords = { hash: jest.fn() };
const service = new RecoveryService(
users as unknown as AuthStore,
store as unknown as RecoveryStore,
mailer as unknown as RecoveryMailer,
limits as unknown as RateLimitService,
passwords as unknown as PasswordService,
parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }),
);
const input = { organizationId: 'org', email: 'user@example.com' };
beforeEach(() => {
jest.resetAllMocks();
users.findUser.mockResolvedValue({
id: 'user',
email: input.email,
status: 'ACTIVE',
});
store.create.mockResolvedValue(true);
});
afterEach(() => jest.restoreAllMocks());
it.each([null, { status: 'PENDING' }, { status: 'SUSPENDED' }])(
'does not disclose or deliver for ineligible accounts %j',
async (user) => {
users.findUser.mockResolvedValue(user);
await expect(service.request(input)).resolves.toBeUndefined();
expect(mailer.send).not.toHaveBeenCalled();
},
);
it('does not deliver if eligibility changes during the request', async () => {
store.create.mockResolvedValue(false);
await service.request(input);
expect(mailer.send).not.toHaveBeenCalled();
});
it('discards undelivered tokens without logging the raw SMTP error', async () => {
const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation();
mailer.send.mockRejectedValue(new Error('smtp-password-secret'));
await service.request(input);
expect(store.discard).toHaveBeenCalledWith(
expect.stringMatching(/^[a-f0-9]{64}$/),
);
expect(JSON.stringify(logger.mock.calls)).not.toContain(
'smtp-password-secret',
);
});
it('hashes both password and token before reset persistence', async () => {
passwords.hash.mockResolvedValue('hash');
await service.reset('token', 'new password');
expect(store.reset).toHaveBeenCalledWith(hashToken('token'), 'hash');
});
});

View File

@ -10,7 +10,9 @@
"skipLibCheck": true,
"outDir": "dist",
"sourceMap": true,
"isolatedModules": true
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"]
}