karst 0.1.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Karst finds a real, existing user who can reach a page in your Rails app — by actually running the route as your users and reporting what happened.
4
4
 
5
+ **Why Karst?** Karst terrain hides complex systems beneath the surface. As a Rails codebase grows, its real runtime paths become similarly difficult to see from the surface. The Karst gem reveals what actually happens underneath.
6
+
5
7
  "Which user can access this page?" is normally answered by reading role checks and `before_action` filters by hand, or asking around until someone remembers a working login. Karst answers it by running the real route, through your real Rails request stack, as a bounded set of real users — and shows you the evidence: HTTP status, redirect, halted callback, exception.
6
8
 
7
9
  ```text
@@ -35,7 +37,7 @@ Open `/karst` in your browser.
35
37
 
36
38
  Using Devise with one user model? That's usually it — Karst finds it automatically through Devise's own routing metadata, with no configuration. If Karst finds more than one Devise model, it asks you to pick which one(s) to test right there on the `/karst` panel — no initializer, no restart. If it finds none at all, it says so instead of guessing.
37
39
 
38
- Custom or non-Devise authentication needs a few lines of setup — see [Custom authentication](#custom-authentication) below.
40
+ Custom or non-Devise authentication needs a few lines of setup — see [Custom authentication](#custom-authentication) below. Using Rails 8's built-in `bin/rails generate authentication`? See [docs/rails8-authentication.md](docs/rails8-authentication.md) for the exact recipe — Rails' generated authentication has no registry Karst can safely infer from, unlike Devise.
39
41
 
40
42
  ## How it works
41
43
 
@@ -45,16 +47,35 @@ Custom or non-Devise authentication needs a few lines of setup — see [Custom a
45
47
  4. Every result shows what actually happened: status, redirect, halted callback, exception, and observed database writes.
46
48
  5. Found a usable user? Click **Test as** to become them in your own browser and keep working.
47
49
 
50
+ ## Working with an existing record
51
+
52
+ **Karst runs against records already in your development database, however they got there.** Console, seeds, `db:fixtures:load`, FactoryBot run in development/console, your app's own UI, or a tool like Body Double — Karst doesn't care how they were created, and doesn't require Body Double specifically.
53
+
54
+ That's true of the *path* unconditionally — `/admin/imports/123` is just a string, and any row backing it is fair game. It's true of *identity* only within a boundary: to run a probe as a specific principal (`--as`, or **Test as** on the panel), that record must be one Karst's configured principal source(s) can actually resolve (see [Identity.resolve](docs/advanced-configuration.md#running-as-a-specific-principal)) — an arbitrary development-database row is not automatically assumable as a principal just because it exists.
55
+
56
+ What Karst does *not* do is start from an object and find routes for you — you still bring the path. The workflow is:
57
+
58
+ 1. **Know a concrete path** — `/admin/imports/123`, from your own knowledge of the app.
59
+ 2. **Verify it** — `bin/rails karst:verify GET /admin/imports/123`, or press **Who can use this?** at `/karst`.
60
+ 3. **Optionally run as a specific user** — `--as User:72` on the CLI (see [CLI](#cli) below), or **Test as** on the panel once a usable user is found.
61
+ 4. **The panel/badge workflow** — every page in development carries a **Karst** badge linking straight to `/karst?controller=...&action=...`, already scoped to what rendered it.
62
+
63
+ One caveat worth being explicit about: a record created and then rolled back *inside a transactional test spec* never reaches the development database at all, so there's nothing there for Karst to run against. Data loaded into the development database itself — by any of the mechanisms above — is what "existing record" means here.
64
+
65
+ Karst does not claim to find every route a given user can reach, does not claim to prove nobody else can reach a route, and does not start a search from an arbitrary object. It runs the one route you name, using either Karst's sampled identity or the explicit principal you select, and reports what it observed.
66
+
48
67
  ## Candidate populations
49
68
 
50
69
  Sometimes the right user is rare and won't show up in a normal recent-user sample. Karst can find these groups itself — no configuration needed. When the ordinary sample comes up empty, `/karst` says so:
51
70
 
52
71
  ```
53
72
  No verified usable user found
