accessgrid 0.2.0 → 0.5.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.
@@ -1,8 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/accessgrid/console.rb
2
4
  module AccessGrid
5
+ # Manages enterprise template and logging operations.
3
6
  class Console
7
+ attr_reader :webhooks, :hid, :credential_profiles
8
+
4
9
  def initialize(client)
5
10
  @client = client
11
+ @webhooks = Webhooks.new(client)
12
+ @hid = HID.new(client)
13
+ @credential_profiles = CredentialProfiles.new(client)
6
14
  end
7
15
 
8
16
  def create_template(params)
@@ -24,15 +32,13 @@ module AccessGrid
24
32
 
25
33
  def get_logs(template_id, params = {})
26
34
  response = @client.make_request(:get, "/v1/console/card-templates/#{template_id}/logs", nil, params)
27
-
35
+
28
36
  # Return full response to match Python's behavior
29
- if response['logs']
30
- response['logs'] = response['logs'].map { |log| Event.new(log) }
31
- end
32
-
37
+ response['logs'] = response['logs'].map { |log| Event.new(log) } if response['logs']
38
+
33
39
  response
34
40
  end
35
-
41
+
36
42
  # Keep event_log for backwards compatibility
37
43
  def event_log(params)
38
44
  template_id = params.delete(:card_template_id)
@@ -40,29 +46,98 @@ module AccessGrid
40
46
  response['logs'] || []
41
47
  end
42
48
 
49
+ def list_pass_template_pairs(params = {})
50
+ response = @client.make_request(:get, '/v1/console/card-template-pairs', nil, params)
51
+
52
+ if response['card_template_pairs']
53
+ response['card_template_pairs'] = response['card_template_pairs'].map { |pair| PassTemplatePair.new(pair) }
54
+ end
55
+
56
+ response
57
+ end
58
+
59
+ def create_pass_template_pair(params)
60
+ response = @client.make_request(:post, '/v1/console/card-template-pairs', params)
61
+ PassTemplatePair.new(response)
62
+ end
63
+
64
+ def list_ledger_items(params = {})
65
+ response = @client.make_request(:get, '/v1/console/ledger-items', nil, params)
66
+
67
+ if response['ledger_items']
68
+ response['ledger_items'] = response['ledger_items'].map { |item| LedgerItem.new(item) }
69
+ end
70
+
71
+ response
72
+ end
73
+
74
+ alias ledger_items list_ledger_items
75
+
76
+ def publish_template(template_id)
77
+ response = @client.make_request(:post, "/v1/console/card-templates/#{template_id}/publish")
78
+ PublishTemplateResponse.new(response)
79
+ end
80
+
81
+ # Reveal the SmartTap private key for a card template, decrypted client-side.
82
+ #
83
+ # The SDK generates a fresh ephemeral P-256 keypair per call, submits the
84
+ # public half, and decrypts the server's response. The returned
85
+ # RevealTemplatePrivateKey carries the plaintext PEM in #private_key;
86
+ # the encrypted envelope is consumed internally and not exposed.
87
+ def reveal_smart_tap(template_id)
88
+ keypair = SmartTapRevealCrypto.generate_keypair
89
+ response = @client.make_request(
90
+ :post,
91
+ "/v1/console/card-templates/#{template_id}/smart-tap/reveal",
92
+ { client_public_key: keypair[:pub_pem] }
93
+ )
94
+
95
+ plaintext = SmartTapRevealCrypto.decrypt_envelope(response['encrypted_private_key'], keypair[:priv])
96
+ RevealTemplatePrivateKey.new(response.merge('private_key' => plaintext))
97
+ end
98
+
99
+ def ios_preflight(card_template_id:, access_pass_ex_id:)
100
+ data = { access_pass_ex_id: access_pass_ex_id }
101
+ response = @client.make_request(:post, "/v1/console/card-templates/#{card_template_id}/ios_preflight", data)
102
+ IosPreflight.new(response)
103
+ end
104
+
105
+ def list_landing_pages
106
+ response = @client.make_request(:get, '/v1/console/landing-pages')
107
+ pages = response.is_a?(Array) ? response : response.fetch('landing_pages', [])
108
+ pages.map { |page| LandingPage.new(page) }
109
+ end
110
+
111
+ def create_landing_page(**params)
112
+ response = @client.make_request(:post, '/v1/console/landing-pages', params)
113
+ LandingPage.new(response)
114
+ end
115
+
116
+ def update_landing_page(landing_page_id:, **params)
117
+ response = @client.make_request(:put, "/v1/console/landing-pages/#{landing_page_id}", params)
118
+ LandingPage.new(response)
119
+ end
120
+
43
121
  private
