opaque_id 1.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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'minitest/test_task'
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module OpaqueId
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ argument :table_name, type: :string, default: nil, banner: 'table_name'
14
+
15
+ class_option :column_name, type: :string, default: 'opaque_id',
16
+ desc: 'Name of the column to add'
17
+
18
+ def create_migration_file
19
+ if table_name.present?
20
+ migration_template 'migration.rb.tt',
21
+ "db/migrate/add_opaque_id_to_#{table_name}.rb"
22
+
23
+ add_include_to_model
24
+ else
25
+ say 'Usage: rails generate opaque_id:install TABLE_NAME', :red
26
+ say 'Example: rails generate opaque_id:install posts', :green
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def add_include_to_model
33
+ model_path = "app/models/#{table_name.singularize}.rb"
34
+
35
+ if File.exist?(model_path)
36
+ # Read existing model file
37
+ content = File.read(model_path)
38
+
39
+ # Check if already included
40
+ if content.include?('include OpaqueId::Model')
41
+ say "OpaqueId::Model already included in #{model_path}", :yellow
42
+ else
43
+ # Add include statement
44
+ content.gsub!(/class #{table_name.classify} < ApplicationRecord/,
45
+ "class #{table_name.classify} < ApplicationRecord\n include OpaqueId::Model")
46
+
47
+ # Write back to file
48
+ File.write(model_path, content)
49
+ say "Updated #{model_path}", :green
50
+ end
51
+ else
52
+ say "Model file #{model_path} not found. Please add 'include OpaqueId::Model' manually.", :yellow
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,6 @@
1
+ class AddOpaqueIdTo<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :<%= table_name %>, :<%= options[:column_name] %>, :string
4
+ add_index :<%= table_name %>, :<%= options[:column_name] %>, unique: true
5
+ end
6
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module OpaqueId
6
+ module Model
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_create :set_opaque_id
11
+ end
12
+
13
+ class_methods do
14
+ def find_by_opaque_id(opaque_id)
15
+ where(opaque_id_column => opaque_id).first
16
+ end
17
+
18
+ def find_by_opaque_id!(opaque_id)
19
+ find_by_opaque_id(opaque_id) || raise(ActiveRecord::RecordNotFound,
20
+ "Couldn't find #{name} with opaque_id=#{opaque_id}")
21
+ end
22
+
23
+ # Configuration options
24
+ def opaque_id_column
25
+ @opaque_id_column ||= :opaque_id
26
+ end
27
+
28
+ def opaque_id_column=(value)
29
+ @opaque_id_column = value
30
+ end
31
+
32
+ def opaque_id_length
33
+ @opaque_id_length ||= 21
34
+ end
35
+
36
+ def opaque_id_length=(value)
37
+ @opaque_id_length = value
38
+ end
39
+
40
+ def opaque_id_alphabet
41
+ @opaque_id_alphabet ||= OpaqueId::ALPHANUMERIC_ALPHABET
42
+ end
43
+
44
+ def opaque_id_alphabet=(value)
45
+ @opaque_id_alphabet = value
46
+ end
47
+
48
+ def opaque_id_require_letter_start
49
+ @opaque_id_require_letter_start ||= false
50
+ end
51
+
52
+ def opaque_id_require_letter_start=(value)
53
+ @opaque_id_require_letter_start = value
54
+ end
55
+
56
+ def opaque_id_purge_chars
57
+ @opaque_id_purge_chars ||= []
58
+ end
59
+
60
+ def opaque_id_purge_chars=(value)
61
+ @opaque_id_purge_chars = value
62
+ end
63
+
64
+ def opaque_id_max_retry
65
+ @opaque_id_max_retry ||= 3
66
+ end
67
+
68
+ def opaque_id_max_retry=(value)
69
+ @opaque_id_max_retry = value
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def set_opaque_id
76
+ return if send(opaque_id_column).present?
77
+
78
+ opaque_id_max_retry.times do
79
+ generated_id = generate_opaque_id
80
+ send("#{opaque_id_column}=", generated_id)
81
+ return unless self.class.where(opaque_id_column => generated_id).exists?
82
+ end
83
+ raise GenerationError, "Failed to generate a unique opaque_id after #{opaque_id_max_retry} attempts"
84
+ end
85
+
86
+ def generate_opaque_id
87
+ loop do
88
+ id = OpaqueId.generate(
89
+ size: opaque_id_length,
90
+ alphabet: opaque_id_alphabet
91
+ )
92
+
93
+ # Apply letter start requirement
94
+ next if opaque_id_require_letter_start && !id.match?(/\A[A-Za-z]/)
95
+
96
+ # Apply character purging
97
+ if opaque_id_purge_chars.any?
98
+ id = id.chars.reject { |char| opaque_id_purge_chars.include?(char) }.join
99
+ next if id.length < opaque_id_length
100
+ end
101
+
102
+ return id
103
+ end
104
+ end
105
+
106
+ def opaque_id_column
107
+ self.class.opaque_id_column
108
+ end
109
+
110
+ def opaque_id_length
111
+ self.class.opaque_id_length
112
+ end
113
+
114
+ def opaque_id_alphabet
115
+ self.class.opaque_id_alphabet
116
+ end
117
+
118
+ def opaque_id_max_retry
119
+ self.class.opaque_id_max_retry
120
+ end
121
+
122
+ def opaque_id_require_letter_start
123
+ self.class.opaque_id_require_letter_start
124
+ end
125
+
126
+ def opaque_id_purge_chars
127
+ self.class.opaque_id_purge_chars
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpaqueId
4
+ VERSION = '1.1.0'
5
+ end
data/lib/opaque_id.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'opaque_id/version'
5
+ require_relative 'opaque_id/model'
6
+
7
+ module OpaqueId
8
+ class Error < StandardError; end
9
+ class GenerationError < Error; end
10
+ class ConfigurationError < Error; end
11
+
12
+ # Standard URL-safe alphabet (64 characters)
13
+ STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
14
+
15
+ # Alphanumeric alphabet (62 characters)
16
+ ALPHANUMERIC_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
17
+
18
+ class << self
19
+ # Generate a cryptographically secure random ID
20
+ def generate(size: 21, alphabet: ALPHANUMERIC_ALPHABET)
21
+ raise ConfigurationError, 'Size must be positive' unless size.positive?
22
+ raise ConfigurationError, 'Alphabet cannot be empty' if alphabet.nil? || alphabet.empty?
23
+
24
+ alphabet_size = alphabet.size
25
+
26
+ # Handle edge case: single character alphabet
27
+ return alphabet * size if alphabet_size == 1
28
+
29
+ return generate_fast(size, alphabet) if alphabet_size == 64
30
+
31
+ generate_unbiased(size, alphabet, alphabet_size)
32
+ end
33
+
34
+ private
35
+
36
+ def generate_fast(size, alphabet)
37
+ bytes = SecureRandom.random_bytes(size).bytes
38
+ size.times.map { |i| alphabet[bytes[i] & 63] }.join
39
+ end
40
+
41
+ def generate_unbiased(size, alphabet, alphabet_size)
42
+ mask = (2 << Math.log2(alphabet_size - 1).floor) - 1
43
+ step = (1.6 * mask * size / alphabet_size).ceil
44
+ id = String.new(capacity: size)
45
+
46
+ loop do
47
+ bytes = SecureRandom.random_bytes(step).bytes
48
+ bytes.each do |byte|
49
+ masked_byte = byte & mask
50
+ if masked_byte < alphabet_size
51
+ id << alphabet[masked_byte]
52
+ return id if id.size == size
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ {
2
+ "release-type": "ruby",
3
+ "packages": {
4
+ ".": {
5
+ "package-name": "opaque_id",
6
+ "version-file": "lib/opaque_id/version.rb"
7
+ }
8
+ },
9
+ "changelog-sections": [
10
+ {
11
+ "type": "feat",
12
+ "section": "Features"
13
+ },
14
+ {
15
+ "type": "fix",
16
+ "section": "Bug Fixes"
17
+ },
18
+ {
19
+ "type": "perf",
20
+ "section": "Performance Improvements"
21
+ },
22
+ {
23
+ "type": "revert",
24
+ "section": "Reverts"
25
+ },
26
+ {
27
+ "type": "docs",
28
+ "section": "Documentation"
29
+ },
30
+ {
31
+ "type": "style",
32
+ "section": "Styles"
33
+ },
34
+ {
35
+ "type": "chore",
36
+ "section": "Miscellaneous Chores"
37
+ },
38
+ {
39
+ "type": "refactor",
40
+ "section": "Code Refactoring"
41
+ },
42
+ {
43
+ "type": "test",
44
+ "section": "Tests"
45
+ },
46
+ {
47
+ "type": "build",
48
+ "section": "Build System"
49
+ },
50
+ {
51
+ "type": "ci",
52
+ "section": "Continuous Integration"
53
+ }
54
+ ]
55
+ }
data/sig/opaque_id.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module OpaqueId
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,202 @@
1
+ # Product Requirements Document: OpaqueId Ruby Gem
2
+
3
+ ## Introduction/Overview
4
+
5
+ The OpaqueId gem is a Ruby library that generates cryptographically secure, collision-free opaque identifiers for ActiveRecord models. This gem replaces the existing `nanoid.rb` dependency by implementing the same functionality using Ruby's built-in `SecureRandom` methods, eliminating external dependencies while maintaining the same security and performance characteristics.
6
+
7
+ The primary problem this gem solves is preventing the exposure of incremental database IDs in public URLs and APIs, which can reveal business metrics, enable enumeration attacks, and expose internal system architecture. Instead, it provides opaque, non-sequential identifiers that are URL-friendly and cryptographically secure.
8
+
9
+ ## Goals
10
+
11
+ 1. **Replace nanoid.rb dependency** - Implement equivalent functionality using Ruby's built-in SecureRandom
12
+ 2. **Maintain security standards** - Provide cryptographically secure ID generation with unbiased distribution
13
+ 3. **Ensure performance** - Achieve 1M+ IDs/sec for 64-character alphabets, 180K+ IDs/sec for 36-character alphabets
14
+ 4. **Simplify integration** - Provide seamless ActiveRecord integration with minimal configuration
15
+ 5. **Enable wide adoption** - Create comprehensive documentation accessible to all Rails developers
16
+ 6. **Ensure reliability** - Implement robust collision detection and retry logic
17
+ 7. **Maintain compatibility** - Support Rails 8.0+ and Ruby 3.2+ environments
18
+
19
+ ## User Stories
20
+
21
+ ### Primary Users: Rails Developers
22
+
23
+ **As a Rails developer**, I want to generate opaque IDs for my models so that I can expose public identifiers without revealing database structure.
24
+
25
+ **As a Rails developer**, I want to easily integrate opaque ID generation into my existing models so that I don't have to manually implement ID generation logic.
26
+
27
+ **As a Rails developer**, I want to configure ID generation parameters (length, alphabet, column name) so that I can customize the behavior for different use cases.
28
+
29
+ **As a Rails developer**, I want to find records by their opaque ID so that I can build public-facing APIs and URLs.
30
+
31
+ **As a Rails developer**, I want the gem to handle collision detection automatically so that I don't have to worry about duplicate IDs.
32
+
33
+ **As a Rails developer**, I want comprehensive documentation and examples so that I can quickly understand and implement the gem in my project.
34
+
35
+ ### Secondary Users: Security-Conscious Teams
36
+
37
+ **As a security-conscious developer**, I want cryptographically secure ID generation so that my public identifiers cannot be predicted or enumerated.
38
+
39
+ **As a security-conscious developer**, I want unbiased character distribution so that my IDs have maximum entropy and cannot be analyzed for patterns.
40
+
41
+ ## Functional Requirements
42
+
43
+ ### Core ID Generation
44
+
45
+ 1. The system must generate cryptographically secure random IDs using Ruby's `SecureRandom`
46
+ 2. The system must implement rejection sampling algorithm to ensure unbiased character distribution
47
+ 3. The system must provide optimized fast path for 64-character alphabets using bitwise operations
48
+ 4. The system must support custom alphabet configurations
49
+ 5. The system must support custom ID length configurations
50
+ 6. The system must provide two predefined alphabets: ALPHANUMERIC_ALPHABET (36 chars) and STANDARD_ALPHABET (64 chars)
51
+
52
+ ### ActiveRecord Integration
53
+
54
+ 7. The system must provide `OpaqueId::Model` concern for easy ActiveRecord integration
55
+ 8. The system must automatically generate opaque IDs on model creation via `before_create` callback
56
+ 9. The system must provide `find_by_opaque_id` and `find_by_opaque_id!` class methods
57
+ 10. The system must support custom column names via `opaque_id_column` configuration
58
+ 11. The system must implement collision detection with configurable retry attempts
59
+ 12. The system must raise appropriate errors when collision resolution fails
60
+
61
+ ### Rails Generator (Optional Convenience Tool)
62
+
63
+ 13. The system must provide optional Rails generator `opaque_id:install` for creating migrations and updating models
64
+ 14. The system must generate migration files that add opaque_id column with unique index
65
+ 15. The system must automatically add `include OpaqueId::Model` to the corresponding model file
66
+ 16. The system must support custom column names via generator options
67
+ 17. The system must require explicit table name argument and show clear usage instructions when run without arguments
68
+ 18. The system must work with any existing table (new or existing models)
69
+ 19. The system must handle edge cases gracefully (missing model file, already included, different class names)
70
+
71
+ ### Configuration Options
72
+
73
+ 20. The system must support `opaque_id_column` configuration (default: `:opaque_id`)
74
+ 21. The system must support `opaque_id_length` configuration (default: `18`)
75
+ 22. The system must support `opaque_id_alphabet` configuration (default: `ALPHANUMERIC_ALPHABET`)
76
+ 23. The system must support `opaque_id_require_letter_start` configuration (default: `true`)
77
+ 24. The system must support `opaque_id_purge_chars` configuration (default: `nil`)
78
+ 25. The system must support `opaque_id_max_retry` configuration (default: `1000`)
79
+
80
+ ### Error Handling
81
+
82
+ 26. The system must raise `OpaqueId::ConfigurationError` for invalid size or empty alphabet
83
+ 27. The system must raise `OpaqueId::GenerationError` when collision resolution fails
84
+ 28. The system must provide clear error messages for debugging
85
+
86
+ ### Standalone Usage
87
+
88
+ 29. The system must provide `OpaqueId.generate` method for standalone ID generation
89
+ 30. The system must support all configuration options in standalone generation
90
+ 31. The system must maintain the same security and performance characteristics in standalone mode
91
+
92
+ ## Non-Goals (Out of Scope)
93
+
94
+ 1. **Other ORM Support** - Will not support Mongoid, Sequel, or other ORMs in initial release
95
+ 2. **Non-Rails Usage** - Will not provide standalone Ruby usage without ActiveRecord dependency
96
+ 3. **Custom Algorithms** - Will not implement alternative ID generation algorithms beyond rejection sampling
97
+ 4. **Database Migrations** - Will not provide automatic database migration for existing records
98
+ 5. **ID Validation** - Will not provide built-in ID format validation (users can implement their own)
99
+ 6. **Bulk Generation** - Will not provide optimized bulk ID generation methods
100
+ 7. **ID Prefixes/Suffixes** - Will not support adding prefixes or suffixes to generated IDs
101
+ 8. **Custom Random Sources** - Will not support custom random number generators beyond SecureRandom
102
+ 9. **Interactive Generator Mode** - Will not provide interactive prompts for generator arguments
103
+ 10. **Backward Compatibility** - Will not maintain compatibility with existing `public_id` implementations
104
+
105
+ ## Design Considerations
106
+
107
+ ### API Design
108
+
109
+ - Follow Rails conventions for ActiveRecord concerns and generators
110
+ - Use descriptive method names that clearly indicate functionality
111
+ - Provide both safe (`find_by_opaque_id`) and unsafe (`find_by_opaque_id!`) lookup methods
112
+ - Use class-level configuration options for easy customization
113
+ - Follow Devise-style generator pattern for seamless integration
114
+
115
+ ### Performance Optimization
116
+
117
+ - Implement fast path for 64-character alphabets using bitwise operations (`byte & 63`)
118
+ - Use rejection sampling with optimal mask calculation for unbiased distribution
119
+ - Pre-allocate string capacity to avoid memory reallocation during generation
120
+ - Batch random byte generation to minimize SecureRandom calls
121
+
122
+ ### Security Considerations
123
+
124
+ - Use only cryptographically secure random number generation
125
+ - Implement proper rejection sampling to avoid modulo bias
126
+ - Provide sufficient entropy through configurable alphabet sizes
127
+ - Ensure IDs cannot be predicted or enumerated
128
+
129
+ ## Technical Considerations
130
+
131
+ ### Dependencies
132
+
133
+ - **ActiveRecord**: >= 6.0 (targeting Rails 8.0+ compatibility)
134
+ - **ActiveSupport**: >= 6.0 (for concern functionality)
135
+ - **Ruby**: >= 3.2 (for modern Ruby features and performance)
136
+
137
+ ### Testing Framework
138
+
139
+ - Use **Minitest** instead of RSpec for consistency with Rails conventions
140
+ - Implement comprehensive unit tests for all public methods
141
+ - Include statistical tests for character distribution uniformity
142
+ - Add performance benchmarks to ensure performance requirements are met
143
+ - Test edge cases including collision scenarios and error conditions
144
+
145
+ ### File Structure
146
+
147
+ ```
148
+ lib/
149
+ ├── opaque_id.rb # Main module with generator
150
+ ├── opaque_id/
151
+ │ ├── version.rb # Version constant
152
+ │ └── model.rb # ActiveRecord concern
153
+ └── generators/
154
+ └── opaque_id/
155
+ ├── install_generator.rb # Migration generator
156
+ └── templates/
157
+ └── migration.rb.tt # Migration template
158
+ ```
159
+
160
+ ### Error Classes
161
+
162
+ - `OpaqueId::Error` - Base error class
163
+ - `OpaqueId::GenerationError` - ID generation failures
164
+ - `OpaqueId::ConfigurationError` - Invalid configuration
165
+
166
+ ## Success Metrics
167
+
168
+ ### Performance Metrics
169
+
170
+ - **Standard alphabet (64 chars)**: Achieve ~1.2M IDs/sec generation rate
171
+ - **Alphanumeric alphabet (36 chars)**: Achieve ~180K IDs/sec generation rate
172
+ - **Custom alphabet (20 chars)**: Achieve ~150K IDs/sec generation rate
173
+
174
+ ### Quality Metrics
175
+
176
+ - **Test Coverage**: Achieve 95%+ code coverage
177
+ - **Documentation**: Provide comprehensive README with examples
178
+ - **Error Handling**: All error conditions properly tested and documented
179
+
180
+ ### Adoption Metrics
181
+
182
+ - **Gem Downloads**: Target 1000+ downloads in first month
183
+ - **GitHub Stars**: Target 50+ stars within 6 months
184
+ - **Community Feedback**: Positive reception from Rails community
185
+
186
+ ## Open Questions
187
+
188
+ 1. **Version Strategy**: Should we follow semantic versioning strictly, or use a different versioning strategy for a utility gem?
189
+
190
+ 2. **Backward Compatibility**: How should we handle potential breaking changes in future versions, especially regarding default configurations?
191
+
192
+ 3. **Performance Testing**: Should we include automated performance regression testing in CI/CD, or rely on manual benchmarking?
193
+
194
+ 4. **Documentation Hosting**: Should we create a dedicated documentation site, or rely on GitHub README and inline documentation?
195
+
196
+ 5. **Community Contributions**: What level of community contribution should we expect, and how should we structure the project to encourage contributions?
197
+
198
+ 6. **Integration Testing**: Should we test against multiple Rails versions in CI, or focus on the target Rails 8.0+ range?
199
+
200
+ 7. **Security Auditing**: Should we implement any security auditing tools or processes for the random number generation?
201
+
202
+ 8. **Migration Path**: How should we help users migrate from nanoid.rb to opaque_id, if at all?