54
- Karst found 3 application-defined user groups that could be tried. [ Review candidate groups ]
73
+ Karst found 3 application-defined user groups that could be tried.
55
74
  ```
56
75
 
57
- Open `/karst/populations`, check the groups Karst may try (`system_admins`, `auditors`, ...), and press **Approve**. From then on, approved groups are searched automatically — through `/karst`, the CLI, and MCP alike — until one produces a usable user. Approving is a hint, never a claim: Karst only reports that a user was *sampled from* `system_admins`, never that the group is what granted access.
76
+ Select the groups Karst may try (`system_admins`, `auditors`, ...) and press **Approve and rerun** without leaving `/karst`. From then on, approved groups are searched automatically — through `/karst`, the CLI, and MCP alike — until one produces a usable user. Approving is a hint, never a claim: Karst only reports that a user was *sampled from* `system_admins`, never that the group is what granted access.
77
+
78
+ Approvals are persistent local state. The advanced `/karst/populations` page exists only to inspect and revoke them, including approvals made stale by a renamed scope or changed principal source. It cannot approve new groups; normal route analysis never needs to visit it.
58
79
 
59
80
  Need populations committed as reviewable code, or applied outside your own machine (CI)? `config.principal_populations` does that and always takes precedence over an approval of the same name — see [docs/advanced-configuration.md](docs/advanced-configuration.md#curating-candidate-populations) for discovery, approval, and precedence details.
60
81
 
@@ -68,8 +89,6 @@ For every user it tries, Karst reports:
68
89
  - observed database writes
69
90
  - which user was tested, and which configured population (if any) produced them
70
91
 
71
- When it can, Karst also shows how the tested user relates to the resource on the page — for example, `Document #22 → user_id → User #27`.
72
-
73
92
  Karst reports observations, not authorization conclusions. If Rails halted at `authorize_admin`, Karst reports that callback name; it does not claim the user lacks permission unless your application says so itself.
74
93
 
75
94
  ## CLI
@@ -81,9 +100,89 @@ bin/rails karst:verify GET /admin/imports/123 --json
81
100
 
82
101
  Runs the same search as `/karst` from a shell. Exit code `0` means a usable user was found, `1` means the search completed without one, `2` means a setup error. The `--json` form is a stable, schema-versioned evidence document meant for scripts and tools.
83
102
 
103
+ The terminal output answers the access question first, in a few lines however many users were tested:
104
+
105
+ ```text
106
+ Sample: 50 users tested, 1 verified usable
107
+ 42 403 Forbidden · halted at require_admin
108
+ 6 302 Found → http://localhost:3000/suspended · halted at require_active
109
+ User #3, User #9, User #14, +3 more
110
+ 1 exception NoMethodError
111
+ User #31
112
+ 1 200 OK · verified usable
113
+ User #27
114
+ identity: 50 confirmed
115
+ ```
116
+
117
+ Each line is one observed outcome and how many users observed it, largest first. Users who observed the same response — same status, redirect, halted callback, exception, dispatched action, and database writes — are one outcome, however long each request took. Users are named, a few at a time, for every outcome except a single largest one; `--json` lists every user under every outcome.
118
+
119
+ Already know which existing record you want to test as — from the console, a seed, `db:fixtures:load`, or your app's own UI? Skip sampling and name it directly:
120
+
121
+ ```bash
122
+ bin/rails karst:verify GET /admin/imports/123 --as User:72
123
+ ```
124
+
125
+ `--as MODEL:ID` resolves that record through Karst's own configured principal source (the same lookup the panel's **Test as** button uses) and runs the route as exactly that principal — never a bespoke `constantize`/`find`, so a record outside your configured source(s) simply won't resolve. It's human-only: neither `verify_access` nor `reproduce_request` over MCP accepts it, and an agent can never pick a principal through Karst.
126
+
127
+ ## Reproducing a request
128
+
129
+ Sometimes the question isn't "who can reach this page" but "something calls
130
+ this endpoint — what request do I send to exercise the same behavior?".
131
+
132
+ Karst answers that the same way: it issues **one** request through your real
133
+ application, inside a rolled-back transaction, and reports what happened —
134
+ plus a cURL command for exactly what it sent.
135
+
136
+ ```bash
137
+ bin/rails karst:reproduce POST /api/v1/inspections \
138
+ --content-type application/json \
139
+ --body '{"serial_number":"ABC123","status":"passed"}'
140
+ ```
141
+
142
+ ```text
143
+ Observed execution
144
+ Api::V1::InspectionsController#create
145
+ halted at authenticate_api_key!
146
+
147
+ Observed response
148
+ 401 text/html
149
+
150
+ Observed effects
151
+ 0 database writes (rollback attempted on the same connection)
152
+
153
+ Reproduce
154
+ curl -X POST 'http://localhost:3000/api/v1/inspections' \
155
+ -H 'Content-Type: application/json' \
156
+ -d '{
157
+ "serial_number": "ABC123",
158
+ "status": "passed"
159
+ }'
160
+ ```
161
+
162
+ That halted callback is the endpoint's real gate — observed, not inferred from
163
+ reading the controller. Add the credential it wants, send again, and the cURL
164
+ you copy is one you have watched work.
165
+
166
+ `karst:reproduce` takes the same `--as MODEL:ID` as `karst:verify` above, to send that one request as a specific existing record instead of the ordinary sampled one.
167
+
168
+ Secrets never come back out. Parameters go through your app's own
169
+ `config.filter_parameters`, and credential-bearing headers become placeholders
170
+ like `<API_KEY>` without their values ever being read. The same thing is
171
+ available at `/karst` under **Reproduce request**, and to agents as the
172
+ `reproduce_request` MCP tool. See
173
+ [docs/request-reproduction.md](docs/request-reproduction.md).
174
+
84
175
  ## Coding agents
