aspera-cli 4.27.5 → 4.28.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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -60,6 +60,15 @@ module Aspera
60
60
  rescue => e
61
61
  e
62
62
  end
63
+ # Native JSON health check endpoint (no auth required)
64
+ result[:system] =
65
+ begin
66
+ Rest::Client
67
+ .new(base_url: url)
68
+ .read('health_check')
69
+ rescue => e
70
+ e
71
+ end
63
72
  result
64
73
  end
65
74
 
@@ -79,10 +88,12 @@ module Aspera
79
88
  # @param app_url [String] Tested URL
80
89
  # @return [Hash] :preset_value, :test_args
81
90
  def wizard(wizard, app_url)
91
+ username = options.get_option(:username, mandatory: true)
82
92
  return {
93
+ preset_ids: [username],
83
94
  preset_value: {
84
95
  url: app_url,
85
- username: options.get_option(:username, mandatory: true),
96
+ username: username,
86
97
  password: options.get_option(:password, mandatory: true)
87
98
  },
88
99
  test_args: 'files browse /'
@@ -99,10 +110,14 @@ module Aspera
99
110
 
100
111
  # common to users and groups
101
112
  USR_GRP_SETTINGS = %i[transfer_settings app_authorizations share_permissions].freeze
113
+ # transfer_settings supports delete (resets to global inheritance); app_authorizations and share_permissions do not
114
+ TRANSFER_SETTINGS_OPS = %i[show modify delete].freeze
102
115
  # share_permissions is read-only (Rails only exposes index+show)
103
116
  SHARE_PERMISSIONS_OPS = %i[list show].freeze
104
- # group users: Rails only exposes index+show+update+destroy (no create)
117
+ # group users (all): Rails only exposes index+show+update+destroy (no create)
105
118
  GROUP_USERS_OPS = (Operations::ALL - %i[create]).freeze
119
+ # local group users: same operations (no create), via data/local_groups/{id}/local_users
120
+ LOCAL_GROUP_USERS_OPS = GROUP_USERS_OPS
106
121
  # Arguments of group users commands, by operation
107
122
  GROUP_USERS_ARGS = {
108
123
  show: [{name: :user_id, type: :identifier}],
@@ -117,10 +132,25 @@ module Aspera
117
132
  command :files, description: 'Browse and transfer files on Shares', aliases: [:repository],
118
133
  mount: {plugin: Node, instance: :shares_node_plugin, only: Node::COMMANDS_SHARES}
119
134
  command :admin, description: 'Administer Shares', setup: :setup_admin
135
+ command :share, description: 'Shares accessible to current user'
136
+
137
+ commands_under :share do
138
+ command :list, description: 'List shares accessible to the current user',
139
+ action: ->(**) { entity_list(api: basic_auth_api(ADMIN_API_PATH), entity: 'shares', items_key: 'shares') }
140
+ command :event, description: 'Transfer notification events on a share'
141
+ commands_under :event do
142
+ command :create,
143
+ description: 'Notify Shares of a completed transfer (triggers email alerts)',
144
+ arguments: [
145
+ {name: :share_id, type: :identifier, lookup: :lookup_share_id_user},
146
+ {name: :event, type: Hash}
147
+ ]
148
+ end
149
+ end
120
150
 
121
151
  commands_under :admin do
122
152
  commands_under :node do
123
- crud_commands entity: 'data/nodes', api: :@api_shares_admin
153
+ crud_commands entity: 'data/nodes', api: :@api_shares_admin, lookup: %w[name]
124
154
  end
125
155
  commands_under :ldap_config, description: 'LDAP configuration' do
126
156
  crud_commands entity: 'data/ldap_configs', api: :@api_shares_admin
@@ -136,13 +166,30 @@ module Aspera
136
166
  body_component: Schema::Registry::SHARES,
137
167
  operations: %i[show create modify]
138
168
  end
169
+ commands_under :directory do
170
+ command :list, description: 'List configured authentication directories',
171
+ action: ->(**) { entity_list(api: @api_shares_admin, entity: 'directories') }
172
+ command :show,
173
+ description: 'Get details of an authentication directory',
174
+ arguments: [{name: :directory_id, type: :identifier}]
175
+ command :users,
176
+ description: 'Search users in a directory',
177
+ arguments: [{name: :directory_id, type: :identifier}]
178
+ command :groups,
179
+ description: 'Search groups in a directory',
180
+ arguments: [{name: :directory_id, type: :identifier}]
181
+ end
182
+ command :authenticate,
183
+ description: 'Validate credentials and optionally check a permission',
184
+ arguments: [{name: :credentials, type: Hash}]
139
185
  # user / group: sub-commands per location, generated for both entity types
140
186
  %i[user group].each do |entity_type|
141
187
  commands_under entity_type do
142
- command :all, description: "#{entity_type.capitalize}s from all sources"
143
- command :local, description: "Local #{entity_type}s"
144
- command :ldap, description: "LDAP #{entity_type}s"
145
- command :saml, description: "SAML #{entity_type}s"
188
+ command :all, description: "#{entity_type.capitalize}s from all sources"
189
+ command :local, description: "Local #{entity_type}s"
190
+ command :ldap, description: "LDAP #{entity_type}s"
191
+ command :saml, description: "SAML #{entity_type}s"
192
+ command :search, description: "Search #{entity_type}s across all directories"
146
193
 
147
194
  user_fields = %w[id user_id username first_name last_name email] if entity_type.eql?(:user)
148
195
  # all: list/show/delete only (no create, no modify — users/groups/:all has no update route)
@@ -154,23 +201,34 @@ module Aspera
154
201
  operations: Operations::ALL - %i[create modify],
155
202
  lookup: lookup_method_all,
156
203
  display_fields: user_fields && (user_fields + %w[directory_user])
204
+ # share_permissions are read-only: tell where they are granted
205
+ grant_hint = "read-only, grant with: admin share #{entity_type}_permissions"
157
206
  USR_GRP_SETTINGS.each do |setting|
158
- # share_permissions: Rails only exposes index+show (read-only)
159
- setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
207
+ # choose the operations set for this setting
208
+ setting_ops =
209
+ case setting
210
+ when :share_permissions then SHARE_PERMISSIONS_OPS
211
+ when :transfer_settings then TRANSFER_SETTINGS_OPS
212
+ else %i[show modify]
213
+ end
160
214
  command setting,
161
- description: "Manage #{entity_noun(setting, singular: false)} for a #{entity_type}",
215
+ description: setting.eql?(:share_permissions) ? "Permissions of a #{entity_type} on shares (#{grant_hint})" : "Manage #{entity_noun(setting, singular: false)} for a #{entity_type}",
162
216
  arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_all}]
