fripa 0.1.4 → 0.3.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: 2ac5a037cb023bb2f087a6d3f6482c4ca8fa8a1e7f7b70d7a01d88e8e7e62db8
4
+ data.tar.gz: a7f6e6894ee51a0195f4555e482e29752188805c0c908791232bc8fa7db272f0
5
5
  SHA512:
6
- metadata.gz: 61530519d2a9d935c9c221322ff05ead8445742f9400aeddde5288a25e12f61c6fdbefc89ddd944d163aa2ec7157eaba9c7b0dcd7ccc758d1b898eae7786466e
7
- data.tar.gz: ca8616bb8d874dfe0c0c41f84590a2d0883f55412ec235b7a731bc49c486d87e2fcf7d0e3774bfaf26c372190c87a2961c60eb7f15ebab3c261abd77a176d6db
6
+ metadata.gz: be1d590702cd1e50e0995b6e8b65683c691ec0f03522ff755126645566d7f87cbd7be9c311db970900ee6b28a3931a5563cadd7cae1c9d24d3982b6bea94c3c5
7
+ data.tar.gz: 9acd2a8785b03cabf3a4902db60efdc6e5c895137759c8371ecf7577ce94f8c37daab75631ed81ea4918cfe795ebf74e23af716fcf3bfbfa3fdde4bd2b3e19d3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
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.3.0] - 2026-10-15
10
+
11
+ ### Changed
12
+ - **`Fripa::Client.new` no longer connects.** The client logs in on the first call. Wrong credentials now raise `Fripa::AuthenticationError` on the first call, not in `Client.new`. Call `client.login!` to log in at once. Blank credentials still raise `ArgumentError` in `Client.new`.
13
+ - The vault transport certificate is cached per process and server URL, so all clients share it (before: one fetch per client). It is fetched again after a vault call fails.
14
+
15
+ ### Added
16
+ - `Fripa.client`: one client for the account in `config.username` / `config.password`, shared by the process. It is built on first use and again when the configuration changes. `Fripa.reset_client!` forgets the shared client (e.g. between tests).
17
+ - `Fripa::Client#login!` (returns the session cookie) and `#logged_in?`.
18
+ - `Fripa::Client.new(username:, session_cookie:)` builds a client from an earlier session, without a login request. `password` is now optional. Without a password, an expired session raises `Fripa::AuthenticationError`.
19
+ - `Fripa::Client.new` without `username` uses the credentials from the configuration. With a `username`, it never uses the configured password.
20
+ - `Configuration#inspect` does not show the password.
21
+
22
+ ## [0.2.0] - 2026-10-12
23
+
24
+ ### Changed
25
+ - Network errors and timeouts (`Faraday::Error`) now raise `Fripa::ConnectionError`. Responses that are not JSON also raise `Fripa::ConnectionError`.
26
+ - The session cookie keeps only `name=value` pairs. Attributes like `path`, `httponly` and `secure` are no longer sent back.
27
+ - Login and API calls share one HTTP connection.
28
+ - `Fripa::Client#inspect` and `Fripa::Authenticator#inspect` no longer show the password or the session cookie.
29
+ - `scheme` is case-insensitive. An unknown scheme raises `ArgumentError` instead of falling back to HTTP.
30
+
31
+ ### Added
32
+ - `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`.
33
+ - The client logs in again and retries once when the session expires (HTTP 401).
34
+ - `Fripa::Client#batch` sends many commands in one HTTP request.
35
+ - Configuration options: `timeout`, `open_timeout`, `api_version` and `adapter` (e.g. `:net_http_persistent` for keep-alive).
36
+ - `ipa_format:` option for `vault.archive` and `vault.retrieve`, to read and write vaults in the format of the `ipa` CLI.
37
+ - `users.passwd` accepts no current password, so admins can reset the password of another user.
38
+ - RBS signatures in `sig/`.
39
+
40
+ ### Fixed
41
+ - `vault.retrieve` returns `nil` for an empty vault instead of raising `NoMethodError`.
42
+ - The vault resource fetches the KRA transport certificate once per client, not on every archive and retrieve.
43
+
44
+ ## [0.1.4] - 2026-04-16
45
+
46
+ ### Added
47
+ - Vault resource: `add`, `show`, `archive`, `retrieve`, `add_member`, `delete`, with transport encryption (AES-128-CBC session key wrapped with the KRA transport certificate).
48
+
49
+ ## [0.1.3] - 2025-11-26
50
+
51
+ ### Added
52
+ - `Fripa::ResponseError` with `#code`, `#message` and `#data` for errors returned by FreeIPA.
53
+
3
54
  ## [0.1.2] - 2025-11-14
