fripa 0.1.3 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d954bb6883076c8fbae4d9ef3f56d89fe96e38b2f01c4397f9d2d2a401aa855e
4
- data.tar.gz: 11e1981ba74865e16c8f38fb1c345f3bbf43497d66627952dfbe465d22e198f8
3
+ metadata.gz: 5034dad8f9b68de329b79f4a447167c68f60edbf9310854f587a14b780f1c3f7
4
+ data.tar.gz: 9dc1b84130c58a48f67aeaa103c39893235c28123b38214acde4eefd40509762
5
5
  SHA512:
6
- metadata.gz: 137b34f03e9f99bb1449acc51cef20f351bd5060e813efdac9ac71ff0d569d23aca4a538af358f08e74fc352576ebd4d07343be96bbd74ad6a5bbd918d210948
7
- data.tar.gz: a01de4bc70e5ee242f28c1ed149917744a3d5d0acfe94b3ddcce08516db35b2c467d2a0f16bf0713455666db5bc3276b37a1e2db097d27fee613cb43f6e4e179
6
+ metadata.gz: 81e82e52a5d09927d3a69ecf550db4482a282faa7fee27b41fcea21d45d27c6764bd7aa45550a21f584ba11c6887b9a3e63829d7c199ca541be1757a7423352a
7
+ data.tar.gz: d95654a1a20db0c69f64ba9f007dd3074011b3eb685b561bf2c2cc6d1ebe1fd8056d0e655073bf4ed2de4e772193238081238049a9f7fc77c5439a74293c6dfa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
1
7
  ## [Unreleased]
2
8
 
9
+ ## [0.2.0] - 2026-10-12
10
+
11
+ ### Changed
12
+ - Network errors and timeouts (`Faraday::Error`) now raise `Fripa::ConnectionError`. Responses that are not JSON also raise `Fripa::ConnectionError`.
13
+ - The session cookie keeps only `name=value` pairs. Attributes like `path`, `httponly` and `secure` are no longer sent back.
14
+ - Login and API calls share one HTTP connection.
15
+ - `Fripa::Client#inspect` and `Fripa::Authenticator#inspect` no longer show the password or the session cookie.
16
+ - `scheme` is case-insensitive. An unknown scheme raises `ArgumentError` instead of falling back to HTTP.
17
+
18
+ ### Added
19
+ - `config.raise_on_partial_failure` (default `false`): when `true`, calls that report per-entry failures (e.g. `group_add_member` with an unknown user, or a user that is already a member) raise `Fripa::PartialFailureError` (a `Fripa::ResponseError`). The full `failed` hash is in `#data`.
20
+ - The client logs in again and retries once when the session expires (HTTP 401).
21
+ - `Fripa::Client#batch` sends many commands in one HTTP request.
22
+ - Configuration options: `timeout`, `open_timeout`, `api_version` and `adapter` (e.g. `:net_http_persistent` for keep-alive).
23
+ - `ipa_format:` option for `vault.archive` and `vault.retrieve`, to read and write vaults in the format of the `ipa` CLI.
24
+ - `users.passwd` accepts no current password, so admins can reset the password of another user.
25
+ - RBS signatures in `sig/`.
26
+
27
+ ### Fixed
28
+ - `vault.retrieve` returns `nil` for an empty vault instead of raising `NoMethodError`.
29
+ - The vault resource fetches the KRA transport certificate once per client, not on every archive and retrieve.
30
+
31
+ ## [0.1.4] - 2026-04-16
32
+
33
+ ### Added
34
+ - Vault resource: `add`, `show`, `archive`, `retrieve`, `add_member`, `delete`, with transport encryption (AES-128-CBC session key wrapped with the KRA transport certificate).
35
+
36
+ ## [0.1.3] - 2025-11-26
37
+
38
+ ### Added
39
+ - `Fripa::ResponseError` with `#code`, `#message` and `#data` for errors returned by FreeIPA.
40
+
3
41
  ## [0.1.2] - 2025-11-14
4
42
 
5
43
  ### Fixed
@@ -20,3 +58,10 @@ Initial release of Fripa - A Ruby client for the FreeIPA JSON-RPC API.
20
58
  - Parameter validation for resource methods
21
59
  - Full test suite with VCR cassettes
22
60
  - Complete API documentation and usage examples
