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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +14 -12
- data/CHANGELOG.md +114 -47
- data/CONTRIBUTING.md +9 -1
- data/README.md +137 -9
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +112 -7
- data/docs/rails8-authentication.md +191 -0
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +57 -11
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/railtie.rb +4 -7
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +145 -84
- data/lib/karst/web/panel.rb +343 -125
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +3 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +38 -18
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- 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.
|
|
73
|
+
Karst found 3 application-defined user groups that could be tried.
|
|
55
74
|
```
|
|
56
75
|
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|