ironclad 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/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/exe/ironclad +6 -0
- data/lib/generators/ironclad/install_generator.rb +80 -0
- data/lib/generators/ironclad/templates/binstub +7 -0
- data/lib/generators/ironclad/templates/tasks.json +33 -0
- data/lib/ironclad/cache/keychain.rb +33 -0
- data/lib/ironclad/cache/keyctl.rb +24 -0
- data/lib/ironclad/cache/null.rb +13 -0
- data/lib/ironclad/cache.rb +28 -0
- data/lib/ironclad/capistrano.rb +36 -0
- data/lib/ironclad/cli.rb +75 -0
- data/lib/ironclad/config.rb +84 -0
- data/lib/ironclad/key_store.rb +29 -0
- data/lib/ironclad/railtie.rb +26 -0
- data/lib/ironclad/source/one_password.rb +30 -0
- data/lib/ironclad/source.rb +10 -0
- data/lib/ironclad/version.rb +5 -0
- data/lib/ironclad.rb +49 -0
- metadata +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8eedb70a393983cdc89c2ec443623a2882c9220bc50dc4ca8659412db81ab4b8
|
|
4
|
+
data.tar.gz: 3160931bea2ded23d11a63d636da6f4d7352b10dd250e4d01e4239c9030ce2ee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: edeaccd5502bc01f07d4c5e4bafc84fc8ac881db1b7140a9e3522bfb6ee6c15aeaf1bff9cb4dde1545b2143b65bf3a3d60e4e019a847bc8d92cc45c961b90ed3
|
|
7
|
+
data.tar.gz: 2364dfa70d9f7ab821898415e0ecbf08c815a4976fac1810d10c9f687aa8297de91df93cdb3ef4c7d6f175015d72b32abed8c1f2f0d0d2915ed831bde80f5ead
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarrett Lusso
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Ironclad
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/ironclad)
|
|
4
|
+
[](https://github.com/jclusso/ironclad/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
Source your Rails credential keys from 1Password instead of committing them to
|
|
7
|
+
the repo or leaving them as plaintext files on disk.
|
|
8
|
+
|
|
9
|
+
Ironclad reads each environment's key (`master.key`, `production.key`) from
|
|
10
|
+
1Password and caches it in the local OS keystore — the macOS Keychain or the
|
|
11
|
+
Linux kernel keyring — so repeated use doesn't round-trip to 1Password. It
|
|
12
|
+
ships:
|
|
13
|
+
|
|
14
|
+
- a CLI for printing a key or editing credentials,
|
|
15
|
+
- a Railtie that loads the current environment's key into `ENV` at boot,
|
|
16
|
+
- Capistrano helpers so deploys need no key file, and
|
|
17
|
+
- an install generator that wires it all into a Rails app.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
`bin/ironclad <env>` is a read-through cache:
|
|
22
|
+
|
|
23
|
+
1. Look up the key in the OS keystore (`<app>-credentials-<env>`).
|
|
24
|
+
2. On a miss, read it from 1Password (`op read`) and seed the cache.
|
|
25
|
+
|
|
26
|
+
The cache never expires. After rotating a key, refresh it with
|
|
27
|
+
`bin/ironclad <env> --refresh`. Platforms with no supported keystore simply
|
|
28
|
+
fetch from 1Password every call.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- The [1Password CLI](https://developer.1password.com/docs/cli/) (`op`), signed
|
|
33
|
+
in to the relevant account.
|
|
34
|
+
- macOS (`security`) or Linux (`keyctl`) for caching. Other platforms work but
|
|
35
|
+
don't cache.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Add it to your Gemfile:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
gem 'ironclad'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then install and run the generator:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
bundle install
|
|
49
|
+
bin/rails generate ironclad:install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The generator asks which environments you manage (so the config and the VS Code
|
|
53
|
+
dropdown match) and which optional integrations to set up (the VS Code "Edit
|
|
54
|
+
Credentials" task, Capistrano wiring). It writes `config/ironclad.yml` with a
|
|
55
|
+
`VAULT` placeholder reference per environment for you to fill in, plus a
|
|
56
|
+
`bin/ironclad` binstub.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
`config/ironclad.yml` maps each environment to a 1Password secret reference. The
|
|
61
|
+
environment names are entirely up to you — `default` is the development/master
|
|
62
|
+
key, and you can define as many others as you like (`staging`, `production`,
|
|
63
|
+
`qa`, `review`, …):
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
account: application-name
|
|
67
|
+
keys:
|
|
68
|
+
default: op://VAULT/application-name/master.key
|
|
69
|
+
production: op://VAULT/application-name/production.key
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `account` — 1Password account shorthand passed to `op --account` (optional).
|
|
73
|
+
- `app` — OS keystore cache namespace (`<app>-credentials-<env>`). Optional;
|
|
74
|
+
defaults to your Rails app name, so you normally omit it.
|
|
75
|
+
- `keys` — environment to 1Password reference. `default` is the development key;
|
|
76
|
+
add an entry for any other environment you need.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
### CLI
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
bin/ironclad # print the development key
|
|
84
|
+
bin/ironclad production # print the production key
|
|
85
|
+
bin/ironclad production --refresh # bypass the cache after a rotation
|
|
86
|
+
bin/ironclad edit staging # edit staging credentials in your editor
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Capistrano
|
|
90
|
+
|
|
91
|
+
Require the helpers in your `Capfile`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
require 'ironclad/capistrano'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This sets `RAILS_MASTER_KEY` for the current stage from 1Password and adds a
|
|
98
|
+
`credential(*keys)` helper for reading the stage's encrypted credentials during
|
|
99
|
+
a deploy — no `config/credentials/<env>.key` file required.
|
|
100
|
+
|
|
101
|
+
### CI
|
|
102
|
+
|
|
103
|
+
Set `RAILS_MASTER_KEY` from a secret in your CI environment rather than shipping
|
|
104
|
+
a key file. Ironclad's boot step is a no-op when the key is already present.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
bin/setup # install dependencies
|
|
110
|
+
bundle exec rake test # run the tests
|
|
111
|
+
bundle exec rubocop # lint
|
|
112
|
+
bin/console # interactive prompt
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
data/exe/ironclad
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/base'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
module Generators
|
|
7
|
+
# Installs Ironclad: asks which environments you manage, writes a
|
|
8
|
+
# config/ironclad.yml to fill in and a bin/ironclad binstub, then asks about
|
|
9
|
+
# optional integrations.
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
def create_config
|
|
14
|
+
create_file 'config/ironclad.yml', config_contents
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_binstub
|
|
18
|
+
copy_file 'binstub', 'bin/ironclad'
|
|
19
|
+
chmod 'bin/ironclad', 0o755
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_vscode_task
|
|
23
|
+
return unless yes?("Add a VS Code 'Edit Credentials' task? (y/N)")
|
|
24
|
+
|
|
25
|
+
template 'tasks.json', '.vscode/tasks.json'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def setup_capistrano
|
|
29
|
+
return unless File.exist?(File.join(destination_root, 'Capfile'))
|
|
30
|
+
return unless yes?('Wire Ironclad into your Capfile for deploys? (y/N)')
|
|
31
|
+
|
|
32
|
+
inject_into_file 'Capfile', "require 'ironclad/capistrano'\n",
|
|
33
|
+
after: %r{^require ['"]capistrano/setup['"].*\n}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_post_install
|
|
37
|
+
say <<~MESSAGE
|
|
38
|
+
|
|
39
|
+
Ironclad installed. Edit config/ironclad.yml and fill in your
|
|
40
|
+
1Password references. In CI / on servers, set RAILS_MASTER_KEY from a
|
|
41
|
+
secret instead.
|
|
42
|
+
|
|
43
|
+
Print a key: bin/ironclad [env]
|
|
44
|
+
Edit credentials: bin/ironclad edit [env]
|
|
45
|
+
MESSAGE
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def environments
|
|
51
|
+
@environments ||= ask(
|
|
52
|
+
'Environments to manage? (comma-separated)',
|
|
53
|
+
default: 'default'
|
|
54
|
+
).split(',').map(&:strip).reject(&:empty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def config_contents
|
|
58
|
+
keys = environments.map do |env|
|
|
59
|
+
field = env == 'default' ? 'master' : env
|
|
60
|
+
" #{env}: op://VAULT/#{app_name}/#{field}.key"
|
|
61
|
+
end.join("\n")
|
|
62
|
+
|
|
63
|
+
<<~YAML
|
|
64
|
+
# Maps each environment to a 1Password secret reference. Replace VAULT
|
|
65
|
+
# (and the item/field if needed) to match your vault. `default` is the
|
|
66
|
+
# development/master key.
|
|
67
|
+
account:
|
|
68
|
+
keys:
|
|
69
|
+
#{keys}
|
|
70
|
+
YAML
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def app_name
|
|
74
|
+
Rails.application.class.module_parent_name.underscore
|
|
75
|
+
rescue StandardError
|
|
76
|
+
File.basename(destination_root)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.0",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"label": "Edit Credentials",
|
|
6
|
+
"type": "shell",
|
|
7
|
+
"command": "\"${SHELL:-/bin/zsh}\" -lic \"EDITOR='code --wait' VISUAL='code --wait' bin/ironclad edit ${input:credentialsEnv}\"",
|
|
8
|
+
"options": {
|
|
9
|
+
"cwd": "${workspaceFolder}"
|
|
10
|
+
},
|
|
11
|
+
"problemMatcher": [],
|
|
12
|
+
"runOptions": {
|
|
13
|
+
"instanceLimit": 4
|
|
14
|
+
},
|
|
15
|
+
"presentation": {
|
|
16
|
+
"reveal": "never",
|
|
17
|
+
"revealProblems": "never",
|
|
18
|
+
"focus": false,
|
|
19
|
+
"panel": "new",
|
|
20
|
+
"showReuseMessage": false,
|
|
21
|
+
"close": true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"inputs": [
|
|
26
|
+
{
|
|
27
|
+
"id": "credentialsEnv",
|
|
28
|
+
"type": "pickString",
|
|
29
|
+
"description": "Which environment's credentials do you want to edit?",
|
|
30
|
+
"options": [<%= environments.map(&:inspect).join(', ') %>]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
module Cache
|
|
7
|
+
# macOS Keychain via the `security` tool. The cache never expires; a key
|
|
8
|
+
# rotation is handled by writing (-U updates) the new value.
|
|
9
|
+
class Keychain
|
|
10
|
+
def initialize(account)
|
|
11
|
+
@account = account
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read(name)
|
|
15
|
+
out, status = Open3.capture2(
|
|
16
|
+
'security', 'find-generic-password',
|
|
17
|
+
'-a', @account, '-s', name, '-w'
|
|
18
|
+
)
|
|
19
|
+
status.success? ? out.chomp : nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(name, key)
|
|
23
|
+
# The key passes via argv (briefly visible to the same user's `ps`); the
|
|
24
|
+
# security tool has no stdin input mode. Acceptable for a local cache.
|
|
25
|
+
system(
|
|
26
|
+
'security', 'add-generic-password', '-U',
|
|
27
|
+
'-a', @account, '-s', name, '-w', key,
|
|
28
|
+
out: File::NULL, err: File::NULL
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
module Cache
|
|
7
|
+
# Linux kernel keyring via `keyctl`, stored in the user keyring (@u): it
|
|
8
|
+
# persists across this user's sessions and clears on reboot, at which point
|
|
9
|
+
# a miss simply re-seeds from the source.
|
|
10
|
+
class Keyctl
|
|
11
|
+
def read(name)
|
|
12
|
+
id, status = Open3.capture2('keyctl', 'search', '@u', 'user', name)
|
|
13
|
+
return unless status.success?
|
|
14
|
+
|
|
15
|
+
out, status = Open3.capture2('keyctl', 'pipe', id.chomp)
|
|
16
|
+
status.success? ? out : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(name, key)
|
|
20
|
+
Open3.capture2('keyctl', 'padd', 'user', name, '@u', stdin_data: key)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ironclad
|
|
4
|
+
module Cache
|
|
5
|
+
# No-op cache for platforms without a supported OS keystore. Every lookup
|
|
6
|
+
# misses, so the key is fetched from the source each call.
|
|
7
|
+
class Null
|
|
8
|
+
def read(_name) = nil
|
|
9
|
+
|
|
10
|
+
def write(_name, _key) = nil
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative 'cache/keychain'
|
|
5
|
+
require_relative 'cache/keyctl'
|
|
6
|
+
require_relative 'cache/null'
|
|
7
|
+
|
|
8
|
+
module Ironclad
|
|
9
|
+
# Selects the OS keystore backend for the current platform.
|
|
10
|
+
module Cache
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def for_platform(account, host_os = RbConfig::CONFIG['host_os'])
|
|
14
|
+
case host_os
|
|
15
|
+
when /darwin/
|
|
16
|
+
Keychain.new(account)
|
|
17
|
+
when /linux/
|
|
18
|
+
keyctl_available? ? Keyctl.new : Null.new
|
|
19
|
+
else
|
|
20
|
+
Null.new
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def keyctl_available?
|
|
25
|
+
system('command -v keyctl', out: File::NULL, err: File::NULL)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/encrypted_configuration'
|
|
4
|
+
require_relative '../ironclad'
|
|
5
|
+
|
|
6
|
+
module Ironclad
|
|
7
|
+
# Capistrano DSL helpers. Require this from your Capfile:
|
|
8
|
+
#
|
|
9
|
+
# require "ironclad/capistrano"
|
|
10
|
+
#
|
|
11
|
+
# so deploys source RAILS_MASTER_KEY from the configured secrets manager and
|
|
12
|
+
# can read credentials without a key file on disk.
|
|
13
|
+
module Capistrano
|
|
14
|
+
# Set RAILS_MASTER_KEY for the current stage (respecting one already set).
|
|
15
|
+
def rails_master_key
|
|
16
|
+
env = fetch(:rails_env, fetch(:stage)).to_s
|
|
17
|
+
ENV['RAILS_MASTER_KEY'] = Ironclad.key(env)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read a value from the stage's encrypted credentials during a deploy.
|
|
21
|
+
def credential(*keys)
|
|
22
|
+
rails_master_key
|
|
23
|
+
env = fetch(:rails_env, fetch(:stage)).to_s
|
|
24
|
+
@ironclad_credentials ||= ActiveSupport::EncryptedConfiguration.new(
|
|
25
|
+
config_path: "config/credentials/#{env}.yml.enc",
|
|
26
|
+
key_path: "config/credentials/#{env}.key",
|
|
27
|
+
env_key: 'RAILS_MASTER_KEY',
|
|
28
|
+
raise_if_missing_key: true
|
|
29
|
+
)
|
|
30
|
+
@ironclad_credentials.dig(*keys) ||
|
|
31
|
+
raise("Rails credential `#{keys.join('.')}` is missing")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Capistrano::DSL.include(Ironclad::Capistrano) if defined?(Capistrano::DSL)
|
data/lib/ironclad/cli.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../ironclad'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
# Command-line entry point. Dependency-free arg parsing keeps boot cheap and
|
|
7
|
+
# avoids loading Rails just to print a key.
|
|
8
|
+
#
|
|
9
|
+
# ironclad [env] [--refresh] print the credentials key (default env)
|
|
10
|
+
# ironclad edit [env] edit Rails credentials for env
|
|
11
|
+
class CLI
|
|
12
|
+
def self.start(argv)
|
|
13
|
+
new(argv).run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(argv)
|
|
17
|
+
@argv = argv.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
case @argv.first
|
|
22
|
+
when 'edit'
|
|
23
|
+
@argv.shift
|
|
24
|
+
edit(@argv.shift || 'default')
|
|
25
|
+
when '-h', '--help', 'help'
|
|
26
|
+
print_help
|
|
27
|
+
else
|
|
28
|
+
print_key
|
|
29
|
+
end
|
|
30
|
+
0
|
|
31
|
+
rescue Error => e
|
|
32
|
+
warn e.message
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def print_key
|
|
39
|
+
refresh = @argv.delete('--refresh') ? true : false
|
|
40
|
+
env = @argv.shift || 'default'
|
|
41
|
+
validate_env!(env)
|
|
42
|
+
puts Ironclad.key(env, refresh: refresh)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def edit(env)
|
|
46
|
+
validate_env!(env)
|
|
47
|
+
ENV['RAILS_MASTER_KEY'] = Ironclad.key(env)
|
|
48
|
+
|
|
49
|
+
args = ['credentials:edit']
|
|
50
|
+
args.push('-e', env) unless env == 'default'
|
|
51
|
+
exec('bin/rails', *args)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_env!(env)
|
|
55
|
+
return if Ironclad.config.environments.include?(env)
|
|
56
|
+
|
|
57
|
+
raise Error, "Unknown environment: #{env} " \
|
|
58
|
+
"(expected #{Ironclad.config.environments.join(', ')})."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def print_help
|
|
62
|
+
puts <<~HELP
|
|
63
|
+
ironclad — Rails credential keys from 1Password, cached locally.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
ironclad [env] [--refresh] print the credentials key (env: default)
|
|
67
|
+
ironclad edit [env] edit Rails credentials for env
|
|
68
|
+
ironclad --help show this help
|
|
69
|
+
|
|
70
|
+
--refresh re-reads from the source after a key rotation.
|
|
71
|
+
Environments are defined in config/ironclad.yml.
|
|
72
|
+
HELP
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
# Project configuration, read from config/ironclad.yml so both the CLI (no
|
|
7
|
+
# Rails boot) and the Railtie can load it. Any environment names work — they
|
|
8
|
+
# only have to match the keys you define here.
|
|
9
|
+
#
|
|
10
|
+
# account: my_app # optional
|
|
11
|
+
# keys:
|
|
12
|
+
# default: op://VAULT/my_app/master.key
|
|
13
|
+
# production: op://VAULT/my_app/production.key
|
|
14
|
+
#
|
|
15
|
+
# `app` (the OS-keystore cache namespace) is optional; it defaults to the
|
|
16
|
+
# Rails app name read from config/application.rb.
|
|
17
|
+
class Config
|
|
18
|
+
attr_reader :app, :account, :keys
|
|
19
|
+
|
|
20
|
+
def self.load(path)
|
|
21
|
+
unless File.exist?(path)
|
|
22
|
+
raise Error, "Ironclad config not found at #{path}. " \
|
|
23
|
+
'Run `rails generate ironclad:install` to create it.'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
root = File.dirname(path, 2)
|
|
27
|
+
data = YAML.safe_load_file(path)
|
|
28
|
+
unless data.is_a?(Hash)
|
|
29
|
+
raise Error, "#{path} is empty or not a YAML mapping"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
new(data, root: root)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Cache namespace, derived from the app's module name in
|
|
36
|
+
# config/application.rb (falling back to the directory name) so the railtie
|
|
37
|
+
# and the no-Rails CLI agree on one OS-keystore entry per app.
|
|
38
|
+
def self.app_namespace(root)
|
|
39
|
+
return 'ironclad' unless root
|
|
40
|
+
|
|
41
|
+
app_rb = File.join(root, 'config', 'application.rb')
|
|
42
|
+
name = File.file?(app_rb) ? File.read(app_rb)[/^\s*module\s+([A-Za-z]\w*)/, 1] : nil
|
|
43
|
+
name ||= File.basename(root)
|
|
44
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(data, root: nil)
|
|
48
|
+
@app = data['app'] || self.class.app_namespace(root)
|
|
49
|
+
@account = data['account']
|
|
50
|
+
@keys = data.fetch('keys') { raise Error, 'ironclad.yml is missing `keys`' }
|
|
51
|
+
unless @keys.is_a?(Hash)
|
|
52
|
+
raise Error, 'ironclad.yml `keys` must be a mapping of environment => secret reference'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reference(environment)
|
|
57
|
+
keys.fetch(environment) do
|
|
58
|
+
raise Error, "No secret reference for `#{environment}`. " \
|
|
59
|
+
"Known environments: #{keys.keys.join(', ')}."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def environments
|
|
64
|
+
keys.keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def key?(environment)
|
|
68
|
+
keys.key?(environment)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The config key to use for a Rails environment: its own if defined,
|
|
72
|
+
# otherwise `default` (the master key). Nil if neither exists.
|
|
73
|
+
def key_for(environment)
|
|
74
|
+
return environment if key?(environment)
|
|
75
|
+
|
|
76
|
+
'default' if key?('default')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# OS keystore service name for an environment's cached key.
|
|
80
|
+
def cache_key(environment)
|
|
81
|
+
"#{app}-credentials-#{environment}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ironclad
|
|
4
|
+
# Read-through cache: keys are read from the local OS keystore and pulled from
|
|
5
|
+
# the source only on a miss, so repeated calls don't round-trip to it.
|
|
6
|
+
class KeyStore
|
|
7
|
+
def initialize(config, cache: nil, source: nil)
|
|
8
|
+
@config = config
|
|
9
|
+
@cache = cache || Cache.for_platform(config.app)
|
|
10
|
+
# Defaults to 1Password; inject another source to use a different manager.
|
|
11
|
+
@source = source || Source::OnePassword.new(config.account)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Return the key for an environment. With refresh: true, skip the cache and
|
|
15
|
+
# re-seed it from the source (use after a key rotation).
|
|
16
|
+
def key(environment, refresh: false)
|
|
17
|
+
name = @config.cache_key(environment)
|
|
18
|
+
|
|
19
|
+
unless refresh
|
|
20
|
+
cached = @cache.read(name)
|
|
21
|
+
return cached if cached && !cached.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
fetched = @source.read(@config.reference(environment))
|
|
25
|
+
@cache.write(name, fetched)
|
|
26
|
+
fetched
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ironclad
|
|
4
|
+
# Loads the credentials key for the current environment into ENV at boot, so a
|
|
5
|
+
# fresh checkout works with no key file on disk. Each Rails environment uses
|
|
6
|
+
# its own key if one is defined in config/ironclad.yml, otherwise the
|
|
7
|
+
# `default` (master) key.
|
|
8
|
+
#
|
|
9
|
+
# A no-op when RAILS_MASTER_KEY is already set (deployed servers and CI provide
|
|
10
|
+
# the key directly) or when Ironclad isn't configured yet.
|
|
11
|
+
class Railtie < Rails::Railtie
|
|
12
|
+
config.before_configuration do
|
|
13
|
+
Ironclad.config_path = Rails.root.join('config', 'ironclad.yml').to_s
|
|
14
|
+
next if ENV['RAILS_MASTER_KEY'] || !Ironclad.configured?
|
|
15
|
+
|
|
16
|
+
name = Ironclad.config.key_for(ENV.fetch('RAILS_ENV', 'development'))
|
|
17
|
+
ENV['RAILS_MASTER_KEY'] = Ironclad.key(name) if name
|
|
18
|
+
rescue Ironclad::Error => e
|
|
19
|
+
# Don't abort boot if the credential source is unavailable (e.g. `op` not
|
|
20
|
+
# signed in or installed). Rails surfaces its own missing-key error later
|
|
21
|
+
# only if credentials are actually accessed.
|
|
22
|
+
warn "Ironclad: could not load credentials key (#{e.message}). " \
|
|
23
|
+
'Try `op signin` or set RAILS_MASTER_KEY.'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Ironclad
|
|
6
|
+
module Source
|
|
7
|
+
# Reads a secret from 1Password via the `op` CLI.
|
|
8
|
+
class OnePassword
|
|
9
|
+
def initialize(account = nil)
|
|
10
|
+
@account = account
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(reference)
|
|
14
|
+
cmd = ['op', 'read', reference]
|
|
15
|
+
cmd.push('--account', @account) if @account
|
|
16
|
+
|
|
17
|
+
out, err, status = Open3.capture3(*cmd)
|
|
18
|
+
unless status.success?
|
|
19
|
+
message = 'Could not read the credentials key from 1Password. ' \
|
|
20
|
+
'Authorize the prompt and retry, or run: op signin'
|
|
21
|
+
detail = err.strip
|
|
22
|
+
message += " (#{detail})" unless detail.empty?
|
|
23
|
+
raise Error, message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
out.chomp
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ironclad
|
|
4
|
+
# Namespace for secret sources. A source responds to #read(reference) and
|
|
5
|
+
# returns the secret as a string, raising Ironclad::Error on failure.
|
|
6
|
+
# OnePassword is the only source today; add another manager as a sibling
|
|
7
|
+
# class here and have KeyStore select it.
|
|
8
|
+
module Source
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/ironclad.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ironclad/version'
|
|
4
|
+
require_relative 'ironclad/config'
|
|
5
|
+
require_relative 'ironclad/source'
|
|
6
|
+
require_relative 'ironclad/source/one_password'
|
|
7
|
+
require_relative 'ironclad/cache'
|
|
8
|
+
require_relative 'ironclad/key_store'
|
|
9
|
+
|
|
10
|
+
# Sources Rails credential keys from a secrets manager, cached in the local OS
|
|
11
|
+
# keystore.
|
|
12
|
+
module Ironclad
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Path to the project's ironclad.yml. Override before first use if needed.
|
|
17
|
+
attr_writer :config_path
|
|
18
|
+
|
|
19
|
+
def config_path
|
|
20
|
+
@config_path ||= File.join(Dir.pwd, 'config', 'ironclad.yml')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configured?
|
|
24
|
+
File.exist?(config_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def config
|
|
28
|
+
@config ||= Config.load(config_path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def store
|
|
32
|
+
@store ||= KeyStore.new(config)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return the credentials key for an environment, using the read-through
|
|
36
|
+
# cache. Pass refresh: true to bypass the cache after a key rotation.
|
|
37
|
+
def key(environment = 'default', refresh: false)
|
|
38
|
+
store.key(environment.to_s, refresh: refresh)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reset memoized state (mainly for tests).
|
|
42
|
+
def reset!
|
|
43
|
+
@config = nil
|
|
44
|
+
@store = nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
require_relative 'ironclad/railtie' if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ironclad
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jarrett Lusso
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rubocop-cache-ventures
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest-mock
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: minitest-reporters
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
description: Ironclad sources Rails credential keys from 1Password and caches them
|
|
83
|
+
in the local OS keystore (macOS Keychain or the Linux kernel keyring), so no key
|
|
84
|
+
files live on disk. Ships a CLI, a Railtie that loads the development key at boot,
|
|
85
|
+
Capistrano helpers, and an install generator.
|
|
86
|
+
email:
|
|
87
|
+
- jclusso@gmail.com
|
|
88
|
+
executables:
|
|
89
|
+
- ironclad
|
|
90
|
+
extensions: []
|
|
91
|
+
extra_rdoc_files: []
|
|
92
|
+
files:
|
|
93
|
+
- LICENSE.txt
|
|
94
|
+
- README.md
|
|
95
|
+
- exe/ironclad
|
|
96
|
+
- lib/generators/ironclad/install_generator.rb
|
|
97
|
+
- lib/generators/ironclad/templates/binstub
|
|
98
|
+
- lib/generators/ironclad/templates/tasks.json
|
|
99
|
+
- lib/ironclad.rb
|
|
100
|
+
- lib/ironclad/cache.rb
|
|
101
|
+
- lib/ironclad/cache/keychain.rb
|
|
102
|
+
- lib/ironclad/cache/keyctl.rb
|
|
103
|
+
- lib/ironclad/cache/null.rb
|
|
104
|
+
- lib/ironclad/capistrano.rb
|
|
105
|
+
- lib/ironclad/cli.rb
|
|
106
|
+
- lib/ironclad/config.rb
|
|
107
|
+
- lib/ironclad/key_store.rb
|
|
108
|
+
- lib/ironclad/railtie.rb
|
|
109
|
+
- lib/ironclad/source.rb
|
|
110
|
+
- lib/ironclad/source/one_password.rb
|
|
111
|
+
- lib/ironclad/version.rb
|
|
112
|
+
homepage: https://github.com/jclusso/ironclad
|
|
113
|
+
licenses:
|
|
114
|
+
- MIT
|
|
115
|
+
metadata:
|
|
116
|
+
allowed_push_host: https://rubygems.org
|
|
117
|
+
homepage_uri: https://github.com/jclusso/ironclad
|
|
118
|
+
source_code_uri: https://github.com/jclusso/ironclad
|
|
119
|
+
documentation_uri: https://github.com/jclusso/ironclad
|
|
120
|
+
changelog_uri: https://github.com/jclusso/ironclad/releases
|
|
121
|
+
bug_tracker_uri: https://github.com/jclusso/ironclad/issues
|
|
122
|
+
github_repo: ssh://github.com/jclusso/ironclad
|
|
123
|
+
rubygems_mfa_required: 'true'
|
|
124
|
+
rdoc_options: []
|
|
125
|
+
require_paths:
|
|
126
|
+
- lib
|
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: 3.3.0
|
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0'
|
|
137
|
+
requirements: []
|
|
138
|
+
rubygems_version: 3.6.9
|
|
139
|
+
specification_version: 4
|
|
140
|
+
summary: 1Password-backed, OS-keystore-cached Rails credential keys.
|
|
141
|
+
test_files: []
|