lex-identity 0.3.0 → 0.4.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -6
  3. data/lex-identity.gemspec +12 -15
  4. data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
  5. data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
  6. data/lib/legion/extensions/identity/client.rb +23 -0
  7. data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
  8. data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
  9. data/lib/legion/extensions/identity/helpers/graph_client.rb +25 -0
  10. data/lib/legion/extensions/identity/helpers/graph_token.rb +32 -0
  11. data/lib/legion/extensions/identity/helpers/token_cache.rb +55 -0
  12. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
  13. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
  14. data/lib/legion/extensions/identity/runners/entra.rb +398 -0
  15. data/lib/legion/extensions/identity/runners/identity.rb +86 -0
  16. data/lib/legion/extensions/identity/version.rb +1 -1
  17. data/lib/legion/extensions/identity.rb +14 -8
  18. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
  19. data/spec/legion/extensions/identity/client_spec.rb +32 -0
  20. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
  21. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
  22. data/spec/legion/extensions/identity/helpers/graph_client_spec.rb +19 -0
  23. data/spec/legion/extensions/identity/helpers/graph_token_spec.rb +31 -0
  24. data/spec/legion/extensions/identity/helpers/token_cache_spec.rb +50 -0
  25. data/spec/legion/extensions/identity/runners/entra_spec.rb +655 -0
  26. data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
  27. data/spec/local_persistence_spec.rb +329 -0
  28. data/spec/spec_helper.rb +33 -0
  29. metadata +40 -67
  30. data/CHANGELOG.md +0 -27
  31. data/LICENSE +0 -21
  32. data/README.md +0 -230
  33. data/lib/legion/extensions/identity/helpers/browser.rb +0 -44
  34. data/lib/legion/extensions/identity/helpers/callback_server.rb +0 -170
  35. data/lib/legion/extensions/identity/helpers/oauth.rb +0 -62
  36. data/lib/legion/extensions/identity/helpers/pkce.rb +0 -20
  37. data/lib/legion/extensions/identity/helpers/provider.rb +0 -52
  38. data/lib/legion/extensions/identity/runners/base/oauth.rb +0 -140
  39. data/spec/support/provider_contract.rb +0 -49
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18e33b5500f9d9b8ea10ec64025f01c80ecf3e31587c1d0933fe12f0a6b5a951
4
- data.tar.gz: e3545bf086d36d22f7001ac6fbb523396b6380311d5c2a06aaabd7a22affdf90
3
+ metadata.gz: 402e57fb1c2fafdaac3bbbfd79ab0cd34d448798fe9f61e14511002845dc3d7c
4
+ data.tar.gz: 30585e986691ac0bee3bd2111f291fdc4adcef5c6ef83179c16a4e12d3e6eb67
5
5
  SHA512:
6
- metadata.gz: a884c9d1092ab1560f008da1c5f45270d64f2702f31dc9316976a4c2e62cc4a8fe4c0cb127d4a13174170d192907cc7e9508401a77f00bc6676872cf3bbd13a0
7
- data.tar.gz: cab921cd7605cc190b9cf5157d650c6816d4af46c0da7394b16b4b114fc556d82ed96b0135ee7eeac188df7f61dbb2ce0604fd7a868d295536ad673ae75e48f4
6
+ metadata.gz: 87d225ef73a724bcbf18649664670e56ed5eb93c7f5f2312ee19cf70550191c41849a4e25bb303aee50e98b330962269cd2c0c53c3781f5926a5fca89ed047f4
7
+ data.tar.gz: 798d6de090c2666919a3bc778adb4decaa0c08e586a14d03fc6e781a13ad2d33ecad0cb35f852b41c12d59c830c0f69b248aaddafe54e6b59e96d4800129dc47
data/Gemfile CHANGED
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  source 'https://rubygems.org'
4
+
4
5
  gemspec
5
6
 
6
- group :development, :test do
7
- gem 'rspec', '~> 3.13'
8
- gem 'rubocop', '~> 1.75'
9
- gem 'rubocop-legion', '~> 0.1'
10
- gem 'simplecov', '~> 0.22'
11
- end
7
+ gem 'faraday', '~> 2.0'
8
+ gem 'rspec', '~> 3.13'
9
+ gem 'rubocop', '~> 1.75', require: false
data/lex-identity.gemspec CHANGED
@@ -5,29 +5,26 @@ require_relative 'lib/legion/extensions/identity/version'
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'lex-identity'
7
7
  spec.version = Legion::Extensions::Identity::VERSION
