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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +961 -0
  3. data/bin/funl +0 -0
  4. data/bin/vkit +30 -0
  5. data/lib/vkit/cli/api/client.rb +115 -0
  6. data/lib/vkit/cli/base_cli.rb +173 -0
  7. data/lib/vkit/cli/commands/approval_command.rb +94 -0
  8. data/lib/vkit/cli/commands/base_command.rb +42 -0
  9. data/lib/vkit/cli/commands/datasource_command.rb +93 -0
  10. data/lib/vkit/cli/commands/fetch_command.rb +48 -0
  11. data/lib/vkit/cli/commands/login_command.rb +136 -0
  12. data/lib/vkit/cli/commands/logout_command.rb +12 -0
  13. data/lib/vkit/cli/commands/policy_bundle_command.rb +62 -0
  14. data/lib/vkit/cli/commands/policy_deploy_command.rb +32 -0
  15. data/lib/vkit/cli/commands/policy_validate_command.rb +31 -0
  16. data/lib/vkit/cli/commands/request_command.rb +102 -0
  17. data/lib/vkit/cli/commands/requests_list_command.rb +47 -0
  18. data/lib/vkit/cli/commands/scan_command.rb +47 -0
  19. data/lib/vkit/cli/commands/whoami_command.rb +14 -0
  20. data/lib/vkit/cli/commands.rb +5 -0
  21. data/lib/vkit/cli/errors.rb +6 -0
  22. data/lib/vkit/cli/policy_bundle_validator.rb +71 -0
  23. data/lib/vkit/cli/requests_cli.rb +23 -0
  24. data/lib/vkit/cli.rb +4 -0
  25. data/lib/vkit/core/auth_client.rb +104 -0
  26. data/lib/vkit/core/credential_resolver.rb +37 -0
  27. data/lib/vkit/core/credential_store.rb +186 -0
  28. data/lib/vkit/core/table_formatter.rb +36 -0
  29. data/lib/vkit/policy/bundle_compiler.rb +154 -0
  30. data/lib/vkit/policy/schema/policy_bundle.schema.json +296 -0
  31. data/lib/vkit/policy/validate_bundle.rb +37 -0
  32. data/lib/vkit/utils/banner.rb +0 -0
  33. data/lib/vkit/utils/config_loader.rb +0 -0
  34. data/lib/vkit/utils/logger.rb +0 -0
  35. data/lib/vkit.rb +3 -0
  36. 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