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:
35
project/src/shared/auth.ts
Normal file
35
project/src/shared/auth.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
40
project/src/shared/tests/auth.test.ts
Normal file
40
project/src/shared/tests/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user