8
- spec.authors = ['LegionIO']
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
9
10
 
10
- spec.summary = 'Shared LegionIO identity extension base'
11
- spec.description = 'Base extension gem for shared behavior across LegionIO identity provider extensions.'
11
+ spec.summary = 'LEX Identity'
12
+ spec.description = 'Human partner identity modeling and behavioral entropy for brain-modeled agentic AI'
12
13
  spec.homepage = 'https://github.com/LegionIO/lex-identity'
13
14
  spec.license = 'MIT'
14
15
  spec.required_ruby_version = '>= 3.4'
15
16
 
16
- spec.metadata['homepage_uri'] = spec.homepage
17
- spec.metadata['source_code_uri'] = spec.homepage
18
- spec.metadata['documentation_uri'] = "#{spec.homepage}/blob/main/README.md"
19
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
- spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-identity'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-identity'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-identity'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-identity/issues'
21
22
  spec.metadata['rubygems_mfa_required'] = 'true'
22
23
 
23
24
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
- Dir.glob('{lib,spec/support}/**/*.rb') + %w[lex-identity.gemspec Gemfile LICENSE README.md CHANGELOG.md]
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-identity.gemspec Gemfile]
25
26
  end
26
27
  spec.require_paths = ['lib']
27
-
28
- spec.add_dependency 'faraday', '~> 2.0'
29
- spec.add_dependency 'legion-cache', '>= 1.3.0'
30
- spec.add_dependency 'legion-json', '>= 1.2.1'
31
- spec.add_dependency 'legion-logging', '>= 1.5.3'
32
- spec.add_dependency 'legion-settings', '>= 1.3.14'
28
+ spec.add_development_dependency 'sequel', '>= 5.70'
29
+ spec.add_development_dependency 'sqlite3', '>= 2.0'
33
30
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Identity
8
+ module Actor
9
+ class CredentialRefresh < Legion::Extensions::Actors::Every
10
+ CREDENTIAL_REFRESH_INTERVAL = 21_600 # 6 hours
11
+
12
+ def runner_class
13
+ Legion::Extensions::Identity::Runners::Entra
14
+ end
15
+
16
+ def runner_function
17
+ 'credential_refresh_cycle'
18
+ end
19
+
20
+ def time
21
+ CREDENTIAL_REFRESH_INTERVAL
22
+ end
23
+
24
+ def enabled?
25
+ defined?(Legion::Data) && Legion::Settings[:data][:connected] != false
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ def use_runner?
31
+ false
32
+ end
33
+
34
+ def check_subtask?
35
+ false
36
+ end
37
+
38
+ def generate_task?
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Identity
8
+ module Actor
9
+ # Periodic orphan detection: scans active workers for disabled Entra apps
10
+ # or inactive owners. Runs every 4 hours by default.
11
+ # Requires legion-data for worker records.
12
+ class OrphanCheck < Legion::Extensions::Actors::Every
13
+ ORPHAN_CHECK_INTERVAL = 14_400 # 4 hours in seconds
14
+
15
+ def runner_class
16
+ Legion::Extensions::Identity::Runners::Entra
17
+ end
18
+
19
+ def runner_function
20
+ 'check_orphans'
21
+ end
22
+
23
+ def time
24
+ ORPHAN_CHECK_INTERVAL
25
+ end
26
+
27
+ def enabled?
28
+ defined?(Legion::Data) && Legion::Settings[:data][:connected] != false
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ def use_runner?
34
+ false
35
+ end
36
+
37
+ def check_subtask?
38
+ false
39
+ end
40
+
41
+ def generate_task?
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/identity/helpers/dimensions'
4
+ require 'legion/extensions/identity/helpers/fingerprint'
5
+ require 'legion/extensions/identity/runners/identity'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Identity
10
+ class Client
11
+ include Runners::Identity
12
+
13
+ def initialize(**)
14
+ @identity_fingerprint = Helpers::Fingerprint.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :identity_fingerprint
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module Dimensions
8
+ # The 6 behavioral dimensions that constitute identity
9
+ IDENTITY_DIMENSIONS = %i[
10
+ communication_cadence
11
+ vocabulary_patterns
12
+ emotional_response
13
+ decision_patterns
14
+ contextual_consistency
15
+ temporal_patterns
16
+ ].freeze
17
+
18
+ # Entropy thresholds (from tick-loop-spec Phase 4)
19
+ HIGH_ENTROPY_THRESHOLD = 0.70
20
+ LOW_ENTROPY_THRESHOLD = 0.20
21
+ OPTIMAL_ENTROPY_RANGE = (0.20..0.70)
22
+
23
+ # EMA alpha for dimension updates
24
+ OBSERVATION_ALPHA = 0.1
25
+
26
+ module_function
27
+
28
+ def new_identity_model
29
+ IDENTITY_DIMENSIONS.to_h do |dim|
30
+ [dim, { mean: 0.5, variance: 0.1, observations: 0, last_observed: nil }]
31
+ end
32
+ end
33
+
34
+ def compute_entropy(observations, model)
35
+ return 0.5 if observations.empty?
36
+
37
+ divergences = IDENTITY_DIMENSIONS.filter_map do |dim|
38
+ obs = observations[dim]
39
+ next unless obs
40
+
41
+ baseline = model[dim]
42
+ next 0.0 unless baseline && baseline[:observations].positive?
43
+
44
+ # Weighted divergence from established baseline
45
+ (obs - baseline[:mean]).abs / [baseline[:variance].to_f, 0.1].max
46
+ end
47
+
48
+ return 0.5 if divergences.empty?
49
+
50
+ raw = divergences.sum / divergences.size
51
+ clamp(raw / 3.0) # normalize: divergence of 3.0 stddevs = entropy 1.0
52
+ end
53
+
54
+ def classify_entropy(entropy)
55
+ if entropy > HIGH_ENTROPY_THRESHOLD
56
+ :high_entropy
57
+ elsif entropy < LOW_ENTROPY_THRESHOLD
58
+ :low_entropy
59
+ else
60
+ :normal
61
+ end
62
+ end
63
+
64
+ def clamp(value, min = 0.0, max = 1.0)
65
+ value.clamp(min, max)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Identity
9
+ module Helpers
10
+ class Fingerprint
11
+ attr_reader :model, :observation_count, :entropy_history
12
+
13
+ def initialize
14
+ @model = Dimensions.new_identity_model
15
+ @observation_count = 0
16
+ @entropy_history = []
17
+ load_from_local
18
+ end
19
+
20
+ def observe(dimension, value)
21
+ return unless Dimensions::IDENTITY_DIMENSIONS.include?(dimension)
22
+
23
+ dim = @model[dimension]
24
+ dim[:observations] += 1
25
+ @observation_count += 1
26
+
27
+ alpha = Dimensions::OBSERVATION_ALPHA
28
+ old_mean = dim[:mean]
29
+ dim[:mean] = (alpha * value) + ((1.0 - alpha) * old_mean)
30
+ deviation = (value - dim[:mean]).abs
31
+ dim[:variance] = (alpha * deviation) + ((1.0 - alpha) * dim[:variance])
32
+ dim[:last_observed] = Time.now.utc
33
+ end
34
+
35
+ def observe_all(observations)
36
+ observations.each { |dim, value| observe(dim, value) }
37
+ end
38
+
39
+ def current_entropy(observations = {})
40
+ entropy = Dimensions.compute_entropy(observations, @model)
41
+ @entropy_history << { entropy: entropy, at: Time.now.utc }
42
+ @entropy_history.shift while @entropy_history.size > 200
43
+ entropy
44
+ end
45
+
46
+ def entropy_trend(window: 10)
47
+ recent = @entropy_history.last(window)
48
+ return :stable if recent.size < 2
49
+
50
+ values = recent.map { |e| e[:entropy] }
51
+ first_half = values[0...(values.size / 2)]
52
+ second_half = values[(values.size / 2)..]
53
+
54
+ diff = (second_half.sum / second_half.size) - (first_half.sum / first_half.size)
55
+ if diff > 0.1
56
+ :rising
57
+ elsif diff < -0.1
58
+ :falling
59
+ else
60
+ :stable
61
+ end
62
+ end
63
+
64
+ def maturity
65
+ if @observation_count < 10
66
+ :nascent
67
+ elsif @observation_count < 100
68
+ :developing
69
+ elsif @observation_count < 1000
70
+ :established
71
+ else
72
+ :mature
73
+ end
74
+ end
75
+
76
+ def to_h
77
+ {
78
+ model: @model,
79
+ observation_count: @observation_count,
80
+ maturity: maturity,
81
+ entropy_history_size: @entropy_history.size
82
+ }
83
+ end
84
+
85
+ def save_to_local
86
+ return unless local_available?
87
+
88
+ db = Legion::Data::Local.connection
89
+
90
+ @model.each do |dimension, data|
91
+ existing = db[:identity_fingerprint].where(dimension: dimension.to_s).first
92
+ row = {
93
+ dimension: dimension.to_s,
94
+ mean: data[:mean],
95
+ variance: data[:variance],
96
+ observations: data[:observations],
97
+ last_observed: data[:last_observed]
98
+ }
99
+ if existing
100
+ db[:identity_fingerprint].where(dimension: dimension.to_s).update(row)
101
+ else
102
+ db[:identity_fingerprint].insert(row)
103
+ end
104
+ end
105
+
106
+ history_json = ::JSON.generate(@entropy_history.map { |e| { entropy: e[:entropy], at: e[:at].iso8601 } })
107
+ meta = db[:identity_meta].first
108
+ if meta
109
+ db[:identity_meta].where(id: meta[:id]).update(
110
+ observation_count: @observation_count,
111
+ entropy_history: history_json
112
+ )
113
+ else
114
+ db[:identity_meta].insert(
115
+ observation_count: @observation_count,
116
+ entropy_history: history_json
117
+ )
118
+ end
119
+
120
+ true
121
+ rescue StandardError => e
122
+ Legion::Logging.warn "lex-identity: save_to_local failed: #{e.message}" if defined?(Legion::Logging)
123
+ false
124
+ end
125
+
126
+ def load_from_local
127
+ return unless local_available?
128
+
129
+ db = Legion::Data::Local.connection
130
+
131
+ db[:identity_fingerprint].each do |row|
132
+ dim = row[:dimension].to_sym
133
+ next unless @model.key?(dim)
134
+
135
+ @model[dim][:mean] = row[:mean].to_f
136
+ @model[dim][:variance] = row[:variance].to_f
137
+ @model[dim][:observations] = row[:observations].to_i
138
+ @model[dim][:last_observed] = row[:last_observed]
139
+ end
140
+
141
+ meta = db[:identity_meta].first
142
+ if meta
143
+ @observation_count = meta[:observation_count].to_i
144
+ raw = meta[:entropy_history]
145
+ if raw && !raw.empty?
146
+ parsed = ::JSON.parse(raw)
147
+ @entropy_history = parsed.map { |e| { entropy: e['entropy'].to_f, at: Time.parse(e['at']) } }
148
+ end
149
+ end
150
+
151
+ true
152
+ rescue StandardError => e
153
+ Legion::Logging.warn "lex-identity: load_from_local failed: #{e.message}" if defined?(Legion::Logging)
154
+ false
155
+ end
156
+
157
+ private
158
+
159
+ def local_available?
160
+ defined?(Legion::Data::Local) && Legion::Data::Local.connected?
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module GraphClient
8
+ GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
9
+
10
+ module_function
11
+
12
+ def connection(token:, base: GRAPH_BASE)
13
+ require 'faraday'
14
+ Faraday.new(url: base) do |conn|
15
+ conn.request :json
16
+ conn.response :json, content_type: /\bjson$/
17
+ conn.headers['Authorization'] = "Bearer #{token}"
18
+ conn.headers['Content-Type'] = 'application/json'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module GraphToken
8
+ TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%<tenant_id>s/oauth2/v2.0/token'
9
+ GRAPH_SCOPE = 'https://graph.microsoft.com/.default'
10
+
11
+ class GraphTokenError < StandardError; end
12
+
13
+ module_function
14
+
15
+ def fetch(tenant_id:, client_id:, client_secret:)
16
+ require 'faraday'
17
+ url = format(TOKEN_ENDPOINT, tenant_id: tenant_id)
18
+ conn = Faraday.new(url: url) do |c|
19
+ c.request :url_encoded
20
+ c.response :json, content_type: /\bjson$/
21
+ end
22
+ resp = conn.post('', grant_type: 'client_credentials', client_id: client_id,
23
+ client_secret: client_secret, scope: GRAPH_SCOPE)
24
+ raise GraphTokenError, resp.body['error_description'] unless resp.success?
25
+
26
+ resp.body['access_token']
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module TokenCache
8
+ REFRESH_BUFFER = 300
9
+
10
+ @mutex = Mutex.new
11
+ @tokens = {}
12
+
13
+ module_function
14
+
15
+ def store(worker_id:, token:, expires_in:)
16
+ @mutex.synchronize do
17
+ @tokens[worker_id] = {
18
+ access_token: token,
19
+ expires_at: Time.now + expires_in,
20
+ acquired_at: Time.now
21
+ }
22
+ end
23
+ end
24
+
25
+ def fetch(worker_id:)
26
+ @mutex.synchronize do
27
+ entry = @tokens[worker_id]
28
+ return nil unless entry
29
+ return nil if Time.now >= entry[:expires_at]
30
+
31
+ entry
32
+ end
33
+ end
34
+
35
+ def approaching_expiry?(worker_id:, buffer: REFRESH_BUFFER)
36
+ @mutex.synchronize do
37
+ entry = @tokens[worker_id]
38
+ return true unless entry
39
+
40
+ (entry[:expires_at] - Time.now) < buffer
41
+ end
42
+ end
43
+
44
+ def clear(worker_id:)
45
+ @mutex.synchronize { @tokens.delete(worker_id) }
46
+ end
47
+
48
+ def clear_all
49
+ @mutex.synchronize { @tokens.clear }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ # Vault secret path conventions for Digital Worker Entra ID credentials.
8
+ #
9
+ # Secrets are stored in Vault KV v2 under a well-known path:
10
+ # secret/data/legion/workers/{worker_id}/entra
11
+ #
12
+ # Legion uses legion-crypt for Vault access. If Vault is not connected,
13
+ # methods return nil/false gracefully.
14
+ module VaultSecrets
15
+ VAULT_PATH_PREFIX = 'secret/data/legion/workers'
16
+
17
+ def self.secret_path(worker_id)
18
+ "#{VAULT_PATH_PREFIX}/#{worker_id}/entra"
19
+ end
20
+
21
+ # Store Entra app client_secret in Vault.
22
+ # Returns true on success, false if Vault is unavailable.
23
+ def self.store_client_secret(worker_id:, client_secret:, entra_app_id: nil)
24
+ return false unless vault_available?
25
+
26
+ path = secret_path(worker_id)
27
+ data = { client_secret: client_secret }
28
+ data[:entra_app_id] = entra_app_id if entra_app_id
29
+
30
+ Legion::Crypt.write(path, data)
31
+ Legion::Logging.info "[identity:vault] stored Entra credentials for worker=#{worker_id}"
32
+ true
33
+ rescue StandardError => e
34
+ Legion::Logging.error "[identity:vault] failed to store credentials for worker=#{worker_id}: #{e.message}"
35
+ false
36
+ end
37
+
38
+ # Read Entra app client_secret from Vault.
39
+ # Returns the secret hash on success, nil if unavailable or not found.
40
+ def self.read_client_secret(worker_id:)
41
+ return nil unless vault_available?
42
+
43
+ path = secret_path(worker_id)
44
+ result = Legion::Crypt.read(path)
45
+ result&.dig(:data, :data) || result&.dig(:data)
46
+ rescue StandardError => e
47
+ Legion::Logging.error "[identity:vault] failed to read credentials for worker=#{worker_id}: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ # Delete Entra app credentials from Vault (used during worker termination).
52
+ # Returns true on success, false if Vault is unavailable.
53
+ def self.delete_client_secret(worker_id:)
54
+ return false unless vault_available?
55
+
56
+ path = secret_path(worker_id)
57
+ Legion::Crypt.delete(path)
58
+ Legion::Logging.info "[identity:vault] deleted Entra credentials for worker=#{worker_id}"
59
+ true
60
+ rescue StandardError => e
61
+ Legion::Logging.error "[identity:vault] failed to delete credentials for worker=#{worker_id}: #{e.message}"
62
+ false
63
+ end
64
+
65
+ def self.vault_available?
66
+ defined?(Legion::Crypt) &&
67
+ defined?(Legion::Settings) &&
68
+ Legion::Settings[:crypt][:vault][:connected] == true
69
+ rescue StandardError
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table(:identity_fingerprint) do
6
+ primary_key :id
7
+ String :dimension, null: false, unique: true
8
+ Float :mean, default: 0.0
9
+ Float :variance, default: 0.0
10
+ Integer :observations, default: 0
11
+ DateTime :last_observed
12
+ end
13
+
14
+ create_table(:identity_meta) do
15
+ primary_key :id
16
+ Integer :observation_count, default: 0
17
+ String :entropy_history, text: true
18
+ end
19
+ end
20
+ end