vaultkit 1.1.6 → 1.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -0
  3. data/lib/vkit/cli/base_cli.rb +7 -1
  4. data/lib/vkit/cli/commands/agent_tokens_create_command.rb +7 -1
  5. data/lib/vkit/cli/commands/scan_command.rb +73 -36
  6. data/lib/vkit/policy/bundle_compiler.rb +27 -5
  7. data/lib/vkit/policy/match_check.rb +120 -0
  8. data/lib/vkit/policy/packs/ai_safety/metadata.yaml +2 -2
  9. data/lib/vkit/policy/packs/ai_safety/policies/01_require_approval_agents_sensitive_prod.yaml +17 -0
  10. data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml +16 -0
  11. data/lib/vkit/policy/packs/ai_safety/policies/03_require_approval_agents_unknown_prod.yaml +17 -0
  12. data/lib/vkit/policy/packs/financial_compliance/metadata.yaml +2 -2
  13. data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_financial_prod.yaml +16 -0
  14. data/lib/vkit/policy/packs/financial_compliance/policies/02_short_ttl_for_financial_grants.yaml +12 -0
  15. data/lib/vkit/policy/packs/starter/metadata.yaml +2 -2
  16. data/lib/vkit/policy/packs/starter/policies/01_deny_credentials.yaml +11 -0
  17. data/lib/vkit/policy/packs/starter/policies/02_mask_pii_by_default.yaml +4 -5
  18. data/lib/vkit/policy/packs/starter/policies/03_mask_high_sensitivity.yaml +15 -0
  19. data/lib/vkit/policy/packs/starter/policies/{03_require_approval_high_sensitivity.yaml → 04_require_approval_high_sensitivity.yaml} +4 -5
  20. data/lib/vkit/policy/packs/starter/policies/{06_default_ttl.yaml → 05_default_ttl.yaml} +3 -4
  21. data/lib/vkit/policy/packs/starter/policies/06_flag_unknown.yaml +12 -0
  22. data/lib/vkit/policy/schema/policy_bundle.schema.json +5 -0
  23. data/lib/vkit/policy/tenancy_check.rb +54 -0
  24. data/lib/vkit/version.rb +1 -1
  25. metadata +13 -13
  26. data/lib/vkit/policy/packs/ai_safety/policies/01_deny_agent_prod_without_clearance.yaml +0 -15
  27. data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_for_sensitive_in_prod.yaml +0 -16
  28. data/lib/vkit/policy/packs/ai_safety/policies/03_mask_sensitive_by_default_for_agents.yaml +0 -18
  29. data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_for_financial_prod.yaml +0 -16
  30. data/lib/vkit/policy/packs/financial_compliance/policies/02_mask_payment_tokens.yaml +0 -17
  31. data/lib/vkit/policy/packs/financial_compliance/policies/03_deny_non_admin_access_pci_in_prod.yaml +0 -15
  32. data/lib/vkit/policy/packs/financial_compliance/policies/04_short_ttl_for_financial_grants.yaml +0 -14
  33. data/lib/vkit/policy/packs/starter/policies/01_deny_sensitive_without_clearance.yaml +0 -15
  34. data/lib/vkit/policy/packs/starter/policies/04_block_cross_region.yaml +0 -14
  35. data/lib/vkit/policy/packs/starter/policies/05_limit_production_access.yaml +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42e3300980d3a6c3041304eea365fad63d8bfb2a5b56a49c32ccb89d21366ec8
4
- data.tar.gz: f353b00284834a0c3eed06f4af09da181d4bb3f622f61bbcfcc949d4014bf109
3
+ metadata.gz: 8490da7d9c28171310faf52f3a8b754dd00798d75837ec76eee4b3f3b5ce6aab
4
+ data.tar.gz: 53ce9b61c202b464b66d9fe3f4d195597a6992c20d5f707247a33da93f196e89
5
5
  SHA512:
6
- metadata.gz: fcf5ba06e5f6256f705de1d228b70c52a94fe4ce26c11d82d3e2dbab30397119ea33d511acdac0d1e383cb3e501c1286e303490f3035a2b9991cee8b04863d87
7
- data.tar.gz: 537cf82edb8000c9a0c5d273c14e1958198022a16a6918b5f5f2ac7f673b1f9df7b1b26c5daf95acbb6bc0a6f3395ff430e73cc09ee6f8934f2179336b09789a
6
+ metadata.gz: e3f239d9f906313cac41a736d2e352b8be1b429e04fe0b08e8bb24190ee4e186510dd6141306dc1834da7750a2d710e39e246fcfe52b76c1915b2c5196731c1e
7
+ data.tar.gz: 822c8f6472e7bd4372b439bde33964329cbf93be371407873bbf2725750122633ba145d3d1635a84aebe0454a97d389f13a2014e1999cc2da96dfe60dc3b0a68
data/README.md CHANGED
@@ -701,6 +701,25 @@ vkit scan production_pg
701
701
  # Summary: 2 new datasets, 3 new fields, 1 modified field
