Skip to content

Commit 6f8a8cd

Browse files
committed
feat(operadores): alta de usuarios piloto con contraseña temporal forzada
Onboarding del equipo piloto Ancud/Quellón con cuentas por rol: - api/operators: toda contraseña definida por gestión es temporal (must_change_password=true), rate limit por IP y auditoría operator_created/password_reset en auth_events - api/password (nuevo): cambio de contraseña propio con sesión; verifica la temporal con scrypt, limpia el flag y audita password_changed - migración 058 (aplicada en prod): operators.must_change_password y extensión del check de auth_events a eventos de ciclo de vida - App gate: sesión con must_change_password renderiza AccountSetup (cambio forzado) y después una bienvenida mínima con los caminos primarios del rol reutilizando roleExperience (sin copy nueva) - /operadores: badge 'Cambio de contraseña pendiente' y hint de temporal; SecurityAudit etiqueta los nuevos eventos - contrato audit-smoke extendido y spec Playwright del flujo completo
1 parent bb309a8 commit 6f8a8cd

18 files changed

Lines changed: 368 additions & 21 deletions

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ Pescamar remains a named implementation inside the broader Seafood Intelligence
149149

150150
The rollout and productization plan is maintained in [`ROADMAP.md`](./ROADMAP.md). Pilot acceptance remains governed by [`PILOT_ACCEPTANCE.md`](./PILOT_ACCEPTANCE.md).
151151

152+
### Alta de usuarios piloto
153+
154+
1. Un administrador entra a **Operadores** (`/operadores`) y crea la identidad con nombre, correo, rol y plantas (Ancud/Quellón) asignando una **contraseña temporal**; el servidor la marca siempre como temporal (`must_change_password`) y registra el alta en `auth_events`.
155+
2. Se entrega el correo y la contraseña temporal al usuario por un canal privado (no hay envío de correo automático ni tokens de invitación).
156+
3. Al primer login la aplicación exige definir contraseña propia antes de mostrar nada (`/api/password` verifica la temporal, limpia el flag y audita `password_changed`); después muestra una bienvenida mínima con los caminos primarios del rol.
157+
4. Roles del piloto: operador de planta → `operations` (o `quality` para calidad), responsable comercial/administrativo → `finance`, gestión por excepción → `admin` (o `viewer` para sólo lectura). Altas y cambios de contraseña quedan en **Auditoría de acceso** (`/modulos`).
158+
152159
---
153160

154161
## Product principles

‎api/_auth-security.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
22
import { getSql } from "./_db.js";
33

44
type Request = { headers?: Record<string, string | string[] | undefined> };
5-
type EventType = "login_success" | "login_failure" | "login_rate_limited" | "logout";
5+
type EventType = "login_success" | "login_failure" | "login_rate_limited" | "logout" | "operator_created" | "password_reset" | "password_changed";
66

77
const WINDOW_MINUTES = 15;
88
const PAIR_LIMIT = 5;

‎api/_auth.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type Headers = Record<string, string | string[] | undefined>;
66
declare const process: { env: Record<string, string | undefined> };
77
export type AuthRequest = { headers?: Headers };
88
export type OperatorRole = "admin" | "operations" | "finance" | "quality" | "viewer";
9-
export type SessionOperator = { id: string; fullName: string; email: string; role: OperatorRole; plantIds: string[]; organizationId: string; executiveExperience: boolean };
9+
export type SessionOperator = { id: string; fullName: string; email: string; role: OperatorRole; plantIds: string[]; organizationId: string; executiveExperience: boolean; mustChangePassword: boolean };
1010