163
217
  commands_under setting do
164
218
  setting_ops.each do |op|
165
- # share_permissions > show needs a permission identifier (list does not), modify needs data
219
+ # share_permissions > show needs a share identifier (list does not), modify needs data
166
220
  op_args =
167
221
  case op
168
- when :show then setting.eql?(:share_permissions) ? [{name: :permission_id, type: :identifier, lookup: :lookup_share_id}] : nil
222
+ when :show then setting.eql?(:share_permissions) ? [{name: :share_id, type: :identifier, lookup: :lookup_share_id}] : nil
169
223
  when :modify then [{name: setting, type: Hash}]
170
224
  end
171
- command op,
172
- description: "#{op.capitalize} #{entity_noun(setting, singular: false)} for a #{entity_type}",
173
- arguments: op_args
225
+ op_description =
226
+ case [setting, op]
227
+ when %i[share_permissions list] then "List permissions of a #{entity_type} on shares (#{grant_hint})"
228
+ when %i[share_permissions show] then "Show permission of a #{entity_type} on a share (#{grant_hint})"
229
+ else "#{op.capitalize} #{entity_noun(setting, singular: false)} for a #{entity_type}"
230
+ end
231
+ command op, description: op_description, arguments: op_args
174
232
  end
175
233
  end
176
234
  end
@@ -197,11 +255,16 @@ module Aspera
197
255
  body_component: Schema::Registry::SHARES
198
256
  if entity_type.eql?(:group)
199
257
  command :users,
200
- description: 'Manage users of a group',
258
+ description: 'Manage users of a local group',
201
259
  arguments: [{name: :group_id, type: :identifier, lookup: :lookup_shares_group_local_id}]
202
260
  commands_under :users do
