hanami-settings-stores 0.0.1 → 0.1.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 +4 -2
- data/lib/hanami/settings/bitwarden_store.rb +132 -0
- data/lib/hanami/settings/composite_store.rb +4 -3
- data/lib/hanami/settings/one_password_store.rb +134 -0
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c8228cf550f63d29462cb128f2949d9fa9a744aac843fbac90a811a5d550ec4
|
|
4
|
+
data.tar.gz: f4abd1d91b57a4946712d602f1f37d1274d5ac89dc2e2e18eeb5f756877e4875
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6986d005390d907eadd3342e78da83016f495c91c48dbd7c53ecdb84d0aa3979ed459d2f5307445ae2e358fdae9db1ffa8cda6ddc02813cd199c61f39a81440f
|
|
7
|
+
data.tar.gz: bf07691cc6721383c34c095f4c8122ee0fc6724e970c2084db25cea20e3fa93d579f751d19a81bfbd299642e28cb4d8b2fa9fc7cf849c0fd6db6ff716b795c6e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## v0.1.0 - 2026-03-17
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Initial release.
|
|
13
|
+
|
|
14
|
+
[Unreleased]: https://github.com/aaronmallen/hanami-settings-stores/compare/v0.1.0...HEAD
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Hanami
|
|
7
|
+
class Settings
|
|
8
|
+
# A settings store backed by Bitwarden.
|
|
9
|
+
#
|
|
10
|
+
# Uses mappings defined via {BitwardenStore::Mixin#bw_setting} on your Settings class to
|
|
11
|
+
# resolve setting values from Bitwarden items. Requires the `bw` CLI to be installed and
|
|
12
|
+
# authenticated.
|
|
13
|
+
#
|
|
14
|
+
# Item fields are cached in memory after the first fetch for each item.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # config/settings.rb
|
|
18
|
+
# class Settings < Hanami::Settings
|
|
19
|
+
# bw_setting :database_url, item: "MyApp", constructor: Types::String
|
|
20
|
+
# bw_setting :api_key, item: "ExternalService", key: :secret_key, constructor: Types::String
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # config/app.rb
|
|
24
|
+
# config.settings_store = Hanami::Settings::CompositeStore.new(
|
|
25
|
+
# Hanami::Settings::EnvStore.new,
|
|
26
|
+
# Hanami::Settings::BitwardenStore.new
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
# @since 0.1.0
|
|
31
|
+
class BitwardenStore
|
|
32
|
+
# DSL extension for {Hanami::Settings} that adds the {#bw_setting} method.
|
|
33
|
+
#
|
|
34
|
+
# Automatically included when this file is required.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @since 0.1.0
|
|
38
|
+
module Mixin
|
|
39
|
+
# @api private
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Returns the Bitwarden mappings for this settings class.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash{Symbol => Hash}]
|
|
44
|
+
# @api private
|
|
45
|
+
def bw_mappings
|
|
46
|
+
@bw_mappings ||= {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Defines a setting backed by a Bitwarden item field.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] the setting name
|
|
52
|
+
# @param item [String] the Bitwarden item name or ID
|
|
53
|
+
# @param key [Symbol, String] the field name (defaults to the setting name)
|
|
54
|
+
# @param options [Hash] additional options passed to `Dry::Configurable#setting`
|
|
55
|
+
#
|
|
56
|
+
# @api public
|
|
57
|
+
# @since 0.1.0
|
|
58
|
+
def bw_setting(name, item:, key: name, **options)
|
|
59
|
+
bw_mappings[name] = {item: item.to_s, key: key.to_s}
|
|
60
|
+
setting(name, **options)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @api private
|
|
65
|
+
def self.prepended(base)
|
|
66
|
+
base.extend(ClassMethods)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def initialize(store = EMPTY_STORE)
|
|
71
|
+
store.bw_mappings = self.class.bw_mappings if store.respond_to?(:bw_mappings=)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize
|
|
77
|
+
@bw_mappings = {}
|
|
78
|
+
@cache = {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Sets the Bitwarden mappings from the settings class.
|
|
82
|
+
#
|
|
83
|
+
# @param mappings [Hash{Symbol => Hash}]
|
|
84
|
+
# @api private
|
|
85
|
+
attr_writer :bw_mappings
|
|
86
|
+
|
|
87
|
+
# Fetches a setting value using its Bitwarden mapping.
|
|
88
|
+
#
|
|
89
|
+
# @param name [String, Symbol] the setting name
|
|
90
|
+
# @param args [Array] optional default value
|
|
91
|
+
# @yield [name] optional block for default value
|
|
92
|
+
# @return [String] the field value
|
|
93
|
+
# @raise [KeyError] if the field is not found and no default is given
|
|
94
|
+
#
|
|
95
|
+
# @api public
|
|
96
|
+
# @since 0.1.0
|
|
97
|
+
def fetch(name, *args, &block)
|
|
98
|
+
mapping = @bw_mappings[name.to_sym]
|
|
99
|
+
|
|
100
|
+
unless mapping
|
|
101
|
+
return args.first unless args.empty?
|
|
102
|
+
return yield(name) if block
|
|
103
|
+
|
|
104
|
+
raise KeyError, "key not found: #{name.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
item_fields(mapping[:item]).fetch(mapping[:key], *args, &block)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# @api private
|
|
113
|
+
def item_fields(item_name)
|
|
114
|
+
@cache[item_name] ||= load_item_fields(item_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @api private
|
|
118
|
+
def load_item_fields(item_name)
|
|
119
|
+
stdout, stderr, status = Open3.capture3("bw", "get", "item", item_name)
|
|
120
|
+
raise "Bitwarden CLI error: #{stderr.strip}" unless status.success?
|
|
121
|
+
|
|
122
|
+
item = JSON.parse(stdout)
|
|
123
|
+
item.fetch("fields", []).each_with_object({}) do |field, hash|
|
|
124
|
+
name = field["name"]
|
|
125
|
+
hash[name] = field["value"] unless name.nil? || name.empty?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Hanami::Settings.prepend(Hanami::Settings::BitwardenStore::Mixin)
|
|
@@ -17,6 +17,10 @@ module Hanami
|
|
|
17
17
|
# @api public
|
|
18
18
|
# @since 0.1.0
|
|
19
19
|
class CompositeStore
|
|
20
|
+
# @api private
|
|
21
|
+
NOT_SET = Object.new.freeze
|
|
22
|
+
private_constant :NOT_SET
|
|
23
|
+
|
|
20
24
|
# @param stores [Array<#fetch>] ordered list of stores to query
|
|
21
25
|
def initialize(*stores)
|
|
22
26
|
@stores = stores
|
|
@@ -43,9 +47,6 @@ module Hanami
|
|
|
43
47
|
|
|
44
48
|
raise KeyError, "key not found: #{name.inspect}"
|
|
45
49
|
end
|
|
46
|
-
|
|
47
|
-
NOT_SET = Object.new.freeze
|
|
48
|
-
private_constant :NOT_SET
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Hanami
|
|
7
|
+
class Settings
|
|
8
|
+
# A settings store backed by 1Password.
|
|
9
|
+
#
|
|
10
|
+
# Uses mappings defined via {OnePasswordStore::Mixin#op_setting} on your Settings class to
|
|
11
|
+
# resolve setting values from 1Password items. Requires the `op` CLI to be installed and
|
|
12
|
+
# authenticated.
|
|
13
|
+
#
|
|
14
|
+
# Item fields are cached in memory after the first fetch for each item.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # config/settings.rb
|
|
18
|
+
# class Settings < Hanami::Settings
|
|
19
|
+
# op_setting :database_url, item: "MyApp", constructor: Types::String
|
|
20
|
+
# op_setting :api_key, item: "ExternalService", key: :secret_key, constructor: Types::String
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # config/app.rb
|
|
24
|
+
# config.settings_store = Hanami::Settings::CompositeStore.new(
|
|
25
|
+
# Hanami::Settings::EnvStore.new,
|
|
26
|
+
# Hanami::Settings::OnePasswordStore.new(vault: "Production")
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
# @since 0.1.0
|
|
31
|
+
class OnePasswordStore
|
|
32
|
+
# DSL extension for {Hanami::Settings} that adds the {#op_setting} method.
|
|
33
|
+
#
|
|
34
|
+
# Automatically included when this file is required.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @since 0.1.0
|
|
38
|
+
module Mixin
|
|
39
|
+
# @api private
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Returns the 1Password mappings for this settings class.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash{Symbol => Hash}]
|
|
44
|
+
# @api private
|
|
45
|
+
def op_mappings
|
|
46
|
+
@op_mappings ||= {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Defines a setting backed by a 1Password item field.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] the setting name
|
|
52
|
+
# @param item [String] the 1Password item name or ID
|
|
53
|
+
# @param key [Symbol, String] the field label (defaults to the setting name)
|
|
54
|
+
# @param options [Hash] additional options passed to `Dry::Configurable#setting`
|
|
55
|
+
#
|
|
56
|
+
# @api public
|
|
57
|
+
# @since 0.1.0
|
|
58
|
+
def op_setting(name, item:, key: name, **options)
|
|
59
|
+
op_mappings[name] = {item: item.to_s, key: key.to_s}
|
|
60
|
+
setting(name, **options)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @api private
|
|
65
|
+
def self.prepended(base)
|
|
66
|
+
base.extend(ClassMethods)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def initialize(store = EMPTY_STORE)
|
|
71
|
+
store.op_mappings = self.class.op_mappings if store.respond_to?(:op_mappings=)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param vault [String] the 1Password vault name or ID
|
|
77
|
+
def initialize(vault:)
|
|
78
|
+
@vault = vault
|
|
79
|
+
@op_mappings = {}
|
|
80
|
+
@cache = {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Sets the 1Password mappings from the settings class.
|
|
84
|
+
#
|
|
85
|
+
# @param mappings [Hash{Symbol => Hash}]
|
|
86
|
+
# @api private
|
|
87
|
+
attr_writer :op_mappings
|
|
88
|
+
|
|
89
|
+
# Fetches a setting value using its 1Password mapping.
|
|
90
|
+
#
|
|
91
|
+
# @param name [String, Symbol] the setting name
|
|
92
|
+
# @param args [Array] optional default value
|
|
93
|
+
# @yield [name] optional block for default value
|
|
94
|
+
# @return [String] the field value
|
|
95
|
+
# @raise [KeyError] if the field is not found and no default is given
|
|
96
|
+
#
|
|
97
|
+
# @api public
|
|
98
|
+
# @since 0.1.0
|
|
99
|
+
def fetch(name, *args, &block)
|
|
100
|
+
mapping = @op_mappings[name.to_sym]
|
|
101
|
+
|
|
102
|
+
unless mapping
|
|
103
|
+
return args.first unless args.empty?
|
|
104
|
+
return yield(name) if block
|
|
105
|
+
|
|
106
|
+
raise KeyError, "key not found: #{name.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
item_fields(mapping[:item]).fetch(mapping[:key], *args, &block)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# @api private
|
|
115
|
+
def item_fields(item_name)
|
|
116
|
+
@cache[item_name] ||= load_item_fields(item_name)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def load_item_fields(item_name)
|
|
121
|
+
stdout, stderr, status = Open3.capture3("op", "item", "get", item_name, "--vault", @vault, "--format", "json")
|
|
122
|
+
raise "1Password CLI error: #{stderr.strip}" unless status.success?
|
|
123
|
+
|
|
124
|
+
item = JSON.parse(stdout)
|
|
125
|
+
item.fetch("fields", []).each_with_object({}) do |field, hash|
|
|
126
|
+
label = field["label"]
|
|
127
|
+
hash[label] = field["value"] unless label.nil? || label.empty?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Hanami::Settings.prepend(Hanami::Settings::OnePasswordStore::Mixin)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hanami-settings-stores
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aaron Allen
|
|
@@ -16,28 +16,28 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '2
|
|
19
|
+
version: '2'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '2
|
|
26
|
+
version: '2'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: zeitwerk
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '2
|
|
33
|
+
version: '2'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '2
|
|
40
|
+
version: '2'
|
|
41
41
|
description: Pluggable settings stores that fetch secrets from password managers like
|
|
42
42
|
1Password and Bitwarden for use with Hanami::Settings.
|
|
43
43
|
email:
|
|
@@ -49,7 +49,9 @@ files:
|
|
|
49
49
|
- CHANGELOG.md
|
|
50
50
|
- LICENSE
|
|
51
51
|
- lib/hanami-settings-stores.rb
|
|
52
|
+
- lib/hanami/settings/bitwarden_store.rb
|
|
52
53
|
- lib/hanami/settings/composite_store.rb
|
|
54
|
+
- lib/hanami/settings/one_password_store.rb
|
|
53
55
|
homepage: https://github.com/aaronmallen/hanami-settings-stores
|
|
54
56
|
licenses:
|
|
55
57
|
- MIT
|