karst 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,188 @@
1
+ # Advanced configuration
2
+
3
+ Almost nothing here is part of installing Karst. A conventional single-model Devise application configures none of it — see [README.md](../README.md). This document is for the exceptions: custom authentication, identity spread across several models, populations committed as code, and a few bounds an ordinary developer should never need to change. See [ARCHITECTURE.md](../ARCHITECTURE.md) for how these pieces are implemented.
4
+
5
+ If you are looking for an option that used to be here, check [Removed configuration](#removed-configuration) at the end.
6
+
7
+ ## Custom or non-Devise authentication
8
+
9
+ Karst does not assume identity is a `User`, an Active Record object, or a Warden session. Configure a lazy candidate source and the hooks a probe session uses to sign in and out:
10
+
11
+ ```ruby
12
+ Karst.configure do |config|
13
+ config.principals = -> { Account.active }
14
+ config.assume_identity = lambda do |session, account|
15
+ session.post "/karst_test_login", params: { account_id: account.id }
16
+ end
17
+ config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
18
+
19
+ # Optional, only evaluated when a display label is needed:
20
+ config.principal_label = ->(account) { "QA account #{account.id}" }
21
+ end
22
+ ```
23
+
24
+ `config.principals` is called only by `Karst::Identity.principals` — Karst never enumerates, samples, or materializes its result itself. `assume_identity` and `clear_identity` must be configured together. This lets an app use a test-only login endpoint or any other session-local mechanism without ever handing Karst a password, email, token, or other credential.
25
+
26
+ `bin/rails generate karst:install` is an optional escape hatch for custom authentication. You usually do not need this generator: conventional single-model Devise apps require no initializer, application controller, or Karst routes. The compatibility-preserving command scaffolds a compact initializer, a `KarstIdentityController` with explicit `TODO`s, and development-only routes. Replace the `TODO`s with your app's real sign-in/sign-out behavior. None of this is required if you already configure Karst by hand.
27
+
28
+ Browser **Test as** needs a second, separate pair of hooks, because they mutate the real Rack request/session rather than an isolated probe session:
29
+
30
+ ```ruby
31
+ Karst.configure do |config|
32
+ config.assume_browser_identity = lambda do |request, account|
33
+ request.session[:account_id] = account.id
34
+ end
35
+ config.clear_browser_identity = lambda do |request|
36
+ request.session.delete(:account_id)
37
+ end
38
+ end
39
+ ```
40
+
41
+ When both are configured, every usable user in the results gets a **Test as** button. Karst resolves the submitted user only through a configured principal source, then invokes the hook, then redirects back to the exact page you were testing.
42
+
43
+ Because `/karst` is served at the Rack boundary, before Action Controller, Rails' authenticity-token helpers aren't available there. Karst instead stores a random nonce in the existing Rack session and requires a constant-time match on every identity-changing POST. This path is local-development-only, requires a writable host session, never accepts an external return URL, and stays inactive unless both browser hooks are configured.
44
+
45
+ ## Authentication identifiers in local human output
46
+
47
+ Without `config.principal_label`, Karst normally labels a principal `Model #id`.
48
+ For a Devise-mapped model, Karst may make the **local HTML panel and human CLI**
49
+ more recognizable by reading the model's single, explicitly declared
50
+ `authentication_keys` field (for example, `email`) and showing
51
+ `user@example.com · User #27`. Email is a `mailto:` link in HTML.
52
+
53
+ This is deliberately evidence-based rather than a column-name heuristic. Karst
54
+ does not scan for `name`, `phone`, `address`, tokens, passwords, or likely login
55
+ columns. No identifier is read when Devise is unavailable, the principal's
56
+ model is not mapped by Devise, the declared key is missing, its value is nil or
57
+ empty, or Devise declares multiple authentication keys. Multiple keys fail
58
+ closed because Karst cannot determine which key is appropriate to disclose.
59
+
60
+ Framework-inferred identifiers are **never serialized in `--json` output or MCP
61
+ output**; those interfaces retain `Model #id`. A callable
62
+ `config.principal_label` still overrides inference completely, is not being
63
+ deprecated here, and remains explicit application consent for that configured
64
+ label to appear in all existing outputs.
65
+
66
+ ## Multiple user models: `config.principal_sources`
67
+
68
+ Some apps represent identity as more than one model (`Author`, `Reader`) rather than one `User` with a role column:
69
+
70
+ ```ruby
71
+ Karst.configure do |config|
72
+ config.principal_sources = {
73
+ authors: { records: -> { Author.all }, populations: { admins: -> { Author.admins } } },
74
+ readers: -> { Reader.all }
75
+ }
76
+ end
77
+ ```
78
+
79
+ Each source is a name plus a lazily-evaluated `records:` callable, and optional `populations:` of its own. Those are the only two keys a source spec accepts; anything else raises `ArgumentError` rather than being quietly ignored. Sources are never merged together — Karst keeps each independently queryable and never confuses `Author #12` with `Reader #12`. `config.principals` (plus `config.principal_populations`) remains fully supported; it's normalized internally into one implicit `:default` source, so this is additive, not a breaking change to the simple form in the main README.
80
+
81
+ Candidates are split across sources within one overall `access_sweep_limit`: every non-empty source gets at least one candidate, and the rest fill round-robin so one source running dry never starves another.
82
+
83
+ ## Several Devise models, selected locally
84
+
85
+ `config.principal_sources` above is the way to commit multiple sources as reviewable Ruby. If your app simply has more than one Devise model (`User`, `Admin`) and nothing configured, Karst still refuses to guess which one(s) to test — but you don't have to write an initializer to resolve that. The `/karst` panel shows every Devise-detected model as a checkbox right where the old "configure `config.principals`" message used to sit:
86
+
87
+ ```
88
+ Karst found 2 user types: Admin, User.
89
+
90
+ Which should Karst test?
91
+
92
+ [ ] Admin
93
+ [ ] User
94
+
95
+ [Save]
96
+ ```
97
+
98
+ Checking one model behaves exactly like a single-model Devise app always has. Checking more than one produces exactly what `config.principal_sources` would: one independently queryable source per model, each keyed by its own Devise/Warden scope (`User` → `:user`, `Admin` → `:admin`) — never collapsed into a combined source, and probe/browser identity always uses the correct scope for whichever source actually produced a given principal.
99
+
100
+ The selection is saved to `tmp/karst/principal_source_selection.json`, relative to `Rails.root` — the same machine-local, git-ignored, development/test-only mechanism candidate-population approval already uses (see [Curating candidate populations](#curating-candidate-populations) below): a bare model name, nothing else, revalidated against Devise's own current `Devise.mappings` on every read. A selected model Devise no longer maps (removed, renamed) is silently dropped rather than trusted, and if that empties the selection entirely, Karst is ambiguous again and asks once more. An explicitly configured `config.principals`/`config.principal_sources` always wins outright over a saved selection, exactly like it wins over Devise inference. Once saved, `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool all pick it up automatically, with no separate wiring — and both return the same actionable, structured error before anything is selected.
101
+
102
+ ## Representative sampling
103
+
104
+ Nothing here is configurable — it is documented so you can read Karst's output, not so you can tune it.
105
+
106
+ When `config.principals` (or a source's `records:`) returns an Active Record relation, Karst doesn't just take whichever rows sort first. It fetches one bounded, recent pool (`principal_candidate_pool_size`, default 1,000 rows, exactly one query) and then, in memory over that pool, tries to cover a handful of different observed states: boolean columns, enum columns, presence/absence of a nullable foreign key, and low-cardinality scalar columns (2–10 distinct values). Anything that looks like PII by name (email, phone, address, token, password, and similar) is excluded outright, as is anything shaped like a tenant/account/organization foreign key.
107
+
108
+ That produces the `Sampled for: role=local_admin` line next to a usable user. It is sampling evidence, never an authorization claim — Karst never states or implies that the role is what let the request through.
109
+
110
+ When the right user is too rare for this to reach — a role held by three people out of 400,000 — that is what candidate populations are for, and they are a separate, later search stage with its own reporting.
111
+
112
+ ## Curating candidate populations
113
+
114
+ Writing `config.principal_populations` by hand works well once you know which scopes matter. On a large app, finding them by reading source is tedious — so Karst separates **discovery** (automatic, executes nothing) from **approval** (always an explicit developer action).
115
+
116
+ **Discovery.** `Karst::Access::PopulationDiscovery` parses application model source with Ruby's standard-library `Ripper` AST parser and lists statically named, zero-argument Rails `scope` declarations. It never calls a scope, never queries anything, and never mutates application state. Only scopes declared directly on a model are found; scopes contributed by a `concern` may not appear. `bin/rails karst:populations` prints every discovered model and scope name, marking the approved ones; discovery is not approval — Karst finding `User.system_admins` says only that such a scope exists, never that it grants access.
117
+
118
+ **Approval.** When an analysis finds no usable user and unapproved candidates exist, `/karst` links to `/karst/populations`: candidates grouped by model, collapsed and searchable. Checking a scope and pressing **Preview** runs one bounded (`LIMIT 3`) query, inside a rolled-back transaction, to show a few matching records — optional, never required to approve. Pressing **Approve selected groups** persists the selection to `tmp/karst/approved_populations.json`, relative to `Rails.root`:
119
+
120
+ ```json
121
+ { "version": 1, "approved": [{ "model": "User", "scope": "system_admins" }] }
122
+ ```
123
+
124
+ Deliberately machine-local, git-ignored development state, not project configuration — delete the file to reset every approval. It holds **only model and scope names**, never user data, never a `-> { ... }` lambda, and Karst never evaluates its contents; an entry is only ever compared, as a string, against what current discovery still confirms. This is what keeps the file from becoming an arbitrary-method allowlist: a hand-edited entry naming an ordinary class method (`destroy_all`) is never confirmed, and an approval whose scope was renamed, given parameters, or deleted stops being executed the moment the source changes — shown as stale on the review page rather than silently dropped.
125
+
126
+ An approval only ever becomes executable for a model that is already a configured or Devise-inferred principal source (the class always comes from that source, never from the file), and only in development/test — production never reads the file. `config.principal_populations`/`config.principal_sources[...] :populations` keeps working unchanged and wins outright over an approval of the same name; Karst compares by name only, since it never inspects a configured callable's body. Approved populations reach `Access::Search` the same way configured ones do, so `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool all pick them up automatically with no adapter-specific wiring.
127
+
128
+ The review page can still generate a ready-to-paste `config.principal_populations = { ... }` (or `config.principal_sources = { ... }`) snippet from your approvals, under **Advanced: export approvals as Ruby** — useful for committing populations as reviewable code, or for CI, where machine-local approval state is deliberately not consulted.
129
+
130
+ ## Resource evidence
131
+
132
+ When a usable user is found for a route with an `:id` segment (`/admin/imports/123`), Karst separately checks whether that exact resource and that exact user share a direct foreign-key relationship — for example, that `Document#22`'s `user_id` column equals `User#27`'s id. Only columns ending in `_id` are ever inspected, and only a direct column-value comparison is made — never a join or a `has_many` traversal, and no other attribute (name, email, token) is ever read. This is shown as **Related state** on a usable result when available, and simply omitted otherwise.
133
+
134
+ ## Full configuration reference
135
+
136
+ This is the entire public configuration surface. Every entry is either an escape hatch for an application Karst cannot infer, or a bound with a working default.
137
+
138
+ ### Normal
139
+
140
+ ```ruby
141
+ config.enabled = true # default: development/test only
142
+ ```
143
+
144
+ The single switch that turns Karst's whole development surface off — `/karst`, the page badge, `bin/rails karst:verify`, and the MCP `verify_access` tool all refuse to run when it is false. Karst is off in production regardless.
145
+
146
+ ### Escape hatches
147
+
148
+ | Option | For |
149
+ | --- | --- |
150
+ | `principals` | Custom or non-Devise authentication ([above](#custom-or-non-devise-authentication)) |
151
+ | `assume_identity` / `clear_identity` | Signing a probe session in and out; must be configured together |
152
+ | `assume_browser_identity` / `clear_browser_identity` | Browser **Test as** under custom authentication |
153
+ | `principal_label` | A display label for a non-Active-Record principal |
154
+ | `principal_sources` | Identity spread across several models ([above](#multiple-user-models-configprincipal_sources)) |
155
+ | `principal_populations` | Populations committed as reviewable code, or needed in CI ([above](#curating-candidate-populations)) |
156
+
157
+ ### Bounds
158
+
159
+ Defaults are chosen to be safe on a large application; changing them is rarely the right fix.
160
+
161
+ ```ruby
162
+ config.access_sweep_limit = 25 # users tried per search (1–100)
163
+ config.principal_candidate_pool_size = 1_000 # recent-row pool for sampling (1–10,000)
164
+ config.population_retry_limit = 3 # records tried per population (1–10)
165
+ config.usable_access_outcome = ->(outcome) { outcome.status == 200 && ... }
166
+ ```
167
+
168
+ Each numeric bound raises `ArgumentError` outside its range rather than clamping. `usable_access_outcome` lets you redefine what counts as "usable" without changing what evidence is captured — for example, treating a `204` as usable for an API endpoint. The default is: HTTP 200, no observed exception, no halted callback.
169
+
170
+ ## Removed configuration
171
+
172
+ Karst is pre-1.0 and prefers a clean surface to accumulated accidental complexity. These options existed in earlier product directions and are gone. Setting one raises `Karst::RemovedConfiguration` (a `NoMethodError`) naming the removal — Karst never silently ignores a removed option or reinterprets it as something else.
173
+
174
+ | Removed | Why, and what to do instead |
175
+ | --- | --- |
176
+ | `config.principal_dimensions` | Sampling states are derived from your schema automatically ([above](#representative-sampling)); there is nothing to declare. A user too rare for the ordinary sample is reached through a candidate population, which reports itself as evidence. |
177
+ | `config.artifact_source` / `config.access_scenario` | Artifact scenarios swept application records rather than routes, and had no place in the current product. Karst analyzes routes. |
178
+ | `config.buffer_size` | Runtime SQL capture is gone: Karst kept a process-wide `sql.active_record` buffer that no Karst surface reported any more. `Karst.buffer`, `Karst.window`, `Karst::Sql::*`, and `Karst.subscribe!`/`unsubscribe!`/`subscribed?` are removed with it. Karst now installs no notification subscriber at boot, so it costs a host application nothing per query. Database writes during a probe are still observed and reported — that has always used its own scoped, per-probe subscription. |
179
+
180
+ A `dimensions:` key inside a `config.principal_sources` spec raises `ArgumentError` for the same reason.
181
+
182
+ ## Safety detail
183
+
184
+ - `/karst`, the badge, and browser Test As only run for loopback requests (and, under WSL, the single detected host-side gateway a Windows browser appears from) while `Rails.env.development?` is true. Forwarding headers and other private-network addresses are never trusted.
185
+ - Every probe runs inside `ActiveRecord::Base.transaction(requires_new: true)` and is always rolled back. This only isolates writes made through the same Active Record connection in that request — not jobs, mail, external HTTP calls, files, Redis, or other database connections.
186
+ - Population callables are evaluated inside that same rollback-only transaction; a population whose callable itself performs a write is rejected even though rollback was attempted, because Karst observed `INSERT`/`UPDATE`/`DELETE` SQL from it.
187
+ - Every search is bounded: at most `access_sweep_limit` requests for the ordinary sample, and the automatic population retry stage can add at most that many again — so enabling populations can roughly double a search's cost, never more.
188
+ - Karst never looks up a user outside a configured principal source. Submitting an arbitrary model name/id to **Test as** resolves nothing unless that model is one of your configured sources.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Karst
6
+ module Generators
7
+ # `bin/rails generate karst:install`
8
+ #
9
+ # Scaffolds a compact initializer plus the development-only probe
10
+ # identity controller/routes a *custom* (non-Devise) identity
11
+ # configuration can drive. See the generated files themselves for what
12
+ # each seam is responsible for.
13
+ #
14
+ # This generator does not need to detect Devise/Warden itself: that
15
+ # detection happens at runtime, from Devise's own routing metadata (see
16
+ # Karst::Identity::DeviseSupport), not at generation time. A conventional
17
+ # single-model Devise application needs nothing from this generator.
18
+ # The initializer, identity controller, and routes exist only for
19
+ # applications that fall back to a custom `config.assume_identity` /
20
+ # `config.clear_identity` pair.
21
+ #
22
+ # This generator is convenience/scaffolding only. Karst remains fully
23
+ # configurable by hand (see README.md); nothing at runtime requires this
24
+ # generator to have been run, and an application that already configures
25
+ # Karst manually has no need to run it.
26
+ #
27
+ # Idempotent: running it again only touches files whose content actually
28
+ # changed, via Thor's own file-collision handling, and the routes
29
+ # insertion is a no-op once the exact same route block is already
30
+ # present.
31
+ class InstallGenerator < ::Rails::Generators::Base
32
+ source_root File.expand_path("templates", __dir__)
33
+
34
+ desc "Scaffolds the custom-authentication escape hatch (not needed for conventional Devise apps)."
35
+
36
+ DEVELOPMENT_ROUTES = <<~ROUTES
37
+ if Rails.env.development?
38
+ post "/karst_test_login", to: "karst_identity#create"
39
+ delete "/karst_test_logout", to: "karst_identity#destroy"
40
+ end
41
+ ROUTES
42
+ private_constant :DEVELOPMENT_ROUTES
43
+
44
+ NEXT_STEPS = <<~STEPS
45
+
46
+ You usually do not need this generator. Use it only when Karst cannot
47
+ infer your application's custom authentication.
48
+
49
+ 1. Configure your principal source:
50
+ config/initializers/karst.rb
51
+
52
+ 2. Implement probe identity setup/clear:
53
+ app/controllers/karst_identity_controller.rb
54
+
55
+ 3. Implement browser Test-as identity hooks:
56
+ config/initializers/karst.rb
57
+
58
+ Then start Rails and visit /karst. See docs/advanced-configuration.md.
59
+
60
+ STEPS
61
+ private_constant :NEXT_STEPS
62
+
63
+ def copy_initializer
64
+ copy_file "karst_initializer.rb", "config/initializers/karst.rb"
65
+ end
66
+
67
+ def copy_identity_controller
68
+ copy_file "karst_identity_controller.rb", "app/controllers/karst_identity_controller.rb"
69
+ end
70
+
71
+ def add_development_routes
72
+ # `route` (a Rails::Generators::Actions helper) injects this exact
73
+ # string once via Thor's own force: false collision handling: a
74
+ # second run whose routes.rb already contains this text is a no-op,
75
+ # so this stays safe to run more than once without duplicating
76
+ # routes or requiring bespoke parsing of config/routes.rb.
77
+ route(DEVELOPMENT_ROUTES.chomp)
78
+ end
79
+
80
+ def post_install_message
81
+ say ""
82
+ say "Karst custom-authentication scaffold created.", :green
83
+ say NEXT_STEPS
84
+ say "Complete the TODOs with this application's real identity semantics.", :yellow
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Development-only endpoints used by the custom probe hooks in karst.rb.
4
+ class KarstIdentityController < ApplicationController
5
+ skip_before_action :verify_authenticity_token, raise: false
6
+
7
+ def create
8
+ principal = Karst::Identity.resolve(model_name: params[:principal_type], id: params[:principal_id])
9
+ return head(:forbidden) unless principal
10
+
11
+ # TODO: establish this app's authentication for principal, then return a response.
12
+ raise NotImplementedError, "Implement this application's custom Karst sign-in"
13
+ end
14
+
15
+ def destroy
16
+ # TODO: clear the authentication established by create, then return a response.
17
+ raise NotImplementedError, "Implement this application's custom Karst sign-out"
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Karst usually requires no initializer for Devise. This escape hatch exists
4
+ # because this application uses custom authentication. Complete each TODO;
5
+ # see docs/advanced-configuration.md for the full contract.
6
+ Karst.configure do |config|
7
+ config.principals = -> { Account.active } # TODO: use this app's principal scope
8
+
9
+ config.assume_identity = lambda do |session, principal|
10
+ descriptor = Karst::Identity.describe(principal)
11
+ session.post "/karst_test_login", params: { principal_type: descriptor.model_name, principal_id: descriptor.id }
12
+ end
13
+ config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
14
+
15
+ # TODO: replace :account_id with this app's browser-session identity.
16
+ config.assume_browser_identity = ->(request, principal) { request.session[:account_id] = principal.id }
17
+ config.clear_browser_identity = ->(request) { request.session.delete(:account_id) }
18
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "population_approvals"
4
+ require_relative "population_discovery"
5
+
6
+ module Karst
7
+ module Access
8
+ # Turns locally approved candidate populations (see
9
+ # Karst::Access::PopulationApprovals) back into ordinary
10
+ # Karst::Access::PrincipalSource populations, so that everything
11
+ # downstream -- Access::Search, the panel, `bin/rails karst:verify`, the
12
+ # MCP `verify_access` tool -- keeps reading exactly one source of truth
13
+ # (Configuration#principal_sources) and needs to know nothing about
14
+ # approval at all.
15
+ #
16
+ # Three independent conditions must hold before an approved entry becomes
17
+ # executable, and any one of them failing silently drops it:
18
+ #
19
+ # 1. Karst is running in a local development/test environment. An
20
+ # approval file that reaches production (committed by accident,
21
+ # copied into an image) approves nothing there.
22
+ # 2. The entry's model name matches the Active Record class of an
23
+ # already-configured (or inferred) principal source. The class is
24
+ # taken from that source -- never looked up, constantized, or loaded
25
+ # from the stored name -- so approval can only ever widen sampling
26
+ # within a model the application already pointed Karst at.
27
+ # 3. Current source-based discovery still confirms that exact
28
+ # zero-argument `scope` declaration on that class. A scope that was
29
+ # removed, renamed, or given parameters stops being executed the
30
+ # moment the source changes, with no file edit required, and a
31
+ # hand-written entry naming an arbitrary class method is never
32
+ # confirmed in the first place.
33
+ #
34
+ # Explicit configuration always wins: a population name a source already
35
+ # configures is never replaced or duplicated by an approved entry of the
36
+ # same name, and configured populations keep their configured order ahead
37
+ # of approved ones (see Access::Search, which tries them in exactly this
38
+ # order).
39
+ module ApprovedPopulations
40
+ class << self
41
+ # Returns a Hash of the same shape it was given, with each source's
42
+ # populations extended by whatever its model has approved and
43
+ # confirmed. Returns the argument untouched when nothing applies, so
44
+ # the overwhelmingly common "no approvals" case costs one file stat.
45
+ def merge(sources)
46
+ return sources unless sources && local_environment?
47
+
48
+ record = PopulationApprovals.load
49
+ return sources if record.entries.empty?
50
+
51
+ discovery = PopulationDiscovery.new
52
+ sources.each_with_object({}) do |(name, source), merged|
53
+ merged[name] = extend_source(source, record.entries, discovery)
54
+ end
55
+ rescue StandardError
56
+ # Approval is an optional convenience layered over configuration
57
+ # Karst already had. If resolving it fails for any reason, the
58
+ # honest degradation is "explicitly configured populations only" --
59
+ # never a broken panel, CLI, or MCP tool.
60
+ sources
61
+ end
62
+
63
+ # Every approved entry that is not currently confirmed for any of the
64
+ # given sources, as [Entry, reason] pairs -- the honest "this
65
+ # approval exists but does nothing" list the panel shows. Never
66
+ # executes anything.
67
+ def stale(sources, record: PopulationApprovals.load, discovery: PopulationDiscovery.new)
68
+ klasses = source_klasses(sources)
69
+ record.entries.filter_map do |entry|
70
+ klass = klasses[entry.model_name]
71
+ next [entry, :no_principal_source] unless klass
72
+ next [entry, :not_discovered] unless discovery.confirms?(klass: klass, method_name: entry.method_name)
73
+
74
+ nil
75
+ end
76
+ end
77
+
78
+ # Karst's local approval workflow is a development affordance and
79
+ # nothing else. Test is included so an application's own test suite
80
+ # (and Karst's) can exercise it; every other environment, production
81
+ # included, ignores the file entirely.
82
+ def local_environment?
83
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
84
+
85
+ env = Rails.env
86
+ env.respond_to?(:development?) && (env.development? || env.test?)
87
+ end
88
+
89
+ private
90
+
91
+ def extend_source(source, entries, discovery)
92
+ klass = source.record_klass
93
+ return source unless klass
94
+
95
+ approved = confirmed_populations(klass, entries, discovery, source.populations)
96
+ approved.empty? ? source : source.with_populations(approved)
97
+ end
98
+
99
+ # The callable built here closes over the class object Karst already
100
+ # held and a scope name discovery just confirmed -- no eval, no
101
+ # const_get, no stored Ruby. It is an ordinary configured-population
102
+ # callable from this point on, resolved (bounded, rollback-wrapped,
103
+ # write-rejecting) by Access::CandidatePopulation like any other.
104
+ def confirmed_populations(klass, entries, discovery, configured)
105
+ entries.each_with_object({}) do |entry, approved|
106
+ next unless entry.model_name == klass.name
107
+
108
+ name = entry.method_name.to_sym
109
+ next if configured.key?(name) || approved.key?(name)
110
+ next unless discovery.confirms?(klass: klass, method_name: name)
111
+
112
+ approved[name] = -> { klass.public_send(name) }
113
+ end
114
+ end
115
+
116
+ # Deliberately keyed by model name: two principal sources over the
117
+ # same model share that model's approvals, and an approval for a
118
+ # model no source exposes belongs to none of them.
119
+ def source_klasses(sources)
120
+ (sources || {}).each_with_object({}) do |(_name, source), klasses|
121
+ klass = source.record_klass
122
+ klasses[klass.name] = klass if klass&.name
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+ require_relative "database_isolation"
5
+
6
+ module Karst
7
+ module Access
8
+ # An application-authored, bounded subset of a model's rows -- for
9
+ # example the rows a configured callable like `-> { User.system_admins }`
10
+ # returns. A population is a hint about where meaningful sampling
11
+ # candidates might live; it is never a claim about behavior or
12
+ # authorization -- only Access::Sweep's runtime execution produces that
13
+ # evidence. See README "Candidate populations" for the full boundary
14
+ # this class exists to preserve.
15
+ #
16
+ # Deliberately generic over what a "candidate" represents. Today's only
17
+ # caller (PrincipalSampler) always treats these records as principals,
18
+ # but nothing here assumes that -- a future artifact-population caller
19
+ # (Subscription.renewable, Import.with_sheets) could resolve populations
20
+ # exactly the same way over a non-principal model, without this class
21
+ # changing at all.
22
+ #
23
+ # This deliberately does not claim that a configured callable is a "real"
24
+ # Rails named scope. Active Record exposes no reliable, public way to
25
+ # distinguish a method defined via the `scope` macro from an ordinary
26
+ # handwritten class method, so Karst only validates the one thing it can
27
+ # actually observe: calling the configured callable returns an
28
+ # ActiveRecord::Relation scoped to the same model being sampled. Whether
29
+ # that relation came from `scope :system_admins, -> { ... }` or a plain
30
+ # `def self.system_admins; ...; end` makes no difference here.
31
+ # rubocop:disable Metrics/BlockLength
32
+ CandidatePopulation = Value.define(:source, :name, :records, :provenance) do
33
+ class << self
34
+ # Resolves one configured name => callable pair into a bounded,
35
+ # already-queried population, or nil when calling the callable does
36
+ # not yield an ActiveRecord::Relation scoped to source_klass (wrong
37
+ # type, wrong model, or the callable itself raising -- including
38
+ # requiring an argument Karst never supplies). Invalid populations
39
+ # are skipped, never raised: a misconfigured population should
40
+ # degrade the candidate pool, not break the sweep. Issues at most
41
+ # one SELECT query, always LIMIT-bounded -- never a COUNT, never full
42
+ # materialization, regardless of how many rows the underlying
43
+ # relation matches. Evaluation and materialization happen inside a
44
+ # rollback-only transaction on the source model's connection. A
45
+ # candidate that emits mutating SQL is rejected even though Karst
46
+ # attempted to roll that transaction back.
47
+ def resolve(name:, callable:, source_klass:, limit:)
48
+ evaluation = evaluate(callable, source_klass, limit)
49
+ return nil unless usable_evaluation?(evaluation)
50
+
51
+ records = evaluation.value
52
+ return nil unless records
53
+
54
+ new(source: source_klass, name: name.to_sym, records: records, provenance: "population=#{name}")
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def evaluate(callable, source_klass, limit)
62
+ DatabaseIsolation.call(connection_class: source_klass) do
63
+ relation = callable.call
64
+ next unless relation.is_a?(ActiveRecord::Relation) && relation.klass == source_klass
65
+
66
+ bounded(relation, source_klass, limit)
67
+ end
68
+ end
69
+
70
+ def usable_evaluation?(evaluation)
71
+ evaluation.exception.nil? && evaluation.write_count.zero? && evaluation.database_rollback_attempted
72
+ end
73
+
74
+ # Only imposes primary-key ordering as a deterministic fallback when
75
+ # the configured relation has none of its own -- an application's
76
+ # own meaningful order (e.g. most-recently-flagged first) is
77
+ # respected rather than silently overridden.
78
+ def bounded(relation, klass, limit)
79
+ ordered = relation.order_values.empty? ? relation.order(klass.primary_key) : relation
80
+ ordered.limit(limit).to_a
81
+ end
82
+ end
83
+ end
84
+ # rubocop:enable Metrics/BlockLength
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "../value"
5
+
6
+ module Karst
7
+ module Access
8
+ # Runs application-authored discovery code in a rollback-only transaction
9
+ # and observes the same mutating SQL evidence used by Access::Sweep.
10
+ # This is same-connection database containment, not general side-effect
11
+ # isolation: jobs, mail, network calls, files, Redis, and writes through
12
+ # other connections remain outside this boundary.
13
+ class DatabaseIsolation
14
+ MUTATION = %r{\A\s*(?:/\*.*?\*/\s*)*(INSERT|UPDATE|DELETE)\b}im
15
+
16
+ Result = Value.define(:value, :exception, :write_count, :database_rollback_attempted)
17
+
18
+ def self.call(connection_class:, &block)
19
+ new(connection_class, block).call
20
+ end
21
+
22
+ def self.mutation?(sql)
23
+ sql.to_s.match?(MUTATION)
24
+ end
25
+
26
+ def initialize(connection_class, callable)
27
+ @connection_class = connection_class
28
+ @callable = callable
29
+ @writes = 0
30
+ end
31
+
32
+ def call
33
+ @connection_class.transaction(requires_new: true) do
34
+ ActiveSupport::Notifications.subscribed(method(:observe), "sql.active_record") { capture }
35
+ raise ActiveRecord::Rollback
36
+ end
37
+
38
+ result(rollback_attempted: true)
39
+ rescue StandardError => e
40
+ @exception ||= e
41
+ result(rollback_attempted: false)
42
+ end
43
+
44
+ private
45
+
46
+ def capture
47
+ @value = @callable.call
48
+ rescue StandardError => e
49
+ @exception = e
50
+ end
51
+
52
+ def observe(_name, _start, _finish, _id, payload)
53
+ @writes += 1 if self.class.mutation?(payload[:sql])
54
+ end
55
+
56
+ def result(rollback_attempted:)
57
+ Result.new(value: @value, exception: @exception, write_count: @writes,
58
+ database_rollback_attempted: rollback_attempted)
59
+ end
60
+ end
61
+ end
62
+ end