203
- GROUP_USERS_OPS.each do |op|
204
- command op, description: "#{op.capitalize} users of a group", arguments: GROUP_USERS_ARGS[op]
261
+ LOCAL_GROUP_USERS_OPS.each do |op|
262
+ local_op_args =
263
+ case op
264
+ when :show, :delete then [{name: :user_id, type: :identifier}]
265
+ when :modify then [{name: :user_id, type: :identifier}, {name: :user, type: Hash}]
266
+ end
267
+ command op, description: "#{op.capitalize} users of a local group", arguments: local_op_args
205
268
  end
206
269
  end
207
270
  end
@@ -218,6 +281,24 @@ module Aspera
218
281
  command :import, description: "Import a SAML #{entity_type}",
219
282
  arguments: [{name: entity_type, type: Hash, bulk: true}]
220
283
  end
284
+
285
+ # search: list + show by URN + (groups only) members
286
+ # The cross-directory search endpoints return a collection object {"resource_type":"collection","members":[...]}
287
+ # entity_plural captured at class-load time in the closure
288
+ entity_plural_search = entity_type.eql?(:user) ? 'users' : 'groups'
289
+ commands_under :search do
290
+ command :list,
291
+ description: "Search #{entity_type}s across all directories (use --query={q: '...'} to filter)",
292
+ action: ->(**) { entity_list(api: @api_shares_admin, entity: entity_plural_search, items_key: 'members') }
293
+ command :show,
294
+ description: "Get directory #{entity_type} details by URN",
295
+ arguments: [{name: :"#{entity_type}_urn", type: :identifier}]
296
+ if entity_type.eql?(:group)
297
+ command :members,
298
+ description: 'Search members in a directory group',
299
+ arguments: [{name: :group_urn, type: :identifier}]
300
+ end
301
+ end
221
302
  end
222
303
  end
223
304
  end
@@ -233,7 +314,7 @@ module Aspera
233
314
  lookup_method = :"lookup_shares_#{entity_type}_#{location}_id"
234
315
  define_method(lookup_method) do |field, value, **|
235
316
  path = admin_entity_path(entity_type, location)
236
- Rest::List.lookup_entity_generic(entity: entity_type, field: field, value: value) { @api_shares_admin.read(path) }['id']
317
+ Rest::List.lookup_entity_generic(field: field, value: value, entity: entity_type) { @api_shares_admin.read(path) }['id']
237
318
  end
238
319
  private lookup_method
239
320
  end
@@ -259,10 +340,12 @@ module Aspera
259
340
  {user_permissions: :user, group_permissions: :group}.each do |perm_type, entity_type|
260
341
  commands_under [:admin, :share, perm_type] do
261
342
  crud_commands entity: :"share_#{perm_type}",
262
- api: :@api_shares_admin,
263
- name: entity_noun(perm_type),
264
- id_name: :"#{entity_type}_id",
265
- lookup: :"lookup_shares_#{entity_type}_all_id"
343
+ api: :@api_shares_admin,
344
+ name: entity_noun(perm_type),
345
+ id_name: :"#{entity_type}_id",
346
+ lookup: :"lookup_shares_#{entity_type}_all_id",
347
+ body_component: Schema::Registry::SHARES,
348
+ schema_entity: "data/shares/{share_id}/#{perm_type}"
266
349
  end
267
350
  end
268
351
 
@@ -297,7 +380,14 @@ module Aspera
297
380
 
298
381
  # Lookup a share id by field/value using the admin API.
299
382
  def lookup_share_id(field, value, **)
300
- Rest::List.lookup_entity_generic(entity: 'share', field: field, value: value) { @api_shares_admin.read('data/shares') }['id']
383
+ Rest::List.lookup_entity_generic(field: field, value: value, entity: 'share') { @api_shares_admin.read('data/shares') }['id']
384
+ end
385
+
386
+ # Lookup a share id by field/value using the user-level API (for share event create).
387
+ def lookup_share_id_user(field, value, **)
388
+ data = basic_auth_api(ADMIN_API_PATH).read('shares')
389
+ data = data['shares'] if data.is_a?(Hash)
390
+ Rest::List.lookup_entity_generic(field: field, value: value, entity: 'share') { data }['id']
301
391
  end
302
392
 
303
393
  # --- health ---
@@ -317,6 +407,25 @@ module Aspera
317
407
  else
318
408
  nagios.add_critical('API', health[:api].to_s)
319
409
  end