44
122
 
45
123
  def transform_template_params(params)
46
- design = params.delete(:design) || {}
47
- support_info = params.delete(:support_info) || {}
48
-
49
- params.merge(
50
- background_color: design[:background_color],
51
- label_color: design[:label_color],
52
- label_secondary_color: design[:label_secondary_color],
53
- support_url: support_info[:support_url],
54
- support_phone_number: support_info[:support_phone_number],
55
- support_email: support_info[:support_email],
56
- privacy_policy_url: support_info[:privacy_policy_url],
57
- terms_and_conditions_url: support_info[:terms_and_conditions_url]
58
- )
124
+ design = params.delete(:design)
125
+ support_info = params.delete(:support_info)
126
+
127
+ # Only merge nested keys if they were provided (backward compat)
128
+ params.merge!(design) if design
129
+ params.merge!(support_info) if support_info
130
+
131
+ params
59
132
  end
60
133
  end
61
134
 
135
+ # Represents a card template configuration.
62
136
  class Template
63
- attr_reader :id, :name, :platform, :protocol, :use_case, :created_at,
137
+ attr_reader :id, :name, :platform, :protocol, :use_case, :created_at,
64
138
  :last_published_at, :issued_keys_count, :active_keys_count,
65
- :allowed_device_counts, :support_settings, :terms_settings, :style_settings
139
+ :allowed_device_counts, :support_settings, :terms_settings, :style_settings,
140
+ :metadata
66
141
 
67
142
  def initialize(data)
68
143
  @id = data['id']
@@ -78,19 +153,275 @@ module AccessGrid
78
153
  @support_settings = data['support_settings']
79
154
  @terms_settings = data['terms_settings']
80
155
  @style_settings = data['style_settings']
156
+ @metadata = data['metadata'] || {}
81
157
  end
82
158
  end
83
159
 
160
+ # Represents a template activity log event.
84
161
  class Event
85
162
  attr_reader :type, :timestamp, :user_id, :ip_address, :user_agent, :metadata
86
163
 
87
164
  def initialize(data)
165
+ metadata = data['metadata']
88
166
  @type = data['event']
89
167
  @timestamp = data['created_at']
90
- @user_id = data['metadata']['user_id'] if data['metadata'] && data['metadata']['user_id']
168
+ @user_id = metadata['user_id'] if metadata && metadata['user_id']
91
169
  @ip_address = data['ip_address']
92
170
  @user_agent = data['user_agent']
