2026-09-06 17:04:11 +08:00
|
|
|
package deviceactivation
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type State string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
Factory State = "FACTORY"
|
|
|
|
|
Claimable State = "CLAIMABLE"
|
|
|
|
|
Bound State = "BOUND"
|
|
|
|
|
Provisioning State = "PROVISIONING"
|
|
|
|
|
Active State = "ACTIVE"
|
|
|
|
|
Lost State = "LOST"
|
|
|
|
|
Revoked State = "REVOKED"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ErrInvalidState = errors.New("invalid device activation state")
|
|
|
|
|
ErrTransitionRejected = errors.New("device activation transition rejected")
|
|
|
|
|
ErrRevisionConflict = errors.New("device activation revision conflict")
|
|
|
|
|
ErrDeviceNotFound = errors.New("device activation target not found")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Input struct {
|
|
|
|
|
DeviceID string
|
|
|
|
|
TenantID string
|
|
|
|
|
Target State
|
|
|
|
|
Reason string
|
|
|
|
|
Source string
|
|
|
|
|
ActorType string
|
|
|
|
|
ActorPublicID *string
|
|
|
|
|
RequestID string
|
|
|
|
|
ExpectedRevision *int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Result struct {
|
|
|
|
|
From State
|
|
|
|
|
To State
|
|
|
|
|
Revision int64
|
|
|
|
|
DeviceVersion int64
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
Changed bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Parse(value string) (State, error) {
|
|
|
|
|
state := State(strings.ToUpper(strings.TrimSpace(value)))
|
|
|
|
|
switch state {
|
|
|
|
|
case Factory, Claimable, Bound, Provisioning, Active, Lost, Revoked:
|
|
|
|
|
return state, nil
|
|
|
|
|
default:
|
|
|
|
|
return "", ErrInvalidState
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func AllowsDeviceAuthentication(state string) bool {
|
|
|
|
|
parsed, err := Parse(state)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Unit-test fakes and rolling deployments may omit the new field. Database
|
|
|
|
|
// rows are never empty after migration 50.
|
|
|
|
|
return strings.TrimSpace(state) == ""
|
|
|
|
|
}
|
|
|
|
|
return parsed == Claimable || parsed == Bound || parsed == Provisioning || parsed == Active
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CanTransition(from, to State) bool {
|
|
|
|
|
if from == to {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if from == Revoked {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if to == Revoked {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch from {
|
|
|
|
|
case Factory:
|
|
|
|
|
return to == Claimable
|
|
|
|
|
case Claimable:
|
|
|
|
|
return to == Bound
|
|
|
|
|
case Bound:
|
|
|
|
|
return to == Claimable || to == Provisioning
|
|
|
|
|
case Provisioning:
|
|
|
|
|
return to == Bound || to == Active
|
|
|
|
|
case Active:
|
|
|
|
|
return to == Claimable || to == Provisioning || to == Lost
|
|
|
|
|
case Lost:
|
|
|
|
|
return to == Claimable || to == Active
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Apply(ctx context.Context, tx pgx.Tx, input Input) (Result, error) {
|
|
|
|
|
if tx == nil || strings.TrimSpace(input.DeviceID) == "" || strings.TrimSpace(input.TenantID) == "" ||
|
|
|
|
|
strings.TrimSpace(input.Reason) == "" || strings.TrimSpace(input.Source) == "" || strings.TrimSpace(input.ActorType) == "" {
|
|
|
|
|
return Result{}, ErrInvalidState
|
|
|
|
|
}
|
|
|
|
|
target, err := Parse(string(input.Target))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, err
|
|
|
|
|
}
|
|
|
|
|
var currentText string
|
|
|
|
|
var revision, deviceVersion int64
|
|
|
|
|
var updatedAt time.Time
|
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
|
|
|
SELECT activation_state,activation_revision,version,activation_updated_at
|
|
|
|
|
FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, input.DeviceID, input.TenantID).
|
|
|
|
|
Scan(¤tText, &revision, &deviceVersion, &updatedAt)
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
return Result{}, ErrDeviceNotFound
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, fmt.Errorf("lock device activation: %w", err)
|
|
|
|
|
}
|
|
|
|
|
current, err := Parse(currentText)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, fmt.Errorf("parse stored device activation state: %w", err)
|
|
|
|
|
}
|
2026-09-12 08:57:29 +08:00
|
|
|
var enrollmentMode string
|
|
|
|
|
if err := tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, input.DeviceID).Scan(&enrollmentMode); err != nil {
|
|
|
|
|
return Result{}, err
|
|
|
|
|
}
|
|
|
|
|
if enrollmentMode == "physical_confirm" && input.Source != "onboarding" && (target == Active || target == Bound || target == Provisioning) {
|
|
|
|
|
return Result{}, ErrTransitionRejected
|
|
|
|
|
}
|
2026-09-06 17:04:11 +08:00
|
|
|
if input.ExpectedRevision != nil && revision != *input.ExpectedRevision {
|
|
|
|
|
return Result{}, ErrRevisionConflict
|
|
|
|
|
}
|
|
|
|
|
if !CanTransition(current, target) {
|
|
|
|
|
return Result{}, ErrTransitionRejected
|
|
|
|
|
}
|
|
|
|
|
if current == target {
|
|
|
|
|
return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt}, nil
|
|
|
|
|
}
|
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
|
|
|
UPDATE device SET activation_state=$3,activation_revision=activation_revision+1,
|
|
|
|
|
activation_reason=$4,activation_updated_at=now(),version=version+1,updated_at=now()
|
|
|
|
|
WHERE id=$1::uuid AND tenant_id=$2::uuid
|
|
|
|
|
RETURNING activation_revision,version,activation_updated_at`, input.DeviceID, input.TenantID, target, strings.TrimSpace(input.Reason)).
|
|
|
|
|
Scan(&revision, &deviceVersion, &updatedAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, fmt.Errorf("update device activation: %w", err)
|
|
|
|
|
}
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
INSERT INTO device_activation_transition(
|
|
|
|
|
tenant_id,device_id,from_state,to_state,reason,source,actor_type,actor_public_id,request_id,activation_revision
|
|
|
|
|
) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,NULLIF($8,'')::uuid,NULLIF($9,''),$10)`,
|
|
|
|
|
input.TenantID, input.DeviceID, current, target, strings.TrimSpace(input.Reason), strings.TrimSpace(input.Source),
|
|
|
|
|
strings.TrimSpace(input.ActorType), optionalString(input.ActorPublicID), strings.TrimSpace(input.RequestID), revision)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, fmt.Errorf("record device activation transition: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt, Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func optionalString(value *string) string {
|
|
|
|
|
if value == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(*value)
|
|
|
|
|
}
|