1111
// The executive (CEO) experience is determined server-side so the client bundle never
1212
// carries the identifying email. Override with the CEO_OPERATOR_EMAIL env var when needed.
@@ -81,16 +81,16 @@ export async function requireOperator(request: AuthRequest, roles?: OperatorRole
8181

8282
const hash = tokenHash(token);
8383
const rows = await getSql()`
84-
select o.id,o.full_name,o.email,o.role,o.plant_ids,s.last_seen_at
84+
select o.id,o.full_name,o.email,o.role,o.plant_ids,o.must_change_password,s.last_seen_at
8585
from operator_sessions s join operators o on o.id=s.operator_id
8686
where s.token_hash=${hash} and s.expires_at>now() and o.active=true limit 1`;
87-
const row = Array.isArray(rows) ? rows[0] as {id:string;full_name:string;email:string;role:OperatorRole;plant_ids:string[];last_seen_at:string|Date|null}|undefined : undefined;
87+
const row = Array.isArray(rows) ? rows[0] as {id:string;full_name:string;email:string;role:OperatorRole;plant_ids:string[];must_change_password:boolean;last_seen_at:string|Date|null}|undefined : undefined;
8888
if (!row || (roles && !roles.includes(row.role))) return null;
8989

9090
const lastSeenMs = row.last_seen_at ? new Date(row.last_seen_at).getTime() : 0;
9191
if (!Number.isFinite(lastSeenMs) || Date.now() - lastSeenMs >= SESSION_TOUCH_INTERVAL_MS) {
9292
await getSql()`update operator_sessions set last_seen_at=now() where token_hash=${hash}`;
9393
}
9494

95-
return { id: row.id, fullName: row.full_name, email: row.email, role: row.role, plantIds: row.plant_ids ?? [], organizationId: organization.organizationId, executiveExperience: executiveExperienceFor(row) } satisfies SessionOperator;
95+
return { id: row.id, fullName: row.full_name, email: row.email, role: row.role, plantIds: row.plant_ids ?? [], organizationId: organization.organizationId, executiveExperience: executiveExperienceFor(row), mustChangePassword: row.must_change_password === true } satisfies SessionOperator;
9696
}

‎api/_migration-manifest.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const expectedMigrations=[
2-
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql','057_pilot_heartbeat.sql'
2+
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql','057_pilot_heartbeat.sql','058_operator_password_rotation.sql'
33
] as const
44

55
export const migrationLandmarks=['parties','receptions','operators','historical_production_records','canonical_production_support_blocks','plant_stations','packing_units','label_templates','pallets','cold_runs','regulatory_holds','japan_export_release_evidence','lot_lifecycle_events','sea_urchin_external_references','gs1_identity_links'] as const

‎api/auth.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export default async function handler(request:Request,response:Response){
3535
return response.status(429).json({ok:false,error:"Demasiados intentos. Intenta nuevamente más tarde."});
3636
}
3737

38-
const rows=await getSql()`select id,full_name,email,role,password_hash,plant_ids from operators where lower(email)=${email} and active=true limit 1`;
39-
const row=Array.isArray(rows)?rows[0] as {id:string;full_name:string;email:string;role:string;password_hash:string|null;plant_ids:string[]}|undefined:undefined;
38+
const rows=await getSql()`select id,full_name,email,role,password_hash,plant_ids,must_change_password from operators where lower(email)=${email} and active=true limit 1`;
39+
const row=Array.isArray(rows)?rows[0] as {id:string;full_name:string;email:string;role:string;password_hash:string|null;plant_ids:string[];must_change_password:boolean}|undefined:undefined;
4040
const passwordOk=verifyPassword(password,row?.password_hash??DUMMY_PASSWORD_HASH);
4141
if(!row||!passwordOk){
4242
await recordLoginFailure(request,email);
@@ -51,7 +51,7 @@ export default async function handler(request:Request,response:Response){
5151
recordAuthEvent("login_success",request,email,row.id,{role:row.role,organizationId:organization.organizationId}),
5252
]);
5353
response.setHeader("Set-Cookie",sessionCookie(session.token,session.maxAge));
54-
return response.status(200).json({ok:true,operator:{id:row.id,fullName:row.full_name,email:row.email,role:row.role,plantIds:row.plant_ids??[],organizationId:organization.organizationId,executiveExperience:executiveExperienceFor(row)}});
54+
return response.status(200).json({ok:true,operator:{id:row.id,fullName:row.full_name,email:row.email,role:row.role,plantIds:row.plant_ids??[],organizationId:organization.organizationId,executiveExperience:executiveExperienceFor(row),mustChangePassword:row.must_change_password===true}});
5555
}
5656
if(request.method==="DELETE"){
5757
const operator=await requireOperator(request);
@@ -67,7 +67,7 @@ export default async function handler(request:Request,response:Response){
6767
}catch(error){
6868
const message=error instanceof Error?error.message:"";
6969
const configuration=message.includes("DATABASE_URL");
70-
const migration=message.includes("operator_sessions")||message.includes("password_hash")||message.includes("auth_login_limits")||message.includes("auth_events");
70+
const migration=message.includes("operator_sessions")||message.includes("password_hash")||message.includes("auth_login_limits")||message.includes("auth_events")||message.includes("must_change_password");
7171
return response.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Faltan migraciones de autenticación":"No fue posible autenticar"});
7272
}
7373
}

