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 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
+ [![Gem Version](https://badge.fury.io/rb/ironclad.svg)](https://rubygems.org/gems/ironclad)
4
+ [![CI](https://github.com/jclusso/ironclad/actions/workflows/ci.yml/badge.svg)](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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ironclad/cli'
5
+
6
+ exit Ironclad::CLI.start(ARGV)
@@ -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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'rubygems'
5
+ require 'bundler/setup'
6
+
7
+ load Gem.bin_path('ironclad', 'ironclad')
@@ -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)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ironclad
4
+ VERSION = '0.1.0'
5
+ 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: []