feat(F-006): users profile, addresses and RBAC

- users module: profile + address CRUD behind use cases (users_profiles,
  users_addresses)
- roles customer/admin on identity_users; role resolved from DB per request
- shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin)
  injected from composition root; users never imports identity
- authorization runs before existence checks; address SQL scoped by user_id
- @fastify/cookie registered once at app root (cross-module)
- migrations 003_identity_roles + 004_users (reversible)
- no new npm dependencies; tests: unit 52, integration 22

Gates: reviewer/security/qa APPROVED; verify.sh green
This commit is contained in:
rikrdo
2026-08-15 09:27:38 +02:00
parent 75293f39bc
commit 546971280f
37 changed files with 1732 additions and 161 deletions

View File

@@ -0,0 +1,35 @@
/**
* Shared auth contracts. Identity implements the authenticator; consumers
* (users, future modules) receive it by injection from the composition root.
* No module ever imports another module for auth.
*/
import type { FastifyRequest } from 'fastify';
import { AppError } from './errors.js';
export type Role = 'customer' | 'admin';
export interface CurrentUser {
id: string;
email: string;
role: Role;
}
/**
* Resolves the session on the request into the current user.
* Throws AppError(401) when the request is not authenticated.
*/
export type Authenticate = (request: FastifyRequest) => Promise<CurrentUser>;
/** Throws AppError(403) unless the user holds the required role. */
export function requireRole(user: CurrentUser, role: Role): void {
if (user.role !== role) {
throw new AppError(403, 'FORBIDDEN', 'Access denied');
}
}
/** Throws AppError(403) unless the user is the resource owner or an admin. */
export function requireOwnerOrAdmin(user: CurrentUser, ownerId: string): void {
if (user.role !== 'admin' && user.id !== ownerId) {
throw new AppError(403, 'FORBIDDEN', 'Access denied');
}
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { AppError } from '../errors.js';
import { requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js';
const customer: CurrentUser = { id: 'user-a', email: 'a@example.com', role: 'customer' };
const admin: CurrentUser = { id: 'user-admin', email: 'admin@example.com', role: 'admin' };
function codeOf(fn: () => void): string | undefined {
try {
fn();
} catch (error) {
return error instanceof AppError ? error.code : undefined;
}
return undefined;
}
describe('requireRole', () => {
it('allows a matching role', () => {
expect(() => requireRole(admin, 'admin')).not.toThrow();
expect(() => requireRole(customer, 'customer')).not.toThrow();
});
it('throws 403 FORBIDDEN for a missing role', () => {
expect(codeOf(() => requireRole(customer, 'admin'))).toBe('FORBIDDEN');
});
});
describe('requireOwnerOrAdmin', () => {
it('allows the owner regardless of role', () => {
expect(() => requireOwnerOrAdmin(customer, 'user-a')).not.toThrow();
});
it('allows an admin on any user', () => {
expect(() => requireOwnerOrAdmin(admin, 'user-b')).not.toThrow();
});
it('throws 403 FORBIDDEN for a non-owner customer', () => {
expect(codeOf(() => requireOwnerOrAdmin(customer, 'user-b'))).toBe('FORBIDDEN');
});
});