legion-data 1.2.0 → 1.3.7
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 +4 -4
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +41 -12
- data/CHANGELOG.md +80 -1
- data/CLAUDE.md +199 -0
- data/Gemfile +4 -0
- data/LICENSE +1 -1
- data/README.md +157 -50
- data/exe/legionio_migrate +0 -0
- data/legion-data.gemspec +13 -13
- data/lib/legion/data/connection.rb +39 -25
- data/lib/legion/data/encryption/cipher.rb +49 -0
- data/lib/legion/data/encryption/key_provider.rb +45 -0
- data/lib/legion/data/encryption/sequel_plugin.rb +54 -0
- data/lib/legion/data/event_store/projection.rb +56 -0
- data/lib/legion/data/event_store.rb +112 -0
- data/lib/legion/data/local.rb +77 -0
- data/lib/legion/data/migration.rb +5 -3
- data/lib/legion/data/migrations/001_add_schema_columns.rb +9 -3
- data/lib/legion/data/migrations/002_add_nodes.rb +10 -12
- data/lib/legion/data/migrations/003_add_settings.rb +10 -10
- data/lib/legion/data/migrations/004_add_extensions.rb +15 -17
- data/lib/legion/data/migrations/005_add_runners.rb +13 -13
- data/lib/legion/data/migrations/006_add_functions.rb +13 -15
- data/lib/legion/data/migrations/007_add_default_extensions.rb +2 -0
- data/lib/legion/data/migrations/008_add_tasks.rb +15 -21
- data/lib/legion/data/migrations/009_add_digital_workers.rb +45 -0
- data/lib/legion/data/migrations/010_add_value_metrics.rb +19 -0
- data/lib/legion/data/migrations/011_add_extensions_registry.rb +30 -0
- data/lib/legion/data/migrations/012_add_apollo_tables.rb +66 -0
- data/lib/legion/data/migrations/013_add_relationships.rb +21 -0
- data/lib/legion/data/migrations/014_add_relationship_columns.rb +27 -0
- data/lib/legion/data/migrations/015_add_rbac_tables.rb +49 -0
- data/lib/legion/data/migrations/016_add_worker_health.rb +33 -0
- data/lib/legion/data/migrations/017_add_audit_log.rb +30 -0
- data/lib/legion/data/migrations/018_add_governance_events.rb +21 -0
- data/lib/legion/data/migrations/019_add_audit_hash_chain.rb +29 -0
- data/lib/legion/data/migrations/020_add_webhooks.rb +37 -0
- data/lib/legion/data/model.rb +4 -1
- data/lib/legion/data/models/apollo_access_log.rb +13 -0
- data/lib/legion/data/models/apollo_entry.rb +18 -0
- data/lib/legion/data/models/apollo_expertise.rb +12 -0
- data/lib/legion/data/models/apollo_relation.rb +14 -0
- data/lib/legion/data/models/audit_log.rb +34 -0
- data/lib/legion/data/models/digital_worker.rb +44 -0
- data/lib/legion/data/models/function.rb +2 -2
- data/lib/legion/data/models/node.rb +16 -0
- data/lib/legion/data/models/rbac_cross_team_grant.rb +33 -0
- data/lib/legion/data/models/rbac_role_assignment.rb +29 -0
- data/lib/legion/data/models/rbac_runner_grant.rb +21 -0
- data/lib/legion/data/models/relationship.rb +13 -0
- data/lib/legion/data/settings.rb +54 -25
- data/lib/legion/data/version.rb +3 -1
- data/lib/legion/data.rb +20 -1
- metadata +51 -32
- data/.github/workflows/rubocop-analysis.yml +0 -28
- data/.github/workflows/sourcehawk-scan.yml +0 -20
- data/CODE_OF_CONDUCT.md +0 -75
- data/CONTRIBUTING.md +0 -55
- data/INDIVIDUAL_CONTRIBUTOR_LICENSE.md +0 -30
- data/NOTICE.txt +0 -9
- data/SECURITY.md +0 -9
- data/attribution.txt +0 -1
- data/sourcehawk.yml +0 -4
data/legion-data.gemspec
CHANGED
|
@@ -6,28 +6,28 @@ Gem::Specification.new do |spec|
|
|
|
6
6
|
spec.name = 'legion-data'
|
|
7
7
|
spec.version = Legion::Data::VERSION
|
|
8
8
|
spec.authors = ['Esity']
|
|
9
|
-
spec.email =
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
10
|
|
|
11
11
|
spec.summary = 'Manages the connects to the backend database'
|
|
12
12
|
spec.description = 'A LegionIO gem to connect to a persistent data store'
|
|
13
|
-
spec.homepage = 'https://github.com/
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/legion-data'
|
|
14
14
|
spec.license = 'Apache-2.0'
|
|
15
|
-
spec.required_ruby_version = '>=
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
16
|
spec.require_paths = ['lib']
|
|
17
17
|
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
18
|
-
spec.
|
|
19
|
-
spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md]
|
|
18
|
+
spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md]
|
|
20
19
|
spec.metadata = {
|
|
21
|
-
'bug_tracker_uri'
|
|
22
|
-
'changelog_uri'
|
|
23
|
-
'documentation_uri'
|
|
24
|
-
'homepage_uri'
|
|
25
|
-
'source_code_uri'
|
|
26
|
-
'wiki_uri'
|
|
20
|
+
'bug_tracker_uri' => 'https://github.com/LegionIO/legion-data/issues',
|
|
21
|
+
'changelog_uri' => 'https://github.com/LegionIO/legion-data/blob/main/CHANGELOG.md',
|
|
22
|
+
'documentation_uri' => 'https://github.com/LegionIO/legion-data',
|
|
23
|
+
'homepage_uri' => 'https://github.com/LegionIO/LegionIO',
|
|
24
|
+
'source_code_uri' => 'https://github.com/LegionIO/legion-data',
|
|
25
|
+
'wiki_uri' => 'https://github.com/LegionIO/legion-data/wiki',
|
|
26
|
+
'rubygems_mfa_required' => 'true'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
spec.add_dependency 'legion-logging'
|
|
30
30
|
spec.add_dependency 'legion-settings'
|
|
31
|
-
spec.add_dependency '
|
|
32
|
-
spec.add_dependency '
|
|
31
|
+
spec.add_dependency 'sequel', '>= 5.70'
|
|
32
|
+
spec.add_dependency 'sqlite3', '>= 2.0'
|
|
33
33
|
end
|
|
@@ -1,27 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'sequel'
|
|
2
4
|
|
|
3
5
|
module Legion
|
|
4
6
|
module Data
|
|
5
7
|
module Connection
|
|
8
|
+
ADAPTERS = %i[sqlite mysql2 postgres].freeze
|
|
9
|
+
|
|
6
10
|
class << self
|
|
7
11
|
attr_accessor :sequel
|
|
8
12
|
|
|
9
13
|
def adapter
|
|
10
|
-
@adapter ||=
|
|
14
|
+
@adapter ||= Legion::Settings[:data][:adapter]&.to_sym || :sqlite
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def setup
|
|
14
|
-
@sequel = if adapter == :
|
|
15
|
-
::Sequel.
|
|
18
|
+
@sequel = if adapter == :sqlite
|
|
19
|
+
::Sequel.sqlite(sqlite_path)
|
|
16
20
|
else
|
|
17
|
-
|
|
21
|
+
begin
|
|
22
|
+
::Sequel.connect(adapter: adapter, **creds_builder)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
raise unless dev_fallback?
|
|
25
|
+
|
|
26
|
+
if defined?(Legion::Logging)
|
|
27
|
+
Legion::Logging.warn(
|
|
28
|
+
"Shared DB unreachable (#{e.message}), dev_mode fallback to SQLite"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
@adapter = :sqlite
|
|
32
|
+
::Sequel.sqlite(sqlite_path)
|
|
33
|
+
end
|
|
18
34
|
end
|
|
19
35
|
Legion::Settings[:data][:connected] = true
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@sequel.logger = Legion::Logging
|
|
23
|
-
@sequel.sql_log_level = Legion::Settings[:data][:connection][:sql_log_level]
|
|
24
|
-
@sequel.log_warn_duration = Legion::Settings[:data][:connection][:log_warn_duration]
|
|
36
|
+
configure_logging
|
|
25
37
|
end
|
|
26
38
|
|
|
27
39
|
def shutdown
|
|
@@ -30,15 +42,9 @@ module Legion
|
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
def creds_builder(final_creds = {})
|
|
33
|
-
final_creds.merge! Legion::Data::Settings.creds
|
|
45
|
+
final_creds.merge! Legion::Data::Settings.creds(adapter)
|
|
34
46
|
final_creds.merge! Legion::Settings[:data][:creds] if Legion::Settings[:data][:creds].is_a? Hash
|
|
35
47
|
|
|
36
|
-
# if Legion::Settings[:data][:connection][:max_connections].is_a? Integer
|
|
37
|
-
# final_creds[:max_connections] = Legion::Settings[:data][:connection][:max_connections]
|
|
38
|
-
# end
|
|
39
|
-
|
|
40
|
-
# final_creds[:preconnect] = :concurrently if Legion::Settings[:data][:connection][:preconnect]
|
|
41
|
-
|
|
42
48
|
return final_creds if Legion::Settings[:vault].nil?
|
|
43
49
|
|
|
44
50
|
if Legion::Settings[:vault][:connected] && ::Vault.sys.mounts.key?(:database)
|
|
@@ -50,15 +56,23 @@ module Legion
|
|
|
50
56
|
final_creds
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def dev_fallback?
|
|
62
|
+
data_settings = Legion::Settings[:data]
|
|
63
|
+
data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sqlite_path
|
|
67
|
+
Legion::Settings[:data][:creds][:database] || 'legionio.db'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configure_logging
|
|
71
|
+
return if Legion::Settings[:data][:connection].nil? || Legion::Settings[:data][:connection][:log].nil?
|
|
72
|
+
|
|
73
|
+
@sequel.logger = Legion::Logging
|
|
74
|
+
@sequel.sql_log_level = Legion::Settings[:data][:connection][:sql_log_level]
|
|
75
|
+
@sequel.log_warn_duration = Legion::Settings[:data][:connection][:log_warn_duration]
|
|
62
76
|
end
|
|
63
77
|
end
|
|
64
78
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Data
|
|
7
|
+
module Encryption
|
|
8
|
+
module Cipher
|
|
9
|
+
VERSION_BYTE = "\x01".b.freeze
|
|
10
|
+
IV_LENGTH = 12
|
|
11
|
+
TAG_LENGTH = 16
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def encrypt(plaintext, key:, aad: '')
|
|
15
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
|
|
16
|
+
iv = OpenSSL::Random.random_bytes(IV_LENGTH)
|
|
17
|
+
cipher.key = key
|
|
18
|
+
cipher.iv = iv
|
|
19
|
+
cipher.auth_data = aad
|
|
20
|
+
|
|
21
|
+
ciphertext = cipher.update(plaintext.to_s) + cipher.final
|
|
22
|
+
tag = cipher.auth_tag(TAG_LENGTH)
|
|
23
|
+
|
|
24
|
+
VERSION_BYTE + iv + ciphertext + tag
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def decrypt(blob, key:, aad: '')
|
|
28
|
+
raise ArgumentError, 'data too short' if blob.bytesize < 1 + IV_LENGTH + TAG_LENGTH
|
|
29
|
+
|
|
30
|
+
version = blob.byteslice(0, 1)
|
|
31
|
+
raise ArgumentError, "unsupported version: #{version.unpack1('C')}" unless version == VERSION_BYTE
|
|
32
|
+
|
|
33
|
+
iv = blob.byteslice(1, IV_LENGTH)
|
|
34
|
+
tag = blob.byteslice(-TAG_LENGTH, TAG_LENGTH)
|
|
35
|
+
ciphertext = blob.byteslice(1 + IV_LENGTH, blob.bytesize - 1 - IV_LENGTH - TAG_LENGTH)
|
|
36
|
+
|
|
37
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
|
|
38
|
+
cipher.key = key
|
|
39
|
+
cipher.iv = iv
|
|
40
|
+
cipher.auth_tag = tag
|
|
41
|
+
cipher.auth_data = aad
|
|
42
|
+
|
|
43
|
+
cipher.update(ciphertext) + cipher.final
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Data
|
|
7
|
+
module Encryption
|
|
8
|
+
class KeyProvider
|
|
9
|
+
def initialize(mode: :auto)
|
|
10
|
+
@mode = mode
|
|
11
|
+
@key_cache = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def key_for(tenant_id: nil)
|
|
15
|
+
cache_key = tenant_id || '__default__'
|
|
16
|
+
@key_cache[cache_key] ||= derive_key(tenant_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear_cache!
|
|
20
|
+
@key_cache.clear
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def derive_key(tenant_id)
|
|
26
|
+
if tenant_id && crypt_available?
|
|
27
|
+
Legion::Crypt::PartitionKeys.derive(tenant_id: tenant_id)
|
|
28
|
+
elsif crypt_available?
|
|
29
|
+
Legion::Crypt.default_encryption_key
|
|
30
|
+
else
|
|
31
|
+
local_key
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def crypt_available?
|
|
36
|
+
defined?(Legion::Crypt::PartitionKeys)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def local_key
|
|
40
|
+
OpenSSL::Digest.digest('SHA256', 'legion-dev-encryption-key')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cipher'
|
|
4
|
+
require_relative 'key_provider'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Data
|
|
8
|
+
module Encryption
|
|
9
|
+
module SequelPlugin
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def encrypted_columns
|
|
12
|
+
@encrypted_columns ||= {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def encrypted_column(name, key_scope: :default)
|
|
16
|
+
col_scope = key_scope
|
|
17
|
+
encrypted_columns[name] = { key_scope: col_scope }
|
|
18
|
+
|
|
19
|
+
define_method(name) do
|
|
20
|
+
raw = super()
|
|
21
|
+
return nil if raw.nil?
|
|
22
|
+
|
|
23
|
+
provider = self.class.encryption_key_provider
|
|
24
|
+
tenant = col_scope == :tenant ? self[:tenant_id] : nil
|
|
25
|
+
key = provider.key_for(tenant_id: tenant)
|
|
26
|
+
aad = "#{self.class.table_name}:#{pk}:#{name}"
|
|
27
|
+
Legion::Data::Encryption::Cipher.decrypt(raw.b, key: key, aad: aad)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
define_method(:"#{name}=") do |value|
|
|
31
|
+
if value.nil?
|
|
32
|
+
super(nil)
|
|
33
|
+
else
|
|
34
|
+
provider = self.class.encryption_key_provider
|
|
35
|
+
tenant = col_scope == :tenant ? self[:tenant_id] : nil
|
|
36
|
+
key = provider.key_for(tenant_id: tenant)
|
|
37
|
+
aad = "#{self.class.table_name}:#{pk || 0}:#{name}"
|
|
38
|
+
encrypted = Legion::Data::Encryption::Cipher.encrypt(value.to_s, key: key, aad: aad)
|
|
39
|
+
super(Sequel.blob(encrypted))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encryption_key_provider
|
|
45
|
+
@encryption_key_provider ||= KeyProvider.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module InstanceMethods
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Data
|
|
5
|
+
module EventStore
|
|
6
|
+
class Projection
|
|
7
|
+
attr_reader :state
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@state = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(_event)
|
|
14
|
+
raise NotImplementedError, "#{self.class} must implement #apply"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.build_from(stream, since: nil)
|
|
18
|
+
projection = new
|
|
19
|
+
events = EventStore.read_stream(stream, since: since)
|
|
20
|
+
events.each { |e| projection.apply(e) }
|
|
21
|
+
projection
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class ConsentState < Projection
|
|
26
|
+
def apply(event)
|
|
27
|
+
scope = event.dig(:data, :scope)
|
|
28
|
+
return unless scope
|
|
29
|
+
|
|
30
|
+
case event[:type]
|
|
31
|
+
when 'consent.granted', 'consent.modified'
|
|
32
|
+
@state[scope] = event.dig(:data, :tier)
|
|
33
|
+
when 'consent.revoked'
|
|
34
|
+
@state.delete(scope)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class GovernanceTimeline < Projection
|
|
40
|
+
def initialize
|
|
41
|
+
super
|
|
42
|
+
@state = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def apply(event)
|
|
46
|
+
@state << {
|
|
47
|
+
type: event[:type],
|
|
48
|
+
stream: event[:stream],
|
|
49
|
+
at: event[:created_at],
|
|
50
|
+
data: event[:data]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Data
|
|
7
|
+
module EventStore
|
|
8
|
+
GOVERNANCE_EVENT_TYPES = %w[
|
|
9
|
+
consent.granted consent.revoked consent.modified
|
|
10
|
+
extinction.triggered extinction.resolved
|
|
11
|
+
worker.registered worker.retired worker.transferred
|
|
12
|
+
scope.approved scope.violated scope.reconciled
|
|
13
|
+
audit.retention_applied audit.exported
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def append(stream:, type:, data: {}, metadata: {})
|
|
18
|
+
return { error: 'db unavailable' } unless db_ready?
|
|
19
|
+
|
|
20
|
+
conn = Legion::Data.connection
|
|
21
|
+
conn.transaction do
|
|
22
|
+
last = conn[:governance_events]
|
|
23
|
+
.where(stream_id: stream)
|
|
24
|
+
.order(Sequel.desc(:sequence_number))
|
|
25
|
+
.first
|
|
26
|
+
|
|
27
|
+
seq = (last&.[](:sequence_number) || 0) + 1
|
|
28
|
+
prev_hash = last&.[](:event_hash) || ('0' * 64)
|
|
29
|
+
|
|
30
|
+
data_json = Legion::JSON.dump(data)
|
|
31
|
+
metadata_json = Legion::JSON.dump(metadata)
|
|
32
|
+
event_hash = compute_hash(stream, seq, type, data_json, prev_hash)
|
|
33
|
+
|
|
34
|
+
conn[:governance_events].insert(
|
|
35
|
+
stream_id: stream,
|
|
36
|
+
event_type: type,
|
|
37
|
+
sequence_number: seq,
|
|
38
|
+
data_json: data_json,
|
|
39
|
+
metadata_json: metadata_json,
|
|
40
|
+
event_hash: event_hash,
|
|
41
|
+
previous_hash: prev_hash,
|
|
42
|
+
created_at: Time.now
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
{ stream: stream, sequence: seq, hash: event_hash }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def read_stream(stream, since: nil)
|
|
50
|
+
return [] unless db_ready?
|
|
51
|
+
|
|
52
|
+
ds = Legion::Data.connection[:governance_events].where(stream_id: stream)
|
|
53
|
+
ds = ds.where { created_at >= since } if since
|
|
54
|
+
ds.order(:sequence_number).all.map { |e| deserialize(e) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_by_type(type, since: nil, limit: 100)
|
|
58
|
+
return [] unless db_ready?
|
|
59
|
+
|
|
60
|
+
ds = Legion::Data.connection[:governance_events].where(event_type: type)
|
|
61
|
+
ds = ds.where { created_at >= since } if since
|
|
62
|
+
ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |e| deserialize(e) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_chain(stream)
|
|
66
|
+
return { valid: false, error: 'db unavailable' } unless db_ready?
|
|
67
|
+
|
|
68
|
+
events = Legion::Data.connection[:governance_events]
|
|
69
|
+
.where(stream_id: stream)
|
|
70
|
+
.order(:sequence_number)
|
|
71
|
+
.all
|
|
72
|
+
|
|
73
|
+
prev_hash = '0' * 64
|
|
74
|
+
events.each do |e|
|
|
75
|
+
expected = compute_hash(stream, e[:sequence_number], e[:event_type], e[:data_json], prev_hash)
|
|
76
|
+
return { valid: false, broken_at: e[:sequence_number] } unless e[:event_hash] == expected
|
|
77
|
+
return { valid: false, broken_at: e[:sequence_number] } unless e[:previous_hash] == prev_hash
|
|
78
|
+
|
|
79
|
+
prev_hash = e[:event_hash]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ valid: true, length: events.size }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def compute_hash(stream, seq, type, data_json, prev_hash)
|
|
88
|
+
Digest::SHA256.hexdigest("#{stream}:#{seq}:#{type}:#{data_json}:#{prev_hash}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def deserialize(event)
|
|
92
|
+
{
|
|
93
|
+
id: event[:id],
|
|
94
|
+
stream: event[:stream_id],
|
|
95
|
+
type: event[:event_type],
|
|
96
|
+
sequence: event[:sequence_number],
|
|
97
|
+
data: Legion::JSON.load(event[:data_json] || '{}'),
|
|
98
|
+
metadata: Legion::JSON.load(event[:metadata_json] || '{}'),
|
|
99
|
+
hash: event[:event_hash],
|
|
100
|
+
created_at: event[:created_at]
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def db_ready?
|
|
105
|
+
defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:governance_events)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sequel'
|
|
4
|
+
require 'sequel/extensions/migration'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Data
|
|
8
|
+
module Local
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :connection, :db_path
|
|
11
|
+
|
|
12
|
+
def setup(database: nil, **)
|
|
13
|
+
return if @connected
|
|
14
|
+
|
|
15
|
+
db_file = database || local_settings[:database] || 'legionio_local.db'
|
|
16
|
+
@db_path = db_file
|
|
17
|
+
@connection = ::Sequel.sqlite(db_file)
|
|
18
|
+
@connected = true
|
|
19
|
+
run_migrations
|
|
20
|
+
Legion::Logging.info "Legion::Data::Local connected to #{db_file}" if defined?(Legion::Logging)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def shutdown
|
|
24
|
+
@connection&.disconnect
|
|
25
|
+
@connection = nil
|
|
26
|
+
@connected = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def connected?
|
|
30
|
+
@connected == true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def register_migrations(name:, path:)
|
|
34
|
+
@registered_migrations ||= {}
|
|
35
|
+
@registered_migrations[name] = path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def registered_migrations
|
|
39
|
+
@registered_migrations || {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def model(table_name)
|
|
43
|
+
raise 'Legion::Data::Local not connected' unless connected?
|
|
44
|
+
|
|
45
|
+
::Sequel::Model(connection[table_name])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset!
|
|
49
|
+
@connection = nil
|
|
50
|
+
@connected = false
|
|
51
|
+
@db_path = nil
|
|
52
|
+
@registered_migrations = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_migrations
|
|
58
|
+
return unless local_settings.dig(:migrations, :auto_migrate) != false
|
|
59
|
+
|
|
60
|
+
registered_migrations.each_value do |path|
|
|
61
|
+
next unless File.directory?(path)
|
|
62
|
+
|
|
63
|
+
::Sequel::TimestampMigrator.new(@connection, path).run
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Legion::Logging.warn "Local migration failed for #{path}: #{e.message}" if defined?(Legion::Logging)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def local_settings
|
|
70
|
+
return {} unless defined?(Legion::Settings)
|
|
71
|
+
|
|
72
|
+
Legion::Settings[:data]&.dig(:local) || {}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'sequel/extensions/migration'
|
|
2
4
|
|
|
3
5
|
module Legion
|
|
4
6
|
module Data
|
|
5
7
|
module Migration
|
|
6
8
|
class << self
|
|
7
|
-
def migrate(connection = Legion::Data.connection, path = "#{__dir__}/migrations", **
|
|
8
|
-
Legion::Settings[:data][:migrations][:version] = Sequel::Migrator.run(connection, path, **
|
|
9
|
-
Legion::Logging.info("Legion::Data::Migration ran successfully to version #{Legion::Settings[:data][:migrations][:version]}")
|
|
9
|
+
def migrate(connection = Legion::Data.connection, path = "#{__dir__}/migrations", **)
|
|
10
|
+
Legion::Settings[:data][:migrations][:version] = Sequel::Migrator.run(connection, path, **)
|
|
11
|
+
Legion::Logging.info("Legion::Data::Migration ran successfully to version #{Legion::Settings[:data][:migrations][:version]}")
|
|
10
12
|
Legion::Settings[:data][:migrations][:ran] = true
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'sequel/extensions/migration'
|
|
2
4
|
|
|
3
5
|
Sequel.migration do
|
|
4
6
|
up do
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
alter_table(:schema_info) do
|
|
8
|
+
# SQLite does not support non-constant defaults in ALTER TABLE ADD COLUMN,
|
|
9
|
+
# so we omit the default here and let the application set timestamps.
|
|
10
|
+
add_column :created_at, DateTime, null: true
|
|
11
|
+
add_column :updated_at, DateTime, null: true
|
|
12
|
+
add_column :catalog, String, size: 255, null: true
|
|
13
|
+
end
|
|
8
14
|
end
|
|
9
15
|
|
|
10
16
|
down do
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
Sequel.migration do
|
|
2
4
|
up do
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
UNIQUE KEY `name` (`name`),
|
|
12
|
-
KEY `active` (`active`),
|
|
13
|
-
KEY `status` (`status`)
|
|
14
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
|
|
5
|
+
create_table(:nodes) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :name, size: 128, null: false, default: '', unique: true
|
|
8
|
+
String :status, size: 255, null: false, default: 'unknown', index: true
|
|
9
|
+
TrueClass :active, null: false, default: true, index: true
|
|
10
|
+
DateTime :created, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
11
|
+
DateTime :updated, null: true
|
|
12
|
+
end
|
|
15
13
|
end
|
|
16
14
|
|
|
17
15
|
down do
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
Sequel.migration do
|
|
2
4
|
up do
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
UNIQUE KEY `key` (`key`)
|
|
12
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
|
|
5
|
+
create_table(:settings) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :key, size: 128, null: false, unique: true
|
|
8
|
+
String :value, size: 256, null: false
|
|
9
|
+
TrueClass :encrypted, null: false, default: false
|
|
10
|
+
DateTime :created, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
11
|
+
DateTime :updated, null: true
|
|
12
|
+
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
down do
|
|
@@ -1,22 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
Sequel.migration do
|
|
2
4
|
up do
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
KEY `name` (`name`),
|
|
17
|
-
KEY `namespace` (`namespace`),
|
|
18
|
-
key `schema_version` (`schema_version`)
|
|
19
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
|
|
5
|
+
create_table(:extensions) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
TrueClass :active, null: false, default: true, index: true
|
|
8
|
+
String :name, size: 128, null: false, index: true
|
|
9
|
+
String :namespace, size: 128, null: false, default: '', index: true
|
|
10
|
+
String :exchange, size: 255, null: true
|
|
11
|
+
String :uri, size: 256, null: true
|
|
12
|
+
Integer :schema_version, null: false, default: 0, index: true
|
|
13
|
+
DateTime :updated, null: true
|
|
14
|
+
DateTime :created, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
15
|
+
|
|
16
|
+
unique %i[name namespace]
|
|
17
|
+
end
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
down do
|