package onboarding import ( "context" "data-terminal-cloud/internal/audit" "errors" "github.com/jackc/pgx/v5" ) // ProvisionCustomerPhone is an administrative migration, deliberately absent from HTTP routes. // The operator must establish the old identity and authorize the phone association beforehand. // It creates only a credential mapping: no user, household, role or SMS-verification state is changed. func (s *Service) ProvisionCustomerPhone(ctx context.Context, userID, issuer, subject, phone, password, requestID string) (bool, error) { return s.provisionPhone(ctx, userID, issuer, subject, phone, password, requestID) } func (s *Service) provisionPhone(ctx context.Context, userID, issuer, subject, phone, password, requestID string) (bool, error) { if !uuidValid(userID) || issuer == "" || subject == "" || !phonePattern.MatchString(phone) || !validNewPassword(password) || !uuidV4(requestID) { return false, errors.New("invalid phone provisioning input") } phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+phone) tx, err := s.pool.Begin(ctx) if err != nil { return false, err } defer tx.Rollback(ctx) var tenant, publicID string err = tx.QueryRow(ctx, `SELECT ua.tenant_id::text,ua.public_id::text FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND `+s.accountPredicate()+` FOR SHARE OF ua,t`, userID, issuer, subject).Scan(&tenant, &publicID) if errors.Is(err, pgx.ErrNoRows) { return false, errors.New("expected active account identity does not match") } if err != nil { return false, err } if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "admin-phone:"+phoneHash); err != nil { return false, err } var existingPhone, existingIssuer, existingSubject, encoded string err = tx.QueryRow(ctx, `SELECT phone_digest,issuer,subject,COALESCE(password_hash,'') FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 OR (issuer=$2 AND subject=$3) ORDER BY phone_digest LIMIT 1 FOR UPDATE`, phoneHash, issuer, subject).Scan(&existingPhone, &existingIssuer, &existingSubject, &encoded) if err == nil { if existingPhone != phoneHash || existingIssuer != issuer || existingSubject != subject { return false, errors.New("phone or identity is already bound; no changes made") } ok, e := verifyPassword(ctx, encoded, password) if e != nil { return false, e } if !ok { return false, errors.New("credential already exists; refusing to replace its password") } return false, tx.Commit(ctx) } if !errors.Is(err, pgx.ErrNoRows) { return false, err } encoded, err = hashPassword(ctx, password) if err != nil { return false, err } masked := phone[:len(phone)-8] + "****" + phone[len(phone)-4:] _, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject,password_hash) VALUES($1,$2,$3,$4,$5)`, phoneHash, masked, issuer, subject, encoded) if err != nil { return false, err } action := "customer.phone.provision" if s.operatorLogin { action = "operator.phone.provision" } err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenant, ActorType: "system", Action: action, ResourceType: "user_account", ResourceID: &publicID, RequestID: requestID, Purpose: "authorized account migration", Result: "success", Detail: map[string]any{"phone_masked": masked, "sms_verified": false}}) if err != nil { return false, err } return true, tx.Commit(ctx) }