4
55
 
5
56
  ### Fixed
@@ -20,3 +71,11 @@ Initial release of Fripa - A Ruby client for the FreeIPA JSON-RPC API.
20
71
  - Parameter validation for resource methods
21
72
  - Full test suite with VCR cassettes
22
73
  - Complete API documentation and usage examples
74
+
75
+ [Unreleased]: https://github.com/renuo/fripa/compare/v0.3.0...HEAD
76
+ [0.3.0]: https://github.com/renuo/fripa/compare/v0.2.0...v0.3.0
77
+ [0.2.0]: https://github.com/renuo/fripa/compare/v0.1.4...v0.2.0
78
+ [0.1.4]: https://github.com/renuo/fripa/compare/v0.1.3...v0.1.4
79
+ [0.1.3]: https://github.com/renuo/fripa/compare/v0.1.2...v0.1.3
80
+ [0.1.2]: https://github.com/renuo/fripa/compare/v0.1.0...v0.1.2
81
+ [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:
@@ -91,26 +114,54 @@ end
91
114
 
92
115
  ### Creating a Client
93
116
 
94
- Create a client instance with user credentials. The client authenticates immediately upon creation:
117
+ #### Service account (shared client)
118
+
119
+ Put the credentials in the configuration and use `Fripa.client`. It is one
120
+ client for the whole process: it logs in on the first call, keeps the session,
121
+ and logs in again when the session expires. It is built again when the
122
+ configuration changes.
95
123
 
96
124
  ```ruby
97
- client = Fripa::Client.new(
98
- username: 'your-username',
99
- password: 'your-password'
100
- )
101
- # Client is now authenticated and ready to use
125
+ Fripa.configure do |config|
126
+ config.host = 'ipa.example.com'
127
+ config.username = 'admin'
128
+ config.password = 'secret'
129
+ end
130
+
131
+ Fripa.client.users.show('alice')
102
132
  ```
103
133
 
104
- If authentication fails, a `Fripa::AuthenticationError` will be raised immediately:
134
+ In tests, call `Fripa.reset_client!` before each test, so no session or test
135
+ double stays from one test to the next.
136
+
137
+ #### Other accounts
138
+
139
+ `Fripa::Client.new` creates a client for one account. It does not connect: it
140
+ logs in on the first call. Call `login!` to log in now, for example to check
141
+ credentials. It returns the session cookie.
105
142
 
106
143
  ```ruby
144
+ client = Fripa::Client.new(username: 'alice', password: 'her-password')
145
+
107
146
  begin
108
- client = Fripa::Client.new(username: 'admin', password: 'wrong-password')
147
+ cookie = client.login!
109
148
  rescue Fripa::AuthenticationError => e
110
149
  puts "Authentication failed: #{e.message}"
111
150
  end
112
151
  ```
113
152
 
153
+ To skip the login in later requests, build the client from the session cookie
154
+ (e.g. kept in an encrypted session):
155
+
156
+ ```ruby
157
+ client = Fripa::Client.new(username: 'alice', session_cookie: cookie)
158
+ ```
159
+
160
+ A client without a password cannot log in again: when its session expires, it
161
+ raises `Fripa::AuthenticationError`. With a `username`, `Fripa::Client.new`
162
+ never uses the password from the configuration. A client can be shared between
163
+ threads.
164
+
114
165
  ### Making API Calls
115
166
 
116
167
  #### Using Resource Methods (Recommended)
@@ -143,6 +194,9 @@ client.users.mod("newuser", mail: "newuser@example.com")
143
194
  # Change user password
144
195
  client.users.passwd("newuser", "NewPassword123", "TempPassword123")
145
196
 
197
+ # Reset the password of another user (admin), without the current password
198
+ client.users.passwd("newuser", "NewPassword123")
199
+
146
200
  # Delete a user
147
201
  client.users.delete("newuser")
148
202
  ```
@@ -181,6 +235,45 @@ client.groups.remove_member_manager("developers", user: ["manager"])
181
235
  client.groups.delete("developers")
182
236
  ```
183
237
 
238
+ ##### Vaults
239
+
240
+ ```ruby
241
+ client = Fripa::Client.new(username: 'admin', password: 'secret')
242
+
243
+ client.vault.add("my-vault", shared: true)
244
+ client.vault.archive("my-vault", data: "secret", shared: true)
245
+ client.vault.retrieve("my-vault", shared: true) # => "secret"
246
+ client.vault.add_member("my-vault", user: ["alice"], shared: true)
247
+ client.vault.delete("my-vault", shared: true)
248
+ ```
249
+
250
+ By default, `archive` stores the raw bytes. The `ipa` CLI stores the data in a
251
+ JSON envelope (`{"data": "<base64>"}`). To share a vault with the `ipa` CLI
252
+ (`ipa vault-archive` / `ipa vault-retrieve`), pass `ipa_format: true`:
253
+
254
+ ```ruby
255
+ client.vault.archive("my-vault", data: "secret", ipa_format: true, shared: true)
256
+ client.vault.retrieve("my-vault", ipa_format: true, shared: true) # => "secret"
257
+ ```
258
+
259
+ The KRA transport certificate is fetched once per process and server, and all
260
+ clients share it (it is public and the same for every account). If the server
261
+ rejects a vault call, the certificate is fetched again on the next call.
262
+
263
+ #### Batch Calls
264
+
265
+ Send many commands in one HTTP request:
266
+
267
+ ```ruby
268
+ result = client.batch(
269
+ ["user_show", ["alice"]],
270
+ ["group_find", [], { sizelimit: 0 }]
271
+ )
272
+ result.dig("result", "results").each { |r| puts r["error"] || r["result"] }
273
+ ```
274
+
275
+ `batch` does not raise for errors in a single command. Check `error` in each result.
276
+
184
277
  #### Using Raw API Calls
185
278
 
186
279
  For API methods without a resource wrapper, use the `call` method directly:
@@ -193,7 +286,31 @@ result = client.call("user_find", ["admin"], { all: true })
193
286
  puts result.dig("result", "count")
194
287
  ```
195
288
 
196
- The client will automatically authenticate if no session exists.
289
+ If the session expires (HTTP 401), the client logs in again and retries the call once.
290
+
291
+ ### Error Handling
292
+
293
+ All errors inherit from `Fripa::Error`:
294
+
295
+ | Error | When |
296
+ |---|---|
297
+ | `Fripa::AuthenticationError` | Login fails |
298
+ | `Fripa::ConnectionError` | Network error, timeout, HTTP error status, or a response that is not JSON |
299
+ | `Fripa::ResponseError` | FreeIPA returns an error (`#code`, `#message`, `#data`) |
300
+ | `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 |
301
+
302
+ By default, these per-entry failures do not raise. They stay in the response, in
303
+ `result["result"]["failed"]`. This will change in 1.0.
304
+
305
+ ```ruby
306
+ begin
307
+ client.groups.add_member("developers", user: ["alice"])
308
+ rescue Fripa::PartialFailureError => e
309
+ e.data # => {"member" => {"user" => [["alice", "This entry is already a member"]], "group" => []}}
310
+ rescue Fripa::ResponseError => e
311
+ puts "#{e.code}: #{e.message}"
312
+ end
313
+ ```
197
314
 
198
315
  ## Development
199
316
 
@@ -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,30 +23,41 @@ 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"])
24
27
  end
25
28
 
26
- private
29
+ def inspect
30
+ "#<#{self.class.name} username=#{client.username.inspect}>"
31
+ end
27
32
 
28
33
  def validate_credentials!
29
34
  raise ArgumentError, "Username cannot be blank" if client.username.to_s.strip.empty?
30
35
  raise ArgumentError, "Password cannot be blank" if client.password.to_s.strip.empty?
31
36
  end
32
37
 
38
+ private
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,16 +6,24 @@ 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
13
14
 
14
- def initialize(username:, password:, config: nil)
15
+ # Does not connect: the client logs in on the first call, or when you call login!.
16
+ # Without username, it uses config.username and config.password. With a
17
+ # username, it never uses the password from the configuration.
18
+ # Give a session_cookie from an earlier login to skip the login request.
19
+ # Without a password, an expired session raises AuthenticationError.
20
+ def initialize(username: nil, password: nil, config: nil, session_cookie: nil)
15
21
  @config = config || Fripa.config
16
- @username = username
17
- @password = password
18
- @session_cookie = authenticator.login!
22
+ @username = username || @config.username
23
+ @password = username ? password : password || @config.password
24
+ @session_cookie = session_cookie unless session_cookie.to_s.empty?
25
+ @logged_in = !@session_cookie.nil?
26
+ authenticator.validate_credentials! unless @logged_in
19
27
  end
20
28
 
21
29
  def users
@@ -34,56 +42,105 @@ module Fripa
34
42
  @authenticator ||= Authenticator.new(self)
35
43
  end
36
44
 
45
+ # Logs in now and returns the session cookie. Raises AuthenticationError
46
+ # if the credentials are wrong.
47
+ def login!
48
+ self.session_cookie = authenticator.login!
49
+ @logged_in = true
50
+ session_cookie
51
+ end
52
+
53
+ def logged_in?
54
+ @logged_in
55
+ end
56
+
37
57
  def call(method, args = [], options = {})
58
+ login! unless logged_in?
38
59
  response = perform_request(method, args, options)
60
+ response = reauthenticate_and_retry(method, args, options) if response.status == UNAUTHORIZED
39
61
 
40
62
  raise ConnectionError, "API call failed: #{response.status}" unless response.success?
41
63
 
42
64
  parse_response(response)
43
65
  end
44
66
 
67
+ # Sends several commands in one HTTP request. Each command is
68
+ # [method, args = [], options = {}]. Per-command errors are not raised:
69
+ # check result["result"]["results"][i]["error"].
70
+ def batch(*commands)
71
+ requests = commands.map do |method, args = [], options = {}|
72
+ { method: method, params: [args, options.merge(version: config.api_version)] }
73
+ end
74
+ call("batch", requests)
75
+ end
76
+
77
+ # Shared by Authenticator, so login and API calls reuse one connection.
78
+ def connection
79
+ @connection ||= Faraday.new(url: config.base_url.to_s, request: request_options) do |f|
80
+ f.adapter(*Array(config.adapter || Faraday.default_adapter))
81
+ f.ssl.verify = config.verify_ssl
82
+ end
83
+ end
84
+
85
+ def referer
86
+ @referer ||= "#{config.base_url}/ipa"
87
+ end
88
+
89
+ def inspect
90
+ "#<#{self.class.name} username=#{username.inspect} host=#{config.host.inspect}>"
91
+ end
92
+
45
93
  private
46
94
 
95
+ def reauthenticate_and_retry(method, args, options)
96
+ raise AuthenticationError, "Session expired and no password to log in again" if password.to_s.empty?
97
+
98
+ login!
99
+ perform_request(method, args, options)
100
+ end
101
+
47
102
  def perform_request(method, args, options)
48
103
  connection.post(API_PATH) do |request|
49
104
  request.headers["Content-Type"] = "application/json"
50
- request.headers["Referer"] = "#{config.base_url}/ipa"
105
+ request.headers["Referer"] = referer
51
106
  request.headers["Cookie"] = session_cookie
52
107
  request.body = build_request_body(method, args, options).to_json
53
108
  end
109
+ rescue Faraday::Error => e
110
+ raise ConnectionError, "API call failed: #{e.message}"
54
111
  end
55
112
 
56
113
  def build_request_body(method, args, options)
57
114
  {
58
115
  method: method,
59
- params: [args, options.merge(version: API_VERSION)],
116
+ params: [args, options.merge(version: config.api_version)],
60
117
  id: 0
61
118
  }
62
119
  end
63
120
 
64
121
  def parse_response(response)
65
122
  parsed = JSON.parse(response.body)
123
+ raise ConnectionError, "API call failed: unexpected response" unless parsed.is_a?(Hash)
124
+
66
125
  raise_if_error(parsed)
67
126
  parsed
127
+ rescue JSON::ParserError
128
+ raise ConnectionError, "API call failed: invalid JSON response"
68
129
  end
69
130
 
70
131
  def raise_if_error(parsed)
71
- return unless parsed["error"]
132
+ if (error = parsed["error"])
133
+ raise ResponseError.new(code: error["code"], message: error["message"] || error["name"], data: error["data"])
134
+ end
135
+
136
+ return unless config.raise_on_partial_failure
72
137
 
73
- error = parsed["error"]
74
- raise ResponseError.new(
75
- code: error["code"],
76
- message: error["message"] || error["name"],
77
- data: error["data"]
78
- )
138
+ failed = parsed.dig("result", "failed")
139
+ raise PartialFailureError, failed if PartialFailureError.failures(failed).any?
79
140
  end
80
141
 
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
142
+ def request_options
143
+ { timeout: config.timeout, open_timeout: config.open_timeout }.compact
87
144
  end
88
145
  end
89
146
  end
@@ -6,13 +6,39 @@ 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, :username, :password
14
+
15
+ # username and password are only used by Fripa.client and by
16
+ # Fripa::Client.new without a username.
17
+ def initialize(host: nil, port: nil, scheme: "https", verify_ssl: true, api_version: DEFAULT_API_VERSION,
18
+ timeout: nil, open_timeout: nil, adapter: nil, raise_on_partial_failure: false,
19
+ username: nil, password: nil)
12
20
  @host = host
13
21
  @port = port
14
22
  @scheme = scheme
15
23
  @verify_ssl = verify_ssl
24
+ @api_version = api_version
25
+ @timeout = timeout
26
+ @open_timeout = open_timeout
27
+ @adapter = adapter
28
+ @raise_on_partial_failure = raise_on_partial_failure
29
+ @username = username
30
+ @password = password
31
+ end
32
+
33
+ # Values that change how a client connects or logs in. Fripa.client builds
34
+ # a new client when they change.
35
+ def options
36
+ { host:, port:, scheme:, verify_ssl:, api_version:, timeout:, open_timeout:, adapter:,
37
+ raise_on_partial_failure:, username:, password: }
38
+ end
39
+
40
+ def inspect
41
+ "#<#{self.class.name} host=#{host.inspect} scheme=#{scheme.inspect} username=#{username.inspect}>"
16
42
  end
17
43
 
18
44
  def base_url
@@ -30,18 +56,41 @@ module Fripa
30
56
  private
31
57
 
32
58
  def build_uri(path: nil)
33
- raise ArgumentError, "host is required" if host.nil? || host.empty?
59
+ raise ArgumentError, "host is required" if host.to_s.empty?
60
+
61
+ normalized_scheme = scheme.to_s.downcase
62
+ raise ArgumentError, "scheme must be one of: #{SCHEMES.join(", ")}" unless SCHEMES.include?(normalized_scheme)
34
63
 
35
- uri_class = scheme == "https" ? URI::HTTPS : URI::HTTP
64
+ uri_class = normalized_scheme == "https" ? URI::HTTPS : URI::HTTP
36
65
  uri_class.build(host: host, port: port, path: path)
37
66
  end
38
67
  end
39
68
 
69
+ @client_lock = Mutex.new
70
+
40
71
  class << self
41
72
  def config
42
73
  @config ||= Configuration.new
43
74
  end
44
75
 
76
+ # A client for the account in Fripa.config (username and password), shared
77
+ # by the whole process. It is built on first use, and again when the
78
+ # configuration changes. Use Fripa::Client.new for other accounts.
79
+ def client
80
+ @client_lock.synchronize do
81
+ unless @client&.config.equal?(config) && @client_options == config.options
82
+ @client = Client.new(config: config)
83
+ @client_options = config.options
84
+ end
85
+ @client
86
+ end
87
+ end
88
+
89
+ # Forgets the shared client (e.g. between tests). The next Fripa.client builds a new one.
90
+ def reset_client!
91
+ @client_lock.synchronize { @client = @client_options = nil }
92
+ end
93
+
45
94
  def config=(value)
46
95
  @config = case value
47
96
  when Hash
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
@@ -5,6 +5,28 @@ require "base64"
5
5
  module Fripa
6
6
  module Resources
7
7
  class Vault < Base
8
+ NOT_FOUND = 4001
9
+
10
+ # The KRA transport certificate belongs to the server, not to an account,
11
+ # and it is public. So all clients of one process share it, per server URL:
12
+ # a service account and per-user clients fetch it only once.
13
+ @transport_certs = {}
14
+ @transport_certs_lock = Mutex.new
15
+
16
+ class << self
17
+ def transport_cert(key)
18
+ @transport_certs_lock.synchronize { @transport_certs[key] ||= yield }
19
+ end
20
+
21
+ def forget_transport_cert(key)
22
+ @transport_certs_lock.synchronize { @transport_certs.delete(key) }
23
+ end
24
+
25
+ def clear_transport_certs!
26
+ @transport_certs_lock.synchronize { @transport_certs.clear }
27
+ end
28
+ end
29
+
8
30
  def show(cn, **scope)
9
31
  validate_identifier!(cn, "cn")
10
32
  call("vault_show", [cn], scope)
@@ -15,24 +37,32 @@ module Fripa
15
37
  call("vault_add_internal", [cn], { ipavaulttype: "standard" }.merge(scope))
16
38
  end
17
39
 
18
- def archive(cn, data:, **scope)
40
+ # ipa_format: true stores the data like `ipa vault-archive`, so the ipa CLI can read it.
41
+ # The default (false) stores the raw bytes, as in previous versions.
42
+ def archive(cn, data:, ipa_format: false, **scope)
19
43
  validate_identifier!(cn, "cn")
20
- transport_cert_der = fetch_transport_cert
44
+ data = VaultTransport.to_ipa_format(data) if ipa_format
21
45
  payload = VaultTransport.wrap(data, transport_cert_der)
22
- call("vault_archive_internal", [cn], binary_encode(payload).merge(wrapping_algo: VaultTransport::WRAPPING_ALGO).merge(scope))
46
+ with_transport_cert_refresh do
47
+ call("vault_archive_internal", [cn],
48
+ binary_encode(payload).merge(wrapping_algo: VaultTransport::WRAPPING_ALGO).merge(scope))
49
+ end
23
50
  end
24
51
 
25
- def retrieve(cn, **scope)
52
+ # ipa_format: true reads data stored by `ipa vault-archive` (or archive with ipa_format: true).
53
+ # Returns nil if the vault has no data.
54
+ def retrieve(cn, ipa_format: false, **scope)
26
55
  validate_identifier!(cn, "cn")
27
56
  session_key = VaultTransport.generate_session_key
28
- response = call_retrieve(cn, session_key, **scope)
29
- result = response.dig("result", "result")
57
+ result = call_retrieve(cn, session_key, **scope).dig("result", "result")
58
+ return nil unless result&.key?("vault_data")
30
59
 
31
- VaultTransport.decrypt(
60
+ data = VaultTransport.decrypt(
32
61
  vault_data: extract_value(result["vault_data"]),
33
62
  nonce: extract_value(result["nonce"]),
34
63
  session_key: session_key
35
64
  )
65
+ ipa_format ? VaultTransport.from_ipa_format(data) : data
36
66
  end
37
67
 
38
68
  def add_member(cn, **scope)
@@ -47,20 +77,33 @@ module Fripa
47
77
 
48
78
  private
49
79
 
80
+ def transport_cert_der
81
+ self.class.transport_cert(client.config.base_url.to_s) { fetch_transport_cert }
82
+ end
83
+
84
+ # If the server rejects a payload, its certificate can have changed
85
+ # (e.g. renewed). Forget it, so that the next call fetches it again.
86
+ def with_transport_cert_refresh
87
+ yield
88
+ rescue ResponseError => e
89
+ self.class.forget_transport_cert(client.config.base_url.to_s) unless e.code == NOT_FOUND
90
+ raise
91
+ end
92
+
50
93
  def fetch_transport_cert
51
94
  response = call("vaultconfig_show", [], { all: true })
52
- cert = response["result"]["result"]["transport_cert"]
53
- Base64.decode64(cert.is_a?(Hash) ? cert["__base64__"] : cert)
95
+ Base64.decode64(extract_value(response.dig("result", "result", "transport_cert")))
54
96
  end
55
97
 
56
98
  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)
99
+ encrypted_key = VaultTransport.encrypt_session_key(session_key, transport_cert_der)
59
100
 
60
- call("vault_retrieve_internal", [cn], {
61
- session_key: { "__base64__" => Base64.strict_encode64(encrypted_key) },
62
- wrapping_algo: VaultTransport::WRAPPING_ALGO
63
- }.merge(scope))
101
+ with_transport_cert_refresh do
102
+ call("vault_retrieve_internal", [cn], {
103
+ session_key: { "__base64__" => Base64.strict_encode64(encrypted_key) },
104
+ wrapping_algo: VaultTransport::WRAPPING_ALGO
105
+ }.merge(scope))
106
+ end
64
107
  end
65
108
 
66
109
  def binary_encode(hash)
@@ -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.3.0"
5
5
  end
data/sig/fripa.rbs CHANGED
@@ -1,4 +1,162 @@
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
+ def self.client: () -> Client
10
+ def self.reset_client!: () -> void
11
+
12
+ class Configuration
13
+ DEFAULT_API_VERSION: String
14
+ SCHEMES: Array[String]
15
+
16
+ attr_accessor host: String?
17
+ attr_accessor port: Integer?
18
+ attr_accessor scheme: String
19
+ attr_accessor verify_ssl: bool
20
+ attr_accessor api_version: String
21
+ attr_accessor timeout: Numeric?
22
+ attr_accessor open_timeout: Numeric?
23
+ attr_accessor adapter: untyped
24
+ attr_accessor raise_on_partial_failure: bool
25
+ attr_accessor username: String?
26
+ attr_accessor password: String?
27
+
28
+ def initialize: (?host: String?, ?port: Integer?, ?scheme: String, ?verify_ssl: bool, ?api_version: String,
29
+ ?timeout: Numeric?, ?open_timeout: Numeric?, ?adapter: untyped,
30
+ ?raise_on_partial_failure: bool, ?username: String?, ?password: String?) -> void
31
+ def options: () -> Hash[Symbol, untyped]
32
+ def base_url: () -> URI::Generic
33
+ def login_url: () -> URI::Generic
34
+ def api_url: () -> URI::Generic
35
+
36
+ private
37
+
38
+ def build_uri: (?path: String?) -> URI::Generic
39
+ end
40
+
41
+ class Error < StandardError
42
+ end
43
+
44
+ class AuthenticationError < Error
45
+ end
46
+
47
+ class ConnectionError < Error
48
+ end
49
+
50
+ class ResponseError < Error
51
+ attr_reader code: Integer?
52
+ attr_reader data: untyped
53
+
54
+ def initialize: (code: Integer?, message: String?, ?data: untyped) -> void
55
+ end
56
+
57
+ class PartialFailureError < ResponseError
58
+ def initialize: (json_hash failed) -> void
59
+ def self.failures: (untyped failed) -> Array[untyped]
60
+ def self.first_reason: (untyped failed) -> String
61
+ end
62
+
63
+ class Client
64
+ API_PATH: String
65
+ API_VERSION: String
66
+ UNAUTHORIZED: Integer
67
+
68
+ attr_reader config: Configuration
69
+ attr_reader username: String?
70
+ attr_reader password: String?
71
+ attr_accessor session_cookie: String?
72
+
73
+ def initialize: (?username: String?, ?password: String?, ?config: Configuration?, ?session_cookie: String?) -> void
74
+ def login!: () -> String?
75
+ def logged_in?: () -> bool
76
+ def users: () -> Resources::User
77
+ def groups: () -> Resources::Group
78
+ def vault: () -> Resources::Vault
79
+ def authenticator: () -> Authenticator
80
+ def call: (String method, ?Array[untyped] args, ?Hash[Symbol, untyped] options) -> json_hash
81
+ def batch: (*Array[untyped] commands) -> json_hash
82
+ def connection: () -> untyped
83
+ def referer: () -> String
84
+ end
85
+
86
+ class Authenticator
87
+ LOGIN_PATH: String
88
+ CONTENT_TYPE: String
89
+ COOKIE_SEPARATOR: Regexp
90
+
91
+ attr_reader client: Client
92
+
93
+ def initialize: (Client client) -> void
94
+ def login!: () -> String?
95
+ def validate_credentials!: () -> void
96
+ end
97
+
98
+ module VaultTransport
99
+ CIPHER: String
100
+ WRAPPING_ALGO: String
101
+ KEY_SIZE: Integer
102
+ IV_SIZE: Integer
103
+ RSA_KEY_SIZE: Integer
104
+ RSA_PADDING: Integer
105
+
106
+ def self.generate_session_key: () -> String
107
+ def self.decrypt: (vault_data: String, nonce: String, session_key: String) -> String
108
+ def self.wrap: (String data, String transport_cert_der) -> { vault_data: String, session_key: String, nonce: String }
109
+ def self.encrypt_data: (String data, String key, String nonce) -> String
110
+ def self.encrypt_session_key: (String session_key, String cert_der) -> String
111
+ def self.unwrap: (vault_data: String, session_key: String, nonce: String, private_key: OpenSSL::PKey::RSA) -> String
112
+ def self.to_ipa_format: (String data) -> String
113
+ def self.from_ipa_format: (String json) -> String
114
+ def self.generate_transport_key: () -> OpenSSL::PKey::RSA
115
+ end
116
+
117
+ module Resources
118
+ class Base
119
+ attr_reader client: Client
120
+
121
+ def initialize: (Client client) -> void
122
+ end
123
+
124
+ class User < Base
125
+ REQUIRED_ADD_FIELDS: Array[Symbol]
126
+
127
+ def find: (?String? uid, **untyped options) -> json_hash
128
+ def show: (String uid) -> json_hash
129
+ def add: (String uid, **untyped attributes) -> json_hash
130
+ def mod: (String uid, **untyped attributes) -> json_hash
131
+ def delete: (String uid) -> json_hash
132
+ def passwd: (String uid, String new_password, ?String? current_password, **untyped options) -> json_hash
133
+ end
134
+
135
+ class Group < Base
136
+ def find: (?String? cn, **untyped options) -> json_hash
137
+ def show: (String cn) -> json_hash
138
+ def add: (String cn, **untyped attributes) -> json_hash
139
+ def mod: (String cn, **untyped attributes) -> json_hash
140
+ def delete: (String cn) -> json_hash
141
+ def add_member: (String cn, **untyped members) -> json_hash
142
+ def remove_member: (String cn, **untyped members) -> json_hash
143
+ def add_member_manager: (String cn, **untyped managers) -> json_hash
144
+ def remove_member_manager: (String cn, **untyped managers) -> json_hash
145
+ end
146
+
147
+ class Vault < Base
148
+ NOT_FOUND: Integer
149
+
150
+ def self.transport_cert: (String key) { () -> String } -> String
151
+ def self.forget_transport_cert: (String key) -> String?
152
+ def self.clear_transport_certs!: () -> void
153
+
154
+ def show: (String cn, **untyped scope) -> json_hash
155
+ def add: (String cn, **untyped scope) -> json_hash
156
+ def archive: (String cn, data: String, ?ipa_format: bool, **untyped scope) -> json_hash
157
+ def retrieve: (String cn, ?ipa_format: bool, **untyped scope) -> String?
158
+ def add_member: (String cn, **untyped scope) -> json_hash
159
+ def delete: (String cn, **untyped scope) -> json_hash
160
+ end
161
+ end
4
162
  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.3.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]