702
702
  ```
703
703
 
704
+ **Classifier Suggestions:**
705
+
706
+ A rescan keeps the labels people reviewed (from a deployed bundle or a manual edit). When the classifier disagrees with one of them, the scan lists it as a suggestion under its dataset, marked `?`. Suggestions are not changes: they don't count toward the changed total, and applying the scan leaves them alone.
707
+
708
+ ```bash
709
+ vkit scan production_pg
710
+
711
+ # Output:
712
+ # settings ✓
713
+ # ? key_value low/internal → high/credential (suggested)
714
+ #
715
+ # No changes detected
716
+ #
717
+ # ? = classifier suggestion, not applied. To accept one, edit
718
+ # datasets/registry.yaml and redeploy the bundle.
719
+ ```
720
+
721
+ To accept a suggestion, change the field's sensitivity and tags in `datasets/registry.yaml`, then build and deploy the bundle. Control planes that predate suggestions don't send them, and the output is unchanged.
722
+
704
723
  **Apply Changes:**
705
724
  ```bash
706
725
  vkit scan production_pg --mode apply
@@ -331,11 +331,17 @@ module Vkit
331
331
  option :name, required: true, desc: "Human-readable name (e.g. billing-bot)"
332
332
  option :expires_in, type: :string, desc: "Token lifetime (e.g. 1h, 24h, 30d)"
333
333
  option :role, type: :string, default: "agent", desc: "Role assigned to this token"
334
+ option :tenant, type: :string, desc: "Tenant this token acts for on tenant-scoped datasets"
335
+ option :clearance, type: :string, desc: "Clearance policies see: low, medium or high"
336
+ option :region, type: :string, desc: "Region policies see: EU, US, CA or APAC"
334
337
  def create
335
338
  Commands::AgentTokensCreateCommand.new.call(
336
339
  name: options[:name],
337
340
  expires_in: options[:expires_in],
338
- role: options[:role]
341
+ role: options[:role],
342
+ tenant: options[:tenant],
343
+ clearance: options[:clearance],
344
+ region: options[:region]
339
345
  )
340
346
  end
341
347
 
@@ -26,6 +26,9 @@ module Vkit
26
26
 
27
27
  puts "🧠 Name: #{token["name"]}"
28
28
  puts "🎭 Role: #{token["role"]}"
29
+ puts "🏢 Tenant: #{token["tenant"]}" if token["tenant"]
30
+ puts "🛡 Clearance: #{token["clearance"]}" if token["clearance"]
31
+ puts "🌍 Region: #{token["region"]}" if token["region"]
29
32
  puts "🆔 Token ID: #{token["id"]}"
30
33
  puts "🏷 Prefix: #{token["prefix"]}"
31
34
  puts "⏳ Expires At: #{token["expires_at"] || "never"}"
@@ -47,7 +50,10 @@ module Vkit
47
50
  body = {
48
51
  name: options.fetch(:name),
49
52
  role: options[:role],
50
- }
53
+ tenant: options[:tenant],
54
+ clearance: options[:clearance],
55
+ region: options[:region]
56
+ }.compact
51
57
 
52
58
  if options[:expires_in]
53
59
  body[:expires_at] = parse_expires_in(options[:expires_in]).iso8601
@@ -19,51 +19,88 @@ module Vkit
19
19
  body: { datasource: datasource_name, mode: mode }
20
20
  )
21
21
 
22
- diff = response["diff"] || {}
23
- datasets = diff["datasets"] || []
24
- changed = datasets.select { |d| d["changes"].values.any?(&:any?) }
25
- clean = datasets.reject { |d| d["changes"].values.any?(&:any?) }
22
+ print_diff(response)
23
+ end
24
+ end
26
25
 