‎api/operators.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { hashPassword, requireOperator } from "./_auth.js";
2+
import { recordAuthEvent } from "./_auth-security.js";
23
import { normalizePlantIds } from "./_plants.js";
34
import { getSql } from "./_db.js";
5+
import { allowClientIp } from "./_rate-limit.js";
46

57
type Request={method?:string;body?:unknown;headers?:Record<string,string|string[]|undefined>};
68
type Response={status:(code:number)=>Response;setHeader:(name:string,value:string)=>void;json:(body:unknown)=>void};
@@ -18,12 +20,13 @@ export default async function handler(request:Request,response:Response){
1820

1921
if(request.method==="GET"){
2022
const rows=isAdmin
21-
? await getSql()`select id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready from operators order by active desc,full_name asc`
22-
: await getSql()`select id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready from operators where role in ('operations','quality','viewer') and plant_ids && ${actor.plantIds}::text[] order by active desc,full_name asc`;
23+
? await getSql()`select id,full_name,email,role,active,plant_ids,must_change_password,created_at,(password_hash is not null) as credentials_ready from operators order by active desc,full_name asc`
24+
: await getSql()`select id,full_name,email,role,active,plant_ids,must_change_password,created_at,(password_hash is not null) as credentials_ready from operators where role in ('operations','quality','viewer') and plant_ids && ${actor.plantIds}::text[] order by active desc,full_name asc`;
2325
return response.status(200).json({ok:true,operators:rows});
2426
}
2527

2628
if(request.method==="POST"){
29+
if(!allowClientIp(request,60_000,10))return response.status(429).json({ok:false,error:"Demasiados altas o restablecimientos por minuto"});
2730
const input=(request.body??{}) as OperatorInput;
2831
const name=String(input.name??"").trim();
2932
const email=String(input.email??"").trim().toLowerCase();
@@ -41,16 +44,20 @@ export default async function handler(request:Request,response:Response){
4144
}
4245
if(name.length<2||name.length>120||!emailPattern.test(email)||email.length>254||!roles.has(role)||password.length<12||password.length>256)return response.status(400).json({ok:false,error:"Nombre, correo, rol y contraseña de 12 a 256 caracteres son obligatorios"});
4346
const passwordHash=hashPassword(password);
44-
const rows=await getSql()`insert into operators (full_name,email,role,password_hash,plant_ids) values (${name},${email},${role},${passwordHash},${plantIds}) on conflict (lower(email)) do update set full_name=excluded.full_name,role=excluded.role,password_hash=excluded.password_hash,plant_ids=excluded.plant_ids,active=true,updated_at=now() returning id,full_name,email,role,active,plant_ids,created_at,true as credentials_ready`;
45-
return response.status(201).json({ok:true,operator:Array.isArray(rows)?rows[0]:null});
47+
// Contraseñas definidas por gestión son siempre temporales: el servidor
48+
// exige el cambio al primer login (operators.must_change_password).
49+
const rows=await getSql()`insert into operators (full_name,email,role,password_hash,plant_ids,must_change_password) values (${name},${email},${role},${passwordHash},${plantIds},true) on conflict (lower(email)) do update set full_name=excluded.full_name,role=excluded.role,password_hash=excluded.password_hash,plant_ids=excluded.plant_ids,must_change_password=excluded.must_change_password,active=true,updated_at=now() returning id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready,(xmax=0) as inserted`;
50+
const saved=Array.isArray(rows)?rows[0] as {id?:string;inserted?:boolean}|undefined:undefined;
51+
await recordAuthEvent(saved?.inserted===true?"operator_created":"password_reset",request,email,saved?.id??null,{role,actorId:actor.id,actorRole:actor.role});
52+
return response.status(201).json({ok:true,operator:saved??null});
4653
}
4754

4855
response.setHeader("Allow","GET, POST");
4956
return response.status(405).json({ok:false,error:"Método no permitido"});
5057
}catch(error){
5158
const message=error instanceof Error?error.message:"";
5259
const configuration=message.includes("DATABASE_URL");
53-
const migration=message.includes("password_hash")||message.includes("operator_sessions");
60+
const migration=message.includes("password_hash")||message.includes("operator_sessions")||message.includes("must_change_password");
5461
return response.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Falta aplicar la migración 003_operator_auth.sql":"No fue posible administrar operadores"});
5562
}
5663
}

