fripa 0.1.4 → 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: 8ff8e5a918933d2c1fd78f075585c5c1d90c90fce2b13e9ad5e01f52882d6826
4
- data.tar.gz: 390931635d1f39407fa112ab02c3e4c3ad170b1161f31888d3213ca491a14206
3
+ metadata.gz: 5034dad8f9b68de329b79f4a447167c68f60edbf9310854f587a14b780f1c3f7
4
+ data.tar.gz: 9dc1b84130c58a48f67aeaa103c39893235c28123b38214acde4eefd40509762
5
5
  SHA512:
6
- metadata.gz: 61530519d2a9d935c9c221322ff05ead8445742f9400aeddde5288a25e12f61c6fdbefc89ddd944d163aa2ec7157eaba9c7b0dcd7ccc758d1b898eae7786466e
7
- data.tar.gz: ca8616bb8d874dfe0c0c41f84590a2d0883f55412ec235b7a731bc49c486d87e2fcf7d0e3774bfaf26c372190c87a2961c60eb7f15ebab3c261abd77a176d6db
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
@@ -36,54 +37,88 @@ module Fripa
36
37
 
37
38
  def call(method, args = [], options = {})
38
39
  response = perform_request(method, args, options)
40
+ response = reauthenticate_and_retry(method, args, options) if response.status == UNAUTHORIZED
39
41
 
40
42
  raise ConnectionError, "API call failed: #{response.status}" unless response.success?
41
43
 
42
44
  parse_response(response)
43
45
  end
44
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
+
45
73
  private
46
74
 
75
+ def reauthenticate_and_retry(method, args, options)
76
+ self.session_cookie = authenticator.login!
77
+ perform_request(method, args, options)
78
+ end
79
+
47
80
  def perform_request(method, args, options)
48
81
  connection.post(API_PATH) do |request|
49
82
  request.headers["Content-Type"] = "application/json"
50
- request.headers["Referer"] = "#{config.base_url}/ipa"
83
+ request.headers["Referer"] = referer
51
84
  request.headers["Cookie"] = session_cookie
52
85
  request.body = build_request_body(method, args, options).to_json
53
86
  end
87
+ rescue Faraday::Error => e
88
+ raise ConnectionError, "API call failed: #{e.message}"
54
89
  end
55
90
 
56
91
  def build_request_body(method, args, options)
57
92
  {
58
93
  method: method,
59
- params: [args, options.merge(version: API_VERSION)],
94
+ params: [args, options.merge(version: config.api_version)],
60
95
  id: 0
61
96
  }
62
97
  end
63
98
 
64
99
  def parse_response(response)
65
100
  parsed = JSON.parse(response.body)
101
+ raise ConnectionError, "API call failed: unexpected response" unless parsed.is_a?(Hash)
102
+
66
103
  raise_if_error(parsed)
67
104
  parsed
105
+ rescue JSON::ParserError
106
+ raise ConnectionError, "API call failed: invalid JSON response"
68
107
  end
69
108
 
70
109
  def raise_if_error(parsed)
71
- 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
72
115
 
73
- error = parsed["error"]
74
- raise ResponseError.new(
75
- code: error["code"],
76
- message: error["message"] || error["name"],
77
- data: error["data"]
78
- )
116
+ failed = parsed.dig("result", "failed")
117
+ raise PartialFailureError, failed if PartialFailureError.failures(failed).any?
79
118
  end
80
119
 
81
- def connection
82
- @connection ||= Faraday.new(url: config.base_url.to_s) do |f|
83
- f.request :url_encoded
84
- f.adapter Faraday.default_adapter
85
- f.ssl.verify = config.verify_ssl
86
- end
120
+ def request_options
121
+ { timeout: config.timeout, open_timeout: config.open_timeout }.compact
87
122
  end
88
123
  end
89
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
@@ -15,24 +15,30 @@ module Fripa
15
15
  call("vault_add_internal", [cn], { ipavaulttype: "standard" }.merge(scope))
16
16
  end
17
17
 
18
- def archive(cn, data:, **scope)
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)
19
21
  validate_identifier!(cn, "cn")
20
- transport_cert_der = fetch_transport_cert
22
+ data = VaultTransport.to_ipa_format(data) if ipa_format
21
23
  payload = VaultTransport.wrap(data, transport_cert_der)
22
- call("vault_archive_internal", [cn], binary_encode(payload).merge(wrapping_algo: VaultTransport::WRAPPING_ALGO).merge(scope))
24
+ call("vault_archive_internal", [cn],
25
+ binary_encode(payload).merge(wrapping_algo: VaultTransport::WRAPPING_ALGO).merge(scope))
23
26
  end
24
27
 