85
176
 
177
+ MCP support is optional. Add its runtime dependency to your application's
178
+ Gemfile and install it before starting the server:
179
+
180
+ ```ruby
181
+ gem "mcp", ">= 1.5.0", "< 1.7"
182
+ ```
183
+
86
184
  ```bash
185
+ bundle install
87
186
  bin/rails karst:mcp
88
187
  ```
89
188
 
@@ -95,11 +194,32 @@ bin/rails karst:mcp
95
194
  }
96
195
  ```
97
196
 
98
- Claude Code or another [MCP](https://modelcontextprotocol.io) client can call `verify_access` and get back exactly the evidence `karst:verify --json` prints. An agent can guess who *should* have access by reading code; only Karst can show who actually does. The agent picks the path and method — it can't choose a user, skip the rollback, or use Test As.
197
+ Claude Code or another [MCP](https://modelcontextprotocol.io) client gets two tools:
198
+
199
+ - `verify_access` — exactly the evidence `karst:verify --json` prints. An agent can guess who *should* have access by reading code; only Karst can show who actually does.
200
+ - `reproduce_request` — exactly the evidence `karst:reproduce --json` prints, including the redacted cURL command. It permits `GET` and `HEAD` by default; mutating methods fail closed before an application request is executed.
201
+
202
+ In both cases the agent picks the request — it can't choose a user, skip the rollback, raise a limit, or use Test As. That includes `--as`: it's a CLI-only, human-only option (see [CLI](#cli) above), and neither MCP tool accepts it in any form.
203
+
204
+ Human-driven `bin/rails karst:reproduce` and `/karst` reproduction retain
205
+ explicit `POST`, `PUT`, `PATCH`, and `DELETE` support. A host that deliberately
206
+ wants to give an MCP client the same mutating capability must opt in:
207
+
208
+ ```ruby
209
+ Karst.configure { |config| config.mcp_mutating_requests = true }
210
+ ```
211
+
212
+ This permits real application requests; it does not make them side-effect-free.
213
+ Karst only attempts rollback for database work on the request's own connection.
214
+ Jobs, email, outbound HTTP, files, Redis/external stores, other database
215
+ connections, and other non-transactional effects remain possible. See
216
+ [request reproduction](docs/request-reproduction.md#from-a-coding-agent).
217
+
218
+ `bin/rails karst:mcp` requires the `mcp` gem to satisfy `>= 1.5.0, < 1.7` — the 1.5 and 1.6 series, which Karst is tested against. If your application already depends on `mcp` at a version outside that range (directly, or through another gem), `karst:mcp` names the version it found and the range it needs; constrain `mcp` in your Gemfile as shown above and run `bundle update mcp` to resolve it.
99
219
 
100
220
  ## Configuration
101
221
 
102
- Usually, you don't. A conventional Devise app needs no initializer at all: the user model comes from Devise's own routing metadata, sampling states come from your schema, and candidate populations are approved at `/karst/populations` rather than written down.
222
+ Usually, you don't. A conventional Devise app needs no initializer at all: the user model comes from Devise's own routing metadata, sampling states come from your schema, and candidate populations are approved inline after a failed analysis rather than written down.
103
223
 
104
224
  The one option worth knowing is the off switch:
105
225
 
@@ -120,17 +240,25 @@ Karst.configure do |config|
120
240
  session.post "/karst_test_login", params: { account_id: account.id }
121
241
  end
122
242
  config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
243
+ # How Karst sees which account the app itself actually ran as:
244
+ config.observe_identity = ->(context) { context.controller&.current_account }
123
245
  end
124
246
  ```
125
247
 