410
+ if health[:system].is_a?(Hash)
411
+ sys = health[:system]
412
+ # one status item for the license (standard or entitlement)
413
+ if sys['valid_license'] || sys['entitled']
414
+ nagios.add_ok('license', sys['entitled'] ? 'entitled' : 'valid')
415
+ else
416
+ nagios.add_critical('license', 'invalid')
417
+ end
418
+ # one status item per configured node
419
+ (sys['nodes'] || []).each do |node|
420
+ if node['status'].to_s.casecmp('active').zero?
421
+ nagios.add_ok("node #{node['id']}", node['status'])
422
+ else
423
+ nagios.add_critical("node #{node['id']}", node['status'])
424
+ end
425
+ end
426
+ else
427
+ nagios.add_critical('system', health[:system].to_s)
428
+ end
320
429
  Result::ObjectList.new(nagios.status_list)
321
430
  end
322
431
 
@@ -336,10 +445,10 @@ module Aspera
336
445
  # @param setting [Symbol] one of USR_GRP_SETTINGS
337
446
  # @param op [Symbol] CRUD operation
338
447
  # entity_id: resolved via arguments:(:identifier) on the setting node
339
- # permission_id: (share_permissions show) and data: (modify) resolved via arguments: on the leaf
340
- def admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, data: nil)
448
+ # share_id: (share_permissions show) and data: (modify) resolved via arguments: on the leaf
449
+ def admin_entity_setting(entity_type, location, setting, op, entity_id:, share_id: nil, data: nil)
341
450
  entity_path = "#{admin_entity_path(entity_type, location)}/#{entity_id}/#{setting}"
342
- send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: permission_id, is_singleton: !setting.eql?(:share_permissions), data: data)
451
+ send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: share_id, is_singleton: !setting.eql?(:share_permissions), data: data)
343
452
  end
344
453
 
345
454
  # Shared handler for :users (group only)
@@ -353,25 +462,45 @@ module Aspera
353
462
 
354
463
  # Generate action_admin_<user|group>_<location>_<verb> for all combinations
355
464
  %i[user group].each do |entity_type|
356
- # all: list/show/delete only (no create, no modify — no update route on /users and /groups)
465
+ # all: list/show/delete only — nested settings (transfer_settings, app_authorizations, share_permissions) only under :all
357
466
  # local: full CRUD only — no nested settings under local_users/local_groups
358
467
  ENTITY_LOCATIONS.each do |location|
359
468
  unless location.eql?(:local)
360
469
  USR_GRP_SETTINGS.each do |setting|
361
- # share_permissions: Rails only exposes index+show (read-only)
362
- setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
470
+ # choose the operations set for this setting
471
+ setting_ops =
472
+ case setting
473
+ when :share_permissions then SHARE_PERMISSIONS_OPS
474
+ when :transfer_settings then TRANSFER_SETTINGS_OPS
475
+ else %i[show modify]
476
+ end
363
477
  setting_ops.each do |op|
364
478
  define_action_method([:admin, entity_type, location, setting, op]) do |**kwargs|
365
- admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id], data: kwargs[setting])
479
+ admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], share_id: kwargs[:share_id], data: kwargs[setting])
366
480
  end
367
481
  end
368
482
  end
369
483
  end
370
484
  next unless entity_type.eql?(:group)
371
- # group users: no create route (Rails only exposes index+show+update+destroy)
372
- GROUP_USERS_OPS.each do |op|
373
- define_action_method([:admin, entity_type, location, :users, op]) do |group_id:, user_id: nil, user: nil, **|
374
- admin_entity_users(entity_type, location, op, group_id: group_id, user_id: user_id, data: user)
485
+ if location.eql?(:all)
486
+ # all group users: no create route (Rails only exposes index+show+update+destroy)
487
+ GROUP_USERS_OPS.each do |op|
488
+ define_action_method([:admin, entity_type, :all, :users, op]) do |group_id:, user_id: nil, user: nil, **|
489
+ admin_entity_users(entity_type, :all, op, group_id: group_id, user_id: user_id, data: user)
490
+ end
491
+ end
492
+ else
493
+ # local group local_users: list/show/modify/delete (via data/local_groups/{id}/local_users)
494
+ LOCAL_GROUP_USERS_OPS.each do |op|
495
+ define_action_method([:admin, entity_type, :local, :users, op]) do |group_id:, user_id: nil, user: nil, **|
496
+ send(
497
+ :"entity_#{op}",
498
+ api: @api_shares_admin,
499
+ entity: "data/local_groups/#{group_id}/local_users",
500
+ id: user_id,
501
+ data: user
502
+ )
503
+ end
375
504
  end
