legion-data 1.1.5 → 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 +10 -2
- data/.rubocop.yml +41 -18
- data/CHANGELOG.md +82 -15
- data/CLAUDE.md +199 -0
- data/Gemfile +11 -1
- data/LICENSE +201 -0
- data/README.md +163 -19
- data/exe/legionio_migrate +0 -0
- data/legion-data.gemspec +22 -35
- 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 +18 -0
- data/lib/legion/data/migrations/003_add_settings.rb +18 -0
- data/lib/legion/data/migrations/004_add_extensions.rb +23 -0
- data/lib/legion/data/migrations/005_add_runners.rb +21 -0
- data/lib/legion/data/migrations/006_add_functions.rb +21 -0
- data/lib/legion/data/migrations/{015_add_default_extensions.rb → 007_add_default_extensions.rb} +3 -0
- data/lib/legion/data/migrations/008_add_tasks.rb +23 -0
- 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 +5 -2
- 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/extension.rb +0 -0
- data/lib/legion/data/models/function.rb +0 -2
- data/lib/legion/data/models/node.rb +17 -3
- 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 +3 -6
- data/lib/legion/data/models/runner.rb +0 -0
- data/lib/legion/data/models/setting.rb +0 -0
- data/lib/legion/data/models/task.rb +0 -0
- data/lib/legion/data/models/task_log.rb +0 -0
- data/lib/legion/data/settings.rb +37 -8
- data/lib/legion/data/version.rb +3 -1
- data/lib/legion/data.rb +31 -13
- metadata +64 -139
- data/.circleci/config.yml +0 -174
- data/.rspec +0 -1
- data/Gemfile.lock +0 -85
- data/Rakefile +0 -55
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/bitbucket-pipelines.yml +0 -26
- data/lib/legion/data/migrations/002_add_users.rb +0 -17
- data/lib/legion/data/migrations/003_add_groups.rb +0 -16
- data/lib/legion/data/migrations/004_add_chains.rb +0 -25
- data/lib/legion/data/migrations/005_add_envs.rb +0 -24
- data/lib/legion/data/migrations/006_add_dcs.rb +0 -24
- data/lib/legion/data/migrations/007_add_nodes.rb +0 -26
- data/lib/legion/data/migrations/008_add_settings.rb +0 -18
- data/lib/legion/data/migrations/009_add_extensions.rb +0 -25
- data/lib/legion/data/migrations/010_add_runners.rb +0 -21
- data/lib/legion/data/migrations/011_add_functions.rb +0 -29
- data/lib/legion/data/migrations/012_add_tasks.rb +0 -28
- data/lib/legion/data/migrations/013_add_task_logs.rb +0 -23
- data/lib/legion/data/migrations/014_add_relationships.rb +0 -27
- data/lib/legion/data/migrations/016_change_task_args.rb +0 -7
- data/lib/legion/data/migrations/017_add_payload_task.rb +0 -7
- data/lib/legion/data/migrations/018_add_migration_column.rb +0 -7
- data/lib/legion/data/migrations/019_add_debug_to_relationships.rb +0 -7
- data/lib/legion/data/migrations/020_add_delay_debug_to_tasks.rb +0 -8
- data/lib/legion/data/models/chain.rb +0 -11
- data/lib/legion/data/models/datacenter.rb +0 -11
- data/lib/legion/data/models/environment.rb +0 -11
- data/lib/legion/data/models/group.rb +0 -10
- data/lib/legion/data/models/user.rb +0 -10
data/README.md
CHANGED
|
@@ -1,37 +1,181 @@
|
|
|
1
|
-
#
|
|
1
|
+
# legion-data
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Persistent database storage for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides database connectivity via Sequel ORM, automatic schema migrations, and data models for extensions, functions, runners, nodes, tasks, settings, digital workers, task relationships, and Apollo shared knowledge tables.
|
|
4
|
+
|
|
5
|
+
Version: 1.3.0
|
|
6
|
+
|
|
7
|
+
## Supported Databases
|
|
8
|
+
|
|
9
|
+
| Database | Adapter | Gem | Default |
|
|
10
|
+
|----------|---------|-----|---------|
|
|
11
|
+
| SQLite | `sqlite` | `sqlite3` (included) | Yes |
|
|
12
|
+
| MySQL | `mysql2` | `mysql2` | No |
|
|
13
|
+
| PostgreSQL | `postgres` | `pg` | No |
|
|
14
|
+
|
|
15
|
+
SQLite is the default adapter and requires no external database server. For MySQL or PostgreSQL, install the corresponding gem and set the adapter in your configuration.
|
|
5
16
|
|
|
6
17
|
## Installation
|
|
7
18
|
|
|
8
|
-
|
|
19
|
+
```bash
|
|
20
|
+
gem install legion-data
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or add to your Gemfile:
|
|
9
24
|
|
|
10
25
|
```ruby
|
|
11
26
|
gem 'legion-data'
|
|
12
|
-
```
|
|
13
27
|
|
|
14
|
-
|
|
28
|
+
# Add one of these for production databases:
|
|
29
|
+
# gem 'mysql2', '>= 0.5.5'
|
|
30
|
+
# gem 'pg', '>= 1.5'
|
|
31
|
+
```
|
|
15
32
|
|
|
16
|
-
|
|
33
|
+
## Data Models
|
|
17
34
|
|
|
18
|
-
|
|
35
|
+
| Model | Table | Description |
|
|
36
|
+
|-------|-------|-------------|
|
|
37
|
+
| `Extension` | `extensions` | Installed LEX extensions |
|
|
38
|
+
| `Function` | `functions` | Available functions per extension |
|
|
39
|
+
| `Runner` | `runners` | Runner definitions (extension + function bindings) |
|
|
40
|
+
| `Node` | `nodes` | Cluster node registry |
|
|
41
|
+
| `Task` | `tasks` | Task instances |
|
|
42
|
+
| `TaskLog` | `task_logs` | Task execution logs |
|
|
43
|
+
| `Setting` | `settings` | Persistent settings store |
|
|
44
|
+
| `DigitalWorker` | `digital_workers` | Digital worker registry (AI-as-labor platform) |
|
|
45
|
+
| `Relationship` | `relationships` | Task trigger/action relationships between functions |
|
|
46
|
+
| `ApolloEntry` | `apollo_entries` | Apollo shared knowledge entries (PostgreSQL only) |
|
|
47
|
+
| `ApolloRelation` | `apollo_relations` | Relations between Apollo knowledge entries (PostgreSQL only) |
|
|
48
|
+
| `ApolloExpertise` | `apollo_expertise` | Per-agent domain expertise tracking (PostgreSQL only) |
|
|
49
|
+
| `ApolloAccessLog` | `apollo_access_log` | Apollo entry access audit log (PostgreSQL only) |
|
|
19
50
|
|
|
20
|
-
|
|
51
|
+
Apollo models require PostgreSQL with the `pgvector` extension. They are skipped silently on SQLite and MySQL.
|
|
21
52
|
|
|
22
53
|
## Usage
|
|
23
54
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
55
|
+
```ruby
|
|
56
|
+
require 'legion/data'
|
|
57
|
+
|
|
58
|
+
# Standard setup (shared DB + local SQLite)
|
|
59
|
+
Legion::Data.setup
|
|
60
|
+
Legion::Data.connection # => Sequel::Database (shared)
|
|
61
|
+
Legion::Data.local.connection # => Sequel::SQLite::Database (local cognitive state)
|
|
62
|
+
Legion::Data::Model::Extension.all # => Sequel::Dataset
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Local Database
|
|
66
|
+
|
|
67
|
+
v1.3.0 introduces `Legion::Data::Local`, a parallel SQLite database always stored locally on the node. It is used for agentic cognitive state persistence (memory traces, trust scores, dream journals, etc.) and is independent of the shared database.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# Local DB is set up automatically during Legion::Data.setup
|
|
71
|
+
# Extensions register their own migration directories
|
|
72
|
+
Legion::Data::Local.register_migrations(name: :memory, path: '/path/to/migrations')
|
|
73
|
+
|
|
74
|
+
# Create a model bound to the local connection
|
|
75
|
+
MyModel = Legion::Data::Local.model(:my_table)
|
|
76
|
+
|
|
77
|
+
# Check status
|
|
78
|
+
Legion::Data::Local.connected? # => true
|
|
79
|
+
Legion::Data::Local.db_path # => "legionio_local.db"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The local database file (`legionio_local.db` by default) can be deleted for cryptographic erasure — no residual data. This is used by `lex-privatecore`.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
### SQLite (default)
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"data": {
|
|
91
|
+
"adapter": "sqlite",
|
|
92
|
+
"creds": {
|
|
93
|
+
"database": "legionio.db"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### MySQL
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"data": {
|
|
104
|
+
"adapter": "mysql2",
|
|
105
|
+
"creds": {
|
|
106
|
+
"username": "legion",
|
|
107
|
+
"password": "legion",
|
|
108
|
+
"database": "legionio",
|
|
109
|
+
"host": "127.0.0.1",
|
|
110
|
+
"port": 3306
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
28
114
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
115
|
+
|
|
116
|
+
### PostgreSQL
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"data": {
|
|
121
|
+
"adapter": "postgres",
|
|
122
|
+
"creds": {
|
|
123
|
+
"user": "legion",
|
|
124
|
+
"password": "legion",
|
|
125
|
+
"database": "legionio",
|
|
126
|
+
"host": "127.0.0.1",
|
|
127
|
+
"port": 5432
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
32
131
|
```
|
|
33
|
-
and the framework will take care of the rest.
|
|
34
132
|
|
|
35
|
-
|
|
133
|
+
PostgreSQL with `pgvector` is required for Apollo models. Install the extension in your database before running migrations:
|
|
134
|
+
|
|
135
|
+
```sql
|
|
136
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
137
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Local Database
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"data": {
|
|
145
|
+
"local": {
|
|
146
|
+
"enabled": true,
|
|
147
|
+
"database": "legionio_local.db",
|
|
148
|
+
"migrations": {
|
|
149
|
+
"auto_migrate": true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Set `enabled: false` to disable local SQLite entirely.
|
|
157
|
+
|
|
158
|
+
### Dev Mode Fallback
|
|
159
|
+
|
|
160
|
+
When `dev_mode: true` and a network database (MySQL/PostgreSQL) is unreachable, the shared connection falls back to SQLite automatically instead of raising.
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"data": {
|
|
165
|
+
"dev_mode": true,
|
|
166
|
+
"dev_fallback": true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### HashiCorp Vault Integration
|
|
172
|
+
|
|
173
|
+
When Vault is connected and a `database/creds/legion` secret path exists, credentials are fetched dynamically from Vault at connection time, overriding any static `creds` configuration.
|
|
174
|
+
|
|
175
|
+
## Requirements
|
|
176
|
+
|
|
177
|
+
- Ruby >= 3.4
|
|
178
|
+
|
|
179
|
+
## License
|
|
36
180
|
|
|
37
|
-
|
|
181
|
+
Apache-2.0
|
|
File without changes
|
data/legion-data.gemspec
CHANGED
|
@@ -1,46 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/data/version'
|
|
4
4
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name =
|
|
6
|
+
spec.name = 'legion-data'
|
|
7
7
|
spec.version = Legion::Data::VERSION
|
|
8
8
|
spec.authors = ['Esity']
|
|
9
9
|
spec.email = ['matthewdiverson@gmail.com']
|
|
10
10
|
|
|
11
|
-
spec.summary = '
|
|
12
|
-
spec.description = '
|
|
13
|
-
spec.homepage
|
|
14
|
-
spec.
|
|
15
|
-
|
|
16
|
-
spec.metadata['bug_tracker_uri'] = 'https://bitbucket.org/legion-io/legion-data/issues?status=new&status=open'
|
|
17
|
-
spec.metadata['changelog_uri'] = 'https://bitbucket.org/legion-io/legion-data/src/CHANGELOG.md'
|
|
18
|
-
spec.metadata['documentation_uri'] = 'https://bitbucket.org/legion-io/legion-data'
|
|
19
|
-
spec.metadata['homepage_uri'] = 'https://bitbucket.org/legion-io/legion-data'
|
|
20
|
-
spec.metadata['source_code_uri'] = 'https://bitbucket.org/legion-io/legion-data'
|
|
21
|
-
spec.metadata['wiki_uri'] = 'https://bitbucket.org/legion-io/legion-data/wiki/Home'
|
|
22
|
-
|
|
23
|
-
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
24
|
-
f.match(%r{^(test|spec|features)/})
|
|
25
|
-
end
|
|
26
|
-
spec.bindir = 'bin'
|
|
27
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
11
|
+
spec.summary = 'Manages the connects to the backend database'
|
|
12
|
+
spec.description = 'A LegionIO gem to connect to a persistent data store'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/legion-data'
|
|
14
|
+
spec.license = 'Apache-2.0'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
28
16
|
spec.require_paths = ['lib']
|
|
29
|
-
|
|
30
|
-
spec.
|
|
31
|
-
spec.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
18
|
+
spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md]
|
|
19
|
+
spec.metadata = {
|
|
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
|
+
}
|
|
36
28
|
|
|
37
29
|
spec.add_dependency 'legion-logging'
|
|
38
30
|
spec.add_dependency 'legion-settings'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
spec.add_dependency 'jdbc-mysql'
|
|
42
|
-
else
|
|
43
|
-
spec.add_dependency 'mysql2'
|
|
44
|
-
end
|
|
45
|
-
spec.add_dependency 'sequel'
|
|
31
|
+
spec.add_dependency 'sequel', '>= 5.70'
|
|
32
|
+
spec.add_dependency 'sqlite3', '>= 2.0'
|
|
46
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
|