auditea 0.1.0.beta.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 308c6d6709fed09f200bcfc60829af1b7bd470a018691f456576818ccadc87ee
4
+ data.tar.gz: 27a669b879d0d864d427359bae7c01daf27e2a2d492e45ed73b492136d659196
5
+ SHA512:
6
+ metadata.gz: 18517d6f3b825b1f1c97daab0bce3d6da97f68b157d87fb6c6aa302d0b6d79e1e202a682d4c427824df2ae71e7a7f51004124b8cc5ee7246d4d898e0e78cdc06
7
+ data.tar.gz: c8faa401027af8fadaa8f565994a875c047bed3840722ef101673c7a4565e554965e13e7f1d9b313bd9245499ffb02110145650ade188891278ec06e5aa87d6d
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0.beta.1 (unreleased)
4
+
5
+ - Initial SDK: capture, structured logs, Rails request/exception observation, ActiveJob failures
6
+ - EVENT_CONTRACT_V1 transport with HTTPS/loopback policy, status classification, byte limits, batch index results
7
+ - Process-aware async queue, flush waits for in-flight work, privacy.redacted_fields
8
+ - Manual smoke script: `examples/manual_capture.rb`
data/DESIGN.md ADDED
@@ -0,0 +1,48 @@
1
+ # AUD-P009 / HR01 Design notes
2
+
3
+ Issue: https://github.com/reznis/auditea-ruby/issues/1
4
+ PR: https://github.com/reznis/auditea-ruby/pull/2
5
+
6
+ ## Authority
7
+
8
+ - Wire contract: AudiTea vNext `EVENT_CONTRACT_V1`
9
+ - Legacy SDK: design input, not a blind port
10
+
11
+ ## HR01 transport foundations
12
+
13
+ - HTTPS required; HTTP only for localhost / 127.0.0.1 / ::1
14
+ - Explicit status classification (201/200/409/422/401/413/415/3xx/429/5xx)
15
+ - No redirect following; Authorization never forwarded elsewhere
16
+ - Injectable sleeper/random for bounded exponential backoff + jitter
17
+ - Client-side 128 KiB / 1 MiB / 100-event limits with byte-aware batch splitting
18
+ - Batch results mapped by `index`; accepted/duplicate siblings not retried
19
+ - Process PID ownership for pre-fork safety
20
+ - `flush` waits for buffer empty **and** in-flight delivery
21
+ - `privacy.redacted_fields` records filtered paths
22
+ - ActiveJob final-failure → `job.failed` without job arguments
23
+
24
+ ## Legacy classification (summary)
25
+
26
+ | Capability | Class |
27
+ |---|---|
28
+ | Explicit capture / track API | KEEP CONCEPT + PORT |
29
+ | Actor / Target helpers | REFACTOR (no email-by-default) |
30
+ | Configuration + Railtie | PORT (observe opt-in) |
31
+ | HTTP Bearer + retries | REFACTOR |
32
+ | Fail-open | KEEP CONCEPT + PORT |
33
+ | Client `event_id` | KEEP CONCEPT + PORT |
34
+ | Client `contract_version` / tenancy fields | DROP / REJECT (vNext conflict) |
35
+ | Request context middleware | PORT + REFACTOR (no session_id) |
36
+ | Rails auto observe | REFACTOR (single subscriber; requests opt-in) |
37
+ | ActionMappingConfig YAML dictionary | REFERENCE ONLY → server taxonomy |
38
+ | Privacy filter | KEEP CONCEPT + REFACTOR (all egress + redacted_fields) |
39
+ | Exception capture | REFACTOR (fixed frames) |
40
+ | Security surface | KEEP CONCEPT (deferred beyond beta.1 core) |
41
+ | `Thread.new` per event | REWRITE → bounded queue |
42
+ | `AUDITEA_DEBUG` full payload logs | DROP |
43
+ | Auto Rails.logger forward | DROP for beta.1; adapter boundary reserved |
44
+
45
+ ## Compatibility
46
+
47
+ - Ruby >= 3.2, including current Ruby 4.0.x (CI: 3.2, 3.3, 3.4, 4.0.6)
48
+ - Rails 7.2 and Rails 8.1 gemfiles under `gemfiles/`
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AudiTea contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # auditea
2
+
3
+ Ruby / Rails SDK for [AudiTea](https://github.com/reznis/AudiTea) event collection.
4
+
5
+ Version: **0.1.0.beta.1**
6
+
7
+ Captures durable technical and business evidence. AudiTea **server** owns taxonomy, dictionary, human labels, and reinterpretation.
8
+
9
+ ## Install (after RubyGems beta publication)
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "auditea", "0.1.0.beta.1"
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ ### Contributor / pre-release fallback
21
+
22
+ ```ruby
23
+ gem "auditea", github: "reznis/auditea-ruby", branch: "main"
24
+ ```
25
+
26
+ Prefer the RubyGems artifact for ordinary TEST deployments once published.
27
+
28
+ ## Configure
29
+
30
+ ```ruby
31
+ # config/initializers/auditea.rb
32
+ Auditea.configure do |config|
33
+ config.endpoint = ENV.fetch("AUDITEA_ENDPOINT", "https://app.auditea.com")
34
+ config.source_token = ENV.fetch("AUDITEA_SOURCE_TOKEN") # at_src_<public_id>_<secret>
35
+ config.capture_requests = true # opt-in Rails request observation
36
+ config.capture_exceptions = true
37
+ config.release = ENV["RELEASE"]
38
+ end
39
+ ```
40
+
41
+ Environment variables: `AUDITEA_ENDPOINT`, `AUDITEA_SOURCE_TOKEN` (or `AUDITEA_API_KEY`), `AUDITEA_CAPTURE_REQUESTS`, `AUDITEA_CAPTURE_EXCEPTIONS`, `AUDITEA_ASYNC`, `AUDITEA_RELEASE`.
42
+
43
+ ## Capture APIs
44
+
45
+ ```ruby
46
+ Auditea.capture(
47
+ "project.approved",
48
+ actor: { type: "user", id: current_user.id },
49
+ target: { type: "Project", id: project.id },
50
+ evidence: { business: { from_status: "draft", to_status: "approved" } }
51
+ )
52
+
53
+ Auditea.log(:info, "Export finished", context: { export_id: export.id })
54
+ ```
55
+
56
+ Wire format follows AudiTea **EVENT_CONTRACT_V1**:
57
+
58
+ - Bearer Source credential authentication
59
+ - No client `contract_version`
60
+ - No client tenancy/scope fields (`workspace_id`, `environment`, …)
61
+ - Extensible `evidence` / `context` / `provenance` / `metadata` bags
62
+
63
+ ## Rails
64
+
65
+ The Railtie installs:
66
+
67
+ - request context middleware (path **without** query string, request_id, method)
68
+ - optional `process_action` observation → `network.request.completed`
69
+ - optional exception observation → `exception.raised`
70
+
71
+ Structural evidence is preserved so the server can later distinguish routes such as `/researchers/projects/18` vs `/bcer/1/projects/18` without SDK redeploy.
72
+
73
+ ### Structured logs
74
+
75
+ Use `Auditea.log` explicitly.
76
+
77
+ **Automatic `Rails.logger` forwarding is intentionally disabled in beta.1** (`Auditea::Instrumentation::LoggerAdapter`).
78
+
79
+ ## Transport
80
+
81
+ - Sync or single-worker async queue (not one thread per event)
82
+ - HTTPS required (HTTP allowed only for localhost / 127.0.0.1 / ::1)
83
+ - Single events → `POST /api/v1/events`
84
+ - Batches → `POST /api/v1/event_batches` with per-index result handling
85
+ - Client-side 128 KiB / 1 MiB / 100-event limits
86
+ - Retries with injectable exponential backoff+jitter on 5xx / 429 / network
87
+ - Process-aware queue for pre-fork servers
88
+ - Fail-open by default (`raise_errors: false`)
89
+
90
+ ## Manual smoke (local AudiTea)
91
+
92
+ ```bash
93
+ AUDITEA_ENDPOINT=http://localhost:3000 \
94
+ AUDITEA_SOURCE_TOKEN=at_src_... \
95
+ bundle exec ruby examples/manual_capture.rb
96
+ ```
97
+
98
+ Never commit a real token. The script prints a minimal safe result only.
99
+
100
+ ## License
101
+
102
+ MIT. See `LICENSE`.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ bundle install
108
+ bundle exec rspec
109
+ bundle exec rubocop
110
+ gem build auditea.gemspec
111
+
112
+ # Rails compatibility (optional local)
113
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
114
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rspec
115
+ BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle install
116
+ BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rspec
117
+ ```
data/RELEASE.md ADDED
@@ -0,0 +1,43 @@
1
+ # Release: auditea 0.1.0.beta.1
2
+
3
+ ## Preconditions (blockers)
4
+
5
+ 1. Independent human review of the PR
6
+ 2. Merge to `main`
7
+ 3. Exact-head CI green on merge commit
8
+ 4. License approved: **MIT**
9
+ 5. RubyGems MFA / owner account for `reznis` with push rights
10
+ 6. OTP / API key for `gem push` stored outside the repository (never commit secrets)
11
+
12
+ ## Build
13
+
14
+ ```bash
15
+ git checkout main
16
+ git pull
17
+ bundle install
18
+ bundle exec rspec
19
+ gem build auditea.gemspec
20
+ # => auditea-0.1.0.beta.1.gem
21
+ ```
22
+
23
+ ## Publish
24
+
25
+ Publish only from reviewed `main` after its exact-head CI is green:
26
+
27
+ ```bash
28
+ gem push auditea-0.1.0.beta.1.gem
29
+ ```
30
+
31
+ Optional GitHub Actions: use `rubygems/configure-rubygems-credentials` with a repository secret `RUBYGEMS_API_KEY` — never commit the secret.
32
+
33
+ ## Verify
34
+
35
+ ```bash
36
+ gem install auditea -v 0.1.0.beta.1 --pre
37
+ ```
38
+
39
+ Then install in Semiweb TEST via:
40
+
41
+ ```ruby
42
+ gem "auditea", "0.1.0.beta.1"
43
+ ```
data/auditea.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/auditea/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "auditea"
7
+ spec.version = Auditea::VERSION
8
+ spec.authors = ["AudiTea"]
9
+ spec.email = ["opensource@auditea.app"]
10
+
11
+ spec.summary = "Ruby/Rails SDK for AudiTea event collection"
12
+ spec.description =
13
+ "Capture business events, Rails request evidence, exceptions, and structured logs " \
14
+ "for AudiTea /api/v1."
15
+ spec.homepage = "https://github.com/reznis/auditea-ruby"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/reznis/auditea-ruby"
21
+ spec.metadata["changelog_uri"] = "https://github.com/reznis/auditea-ruby/blob/main/CHANGELOG.md"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
24
+
25
+ spec.files = Dir[
26
+ "lib/**/*",
27
+ "examples/**/*",
28
+ "CHANGELOG.md",
29
+ "README.md",
30
+ "DESIGN.md",
31
+ "RELEASE.md",
32
+ "LICENSE*",
33
+ "auditea.gemspec"
34
+ ]
35
+ spec.require_paths = ["lib"]
36
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Manual smoke against a local AudiTea server. Never commit tokens.
4
+ #
5
+ # Usage:
6
+ # AUDITEA_ENDPOINT=http://localhost:3000 \
7
+ # AUDITEA_SOURCE_TOKEN=at_src_... \
8
+ # bundle exec ruby examples/manual_capture.rb
9
+
10
+ require "bundler/setup"
11
+ require "auditea"
12
+
13
+ endpoint = ENV.fetch("AUDITEA_ENDPOINT")
14
+ token = ENV.fetch("AUDITEA_SOURCE_TOKEN")
15
+
16
+ Auditea.configure do |config|
17
+ config.endpoint = endpoint
18
+ config.source_token = token
19
+ config.async = false
20
+ config.raise_errors = true
21
+ config.logger = Logger.new($stdout)
22
+ end
23
+
24
+ result = Auditea.capture(
25
+ "sdk.manual_smoke",
26
+ category: "debug",
27
+ severity: "info",
28
+ evidence: {
29
+ "smoke" => {
30
+ "script" => "examples/manual_capture.rb",
31
+ "note" => "manual capture from auditea-ruby"
32
+ }
33
+ },
34
+ metadata: {
35
+ "sdk_event_kind" => "manual_smoke"
36
+ }
37
+ )
38
+
39
+ Auditea.flush(timeout: 5)
40
+
41
+ safe = {
42
+ ok: result[:ok],
43
+ event_id: result[:event_id],
44
+ outcome: result[:outcome],
45
+ status: result[:status],
46
+ queued: result[:queued],
47
+ reason: result[:reason]
48
+ }.compact
49
+
50
+ puts "auditea manual_capture result=#{safe.inspect}"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Actor
5
+ module_function
6
+
7
+ # No email-by-default (REWRITE of legacy Actor.from_user PII default).
8
+ def from_user(user, type: nil)
9
+ return nil unless user
10
+
11
+ {
12
+ "type" => type || user.class&.name,
13
+ "id" => safe(user, :id) || safe(user, :to_param)
14
+ }.compact
15
+ end
16
+
17
+ def safe(object, method_name)
18
+ return nil unless object.respond_to?(method_name)
19
+
20
+ value = object.public_send(method_name)
21
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? nil : value.to_s
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ class Client
5
+ def initialize(configuration = nil, http: nil, queue: nil)
6
+ @configuration = configuration
7
+ @injected_http = http
8
+ @injected_queue = queue
9
+ rebuild!
10
+ end
11
+
12
+ def configuration
13
+ @configuration || Auditea.configuration
14
+ end
15
+
16
+ def enqueue(event)
17
+ queue.enqueue(event)
18
+ end
19
+
20
+ def flush(timeout: 5)
21
+ queue.flush(timeout: timeout)
22
+ end
23
+
24
+ def shutdown
25
+ queue.shutdown
26
+ end
27
+
28
+ def reset!
29
+ shutdown
30
+ rebuild!
31
+ end
32
+
33
+ private
34
+
35
+ def queue
36
+ @queue || rebuild!
37
+ end
38
+
39
+ def rebuild!
40
+ cfg = configuration
41
+ @http = @injected_http || Transport::Http.new(cfg)
42
+ @queue = @injected_queue || Transport::Queue.new(cfg, http: @http)
43
+ @queue
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ class Configuration
5
+ DEFAULT_ENDPOINT = "https://app.auditea.com"
6
+ DEFAULT_BATCH_SIZE = 50
7
+ DEFAULT_FLUSH_INTERVAL = 2.0
8
+ DEFAULT_QUEUE_CAPACITY = 1_000
9
+ DEFAULT_TIMEOUT = 3.0
10
+ DEFAULT_OPEN_TIMEOUT = 2.0
11
+ DEFAULT_WRITE_TIMEOUT = 3.0
12
+ DEFAULT_MAX_RETRIES = 3
13
+ DEFAULT_MESSAGE_MAX = 8_192
14
+ DEFAULT_NESTING_MAX = 16
15
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
16
+
17
+ attr_accessor :endpoint, :source_token, :enabled, :async, :raise_errors,
18
+ :batch_size, :flush_interval, :queue_capacity, :timeout,
19
+ :open_timeout, :write_timeout, :max_retries, :logger, :release, :sdk_name,
20
+ :capture_requests, :capture_exceptions, :capture_request_params,
21
+ :capture_active_job, :message_max_bytes, :nesting_max_depth
22
+
23
+ def initialize
24
+ @endpoint = ENV.fetch("AUDITEA_ENDPOINT", DEFAULT_ENDPOINT)
25
+ @source_token = ENV["AUDITEA_SOURCE_TOKEN"] || ENV.fetch("AUDITEA_API_KEY", nil)
26
+ @enabled = truthy?(ENV.fetch("AUDITEA_ENABLED", "true"))
27
+ @async = truthy?(ENV.fetch("AUDITEA_ASYNC", "true"))
28
+ @raise_errors = truthy?(ENV.fetch("AUDITEA_RAISE_ERRORS", "false"))
29
+ @batch_size = integer_env("AUDITEA_BATCH_SIZE", DEFAULT_BATCH_SIZE)
30
+ @flush_interval = float_env("AUDITEA_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL)
31
+ @queue_capacity = integer_env("AUDITEA_QUEUE_CAPACITY", DEFAULT_QUEUE_CAPACITY)
32
+ @timeout = float_env("AUDITEA_TIMEOUT", DEFAULT_TIMEOUT)
33
+ @open_timeout = float_env("AUDITEA_OPEN_TIMEOUT", DEFAULT_OPEN_TIMEOUT)
34
+ @write_timeout = float_env("AUDITEA_WRITE_TIMEOUT", DEFAULT_WRITE_TIMEOUT)
35
+ @max_retries = integer_env("AUDITEA_MAX_RETRIES", DEFAULT_MAX_RETRIES)
36
+ @logger = default_logger
37
+ @release = ENV["AUDITEA_RELEASE"] || ENV["RELEASE"] || ENV.fetch("HEROKU_RELEASE_VERSION", nil)
38
+ @sdk_name = "auditea-ruby"
39
+ @capture_requests = truthy?(ENV.fetch("AUDITEA_CAPTURE_REQUESTS", "false"))
40
+ @capture_exceptions = truthy?(ENV.fetch("AUDITEA_CAPTURE_EXCEPTIONS", "true"))
41
+ @capture_request_params = truthy?(ENV.fetch("AUDITEA_CAPTURE_REQUEST_PARAMS", "false"))
42
+ @capture_active_job = truthy?(ENV.fetch("AUDITEA_CAPTURE_ACTIVE_JOB", "true"))
43
+ @message_max_bytes = integer_env("AUDITEA_MESSAGE_MAX_BYTES", DEFAULT_MESSAGE_MAX)
44
+ @nesting_max_depth = integer_env("AUDITEA_NESTING_MAX_DEPTH", DEFAULT_NESTING_MAX)
45
+ end
46
+
47
+ def enabled?
48
+ !!@enabled
49
+ end
50
+
51
+ def async?
52
+ !!@async
53
+ end
54
+
55
+ def configured?
56
+ source_token.to_s.strip != "" && endpoint.to_s.strip != ""
57
+ end
58
+
59
+ def validate!
60
+ raise ConfigurationError, "endpoint must be HTTPS (or HTTP to localhost/127.0.0.1/::1)" unless valid_endpoint?
61
+ raise ConfigurationError, "batch_size must be 1..100" unless batch_size.between?(1, 100)
62
+ raise ConfigurationError, "queue_capacity must be >= 1" unless queue_capacity >= 1
63
+
64
+ true
65
+ end
66
+
67
+ def events_url
68
+ join_path("/api/v1/events")
69
+ end
70
+
71
+ def event_batches_url
72
+ join_path("/api/v1/event_batches")
73
+ end
74
+
75
+ def valid_endpoint?
76
+ uri = URI.parse(endpoint.to_s)
77
+ host = normalize_host(uri.host)
78
+ return false if host.empty?
79
+
80
+ case uri
81
+ when URI::HTTPS
82
+ true
83
+ when URI::HTTP
84
+ LOOPBACK_HOSTS.include?(host)
85
+ else
86
+ false
87
+ end
88
+ rescue URI::InvalidURIError
89
+ false
90
+ end
91
+
92
+ private
93
+
94
+ def normalize_host(host)
95
+ host.to_s.downcase.delete_prefix("[").delete_suffix("]")
96
+ end
97
+
98
+ def join_path(path)
99
+ base = endpoint.to_s.sub(%r{/\z}, "")
100
+ "#{base}#{path}"
101
+ end
102
+
103
+ def default_logger
104
+ return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
105
+
106
+ Logger.new($stdout)
107
+ end
108
+
109
+ def truthy?(value)
110
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
111
+ end
112
+
113
+ def integer_env(name, default)
114
+ Integer(ENV.fetch(name, default))
115
+ rescue ArgumentError, TypeError
116
+ default
117
+ end
118
+
119
+ def float_env(name, default)
120
+ Float(ENV.fetch(name, default))
121
+ rescue ArgumentError, TypeError
122
+ default
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ # Request-scoped enrichment bag. Stored without session_id / cookies / credentials.
5
+ module CurrentContext
6
+ class << self
7
+ def store
8
+ Thread.current[:auditea_current_context] ||= {}
9
+ end
10
+
11
+ def clear!
12
+ Thread.current[:auditea_current_context] = {}
13
+ end
14
+
15
+ def set(attrs)
16
+ store.merge!(stringify(attrs))
17
+ end
18
+
19
+ def to_h
20
+ store.dup
21
+ end
22
+
23
+ private
24
+
25
+ def stringify(attrs)
26
+ attrs.each_with_object({}) do |(key, value), memo|
27
+ next if value.nil?
28
+
29
+ memo[key.to_s] = value.is_a?(Hash) ? stringify(value) : value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module EventBuilder
5
+ ACTION_FORMAT = /\A[a-z][a-z0-9_.]{0,126}\z/
6
+
7
+ module_function
8
+
9
+ def build(action:, category: nil, severity: nil, actor: nil, target: nil,
10
+ context: nil, evidence: nil, provenance: nil, metadata: nil,
11
+ privacy: nil, occurred_at: nil, event_id: nil)
12
+ action_key = action.to_s
13
+ raise ArgumentError, "invalid action: #{action_key.inspect}" unless action_key.match?(ACTION_FORMAT)
14
+
15
+ event = {
16
+ "event_id" => presence(event_id) || SecureRandom.uuid,
17
+ "occurred_at" => format_time(occurred_at || Time.now.utc),
18
+ "action" => action_key
19
+ }
20
+
21
+ event["category"] = category.to_s if presence(category)
22
+ event["severity"] = severity.to_s if presence(severity)
23
+ event["actor"] = stringify_keys(actor) if actor
24
+ event["target"] = stringify_keys(target) if target
25
+
26
+ merged_context = merge_context(context)
27
+ event["context"] = merged_context if merged_context
28
+
29
+ event["evidence"] = stringify_keys(evidence) if evidence
30
+ event["provenance"] = merge_provenance(provenance)
31
+ event["metadata"] = stringify_keys(metadata) if metadata
32
+ event["privacy"] = stringify_keys(privacy) if privacy
33
+
34
+ # Never include contract_version or tenancy/scope selectors (server-owned).
35
+ event
36
+ end
37
+
38
+ def merge_context(context)
39
+ base = CurrentContext.to_h
40
+ extra = stringify_keys(context || {})
41
+ merged = base.merge(extra)
42
+ merged.empty? ? nil : merged
43
+ end
44
+
45
+ def merge_provenance(provenance)
46
+ base = {
47
+ "sdk" => Auditea.configuration.sdk_name,
48
+ "sdk_version" => VERSION,
49
+ "runtime" => "ruby",
50
+ "ruby_version" => RUBY_VERSION
51
+ }
52
+ release = Auditea.configuration.release
53
+ base["release"] = release if presence(release)
54
+ base["rails_version"] = Rails.version if defined?(Rails) && Rails.respond_to?(:version)
55
+ base.merge(stringify_keys(provenance || {}))
56
+ end
57
+
58
+ def format_time(time)
59
+ time = Time.parse(time.to_s) unless time.respond_to?(:utc)
60
+ time.utc.iso8601
61
+ end
62
+
63
+ def stringify_keys(value)
64
+ case value
65
+ when Hash
66
+ value.each_with_object({}) { |(k, v), memo| memo[k.to_s] = stringify_keys(v) }
67
+ when Array
68
+ value.map { |v| stringify_keys(v) }
69
+ else
70
+ value
71
+ end
72
+ end
73
+
74
+ def presence(value)
75
+ return nil if value.nil?
76
+ return nil if value.respond_to?(:empty?) && value.empty?
77
+
78
+ value
79
+ end
80
+ end
81
+ end