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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/lib/credential_parity/checker.rb +89 -0
- data/lib/credential_parity/configuration.rb +38 -0
- data/lib/credential_parity/middleware.rb +46 -0
- data/lib/credential_parity/railtie.rb +23 -0
- data/lib/credential_parity/rspec.rb +9 -0
- data/lib/credential_parity/version.rb +3 -0
- data/lib/credential_parity/violation.rb +11 -0
- data/lib/credential_parity.rb +95 -0
- data/lib/tasks/credential_parity.rake +21 -0
- metadata +74 -0
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
|
+
[](https://github.com/velocity-labs/credential_parity/actions/workflows/ci.yml)
|
|
6
|
+
[](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,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: []
|