171
+ @metadata = metadata
172
+ end
173
+ end
174
+
175
+ # Represents a paired iOS and Android template configuration.
176
+ class PassTemplatePair
177
+ attr_reader :id, :name, :created_at, :android_template, :ios_template
178
+
179
+ def initialize(data)
180
+ android_template = data['android_template']
181
+ ios_template = data['ios_template']
182
+ @id = data['id']
183
+ @name = data['name']
184
+ @created_at = data['created_at']
185
+ @android_template = android_template ? TemplateInfo.new(android_template) : nil
186
+ @ios_template = ios_template ? TemplateInfo.new(ios_template) : nil
187
+ end
188
+ end
189
+
190
+ # Minimal template info used within PassTemplatePair.
191
+ class TemplateInfo
192
+ attr_reader :id, :name, :platform
193
+
194
+ def initialize(data)
195
+ @id = data['id']
196
+ @name = data['name']
197
+ @platform = data['platform']
198
+ end
199
+ end
200
+
201
+ # Represents an iOS In-App Provisioning preflight response.
202
+ class IosPreflight
203
+ attr_reader :provisioning_credential_identifier, :sharing_instance_identifier,
204
+ :card_template_identifier, :environment_identifier
205
+
206
+ def initialize(data)
207
+ @provisioning_credential_identifier = data['provisioningCredentialIdentifier']
208
+ @sharing_instance_identifier = data['sharingInstanceIdentifier']
209
+ @card_template_identifier = data['cardTemplateIdentifier']
210
+ @environment_identifier = data['environmentIdentifier']
211
+ end
212
+ end
213
+
214
+ # Result of publishing a card template.
215
+ class PublishTemplateResponse
216
+ attr_reader :id, :status
217
+
218
+ def initialize(data)
219
+ @id = data['id']
220
+ @status = data['status']
221
+ end
222
+ end
223
+
224
+ # Result of revealing a SmartTap private key. #private_key is the plaintext
225
+ # PEM, decrypted client-side by the SDK; the encrypted envelope is consumed
226
+ # internally and not exposed.
227
+ class RevealTemplatePrivateKey
228
+ attr_reader :key_version, :collector_id, :fingerprint, :private_key
229
+
230
+ def initialize(data)
231
+ @key_version = data['key_version']
232
+ @collector_id = data['collector_id']
233
+ @fingerprint = data['fingerprint']
234
+ @private_key = data['private_key']
235
+ end
236
+ end
237
+
238
+ # Represents a billing ledger item.
239
+ class LedgerItem
240
+ attr_reader :created_at, :amount, :id, :kind, :metadata, :access_pass
241
+
242
+ def initialize(data)
243
+ @created_at = data['created_at']
244
+ @amount = data['amount']
245
+ @id = data['id']
246
+ @kind = data['kind']
247
+ @metadata = data['metadata']
248
+ @access_pass = data['access_pass'] ? LedgerItemAccessPass.new(data['access_pass']) : nil
249
+ end
250
+ end
251
+
252
+ # Represents an access pass reference within a ledger item.
253
+ class LedgerItemAccessPass
254
+ attr_reader :id, :full_name, :state, :metadata, :unified_access_pass_ex_id, :pass_template
255
+
256
+ def initialize(data)
257
+ @id = data['id']
258
+ @full_name = data['full_name']
259
+ @state = data['state']
93
260
  @metadata = data['metadata']