‎api/password.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { hashPassword, requireOperator, verifyPassword } from "./_auth.js";
2+
import { recordAuthEvent } from "./_auth-security.js";
3+
import { getSql } from "./_db.js";
4+
import { allowClientIp } from "./_rate-limit.js";
5+
6+
type Request={method?:string;body?:unknown;headers?:Record<string,string|string[]>};
7+
type Response={status:(code:number)=>Response;setHeader:(name:string,value:string)=>void;json:(body:unknown)=>void};
8+
type PasswordInput={currentPassword?:unknown;newPassword?:unknown};
9+
10+
// Self-service password change with a live session. This is the only way to
11+
// clear operators.must_change_password, so accounts created or reset by
12+
// management with a temporary password cannot reach the application until the
13+
// operator chooses their own credential. Current password is verified against
14+
// the stored scrypt hash and the rotation is audit-logged.
15+
16+
export default async function handler(request:Request,response:Response){
17+
response.setHeader("Cache-Control","no-store");
18+
try{
19+
if(request.method!=="POST"){
20+
response.setHeader("Allow","POST");
21+
return response.status(405).json({ok:false,error:"Método no permitido"});
22+
}
23+
if(!allowClientIp(request,60_000,10))return response.status(429).json({ok:false,error:"Demasiados cambios de contraseña por minuto"});
24+
const operator=await requireOperator(request);
25+
if(!operator)return response.status(401).json({ok:false,error:"Sesión requerida"});
26+
const input=(request.body??{}) as PasswordInput;
27+
const currentPassword=String(input.currentPassword??"");
28+
const newPassword=String(input.newPassword??"");
29+
if(currentPassword.length<8||currentPassword.length>256||newPassword.length<12||newPassword.length>256)
30+
return response.status(400).json({ok:false,error:"La contraseña actual y una nueva de 12 a 256 caracteres son obligatorias"});
31+
if(currentPassword===newPassword)return response.status(400).json({ok:false,error:"La nueva contraseña debe ser distinta de la temporal"});
32+
const rows=await getSql()`select password_hash from operators where id=${operator.id}::uuid limit 1`;
33+
const stored=Array.isArray(rows)?(rows[0] as {password_hash?:string|null}|undefined)?.password_hash??null:null;
34+
if(!verifyPassword(currentPassword,stored))return response.status(403).json({ok:false,error:"La contraseña actual no es válida"});
35+
await getSql()`update operators set password_hash=${hashPassword(newPassword)},must_change_password=false,updated_at=now() where id=${operator.id}::uuid`;
36+
await recordAuthEvent("password_changed",request,operator.email,operator.id,{role:operator.role});
37+
return response.status(200).json({ok:true});
38+
}catch(error){
39+
const message=error instanceof Error?error.message:"";
40+
const configuration=message.includes("DATABASE_URL");
41+
const migration=message.includes("password_hash")||message.includes("must_change_password");
42+
return response.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Faltan migraciones de credenciales":"No fue posible cambiar la contraseña"});
43+
}
44+
}

0 commit comments

Comments
 (0)