376
505
  end
377
506
  end
@@ -397,6 +526,47 @@ module Aspera
397
526
  @api_shares_admin.create("#{path}/import", entity_parameters)
398
527
  end
399
528
  end
529
+
530
+ # search: list + show + (groups) members — cross-directory endpoints under /api/v1/users|groups
531
+ # URNs contain ':' and '=' and must be percent-encoded in the URL path segment.
532
+ entity_plural = entity_type.eql?(:user) ? 'users' : 'groups'
533
+ define_action_method(%i[admin].push(entity_type, :search, :show)) do |**kwargs|
534
+ urn = URI.encode_www_form_component(kwargs[:"#{entity_type}_urn"])
535
+ Result::SingleObject.new(@api_shares_admin.read("#{entity_plural}/#{urn}"))
536
+ end
537
+ next unless entity_type.eql?(:group)
538
+ define_action_method(%i[admin group search members]) do |group_urn:, **|
539
+ encoded = URI.encode_www_form_component(group_urn)
540
+ entity_list(api: @api_shares_admin, entity: "groups/#{encoded}/members", items_key: 'members')
541
+ end
542
+ end
543
+
544
+ # admin > directory > show / users / groups
545
+ # directory_id is a URN — must be percent-encoded in the URL path segment.
546
+ define_action_method(%i[admin directory show]) do |directory_id:, **|
547
+ enc = URI.encode_www_form_component(directory_id)
548
+ Result::SingleObject.new(@api_shares_admin.read("directories/#{enc}"))
549
+ end
550
+
551
+ define_action_method(%i[admin directory users]) do |directory_id:, **|
552
+ enc = URI.encode_www_form_component(directory_id)
553
+ entity_list(api: @api_shares_admin, entity: "directories/#{enc}/users", items_key: 'members')
554
+ end
555
+
556
+ define_action_method(%i[admin directory groups]) do |directory_id:, **|
557
+ enc = URI.encode_www_form_component(directory_id)
558
+ entity_list(api: @api_shares_admin, entity: "directories/#{enc}/groups", items_key: 'members')
559
+ end
560
+
561
+ # admin > authenticate
562
+ define_action_method(%i[admin authenticate]) do |credentials:, **|
563
+ Result::SingleObject.new(@api_shares_admin.create('authenticate', credentials))
564
+ end
565
+
566
+ # share > event > create
567
+ define_action_method(%i[share event create]) do |share_id:, event:, **|
568
+ basic_auth_api(ADMIN_API_PATH).create("shares/#{share_id}/events", event)
569
+ Result::Status.new('event created')
400
570
  end
401
571
  end
402
572
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/assert'
4
+ require 'aspera/secret_hider'
5
+ require 'aspera/cli/security'
4
6
  require 'aspera/cli/preset_manager'
5
7
 
6
8
  module Aspera
@@ -13,10 +15,8 @@ module Aspera
13
15
  CONF_OVERVIEW_KEYS = %w[preset parameter value].freeze
14
16
 
15
17
  def action_preset_overview(**)
16
- cp = presets.config_presets
17
- # Display process modifies the value (hide secrets): we do not want to save removed secrets
18
- data = PresetManager.deep_clone(cp)
19
- formatter.hide_secrets(data)
18
+ # Secrets hidden by key name here: the key becomes the value of column `parameter`
19
+ data = formatter.hide_secrets(presets.config_presets)
20
20
  result = []
21
21
  data.each do |config, preset|
22
22
  preset.each do |parameter, value|
@@ -83,7 +83,7 @@ module Aspera
83
83
  incr += 1
84
84
  end
85
85
  Log.log.info { "Securing #{preset_name}.#{option_name} -> vault label: #{vault_label}" }
86
- vault.set({label: vault_label, password: value})
86
+ vault.set({label: vault_label, password: value}) # HashiCorpIgnore
87
87
  preset[option_name] = "@vault:#{vault_label}.password"
88
88
  end
