lex-identity 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/lex-identity.gemspec +30 -0
- data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
- data/lib/legion/extensions/identity/client.rb +23 -0
- data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
- data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
- data/lib/legion/extensions/identity/runners/entra.rb +223 -0
- data/lib/legion/extensions/identity/runners/identity.rb +86 -0
- data/lib/legion/extensions/identity/version.rb +9 -0
- data/lib/legion/extensions/identity.rb +24 -0
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
- data/spec/legion/extensions/identity/client_spec.rb +32 -0
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
- data/spec/legion/extensions/identity/runners/entra_spec.rb +405 -0
- data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
- data/spec/local_persistence_spec.rb +329 -0
- data/spec/spec_helper.rb +33 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3681b4b6aab75c906d15647f1f359d19760d42994803b8c89d733480e77eacff
|
|
4
|
+
data.tar.gz: 4b49caa8730568c80e4e84a8d43f9053d83e6bef70a22730115a63492d7d5a06
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 527ef0472ad306da82d94ebea24a2b48c0375e164248b322aff1a721897d84f7518eac789852f17af88df14117ddfc9523dd65b42047a84d27af4fb907797d03
|
|
7
|
+
data.tar.gz: d7e69edd6068f138112c1a685423fc34fd44b8c5d83482c4c4ec8a1179b628955f2043a2e0d43a2b0dc0035b71129f8f56d5d6119dfc69ca64317087a7905849
|
data/Gemfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/identity/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-identity'
|
|
7
|
+
spec.version = Legion::Extensions::Identity::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Identity'
|
|
12
|
+
spec.description = 'Human partner identity modeling and behavioral entropy for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-identity'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
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'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-identity.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'sequel', '>= 5.70'
|
|
29
|
+
spec.add_development_dependency 'sqlite3', '>= 2.0'
|
|
30
|
+
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,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
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Runners
|
|
7
|
+
# Entra ID Application identity integration for Digital Workers.
|
|
8
|
+
#
|
|
9
|
+
# Permission model:
|
|
10
|
+
# - Entra app CREATION is done by the human owner (requires Application.ReadWrite.All
|
|
11
|
+
# which Legion does not have and should not have)
|
|
12
|
+
# - Legion gets Application.Read.All or Directory.Read.All for read operations
|
|
13
|
+
# - OIDC token validation uses the public JWKS endpoint (no special permission)
|
|
14
|
+
# - Write operations (transfer ownership, disable apps) update the Legion DB
|
|
15
|
+
# and emit events; the human completes the Entra side manually
|
|
16
|
+
module Entra
|
|
17
|
+
GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0'
|
|
18
|
+
ENTRA_JWKS_URL_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/discovery/v2.0/keys'
|
|
19
|
+
ENTRA_ISSUER_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/v2.0'
|
|
20
|
+
|
|
21
|
+
# Validate a worker's identity by checking its Entra app registration exists
|
|
22
|
+
# and its OIDC token is valid.
|
|
23
|
+
# OIDC validation uses the public JWKS endpoint — no Graph API permission needed.
|
|
24
|
+
def validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil, **)
|
|
25
|
+
worker = find_worker(worker_id)
|
|
26
|
+
return { valid: false, error: 'worker not found' } unless worker
|
|
27
|
+
|
|
28
|
+
app_id = entra_app_id || worker[:entra_app_id]
|
|
29
|
+
return { valid: false, error: 'no entra_app_id' } unless app_id
|
|
30
|
+
|
|
31
|
+
# If a token is provided and legion-crypt has JWKS support, validate it
|
|
32
|
+
if token && defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
|
|
33
|
+
tid = tenant_id || resolve_tenant_id
|
|
34
|
+
return { valid: false, error: 'no tenant_id configured' } unless tid
|
|
35
|
+
|
|
36
|
+
jwks_url = format(ENTRA_JWKS_URL_TEMPLATE, tenant_id: tid)
|
|
37
|
+
issuer = format(ENTRA_ISSUER_TEMPLATE, tenant_id: tid)
|
|
38
|
+
|
|
39
|
+
claims = Legion::Crypt::JWT.verify_with_jwks(
|
|
40
|
+
token,
|
|
41
|
+
jwks_url: jwks_url,
|
|
42
|
+
issuers: [issuer],
|
|
43
|
+
audience: app_id
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Legion::Logging.debug "[identity:entra] token validated: worker=#{worker_id} sub=#{claims[:sub]}"
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
valid: true,
|
|
50
|
+
worker_id: worker_id,
|
|
51
|
+
entra_app_id: app_id,
|
|
52
|
+
owner_msid: worker[:owner_msid],
|
|
53
|
+
lifecycle: worker[:lifecycle_state],
|
|
54
|
+
claims: claims,
|
|
55
|
+
validated_at: Time.now.utc
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# No token provided — return identity info without token validation
|
|
60
|
+
Legion::Logging.debug "[identity:entra] validate (no token): worker=#{worker_id} entra_app=#{app_id}"
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
valid: true,
|
|
64
|
+
worker_id: worker_id,
|
|
65
|
+
entra_app_id: app_id,
|
|
66
|
+
owner_msid: worker[:owner_msid],
|
|
67
|
+
lifecycle: worker[:lifecycle_state],
|
|
68
|
+
validated_at: Time.now.utc
|
|
69
|
+
}
|
|
70
|
+
rescue Legion::Crypt::JWT::ExpiredTokenError => e
|
|
71
|
+
{ valid: false, error: 'token_expired', message: e.message }
|
|
72
|
+
rescue Legion::Crypt::JWT::InvalidTokenError => e
|
|
73
|
+
{ valid: false, error: 'token_invalid', message: e.message }
|
|
74
|
+
rescue Legion::Crypt::JWT::Error => e
|
|
75
|
+
{ valid: false, error: 'token_error', message: e.message }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sync the worker's owner from Entra app ownership.
|
|
79
|
+
# Requires: Application.Read.All or Directory.Read.All (read-only)
|
|
80
|
+
def sync_owner(worker_id:, **)
|
|
81
|
+
worker = find_worker(worker_id)
|
|
82
|
+
return { synced: false, error: 'worker not found' } unless worker
|
|
83
|
+
|
|
84
|
+
# TODO: With Application.Read.All, call:
|
|
85
|
+
# GET #{GRAPH_API_BASE}/applications/#{worker[:entra_object_id]}/owners
|
|
86
|
+
# Parse owner MSID from response, update local record
|
|
87
|
+
|
|
88
|
+
Legion::Logging.debug "[identity:entra] sync_owner: worker=#{worker_id} current_owner=#{worker[:owner_msid]}"
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
synced: true,
|
|
92
|
+
worker_id: worker_id,
|
|
93
|
+
owner_msid: worker[:owner_msid],
|
|
94
|
+
source: :local, # will be :graph_api when read permission granted
|
|
95
|
+
synced_at: Time.now.utc
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Transfer ownership of a digital worker to a new human.
|
|
100
|
+
# Updates the Legion DB record and emits an audit event.
|
|
101
|
+
# The Entra app ownership change must be done by the human owner
|
|
102
|
+
# (requires Application.ReadWrite.All which Legion intentionally does not have).
|
|
103
|
+
def transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil, **)
|
|
104
|
+
worker = find_worker(worker_id)
|
|
105
|
+
return { transferred: false, error: 'worker not found' } unless worker
|
|
106
|
+
|
|
107
|
+
old_owner = worker[:owner_msid]
|
|
108
|
+
return { transferred: false, error: 'same owner' } if old_owner == new_owner_msid
|
|
109
|
+
|
|
110
|
+
# Update local record — this is the Legion side of the transfer
|
|
111
|
+
if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
|
|
112
|
+
dw = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
|
|
113
|
+
dw&.update(owner_msid: new_owner_msid, updated_at: Time.now.utc)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Entra app ownership change requires Application.ReadWrite.All.
|
|
117
|
+
# Legion does not have this permission by design — the human owner
|
|
118
|
+
# must update Entra app ownership separately via Azure Portal or CLI.
|
|
119
|
+
|
|
120
|
+
audit = {
|
|
121
|
+
event: :ownership_transferred,
|
|
122
|
+
worker_id: worker_id,
|
|
123
|
+
from_owner: old_owner,
|
|
124
|
+
to_owner: new_owner_msid,
|
|
125
|
+
transferred_by: transferred_by,
|
|
126
|
+
reason: reason,
|
|
127
|
+
entra_action_required: 'update Entra app ownership via Azure Portal or az CLI',
|
|
128
|
+
at: Time.now.utc
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Legion::Events.emit('worker.ownership_transferred', audit) if defined?(Legion::Events)
|
|
132
|
+
Legion::Logging.info "[identity:entra] ownership transferred (Legion DB): worker=#{worker_id} " \
|
|
133
|
+
"from=#{old_owner} to=#{new_owner_msid} by=#{transferred_by}"
|
|
134
|
+
Legion::Logging.warn '[identity:entra] Entra app ownership must be updated manually (requires Application.ReadWrite.All)'
|
|
135
|
+
|
|
136
|
+
{ transferred: true }.merge(audit)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Scan for orphaned workers: Entra apps that are disabled or owners no longer active.
|
|
140
|
+
# Requires: Application.Read.All or Directory.Read.All (read-only)
|
|
141
|
+
# Orphan REMEDIATION (disabling apps) requires human action since Legion
|
|
142
|
+
# does not have Application.ReadWrite.All.
|
|
143
|
+
def check_orphans(**)
|
|
144
|
+
return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
|
|
145
|
+
|
|
146
|
+
active_workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
|
|
147
|
+
orphans = []
|
|
148
|
+
skipped = 0
|
|
149
|
+
|
|
150
|
+
active_workers.each do |worker|
|
|
151
|
+
# Skip auto-registered extension workers without real Entra apps
|
|
152
|
+
if system_placeholder?(worker.entra_app_id, worker.worker_id)
|
|
153
|
+
skipped += 1
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# TODO: With Application.Read.All, check:
|
|
158
|
+
# GET #{GRAPH_API_BASE}/applications/#{entra_object_id} — is app disabled?
|
|
159
|
+
# GET #{GRAPH_API_BASE}/users/#{owner_msid} — is owner active?
|
|
160
|
+
# If either is disabled/deleted:
|
|
161
|
+
# orphans << worker
|
|
162
|
+
# auto_pause_orphan(worker, reason: :entra_app_disabled)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
Legion::Logging.debug "[identity:entra] orphan check: scanned #{active_workers.size} active workers, skipped #{skipped} system workers"
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :pending_entra_validation } },
|
|
169
|
+
checked: active_workers.size - skipped,
|
|
170
|
+
skipped: skipped,
|
|
171
|
+
source: :local,
|
|
172
|
+
checked_at: Time.now.utc
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def find_worker(worker_id)
|
|
179
|
+
if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
|
|
180
|
+
worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
|
|
181
|
+
return worker.to_hash if worker
|
|
182
|
+
end
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def system_placeholder?(entra_app_id, worker_id)
|
|
187
|
+
return true if entra_app_id.nil? || entra_app_id == 'system'
|
|
188
|
+
return true if entra_app_id == worker_id
|
|
189
|
+
return true if entra_app_id.start_with?('lex-')
|
|
190
|
+
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def resolve_tenant_id
|
|
195
|
+
if defined?(Legion::Settings) &&
|
|
196
|
+
Legion::Settings[:identity]&.dig(:entra, :tenant_id)
|
|
197
|
+
return Legion::Settings[:identity][:entra][:tenant_id]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def auto_pause_orphan(worker, reason:)
|
|
204
|
+
worker.update(lifecycle_state: 'paused', updated_at: Time.now.utc)
|
|
205
|
+
|
|
206
|
+
if defined?(Legion::Events)
|
|
207
|
+
Legion::Events.emit('worker.orphan_detected', {
|
|
208
|
+
worker_id: worker.worker_id,
|
|
209
|
+
owner_msid: worker.owner_msid,
|
|
210
|
+
reason: reason,
|
|
211
|
+
action: :auto_paused,
|
|
212
|
+
remediation: 'disable or reassign Entra app via Azure Portal',
|
|
213
|
+
at: Time.now.utc
|
|
214
|
+
})
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Legion::Logging.warn "[identity:entra] orphan detected: worker=#{worker.worker_id} reason=#{reason} — auto-paused"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|