ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
74 lines
3.4 KiB
Go
74 lines
3.4 KiB
Go
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)
|
|
}
|