89
89
 
@@ -93,7 +93,7 @@ module Aspera
93
93
  name = preset_name(name)
94
94
  cp = presets.config_presets
95
95
  raise Cli::BadArgument, "no such preset: #{name}" unless cp.key?(name)
96
- Result::SingleObject.new(PresetManager.deep_clone(cp[name]))
96
+ Result::SingleObject.new(cp[name])
97
97
  end
98
98
 
99
99
  def action_preset_delete(name:, **)
@@ -117,7 +117,11 @@ module Aspera
117
117
  value = cp[name][param_name]
118
118
  raise Cli::BadArgument, "no such option in preset #{name}: #{param_name}" if value.nil?
119
119
  case value
120
- when Numeric, String then return Result::Text.new(ExtendedValue.instance.evaluate(value.to_s, context: 'preset'))
120
+ when Numeric, String
121
+ value = ExtendedValue.instance.evaluate(value.to_s, context: 'preset')
122
+ # A secret has no key name in a text result: declared, so that it is hidden
123
+ SecretHider.instance.add_secret_values(value) if SecretHider.instance.secret?(param_name, value)
124
+ return Result::Text.new(value)
121
125
  end
122
126
  Result::SingleObject.new(value)
123
127
  end
@@ -132,6 +136,10 @@ module Aspera
132
136
 
133
137
  def action_preset_set(name:, param_name:, param_value:, **)
134
138
  name = preset_name(name, create: true)
139
+ # Key names are not secrets: converted, or split with dot-notation, a secret would not be hidden in results
140
+ Security.instance.check_secret_processing('preset parameter name', hint: 'give a name that is not a secret') do
141
+ SecretHider.instance.contains_secret?(param_name)
142
+ end
135
143
  param_name = Parser.option_line_to_name(param_name)
136
144
  presets.set_key(name, param_name, Parser.smart_convert(param_value))
137
145
  secure_preset_option(presets.config_presets[name], name, param_name)
@@ -6,6 +6,7 @@ require 'aspera/cli/version'
6
6
  require 'aspera/cli/info'
7
7
  require 'aspera/log'
8
8
  require 'aspera/assert'
9
+ require 'aspera/secret_hider'
9
10
  require 'aspera/yaml'
10
11
  require 'digest'
11
12
  require 'yaml'
@@ -66,7 +67,8 @@ module Aspera
66
67
  if File.exist?(@config_file)
67
68
  Log.log.debug { "loading #{@config_file}" }
68
69
  begin
69
- @config_presets = Yaml.safe_load(File.read(@config_file))
70
+ # Before checksum: the conversion alone does not save the file
71
+ @config_presets = self.class.stringify_secrets(nil, Yaml.safe_load(File.read(@config_file)))
70
72
  rescue Psych::SyntaxError
71
73
  Log.log.error { "YAML error in config file: #{@config_file}" }
72
74
  raise
@@ -91,6 +93,8 @@ module Aspera
91
93
  end
92
94
  raise Cli::Error, e.to_s
93
95
  end
96
+ # Hidden wherever they appear (logs, results), also when copied under another key name
97
+ SecretHider.instance.add_secrets_in(@config_presets)
94
98
  end
95
99
 
96
100
  # Save to disk only if content changed since last load/save.
@@ -152,7 +156,10 @@ module Aspera
152
156
  end
153
157
  current = self.class.deep_clone(current) unless current.is_a?(String)
154
158
  current.delete_if { |k, _| k.to_s.start_with?(Key::META_PREFIX) } if current.is_a?(Hash)
155
- ExtendedValue.instance.evaluate(current, context: 'preset')
159
+ current = ExtendedValue.instance.evaluate(current, context: 'preset')
160
+ # A secret read alone has no key name: declared, so that it is hidden wherever it appears
161
+ SecretHider.instance.add_secret_values(current) if SecretHider.instance.secret?(names.last, current)
162
+ current
156
163
  end
157
164
 
158
165
  # Set a single key in a preset hash (creates the preset if absent).
@@ -161,6 +168,7 @@ module Aspera
161
168
  def set_key(preset, param_name, param_value)
162
169
  Aspera.assert_type(param_name, String, Symbol) { 'parameter' }
163
170
  param_name = param_name.to_s
