isds 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Client
5
+ attr_reader :configuration, :connection
6
+
7
+ def initialize(config = nil, &block)
8
+ @configuration = config || ISDS.configuration
9
+ yield(@configuration) if block
10
+ @connection = Connection.new(@configuration)
11
+ end
12
+
13
+ def authenticate!
14
+ configuration.validate!
15
+ # Perform a simple operation to verify credentials
16
+ connection.call(:info, :get_owner_info_from_login)
17
+ @authenticated = true
18
+ rescue ISDS::Error
19
+ @authenticated = false
20
+ raise
21
+ end
22
+
23
+ def connected?
24
+ @authenticated == true
25
+ end
26
+
27
+ def disconnect
28
+ @authenticated = false
29
+ @connection = Connection.new(configuration)
30
+ end
31
+
32
+ # --- Message operations ---
33
+
34
+ def send_message(to_databox_id, subject:, attachments: [], **options)
35
+ message = Message.new(connection: connection)
36
+ message.create(to_databox_id, subject: subject, attachments: attachments, **options)
37
+ end
38
+
39
+ def receive_messages(limit: 50, unread_only: true, **filters)
40
+ Message.received(connection: connection, limit: limit, unread_only: unread_only, **filters)
41
+ end
42
+
43
+ def sent_messages(limit: 50, **filters)
44
+ Message.sent(connection: connection, limit: limit, **filters)
45
+ end
46
+
47
+ def find_message(message_id)
48
+ Message.find(message_id, connection: connection)
49
+ end
50
+
51
+ def mark_messages_read(message_ids)
52
+ Array(message_ids).each do |id|
53
+ Message.mark_as_read(id, connection: connection)
54
+ end
55
+ end
56
+
57
+ # --- Databox operations ---
58
+
59
+ def find_databox(databox_id)
60
+ Databox.find(databox_id, connection: connection)
61
+ end
62
+
63
+ def search_databoxes(query, **filters)
64
+ Search.databoxes(query, connection: connection, **filters)
65
+ end
66
+
67
+ def check_databox_status(databox_id)
68
+ Databox.check_status(databox_id, connection: connection)
69
+ end
70
+
71
+ # --- Info operations ---
72
+
73
+ def owner_info
74
+ connection.call(:info, :get_owner_info_from_login)
75
+ end
76
+
77
+ def password_info
78
+ connection.call(:access, :get_password_info)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Configuration
5
+ VALID_ENVIRONMENTS = %i[test production].freeze
6
+ VALID_AUTH_METHODS = %i[basic certificate access_interface].freeze
7
+ DEFAULT_TIMEOUT = 30
8
+ DEFAULT_RETRIES = 3
9
+ DEFAULT_POOL_SIZE = 5
10
+ DEFAULT_LARGE_MESSAGE_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB
11
+
12
+ attr_accessor :environment, :timeout, :retries, :auth_method,
13
+ :username, :password,
14
+ :certificate_path, :private_key_path, :key_password,
15
+ :logger, :log_level, :enable_request_logging,
16
+ :connection_pool_size, :request_timeout, :large_message_chunk_size,
17
+ :endpoints, :ssl_verify_peer, :ssl_ca_file, :ssl_version,
18
+ :on_error, :retry_on,
19
+ :cache_store, :cache_ttl
20
+
21
+ def initialize(**options)
22
+ assign_core_options(options)
23
+ assign_auth_options(options)
24
+ assign_logging_options(options)
25
+ assign_connection_options(options)
26
+ assign_ssl_options(options)
27
+ assign_advanced_options(options)
28
+ end
29
+
30
+ def valid?
31
+ validate!
32
+ true
33
+ rescue ConfigurationError
34
+ false
35
+ end
36
+
37
+ def validate!
38
+ validate_environment!
39
+ validate_auth_method!
40
+ validate_credentials!
41
+ end
42
+
43
+ def endpoint_urls
44
+ @endpoints || ISDS::ENDPOINTS.fetch(environment)
45
+ end
46
+
47
+ def endpoint_for(service)
48
+ urls = endpoint_urls
49
+
50
+ # Direct key lookup (backward compat with custom endpoints)
51
+ return urls[service] if urls.key?(service)
52
+
53
+ # Map through SERVICE_ENDPOINT_MAP
54
+ endpoint_key = ISDS::SERVICE_ENDPOINT_MAP[service]
55
+ raise ConfigurationError, "Unknown service: #{service}" unless endpoint_key
56
+
57
+ urls.fetch(endpoint_key) do
58
+ raise ConfigurationError, "No endpoint configured for #{endpoint_key} (service: #{service})"
59
+ end
60
+ end
61
+
62
+ def base_url
63
+ endpoint_urls[:base_url]
64
+ end
65
+
66
+ def test?
67
+ environment == :test
68
+ end
69
+
70
+ def production?
71
+ environment == :production
72
+ end
73
+
74
+ private
75
+
76
+ def validate_environment!
77
+ return if VALID_ENVIRONMENTS.include?(environment)
78
+
79
+ raise ConfigurationError, "Invalid environment: #{environment}. Must be one of: #{VALID_ENVIRONMENTS.join(', ')}"
80
+ end
81
+
82
+ def validate_auth_method!
83
+ return if VALID_AUTH_METHODS.include?(auth_method)
84
+
85
+ raise ConfigurationError,
86
+ "Invalid auth_method: #{auth_method}. Must be one of: #{VALID_AUTH_METHODS.join(', ')}"
87
+ end
88
+
89
+ def validate_credentials!
90
+ case auth_method
91
+ when :basic, :access_interface
92
+ validate_basic_credentials!
93
+ when :certificate
94
+ validate_certificate_credentials!
95
+ end
96
+ end
97
+
98
+ def assign_core_options(options)
99
+ @environment = options.fetch(:environment, :test)
100
+ @timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
101
+ @retries = options.fetch(:retries, DEFAULT_RETRIES)
102
+ end
103
+
104
+ def assign_auth_options(options)
105
+ @auth_method = options.fetch(:auth_method, :basic)
106
+ @username = options[:username]
107
+ @password = options[:password]
108
+ @certificate_path = options[:certificate_path]
109
+ @private_key_path = options[:private_key_path]
110
+ @key_password = options[:key_password]
111
+ end
112
+
113
+ def assign_logging_options(options)
114
+ @logger = options[:logger]
115
+ @log_level = options.fetch(:log_level, :info)
116
+ @enable_request_logging = options.fetch(:enable_request_logging, false)
117
+ end
118
+
119
+ def assign_connection_options(options)
120
+ @connection_pool_size = options.fetch(:connection_pool_size, DEFAULT_POOL_SIZE)
121
+ @request_timeout = options.fetch(:request_timeout, 120)
122
+ @large_message_chunk_size = options.fetch(:large_message_chunk_size, DEFAULT_LARGE_MESSAGE_CHUNK_SIZE)
123
+ @endpoints = options[:endpoints]
124
+ end
125
+
126
+ def assign_ssl_options(options)
127
+ @ssl_verify_peer = options.fetch(:ssl_verify_peer, true)
128
+ @ssl_ca_file = options[:ssl_ca_file]
129
+ @ssl_version = options[:ssl_version]
130
+ end
131
+
132
+ def assign_advanced_options(options)
133
+ @on_error = options[:on_error]
134
+ @retry_on = options[:retry_on]
135
+ @cache_store = options[:cache_store]
136
+ @cache_ttl = options.fetch(:cache_ttl, 3600)
137
+ end
138
+
139
+ def validate_basic_credentials!
140
+ raise ConfigurationError, 'Username is required for basic authentication' if username.nil? || username.empty?
141
+ raise ConfigurationError, 'Password is required for basic authentication' if password.nil? || password.empty?
142
+ end
143
+
144
+ def validate_certificate_credentials!
145
+ if certificate_path.nil? || certificate_path.empty?
146
+ raise ConfigurationError,
147
+ 'Certificate path is required for certificate authentication'
148
+ end
149
+ return unless private_key_path.nil? || private_key_path.empty?
150
+
151
+ raise ConfigurationError, 'Private key path is required for certificate authentication'
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Connection
5
+ attr_reader :configuration, :authenticator
6
+
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ @authenticator = Authentication::Base.build(configuration)
10
+ @soap_clients = {}
11
+ end
12
+
13
+ def call(service, operation, message = {})
14
+ client = soap_client_for(service)
15
+ response = client.call(operation, message: message)
16
+ parse_response(response)
17
+ rescue Savon::SOAPFault => e
18
+ handle_soap_fault(e)
19
+ rescue Savon::HTTPError => e
20
+ handle_http_error(e)
21
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
22
+ raise TimeoutError.new("Request timed out: #{e.message}", original_error: e)
23
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
24
+ raise ConnectionError.new("Connection failed: #{e.message}", original_error: e)
25
+ end
26
+
27
+ def soap_client_for(service)
28
+ @soap_clients[service] ||= build_soap_client(service)
29
+ end
30
+
31
+ private
32
+
33
+ def build_soap_client(service)
34
+ endpoint = configuration.endpoint_for(service)
35
+
36
+ options = base_savon_options(endpoint)
37
+ options = authenticator.apply(options)
38
+ Savon.client(**options)
39
+ end
40
+
41
+ def base_savon_options(endpoint) # rubocop:disable Metrics/AbcSize
42
+ options = {
43
+ endpoint: endpoint,
44
+ namespace: ISDS::ISDS_NAMESPACE,
45
+ namespace_identifier: :isds,
46
+ env_namespace: :soapenv,
47
+ open_timeout: configuration.timeout,
48
+ read_timeout: configuration.request_timeout,
49
+ ssl_verify_mode: configuration.ssl_verify_peer ? :peer : :none,
50
+ log: configuration.enable_request_logging,
51
+ pretty_print_xml: configuration.test?,
52
+ convert_response_tags_to: ->(tag) { tag.snakecase.to_sym }
53
+ }
54
+
55
+ options[:ssl_ca_cert_file] = configuration.ssl_ca_file if configuration.ssl_ca_file
56
+ if configuration.logger
57
+ options[:logger] = configuration.logger
58
+ options[:log_level] = configuration.log_level
59
+ end
60
+ options
61
+ end
62
+
63
+ def parse_response(response)
64
+ body = response.body
65
+ check_isds_status!(body)
66
+ body
67
+ end
68
+
69
+ def check_isds_status!(body)
70
+ status = extract_status(body)
71
+ return if status.nil?
72
+
73
+ code = status[:dm_status_code] || status[:db_status_code]
74
+ message = status[:dm_status_message] || status[:db_status_message]
75
+ return if code == '0000'
76
+
77
+ raise ErrorMapper.map(code, message)
78
+ end
79
+
80
+ def extract_status(body)
81
+ return nil unless body.is_a?(Hash)
82
+
83
+ body.each_value do |value|
84
+ next unless value.is_a?(Hash)
85
+
86
+ return value[:dm_status] if value[:dm_status]
87
+ return value[:db_status] if value[:db_status]
88
+ end
89
+ nil
90
+ end
91
+
92
+ def handle_soap_fault(error)
93
+ fault_message = error.to_hash.dig(:fault, :faultstring) || error.message
94
+
95
+ if fault_message.match?(/authentication|unauthorized|login/i)
96
+ raise InvalidCredentialsError.new(fault_message, original_error: error)
97
+ end
98
+
99
+ raise ISDS::Error.new("SOAP Fault: #{fault_message}", original_error: error)
100
+ end
101
+
102
+ def handle_http_error(error)
103
+ case error.http.code
104
+ when 401, 403
105
+ raise InvalidCredentialsError.new("Authentication failed (HTTP #{error.http.code})", original_error: error)
106
+ when 503
107
+ raise ServiceUnavailableError.new('ISDS service unavailable', original_error: error)
108
+ else
109
+ raise NetworkError.new("HTTP error #{error.http.code}", original_error: error)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Databox
5
+ attr_reader :id, :name, :type, :ico, :address, :status,
6
+ :pdz_enabled, :ovm, :raw_data
7
+
8
+ def initialize(**attributes)
9
+ @id = attributes[:id]
10
+ @name = attributes[:name]
11
+ @type = attributes[:type]
12
+ @ico = attributes[:ico]
13
+ @address = attributes[:address]
14
+ @status = attributes[:status]
15
+ @pdz_enabled = attributes[:pdz_enabled]
16
+ @ovm = attributes[:ovm]
17
+ @raw_data = attributes[:raw_data]
18
+ end
19
+
20
+ def active?
21
+ status&.to_i == 1
22
+ end
23
+
24
+ def accessible?
25
+ active?
26
+ end
27
+
28
+ def can_receive_pdt?
29
+ pdz_enabled == true
30
+ end
31
+
32
+ def ovm?
33
+ ovm == true
34
+ end
35
+
36
+ def self.find(databox_id, connection:)
37
+ validate_databox_id!(databox_id)
38
+
39
+ response = connection.call(:search, :find_data_box, {
40
+ 'isds:dbOwnerInfo' => {
41
+ 'isds:dbID' => databox_id
42
+ }
43
+ })
44
+
45
+ from_response(response)
46
+ end
47
+
48
+ def self.find_by_ico(ico, connection:)
49
+ response = connection.call(:search, :find_data_box, {
50
+ 'isds:dbOwnerInfo' => {
51
+ 'isds:ic' => ico
52
+ }
53
+ })
54
+
55
+ from_response(response)
56
+ end
57
+
58
+ def self.check_status(databox_id, connection:)
59
+ validate_databox_id!(databox_id)
60
+
61
+ response = connection.call(:search, :check_data_box, {
62
+ 'isds:dbID' => databox_id
63
+ })
64
+
65
+ extract_status(response)
66
+ end
67
+
68
+ def self.validate_databox_id!(databox_id)
69
+ return if databox_id.is_a?(String) && databox_id.match?(/\A[a-z0-9]{7}\z/i)
70
+
71
+ raise InvalidMessageError, "Invalid databox ID format: #{databox_id}. Must be 7 alphanumeric characters."
72
+ end
73
+
74
+ private_class_method def self.from_response(response)
75
+ records = extract_records(response)
76
+ return nil if records.nil? || records.empty?
77
+
78
+ records = [records] unless records.is_a?(Array)
79
+ records.map { |record| from_record(record) }
80
+ end
81
+
82
+ private_class_method def self.extract_records(response)
83
+ key = response.keys.first
84
+ response.dig(key, :db_results, :db_owner_info) ||
85
+ response.dig(key, :db_owner_info)
86
+ end
87
+
88
+ private_class_method def self.from_record(record)
89
+ new(
90
+ id: record[:db_id],
91
+ name: [record[:firm_name], record[:pn_first_name], record[:pn_last_name]].compact.join(' ').strip,
92
+ type: record[:db_type],
93
+ ico: record[:ic],
94
+ address: build_address(record),
95
+ status: record[:db_state],
96
+ pdz_enabled: record[:db_open_addressing],
97
+ ovm: record[:db_type]&.start_with?('OVM'),
98
+ raw_data: record
99
+ )
100
+ end
101
+
102
+ private_class_method def self.build_address(record)
103
+ parts = [
104
+ record[:ad_street],
105
+ record[:ad_number_in_street],
106
+ record[:ad_city],
107
+ record[:ad_zip_code]
108
+ ].compact
109
+ parts.join(', ')
110
+ end
111
+
112
+ private_class_method def self.extract_status(response)
113
+ key = response.keys.first
114
+ state = response.dig(key, :db_state)
115
+ {
116
+ status: state&.to_i,
117
+ active: state&.to_i == 1
118
+ }
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module ISDS
6
+ module DigitalSignature
7
+ class Verifier
8
+ attr_reader :connection
9
+
10
+ def initialize(connection:)
11
+ @connection = connection
12
+ end
13
+
14
+ def verify_message(message_id)
15
+ response = connection.call(:info, :signed_message_download, {
16
+ 'isds:dmID' => message_id
17
+ })
18
+
19
+ signature_data = response.dig(:signed_message_download_response, :dm_signature)
20
+ return { valid: false, error: 'No signature found' } unless signature_data
21
+
22
+ verify_pkcs7_signature(Base64.decode64(signature_data))
23
+ end
24
+
25
+ def verify_delivery_info(message_id)
26
+ response = connection.call(:info, :get_signed_delivery_info, {
27
+ 'isds:dmID' => message_id
28
+ })
29
+
30
+ signed_info = response.dig(:get_signed_delivery_info_response, :dm_signature)
31
+ return { valid: false, error: 'No delivery signature found' } unless signed_info
32
+
33
+ verify_pkcs7_signature(Base64.decode64(signed_info))
34
+ end
35
+
36
+ private
37
+
38
+ def verify_pkcs7_signature(data)
39
+ pkcs7 = OpenSSL::PKCS7.new(data)
40
+ store = OpenSSL::X509::Store.new
41
+ store.set_default_paths
42
+
43
+ valid = pkcs7.verify([], store, nil, OpenSSL::PKCS7::NOVERIFY)
44
+ {
45
+ valid: valid,
46
+ signer: extract_signer_info(pkcs7),
47
+ signed_at: extract_signing_time(pkcs7)
48
+ }
49
+ rescue OpenSSL::PKCS7::PKCS7Error => e
50
+ { valid: false, error: e.message }
51
+ end
52
+
53
+ def extract_signer_info(pkcs7)
54
+ cert = pkcs7.certificates&.first
55
+ cert&.subject&.to_s
56
+ end
57
+
58
+ def extract_signing_time(pkcs7)
59
+ signer = pkcs7.signers&.first
60
+ signer&.signed_time
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/isds/error.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Error < StandardError
5
+ attr_reader :code, :original_error
6
+
7
+ def initialize(message = nil, code: nil, original_error: nil)
8
+ @code = code
9
+ @original_error = original_error
10
+ super(message)
11
+ end
12
+ end
13
+
14
+ class ConfigurationError < Error; end
15
+
16
+ # Network and connection errors
17
+ class NetworkError < Error; end
18
+ class TimeoutError < NetworkError; end
19
+ class ConnectionError < NetworkError; end
20
+
21
+ # Authentication errors
22
+ class AuthenticationError < Error; end
23
+ class InvalidCredentialsError < AuthenticationError; end
24
+ class CertificateError < AuthenticationError; end
25
+
26
+ # Business logic errors
27
+ class DataboxNotFoundError < Error; end
28
+ class MessageNotFoundError < Error; end
29
+ class PermissionDeniedError < Error; end
30
+ class InvalidMessageError < Error; end
31
+
32
+ # ISDS service errors
33
+ class ServiceUnavailableError < Error; end
34
+ class TemporaryUnavailableError < Error; end
35
+ class QuotaExceededError < Error; end
36
+
37
+ # File handling errors
38
+ class AttachmentError < Error; end
39
+ class FileSizeExceededError < AttachmentError; end
40
+ class UnsupportedFormatError < AttachmentError; end
41
+
42
+ # Maps ISDS status codes to error classes
43
+ class ErrorMapper
44
+ STATUS_MAP = {
45
+ '0001' => InvalidCredentialsError,
46
+ '0002' => PermissionDeniedError,
47
+ '0003' => DataboxNotFoundError,
48
+ '0004' => MessageNotFoundError,
49
+ '0005' => InvalidMessageError,
50
+ '0006' => QuotaExceededError,
51
+ '9999' => ServiceUnavailableError
52
+ }.freeze
53
+
54
+ def self.map(status_code, status_message)
55
+ error_class = STATUS_MAP.fetch(status_code, Error)
56
+ error_class.new(status_message, code: status_code)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ module LargeMessages
5
+ class Downloader
6
+ attr_reader :connection
7
+
8
+ def initialize(connection:)
9
+ @connection = connection
10
+ end
11
+
12
+ def download(message_id, attachment_id, target_path, &progress_callback)
13
+ info = get_attachment_info(message_id, attachment_id)
14
+ total_chunks = info[:chunks_total].to_i
15
+
16
+ File.open(target_path, 'wb') do |file|
17
+ (1..total_chunks).each do |chunk_num|
18
+ chunk_data = download_chunk(message_id, attachment_id, chunk_num)
19
+ file.write(chunk_data)
20
+
21
+ progress_callback&.call(chunk_num, total_chunks)
22
+ end
23
+ end
24
+
25
+ verify_integrity(target_path, info[:hash]) if info[:hash]
26
+ target_path
27
+ end
28
+
29
+ private
30
+
31
+ def get_attachment_info(message_id, attachment_id)
32
+ response = connection.call(:large_messages, :get_attachment_info, {
33
+ 'isds:dmID' => message_id,
34
+ 'isds:dmFileID' => attachment_id
35
+ })
36
+
37
+ key = response.keys.first
38
+ response[key] || {}
39
+ end
40
+
41
+ def download_chunk(message_id, attachment_id, chunk_number)
42
+ response = connection.call(:large_messages, :download_chunk, {
43
+ 'isds:dmID' => message_id,
44
+ 'isds:dmFileID' => attachment_id,
45
+ 'isds:dmChunkSeq' => chunk_number
46
+ })
47
+
48
+ key = response.keys.first
49
+ encoded = response.dig(key, :dm_encoded_content)
50
+ Base64.decode64(encoded)
51
+ end
52
+
53
+ def verify_integrity(file_path, expected_hash)
54
+ actual_hash = Digest::SHA256.file(file_path).hexdigest
55
+ return if actual_hash == expected_hash
56
+
57
+ raise AttachmentError, "File integrity check failed. Expected: #{expected_hash}, got: #{actual_hash}"
58
+ end
59
+ end
60
+ end
61
+ end