27
- if changed.empty?
28
- datasets.each { |d| puts " #{d["name"].ljust(24)} ✓" }
29
- puts
30
- puts " No changes detected"
31
- else
32
- changed.each do |dataset|
33
- added = dataset.dig("changes", "added_fields") || []
34
- removed = dataset.dig("changes", "removed_fields") || []
35
- modified = dataset.dig("changes", "changed_fields") || []
36
-
37
- puts " #{dataset["name"]}"
38
-
39
- added.each do |f|
40
- name = f.is_a?(Hash) ? f["name"] : f
41
- type = f.is_a?(Hash) && f["type"] ? " (#{f["type"]})" : ""
42
- puts " + #{name}#{type} ⚠️ unclassified"
43
- end
44
-
45
- removed.each do |f|
46
- name = f.is_a?(Hash) ? f["name"] : f
47
- puts " - #{name}"
48
- end
49
-
50
- modified.each do |f|
51
- name = f.is_a?(Hash) ? f["name"] : f
52
- puts " ~ #{name}"
53
- end
54
-
55
- puts
26
+ private
27
+
28
+ def print_diff(response)
29
+ diff = response["diff"] || {}
30
+ datasets = diff["datasets"] || []
31
+ changed = datasets.select { |d| d["changes"].values.any?(&:any?) }
32
+ clean = datasets.reject { |d| d["changes"].values.any?(&:any?) }
33
+
34
+ if changed.empty?
35
+ datasets.each { |d| print_clean(d) }
36
+ puts
37
+ puts " No changes detected"
38
+ else
39
+ changed.each do |dataset|
40
+ added = dataset.dig("changes", "added_fields") || []
41
+ removed = dataset.dig("changes", "removed_fields") || []
42
+ modified = dataset.dig("changes", "changed_fields") || []
43
+
44
+ puts " #{dataset["name"]}"
45
+
46
+ added.each do |f|
47
+ name = f.is_a?(Hash) ? f["name"] : f
48
+ type = f.is_a?(Hash) && f["type"] ? " (#{f["type"]})" : ""
49
+ puts " + #{name}#{type} ⚠️ unclassified"
50
+ end
51
+
52
+ removed.each do |f|
53
+ name = f.is_a?(Hash) ? f["name"] : f
54
+ puts " - #{name}"
56
55
  end
57
56
 
58
- clean.each { |d| puts " #{d["name"].ljust(24)} ✓" }
57
+ modified.each do |f|
58
+ name = f.is_a?(Hash) ? f["name"] : f
59
+ puts " ~ #{name}"
60
+ end
61
+
62
+ print_suggestions(dataset)
59
63
 
60
64
  puts
61
- puts " #{changed.size} changed · run with --apply to update baseline"
62
65
  end
63
66
 
67
+ clean.each { |d| print_clean(d) }
68
+
64
69
  puts
65
- puts " ✅ Applied" if response["applied"]
70
+ puts " #{changed.size} changed · run with --apply to update baseline"
66
71
  end
72
+
73
+ # Suggestions are reviewed labels the classifier now disagrees with.
74
+ # Older control planes don't send them; applying a scan never changes them.
75
+ if datasets.any? { |d| suggestions_for(d).any? }
76
+ puts
77
+ puts " ? = classifier suggestion, not applied. To accept one, edit"
78
+ puts " datasets/registry.yaml and redeploy the bundle."
79
+ end
80
+
81
+ puts
82
+ puts " ✅ Applied" if response["applied"]
83
+ end
84
+
85
+ def print_clean(dataset)
86
+ puts " #{dataset["name"].ljust(24)} ✓"
87
+ print_suggestions(dataset)
88
+ end
89
+
90
+ def print_suggestions(dataset)
91
+ suggestions_for(dataset).each do |s|
92
+ puts " ? #{s["name"]} #{label(s["current"])} → #{label(s["suggested"])} (suggested)"
93
+ end
94
+ end
95
+
96
+ def suggestions_for(dataset)
97
+ dataset["suggestions"] || []
98
+ end
99
+
100
+ def label(classification)
101
+ classification ||= {}
102
+ tags = Array(classification["tags"])
103
+ [classification["sensitivity"], *(tags.join(",") unless tags.empty?)].compact.join("/")
67
104
  end
68
105
  end
69
106
  end
@@ -3,15 +3,21 @@ require "yaml"
3
3
  require "digest"
4
4
  require "time"
5
5
  require_relative "policy_validator"
6
+ require_relative "match_check"
7
+ require_relative "tenancy_check"
8
+ require_relative "validation_error"
6
9
 
7
10
  module Vkit
8
11
  module Policy
9
12
  class BundleCompiler
10
13
  FORMAT_VERSION = "v1"
11
14
 
15
+ # Raises if a policy can never match (see MatchCheck).
12
16
  def self.compile!(org_slug:, bundle_version:, policies_dir:, registry_dir:, source: {})
13
17
  policies = load_policies(policies_dir)
14
- registry = load_registry(registry_dir)
18
+ raw_registry = load_raw_registry(registry_dir)
19
+ registry = normalize_registry(raw_registry)
20
+ check_matches!(policies, registry, raw_registry)
15
21
 