171
+ param_value = self.class.stringify_secrets(param_name.split(DotContainer::SEPARATOR).last, param_value)
164
172
  selected = @config_presets[preset]
165
173
  if selected.nil?
166
174
  Log.log.debug { "Unknown preset name: #{preset}, initializing" }
@@ -276,6 +284,20 @@ module Aspera
276
284
  end
277
285
  dst
278
286
  end
287
+
288
+ # A secret is a String: a number under a secret key name (e.g. password `12345678`, unquoted in YAML or set on command line)
289
+ # is converted, else it is not identified as a secret, and is shown in results
290
+ # @param key [String, nil] Key name of value
291
+ # @param value [Object] Value: containers are modified in place
292
+ # @return [Object] Value, or its String if it is a secret
293
+ def stringify_secrets(key, value)
294
+ case value
295
+ when Hash then value.each { |k, v| value[k] = stringify_secrets(k, v) }
296
+ when Array then value.map! { |v| stringify_secrets(nil, v) }
297
+ when Numeric then value = value.to_s if SecretHider.instance.secret?(key, value.to_s)
298
+ end
299
+ value
300
+ end
279
301
  end
280
302
 
281
303
  private
@@ -7,13 +7,15 @@ module Aspera
7
7
  module Cli
8
8
  # Console input
9
9
  module Prompt
10
+ extend self
11
+
10
12
  # Prompt user for console input
11
13
  # The prompt is displayed on `stderr`, like for sensitive input, so that `stdout` contains only results.
12
14
  # @param prompt [String] prompt string to display
13
15
  # @param sensitive [Boolean] whether to hide typed input
14
16
  # @return [String] user input stripped of trailing newline
15
17
  def prompt_user_input(prompt, sensitive: false)
16
- return $stdin.getpass("#{prompt}> ") if sensitive
18
+ return $stdin.getpass("#{prompt}> ") if sensitive && $stdin.tty?
17
19
  $stderr.print("#{prompt}> ")
18
20
  line = $stdin.gets
19
21
  Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
@@ -29,6 +29,12 @@ module Aspera
29
29
  @fields = fields
30
30
  end
31
31
 
32
+ # @param data [Object] New data
33
+ # @return [Result] Copy of this result, with other data (e.g. with secrets hidden)
34
+ def with_data(data)
35
+ dup.tap { |r| r.data = data }
36
+ end
37
+
32
38
  # Format this result using the provided formatter
33
39
  # This method implements the Visitor pattern, allowing each Result subclass
34
40
  # to define how it should be formatted without the Formatter needing to know
@@ -81,6 +87,13 @@ module Aspera
81
87
 
82
88
  # Formats rendering the data structure itself
83
89
  STRUCTURED_FORMATS = %i[ruby json jsonpp yaml].freeze
90
+ # Types of a single value result (Text), or of items of a value list
91
+ SCALAR_TYPES = [String, Integer, Float, Symbol, TrueClass, FalseClass].freeze
92
+
93
+ protected
94
+
95
+ # Used by `with_data`
96
+ attr_writer :data
84
97
 
85
98
  private
86
99
 
@@ -174,8 +187,9 @@ module Aspera
174
187
 
175
188
  # Text result
176
189
  class Text < Result
190
+ # @param data [String, Numeric, Symbol, Boolean] The value, hidden if it is a secret declared to `SecretHider`
177
191
  def initialize(data)
178
- Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError) { 'text result data' }
192
+ Aspera.assert_type(data, *SCALAR_TYPES, type: ArgumentError) { 'text result data' }
179
193
  super(data: data)
180
194
  end
181
195
 
@@ -351,12 +365,11 @@ module Aspera
351
365
  all_types = data.map(&:class).uniq
352
366
  return ObjectList.new(data) if all_types.eql?([Hash])
353
367
 
354
- scalar_types = [String, Integer, Symbol]
355
- unsupported_types = all_types - scalar_types
368
+ unsupported_types = all_types - SCALAR_TYPES
356
369
  return ValueList.new(data, name: 'list') if unsupported_types.empty?
357
370
 
358
371
  Aspera.error_unexpected_value(unsupported_types) { 'list item types' }
359
- when String, Integer, Symbol
372
+ when *SCALAR_TYPES
360
373
  Text.new(data)
361
374
  else
362
375
  Aspera.error_unexpected_value(data.class.name) { 'result type' }