vaultkit 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 +7 -0
- data/README.md +961 -0
- data/bin/funl +0 -0
- data/bin/vkit +30 -0
- data/lib/vkit/cli/api/client.rb +115 -0
- data/lib/vkit/cli/base_cli.rb +173 -0
- data/lib/vkit/cli/commands/approval_command.rb +94 -0
- data/lib/vkit/cli/commands/base_command.rb +42 -0
- data/lib/vkit/cli/commands/datasource_command.rb +93 -0
- data/lib/vkit/cli/commands/fetch_command.rb +48 -0
- data/lib/vkit/cli/commands/login_command.rb +136 -0
- data/lib/vkit/cli/commands/logout_command.rb +12 -0
- data/lib/vkit/cli/commands/policy_bundle_command.rb +62 -0
- data/lib/vkit/cli/commands/policy_deploy_command.rb +32 -0
- data/lib/vkit/cli/commands/policy_validate_command.rb +31 -0
- data/lib/vkit/cli/commands/request_command.rb +102 -0
- data/lib/vkit/cli/commands/requests_list_command.rb +47 -0
- data/lib/vkit/cli/commands/scan_command.rb +47 -0
- data/lib/vkit/cli/commands/whoami_command.rb +14 -0
- data/lib/vkit/cli/commands.rb +5 -0
- data/lib/vkit/cli/errors.rb +6 -0
- data/lib/vkit/cli/policy_bundle_validator.rb +71 -0
- data/lib/vkit/cli/requests_cli.rb +23 -0
- data/lib/vkit/cli.rb +4 -0
- data/lib/vkit/core/auth_client.rb +104 -0
- data/lib/vkit/core/credential_resolver.rb +37 -0
- data/lib/vkit/core/credential_store.rb +186 -0
- data/lib/vkit/core/table_formatter.rb +36 -0
- data/lib/vkit/policy/bundle_compiler.rb +154 -0
- data/lib/vkit/policy/schema/policy_bundle.schema.json +296 -0
- data/lib/vkit/policy/validate_bundle.rb +37 -0
- data/lib/vkit/utils/banner.rb +0 -0
- data/lib/vkit/utils/config_loader.rb +0 -0
- data/lib/vkit/utils/logger.rb +0 -0
- data/lib/vkit.rb +3 -0
- metadata +94 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Vkit
|
|
7
|
+
module Core
|
|
8
|
+
class AuthClient
|
|
9
|
+
DEFAULT_BASE_URL = ENV["VKIT_ENDPOINT"] || "http://localhost:3000"
|
|
10
|
+
|
|
11
|
+
def initialize(base_url: DEFAULT_BASE_URL)
|
|
12
|
+
@base_url = base_url.chomp("/")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def discover
|
|
16
|
+
uri = uri_for("/auth/cli")
|
|
17
|
+
res = http_get(uri)
|
|
18
|
+
parse_json(res)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start_cli_login
|
|
22
|
+
uri = uri_for("/auth/cli/start")
|
|
23
|
+
req = Net::HTTP::Post.new(uri)
|
|
24
|
+
req["Content-Type"] = "application/json"
|
|
25
|
+
req.body = "{}"
|
|
26
|
+
|
|
27
|
+
res = http_request(uri, req)
|
|
28
|
+
parse_json(res)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def poll_cli_login(poll_token)
|
|
32
|
+
uri = uri_for("/auth/cli/poll?token=#{poll_token}")
|
|
33
|
+
req = Net::HTTP::Get.new(uri)
|
|
34
|
+
|
|
35
|
+
http_request(uri, req, allow_non_200: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def password_login(email:, password:)
|
|
39
|
+
uri = uri_for("/api/users/sign_in")
|
|
40
|
+
req = Net::HTTP::Post.new(uri)
|
|
41
|
+
req["Content-Type"] = "application/json"
|
|
42
|
+
req.body = JSON.dump(
|
|
43
|
+
user: {
|
|
44
|
+
email: email,
|
|
45
|
+
password: password
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
res = http_request(uri, req)
|
|
50
|
+
body = parse_json(res)
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
token: body["token"],
|
|
54
|
+
user: body["user"]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def whoami(token)
|
|
59
|
+
uri = uri_for("/auth/whoami")
|
|
60
|
+
req = Net::HTTP::Get.new(uri)
|
|
61
|
+
req["Authorization"] = "Bearer #{token}"
|
|
62
|
+
|
|
63
|
+
res = http_request(uri, req)
|
|
64
|
+
body = parse_json(res)
|
|
65
|
+
|
|
66
|
+
body["user"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def uri_for(path)
|
|
72
|
+
URI("#{@base_url}#{path}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def http_get(uri)
|
|
76
|
+
req = Net::HTTP::Get.new(uri)
|
|
77
|
+
http_request(uri, req)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def http_request(uri, req, allow_non_200: false)
|
|
81
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
|
+
http.use_ssl = uri.scheme == "https"
|
|
83
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
84
|
+
|
|
85
|
+
res = http.request(req)
|
|
86
|
+
|
|
87
|
+
return res if allow_non_200
|
|
88
|
+
|
|
89
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
90
|
+
raise "HTTP #{res.code}: #{res.body}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
res
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_json(res)
|
|
97
|
+
JSON.parse(res.body)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
raise "Invalid JSON response from server"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require_relative "providers/vaultkit_provider"
|
|
2
|
+
|
|
3
|
+
module Vkit
|
|
4
|
+
module Core
|
|
5
|
+
class CredentialResolver
|
|
6
|
+
def initialize(datasource_store: DatasourceStore.new)
|
|
7
|
+
@store = datasource_store
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Main entry point
|
|
11
|
+
def resolve(datasource_id)
|
|
12
|
+
ds = @store.fetch(datasource_id)
|
|
13
|
+
raise "Unknown datasource: #{datasource_id}" unless ds
|
|
14
|
+
|
|
15
|
+
provider = provider_for(ds[:provider] || "vaultkit")
|
|
16
|
+
provider.resolve(ds)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def provider_for(name)
|
|
22
|
+
case name
|
|
23
|
+
when "vaultkit"
|
|
24
|
+
Providers::VaultKitProvider.new
|
|
25
|
+
when "aws"
|
|
26
|
+
Providers::AwsSecretsProvider.new
|
|
27
|
+
when "gcp"
|
|
28
|
+
Providers::GcpSecretManagerProvider.new
|
|
29
|
+
when "azure"
|
|
30
|
+
Providers::AzureKeyVaultProvider.new
|
|
31
|
+
else
|
|
32
|
+
raise "Unknown credential provider: #{name}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Vkit
|
|
7
|
+
module Core
|
|
8
|
+
class CredentialStore
|
|
9
|
+
SERVICE = "vkit"
|
|
10
|
+
ACCOUNT = "credentials"
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@os = RbConfig::CONFIG["host_os"]
|
|
14
|
+
@fallback_path = File.join(Dir.home, ".vkit", "credentials.json")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def save(endpoint:, token:, user:)
|
|
18
|
+
payload = {
|
|
19
|
+
"endpoint" => endpoint,
|
|
20
|
+
"token" => token,
|
|
21
|
+
"user" => user
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
case
|
|
25
|
+
when mac?
|
|
26
|
+
mac_keychain_store(payload)
|
|
27
|
+
when linux? && secret_tool_available?
|
|
28
|
+
linux_secret_service_store(payload)
|
|
29
|
+
else
|
|
30
|
+
file_store(payload)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def endpoint
|
|
37
|
+
load_payload&.dig("endpoint")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def token
|
|
41
|
+
load_payload&.dig("token")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def user
|
|
45
|
+
load_payload&.dig("user")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def logged_in?
|
|
49
|
+
!!(endpoint && token)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear!
|
|
53
|
+
case
|
|
54
|
+
when mac?
|
|
55
|
+
mac_keychain_delete
|
|
56
|
+
when linux? && secret_tool_available?
|
|
57
|
+
linux_secret_service_delete
|
|
58
|
+
else
|
|
59
|
+
file_delete
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear_token!
|
|
64
|
+
payload = load_payload
|
|
65
|
+
return unless payload
|
|
66
|
+
|
|
67
|
+
payload.delete("token")
|
|
68
|
+
|
|
69
|
+
case
|
|
70
|
+
when mac?
|
|
71
|
+
mac_keychain_store(payload)
|
|
72
|
+
when linux? && secret_tool_available?
|
|
73
|
+
linux_secret_service_store(payload)
|
|
74
|
+
else
|
|
75
|
+
file_store(payload)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_payload
|
|
80
|
+
case
|
|
81
|
+
when mac?
|
|
82
|
+
mac_keychain_load
|
|
83
|
+
when linux? && secret_tool_available?
|
|
84
|
+
linux_secret_service_load
|
|
85
|
+
else
|
|
86
|
+
file_load
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def mac?
|
|
91
|
+
@os =~ /darwin/
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def linux?
|
|
95
|
+
@os =~ /linux/
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def secret_tool_available?
|
|
99
|
+
system("which secret-tool > /dev/null 2>&1")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mac_keychain_store(payload)
|
|
103
|
+
mac_keychain_delete
|
|
104
|
+
system(
|
|
105
|
+
"security", "add-generic-password",
|
|
106
|
+
"-a", ACCOUNT,
|
|
107
|
+
"-s", SERVICE,
|
|
108
|
+
"-w", payload.to_json,
|
|
109
|
+
"-U"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def mac_keychain_load
|
|
114
|
+
stdout, _stderr, status =
|
|
115
|
+
Open3.capture3(
|
|
116
|
+
"security", "find-generic-password",
|
|
117
|
+
"-a", ACCOUNT,
|
|
118
|
+
"-s", SERVICE,
|
|
119
|
+
"-w"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return nil unless status.success?
|
|
123
|
+
JSON.parse(stdout)
|
|
124
|
+
rescue
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def mac_keychain_delete
|
|
129
|
+
system(
|
|
130
|
+
"security", "delete-generic-password",
|
|
131
|
+
"-a", ACCOUNT,
|
|
132
|
+
"-s", SERVICE,
|
|
133
|
+
out: File::NULL,
|
|
134
|
+
err: File::NULL
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def linux_secret_service_store(payload)
|
|
139
|
+
Open3.capture3(
|
|
140
|
+
"secret-tool",
|
|
141
|
+
"store",
|
|
142
|
+
"--label=VaultKit Credentials",
|
|
143
|
+
"service", SERVICE,
|
|
144
|
+
"account", ACCOUNT,
|
|
145
|
+
stdin_data: payload.to_json
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def linux_secret_service_load
|
|
150
|
+
stdout, _stderr, status =
|
|
151
|
+
Open3.capture3(
|
|
152
|
+
"secret-tool",
|
|
153
|
+
"lookup",
|
|
154
|
+
"service", SERVICE,
|
|
155
|
+
"account", ACCOUNT
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return nil unless status.success?
|
|
159
|
+
JSON.parse(stdout)
|
|
160
|
+
rescue
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def linux_secret_service_delete
|
|
165
|
+
linux_secret_service_store({})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def file_store(payload)
|
|
169
|
+
FileUtils.mkdir_p(File.dirname(@fallback_path))
|
|
170
|
+
File.write(@fallback_path, JSON.pretty_generate(payload))
|
|
171
|
+
File.chmod(0o600, @fallback_path)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def file_load
|
|
175
|
+
return nil unless File.exist?(@fallback_path)
|
|
176
|
+
JSON.parse(File.read(@fallback_path))
|
|
177
|
+
rescue
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def file_delete
|
|
182
|
+
FileUtils.rm_f(@fallback_path)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Vkit
|
|
2
|
+
module Core
|
|
3
|
+
class TableFormatter
|
|
4
|
+
def self.render(rows)
|
|
5
|
+
return puts "(no rows)" if rows.nil? || rows.empty?
|
|
6
|
+
|
|
7
|
+
headers = rows.first.keys
|
|
8
|
+
|
|
9
|
+
# Calculate column widths
|
|
10
|
+
col_widths = headers.map do |h|
|
|
11
|
+
[h.length, *rows.map { |r| r[h].to_s.length }].max
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Builders
|
|
15
|
+
def self.border(col_widths)
|
|
16
|
+
"+" + col_widths.map { |w| "-" * (w + 2) }.join("+") + "+"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.row(values, col_widths)
|
|
20
|
+
"|" + values.map.with_index { |v, i| " #{v.to_s.ljust(col_widths[i])} " }.join("|") + "|"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Print table
|
|
24
|
+
puts border(col_widths)
|
|
25
|
+
puts row(headers, col_widths)
|
|
26
|
+
puts border(col_widths)
|
|
27
|
+
|
|
28
|
+
rows.each do |row|
|
|
29
|
+
puts row(row.values_at(*headers), col_widths)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
puts border(col_widths)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Vkit
|
|
7
|
+
module Policy
|
|
8
|
+
class BundleCompiler
|
|
9
|
+
FORMAT_VERSION = "v1"
|
|
10
|
+
|
|
11
|
+
def self.compile!(org_slug:, bundle_version:, policies_dir:, registry_dir:, source: {})
|
|
12
|
+
policies = load_policies(policies_dir)
|
|
13
|
+
registry = load_registry(registry_dir)
|
|
14
|
+
|
|
15
|
+
bundle = {
|
|
16
|
+
"bundle" => {
|
|
17
|
+
"format_version" => FORMAT_VERSION,
|
|
18
|
+
"org_slug" => org_slug,
|
|
19
|
+
"bundle_version" => bundle_version,
|
|
20
|
+
"created_at" => Time.now.utc.iso8601,
|
|
21
|
+
"source" => normalize_source(source),
|
|
22
|
+
"checksum" => "" # filled below
|
|
23
|
+
},
|
|
24
|
+
"registry" => registry,
|
|
25
|
+
"policies" => normalize_policies(policies),
|
|
26
|
+
"signing" => nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
canonical = canonical_json(bundle)
|
|
30
|
+
bundle["bundle"]["checksum"] = Digest::SHA256.hexdigest(canonical)
|
|
31
|
+
|
|
32
|
+
bundle
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Loading
|
|
36
|
+
def self.load_policies(dir)
|
|
37
|
+
files = Dir[File.join(dir, "*.y{a,}ml")].sort
|
|
38
|
+
raise "No policy files found in #{dir}" if files.empty?
|
|
39
|
+
|
|
40
|
+
files.map do |f|
|
|
41
|
+
data = YAML.load_file(f)
|
|
42
|
+
raise "Policy file #{f} must be a Hash" unless data.is_a?(Hash)
|
|
43
|
+
data.merge("__file" => File.basename(f))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.load_registry(dir)
|
|
48
|
+
path = File.join(dir, "registry.yaml")
|
|
49
|
+
raise "Missing datasets/registry.yaml" unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
raw = YAML.load_file(path)
|
|
52
|
+
normalize_registry(raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalization
|
|
56
|
+
def self.normalize_source(source)
|
|
57
|
+
{ "type" => "git" }.merge(source.transform_keys(&:to_s))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.normalize_policies(policies)
|
|
61
|
+
seen = {}
|
|
62
|
+
|
|
63
|
+
normalized = policies.map do |p|
|
|
64
|
+
id = p["id"].to_s.strip
|
|
65
|
+
raise "Policy id missing in #{p["__file"]}" if id.empty?
|
|
66
|
+
raise "Duplicate policy id: #{id}" if seen[id]
|
|
67
|
+
seen[id] = true
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
"id" => id,
|
|
71
|
+
"description" => p["description"],
|
|
72
|
+
"match" => p["match"],
|
|
73
|
+
"when" => p["context"], # ADAPT authoring → runtime
|
|
74
|
+
"action" => normalize_action(p["action"]),
|
|
75
|
+
"reason" => p.dig("action", "reason"),
|
|
76
|
+
"approval" => extract_approval(p),
|
|
77
|
+
"masking" => extract_masking(p),
|
|
78
|
+
"ttl_seconds" => p.dig("action", "ttl"),
|
|
79
|
+
"priority" => p["priority"]
|
|
80
|
+
}.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
normalized.sort_by { |p| [-(p["priority"] || 0), p["id"]] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.normalize_action(action)
|
|
87
|
+
return "allow" unless action.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
return "deny" if action["deny"]
|
|
90
|
+
return "require_approval" if action["require_approval"]
|
|
91
|
+
return "mask" if action["mask"]
|
|
92
|
+
"allow"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.extract_approval(p)
|
|
96
|
+
return unless p.dig("action", "require_approval")
|
|
97
|
+
{ "approver_role" => p.dig("action", "approver_role") }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.extract_masking(p)
|
|
101
|
+
return unless p.dig("action", "mask")
|
|
102
|
+
p["masking"]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.normalize_registry(raw)
|
|
106
|
+
datasets = raw.map do |name, data|
|
|
107
|
+
{
|
|
108
|
+
"name" => name.to_s,
|
|
109
|
+
"datasource" => data["datasource"].to_s,
|
|
110
|
+
"fields" => normalize_fields(data["fields"] || {})
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
datasources =
|
|
115
|
+
datasets
|
|
116
|
+
.map { |d| d["datasource"] }
|
|
117
|
+
.uniq
|
|
118
|
+
.map { |ds| { "name" => ds, "type" => "postgres", "config" => {} } }
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"datasets" => datasets,
|
|
122
|
+
"datasources" => datasources
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.normalize_fields(fields)
|
|
127
|
+
fields.map do |name, meta|
|
|
128
|
+
{
|
|
129
|
+
"name" => name.to_s,
|
|
130
|
+
"type" => meta["type"],
|
|
131
|
+
"sensitivity" => meta["sensitivity"].to_s,
|
|
132
|
+
"tags" => [meta["category"]].compact.map(&:to_s)
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Canonicalization
|
|
138
|
+
def self.canonical_json(obj)
|
|
139
|
+
JSON.generate(sort_keys_deep(obj))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.sort_keys_deep(value)
|
|
143
|
+
case value
|
|
144
|
+
when Hash
|
|
145
|
+
value.keys.sort.each_with_object({}) { |k, h| h[k] = sort_keys_deep(value[k]) }
|
|
146
|
+
when Array
|
|
147
|
+
value.map { |v| sort_keys_deep(v) }
|
|
148
|
+
else
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|