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

@@ -3,7 +3,6 @@
* calls use cases, maps domain errors to the shared error envelope.
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { z } from 'zod';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
@@ -47,8 +46,6 @@ export async function registerIdentityRoutes(
app: FastifyInstance,
deps: IdentityRoutesDeps,
): Promise<void> {
await app.register(fastifyCookie);
const cookieSecure = deps.cookieSecure ?? true;
const hasher = deps.hasher ?? new Argon2PasswordHasher();
const users = new PgUserRepository(deps.pool);
@@ -70,7 +67,9 @@ export async function registerIdentityRoutes(
const input = parseJson(credentialsSchema, request.body);
try {
const user = await registerUser.execute(input);
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
return reply
.code(201)
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
} catch (error) {
if (error instanceof EmailAlreadyRegisteredError) {
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
@@ -84,7 +83,9 @@ export async function registerIdentityRoutes(
try {
const result = await login.execute(input);
setSessionCookie(reply, result.token, cookieSecure);
return reply.code(200).send({ id: result.user.id, email: result.user.email });
return reply
.code(200)
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
} catch (error) {
if (error instanceof RateLimitedError) {
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));

View File

@@ -2,9 +2,12 @@
* Identity domain. Pure types and rules: no framework, no infrastructure.
*/
import type { Role } from '../../../shared/auth.js';
export interface User {
id: string;
email: string;
role: Role;
createdAt: Date;
}

View File

@@ -7,3 +7,4 @@ export {
SESSION_COOKIE_NAME,
type IdentityRoutesDeps,
} from './api/identity.routes.js';
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';

View File

@@ -5,12 +5,14 @@
import type pg from 'pg';
import type { UserRepository } from '../domain/ports.js';
import type { NewUser, User } from '../domain/user.js';
import type { Role } from '../../../shared/auth.js';
import { EmailAlreadyRegisteredError } from '../domain/errors.js';
interface UserRow {
id: string;
email: string;
password_hash: string;
role: Role;
created_at: Date;
}
@@ -24,14 +26,14 @@ export class PgUserRepository implements UserRepository {
const result = await this.pool.query<UserRow>(
`INSERT INTO identity_users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, created_at`,
RETURNING id, email, role, created_at`,
[user.email, user.passwordHash],
);
const row = result.rows[0];
if (!row) {
throw new Error('identity_users INSERT returned no row');
}
return { id: row.id, email: row.email, createdAt: row.created_at };
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
} catch (error) {
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
throw new EmailAlreadyRegisteredError();
@@ -42,7 +44,7 @@ export class PgUserRepository implements UserRepository {
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
const result = await this.pool.query<UserRow>(
`SELECT id, email, password_hash, created_at
`SELECT id, email, password_hash, role, created_at
FROM identity_users
WHERE email = $1`,
[email],
@@ -54,6 +56,7 @@ export class PgUserRepository implements UserRepository {
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
passwordHash: row.password_hash,
};

View File

@@ -0,0 +1,44 @@
/**
* Resolves the session cookie into the current user.
* Server-side truth: validity (expiry + revocation) and role come from the DB,
* never from the client. Exported through the module index so the composition
* root can inject it into other modules without cross-module imports.
*/
import type { FastifyRequest } from 'fastify';
import type pg from 'pg';
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { hashSessionToken } from './session-token.js';
import { SESSION_COOKIE_NAME } from '../api/identity.routes.js';
interface ResolvedRow {
id: string;
email: string;
role: string;
}
const RESOLVE_SQL = `
SELECT u.id, u.email, u.role
FROM identity_sessions s
JOIN identity_users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.revoked_at IS NULL
AND s.expires_at > now()
`;
export function createSessionAuthenticator(pool: pg.Pool): Authenticate {
return async (request: FastifyRequest): Promise<CurrentUser> => {
const token = request.cookies[SESSION_COOKIE_NAME];
if (!token) {
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
}
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashSessionToken(token)]);
const row = result.rows[0];
if (!row) {
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
}
return { id: row.id, email: row.email, role: row.role as CurrentUser['role'] };
};
}

View File

@@ -0,0 +1,163 @@
/**
* Users API adapters. Authorization (role + ownership) runs BEFORE existence
* checks, so a non-owner always gets 403 regardless of resource existence.
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type pg from 'pg';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js';
import { GetProfile, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
import {
CreateAddress,
DeleteAddress,
ListAddresses,
UpdateAddress,
} from '../application/address-use-cases.js';
import { PgProfileRepository } from '../infrastructure/pg-profile-repository.js';
import { PgAddressRepository } from '../infrastructure/pg-address-repository.js';
import type { Address } from '../domain/address.js';
import type { Profile } from '../domain/profile.js';
export interface UsersRoutesDeps {
pool: pg.Pool;
/** Injected by the composition root (identity owns session resolution). */
authenticate: Authenticate;
}
const uuidParamSchema = z.object({ id: z.uuid() });
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: z.uuid() });
const profilePatchSchema = z
.object({
displayName: z.string().min(1).max(200).optional(),
phone: z.string().min(1).max(50).optional(),
})
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
message: 'At least one of displayName or phone is required',
});
const newAddressSchema = z.object({
label: z.string().max(100).optional().nullable(),
recipientName: z.string().min(1).max(200),
street: z.string().min(1).max(300),
city: z.string().min(1).max(100),
postalCode: z.string().min(1).max(20),
country: z.string().min(1).max(100),
isDefault: z.boolean().optional(),
});
const addressPatchSchema = newAddressSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one address field is required',
});
export async function registerUsersRoutes(
app: FastifyInstance,
deps: UsersRoutesDeps,
): Promise<void> {
const profiles = new PgProfileRepository(deps.pool);
const addresses = new PgAddressRepository(deps.pool);
const getProfile = new GetProfile(profiles);
const updateProfile = new UpdateProfile(profiles);
const listProfiles = new ListProfiles(profiles);
const listAddresses = new ListAddresses(addresses);
const createAddress = new CreateAddress(addresses);
const updateAddress = new UpdateAddress(addresses);
const deleteAddress = new DeleteAddress(addresses);
app.get('/users', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const items = await listProfiles.execute();
return reply.send({ items: items.map(serializeProfile) });
});
app.get('/users/:id', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(uuidParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const profile = await getProfile.execute(id);
if (!profile) {
throw new AppError(404, 'NOT_FOUND', 'Profile not found');
}
return reply.send(serializeProfile(profile));
});
app.patch('/users/:id', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(uuidParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const patch = parseJson(profilePatchSchema, request.body);
const profile = await updateProfile.execute(id, patch);
return reply.send(serializeProfile(profile));
});
app.get('/users/:id/addresses', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(uuidParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const items = await listAddresses.execute(id);
return reply.send({ items: items.map(serializeAddress) });
});
app.post('/users/:id/addresses', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(uuidParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const input = parseJson(newAddressSchema, request.body);
const address = await createAddress.execute(id, input);
return reply.code(201).send(serializeAddress(address));
});
app.patch('/users/:id/addresses/:addressId', async (request, reply) => {
const user = await deps.authenticate(request);
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const patch = parseJson(addressPatchSchema, request.body);
const address = await updateAddress.execute(id, addressId, patch);
if (!address) {
throw new AppError(404, 'NOT_FOUND', 'Address not found');
}
return reply.send(serializeAddress(address));
});
app.delete('/users/:id/addresses/:addressId', async (request, reply) => {
const user = await deps.authenticate(request);
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const deleted = await deleteAddress.execute(id, addressId);
if (!deleted) {
throw new AppError(404, 'NOT_FOUND', 'Address not found');
}
return reply.code(204).send();
});
}
function serializeProfile(profile: Profile) {
return {
userId: profile.userId,
displayName: profile.displayName,
phone: profile.phone,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
};
}
function serializeAddress(address: Address) {
return {
id: address.id,
userId: address.userId,
label: address.label,
recipientName: address.recipientName,
street: address.street,
city: address.city,
postalCode: address.postalCode,
country: address.country,
isDefault: address.isDefault,
createdAt: address.createdAt.toISOString(),
updatedAt: address.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,37 @@
/**
* Address use cases. Every operation is scoped to the owner's userId.
*/
import type { AddressRepository } from '../domain/ports.js';
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
export class ListAddresses {
constructor(private readonly addresses: AddressRepository) {}
async execute(userId: string): Promise<Address[]> {
return this.addresses.listByUserId(userId);
}
}
export class CreateAddress {
constructor(private readonly addresses: AddressRepository) {}
async execute(userId: string, input: NewAddress): Promise<Address> {
return this.addresses.create(userId, input);
}
}
export class UpdateAddress {
constructor(private readonly addresses: AddressRepository) {}
async execute(
userId: string,
addressId: string,
patch: AddressPatch,
): Promise<Address | undefined> {
return this.addresses.update(userId, addressId, patch);
}
}
export class DeleteAddress {
constructor(private readonly addresses: AddressRepository) {}
async execute(userId: string, addressId: string): Promise<boolean> {
return this.addresses.delete(userId, addressId);
}
}

View File

@@ -0,0 +1,27 @@
/**
* Profile use cases. Thin orchestration over ports; ownership/role checks
* happen in the API layer before these run.
*/
import type { ProfileRepository } from '../domain/ports.js';
import type { Profile, ProfilePatch } from '../domain/profile.js';
export class GetProfile {
constructor(private readonly profiles: ProfileRepository) {}
async execute(userId: string): Promise<Profile | undefined> {
return this.profiles.findByUserId(userId);
}
}
export class UpdateProfile {
constructor(private readonly profiles: ProfileRepository) {}
async execute(userId: string, patch: ProfilePatch): Promise<Profile> {
return this.profiles.upsert(userId, patch);
}
}
export class ListProfiles {
constructor(private readonly profiles: ProfileRepository) {}
async execute(): Promise<Profile[]> {
return this.profiles.list();
}
}

View File

@@ -0,0 +1,30 @@
/**
* Address domain model.
*/
export interface Address {
id: string;
userId: string;
label: string | null;
recipientName: string;
street: string;
city: string;
postalCode: string;
country: string;
isDefault: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface NewAddress {
label?: string | null;
recipientName: string;
street: string;
city: string;
postalCode: string;
country: string;
isDefault?: boolean;
}
/** Fields an address update may set. Undefined = leave unchanged. */
export type AddressPatch = Partial<NewAddress>;

View File

@@ -0,0 +1,22 @@
/**
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
* All operations are scoped by userId so ownership is enforced in the query.
*/
import type { Profile, ProfilePatch } from './profile.js';
import type { Address, AddressPatch, NewAddress } from './address.js';
export interface ProfileRepository {
findByUserId(userId: string): Promise<Profile | undefined>;
/** Idempotent upsert; only provided fields change. */
upsert(userId: string, patch: ProfilePatch): Promise<Profile>;
list(): Promise<Profile[]>;
}
export interface AddressRepository {
listByUserId(userId: string): Promise<Address[]>;
create(userId: string, input: NewAddress): Promise<Address>;
/** Returns undefined when the address does not belong to userId. */
update(userId: string, addressId: string, patch: AddressPatch): Promise<Address | undefined>;
/** Returns false when the address does not belong to userId. */
delete(userId: string, addressId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,17 @@
/**
* Users domain. Pure types: no framework, no infrastructure.
*/
export interface Profile {
userId: string;
displayName: string | null;
phone: string | null;
createdAt: Date;
updatedAt: Date;
}
/** Fields a profile update may set. Undefined = leave unchanged. */
export interface ProfilePatch {
displayName?: string | null;
phone?: string | null;
}

View File

@@ -0,0 +1,6 @@
/**
* Public API of the users module. Everything the module exposes to the outside
* world goes through this file. Auth arrives by injection (shared contract),
* never by importing identity.
*/
export { registerUsersRoutes, type UsersRoutesDeps } from './api/users.routes.js';

View File

@@ -0,0 +1,131 @@
/**
* PostgreSQL AddressRepository. All operations are scoped by user_id, so a
* caller can never read or mutate another user's address even with a valid id.
*/
import type pg from 'pg';
import type { AddressRepository } from '../domain/ports.js';
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
interface AddressRow {
id: string;
user_id: string;
label: string | null;
recipient_name: string;
street: string;
city: string;
postal_code: string;
country: string;
is_default: boolean;
created_at: Date;
updated_at: Date;
}
/** Whitelist of updatable columns -> input key. Prevents SQL building from input. */
const UPDATABLE: ReadonlyArray<[keyof AddressPatch, string]> = [
['label', 'label'],
['recipientName', 'recipient_name'],
['street', 'street'],
['city', 'city'],
['postalCode', 'postal_code'],
['country', 'country'],
['isDefault', 'is_default'],
];
export class PgAddressRepository implements AddressRepository {
constructor(private readonly pool: pg.Pool) {}
async listByUserId(userId: string): Promise<Address[]> {
const result = await this.pool.query<AddressRow>(
`SELECT * FROM users_addresses WHERE user_id = $1
ORDER BY is_default DESC, created_at`,
[userId],
);
return result.rows.map(toAddress);
}
async create(userId: string, input: NewAddress): Promise<Address> {
const result = await this.pool.query<AddressRow>(
`INSERT INTO users_addresses
(user_id, label, recipient_name, street, city, postal_code, country, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
userId,
input.label ?? null,
input.recipientName,
input.street,
input.city,
input.postalCode,
input.country,
input.isDefault ?? false,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('users_addresses INSERT returned no row');
}
return toAddress(row);
}
async update(
userId: string,
addressId: string,
patch: AddressPatch,
): Promise<Address | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
const value = patch[key];
if (value !== undefined) {
values.push(value);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findByIdScoped(userId, addressId);
}
values.push(userId, addressId);
const result = await this.pool.query<AddressRow>(
`UPDATE users_addresses SET ${setClauses.join(', ')}, updated_at = now()
WHERE user_id = $${values.length - 1} AND id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toAddress(row) : undefined;
}
async delete(userId: string, addressId: string): Promise<boolean> {
const result = await this.pool.query(
`DELETE FROM users_addresses WHERE user_id = $1 AND id = $2`,
[userId, addressId],
);
return (result.rowCount ?? 0) > 0;
}
private async findByIdScoped(userId: string, addressId: string): Promise<Address | undefined> {
const result = await this.pool.query<AddressRow>(
`SELECT * FROM users_addresses WHERE user_id = $1 AND id = $2`,
[userId, addressId],
);
const row = result.rows[0];
return row ? toAddress(row) : undefined;
}
}
function toAddress(row: AddressRow): Address {
return {
id: row.id,
userId: row.user_id,
label: row.label,
recipientName: row.recipient_name,
street: row.street,
city: row.city,
postalCode: row.postal_code,
country: row.country,
isDefault: row.is_default,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,80 @@
/**
* PostgreSQL ProfileRepository. Parameterized queries only; update columns are
* whitelisted, never built from user input.
*/
import type pg from 'pg';
import type { ProfileRepository } from '../domain/ports.js';
import type { Profile, ProfilePatch } from '../domain/profile.js';
interface ProfileRow {
user_id: string;
display_name: string | null;
phone: string | null;
created_at: Date;
updated_at: Date;
}
export class PgProfileRepository implements ProfileRepository {
constructor(private readonly pool: pg.Pool) {}
async findByUserId(userId: string): Promise<Profile | undefined> {
const result = await this.pool.query<ProfileRow>(
`SELECT user_id, display_name, phone, created_at, updated_at
FROM users_profiles WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
return row ? toProfile(row) : undefined;
}
async upsert(userId: string, patch: ProfilePatch): Promise<Profile> {
await this.pool.query(
`INSERT INTO users_profiles (user_id) VALUES ($1)
ON CONFLICT (user_id) DO NOTHING`,
[userId],
);
const setClauses: string[] = [];
const values: unknown[] = [];
if (patch.displayName !== undefined) {
values.push(patch.displayName);
setClauses.push(`display_name = $${values.length}`);
}
if (patch.phone !== undefined) {
values.push(patch.phone);
setClauses.push(`phone = $${values.length}`);
}
if (setClauses.length > 0) {
values.push(userId);
await this.pool.query(
`UPDATE users_profiles SET ${setClauses.join(', ')}, updated_at = now()
WHERE user_id = $${values.length}`,
values,
);
}
const profile = await this.findByUserId(userId);
if (!profile) {
throw new Error('users_profiles upsert did not return a row');
}
return profile;
}
async list(): Promise<Profile[]> {
const result = await this.pool.query<ProfileRow>(
`SELECT user_id, display_name, phone, created_at, updated_at
FROM users_profiles ORDER BY created_at`,
);
return result.rows.map(toProfile);
}
}
function toProfile(row: ProfileRow): Profile {
return {
userId: row.user_id,
displayName: row.display_name,
phone: row.phone,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}