fripa 0.2.0 → 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: 5034dad8f9b68de329b79f4a447167c68f60edbf9310854f587a14b780f1c3f7
4
- data.tar.gz: 9dc1b84130c58a48f67aeaa103c39893235c28123b38214acde4eefd40509762
3
+ metadata.gz: 2ac5a037cb023bb2f087a6d3f6482c4ca8fa8a1e7f7b70d7a01d88e8e7e62db8
4
+ data.tar.gz: a7f6e6894ee51a0195f4555e482e29752188805c0c908791232bc8fa7db272f0
5
5
  SHA512:
6
- metadata.gz: 81e82e52a5d09927d3a69ecf550db4482a282faa7fee27b41fcea21d45d27c6764bd7aa45550a21f584ba11c6887b9a3e63829d7c199ca541be1757a7423352a
7
- data.tar.gz: d95654a1a20db0c69f64ba9f007dd3074011b3eb685b561bf2c2cc6d1ebe1fd8056d0e655073bf4ed2de4e772193238081238049a9f7fc77c5439a74293c6dfa
6
+ metadata.gz: be1d590702cd1e50e0995b6e8b65683c691ec0f03522ff755126645566d7f87cbd7be9c311db970900ee6b28a3931a5563cadd7cae1c9d24d3982b6bea94c3c5
7
+ data.tar.gz: 9acd2a8785b03cabf3a4902db60efdc6e5c895137759c8371ecf7577ce94f8c37daab75631ed81ea4918cfe795ebf74e23af716fcf3bfbfa3fdde4bd2b3e19d3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
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
+
9
22
  ## [0.2.0] - 2026-10-12
10
23
 
11
24
  ### Changed
@@ -59,7 +72,8 @@ Initial release of Fripa - A Ruby client for the FreeIPA JSON-RPC API.
59
72
  - Full test suite with VCR cassettes
60
73
  - Complete API documentation and usage examples
61
74
 
62
- [Unreleased]: https://github.com/renuo/fripa/compare/v0.2.0...HEAD
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
63
77
  [0.2.0]: https://github.com/renuo/fripa/compare/v0.1.4...v0.2.0
64
78
  [0.1.4]: https://github.com/renuo/fripa/compare/v0.1.3...v0.1.4
65
79
  [0.1.3]: https://github.com/renuo/fripa/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -114,26 +114,54 @@ end
114
114
 
115
115
  ### Creating a Client
116
116
 
117
- 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.
118
123
 
119
124
  ```ruby
120
- client = Fripa::Client.new(
121
- username: 'your-username',
122
- password: 'your-password'
123
- )
124
- # 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')
125
132
  ```
126
133
 
127
- 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.
128
142
 
129
143
  ```ruby
144
+ client = Fripa::Client.new(username: 'alice', password: 'her-password')
145
+
130
146
  begin
131
- client = Fripa::Client.new(username: 'admin', password: 'wrong-password')
147
+ cookie = client.login!
132
148
  rescue Fripa::AuthenticationError => e
133
149
  puts "Authentication failed: #{e.message}"
134
150
  end
135
151
  ```
136
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
+
137
165
  ### Making API Calls
138
166
 
139
167
  #### Using Resource Methods (Recommended)
@@ -228,7 +256,9 @@ client.vault.archive("my-vault", data: "secret", ipa_format: true, shared: true)
228
256
  client.vault.retrieve("my-vault", ipa_format: true, shared: true) # => "secret"
