@@ -0,0 +1,162 @@
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package deviceactivation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanTransition(t *testing.T) {
|
||||
allowed := [][2]State{{Factory, Claimable}, {Claimable, Bound}, {Bound, Provisioning}, {Provisioning, Active}, {Active, Lost}, {Lost, Active}, {Active, Claimable}, {Active, Provisioning}, {Bound, Revoked}}
|
||||
for _, edge := range allowed {
|
||||
if !CanTransition(edge[0], edge[1]) {
|
||||
t.Fatalf("expected %s -> %s to be allowed", edge[0], edge[1])
|
||||
}
|
||||
}
|
||||
rejected := [][2]State{{Factory, Active}, {Claimable, Active}, {Active, Bound}, {Revoked, Claimable}, {Lost, Provisioning}}
|
||||
for _, edge := range rejected {
|
||||
if CanTransition(edge[0], edge[1]) {
|
||||
t.Fatalf("expected %s -> %s to be rejected", edge[0], edge[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowsDeviceAuthentication(t *testing.T) {
|
||||
for _, state := range []string{"", "CLAIMABLE", "BOUND", "PROVISIONING", "ACTIVE"} {
|
||||
if !AllowsDeviceAuthentication(state) {
|
||||
t.Fatalf("expected %q to authenticate", state)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{"FACTORY", "LOST", "REVOKED", "invalid"} {
|
||||
if AllowsDeviceAuthentication(state) {
|
||||
t.Fatalf("expected %q to be blocked", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user