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 +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +39 -9
- data/lib/fripa/authenticator.rb +2 -2
- data/lib/fripa/client.rb +27 -5
- data/lib/fripa/configuration.rb +38 -2
- data/lib/fripa/resources/vault.rb +42 -9
- data/lib/fripa/version.rb +1 -1
- data/sig/fripa.rbs +18 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ac5a037cb023bb2f087a6d3f6482c4ca8fa8a1e7f7b70d7a01d88e8e7e62db8
|
|
4
|
+
data.tar.gz: a7f6e6894ee51a0195f4555e482e29752188805c0c908791232bc8fa7db272f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/fripa/authenticator.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
data/lib/fripa/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
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
|
|
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
|