229
257
  ```
230
258
 
231
- The client fetches the KRA transport certificate once and keeps it.
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.
232
262
 
233
263
  #### Batch Calls
234
264
 
@@ -30,13 +30,13 @@ module Fripa
30
30
  "#<#{self.class.name} username=#{client.username.inspect}>"
31
31
  end
32
32
 
33
- private
34
-
35
33
  def validate_credentials!
36
34
  raise ArgumentError, "Username cannot be blank" if client.username.to_s.strip.empty?
37
35
  raise ArgumentError, "Password cannot be blank" if client.password.to_s.strip.empty?
38
36
  end
39
37
 
38
+ private
39
+
40
40
  def perform_login
41
41
  client.connection.post(LOGIN_PATH) do |request|
42
42
  request.headers["Content-Type"] = CONTENT_TYPE
data/lib/fripa/client.rb CHANGED
@@ -12,11 +12,18 @@ module Fripa
12
12
  attr_reader :config, :username, :password
13
13
  attr_accessor :session_cookie
14
14
 
15
- 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)
16
21
  @config = config || Fripa.config
17
- @username = username
18
- @password = password
19
- @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
20
27
  end
21
28
 
22
29
  def users
@@ -35,7 +42,20 @@ module Fripa
35
42
  @authenticator ||= Authenticator.new(self)
36
43
  end
37
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
+
38
57
  def call(method, args = [], options = {})
58
+ login! unless logged_in?
39
59
  response = perform_request(method, args, options)
40
60
  response = reauthenticate_and_retry(method, args, options) if response.status == UNAUTHORIZED
41
61
 
@@ -73,7 +93,9 @@ module Fripa
73
93
  private
74
94
 
75
95
  def reauthenticate_and_retry(method, args, options)
76
- self.session_cookie = authenticator.login!
96
+ raise AuthenticationError, "Session expired and no password to log in again" if password.to_s.empty?
97
+
98
+ login!
77
99
  perform_request(method, args, options)
78
100
  end
79
101
 
@@ -10,10 +10,13 @@ module Fripa
10
10
  SCHEMES = %w[http https].freeze
11
11
 
12
12
  attr_accessor :host, :port, :scheme, :verify_ssl, :api_version, :timeout, :open_timeout, :adapter,
13
- :raise_on_partial_failure
13
+ :raise_on_partial_failure, :username, :password
14
14
 
15
+ # username and password are only used by Fripa.client and by
16
+ # Fripa::Client.new without a username.
15
17
  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)
18
+ timeout: nil, open_timeout: nil, adapter: nil, raise_on_partial_failure: false,
19
+ username: nil, password: nil)
17
20
  @host = host
18
21
  @port = port
19
22
  @scheme = scheme
@@ -23,6 +26,19 @@ module Fripa
23
26
  @open_timeout = open_timeout
24
27
  @adapter = adapter
25
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}>"
26
42
  end
27
43
 
28
44
  def base_url
@@ -50,11 +66,31 @@ module Fripa
50
66
  end
51
67
  end
52
68
 
69
+ @client_lock = Mutex.new
70
+
53
71
  class << self
54
72
  def config
55
73
  @config ||= Configuration.new
56
74
  end
57
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
+
58
94
  def config=(value)
59
95
  @config = case value
60
96
  when Hash
@@ -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)
@@ -21,8 +43,10 @@ module Fripa
21
43
  validate_identifier!(cn, "cn")
22
44
  data = VaultTransport.to_ipa_format(data) if ipa_format
23
45
  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))
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
26
50
  end
27
51
 
28
52
  # ipa_format: true reads data stored by `ipa vault-archive` (or archive with ipa_format: true).
@@ -53,10 +77,17 @@ module Fripa
53
77
 
54
78
  private
55
79
 
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
80
  def transport_cert_der
59
- @transport_cert_der ||= fetch_transport_cert
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
60
91
  end
61
92
 
62
93
  def fetch_transport_cert
@@ -67,10 +98,12 @@ module Fripa
67
98
  def call_retrieve(cn, session_key, **scope)
68
99
  encrypted_key = VaultTransport.encrypt_session_key(session_key, transport_cert_der)
69
100
 
70
- call("vault_retrieve_internal", [cn], {
71
- session_key: { "__base64__" => Base64.strict_encode64(encrypted_key) },
72
- wrapping_algo: VaultTransport::WRAPPING_ALGO
73
- }.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
74
107
  end
75
108
 
76
109
  def binary_encode(hash)
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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/sig/fripa.rbs CHANGED
@@ -6,6 +6,8 @@ module Fripa
6
6
  def self.config: () -> Configuration
7
7
  def self.config=: (Configuration | Hash[Symbol, untyped] value) -> Configuration
8
8
  def self.configure: () { (Configuration) -> void } -> void
9
+ def self.client: () -> Client
10
+ def self.reset_client!: () -> void
9
11
 
10
12
  class Configuration
11
13
  DEFAULT_API_VERSION: String
@@ -20,10 +22,13 @@ module Fripa
20
22
  attr_accessor open_timeout: Numeric?
21
23
  attr_accessor adapter: untyped
22
24
  attr_accessor raise_on_partial_failure: bool
25
+ attr_accessor username: String?
26
+ attr_accessor password: String?
23
27
 
24
28
  def initialize: (?host: String?, ?port: Integer?, ?scheme: String, ?verify_ssl: bool, ?api_version: String,
25
29
  ?timeout: Numeric?, ?open_timeout: Numeric?, ?adapter: untyped,
26
- ?raise_on_partial_failure: bool) -> void
30
+ ?raise_on_partial_failure: bool, ?username: String?, ?password: String?) -> void
31
+ def options: () -> Hash[Symbol, untyped]
27
32
  def base_url: () -> URI::Generic
28
33
  def login_url: () -> URI::Generic
29
34
  def api_url: () -> URI::Generic
@@ -61,11 +66,13 @@ module Fripa
61
66
  UNAUTHORIZED: Integer
62
67
 
63
68
  attr_reader config: Configuration
64
- attr_reader username: String
65
- attr_reader password: String
69
+ attr_reader username: String?
70
+ attr_reader password: String?
66
71
  attr_accessor session_cookie: String?
67
72
 
68
- def initialize: (username: String, password: String, ?config: Configuration?) -> void
73
+ def initialize: (?username: String?, ?password: String?, ?config: Configuration?, ?session_cookie: String?) -> void
74
+ def login!: () -> String?
75
+ def logged_in?: () -> bool
69
76
  def users: () -> Resources::User
70
77
  def groups: () -> Resources::Group
71
78
  def vault: () -> Resources::Vault
@@ -85,6 +92,7 @@ module Fripa
85
92
 
86
93
  def initialize: (Client client) -> void
87
94
  def login!: () -> String?
95
+ def validate_credentials!: () -> void
88
96
  end
89
97
 
90
98
  module VaultTransport
@@ -137,6 +145,12 @@ module Fripa
137
145
  end
138
146
 
139
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
+
140
154
  def show: (String cn, **untyped scope) -> json_hash
141
155
  def add: (String cn, **untyped scope) -> json_hash
142
156
  def archive: (String cn, data: String, ?ipa_format: bool, **untyped scope) -> json_hash
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fripa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo