package saas import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) type DeviceClaimResult struct { BindingID string `json:"binding_id"` DeviceID string `json:"device_id"` HouseholdID string `json:"household_id"` SubjectID string `json:"subject_id"` BoundAt time.Time `json:"bound_at"` } func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal, claimCode, householdPublicID, subjectPublicID, requestID string) (DeviceClaimResult, bool, error) { var result DeviceClaimResult var replayed bool digestBytes := sha256.Sum256([]byte(claimCode)) digest := hex.EncodeToString(digestBytes[:]) err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityDeviceManage, true) if err != nil { return err } var subjectID string if err := tx.QueryRow(ctx, ` SELECT id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status <> 'deleted' FOR SHARE`, subjectPublicID, access.tenantID, access.householdID).Scan(&subjectID); errors.Is(err, pgx.ErrNoRows) { return ErrAccessDenied } else if err != nil { return fmt.Errorf("resolve claim subject: %w", err) } var claimID, deviceID, devicePublicID string var expiresAt time.Time var revokedAt, usedAt *time.Time var usedBindingID *string if err := tx.QueryRow(ctx, ` SELECT dc.id::text,dc.device_id::text,d.public_id::text,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered' FOR UPDATE OF dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) { return ErrDeviceClaimInvalid } else if err != nil { return fmt.Errorf("resolve device claim: %w", err) } if revokedAt != nil || !expiresAt.After(time.Now()) { return ErrDeviceClaimInvalid } if usedAt != nil { if usedBindingID == nil { return ErrDeviceClaimInvalid } if err := tx.QueryRow(ctx, ` SELECT b.public_id::text,d.public_id::text,h.public_id::text,sp.public_id::text,b.bound_at FROM device_binding b JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id WHERE b.id=$1::uuid`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil { return fmt.Errorf("read used device claim binding: %w", err) } if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID { return ErrDeviceClaimUsed } replayed = true return nil } var activeBinding bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, deviceID).Scan(&activeBinding); err != nil { return fmt.Errorf("check active device binding: %w", err) } if activeBinding { return ErrDeviceAlreadyBound } var bindingInternalID string if err := tx.QueryRow(ctx, ` INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'one_time_claim') RETURNING id::text,public_id::text,$6::text,$7::text,$8::text,bound_at`, access.tenantID, deviceID, access.householdID, subjectID, access.userID, devicePublicID, householdPublicID, subjectPublicID).Scan(&bindingInternalID, &result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil { return fmt.Errorf("insert claimed device binding: %w", err) } if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil { return fmt.Errorf("mark device claim used: %w", err) } return s.audit.Record(ctx, tx, audit.Event{ TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID), RequestID: requestID, Result: "success", Detail: map[string]any{"device_id": devicePublicID, "household_id": householdPublicID, "subject_id": subjectPublicID}, }) }) return result, replayed, err }