122 lines
6.7 KiB
Go
122 lines
6.7 KiB
Go
package saas
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/deviceactivation"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const accountDeviceSelect = `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,d.id::text,
|
||
|
|
d.public_id::text,ab.id::text,d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,
|
||
|
|
dt.firmware_version,dt.desired_revision,dt.reported_revision,dt.last_reported_at,ab.bound_at,d.registered_at,d.version
|
||
|
|
FROM device_account_binding ab JOIN device d ON d.id=ab.device_id AND d.tenant_id=ab.tenant_id
|
||
|
|
JOIN tenant t ON t.id=d.tenant_id AND t.status='active'
|
||
|
|
JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id AND ua.status='active' AND ua.account_kind='customer'
|
||
|
|
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||
|
|
WHERE ab.released_at IS NULL AND ab.generation=d.binding_generation AND ua.oidc_issuer=$1 AND ua.oidc_subject=$2
|
||
|
|
AND d.status='registered' AND d.activation_state NOT IN ('REVOKED','LOST')
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL)`
|
||
|
|
|
||
|
|
func scanAccountDevice(row interface{ Scan(...any) error }) (deviceAccess, ManagedDevice, error) {
|
||
|
|
var a deviceAccess
|
||
|
|
var d ManagedDevice
|
||
|
|
err := row.Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.deviceID, &d.ID, &d.AccountBindingID, &d.Model, &d.HardwareRevision, &d.Status, &d.MinimumSecurityVersion, &d.MinimumFirmwareVersion, &d.FirmwareVersion, &d.DesiredRevision, &d.ReportedRevision, &d.LastReportedAt, &d.BoundAt, &d.RegisteredAt, &d.Version)
|
||
|
|
a.role = identity.Role("owner")
|
||
|
|
a.desiredRevision = d.DesiredRevision
|
||
|
|
d.BindingID = d.AccountBindingID
|
||
|
|
return a, d, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) listAccountDevices(ctx context.Context, p identity.Principal) ([]ManagedDevice, error) {
|
||
|
|
rows, err := s.pool.Query(ctx, accountDeviceSelect+` ORDER BY ab.bound_at DESC,d.public_id`, p.Issuer, p.Subject)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
result := []ManagedDevice{}
|
||
|
|
for rows.Next() {
|
||
|
|
_, d, e := scanAccountDevice(rows)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
result = append(result, d)
|
||
|
|
}
|
||
|
|
return result, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func resolveAccountDevice(ctx context.Context, q queryRow, p identity.Principal, public string, capability identity.Capability, lock bool) (deviceAccess, ManagedDevice, error) {
|
||
|
|
// Only device capabilities may be granted without a household.
|
||
|
|
if capability != identity.CapabilityHouseholdRead && capability != identity.CapabilityDeviceManage && capability != identity.CapabilityFirmwareManage {
|
||
|
|
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
|
||
|
|
}
|
||
|
|
sql := accountDeviceSelect + ` AND d.public_id=$3::uuid`
|
||
|
|
if lock {
|
||
|
|
sql += ` FOR UPDATE OF d,dt`
|
||
|
|
}
|
||
|
|
a, d, err := scanAccountDevice(q.QueryRow(ctx, sql, p.Issuer, p.Subject, public))
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
err = ErrAccessDenied
|
||
|
|
}
|
||
|
|
return a, d, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) releaseAccountDevice(ctx context.Context, tx pgx.Tx, p identity.Principal, a deviceAccess, d ManagedDevice, requestID string, in ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
|
||
|
|
if in.Action != DeviceBindingActionUnbind || in.ExpectedBindingID != d.AccountBindingID {
|
||
|
|
return DeviceBindingChangeResult{}, ErrDeviceBindingStale
|
||
|
|
}
|
||
|
|
impact, err := s.readDeviceBindingImpact(ctx, tx, p, a, d, in.Action, "")
|
||
|
|
if err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if !impact.Allowed {
|
||
|
|
return DeviceBindingChangeResult{}, ErrDeviceBindingBlocked
|
||
|
|
}
|
||
|
|
var lifecycle string
|
||
|
|
if err = tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid FOR UPDATE`, a.deviceID).Scan(&lifecycle); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
now := time.Now().UTC()
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE device_account_binding SET released_at=$2 WHERE id=$1::uuid AND released_at IS NULL`, d.AccountBindingID, now); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=$1::uuid`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, a.deviceID); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
transition := deviceactivation.Input{DeviceID: a.deviceID, TenantID: a.tenantID, Target: deviceactivation.Claimable, Reason: "用户移除账号设备", Source: "onboarding", ActorType: "user", ActorPublicID: &a.userPublicID, RequestID: requestID}
|
||
|
|
if lifecycle == "PROVISIONING" {
|
||
|
|
transition.Target = deviceactivation.Bound
|
||
|
|
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
transition.Target = deviceactivation.Claimable
|
||
|
|
}
|
||
|
|
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
|
||
|
|
return DeviceBindingChangeResult{}, err
|
||
|
|
}
|
||
|
|
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "device.account.unbind", ResourceType: "device", ResourceID: &d.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"account_binding_id": d.AccountBindingID}})
|
||
|
|
return DeviceBindingChangeResult{DeviceID: d.ID, Action: in.Action, PreviousBindingID: d.AccountBindingID, ChangedAt: now}, err
|
||
|
|
}
|