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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +126 -9
- data/lib/fripa/authenticator.rb +25 -11
- data/lib/fripa/client.rb +77 -20
- data/lib/fripa/configuration.rb +53 -4
- data/lib/fripa/errors.rb +23 -0
- data/lib/fripa/resources/user.rb +5 -3
- data/lib/fripa/resources/vault.rb +58 -15
- data/lib/fripa/vault_transport.rb +11 -5
- data/lib/fripa/version.rb +1 -1
- data/sig/fripa.rbs +159 -1
- metadata +12 -14
- data/.env.example +0 -4
- data/.simplecov +0 -7
- data/CODE_OF_CONDUCT.md +0 -132
- data/CONTRIBUTING.md +0 -96
- data/Rakefile +0 -17
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
|
@@ -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
|
+
[](https://rubygems.org/gems/fripa)
|
|
4
|
+
[](https://github.com/renuo/fripa/actions/workflows/main.yml)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](https://rubydoc.info/gems/fripa)
|
|
7
|
+
[](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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/fripa/authenticator.rb
CHANGED
|
@@ -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
|
-
|
|
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"] =
|
|
37
|
-
request.body =
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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"] =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
raise
|
|
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
|
|
82
|
-
|
|
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
|
data/lib/fripa/configuration.rb
CHANGED
|
@@ -6,13 +6,39 @@ require "uri"
|
|
|
6
6
|
module Fripa
|
|
7
7
|
# Configuration for Fripa
|
|
8
8
|
class Configuration
|
|
9
|
-
|
|
9
|
+
DEFAULT_API_VERSION = "2.251"
|
|
10
|
+
SCHEMES = %w[http https].freeze
|
|
10
11
|
|
|
11
|
-
|
|
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.
|
|
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 =
|
|
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
|
data/lib/fripa/resources/user.rb
CHANGED
|
@@ -32,11 +32,13 @@ module Fripa
|
|
|
32
32
|
call("user_del", [uid])
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
data = VaultTransport.to_ipa_format(data) if ipa_format
|
|
21
45
|
payload = VaultTransport.wrap(data, transport_cert_der)
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
data/sig/fripa.rbs
CHANGED
|
@@ -1,4 +1,162 @@
|
|
|
1
1
|
module Fripa
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Costanzo
|
|
8
|
-
bindir:
|
|
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
|
|
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.
|
|
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
data/.simplecov
DELETED
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]
|