261
+ @unified_access_pass_ex_id = data['unified_access_pass_ex_id']
262
+ @pass_template = data['pass_template'] ? LedgerItemPassTemplate.new(data['pass_template']) : nil
263
+ end
264
+ end
265
+
266
+ # Represents a pass template reference within a ledger item's access pass.
267
+ class LedgerItemPassTemplate
268
+ attr_reader :id, :name, :protocol, :platform, :use_case
269
+
270
+ def initialize(data)
271
+ @id = data['id']
272
+ @name = data['name']
273
+ @protocol = data['protocol']
274
+ @platform = data['platform']
275
+ @use_case = data['use_case']
276
+ end
277
+ end
278
+
279
+ # Represents a landing page configuration.
280
+ class LandingPage
281
+ attr_reader :id, :name, :created_at, :kind, :password_protected, :logo_url
282
+
283
+ def initialize(data)
284
+ @id = data['id']
285
+ @name = data['name']
286
+ @created_at = data['created_at']
287
+ @kind = data['kind']
288
+ @password_protected = data['password_protected']
289
+ @logo_url = data['logo_url']
290
+ end
291
+ end
292
+
293
+ # Represents a credential profile configuration.
294
+ class CredentialProfile
295
+ attr_reader :id, :aid, :name, :apple_id, :created_at, :card_storage, :keys, :files
296
+
297
+ def initialize(data)
298
+ @id = data['id']
299
+ @aid = data['aid']
300
+ @name = data['name']
301
+ @apple_id = data['apple_id']
302
+ @created_at = data['created_at']
303
+ @card_storage = data['card_storage']
304
+ @keys = data['keys'] || []
305
+ @files = data['files'] || []
306
+ end
307
+ end
308
+
309
+ # Manages credential profile operations.
310
+ class CredentialProfiles
311
+ def initialize(client)
312
+ @client = client
313
+ end
314
+
315
+ def create(**params)
316
+ response = @client.make_request(:post, '/v1/console/credential-profiles', params)
317
+ CredentialProfile.new(response)
318
+ end
319
+
320
+ def list
321
+ response = @client.make_request(:get, '/v1/console/credential-profiles')
322
+ profiles = response.is_a?(Array) ? response : response.fetch('credential_profiles', [])
323
+ profiles.map { |profile| CredentialProfile.new(profile) }
324
+ end
325
+ end
326
+
327
+ # Manages webhook operations.
328
+ class Webhooks
329
+ def initialize(client)
330
+ @client = client
331
+ end
332
+
333
+ def create(name:, url:, subscribed_events:, auth_method: 'bearer_token')
334
+ data = {
335
+ name: name,
336
+ url: url,
337
+ subscribed_events: subscribed_events,
338
+ auth_method: auth_method
339
+ }
340
+ response = @client.make_request(:post, '/v1/console/webhooks', data)
341
+ Webhook.new(response)
342
+ end
343
+
344
+ def list(**params)
345
+ response = @client.make_request(:get, '/v1/console/webhooks', nil, params)
346
+ (response['webhooks'] || []).map { |wh| Webhook.new(wh) }
347
+ end
348
+
349
+ def delete(webhook_id)
350
+ @client.make_request(:delete, "/v1/console/webhooks/#{webhook_id}")
351
+ end
352
+ end
353
+
354
+ # Represents a webhook configuration.
355
+ class Webhook
356
+ attr_reader :id, :name, :url, :auth_method, :subscribed_events,
357
+ :created_at, :private_key, :client_cert, :cert_expires_at
358
+
359
+ def initialize(data)
360
+ @id = data['id']
361
+ @name = data['name']
362
+ @url = data['url']
363
+ @auth_method = data['auth_method']
364
+ @subscribed_events = data['subscribed_events']
365
+ @created_at = data['created_at']
366
+ @private_key = data['private_key']
367
+ @client_cert = data['client_cert']
368
+ @cert_expires_at = data['cert_expires_at']
369
+ end
370
+ end
371
+
372
+ # Provides access to HID-related services.
373
+ class HID
374
+ attr_reader :orgs
375
+
376
+ def initialize(client)
377
+ @orgs = HIDOrgs.new(client)
378
+ end
379
+ end
380
+
381
+ # Manages HID organization operations.
382
+ class HIDOrgs
383
+ def initialize(client)
384
+ @client = client
385
+ end
386
+
387
+ def create(name:, full_address:, phone:, first_name:, last_name:)
388
+ data = {
389
+ name: name,
390
+ full_address: full_address,
391
+ phone: phone,
392
+ first_name: first_name,
393
+ last_name: last_name
394
+ }
395
+ response = @client.make_request(:post, '/v1/console/hid/orgs', data)
396
+ HidOrg.new(response)
397
+ end
398
+
399
+ def list
400
+ response = @client.make_request(:get, '/v1/console/hid/orgs')
401
+ response.map { |org| HidOrg.new(org) }
402
+ end
403
+
404
+ def activate(email:, password:)
405
+ data = { email: email, password: password }
406
+ response = @client.make_request(:post, '/v1/console/hid/orgs/activate', data)
407
+ HidOrg.new(response)
408
+ end
409
+ end
410
+
411
+ # Represents an HID organization.
412
+ class HidOrg
413
+ attr_reader :id, :name, :slug, :first_name, :last_name, :phone, :full_address, :status, :created_at
414
+
415
+ def initialize(data)
416
+ @id = data['id']
417
+ @name = data['name']
418
+ @slug = data['slug']
419
+ @first_name = data['first_name']
420
+ @last_name = data['last_name']
421
+ @phone = data['phone']
422
+ @full_address = data['full_address']
423
+ @status = data['status']
424
+ @created_at = data['created_at']
94
425
  end
95
426
  end
