167 lines
8.0 KiB
Go
167 lines
8.0 KiB
Go
package saas
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/database"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
DeviceBindingActionUnbind = "unbind"
|
||
|
|
DeviceBindingActionTransfer = "transfer"
|
||
|
|
)
|
||
|
|
|
||
|
|
type DeviceBindingImpact struct {
|
||
|
|
DeviceID string `json:"device_id"`
|
||
|
|
BindingID string `json:"binding_id"`
|
||
|
|
Action string `json:"action"`
|
||
|
|
SourceHouseholdID string `json:"source_household_id"`
|
||
|
|
SourceSubjectID string `json:"source_subject_id"`
|
||
|
|
SourceSubjectName string `json:"source_subject_name"`
|
||
|
|
TargetHouseholdID *string `json:"target_household_id,omitempty"`
|
||
|
|
TargetSubjectID *string `json:"target_subject_id,omitempty"`
|
||
|
|
TargetSubjectName *string `json:"target_subject_name,omitempty"`
|
||
|
|
ActiveConversationID *string `json:"active_conversation_id,omitempty"`
|
||
|
|
PendingCommandCount int `json:"pending_command_count"`
|
||
|
|
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
|
||
|
|
Allowed bool `json:"allowed"`
|
||
|
|
Blockers []string `json:"blockers"`
|
||
|
|
EvaluatedAt time.Time `json:"evaluated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ChangeDeviceBindingInput struct {
|
||
|
|
Action string
|
||
|
|
ExpectedBindingID string
|
||
|
|
TargetSubjectID string
|
||
|
|
Reason string
|
||
|
|
Acknowledged bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type DeviceBindingChangeResult struct {
|
||
|
|
DeviceID string `json:"device_id"`
|
||
|
|
Action string `json:"action"`
|
||
|
|
PreviousBindingID string `json:"previous_binding_id"`
|
||
|
|
BindingID *string `json:"binding_id,omitempty"`
|
||
|
|
HouseholdID *string `json:"household_id,omitempty"`
|
||
|
|
SubjectID *string `json:"subject_id,omitempty"`
|
||
|
|
ChangedAt time.Time `json:"changed_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) GetDeviceBindingImpact(ctx context.Context, principal identity.Principal, devicePublicID, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
|
||
|
|
access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityDeviceManage, false)
|
||
|
|
if err != nil {
|
||
|
|
return DeviceBindingImpact{}, err
|
||
|
|
}
|
||
|
|
return s.readDeviceBindingImpact(ctx, s.pool, principal, access, device, action, targetSubjectPublicID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Principal, devicePublicID, requestID string, input ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
|
||
|
|
var result DeviceBindingChangeResult
|
||
|
|
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||
|
|
access, device, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var bindingID string
|
||
|
|
if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return ErrDeviceBindingStale
|
||
|
|
} else if err != nil {
|
||
|
|
return fmt.Errorf("lock active device binding: %w", err)
|
||
|
|
}
|
||
|
|
if bindingID != input.ExpectedBindingID {
|
||
|
|
return ErrDeviceBindingStale
|
||
|
|
}
|
||
|
|
impact, err := s.readDeviceBindingImpact(ctx, tx, principal, access, device, input.Action, input.TargetSubjectID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !impact.Allowed {
|
||
|
|
return ErrDeviceBindingBlocked
|
||
|
|
}
|
||
|
|
now := time.Now().UTC()
|
||
|
|
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=$1,unbound_by=$2::uuid,reason=$3 WHERE public_id=$4::uuid AND unbound_at IS NULL`, now, access.userID, input.Action+":"+strings.TrimSpace(input.Reason), bindingID); err != nil {
|
||
|
|
return fmt.Errorf("close active device binding: %w", err)
|
||
|
|
}
|
||
|
|
result = DeviceBindingChangeResult{DeviceID: devicePublicID, Action: input.Action, PreviousBindingID: bindingID, ChangedAt: now}
|
||
|
|
if input.Action == DeviceBindingActionTransfer {
|
||
|
|
targetAccess, _, err := resolveSubject(ctx, tx, principal, input.TargetSubjectID, identity.CapabilityDeviceManage, true)
|
||
|
|
if err != nil || targetAccess.tenantID != access.tenantID {
|
||
|
|
return ErrAccessDenied
|
||
|
|
}
|
||
|
|
var newBindingID string
|
||
|
|
if err := tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason,bound_at)
|
||
|
|
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7)
|
||
|
|
RETURNING public_id::text`, access.tenantID, access.deviceID, targetAccess.householdID, targetAccess.subjectID, access.userID, "transfer:"+strings.TrimSpace(input.Reason), now).Scan(&newBindingID); err != nil {
|
||
|
|
return fmt.Errorf("create transferred device binding: %w", err)
|
||
|
|
}
|
||
|
|
result.BindingID = &newBindingID
|
||
|
|
result.HouseholdID = &targetAccess.householdPublicID
|
||
|
|
result.SubjectID = &input.TargetSubjectID
|
||
|
|
}
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{
|
||
|
|
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||
|
|
Action: "device.binding." + input.Action, ResourceType: "device", ResourceID: strPtr(devicePublicID),
|
||
|
|
RequestID: requestID, Result: "success", Detail: map[string]any{
|
||
|
|
"previous_binding_id": bindingID, "new_binding_id": result.BindingID, "target_household_id": result.HouseholdID,
|
||
|
|
"target_subject_id": result.SubjectID, "reason": strings.TrimSpace(input.Reason),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, principal identity.Principal, access deviceAccess, device ManagedDevice, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
|
||
|
|
if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer {
|
||
|
|
return DeviceBindingImpact{}, ErrNotFound
|
||
|
|
}
|
||
|
|
impact := DeviceBindingImpact{
|
||
|
|
DeviceID: device.ID, BindingID: device.BindingID, Action: action,
|
||
|
|
SourceHouseholdID: device.HouseholdID, SourceSubjectID: device.SubjectID, SourceSubjectName: device.SubjectName,
|
||
|
|
Blockers: make([]string, 0), EvaluatedAt: time.Now().UTC(),
|
||
|
|
}
|
||
|
|
if action == DeviceBindingActionTransfer {
|
||
|
|
if targetSubjectPublicID == "" || targetSubjectPublicID == device.SubjectID {
|
||
|
|
impact.Blockers = append(impact.Blockers, "target_subject_required")
|
||
|
|
} else {
|
||
|
|
targetAccess, target, err := resolveSubject(ctx, q, principal, targetSubjectPublicID, identity.CapabilityDeviceManage, false)
|
||
|
|
if err != nil || targetAccess.tenantID != access.tenantID {
|
||
|
|
return DeviceBindingImpact{}, ErrAccessDenied
|
||
|
|
}
|
||
|
|
impact.TargetHouseholdID = &target.HouseholdID
|
||
|
|
impact.TargetSubjectID = &target.ID
|
||
|
|
impact.TargetSubjectName = &target.PreferredName
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var activeConversation string
|
||
|
|
err := q.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') ORDER BY created_at DESC LIMIT 1`, access.deviceID, access.tenantID).Scan(&activeConversation)
|
||
|
|
if err == nil {
|
||
|
|
impact.ActiveConversationID = &activeConversation
|
||
|
|
impact.Blockers = append(impact.Blockers, "active_conversation")
|
||
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return DeviceBindingImpact{}, fmt.Errorf("check active device conversation: %w", err)
|
||
|
|
}
|
||
|
|
if err := q.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()`, access.deviceID, access.tenantID).Scan(&impact.PendingCommandCount); err != nil {
|
||
|
|
return DeviceBindingImpact{}, fmt.Errorf("count pending device commands: %w", err)
|
||
|
|
}
|
||
|
|
if impact.PendingCommandCount > 0 {
|
||
|
|
impact.Blockers = append(impact.Blockers, "pending_device_commands")
|
||
|
|
}
|
||
|
|
if err := q.QueryRow(ctx, `SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('PENDING','COMMAND_QUEUED','DOWNLOADING','INSTALLING')`, access.deviceID, access.tenantID).Scan(&impact.ActiveFirmwareTaskCount); err != nil {
|
||
|
|
return DeviceBindingImpact{}, fmt.Errorf("count active firmware assignments: %w", err)
|
||
|
|
}
|
||
|
|
if impact.ActiveFirmwareTaskCount > 0 {
|
||
|
|
impact.Blockers = append(impact.Blockers, "active_firmware_task")
|
||
|
|
}
|
||
|
|
impact.Allowed = len(impact.Blockers) == 0
|
||
|
|
return impact, nil
|
||
|
|
}
|