credential_parity 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '004909c74b9ded92ab8c7f1498486a7c553b0a1810e63cc88bb71bc4c566e8a6'
4
+ data.tar.gz: 7cc15ea654e3373d38b4fdcd600a22cd92407f30fb8165ad02966c91956d4314
5
+ SHA512:
6
+ metadata.gz: 0ca1a4c4f4ac1d42f8143b27b1cec892e7141792cfa9403c4a04dcb125f2ae20d2b5f796515d5fe9321ce423112db0c04727229a07a062ac60c5fcddd8420eb3
7
+ data.tar.gz: 1b31e46a91d96996032dd6125a76257e439ad849c2e05ab18eb20ec5fa16696a0125b05cebe71bf3e3237a270734231975a2806ae2d044e13cbfa8f1ae1d8648
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-09-07
4
+
5
+ Initial release.
6
+
7
+ ### Added
8
+ - Compares the leaf key paths declared by each environment's encrypted credential file and fails when they disagree
9
+ - Paths are compared, never values, so rotating one environment's secret is not drift and no secret can reach an error message
10
+ - Nested drift is caught: a key misspelled in the middle of a path is found even though every environment still declares the same top level key
11
+ - A declared key with no value or an empty mapping counts as a leaf rather than a branch
12
+ - Asymmetric rule requiring no allowlist: a reference environment, mirrors that must declare exactly the same keys, and subsets that may omit keys but never add their own
13
+ - Configurable `reference`, `mirrors`, `subsets`, `directory`, and `middleware_environments`
14
+ - Development middleware over an `ActiveSupport::FileUpdateChecker`, so the files are only decrypted again after one of them changes
15
+ - A failed check leaves the middleware wanting another, so correcting a file and reloading is enough with no restart
16
+ - `credential_parity/rspec` for the test suite boot, next to `ActiveRecord::Migration.maintain_test_schema!`
17
+ - `rake credential_parity:verify` for a deploy step or the command line
18
+ - Self-disables where the keys are not all present, which is CI and every deployed host, so nothing fails there
19
+ - `rails credentials:edit` is deliberately never gated, since it is the command used to repair drift
20
+ - `SKIP_CREDENTIAL_PARITY_CHECK=1` escape hatch
21
+ - Ruby 3.2, 3.3, and 3.4 support
22
+ - Tested with Appraisal against ActiveSupport 7.0, 7.1, 7.2, and 8.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Velocity Labs, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # CredentialParity
2
+
3
+ Catch Rails credential drift between environments before it reaches production.
4
+
5
+ [![CI](https://github.com/velocity-labs/credential_parity/actions/workflows/ci.yml/badge.svg)](https://github.com/velocity-labs/credential_parity/actions/workflows/ci.yml)
6
+ [![Gem Version](https://badge.fury.io/rb/credential_parity.svg)](https://rubygems.org/gems/credential_parity)
7
+
8
+ Rails ships nothing that compares your credential files to each other. Because
9
+ the whole file is encrypted rather than just the values, a key added to three of
10
+ four files and misspelled in the fourth is invisible in a diff, and
11
+ `credentials.dig` returns `nil` for it rather than complaining. The first sign of
12
+ trouble is the environment that missed it failing after a deploy, found by
13
+ whoever ran the deploy rather than whoever made the change.
14
+
15
+ ```
16
+ Credential keys are out of sync between environments.
17
+
18
+ staging is missing vendor.indexing_api.private_key, which production defines
19
+ staging defines vendor.indexing.private_key, which production does not
20
+ ```
21
+
22
+ ## Environment Roles
23
+
24
+ Every environment is either a mirror of the reference or a subset of it. The
25
+ defaults describe the shape almost every Rails app already has:
26
+
27
+ | Environment | Role | May omit a key | May add its own key |
28
+ |-------------|------|----------------|---------------------|
29
+ | production | reference | n/a | n/a |
30
+ | staging | mirror | No | No |
31
+ | development | subset | Yes | No |
32
+ | test | subset | Yes | No |
33
+
34
+ The asymmetry is the point. A flat "all files must be identical" rule is wrong on
35
+ its face, because development and test legitimately go without credentials for
36
+ services only the deployed environments talk to. Letting them be subsets means
37
+ no allowlist of exceptions to maintain, and no allowlist to go stale.
38
+
39
+ ## Requirements
40
+
41
+ Ruby 3.2 or newer, and ActiveSupport 7.0 or newer.
42
+
43
+ ## Installation
44
+
45
+ ```ruby
46
+ gem "credential_parity"
47
+ ```
48
+
49
+ Then add the check to your test suite, next to the pending migration check that
50
+ is already there:
51
+
52
+ ```ruby
53
+ # spec/rails_helper.rb
54
+ ActiveRecord::Migration.maintain_test_schema!
55
+ require "credential_parity/rspec"
56
+ ```
57
+
58
+ On Minitest, call it directly:
59
+
60
+ ```ruby
61
+ # test/test_helper.rb
62
+ CredentialParity.check!
63
+ ```
64
+
65
+ That is all. The development middleware installs itself.
66
+
67
+ ## Usage
68
+
69
+ ### In development
70
+
71
+ A middleware raises on page load when the files stop agreeing, the same way a
72
+ pending migration does. It wraps an `ActiveSupport::FileUpdateChecker`, so the
73
+ files are only decrypted again after one of them actually changes and the per
74
+ request cost is a stat call. Correct the file and reload; no restart needed.
75
+
76
+ ### In your test suite
77
+
78
+ `require "credential_parity/rspec"` at boot, next to `maintain_test_schema!`.
79
+ This is the hook that catches the person who introduced the drift, because
80
+ adding a credential is always followed by running something.
81
+
82
+ ### From the command line
83
+
84
+ ```bash
85
+ bundle exec rake credential_parity:verify
86
+ ```
87
+
88
+ Prints the environments it compared, or exits non-zero listing every violation.
89
+ Useful in a deploy step, or when you want an answer without booting a server.
90
+
91
+ ### Anywhere else
92
+
93
+ ```ruby
94
+ CredentialParity.check! # raises DriftError, or returns nil
95
+ CredentialParity.violations # array of Violation, empty when clean
96
+ CredentialParity.checkable? # false when the keys are not all present
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ All configuration is optional. CredentialParity works out of the box on an app
102
+ with the usual four environments.
103
+
104
+ ```ruby
105
+ # config/initializers/credential_parity.rb
106
+ CredentialParity.configure do |config|
107
+ # The environment every other one is compared against (default: :production)
108
+ config.reference = :production
109
+
110
+ # Must declare exactly the same key paths as the reference (default: [:staging])
111
+ config.mirrors = [:staging, :demo]
112
+
113
+ # May omit keys, may never add their own (default: [:development, :test])
114
+ config.subsets = [:development, :test]
115
+
116
+ # Where the .yml.enc and .key files live
117
+ # (default: Rails.root.join("config/credentials"))
118
+ config.directory = Rails.root.join("config/credentials")
119
+
120
+ # Environments the page load middleware is installed in (default: [:development])
121
+ config.middleware_environments = [:development]
122
+ end
123
+ ```
124
+
125
+ An app with no staging environment sets `config.mirrors = []`.
126
+
127
+ ## How the comparison works
128
+
129
+ Leaf key paths are compared, never values. Two consequences follow:
130
+
131
+ - **Rotating a secret in one environment is not drift.** Every environment is
132
+ supposed to hold a different value for the same key.
133
+ - **No secret can reach an error message.** Only path names are ever read out,
134
+ which matters because that message gets pasted into terminals and chat.
135
+
136
+ Recursing to leaf paths, rather than comparing top level keys, is what catches
137
+ the nested case. If production declares `vendor.indexing_api.private_key` and
138
+ every other environment declares `vendor.indexing.private_key`, all four still
139
+ declare a top level `vendor`, so a shallow comparison sees nothing wrong.
140
+
141
+ A declared key with nothing under it, `some_key:` or `some_key: {}`, counts as a
142
+ leaf. It is still a key one environment declares and another does not.
143
+
144
+ ## When it does not run
145
+
146
+ The check needs every environment's key, so it runs on developer machines, which
147
+ is the only place all of those keys exist, and quietly does nothing anywhere
148
+ else. On CI and on deployed hosts only one environment's key is present, so there
149
+ is nothing to compare and nothing fails.
150
+
151
+ `rails credentials:edit` is deliberately never gated. It is the command used to
152
+ repair drift, so a check that blocked it would lock you out of the fix. The
153
+ scoping is copied from `ActiveRecord::Migration::CheckPending`, which Rails runs
154
+ on web requests and at test boot but not for generators, `console`, or `runner`.
155
+
156
+ Set `SKIP_CREDENTIAL_PARITY_CHECK=1` to bypass it entirely.
157
+
158
+ ## Error Handling
159
+
160
+ All errors inherit from `CredentialParity::Error`, so you can catch everything
161
+ with one rescue or handle specific cases:
162
+
163
+ ```ruby
164
+ begin
165
+ CredentialParity.check!
166
+ rescue CredentialParity::Error => e
167
+ # Catch any credential_parity error
168
+ end
169
+ ```
170
+
171
+ Specific error classes:
172
+
173
+ | Error | When |
174
+ |-------|------|
175
+ | `CredentialParity::DriftError` | The environments disagree (message lists every path and where it is missing or extra) |
176
+ | `CredentialParity::ConfigurationError` | No credentials directory could be resolved, or a violation carried an unknown kind |
177
+
178
+ ## Deployment Notes
179
+
180
+ ### CI
181
+
182
+ Nothing to do. CI holds at most one environment's key, usually through
183
+ `RAILS_MASTER_KEY`, so the check reports itself unable to run and your build is
184
+ unaffected. Adding `rake credential_parity:verify` to CI is harmless but will
185
+ skip for the same reason.
186
+
187
+ ### Deployed hosts
188
+
189
+ Same story. A production dyno has `production.key` and nothing else, so parity
190
+ cannot be evaluated there. If you want a deploy time gate, the thing to check on
191
+ a deployed host is that every credential your code reads is present, which is a
192
+ different question from whether the environments agree.
193
+
194
+ ## Contributing
195
+
196
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/velocity-labs/credential_parity).
197
+
198
+ 1. Fork the repo
199
+ 2. Create your feature branch (`git checkout -b my-feature`)
200
+ 3. Make your changes with tests
201
+ 4. Ensure all tests pass (`bundle exec rake test`)
202
+ 5. Commit and push
203
+ 6. Open a pull request
204
+
205
+ ## Testing
206
+
207
+ ```bash
208
+ # Install dependencies
209
+ bundle install
210
+
211
+ # Run the full test suite
212
+ bundle exec rake test
213
+
214
+ # Run tests against a specific ActiveSupport version
215
+ bundle exec appraisal activesupport-7.0 rake test
216
+ bundle exec appraisal activesupport-8.0 rake test
217
+
218
+ # Run all appraisals
219
+ bundle exec appraisal rake test
220
+ ```
221
+
222
+ Available appraisals: `activesupport-7.0`, `activesupport-7.1`,
223
+ `activesupport-7.2`, `activesupport-8.0`.
224
+
225
+ Tests generate their own encrypted fixtures in a tmpdir, so no real credential
226
+ file is ever decrypted and no external services are needed.
227
+
228
+ ## License
229
+
230
+ Copyright (c) 2026 Velocity Labs, LLC. Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,89 @@
1
+ require "active_support"
2
+ require "active_support/encrypted_configuration"
3
+
4
+ module CredentialParity
5
+ # Reads every declared environment's credential file and compares the sets of
6
+ # leaf key paths they declare.
7
+ #
8
+ # Paths, never values. Every environment is supposed to hold a different secret
9
+ # for the same key, so rotating one is not drift, and comparing paths alone
10
+ # means no secret can reach an error message.
11
+ class Checker
12
+ # EncryptedFile falls back to ENV[env_key] when the key file is absent. A real
13
+ # variable name here would let a CI-provided RAILS_MASTER_KEY decrypt one
14
+ # environment and then fail the rest on an invalid message, instead of
15
+ # reporting an honest "no key for that environment on this machine".
16
+ UNUSED_ENV_KEY = "CREDENTIAL_PARITY_UNUSED_ENV_KEY".freeze
17
+
18
+ def initialize(config)
19
+ @config = config
20
+ end
21
+
22
+ def checkable?
23
+ config.environments.all? { |environment| content_path(environment).exist? && key_path(environment).exist? }
24
+ end
25
+
26
+ def content_paths
27
+ config.environments.map { |environment| content_path(environment) }
28
+ end
29
+
30
+ def violations
31
+ return [] unless checkable?
32
+
33
+ paths = paths_by_environment
34
+ reference = paths.fetch(config.reference)
35
+
36
+ mirror_violations(paths, reference) + subset_violations(paths, reference)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :config
42
+
43
+ def configuration_for(environment)
44
+ ActiveSupport::EncryptedConfiguration.new(
45
+ config_path: content_path(environment).to_s,
46
+ env_key: UNUSED_ENV_KEY,
47
+ key_path: key_path(environment).to_s,
48
+ raise_if_missing_key: true
49
+ )
50
+ end
51
+
52
+ def content_path(environment)
53
+ config.credentials_directory.join("#{environment}.yml.enc")
54
+ end
55
+
56
+ def key_path(environment)
57
+ config.credentials_directory.join("#{environment}.key")
58
+ end
59
+
60
+ # An empty mapping is a leaf, not a branch. "some_key:" with nothing under it
61
+ # is still a declared key, and walking into it would drop it silently.
62
+ def leaf_paths(value, prefix = [])
63
+ return [prefix.join(".")] if prefix.any? && !(value.is_a?(Hash) && value.any?)
64
+
65
+ value.flat_map { |key, nested| leaf_paths(nested, prefix + [key.to_s]) }
66
+ end
67
+
68
+ def mirror_violations(paths, reference)
69
+ config.mirrors.map(&:to_sym).flat_map do |environment|
70
+ own = paths.fetch(environment)
71
+ violations_for(environment, :missing, reference - own) + violations_for(environment, :unexpected, own - reference)
72
+ end
73
+ end
74
+
75
+ def paths_by_environment
76
+ config.environments.to_h { |environment| [environment, leaf_paths(configuration_for(environment).config).uniq.sort] }
77
+ end
78
+
79
+ def subset_violations(paths, reference)
80
+ config.subsets.map(&:to_sym).flat_map do |environment|
81
+ violations_for(environment, :unexpected, paths.fetch(environment) - reference)
82
+ end
83
+ end
84
+
85
+ def violations_for(environment, kind, paths)
86
+ paths.map { |path| Violation.new(environment: environment, kind: kind, path: path, reference: config.reference) }
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,38 @@
1
+ module CredentialParity
2
+ # Which environments exist, and what each one is allowed to look like next to
3
+ # the reference. Every environment is either a mirror (must declare exactly the
4
+ # same keys) or a subset (may omit keys, may never add its own).
5
+ #
6
+ # The defaults describe the shape almost every Rails app has: production is the
7
+ # truth, staging is another deployed environment so it has to match, and the
8
+ # two local environments go without the credentials for services only the
9
+ # deployed environments talk to.
10
+ class Configuration
11
+ attr_accessor :directory, :middleware_environments, :mirrors, :reference, :subsets
12
+
13
+ def initialize
14
+ @directory = nil
15
+ @middleware_environments = [:development]
16
+ @mirrors = [:staging]
17
+ @reference = :production
18
+ @subsets = %i[development test]
19
+ end
20
+
21
+ def credentials_directory
22
+ return directory if directory
23
+ raise ConfigurationError, "Set CredentialParity.config.directory when Rails is not loaded" unless defined?(::Rails) && ::Rails.root
24
+
25
+ ::Rails.root.join("config/credentials")
26
+ end
27
+
28
+ def environments
29
+ ([reference] + mirrors + subsets).map(&:to_sym).uniq
30
+ end
31
+
32
+ def install_middleware?
33
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:env)
34
+
35
+ middleware_environments.map(&:to_s).include?(::Rails.env.to_s)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ require "active_support"
2
+ require "active_support/file_update_checker"
3
+
4
+ module CredentialParity
5
+ # The shape is lifted from ActiveRecord::Migration::CheckPending. The file
6
+ # watcher means the encrypted files are only decrypted again after one of them
7
+ # actually changes, so the per request cost is a stat call.
8
+ #
9
+ # The @needs_check flag is doing two jobs that are easy to miss. A freshly
10
+ # built FileUpdateChecker already considers itself current, so execute_if_updated
11
+ # on its own would never run the first check at all. And when a check raises,
12
+ # the flag stays set, so correcting the file and reloading is enough rather than
13
+ # having the failure recorded as a completed run.
14
+ class Middleware
15
+ def initialize(app, file_watcher: ActiveSupport::FileUpdateChecker)
16
+ @app = app
17
+ @file_watcher = file_watcher
18
+ @mutex = Mutex.new
19
+ @needs_check = true
20
+ end
21
+
22
+ def call(env)
23
+ @mutex.synchronize do
24
+ @watcher ||= build_watcher do
25
+ @needs_check = true
26
+ CredentialParity.check!
27
+ @needs_check = false
28
+ end
29
+
30
+ if @needs_check
31
+ @watcher.execute
32
+ else
33
+ @watcher.execute_if_updated
34
+ end
35
+ end
36
+
37
+ @app.call(env)
38
+ end
39
+
40
+ private
41
+
42
+ def build_watcher(&block)
43
+ @file_watcher.new(CredentialParity.content_paths.map(&:to_s), &block)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # Loaded unconditionally so that the Rails check lives in one place, in a file
2
+ # that means nothing without Rails anyway.
3
+ return unless defined?(::Rails::Railtie)
4
+
5
+ module CredentialParity
6
+ class Railtie < ::Rails::Railtie
7
+ # After load_config_initializers so the host app's own configure block has
8
+ # already run, and still comfortably before the finisher builds the stack.
9
+ #
10
+ # A gem gets this for free where an app cannot. Code in an app's lib/ is
11
+ # autoloaded, and both config/environments/*.rb and config/initializers/*.rb
12
+ # run before :setup_main_autoloader, so referencing middleware from either
13
+ # raises NameError. Gem code is required at Bundler.require time, so the
14
+ # constant is simply there.
15
+ initializer "credential_parity.middleware", after: :load_config_initializers do |app|
16
+ app.middleware.use CredentialParity::Middleware if CredentialParity.config.install_middleware?
17
+ end
18
+
19
+ rake_tasks do
20
+ load File.expand_path("../tasks/credential_parity.rake", __dir__)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ require "credential_parity"
2
+
3
+ # Checks parity as the suite boots, next to where rails_helper.rb already calls
4
+ # ActiveRecord::Migration.maintain_test_schema!. This is the hook that catches
5
+ # the person who introduced the drift, because adding a credential is always
6
+ # followed by running something.
7
+ #
8
+ # No-ops unless every environment's key file is present, so CI stays green.
9
+ CredentialParity.check!
@@ -0,0 +1,3 @@
1
+ module CredentialParity
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,11 @@
1
+ module CredentialParity
2
+ Violation = Struct.new(:environment, :kind, :path, :reference, keyword_init: true) do
3
+ def message
4
+ case kind
5
+ when :missing then "#{environment} is missing #{path}, which #{reference} defines"
6
+ when :unexpected then "#{environment} defines #{path}, which #{reference} does not"
7
+ else raise ConfigurationError, "Unknown violation kind #{kind.inspect}"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,95 @@
1
+ require "credential_parity/version"
2
+ require "credential_parity/configuration"
3
+ require "credential_parity/violation"
4
+ require "credential_parity/checker"
5
+ require "credential_parity/middleware"
6
+ require "credential_parity/railtie"
7
+
8
+ # Rails ships nothing that compares the environment credential files to each
9
+ # other. Because the whole file is encrypted rather than just the values, a key
10
+ # added to three of four files and misspelled in the fourth is invisible in a
11
+ # diff, and credentials.dig returns nil for it at runtime rather than
12
+ # complaining, so the first sign of trouble is the environment that missed it
13
+ # failing after a deploy.
14
+ module CredentialParity
15
+ SKIP_ENV_VARIABLE = "SKIP_CREDENTIAL_PARITY_CHECK".freeze
16
+
17
+ class Error < StandardError; end
18
+
19
+ # The environments disagree about which keys exist.
20
+ class DriftError < Error; end
21
+
22
+ # The library cannot work out where to look, or was handed something it does
23
+ # not understand.
24
+ class ConfigurationError < Error; end
25
+
26
+ class << self
27
+ # Raises when the declared environments disagree. Does nothing when the key
28
+ # files are not all present, which is the normal case on CI and on every
29
+ # deployed host, where only one environment's key exists.
30
+ def check!
31
+ found = violations
32
+ return if found.empty?
33
+
34
+ raise DriftError, error_message(found)
35
+ end
36
+
37
+ def checkable?
38
+ return false if skip?
39
+
40
+ checker.checkable?
41
+ end
42
+
43
+
44
+ def config
45
+ @config ||= Configuration.new
46
+ end
47
+
48
+ def configure
49
+ yield config
50
+ end
51
+
52
+ def content_paths
53
+ checker.content_paths
54
+ end
55
+
56
+ def reset!
57
+ @checker = nil
58
+ @config = nil
59
+ end
60
+
61
+ def violations
62
+ return [] if skip?
63
+
64
+ checker.violations
65
+ end
66
+
67
+ private
68
+
69
+ def checker
70
+ Checker.new(config)
71
+ end
72
+
73
+ def error_message(found)
74
+ <<~MESSAGE
75
+ Credential keys are out of sync between environments.
76
+
77
+ #{found.map { |violation| " #{violation.message}" }.join("\n")}
78
+
79
+ #{config.reference} is the reference. #{list(config.mirrors)} must declare exactly the same keys.
80
+ #{list(config.subsets)} may omit keys but must not add keys of their own.
81
+
82
+ Correct whichever file is wrong with `bin/rails credentials:edit --environment ENVIRONMENT`,
83
+ or set #{SKIP_ENV_VARIABLE}=1 to bypass this check.
84
+ MESSAGE
85
+ end
86
+
87
+ def list(environments)
88
+ environments.map(&:to_s).join(", ")
89
+ end
90
+
91
+ def skip?
92
+ !ENV[SKIP_ENV_VARIABLE].to_s.empty?
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,21 @@
1
+ namespace :credential_parity do
2
+ desc "Verify that the environment credential files declare the same keys"
3
+ task verify: :environment do
4
+ unless CredentialParity.checkable?
5
+ puts "Skipped: every declared environment's key file must be present to compare them."
6
+ next
7
+ end
8
+
9
+ violations = CredentialParity.violations
10
+
11
+ if violations.empty?
12
+ puts "Credential keys are in sync across #{CredentialParity.config.environments.join(', ')}."
13
+ next
14
+ end
15
+
16
+ # abort rather than raise. An exception reporter hooked into rake would treat
17
+ # a raise here as a production error, and drift is a local condition for the
18
+ # developer to fix rather than something worth paging on.
19
+ abort violations.map(&:message).join("\n")
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: credential_parity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Velocity Labs, LLC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: 'Compares the leaf key paths declared by each of your Rails encrypted
27
+ credential files and fails when they disagree. Paths only, never values, so rotating
28
+ a secret is not drift and no secret reaches an error message. Hooked where Rails
29
+ already checks for pending migrations: a development middleware, the test suite
30
+ boot, and a rake task.'
31
+ email:
32
+ - admin@velocitylabs.io
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - lib/credential_parity.rb
41
+ - lib/credential_parity/checker.rb
42
+ - lib/credential_parity/configuration.rb
43
+ - lib/credential_parity/middleware.rb
44
+ - lib/credential_parity/railtie.rb
45
+ - lib/credential_parity/rspec.rb
46
+ - lib/credential_parity/version.rb
47
+ - lib/credential_parity/violation.rb
48
+ - lib/tasks/credential_parity.rake
49
+ homepage: https://github.com/velocity-labs/credential_parity
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/velocity-labs/credential_parity
54
+ source_code_uri: https://github.com/velocity-labs/credential_parity
55
+ changelog_uri: https://github.com/velocity-labs/credential_parity/blob/main/CHANGELOG.md
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.2'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.6.9
72
+ specification_version: 4
73
+ summary: Catch Rails credential drift between environments before it reaches production
74
+ test_files: []