96
- end
427
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessGrid
4
+ # base error
5
+ class Error < StandardError; end
6
+
7
+ # Raised when API credentials are invalid.
8
+ class AuthenticationError < Error; end
9
+
10
+ # Raised when a requested resource does not exist.
11
+ class ResourceNotFoundError < Error; end
12
+
13
+ # Raised when request parameters fail validation.
14
+ class ValidationError < Error; end
15
+
16
+ # Raised when a SmartTap reveal envelope is missing fields or contains
17
+ # non-base64 / non-PEM data.
18
+ class InvalidEnvelopeError < Error; end
19
+
20
+ # Raised when AES-GCM auth-tag verification fails while decrypting a
21
+ # SmartTap reveal envelope (wrong key, tampered envelope, or wire-format
22
+ # drift between server and SDK).
23
+ class DecryptError < Error; end
24
+
25
+ # additional error classes to match Python version
26
+ class AccessGridError < Error; end
27
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module AccessGrid
6
+ # Builds and configures HTTP requests for the AccessGrid API.
7
+ class Request
8
+ PAYLOAD_SIGNATURE_PARAM = :sig_payload
9
+ HTTP_METHODS = {
10
+ get: Net::HTTP::Get,
11
+ post: Net::HTTP::Post,
12
+ put: Net::HTTP::Put,
13
+ patch: Net::HTTP::Patch,
14
+ delete: Net::HTTP::Delete
15
+ }.freeze
16
+
17
+ attr_reader :account_id, :body, :http_method, :params, :payload, :uri
18
+
19
+ def initialize(attrs)
20
+ # required attributes
21
+ @http_method = attrs.fetch(:http_method)
22
+ @provided_host = attrs.fetch(:host)
23
+ @provided_path = attrs.fetch(:path)
24
+
25
+ # optional attributes
26
+ @account_id = attrs.fetch(:account_id, nil)
27
+ @body = attrs.fetch(:body, nil)
28
+ @params = attrs.fetch(:params, nil) || {}
29
+
30
+ # computed attributes
31
+ initialize_payload
32
+ initialize_uri
33
+ end
34
+
35
+ def net_http_request
36
+ return @net_http_request if defined?(@net_http_request)
37
+
38
+ @net_http_request = generate_net_http_request!.tap do |req|
39
+ req['Content-Type'] = 'application/json'
40
+ req['X-ACCT-ID'] = account_id
41
+ req['User-Agent'] = "accessgrid.rb @ v#{AccessGrid::VERSION}"
42
+
43
+ req.body = body.to_json if body && !get?
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def generate_net_http_request!
50
+ klass = HTTP_METHODS.fetch(http_method) do
51
+ raise ArgumentError, "Unsupported HTTP method: #{http_method}"
52
+ end
53
+ klass.new(uri.request_uri)
54
+ end
55
+
56
+ def initialize_payload
57
+ return @payload if defined?(@payload)
58
+ return @payload = default_payload unless post_without_body_or_get?
59
+
60
+ if resource_id
61
+ @payload = { id: resource_id }.to_json
62
+ @params[PAYLOAD_SIGNATURE_PARAM] = @payload
63
+ else
64
+ @payload = '{}'
65
+ end
66
+
67
+ @payload
68
+ end
69
+
70
+ def initialize_uri
71
+ @uri = URI.parse("#{@provided_host}#{@provided_path}").tap do |parsed_uri|
72
+ parsed_uri.query = URI.encode_www_form(@params) if @params.any?
73
+ end
74
+ end
75
+
76
+ def resource_id
77
+ return @resource_id if defined?(@resource_id)
78
+ return @resource_id = nil unless post_without_body_or_get?
79
+
80
+ parts = @provided_path.strip.split('/')
81
+ return @resource_id = nil unless parts.length >= 2
82
+
83
+ last_part = parts.last
84
+ second_to_last_part = parts[-2]
85
+ @resource_id = %w[suspend resume unlink delete publish].include?(last_part) ? second_to_last_part : last_part
86
+ end
87
+
88
+ def get?
89
+ http_method == :get
90
+ end
91
+
92
+ def delete?
93
+ http_method == :delete
94
+ end
95
+
96
+ def post?
97
+ http_method == :post
98
+ end
99
+
100
+ def empty_body?
101
+ body.nil? || body.empty?
102
+ end
103
+
104
+ def post_without_body_or_get?
105
+ get? || delete? || (post? && empty_body?)
106
+ end
107
+
108
+ def default_payload
109
+ body&.to_json || ''
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module AccessGrid
7
+ # Internal crypto helpers for the SmartTap reveal flow.
8
+ #
9
+ # Driven by Console#reveal_smart_tap; not part of the public SDK surface.
10
+ # Pure stdlib — no new gem deps.
11
+ #
12
+ # @api private
13
+ module SmartTapRevealCrypto
14
+ CURVE = 'prime256v1'
15
+ HKDF_INFO = 'accessgrid-smart-tap-reveal-v1'
16
+ KEY_LEN = 32
17
+
18
+ # Generate a fresh ephemeral P-256 keypair for a reveal call.
19
+ #
20
+ # @return [Hash] `{priv: OpenSSL::PKey::EC, pub_pem: String}`
21
+ def self.generate_keypair
22
+ priv = OpenSSL::PKey::EC.generate(CURVE)
23
+ { priv: priv, pub_pem: priv.public_to_pem }
24
+ end
25
+
26
+ # Decrypt the encrypted_private_key envelope from the reveal endpoint.
27
+ #
28
+ # Performs ECDH(client_priv, server_ephemeral_pub) + HKDF-SHA256 +
29
+ # AES-256-GCM. Must match the server-side encryption parameters exactly.
30
+ #
31
+ # @return [String] the plaintext SmartTap private key PEM.
32
+ # @raise [RuntimeError] on missing/bad envelope or auth-tag verification failure.
33
+ def self.decrypt_envelope(envelope, priv)
34
+ server_pub = parse_ephemeral_public_key(envelope)
35
+ nonce = decode_envelope_bytes(envelope['iv'])
36
+ ciphertext = decode_envelope_bytes(envelope['ciphertext'])
37
+ tag = decode_envelope_bytes(envelope['tag'])
38
+
39
+ aes_key = derive_aes_key(priv, server_pub)
40
+ aes_gcm_decrypt(aes_key, nonce, ciphertext, tag)
41
+ end
42
+
43
+ # @api private
44
+ def self.parse_ephemeral_public_key(envelope)
45
+ pem = envelope['ephemeral_public_key']
46
+ raise InvalidEnvelopeError, 'Invalid ephemeral_public_key in envelope' unless pem.is_a?(String) && !pem.empty?
47
+
48
+ OpenSSL::PKey::EC.new(pem)
49
+ end
50
+
51
+ # @api private
52
+ def self.derive_aes_key(priv, server_pub)
53
+ shared_secret = priv.dh_compute_key(server_pub.public_key)
54
+ OpenSSL::KDF.hkdf(shared_secret, salt: '', info: HKDF_INFO, length: KEY_LEN, hash: 'SHA256')
55
+ end
56
+
57
+ # @api private
58
+ def self.aes_gcm_decrypt(aes_key, nonce, ciphertext, tag)
59
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
60
+ cipher.key = aes_key
61
+ cipher.iv = nonce
62
+ cipher.auth_tag = tag
63
+ cipher.auth_data = ''
64
+ cipher.update(ciphertext) + cipher.final
65
+ rescue OpenSSL::Cipher::CipherError
66
+ raise DecryptError, 'AES-GCM decryption failed (auth tag verification)'
67
+ end
68
+
69
+ # @api private
70
+ def self.decode_envelope_bytes(value)
71
+ raise InvalidEnvelopeError, 'Envelope iv/ciphertext/tag must be base64-encoded' unless value.is_a?(String)
72
+
73
+ Base64.strict_decode64(value)
74
+ rescue ArgumentError
75
+ raise InvalidEnvelopeError, 'Envelope iv/ciphertext/tag must be base64-encoded'
76
+ end
77
+ end
78
+ end
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/accessgrid/version.rb
2
4
  module AccessGrid
3
- VERSION = '0.2.0'
4
- end
5
+ VERSION = '0.5.0'
6
+ end