16
22
  bundle = {
17
23
  "bundle" => {
@@ -49,12 +55,25 @@ module Vkit
49
55
  end
50
56
  end
51
57
 
52
- def self.load_registry(dir)
58
+ def self.check_matches!(policies, registry, raw_registry)
59
+ errors = MatchCheck.call(policies, registry).errors + TenancyCheck.call(raw_registry)
60
+ return if errors.empty?
61
+
62
+ raise ValidationError, <<~MSG
63
+ These policies or datasets would never protect anything as written:
64
+
65
+ #{errors.map { |e| " - #{e}" }.join("\n")}
66
+
67
+ Fix or remove them. If they came from a policy pack, `vkit policy pack upgrade` installs
68
+ versions that can match.
69
+ MSG
70
+ end
71
+
72
+ def self.load_raw_registry(dir)
53
73
  path = File.join(dir, "registry.yaml")
54
74
  raise "Missing datasets/registry.yaml" unless File.exist?(path)
55
75
 
56
- raw = YAML.load_file(path)
57
- normalize_registry(raw)
76
+ YAML.load_file(path) || {}
58
77
  end
59
78
 
60
79
  # Normalization
@@ -138,8 +157,11 @@ module Vkit
138
157
  {
139
158
  "name" => name.to_s,
140
159
  "datasource" => data["datasource"].to_s,
160
+ # A field name confines every query to the requester's tenant;
161
+ # false (a deliberately shared table) only matters to TenancyCheck.
162
+ "tenant_column" => (data["tenant_column"] if data["tenant_column"].is_a?(String)),
141
163
  "fields" => normalize_fields(data["fields"] || {})
142
- }
164
+ }.compact
143
165
  end
144
166
 
145
167
  datasources =
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vkit
6
+ module Policy
7
+ # Finds policies that can never match, by checking them against the
8
+ # registry being bundled and against what the control plane evaluates:
9
+ # a key the engine ignores, a tag or sensitivity no field can have, a
10
+ # dataset that isn't in the registry, or a context value VaultKit never
11
+ # sets. Requester clearance and region are set by admins on users and
12
+ # agent tokens (vaultkit-inc/vaultkit#76), so they're checked like any
13
+ # other value.
14
+ class MatchCheck
15
+ # Labels the scanner's classifier assigns. Always valid, so a pack can
16
+ # cover secrets before a database has any.
17
+ # unknown marks a column nobody has reviewed yet.
18
+ CLASSIFIER_TAGS = %w[pii financial credential internal unknown].freeze
19
+ SENSITIVITIES = %w[high medium low unknown].freeze
20
+
21
+ MATCH_KEYS = %w[dataset fields context].freeze
22
+ FIELD_KEYS = %w[any all contains sensitivity].freeze
23
+ CONTEXT_KEYS = %w[environment dataset_region requester_role requester_region requester_clearance time].freeze
24
+ ENVIRONMENTS = %w[production staging development sandbox].freeze
25
+ REGIONS = %w[EU US CA APAC].freeze
26
+ CLEARANCES = %w[low medium high].freeze
27
+
28
+ Result = Struct.new(:errors, keyword_init: true)
29
+
30
+ def self.call(policies, registry)
31
+ new(registry).call(policies)
32
+ end
33
+
34
+ def initialize(registry)
35
+ fields = Array(registry["datasets"]).flat_map { |ds| Array(ds["fields"]) }
36
+ @datasets = Array(registry["datasets"]).map { |ds| ds["name"].to_s }.to_set
37
+ @tags = (CLASSIFIER_TAGS + fields.flat_map { |f| Array(f["tags"]).map(&:to_s) }).to_set
38
+ @sensitivities = (SENSITIVITIES + fields.map { |f| f["sensitivity"].to_s }).reject(&:empty?).to_set
39
+ end
40
+
41
+ def call(policies)
42
+ errors = []
43
+ policies.each do |policy|
44
+ where = "#{policy['__file'] || policy['id']} (#{policy['id']})"
45
+ problems(policy).each { |p| errors << "#{where}: #{p}" }
46
+ end
47
+ Result.new(errors: errors)
48
+ end
49
+
50
+ private
51
+
52
+ def problems(policy)
53
+ match = policy["match"] || {}
54
+ return ["match must be a mapping"] unless match.is_a?(Hash)
55
+
56
+ unknown_keys(match.keys, MATCH_KEYS, "match") +
57
+ dataset_problems(match["dataset"]) +
58
+ field_problems(match["fields"]) +
59
+ context_problems(policy["context"], "context") +
60
+ context_problems(match["context"], "match.context") +
61
+ mask_problems(policy, match)
62
+ end
63
+
64
+ def unknown_keys(keys, allowed, path)
65
+ (keys.map(&:to_s) - allowed).map do |key|
66
+ next "#{path}.category isn't supported: the engine ignores it. Use `contains: [<category>]` instead." if key == "category"
67
+
68
+ "#{path}.#{key} isn't supported, so the engine ignores it. Allowed: #{allowed.join(', ')}"
69
+ end
70
+ end
71
+
72
+ def dataset_problems(dataset)
73
+ return [] if dataset.nil? || @datasets.include?(dataset.to_s)
74
+
75
+ ["dataset #{dataset} isn't in datasets/registry.yaml. Scan the datasource, or fix the name."]
76
+ end
77
+
78
+ def field_problems(fields)
79
+ return [] if fields.nil?
80
+ return ["match.fields must be a mapping"] unless fields.is_a?(Hash)
81
+
82
+ problems = unknown_keys(fields.keys, FIELD_KEYS, "match.fields")
83
+ tags = Array(fields["contains"]).map(&:to_s)
84
+ unknown = tags.reject { |t| @tags.include?(t) }
85
+ problems << "no field has the tag #{unknown.join(', ')}. Known tags: #{@tags.to_a.sort.join(', ')}" if unknown.any?
86
+ if fields.key?("sensitivity") && !@sensitivities.include?(fields["sensitivity"].to_s)
87
+ problems << "sensitivity #{fields['sensitivity']} never occurs. Use one of: #{@sensitivities.to_a.join(', ')}"
88
+ end
89
+ problems
90
+ end
91
+
92
+ def context_problems(context, path)
93
+ return [] if context.nil?
94
+ return ["#{path} must be a mapping"] unless context.is_a?(Hash)
95
+
96
+ unknown_keys(context.keys, CONTEXT_KEYS, path) + context.filter_map do |key, value|
97
+ case key.to_s
98
+ when "requester_clearance"
99
+ "#{path}.requester_clearance #{value} isn't one of: #{CLEARANCES.join(', ')}" unless CLEARANCES.include?(value.to_s)
100
+ when "requester_region"
101
+ "#{path}.requester_region #{value} isn't one of: #{REGIONS.join(', ')}" unless REGIONS.include?(value.to_s)
102
+ when "time"
103
+ "#{path}.time only works under match.context." if path == "context"
104
+ when "environment"
105
+ "#{path}.environment #{value} isn't one of: #{ENVIRONMENTS.join(', ')}" unless ENVIRONMENTS.include?(value.to_s)
106
+ when "dataset_region"
107
+ "#{path}.dataset_region #{value} isn't one of: #{REGIONS.join(', ')}" unless REGIONS.include?(value.to_s)
108
+ end
109
+ end
110
+ end
111
+
112
+ def mask_problems(policy, match)
113
+ return [] unless policy.dig("action", "mask") == true
114
+ return [] if match["fields"].is_a?(Hash) && (match["fields"].keys.map(&:to_s) & %w[any contains sensitivity]).any?
115
+
116
+ ["a mask policy needs match.fields (any, contains or sensitivity) to say which fields to mask"]
117
+ end
118
+ end
119
+ end
120
+ end
@@ -1,8 +1,8 @@
1
1
  __pack_meta:
2
2
  name: ai_safety
3
- version: "1.1.0"
3
+ version: "2.0.0"
4
4
  layer: 30
5
- description: "Secure defaults for AI/agent access: least-privilege, approvals for sensitive, environment controls."
5
+ description: "Agents need approval for personal, financial and unreviewed data in production, and for high-sensitivity data anywhere."
6
6
  dependencies:
7
7
  - starter
8
8
  priority_band:
@@ -0,0 +1,17 @@
1
+ id: ai_require_approval_agents_sensitive_prod
2
+ description: Agents need security approval for personal or financial data in production.
3
+ priority: 1200
4
+
5
+ match:
6
+ fields:
7
+ contains: [pii, financial]
8
+
9
+ context:
10
+ environment: production
11
+ requester_role: agent
12
+
13
+ action:
14
+ require_approval: true
15
+ approver_role: security
16
+ reason: Agents reading personal or financial data in production need security review.
17
+ ttl: 1h
@@ -0,0 +1,16 @@
1
+ id: ai_require_approval_agents_high_sensitivity
2
+ description: Agents need security approval for high-sensitivity data in every environment.
3
+ priority: 1300
4
+
5
+ match:
6
+ fields:
7
+ sensitivity: high
8
+
9
+ context:
10
+ requester_role: agent
11
+
12
+ action:
13
+ require_approval: true
14
+ approver_role: security
15
+ reason: Agents reading high-sensitivity data need security review, in any environment.
16
+ ttl: 1h
@@ -0,0 +1,17 @@
1
+ id: ai_require_approval_agents_unknown_prod
2
+ description: Agents need security approval in production for columns nobody has reviewed yet.
3
+ priority: 1400
4
+
5
+ match:
6
+ fields:
7
+ sensitivity: unknown
8
+
9
+ context:
10
+ environment: production
11
+ requester_role: agent
12
+
13
+ action:
14
+ require_approval: true
15
+ approver_role: security
16
+ reason: This column's sensitivity is unknown, so an agent needs security review to read it in production.
17
+ ttl: 1h
@@ -1,8 +1,8 @@
1
1
  __pack_meta:
2
2
  name: financial_compliance
3
- version: "1.0.0"
3
+ version: "2.0.0"
4
4
  layer: 40
5
- description: "Financial controls: approvals, masking, audit-friendly TTLs for financial + payment data."
5
+ description: "Finance approval for financial data in production, and short-lived grants for financial fields."
6
6
  dependencies:
7
7
  - starter
8
8
  priority_band:
@@ -0,0 +1,16 @@
1
+ id: fin_require_approval_financial_prod
2
+ description: A finance manager approves requests for financial data in production.
3
+ priority: 2100
4
+
5
+ match:
6
+ fields:
7
+ contains: [financial]
8
+
9
+ context:
10
+ environment: production
11
+
12
+ action:
13
+ require_approval: true
14
+ approver_role: finance_manager
15
+ reason: Financial data in production needs finance approval.
16
+ ttl: 30m
@@ -0,0 +1,12 @@
1
+ id: fin_short_ttl_financial
2
+ description: Grants that include financial fields expire after 30 minutes.
3
+ priority: 2400
4
+
5
+ match:
6
+ fields:
7
+ contains: [financial]
8
+
9
+ action:
10
+ allow: true
11
+ reason: Financial grants expire quickly.
12
+ ttl: 30m
@@ -1,8 +1,8 @@
1
1
  __pack_meta:
2
2
  name: starter
3
- version: "1.0.0"
3
+ version: "2.0.0"
4
4
  layer: 10
5
- description: "Secure-by-default baseline policies for VaultKit."
5
+ description: "Secure defaults: secrets denied, PII and high-sensitivity fields masked, approval for high-sensitivity data in production, unreviewed columns flagged."
6
6
  dependencies: []
7
7
  priority_band:
8
8
  min: 0
@@ -0,0 +1,11 @@
1
+ id: deny_credentials
2
+ description: Never return passwords, tokens, API keys or other secrets.
3
+ priority: 95
4
+
5
+ match:
6
+ fields:
7
+ contains: [credential]
8
+
9
+ action:
10
+ deny: true
11
+ reason: Credentials are never returned. Use the service that owns them instead.
@@ -1,16 +1,15 @@
1
1
  id: mask_pii_by_default
2
- description: "Mask PII fields by default unless explicitly allowed."
3
-
2
+ description: Mask personal data for every requester, including after approval.
4
3
  priority: 80
5
4
 
6
5
  match:
7
6
  fields:
8
- contains: ["pii"]
7
+ contains: [pii]
9
8
 
10
9
  action:
11
10
  mask: true
12
- reason: "PII is masked by default for safety."
11
+ reason: Personal data is masked by default.
13
12
  ttl: 1h
14
13
 
15
14
  masking:
16
- default_method: redact
15
+ default_method: full
@@ -0,0 +1,15 @@
1
+ id: mask_high_sensitivity
2
+ description: Mask high-sensitivity fields, such as bank accounts, even when they aren't personal data.
3
+ priority: 82
4
+
5
+ match:
6
+ fields:
7
+ sensitivity: high
8
+
9
+ action:
10
+ mask: true
11
+ reason: High-sensitivity data is masked by default.
12
+ ttl: 1h
13
+
14
+ masking:
15
+ default_method: full
@@ -1,5 +1,6 @@
1
1
  id: require_approval_high_sensitivity
2
- description: "Require approval before accessing high sensitivity data."
2
+ description: A data owner approves requests for high-sensitivity data in production.
3
+ priority: 85
3
4
 
4
5
  match:
5
6
  fields:
@@ -8,10 +9,8 @@ match:
8
9
  context:
9
10
  environment: production
10
11
 
11
- priority: 85
12
-
13
12
  action:
14
13
  require_approval: true
15
14
  approver_role: data_owner
16
- reason: "High sensitivity data requires manual review in production."
17
- ttl: "1h"
15
+ reason: High-sensitivity data in production needs a data owner's approval.
16
+ ttl: 1h
@@ -1,10 +1,9 @@
1
1
  id: default_ttl_for_allows
2
- description: "Apply a default TTL to allow decisions."
2
+ description: Grants for allowed requests last four hours.
3
+ priority: 10
3
4
 
4
5
  match: {}
5
6
 
6
- priority: 10
7
-
8
7
  action:
9
8
  allow: true
10
- ttl: "4h"
9
+ ttl: 4h
@@ -0,0 +1,12 @@
1
+ id: flag_unknown_sensitivity
2
+ description: Allow columns nobody has reviewed yet, and record why, so they show up in the audit log.
3
+ priority: 20
4
+
5
+ match:
6
+ fields:
7
+ sensitivity: unknown
8
+
9
+ action:
10
+ allow: true
11
+ reason: This column's sensitivity is unknown. Review its label in datasets/registry.yaml.
12
+ ttl: 1h
@@ -102,6 +102,11 @@
102
102
  "properties": {
103
103
  "name": { "type": "string", "minLength": 1 },
104
104
  "datasource": { "type": "string", "minLength": 1 },
105
+ "tenant_column": {
106
+ "type": "string",
107
+ "minLength": 1,
108
+ "description": "Column holding the tenant id. Every query on this dataset is confined to the requester's tenant."
109
+ },
105
110
  "fields": {
106
111
  "type": "array",
107
112
  "minItems": 1,
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vkit
4
+ module Policy
5
+ # Checks the tenant columns in datasets/registry.yaml. A dataset with
6
+ # `tenant_column: <field>` is confined to the requester's tenant on every
7
+ # query. The risk is a tenant table nobody marked, so a dataset that has
8
+ # another dataset's tenant column must either be marked too, or say
9
+ # `tenant_column: false` to declare every tenant may read all of it.
10
+ class TenancyCheck
11
+ def self.call(raw_registry)
12
+ new(raw_registry || {}).call
13
+ end
14
+
15
+ def initialize(raw_registry)
16
+ @datasets = raw_registry.to_h { |name, data| [name.to_s, data || {}] }
17
+ end
18
+
19
+ def call
20
+ @datasets.flat_map { |name, data| problems(name, data) }
21
+ end
22
+
23
+ private
24
+
25
+ def problems(name, data)
26
+ column = data["tenant_column"]
27
+ fields = (data["fields"] || {}).keys.map(&:to_s)
28
+
29
+ case column
30
+ when false then []
31
+ when nil then unmarked(name, fields)
32
+ when String
33
+ return [] if fields.include?(column)
34
+
35
+ ["#{name}: tenant_column #{column} isn't one of its fields, so no query on it could be confined to a tenant."]
36
+ else
37
+ ["#{name}: tenant_column must be a field name, or false for a table every tenant may read."]
38
+ end
39
+ end
40
+
41
+ def unmarked(name, fields)
42
+ (fields & tenant_columns).map do |column|
43
+ owners = @datasets.select { |_, d| d["tenant_column"] == column }.keys.join(", ")
44
+ "#{name} has #{column}, the tenant column of #{owners}, but no tenant_column. Add " \
45
+ "`tenant_column: #{column}` to confine it to one tenant, or `tenant_column: false` if every tenant may read all of it."
46
+ end
47
+ end
48
+
49
+ def tenant_columns
50
+ @tenant_columns ||= @datasets.values.map { |d| d["tenant_column"] }.grep(String).uniq
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/vkit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vkit
4
- VERSION = "1.1.6"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vaultkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.6
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nnamdi Ogundu
@@ -95,24 +95,24 @@ files:
95
95
  - lib/vkit/core/registry_diff_printer.rb
96
96
  - lib/vkit/core/table_formatter.rb
97
97
  - lib/vkit/policy/bundle_compiler.rb
98
+ - lib/vkit/policy/match_check.rb
98
99
  - lib/vkit/policy/packs/ai_safety/metadata.yaml
99
- - lib/vkit/policy/packs/ai_safety/policies/01_deny_agent_prod_without_clearance.yaml
100
- - lib/vkit/policy/packs/ai_safety/policies/02_require_approval_for_sensitive_in_prod.yaml
101
- - lib/vkit/policy/packs/ai_safety/policies/03_mask_sensitive_by_default_for_agents.yaml
100
+ - lib/vkit/policy/packs/ai_safety/policies/01_require_approval_agents_sensitive_prod.yaml
101
+ - lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml
102
+ - lib/vkit/policy/packs/ai_safety/policies/03_require_approval_agents_unknown_prod.yaml
102
103
  - lib/vkit/policy/packs/financial_compliance/metadata.yaml
103
- - lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_for_financial_prod.yaml
104
- - lib/vkit/policy/packs/financial_compliance/policies/02_mask_payment_tokens.yaml
105
- - lib/vkit/policy/packs/financial_compliance/policies/03_deny_non_admin_access_pci_in_prod.yaml
106
- - lib/vkit/policy/packs/financial_compliance/policies/04_short_ttl_for_financial_grants.yaml
104
+ - lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_financial_prod.yaml
105
+ - lib/vkit/policy/packs/financial_compliance/policies/02_short_ttl_for_financial_grants.yaml
107
106
  - lib/vkit/policy/packs/starter/metadata.yaml
108
- - lib/vkit/policy/packs/starter/policies/01_deny_sensitive_without_clearance.yaml
107
+ - lib/vkit/policy/packs/starter/policies/01_deny_credentials.yaml
109
108
  - lib/vkit/policy/packs/starter/policies/02_mask_pii_by_default.yaml
110
- - lib/vkit/policy/packs/starter/policies/03_require_approval_high_sensitivity.yaml
111
- - lib/vkit/policy/packs/starter/policies/04_block_cross_region.yaml
112
- - lib/vkit/policy/packs/starter/policies/05_limit_production_access.yaml
113
- - lib/vkit/policy/packs/starter/policies/06_default_ttl.yaml
109
+ - lib/vkit/policy/packs/starter/policies/03_mask_high_sensitivity.yaml
110
+ - lib/vkit/policy/packs/starter/policies/04_require_approval_high_sensitivity.yaml
111
+ - lib/vkit/policy/packs/starter/policies/05_default_ttl.yaml
112
+ - lib/vkit/policy/packs/starter/policies/06_flag_unknown.yaml
114
113
  - lib/vkit/policy/policy_validator.rb
115
114
  - lib/vkit/policy/schema/policy_bundle.schema.json
115
+ - lib/vkit/policy/tenancy_check.rb
116
116
  - lib/vkit/policy/validate_bundle.rb
117
117
  - lib/vkit/policy/validation_error.rb
118
118
  - lib/vkit/utils/banner.rb
@@ -1,15 +0,0 @@
1
- id: ai_deny_agent_prod_without_clearance
2
- description: "Deny agent access in production unless requester_clearance is high/admin."
3
- priority: 1100
4
-
5
- match: {}
6
-
7
- context:
8
- environment: production
9
- requester_role: agent
10
- requester_clearance: low
11
-
12
- action:
13
- deny: true
14
- reason: "Automation/agents in production require elevated clearance."
15
- ttl: "1h"
@@ -1,16 +0,0 @@
1
- id: ai_require_approval_sensitive_prod
2
- description: "Require approval when accessing sensitive fields in production."
3
- priority: 1200
4
-
5
- match:
6
- fields:
7
- contains: ["pii", "financial", "phi", "secrets"]
8
-
9
- context:
10
- environment: production
11
-
12
- action:
13
- require_approval: true
14
- approver_role: security
15
- reason: "Sensitive data access in production requires security review."
16
- ttl: "1h"
@@ -1,18 +0,0 @@
1
- id: ai_mask_sensitive_for_agents
2
- description: "Mask sensitive fields by default for agents."
3
- priority: 1300
4
-
5
- match:
6
- fields:
7
- contains: ["pii", "financial", "phi", "secrets"]
8
-
9
- context:
10
- requester_role: agent
11
-
12
- action:
13
- mask: true
14
- reason: "Agents should not receive raw sensitive fields by default."
15
- ttl: "4h"
16
-
17
- masking:
18
- default_method: redact
@@ -1,16 +0,0 @@
1
- id: fin_require_approval_financial_prod
2
- description: "Require approval for financial-category fields in production."
3
- priority: 2100
4
-
5
- match:
6
- fields:
7
- category: financial
8
-
9
- context:
10
- environment: production
11
-
12
- action:
13
- require_approval: true
14
- approver_role: finance_manager
15
- reason: "Financial data access in production requires finance approval."
16
- ttl: "1h"
@@ -1,17 +0,0 @@
1
- id: fin_mask_payment_tokens
2
- description: "Mask payment tokens / card-related fields everywhere."
3
- priority: 2200
4
-
5
- match:
6
- fields:
7
- contains: ["pci", "card", "payment_token"]
8
-
9
- context: {}
10
-
11
- action:
12
- mask: true
13
- reason: "Payment tokens/card-related data must never be exposed in raw form."
14
- ttl: 24h
15
-
16
- masking:
17
- default_method: redact
@@ -1,15 +0,0 @@
1
- id: fin_deny_non_admin_pci_prod
2
- description: "Deny PCI-tagged data access in production unless admin."
3
- priority: 2300
4
-
5
- match:
6
- fields:
7
- contains: ["pci"]
8
-
9
- context:
10
- environment: production
11
-
12
- action:
13
- deny: true
14
- reason: "PCI-tagged data in production is restricted to admins."
15
- ttl: "1h"
@@ -1,14 +0,0 @@
1
- id: fin_short_ttl_financial
2
- description: "Short TTL for financial access grants to reduce risk window."
3
- priority: 2400
4
-
5
- match:
6
- fields:
7
- category: financial
8
-
9
- context: {}
10
-
11
- action:
12
- allow: true
13
- reason: "Financial grants must expire quickly."
14
- ttl: "30m"
@@ -1,15 +0,0 @@
1
- id: deny_sensitive_without_clearance
2
- description: "Deny access to highly sensitive data without sufficient clearance."
3
-
4
- match:
5
- fields:
6
- sensitivity: high
7
-
8
- context:
9
- requester_clearance: low
10
-
11
- priority: 90
12
-
13
- action:
14
- deny: true
15
- reason: "High sensitivity data requires elevated clearance."
@@ -1,14 +0,0 @@
1
- id: block_cross_region
2
- description: "Block cross-region access to prevent data residency violations."
3
-
4
- match: {}
5
-
6
- context:
7
- requester_region: US
8
- dataset_region: EU
9
-
10
- priority: 95
11
-
12
- action:
13
- deny: true
14
- reason: "Cross-region data access is not permitted."
@@ -1,16 +0,0 @@
1
- id: limit_production_access
2
- description: "Restrict production data access to approved roles."
3
-
4
- match: {}
5
-
6
- context:
7
- environment: production
8
- requester_role: analyst
9
-
10
- priority: 70
11
-
12
- action:
13
- require_approval: true
14
- approver_role: admin
15
- reason: "Production access for analysts requires approval."
16
- ttl: "2h"