126
- The compatibility-preserving `bin/rails generate karst:install` command optionally scaffolds this custom-authentication escape hatch. Replace its `TODO`s with your app's real sign-in/sign-out code. A conventional single-model Devise app needs none of its initializer, controller, or routes. Browser **Test as** needs a second, similar pair of hooks (`config.assume_browser_identity` / `config.clear_browser_identity`) — see [docs/advanced-configuration.md](docs/advanced-configuration.md).
248
+ That last hook is what lets Karst prove a result rather than assume it. Devise applications need no equivalent — Karst reads the application's own Warden session. Without either, Karst reports identity as *unobservable* instead of claiming a request ran as the user it was asked to run as. See [Runtime-confirmed identity](docs/advanced-configuration.md#runtime-confirmed-identity).
249
+
250
+ The `bin/rails generate karst:install` command optionally scaffolds this custom-authentication escape hatch. Replace its `TODO`s with your app's real sign-in/sign-out code. A conventional single-model Devise app needs none of its initializer, controller, or routes. Browser **Test as** needs a second, similar pair of hooks (`config.assume_browser_identity` / `config.clear_browser_identity`) — see [docs/advanced-configuration.md](docs/advanced-configuration.md).
251
+
252
+ Using Rails 8's own `bin/rails generate authentication` instead of Devise? [docs/rails8-authentication.md](docs/rails8-authentication.md) is the same escape hatch, filled in with that generator's own `User`/`Session`/`Current` objects.
127
253
 
128
254
  ## Safety
129
255
 
130
- Karst is for local development only — `/karst`, the badge, and Test As only work from loopback requests while `Rails.env.development?` is true. Every search is bounded (25 users by default, 100 max), and every probe runs inside a database transaction Karst rolls back.
256
+ Karst is for local development only — `/karst`, the badge, and Test As only work from loopback requests while `Rails.env.development?` is true. The ordinary sample is bounded to 25 users by default (100 max). If it finds no usable user, approved candidate populations can add a separate bounded retry stage. Every probe runs inside a database transaction Karst rolls back.
131
257
 
132
258
  That rollback only covers writes made through the same Active Record connection. Jobs, mail, external HTTP calls, files, Redis, and other database connections aren't covered — a route that triggers those can still cause real side effects even though its own database writes are undone.
133
259
 
260
+ Access analysis is GET-only, and stays that way: running a mutating method as 25 users would mean 25 real creates. [Request reproduction](docs/request-reproduction.md) is the one place a non-GET request is issued, always exactly once and always because you asked for it.
261
+
134
262
  ## Compatibility
135
263
 
136
264
  Ruby 2.7+, Rails 6.1+. Every core capability (`/karst`, CLI, MCP, access search) works across that whole range; the page-local badge needs Rack 3 (Rails 7.1+) and is simply absent on Rails 6.1/7.0 — `/karst` itself is unaffected. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for the CI-backed matrix.
data/SECURITY.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Supported versions
4
4
 
5
- Karst has not released a supported version yet. This policy will be updated when releases begin.
5
+ The latest released version of Karst receives security fixes. Older prerelease and early versions may not receive fixes; users should upgrade to the latest release.
6
6
 
7
7
  ## Reporting a vulnerability
8
8
 
@@ -4,8 +4,28 @@ Almost nothing here is part of installing Karst. A conventional single-model Dev
4
4
 
