Files
story/internal/onboarding/admin_phone_account.go
T

74 lines
3.4 KiB
Go
Raw Normal View History

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)
}