This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user