5
5
  If you are looking for an option that used to be here, check [Removed configuration](#removed-configuration) at the end.
6
6
 
7
+ ## Mutating MCP reproduction
8
+
9
+ The MCP `reproduce_request` tool permits only `GET` and `HEAD` by default.
10
+ Human-driven reproduction through `bin/rails karst:reproduce` and `/karst`
11
+ still supports explicit mutating methods. To grant that capability to MCP as
12
+ well, the host application must opt in:
13
+
14
+ ```ruby
15
+ Karst.configure { |config| config.mcp_mutating_requests = true }
16
+ ```
17
+
18
+ Without the opt-in, every other method is refused before an application
19
+ request is constructed or executed. With it, MCP may issue `POST`, `PUT`,
20
+ `PATCH`, and `DELETE` under Karst's normal reproduction constraints. Database
21
+ rollback is same-connection best effort, not general side-effect isolation:
22
+ outbound HTTP, email, jobs, files, Redis/external stores, writes through other
23
+ database connections, and other non-transactional effects can still occur.
24
+
7
25
  ## Custom or non-Devise authentication
8
26
 
27
+ Using Rails 8's own `bin/rails generate authentication` rather than Devise? See [rails8-authentication.md](rails8-authentication.md) for the exact, complete recipe — it is an instance of the same escape hatch documented below, filled in with that generator's own `User`/`Session`/`Current` objects. Karst has no registry to infer this from the way it does for Devise, so it needs the few lines below rather than nothing.
28
+
9
29
  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
30
 
11
31
  ```ruby
@@ -21,9 +41,19 @@ Karst.configure do |config|
21
41
  end
22
42
  ```
23
43
 
44
+ Then tell Karst how to see which principal the application itself ended up running as. Without this, Karst reports identity as *unobservable* for a non-Devise application rather than assuming its requested identity was the one that ran:
45
+
46
+ ```ruby
47
+ Karst.configure do |config|
48
+ config.observe_identity = ->(context) { context.controller&.current_account }
49
+ end
50
+ ```
51
+
52
+ See [Runtime-confirmed identity](#runtime-confirmed-identity) below for what Karst does with it.
53
+
24
54
  `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
55
 
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.
56
+ `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 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
57
 
28
58
  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
59
 
@@ -42,6 +72,75 @@ When both are configured, every usable user in the results gets a **Test as** bu
42
72
 
43
73
  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
74
 
75
+ ## Runtime-confirmed identity
76
+
77
+ Asking Karst to run a request as `User #123` is not evidence that the request ran as `User #123`. Karst keeps the two apart, on every outcome:
78
+
79
+ | | What it is |
80
+ | --- | --- |
81
+ | **requested** | The identity Karst was asked to execute as. Intent. `nil` for an anonymous probe. |
82
+ | **established** | What Karst's own identity seam managed to set up. Setup, never evidence. |
83
+ | **observed** | The principal the Rails application itself resolved while running the request. Evidence. |
84
+
85
+ `identity.confirmation` is the only field that says whether a principal claim about a request is true, and it fails closed:
86
+
87
+ | `confirmation` | Meaning |
88
+ | --- | --- |
89
+ | `confirmed` | A principal was requested, and the application resolved that same one. |
90
+ | `confirmed_anonymous` | An anonymous probe was requested, and the application resolved no principal. |
91
+ | `absent` | A principal was requested; the application resolved none. The request did **not** run as that user. |
92
+ | `mismatch` | The application resolved a *different* principal than the one requested. |
93
+ | `contaminated` | An anonymous probe was requested, and the application resolved a principal anyway. The probe is invalid, not anonymous. |
94
+ | `unobservable` | Karst could not determine which identity the application used. Nothing may be concluded. |
95
+
96
+ Only `confirmed` and `confirmed_anonymous` are evidence about identity.
97
+
98
+ Where the observation comes from:
99
+
100
+ - **Devise/Warden**: nothing to configure. Karst reads the application's own Warden proxy from the probe request — the same object Devise's `current_<scope>` helper returns — through Warden's public `Proxy#user`, which deserializes an existing session and never runs authentication strategies.
101
+ - **Anything else**: `config.observe_identity`, a callable given one context object (`#controller`, `#request`, `#env`) and returning the application's runtime principal, or `nil` when it resolved none. `controller` is the exact `ActionController` instance that processed the request — Rails sets it on the request env before any callback runs, so it is available even for a request a `before_action` halted.
102
+ - **Neither**: `unobservable`. Karst never falls back to the requested identity.
103
+
104
+ Timing matters: when an access callback halts the request, Karst observes identity *at that halt* — the state the application had established when the access decision was made — rather than after the request unwound. `identity.observed_at` says which moment an observation came from, and `identity.changed_during_request` is true when the identity the application ended with differs from the one the decision was made with.
105
+
106
+ This is also what makes authentication and authorization separable. A route gated by `authorize_admin` produces three different observations that mean three different things:
107
+
108
+ ```
109
+ anonymous observed none halted at authorize_admin
110
+ ordinary user observed User #4 halted at authorize_admin
111
+ admin user observed User #2 200 OK
112
+ ```
113
+
114
+ The first says the gate participates in authentication; the second that it imposes an authorization condition on an identity that *was* correctly established. Karst reports the observations, and does not itself conclude either.
115
+
116
+ ### Anonymous probes
117
+
118
+ An anonymous probe is a probe in its own right, not the absence of one:
119
+
120
+ ```bash
121
+ bin/rails karst:verify --anonymous /admin/imports/123
122
+ ```
123
+
124
+ or, over MCP, `verify_access(path:, identity: "anonymous")`. Karst establishes no identity, drops any identity queued for the probe session, runs the application's own `clear_identity` hook when one is configured, and then verifies at runtime that the application really did see no principal. If stale state produces one anyway, the result is `contaminated` — never "anonymous". An anonymous probe needs no principal source at all, so it works in an application Karst could not otherwise test.
125
+
126
+ ### Running as a specific principal
127
+
128
+ `karst:verify` and `karst:reproduce` both accept `--as MODEL:ID`, a human-only way to run as one already-existing record instead of a sampled one:
129
+
130
+ ```bash
131
+ bin/rails karst:verify GET /admin/imports/123 --as User:72
132
+ bin/rails karst:reproduce GET /admin/imports/123 --as User:72
133
+ ```
134
+
135
+ `MODEL:ID` is resolved exclusively through `Identity.resolve` — the same lookup the `/karst` panel's own **Test as** button already uses (see `Karst::Web::BrowserIdentity#assume`). It tries each configured principal source in turn and only ever runs a scoped query (or, for a plain Array/Enumerable source, an in-memory comparison) against that source's own records; a model name or id that does not belong to any configured source simply resolves to nothing. There is no `constantize`/`find` fallback and no way to reach a record Karst wasn't already configured to consider.
136
+
137
+ A few things this deliberately is not:
138
+
139
+ - It cannot be combined with `--anonymous` — one request has exactly one identity, requested one way.
140
+ - It never falls back to sampling or to anonymous when the reference doesn't resolve; an explicit `--as` that fails to resolve is a hard, structured error (`error.type: "input_error"`).
141
+ - It is not available over MCP. Neither `verify_access` nor `reproduce_request` accepts a principal argument in any form — an agent can choose *what* to request, never *who* to request it as. This is the same boundary that keeps Test As out of MCP.
142
+ - It does not turn an object into a route. You still bring the concrete path; Karst still only runs the one route you name.
143
+
45
144
  ## Authentication identifiers in local human output
46
145
 
47
146
  Without `config.principal_label`, Karst normally labels a principal `Model #id`.
@@ -113,23 +212,28 @@ When the right user is too rare for this to reach — a role held by three peopl
113
212
 
114
213
  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
214
 
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.
215
+ **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. Discovery is not approval — Karst finding `User.system_admins` says only that such a scope exists, never that it grants access.
117
216
 
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`:
217
+ **Approval.** When an analysis finds no usable user and unapproved candidates exist, `/karst` offers those candidates in the failed result. Pressing **Approve and rerun** persists the checked selection to `tmp/karst/approved_populations.json`, relative to `Rails.root`, and repeats that same route analysis:
119
218
 
120
219
  ```json
121
220
  { "version": 1, "approved": [{ "model": "User", "scope": "system_admins" }] }
122
221
  ```
123
222
 
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.
223
+ Deliberately machine-local, git-ignored development state, not project configuration, 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.
224
+
225
+ **Revocation.** The advanced `/karst/populations` surface lists only approvals already stored, marks approvals that current discovery or principal-source configuration no longer confirms, and revokes one exact entry at a time. It cannot discover or approve groups. This narrow page remains because stale approvals may no longer appear in any failed route result, while persistent local state must always have a safe removal path.
125
226
 
126
227
  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
228
 
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.
229
+ ## When configuration is read
230
+
231
+ Working out the effective principal sources is real work: reading the local approval and selection files, parsing model source with `Ripper`, and calling your `principals`/`principal_sources` callables. Karst does it **once per resolution**, and reuses that one result for everything else the same resolution answers — setup checks, sampling, population retries, `--as` lookup, **Test as** availability for every listed user, and (on `/karst`) discovering candidate populations to suggest. Within one resolution, each principal source callable is called once.
232
+
233
+ Reading `bin/rails karst:verify`, an MCP `verify_access` call, and an ordinary `/karst` analysis each take exactly one resolution. **Approving a population is the one operation that takes two**: validating the submission needs the effective sources *before* the write (to confirm what was submitted is still real), and the render that follows needs them fresh *after* the write, so the approval just saved is reflected rather than the state from before it. Each of those two resolutions is still itself one file read and one parse, never repeated within itself.
129
234
 
130
- ## Resource evidence
235
+ Nothing is cached beyond one resolution. The next operation reads everything again, so an approval revoked at `/karst/populations`, a scope deleted from a model, or a changed initializer takes effect on the next analysis without restarting the server. Callables that run per request by design — `assume_identity`, `clear_identity`, `observe_identity`, `principal_label` — and `usable_access_outcome`, which is applied to each outcome, are unaffected.
131
236
 
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
237
 
134
238
  ## Full configuration reference
135
239
 
@@ -149,6 +253,7 @@ The single switch that turns Karst's whole development surface off — `/karst`,
149
253
  | --- | --- |
150
254
  | `principals` | Custom or non-Devise authentication ([above](#custom-or-non-devise-authentication)) |
151
255
  | `assume_identity` / `clear_identity` | Signing a probe session in and out; must be configured together |
256
+ | `observe_identity` | Observing which principal the application actually ran as, for non-Devise authentication ([above](#runtime-confirmed-identity)) |
152
257
  | `assume_browser_identity` / `clear_browser_identity` | Browser **Test as** under custom authentication |
153
258
  | `principal_label` | A display label for a non-Active-Record principal |
154
259
  | `principal_sources` | Identity spread across several models ([above](#multiple-user-models-configprincipal_sources)) |
@@ -0,0 +1,191 @@
1
+ # Rails 8 generated authentication
2
+
3
+ `bin/rails generate authentication` (new in Rails 8) scaffolds a plain
4
+ `User`/`Session` pair, an `ActiveSupport::CurrentAttributes` `Current` class,
5
+ and an `Authentication` concern that resumes a session from a signed,
6
+ permanent `session_id` cookie. It is not Devise, and it registers itself
7
+ nowhere Karst can safely discover: Devise's zero-config path exists only
8
+ because `Devise.mappings` is public, framework-owned metadata Devise itself
9
+ already relies on for `current_user`/`authenticate_user!`. Rails' generated
10
+ authentication has no equivalent registry — there is no framework-provided
11
+ list of "the models this app authenticates" or "the concern doing it" for
12
+ Karst to read. Guessing from column names (`password_digest`), class names
13
+ (`User`, `Session`, `Current`), or `ApplicationController`'s ancestry would
14
+ be exactly the kind of heuristic inference Karst deliberately never does for
15
+ custom authentication (see [Custom or non-Devise
16
+ authentication](advanced-configuration.md#custom-or-non-devise-authentication)).
17
+ So this is a small, explicit recipe instead of a one-liner — six short
18
+ `Karst.configure` blocks below cover the whole thing, using this
19
+ application's own generated `User`, `Session`, and `Authentication`
20
+ concern exactly as the generator created them. Nothing here monkey-patches
21
+ `Current`, `Session`, or any generated controller.
22
+
23
+ This is the *complete* minimum configuration; nothing else is required.
24
+
25
+ ## 1. Principal source
26
+
27
+ ```ruby
28
+ Karst.configure do |config|
29
+ config.principals = -> { User.all }
30
+ end
31
+ ```
32
+
33
+ ## 2. Probe identity: sign a probe session in
34
+
35
+ Karst's access search runs each probe through an isolated
36
+ `ActionDispatch::Integration::Session`, inside a database transaction Karst
37
+ always rolls back — so the cleanest way to sign a probe in is the same way
38
+ a real user signs in: create a `Session` row and let the real
39
+ `Authentication` concern resume it. Run `bin/rails generate karst:install`
40
+ to scaffold the controller and development-only routes (the generator is
41
+ authentication-agnostic; it works the same regardless of what identity
42
+ system fills in its `TODO`s). Replace the *entire body* of the generated
43
+ `app/controllers/karst_identity_controller.rb` -- not just its `TODO`s --
44
+ with the code below; the generator's own template raises
45
+ `NotImplementedError` and has no knowledge of this application's generated
46
+ `Authentication` concern:
47
+
48
+ ```ruby
49
+ # app/controllers/karst_identity_controller.rb
50
+ class KarstIdentityController < ApplicationController
51
+ allow_unauthenticated_access only: :create
52
+ skip_before_action :verify_authenticity_token, raise: false
53
+
54
+ def create
55
+ principal = Karst::Identity.resolve(model_name: params[:principal_type], id: params[:principal_id])
56
+ return head(:forbidden) unless principal
57
+
58
+ start_new_session_for(principal) # from this app's own Authentication concern
59
+ head :no_content
60
+ end
61
+
62
+ def destroy
63
+ terminate_session # from this app's own Authentication concern
64
+ head :no_content
65
+ end
66
+ end
67
+ ```
68
+
69
+ `start_new_session_for`/`terminate_session` are the exact private helpers
70
+ the Rails 8 generator already put in `app/controllers/concerns/authentication.rb`
71
+ — this controller calls them, it does not reimplement them. `only: :create`
72
+ mirrors the generated `SessionsController`'s own
73
+ `allow_unauthenticated_access only: %i[new create]`: signing a probe in must
74
+ skip `require_authentication` (nothing is authenticated yet), but signing
75
+ one out does not need to, exactly like a real user's logout.
76
+
77
+ ```ruby
78
+ Karst.configure do |config|
79
+ config.assume_identity = lambda do |session, principal|
80
+ descriptor = Karst::Identity.describe(principal)
81
+ session.post "/karst_test_login", params: { principal_type: descriptor.model_name, principal_id: descriptor.id }
82
+ end
83
+ end
84
+ ```
85
+
86
+ ## 3. Probe identity: clear it
87
+
88
+ ```ruby
89
+ Karst.configure do |config|
90
+ config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
91
+ end
92
+ ```
93
+
94
+ Karst always calls this after a probe, including when the route being
95
+ tested raises — so a probe's `Session` row is created and destroyed inside
96
+ the same rollback-only transaction as everything else it does. No probe
97
+ identity ever outlives its own analysis. See [the "database writes
98
+ observed" note](#note-database-writes-observed-is-honest-not-zero) below
99
+ for the one visible side effect of this being a *real* database write,
100
+ even though it never persists.
101
+
102
+ ## 4. Runtime-confirmed identity
103
+
104
+ Karst reports which principal the *application* resolved, not the one it was
105
+ asked to run as. The generated `Authentication` concern resolves exactly that
106
+ into `Current.user`, so the whole observation is one line:
107
+
108
+ ```ruby
109
+ Karst.configure do |config|
110
+ config.observe_identity = ->(_context) { Current.user }
111
+ end
112
+ ```
113
+
114
+ `Current` is request-local and reset by Rails' own executor between requests,
115
+ so reading it after the probe request is reading that request's own resolved
116
+ identity. A probe whose identity the application never established is then
117
+ reported as `absent` rather than as the requested user having reached the
118
+ route; an anonymous probe (`bin/rails karst:verify --anonymous`) is confirmed
119
+ anonymous only when `Current.user` really was nil. See [Runtime-confirmed
120
+ identity](advanced-configuration.md#runtime-confirmed-identity).
121
+
122
+ ## 5. Browser Test As
123
+
124
+ Test As mutates the developer's real browser session directly, not through
125
+ a sub-request — so it sets the same signed cookie
126
+ `start_new_session_for` sets, using a real `Session` row:
127
+
128
+ ```ruby
129
+ Karst.configure do |config|
130
+ config.assume_browser_identity = lambda do |request, principal|
131
+ rails_request = ActionDispatch::Request.new(request.env)
132
+ probe_session = principal.sessions.create!(user_agent: "Karst Test As", ip_address: "127.0.0.1")
133
+ rails_request.cookie_jar.signed.permanent[:session_id] =
134
+ { value: probe_session.id, httponly: true, same_site: :lax }
135
+ end
136
+ end
137
+ ```
138
+
139
+ `request` here is a bare `Rack::Request` (Karst serves `/karst` at the Rack
140
+ boundary, before Action Controller) — wrapping its `env` in
141
+ `ActionDispatch::Request` is ordinary Rack request-object adaptation, not a
142
+ patch to any Rails class, and gives access to the same signed `cookie_jar`
143
+ a real controller's `cookies.signed` uses. Because `ActionDispatch::Cookies`
144
+ sits above Karst's middleware in the stack, the `Set-Cookie` header this
145
+ produces is flushed on the way back out exactly like it would be for a real
146
+ controller action.
147
+
148
+ ## 6. Stop Testing As
149
+
150
+ ```ruby
151
+ Karst.configure do |config|
152
+ config.clear_browser_identity = lambda do |request|
153
+ rails_request = ActionDispatch::Request.new(request.env)
154
+ Session.find_by(id: rails_request.cookie_jar.signed[:session_id])&.destroy
155
+ rails_request.cookie_jar.delete(:session_id)
156
+ end
157
+ end
158
+ ```
159
+
160
+ This is the whole recipe. There is no seventh step, and nothing above reaches
161
+ into `Current`, `Session`, or a generated controller's internals —
162
+ `start_new_session_for`/`terminate_session` are called as the application's
163
+ own public-to-its-subclasses methods, exactly as a real controller action
164
+ would call them.
165
+
166
+ ## Note: "database writes observed" is honest, not zero
167
+
168
+ Devise/Warden's Test As and the plain `session[:user_id] = ...` custom-auth
169
+ example are memory-only: neither ever performs a database write to sign a
170
+ probe in or out, so a route with no writes of its own reports "Database
171
+ writes observed: 0". Rails' generated authentication persists a `Session`
172
+ row per sign-in, so every probe under this recipe legitimately shows **2**
173
+ observed writes (the login `INSERT` and the logout `DELETE`) even when the
174
+ route itself never touches the database. This is not a bug and not
175
+ something to suppress: Karst reports what actually happened, and an insert
176
+ + delete genuinely happened. It does mean write-count evidence from this
177
+ recipe is not directly comparable to a Devise/Warden or memory-only
178
+ custom-auth analysis of the same route — a "2 writes observed" result here
179
+ can mean only Karst's own probe login/logout wrote anything.
180
+
181
+ ## Acceptance test
182
+
183
+ [`spec/integration/rails8_auth_golden_path_integration_spec.rb`](../spec/integration/rails8_auth_golden_path_integration_spec.rb)
184
+ boots a real `Rails::Application` using exactly this recipe against a
185
+ fixture that reproduces the Rails 8 generator's own files
186
+ ([`spec/support/rails8_auth_application.rb`](../spec/support/rails8_auth_application.rb)),
187
+ with an ordinary user population and one rare privileged user, a restricted
188
+ route, a real access search, candidate-population discovery/approval, Test
189
+ As, Stop Testing As, and an assertion that no row is left behind afterward.
190
+ It runs against the real `rails ~> 8.0.0` gemfile
191
+ ([`gemfiles/rails_8_0.gemfile`](../gemfiles/rails_8_0.gemfile)) in CI.