25
- def retrieve(cn, **scope)
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)
26
31
  validate_identifier!(cn, "cn")
27
32
  session_key = VaultTransport.generate_session_key
28
- response = call_retrieve(cn, session_key, **scope)
29
- result = response.dig("result", "result")
33
+ result = call_retrieve(cn, session_key, **scope).dig("result", "result")
34
+ return nil unless result&.key?("vault_data")
30
35
 
31
- VaultTransport.decrypt(
36
+ data = VaultTransport.decrypt(
32
37
  vault_data: extract_value(result["vault_data"]),
33
38
  nonce: extract_value(result["nonce"]),
34
39
  session_key: session_key
35
40
  )
41
+ ipa_format ? VaultTransport.from_ipa_format(data) : data
36
42
  end
37
43
 
38
44
  def add_member(cn, **scope)
@@ -47,15 +53,19 @@ module Fripa
47
53
 
48
54
  private
49
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
+
50
62
  def fetch_transport_cert
51
63
  response = call("vaultconfig_show", [], { all: true })
52
- cert = response["result"]["result"]["transport_cert"]
53
- Base64.decode64(cert.is_a?(Hash) ? cert["__base64__"] : cert)
64
+ Base64.decode64(extract_value(response.dig("result", "result", "transport_cert")))
54
65
  end
55
66
 
56
67
  def call_retrieve(cn, session_key, **scope)
57
- cert = OpenSSL::X509::Certificate.new(fetch_transport_cert)
58
- encrypted_key = cert.public_key.public_encrypt(session_key, VaultTransport::RSA_PADDING)
68
+ encrypted_key = VaultTransport.encrypt_session_key(session_key, transport_cert_der)
59
69
 
60
70
  call("vault_retrieve_internal", [cn], {
61
71
  session_key: { "__base64__" => Base64.strict_encode64(encrypted_key) },
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "openssl"
4
4
  require "base64"
5
+ require "json"
5
6
 
6
7
  module Fripa
7
8
  module VaultTransport
@@ -51,12 +52,17 @@ module Fripa
51
52
 
52
53
  def self.unwrap(vault_data:, session_key:, nonce:, private_key:)
53
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
54
57
 
55
- decipher = OpenSSL::Cipher.new(CIPHER)
56
- decipher.decrypt
57
- decipher.key = decrypted_session_key
58
- decipher.iv = Base64.decode64(nonce)
59
- decipher.update(Base64.decode64(vault_data)) + decipher.final
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"))
60
66
  end
61
67
 
62
68
  def self.generate_transport_key
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.4"
4
+ VERSION = "0.2.0"
5
5
  end
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,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fripa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
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:
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: base64
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '0.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '0.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -37,21 +37,17 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
- 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.'
41
42
  email:
42
43
  - dev.bcostanzo@gmail.com
43
44
  executables: []
44
45
  extensions: []
45
46
  extra_rdoc_files: []
46
47
  files:
47
- - ".env.example"
48
- - ".simplecov"
49
48
  - CHANGELOG.md
50
- - CODE_OF_CONDUCT.md
51
- - CONTRIBUTING.md
52
49
  - LICENSE.txt
53
50
  - README.md
54
- - Rakefile
55
51
  - lib/fripa.rb
56
52
  - lib/fripa/authenticator.rb
57
53
  - lib/fripa/client.rb
@@ -68,9 +64,11 @@ homepage: https://github.com/renuo/fripa
68
64
  licenses:
69
65
  - MIT
70
66
  metadata:
71
- homepage_uri: https://github.com/renuo/fripa
72
67
  source_code_uri: https://github.com/renuo/fripa
73
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'
74
72
  rdoc_options: []
75
73
  require_paths:
76
74
  - lib
@@ -85,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
83
  - !ruby/object:Gem::Version
86
84
  version: '0'
87
85
  requirements: []
88
- rubygems_version: 4.0.8
86
+ rubygems_version: 4.0.10
89
87
  specification_version: 4
90
88
  summary: Ruby client for FreeIPA JSON-RPC API
91
89
  test_files: []
data/.env.example DELETED
@@ -1,4 +0,0 @@
1
- FREEIPA_HOST=ipa.example.com
2
- FREEIPA_USER=admin
3
- FREEIPA_PASSWORD=your-password
4
- FREEIPA_VERIFY_SSL=true
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,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dotenv"
4
- Dotenv.load
5
-
6
- require "bundler/gem_tasks"
7
- require "minitest/test_task"
8
-
9
- Minitest::TestTask.create do |t|
10
- t.warning = false
11
- end
12
-
13
- require "rubocop/rake_task"
14
-
15
- RuboCop::RakeTask.new
16
-
17
- task default: %i[test rubocop]