61
+
62
+ [Unreleased]: https://github.com/renuo/fripa/compare/v0.2.0...HEAD
63
+ [0.2.0]: https://github.com/renuo/fripa/compare/v0.1.4...v0.2.0
64
+ [0.1.4]: https://github.com/renuo/fripa/compare/v0.1.3...v0.1.4
65
+ [0.1.3]: https://github.com/renuo/fripa/compare/v0.1.2...v0.1.3
66
+ [0.1.2]: https://github.com/renuo/fripa/compare/v0.1.0...v0.1.2
67
+ [0.1.0]: https://github.com/renuo/fripa/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Fripa
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/fripa.svg)](https://rubygems.org/gems/fripa)
4
+ [![CI](https://github.com/renuo/fripa/actions/workflows/main.yml/badge.svg)](https://github.com/renuo/fripa/actions/workflows/main.yml)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
6
+ [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://rubydoc.info/gems/fripa)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.txt)
8
+
3
9
  A Ruby client for the FreeIPA JSON-RPC API.
4
10
 
5
11
  Docs: [FreeIPA JSON-RPC API](https://freeipa.readthedocs.io/en/latest/api/json-rpc.html)
@@ -74,6 +80,23 @@ Fripa.config.scheme = 'https' # optional
74
80
  Fripa.config.verify_ssl = false
75
81
  ```
76
82
 
83
+ ### Advanced options
84
+
85
+ ```ruby
86
+ Fripa.configure do |config|
87
+ config.timeout = 30 # read timeout in seconds (default: Faraday default)
88
+ config.open_timeout = 5 # connect timeout in seconds (default: Faraday default)
89
+ config.api_version = "2.251" # FreeIPA API version sent with each call
90
+ config.adapter = :net_http_persistent # any Faraday adapter (default: Faraday.default_adapter)
91
+ config.raise_on_partial_failure = true # raise Fripa::PartialFailureError (default: false)
92
+ end
93
+ ```
94
+
95
+ The default adapter opens a new TCP/TLS connection for every request. If you
96
+ make many calls with one client, a keep-alive adapter is much faster. Add
97
+ `gem "faraday-net_http_persistent"` to your Gemfile and set
98
+ `config.adapter = :net_http_persistent`.
99
+
77
100
  ### Local development example
78
101
 
79
102
  For local FreeIPA instances running on HTTP:
@@ -143,6 +166,9 @@ client.users.mod("newuser", mail: "newuser@example.com")
143
166
  # Change user password
144
167
  client.users.passwd("newuser", "NewPassword123", "TempPassword123")
145
168
 
169
+ # Reset the password of another user (admin), without the current password
170
+ client.users.passwd("newuser", "NewPassword123")
171
+
146
172
  # Delete a user
147
173
  client.users.delete("newuser")
148
174
  ```
@@ -181,6 +207,43 @@ client.groups.remove_member_manager("developers", user: ["manager"])
181
207
  client.groups.delete("developers")
182
208
  ```
183
209
 
210
+ ##### Vaults
211
+
212
+ ```ruby
213
+ client = Fripa::Client.new(username: 'admin', password: 'secret')
214
+
215
+ client.vault.add("my-vault", shared: true)
216
+ client.vault.archive("my-vault", data: "secret", shared: true)
217
+ client.vault.retrieve("my-vault", shared: true) # => "secret"
218
+ client.vault.add_member("my-vault", user: ["alice"], shared: true)
219
+ client.vault.delete("my-vault", shared: true)
220
+ ```
221
+
222
+ By default, `archive` stores the raw bytes. The `ipa` CLI stores the data in a
223
+ JSON envelope (`{"data": "<base64>"}`). To share a vault with the `ipa` CLI
224
+ (`ipa vault-archive` / `ipa vault-retrieve`), pass `ipa_format: true`:
225
+
226
+ ```ruby
227
+ client.vault.archive("my-vault", data: "secret", ipa_format: true, shared: true)
228
+ client.vault.retrieve("my-vault", ipa_format: true, shared: true) # => "secret"
229
+ ```
230
+
231
+ The client fetches the KRA transport certificate once and keeps it.
232
+
233
+ #### Batch Calls
234
+
235
+ Send many commands in one HTTP request:
236
+
237
+ ```ruby
238
+ result = client.batch(
239
+ ["user_show", ["alice"]],
240
+ ["group_find", [], { sizelimit: 0 }]
241
+ )
242
+ result.dig("result", "results").each { |r| puts r["error"] || r["result"] }
243
+ ```
244
+
245
+ `batch` does not raise for errors in a single command. Check `error` in each result.
246
+
184
247
  #### Using Raw API Calls
185
248
 
186
249
  For API methods without a resource wrapper, use the `call` method directly:
@@ -193,7 +256,31 @@ result = client.call("user_find", ["admin"], { all: true })
193
256
  puts result.dig("result", "count")
194
257
  ```
195
258
 
196
- The client will automatically authenticate if no session exists.
259
+ If the session expires (HTTP 401), the client logs in again and retries the call once.
260
+
261
+ ### Error Handling
262
+
263
+ All errors inherit from `Fripa::Error`:
264
+
265
+ | Error | When |
266
+ |---|---|
267
+ | `Fripa::AuthenticationError` | Login fails |
268
+ | `Fripa::ConnectionError` | Network error, timeout, HTTP error status, or a response that is not JSON |
269
+ | `Fripa::ResponseError` | FreeIPA returns an error (`#code`, `#message`, `#data`) |
270
+ | `Fripa::PartialFailureError` | Only with `config.raise_on_partial_failure = true`. A subclass of `ResponseError`. The call succeeds but some entries fail, e.g. `group_add_member` with an unknown user. `#data` has the full `failed` hash |
271
+
272
+ By default, these per-entry failures do not raise. They stay in the response, in
273
+ `result["result"]["failed"]`. This will change in 1.0.
274
+
275
+ ```ruby
276
+ begin
277
+ client.groups.add_member("developers", user: ["alice"])
278
+ rescue Fripa::PartialFailureError => e
279
+ e.data # => {"member" => {"user" => [["alice", "This entry is already a member"]], "group" => []}}
280
+ rescue Fripa::ResponseError => e
281
+ puts "#{e.code}: #{e.message}"
282
+ end
283
+ ```
197
284
 
198
285
  ## Development
199
286
 
@@ -7,6 +7,9 @@ module Fripa
7
7
  class Authenticator
8
8
  LOGIN_PATH = "/ipa/session/login_password"
9
9
  CONTENT_TYPE = "application/x-www-form-urlencoded"
10
+ # Splits a joined Set-Cookie header ("a=1; path=/, b=2") between cookies,
11
+ # but not inside attributes such as "Expires=Wed, 21 Oct 2026".
12
+ COOKIE_SEPARATOR = /,\s*(?=[^\s;,=]+=)/
10
13
 
11
14
  attr_reader :client
12
15
 
@@ -20,7 +23,11 @@ module Fripa
20
23
 
21
24
  raise AuthenticationError, "Login failed: #{response.status}" unless response.success?
22
25
 
23
- response.headers["set-cookie"]
26
+ session_cookie(response.headers["set-cookie"])
27
+ end
28
+
29
+ def inspect
30
+ "#<#{self.class.name} username=#{client.username.inspect}>"
24
31
  end
25
32
 
26
33
  private
@@ -31,19 +38,26 @@ module Fripa
31
38
  end
32
39
 
33
40
  def perform_login
34
- connection.post(LOGIN_PATH) do |request|
41
+ client.connection.post(LOGIN_PATH) do |request|
35
42
  request.headers["Content-Type"] = CONTENT_TYPE
36
- request.headers["Referer"] = "#{client.config.base_url}/ipa"
37
- request.body = URI.encode_www_form(user: client.username, password: client.password)
43
+ request.headers["Referer"] = client.referer
44
+ request.body = login_body
38
45
  end
46
+ rescue Faraday::Error => e
47
+ raise ConnectionError, "Login failed: #{e.message}"
39
48
  end
40
49
 
41
- def connection
42
- @connection ||= Faraday.new(url: client.config.base_url.to_s) do |f|
43
- f.request :url_encoded
44
- f.adapter Faraday.default_adapter
45
- f.ssl.verify = client.config.verify_ssl
46
- end
50
+ def login_body
51
+ URI.encode_www_form(user: client.username, password: client.password)
52
+ end
53
+
54
+ # Keeps only "name=value" pairs: attributes like path, httponly or secure
55
+ # must not be sent back in the Cookie header. Returns nil without a
56
+ # Set-Cookie header, as before (recorded test cassettes often drop it).
57
+ def session_cookie(header)
58
+ return nil if header.nil?
59
+
60
+ header.split(COOKIE_SEPARATOR).map { |cookie| cookie.split(";").first.to_s.strip }.reject(&:empty?).join("; ")
47
61
  end
48
62
  end
49
63
  end
data/lib/fripa/client.rb CHANGED
@@ -6,7 +6,8 @@ require "json"
6
6
  module Fripa
7
7
  class Client
8
8
  API_PATH = "/ipa/session/json"
9
- API_VERSION = "2.251"
9
+ API_VERSION = Configuration::DEFAULT_API_VERSION
10
+ UNAUTHORIZED = 401
10
11
 
11
12
  attr_reader :config, :username, :password
12
13
  attr_accessor :session_cookie
@@ -26,60 +27,98 @@ module Fripa
26
27
  @groups ||= Resources::Group.new(self)
27
28
  end
28
29
 
30
+ def vault
31
+ @vault ||= Resources::Vault.new(self)
32
+ end
33
+
29
34
  def authenticator
30
35
  @authenticator ||= Authenticator.new(self)
31
36
  end
32
37
 
33
38
  def call(method, args = [], options = {})
34
39
  response = perform_request(method, args, options)
40
+ response = reauthenticate_and_retry(method, args, options) if response.status == UNAUTHORIZED
35
41
 
36
42
  raise ConnectionError, "API call failed: #{response.status}" unless response.success?
37
43
 
38
44
  parse_response(response)
39
45
  end
40
46
 
47
+ # Sends several commands in one HTTP request. Each command is
48
+ # [method, args = [], options = {}]. Per-command errors are not raised:
49
+ # check result["result"]["results"][i]["error"].
50
+ def batch(*commands)
51
+ requests = commands.map do |method, args = [], options = {}|
52
+ { method: method, params: [args, options.merge(version: config.api_version)] }
53
+ end
54
+ call("batch", requests)
55
+ end
56
+
57
+ # Shared by Authenticator, so login and API calls reuse one connection.
58
+ def connection
59
+ @connection ||= Faraday.new(url: config.base_url.to_s, request: request_options) do |f|
60
+ f.adapter(*Array(config.adapter || Faraday.default_adapter))
61
+ f.ssl.verify = config.verify_ssl
62
+ end
63
+ end
64
+
65
+ def referer
66
+ @referer ||= "#{config.base_url}/ipa"
67
+ end
68
+
69
+ def inspect
70
+ "#<#{self.class.name} username=#{username.inspect} host=#{config.host.inspect}>"
71
+ end
72
+
41
73
  private
42
74
 
75
+ def reauthenticate_and_retry(method, args, options)
76
+ self.session_cookie = authenticator.login!
77
+ perform_request(method, args, options)
78
+ end
79
+
43
80
  def perform_request(method, args, options)
44
81
  connection.post(API_PATH) do |request|
45
82
  request.headers["Content-Type"] = "application/json"
46
- request.headers["Referer"] = "#{config.base_url}/ipa"
83
+ request.headers["Referer"] = referer
47
84
  request.headers["Cookie"] = session_cookie
48
85
  request.body = build_request_body(method, args, options).to_json
49
86
  end
87
+ rescue Faraday::Error => e
88
+ raise ConnectionError, "API call failed: #{e.message}"
50
89
  end
51
90
 
52
91
  def build_request_body(method, args, options)
53
92
  {
54
93
  method: method,
55
- params: [args, options.merge(version: API_VERSION)],
94
+ params: [args, options.merge(version: config.api_version)],
56
95
  id: 0
57
96
  }
58
97
  end
59
98
 
60
99
  def parse_response(response)
61
100
  parsed = JSON.parse(response.body)
101
+ raise ConnectionError, "API call failed: unexpected response" unless parsed.is_a?(Hash)
102
+
62
103
  raise_if_error(parsed)
63
104
  parsed
105
+ rescue JSON::ParserError
106
+ raise ConnectionError, "API call failed: invalid JSON response"
64
107
  end
65
108
 
66
109
  def raise_if_error(parsed)
67
- return unless parsed["error"]
110
+ if (error = parsed["error"])
111
+ raise ResponseError.new(code: error["code"], message: error["message"] || error["name"], data: error["data"])
112
+ end
113
+
114
+ return unless config.raise_on_partial_failure
68
115
 
69
- error = parsed["error"]
70
- raise ResponseError.new(
71
- code: error["code"],
72
- message: error["message"] || error["name"],
73
- data: error["data"]
74
- )
116
+ failed = parsed.dig("result", "failed")
117
+ raise PartialFailureError, failed if PartialFailureError.failures(failed).any?
75
118
  end
76
119
 
77
- def connection
78
- @connection ||= Faraday.new(url: config.base_url.to_s) do |f|
79
- f.request :url_encoded
80
- f.adapter Faraday.default_adapter
81
- f.ssl.verify = config.verify_ssl
82
- end
120
+ def request_options
121
+ { timeout: config.timeout, open_timeout: config.open_timeout }.compact
83
122
  end
84
123
  end
85
124
  end
@@ -6,13 +6,23 @@ require "uri"
6
6
  module Fripa
7
7
  # Configuration for Fripa
8
8
  class Configuration
9
- attr_accessor :host, :port, :scheme, :verify_ssl
9
+ DEFAULT_API_VERSION = "2.251"
10
+ SCHEMES = %w[http https].freeze
10
11
 
11
- def initialize(host: nil, port: nil, scheme: "https", verify_ssl: true)
12
+ attr_accessor :host, :port, :scheme, :verify_ssl, :api_version, :timeout, :open_timeout, :adapter,
13
+ :raise_on_partial_failure
14
+
15
+ def initialize(host: nil, port: nil, scheme: "https", verify_ssl: true, api_version: DEFAULT_API_VERSION,
16
+ timeout: nil, open_timeout: nil, adapter: nil, raise_on_partial_failure: false)
12
17
  @host = host
13
18
  @port = port
14
19
  @scheme = scheme
15
20
  @verify_ssl = verify_ssl
21
+ @api_version = api_version
22
+ @timeout = timeout
23
+ @open_timeout = open_timeout
24
+ @adapter = adapter
25
+ @raise_on_partial_failure = raise_on_partial_failure
16
26
  end
17
27
 
18
28
  def base_url
@@ -30,9 +40,12 @@ module Fripa
30
40
  private
31
41
 
32
42
  def build_uri(path: nil)
33
- raise ArgumentError, "host is required" if host.nil? || host.empty?
43
+ raise ArgumentError, "host is required" if host.to_s.empty?
44
+
45
+ normalized_scheme = scheme.to_s.downcase
46
+ raise ArgumentError, "scheme must be one of: #{SCHEMES.join(", ")}" unless SCHEMES.include?(normalized_scheme)
34
47
 
35
- uri_class = scheme == "https" ? URI::HTTPS : URI::HTTP
48
+ uri_class = normalized_scheme == "https" ? URI::HTTPS : URI::HTTP
36
49
  uri_class.build(host: host, port: port, path: path)
37
50
  end
38
51
  end
data/lib/fripa/errors.rb CHANGED
@@ -14,4 +14,27 @@ module Fripa
14
14
  super(message)
15
15
  end
16
16
  end
17
+
18
+ # Raised when FreeIPA answers without an error but reports per-entry failures,
19
+ # e.g. group_add_member with a user that does not exist or is already a member.
20
+ # Only raised with config.raise_on_partial_failure = true.
21
+ # #data holds the full "failed" hash from the response.
22
+ class PartialFailureError < ResponseError
23
+ def initialize(failed)
24
+ super(code: nil, message: self.class.first_reason(failed), data: failed)
25
+ end
26
+
27
+ def self.failures(failed)
28
+ return [] unless failed.is_a?(Hash)
29
+
30
+ failed.values.flat_map do |types|
31
+ types.is_a?(Hash) ? types.values.flat_map { |entries| Array(entries) } : Array(types)
32
+ end
33
+ end
34
+
35
+ def self.first_reason(failed)
36
+ entry = failures(failed).first
37
+ entry.is_a?(Array) ? entry.last.to_s : entry.to_s
38
+ end
39
+ end
17
40
  end
@@ -32,11 +32,13 @@ module Fripa
32
32
  call("user_del", [uid])
33
33
  end
34
34
 
35
- def passwd(uid, new_password, current_password, **options)
35
+ # current_password is only needed when users change their own password.
36
+ # Admins can omit it to reset the password of another user.
37
+ def passwd(uid, new_password, current_password = nil, **options)
36
38
  validate_identifier!(uid, "uid")
37
39
  validate_password!(new_password, "new_password")
38
- validate_password!(current_password, "current_password")
39
- call("passwd", [uid, new_password, current_password], options)
40
+ validate_password!(current_password, "current_password") unless current_password.nil?
41
+ call("passwd", [uid, new_password, current_password].compact, options)
40
42
  end
41
43
 
42
44
  private
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Fripa
6
+ module Resources
7
+ class Vault < Base
8
+ def show(cn, **scope)
9
+ validate_identifier!(cn, "cn")
10
+ call("vault_show", [cn], scope)
11
+ end
12
+
13
+ def add(cn, **scope)
14
+ validate_identifier!(cn, "cn")
15
+ call("vault_add_internal", [cn], { ipavaulttype: "standard" }.merge(scope))
16
+ end
17
+
18
+ # ipa_format: true stores the data like `ipa vault-archive`, so the ipa CLI can read it.
19
+ # The default (false) stores the raw bytes, as in previous versions.
20
+ def archive(cn, data:, ipa_format: false, **scope)
21
+ validate_identifier!(cn, "cn")
22
+ data = VaultTransport.to_ipa_format(data) if ipa_format
23
+ payload = VaultTransport.wrap(data, transport_cert_der)
24
+ call("vault_archive_internal", [cn],
25
+ binary_encode(payload).merge(wrapping_algo: VaultTransport::WRAPPING_ALGO).merge(scope))
26
+ end
27
+
28
+ # ipa_format: true reads data stored by `ipa vault-archive` (or archive with ipa_format: true).
29
+ # Returns nil if the vault has no data.
30
+ def retrieve(cn, ipa_format: false, **scope)
31
+ validate_identifier!(cn, "cn")
32
+ session_key = VaultTransport.generate_session_key
33
+ result = call_retrieve(cn, session_key, **scope).dig("result", "result")
34
+ return nil unless result&.key?("vault_data")
35
+
36
+ data = VaultTransport.decrypt(
37
+ vault_data: extract_value(result["vault_data"]),
38
+ nonce: extract_value(result["nonce"]),
39
+ session_key: session_key
40
+ )
41
+ ipa_format ? VaultTransport.from_ipa_format(data) : data
42
+ end
43
+
44
+ def add_member(cn, **scope)
45
+ validate_identifier!(cn, "cn")
46
+ call("vault_add_member", [cn], scope)
47
+ end
48
+
49
+ def delete(cn, **scope)
50
+ validate_identifier!(cn, "cn")
51
+ call("vault_del", [cn], scope)
52
+ end
53
+
54
+ private
55
+
56
+ # The KRA transport certificate does not change between calls, so we
57
+ # fetch it once per client (the ipa CLI caches it too).
58
+ def transport_cert_der
59
+ @transport_cert_der ||= fetch_transport_cert
60
+ end
61
+
62
+ def fetch_transport_cert
63
+ response = call("vaultconfig_show", [], { all: true })
64
+ Base64.decode64(extract_value(response.dig("result", "result", "transport_cert")))
65
+ end
66
+
67
+ def call_retrieve(cn, session_key, **scope)
68
+ encrypted_key = VaultTransport.encrypt_session_key(session_key, transport_cert_der)
69
+
70
+ call("vault_retrieve_internal", [cn], {
71
+ session_key: { "__base64__" => Base64.strict_encode64(encrypted_key) },
72
+ wrapping_algo: VaultTransport::WRAPPING_ALGO
73
+ }.merge(scope))
74
+ end
75
+
76
+ def binary_encode(hash)
77
+ hash.transform_values { |v| { "__base64__" => v } }
78
+ end
79
+
80
+ def extract_value(value)
81
+ value.is_a?(Hash) ? value["__base64__"] : value
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module Fripa
8
+ module VaultTransport
9
+ CIPHER = "AES-128-CBC"
10
+ WRAPPING_ALGO = "aes-128-cbc"
11
+ KEY_SIZE = 16
12
+ IV_SIZE = 16
13
+ RSA_KEY_SIZE = 2048
14
+ RSA_PADDING = OpenSSL::PKey::RSA::PKCS1_PADDING
15
+
16
+ def self.generate_session_key
17
+ OpenSSL::Random.random_bytes(KEY_SIZE)
18
+ end
19
+
20
+ def self.decrypt(vault_data:, nonce:, session_key:)
21
+ decipher = OpenSSL::Cipher.new(CIPHER)
22
+ decipher.decrypt
23
+ decipher.key = session_key
24
+ decipher.iv = Base64.decode64(nonce)
25
+ decipher.update(Base64.decode64(vault_data)) + decipher.final
26
+ end
27
+
28
+ def self.wrap(data, transport_cert_der)
29
+ session_key = generate_session_key
30
+ nonce = OpenSSL::Random.random_bytes(IV_SIZE)
31
+ vault_data = encrypt_data(data, session_key, nonce)
32
+ encrypted_session_key = encrypt_session_key(session_key, transport_cert_der)
33
+
34
+ {
35
+ vault_data: Base64.strict_encode64(vault_data),
36
+ session_key: Base64.strict_encode64(encrypted_session_key),
37
+ nonce: Base64.strict_encode64(nonce)
38
+ }
39
+ end
40
+
41
+ def self.encrypt_data(data, key, nonce)
42
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
43
+ cipher.key = key
44
+ cipher.iv = nonce
45
+ cipher.update(data.b) + cipher.final
46
+ end
47
+
48
+ def self.encrypt_session_key(session_key, cert_der)
49
+ cert = OpenSSL::X509::Certificate.new(cert_der)
50
+ cert.public_key.public_encrypt(session_key, RSA_PADDING)
51
+ end
52
+
53
+ def self.unwrap(vault_data:, session_key:, nonce:, private_key:)
54
+ decrypted_session_key = private_key.private_decrypt(Base64.decode64(session_key), RSA_PADDING)
55
+ decrypt(vault_data: vault_data, nonce: nonce, session_key: decrypted_session_key)
56
+ end
57
+
58
+ # The ipa CLI (ipa vault-archive / vault-retrieve) stores {"data": base64(data)},
59
+ # not the raw bytes. Use these to read or write vaults shared with the CLI.
60
+ def self.to_ipa_format(data)
61
+ JSON.generate("data" => Base64.strict_encode64(data))
62
+ end
63
+
64
+ def self.from_ipa_format(json)
65
+ Base64.strict_decode64(JSON.parse(json).fetch("data"))
66
+ end
67
+
68
+ def self.generate_transport_key
69
+ OpenSSL::PKey::RSA.generate(RSA_KEY_SIZE)
70
+ end
71
+ end
72
+ end
data/lib/fripa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fripa
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/fripa.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "fripa/authenticator"
7
7
  require_relative "fripa/resources/base"
8
8
  require_relative "fripa/resources/user"
9
9
  require_relative "fripa/resources/group"
10
+ require_relative "fripa/vault_transport"
11
+ require_relative "fripa/resources/vault"
10
12
  require_relative "fripa/client"
11
13
 
12
14
  module Fripa
data/sig/fripa.rbs CHANGED
@@ -1,4 +1,148 @@
1
1
  module Fripa
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ type json_hash = Hash[String, untyped]
5
+
6
+ def self.config: () -> Configuration
7
+ def self.config=: (Configuration | Hash[Symbol, untyped] value) -> Configuration
8
+ def self.configure: () { (Configuration) -> void } -> void
9
+
10
+ class Configuration
11
+ DEFAULT_API_VERSION: String
12
+ SCHEMES: Array[String]
13
+
14
+ attr_accessor host: String?
15
+ attr_accessor port: Integer?
16
+ attr_accessor scheme: String
17
+ attr_accessor verify_ssl: bool
18
+ attr_accessor api_version: String
19
+ attr_accessor timeout: Numeric?
20
+ attr_accessor open_timeout: Numeric?
21
+ attr_accessor adapter: untyped
22
+ attr_accessor raise_on_partial_failure: bool
23
+
24
+ def initialize: (?host: String?, ?port: Integer?, ?scheme: String, ?verify_ssl: bool, ?api_version: String,
25
+ ?timeout: Numeric?, ?open_timeout: Numeric?, ?adapter: untyped,
26
+ ?raise_on_partial_failure: bool) -> void
27
+ def base_url: () -> URI::Generic
28
+ def login_url: () -> URI::Generic
29
+ def api_url: () -> URI::Generic
30
+
31
+ private
32
+
33
+ def build_uri: (?path: String?) -> URI::Generic
34
+ end
35
+
36
+ class Error < StandardError
37
+ end
38
+
39
+ class AuthenticationError < Error
40
+ end
41
+
42
+ class ConnectionError < Error
43
+ end
44
+
45
+ class ResponseError < Error
46
+ attr_reader code: Integer?
47
+ attr_reader data: untyped
48
+
49
+ def initialize: (code: Integer?, message: String?, ?data: untyped) -> void
50
+ end
51
+
52
+ class PartialFailureError < ResponseError
53
+ def initialize: (json_hash failed) -> void
54
+ def self.failures: (untyped failed) -> Array[untyped]
55
+ def self.first_reason: (untyped failed) -> String
56
+ end
57
+
58
+ class Client
59
+ API_PATH: String
60
+ API_VERSION: String
61
+ UNAUTHORIZED: Integer
62
+
63
+ attr_reader config: Configuration
64
+ attr_reader username: String
65
+ attr_reader password: String
66
+ attr_accessor session_cookie: String?
67
+
68
+ def initialize: (username: String, password: String, ?config: Configuration?) -> void
69
+ def users: () -> Resources::User
70
+ def groups: () -> Resources::Group
71
+ def vault: () -> Resources::Vault
72
+ def authenticator: () -> Authenticator
73
+ def call: (String method, ?Array[untyped] args, ?Hash[Symbol, untyped] options) -> json_hash
74
+ def batch: (*Array[untyped] commands) -> json_hash
75
+ def connection: () -> untyped
76
+ def referer: () -> String
77
+ end
78
+
79
+ class Authenticator
80
+ LOGIN_PATH: String
81
+ CONTENT_TYPE: String
82
+ COOKIE_SEPARATOR: Regexp
83
+
84
+ attr_reader client: Client
85
+
86
+ def initialize: (Client client) -> void
87
+ def login!: () -> String?
88
+ end
89
+
90
+ module VaultTransport
91
+ CIPHER: String
92
+ WRAPPING_ALGO: String
93
+ KEY_SIZE: Integer
94
+ IV_SIZE: Integer
95
+ RSA_KEY_SIZE: Integer
96
+ RSA_PADDING: Integer
97
+
98
+ def self.generate_session_key: () -> String
99
+ def self.decrypt: (vault_data: String, nonce: String, session_key: String) -> String
100
+ def self.wrap: (String data, String transport_cert_der) -> { vault_data: String, session_key: String, nonce: String }
101
+ def self.encrypt_data: (String data, String key, String nonce) -> String
102
+ def self.encrypt_session_key: (String session_key, String cert_der) -> String
103
+ def self.unwrap: (vault_data: String, session_key: String, nonce: String, private_key: OpenSSL::PKey::RSA) -> String
104
+ def self.to_ipa_format: (String data) -> String
105
+ def self.from_ipa_format: (String json) -> String
106
+ def self.generate_transport_key: () -> OpenSSL::PKey::RSA
107
+ end
108
+
109
+ module Resources
110
+ class Base
111
+ attr_reader client: Client
112
+
113
+ def initialize: (Client client) -> void
114
+ end
115
+
116
+ class User < Base
117
+ REQUIRED_ADD_FIELDS: Array[Symbol]
118
+
119
+ def find: (?String? uid, **untyped options) -> json_hash
120
+ def show: (String uid) -> json_hash
121
+ def add: (String uid, **untyped attributes) -> json_hash
122
+ def mod: (String uid, **untyped attributes) -> json_hash
123
+ def delete: (String uid) -> json_hash
124
+ def passwd: (String uid, String new_password, ?String? current_password, **untyped options) -> json_hash
125
+ end
126
+
127
+ class Group < Base
128
+ def find: (?String? cn, **untyped options) -> json_hash
129
+ def show: (String cn) -> json_hash
130
+ def add: (String cn, **untyped attributes) -> json_hash
131
+ def mod: (String cn, **untyped attributes) -> json_hash
132
+ def delete: (String cn) -> json_hash
133
+ def add_member: (String cn, **untyped members) -> json_hash
134
+ def remove_member: (String cn, **untyped members) -> json_hash
135
+ def add_member_manager: (String cn, **untyped managers) -> json_hash
136
+ def remove_member_manager: (String cn, **untyped managers) -> json_hash
137
+ end
138
+
139
+ class Vault < Base
140
+ def show: (String cn, **untyped scope) -> json_hash
141
+ def add: (String cn, **untyped scope) -> json_hash
142
+ def archive: (String cn, data: String, ?ipa_format: bool, **untyped scope) -> json_hash
143
+ def retrieve: (String cn, ?ipa_format: bool, **untyped scope) -> String?
144
+ def add_member: (String cn, **untyped scope) -> json_hash
145
+ def delete: (String cn, **untyped scope) -> json_hash
146
+ end
147
+ end
4
148
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fripa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,20 +37,17 @@ dependencies:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
39
  version: '2.0'
26
- description: A Ruby client library for interacting with FreeIPA's JSON-RPC API
40
+ description: 'A Ruby client library for FreeIPA''s JSON-RPC API: users, groups, vaults
41
+ and batch calls.'
27
42
  email:
28
43
  - dev.bcostanzo@gmail.com
29
44
  executables: []
30
45
  extensions: []
31
46
  extra_rdoc_files: []
32
47
  files:
33
- - ".simplecov"
34
48
  - CHANGELOG.md
35
- - CODE_OF_CONDUCT.md
36
- - CONTRIBUTING.md
37
49
  - LICENSE.txt
38
50
  - README.md
39
- - Rakefile
40
51
  - lib/fripa.rb
41
52
  - lib/fripa/authenticator.rb
42
53
  - lib/fripa/client.rb
@@ -45,15 +56,19 @@ files:
45
56
  - lib/fripa/resources/base.rb
46
57
  - lib/fripa/resources/group.rb
47
58
  - lib/fripa/resources/user.rb
59
+ - lib/fripa/resources/vault.rb
60
+ - lib/fripa/vault_transport.rb
48
61
  - lib/fripa/version.rb
49
62
  - sig/fripa.rbs
50
63
  homepage: https://github.com/renuo/fripa
51
64
  licenses:
52
65
  - MIT
53
66
  metadata:
54
- homepage_uri: https://github.com/renuo/fripa
55
67
  source_code_uri: https://github.com/renuo/fripa
56
68
  changelog_uri: https://github.com/renuo/fripa/blob/main/CHANGELOG.md
69
+ bug_tracker_uri: https://github.com/renuo/fripa/issues
70
+ documentation_uri: https://rubydoc.info/gems/fripa
71
+ rubygems_mfa_required: 'true'
57
72
  rdoc_options: []
58
73
  require_paths:
59
74
  - lib
@@ -68,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
83
  - !ruby/object:Gem::Version
69
84
  version: '0'
70
85
  requirements: []
71
- rubygems_version: 3.7.2
86
+ rubygems_version: 4.0.10
72
87
  specification_version: 4
73
88
  summary: Ruby client for FreeIPA JSON-RPC API
74
89
  test_files: []
data/.simplecov DELETED
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- SimpleCov.start do
4
- add_filter "/test/"
5
- enable_coverage :branch
6
- primary_coverage :branch
7
- end
data/CODE_OF_CONDUCT.md DELETED
@@ -1,132 +0,0 @@
1
- # Contributor Covenant Code of Conduct
2
-
3
- ## Our Pledge
4
-
5
- We as members, contributors, and leaders pledge to make participation in our
6
- community a harassment-free experience for everyone, regardless of age, body
7
- size, visible or invisible disability, ethnicity, sex characteristics, gender
8
- identity and expression, level of experience, education, socio-economic status,
9
- nationality, personal appearance, race, caste, color, religion, or sexual
10
- identity and orientation.
11
-
12
- We pledge to act and interact in ways that contribute to an open, welcoming,
13
- diverse, inclusive, and healthy community.
14
-
15
- ## Our Standards
16
-
17
- Examples of behavior that contributes to a positive environment for our
18
- community include:
19
-
20
- * Demonstrating empathy and kindness toward other people
21
- * Being respectful of differing opinions, viewpoints, and experiences
22
- * Giving and gracefully accepting constructive feedback
23
- * Accepting responsibility and apologizing to those affected by our mistakes,
24
- and learning from the experience
25
- * Focusing on what is best not just for us as individuals, but for the overall
26
- community
27
-
28
- Examples of unacceptable behavior include:
29
-
30
- * The use of sexualized language or imagery, and sexual attention or advances of
31
- any kind
32
- * Trolling, insulting or derogatory comments, and personal or political attacks
33
- * Public or private harassment
34
- * Publishing others' private information, such as a physical or email address,
35
- without their explicit permission
36
- * Other conduct which could reasonably be considered inappropriate in a
37
- professional setting
38
-
39
- ## Enforcement Responsibilities
40
-
41
- Community leaders are responsible for clarifying and enforcing our standards of
42
- acceptable behavior and will take appropriate and fair corrective action in
43
- response to any behavior that they deem inappropriate, threatening, offensive,
44
- or harmful.
45
-
46
- Community leaders have the right and responsibility to remove, edit, or reject
47
- comments, commits, code, wiki edits, issues, and other contributions that are
48
- not aligned to this Code of Conduct, and will communicate reasons for moderation
49
- decisions when appropriate.
50
-
51
- ## Scope
52
-
53
- This Code of Conduct applies within all community spaces, and also applies when
54
- an individual is officially representing the community in public spaces.
55
- Examples of representing our community include using an official email address,
56
- posting via an official social media account, or acting as an appointed
57
- representative at an online or offline event.
58
-
59
- ## Enforcement
60
-
61
- Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
- reported to the community leaders responsible for enforcement at
63
- [INSERT CONTACT METHOD].
64
- All complaints will be reviewed and investigated promptly and fairly.
65
-
66
- All community leaders are obligated to respect the privacy and security of the
67
- reporter of any incident.
68
-
69
- ## Enforcement Guidelines
70
-
71
- Community leaders will follow these Community Impact Guidelines in determining
72
- the consequences for any action they deem in violation of this Code of Conduct:
73
-
74
- ### 1. Correction
75
-
76
- **Community Impact**: Use of inappropriate language or other behavior deemed
77
- unprofessional or unwelcome in the community.
78
-
79
- **Consequence**: A private, written warning from community leaders, providing
80
- clarity around the nature of the violation and an explanation of why the
81
- behavior was inappropriate. A public apology may be requested.
82
-
83
- ### 2. Warning
84
-
85
- **Community Impact**: A violation through a single incident or series of
86
- actions.
87
-
88
- **Consequence**: A warning with consequences for continued behavior. No
89
- interaction with the people involved, including unsolicited interaction with
90
- those enforcing the Code of Conduct, for a specified period of time. This
91
- includes avoiding interactions in community spaces as well as external channels
92
- like social media. Violating these terms may lead to a temporary or permanent
93
- ban.
94
-
95
- ### 3. Temporary Ban
96
-
97
- **Community Impact**: A serious violation of community standards, including
98
- sustained inappropriate behavior.
99
-
100
- **Consequence**: A temporary ban from any sort of interaction or public
101
- communication with the community for a specified period of time. No public or
102
- private interaction with the people involved, including unsolicited interaction
103
- with those enforcing the Code of Conduct, is allowed during this period.
104
- Violating these terms may lead to a permanent ban.
105
-
106
- ### 4. Permanent Ban
107
-
108
- **Community Impact**: Demonstrating a pattern of violation of community
109
- standards, including sustained inappropriate behavior, harassment of an
110
- individual, or aggression toward or disparagement of classes of individuals.
111
-
112
- **Consequence**: A permanent ban from any sort of public interaction within the
113
- community.
114
-
115
- ## Attribution
116
-
117
- This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
- version 2.1, available at
119
- [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
-
121
- Community Impact Guidelines were inspired by
122
- [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
-
124
- For answers to common questions about this code of conduct, see the FAQ at
125
- [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
- [https://www.contributor-covenant.org/translations][translations].
127
-
128
- [homepage]: https://www.contributor-covenant.org
129
- [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
- [Mozilla CoC]: https://github.com/mozilla/diversity
131
- [FAQ]: https://www.contributor-covenant.org/faq
132
- [translations]: https://www.contributor-covenant.org/translations
data/CONTRIBUTING.md DELETED
@@ -1,96 +0,0 @@
1
- # Contributing to Fripa
2
-
3
- Thank you for your interest in contributing to Fripa! This document provides guidelines and instructions for contributing.
4
-
5
- ## How to Contribute
6
-
7
- ### Reporting Bugs
8
-
9
- If you find a bug, please open an issue on GitHub with:
10
- - A clear, descriptive title
11
- - Steps to reproduce the issue
12
- - Expected behavior vs. actual behavior
13
- - Your Ruby version and FreeIPA version
14
- - Any relevant code samples or error messages
15
-
16
- ### Suggesting Features
17
-
18
- Feature suggestions are welcome! Please open an issue with:
19
- - A clear description of the feature
20
- - Use cases explaining why this feature would be useful
21
- - Any implementation ideas you might have
22
-
23
- ### Pull Requests
24
-
25
- 1. **Fork the repository** and create your branch from `main`
26
- 2. **Make your changes** following our coding standards
27
- 3. **Add tests** for any new functionality
28
- 4. **Ensure all tests pass** by running `bin/test`
29
- 5. **Run the linter** with `bundle exec rubocop`
30
- 6. **Update documentation** if you're changing functionality
31
- 7. **Submit your pull request** with a clear description of changes
32
-
33
- ## Development Setup
34
-
35
- 1. Clone the repository:
36
- ```bash
37
- git clone https://github.com/brunocostanzo/fripa.git
38
- cd fripa
39
- ```
40
-
41
- 2. Install dependencies:
42
- ```bash
43
- bin/setup
44
- ```
45
-
46
- 3. Run tests:
47
- ```bash
48
- bin/test
49
- ```
50
-
51
- 4. Run linter:
52
- ```bash
53
- bundle exec rubocop
54
- ```
55
-
56
- ## Coding Standards
57
-
58
- - Follow the [Ruby Style Guide](https://rubystyle.guide/)
59
- - Use RuboCop to check your code style
60
- - Write clear, self-documenting code
61
- - Add comments for complex logic
62
- - Keep methods small and focused
63
- - Maintain test coverage above 95%
64
-
65
- ## Testing Guidelines
66
-
67
- - Write tests for all new features
68
- - Update tests when modifying existing features
69
- - Use VCR cassettes for API interactions
70
- - Ensure both line and branch coverage remain high
71
- - Test both success and error cases
72
-
73
- ## Commit Messages
74
-
75
- Write clear, descriptive commit messages:
76
- - Use present tense ("Add feature" not "Added feature")
77
- - Capitalize the first letter
78
- - Keep the first line under 72 characters
79
- - Add detailed description if needed
80
-
81
- Example:
82
- ```
83
- Add support for host management
84
-
85
- - Implement host resource with CRUD operations
86
- - Add tests with VCR cassettes
87
- - Update README with usage examples
88
- ```
89
-
90
- ## Questions?
91
-
92
- If you have questions about contributing, feel free to open an issue labeled "question".
93
-
94
- ## Code of Conduct
95
-
96
- By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
data/Rakefile DELETED
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "minitest/test_task"
5
-
6
- Minitest::TestTask.create do |t|
7
- t.warning = false
8
- end
9
-
10
- require "rubocop/rake_task"
11
-
12
- RuboCop::RakeTask.new
13
-
14
- task default: %i[test rubocop]