karst 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +8 -11
- data/CHANGELOG.md +37 -50
- data/README.md +20 -8
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +8 -9
- data/docs/rails8-authentication.md +171 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/configuration.rb +5 -5
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/railtie.rb +4 -7
- 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 +50 -78
- data/lib/karst/web/panel.rb +44 -81
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +1 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +14 -1
- metadata +7 -14
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c8a746d264f980f133db4f4e60538708b4323b3cdbbbfb0689ce82278bd0cce
|
|
4
|
+
data.tar.gz: 8060e9f4c93bdf891a2a19fb1195df8d3a2900ddda983bf8230117c1a1bec569
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecced3eecbac561c88edb885f3eded09fa4d4cc67a3ec61e557ed4d27ef684e02c216009d2d503dbc2ec6cb69b56f379f75b37104d98cadc2eb553827bc7ba93
|
|
7
|
+
data.tar.gz: 4c71f5045b65849961c6490720a6535ab337157540a8c689a14474c66d228fdb42b95867813741df0aacf81726d4c111be78b124554a678e3cd9075d79e67752
|
data/ARCHITECTURE.md
CHANGED
|
@@ -5,14 +5,11 @@ This document describes how Karst is put together and the policy that governs wh
|
|
|
5
5
|
## Component map
|
|
6
6
|
|
|
7
7
|
- `Karst::Configuration` — the whole public configuration surface, deliberately small: one on/off switch, the escape hatches for authentication Karst cannot infer, and four bounds with working defaults. It also owns `#principal_sources`, the single effective-configuration method every adapter reads. Karst installs no `ActiveSupport::Notifications` subscriber at boot; the only SQL Karst observes is what a probe itself emits, through a scoped per-probe subscription in `Access::DatabaseIsolation`.
|
|
8
|
-
- `Karst::Spec::Observer` / `Spec::Reporter` — an opt-in RSpec integration that turns real spec execution (via `ActiveSupport::Notifications` and Warden's public hooks) into a deterministic JSON scenario artifact, with no source parsing and no database access.
|
|
9
|
-
- `Karst::Spec::Catalog` — a read-only index over that artifact; requires none of RSpec, Rails, or a database to read an already-written catalog.
|
|
10
8
|
- `Karst::Web::Middleware` / `Web::Panel` / `Web::Badge` / `Web::Locality` — Karst's development-only HTTP surface: `GET /karst` served directly at the Rack boundary (no engine, route, or controller) plus an optional page-local badge injected into eligible host HTML responses.
|
|
11
9
|
- `Karst::Access::PrincipalSampler` — a bounded candidate-selection step ahead of `Access::Sweep`, with nothing configurable in it. It materializes one recent-N pool with a single `LIMIT` query, then stratifies that pool in memory over states derived from the schema itself: boolean columns, `enum` columns, nullable-foreign-key presence/absence, and low-cardinality scalars, minus anything PII- or tenancy-shaped. Its total query volume is exactly one query, independent of table size -- no discovery query, `COUNT`, unbounded scan, or live subquery. It only selects candidates and never executes a route, so `Access::Sweep` remains the sole behavioral-evidence contract. Candidate populations are deliberately not here: they are `Access::Search`'s second stage, so "the ordinary sample failed, then `system_admins` reached it" stays reportable rather than being folded into an ordinary-looking sample.
|
|
12
10
|
- `Karst::Access::CandidatePopulation` — resolves one configured name => callable pair into a bounded, already-queried population plus a provenance label (`population=system_admins`), or `nil` when calling the callable does not yield an `ActiveRecord::Relation` scoped to the same model being sampled; never raises for a misconfigured population, and never issues more than one `LIMIT`-bounded query regardless of the underlying relation's row count. Preserves the configured relation's own ordering when it has one, adding a deterministic primary-key fallback only when it does not. Deliberately does not claim that a configured callable is a "real" Rails named scope -- Active Record exposes no reliable, public way to distinguish a method defined via the `scope` macro from an ordinary handwritten class method, so this class validates only what it can actually observe: the shape of what calling the callable returns. A population is a hint about where meaningful candidates might live -- "these records are worth trying," never "these records satisfy the behavior" -- so it carries no authorization or behavioral claim; only `Access::Sweep`'s runtime execution produces that evidence. Deliberately generic over what a candidate represents: `PrincipalSampler` is its only caller today, but nothing in this class assumes authentication, so a future artifact-population caller (`Subscription.renewable`, `Import.with_sheets`) could reuse it unchanged.
|
|
13
11
|
- `Karst::Access::PrincipalSource` / `PrincipalSelection` — the multi-source layer above `PrincipalSampler`. A `PrincipalSource` is "which records may Karst consider at all": a name, a lazily-evaluated records callable, and its own optional `populations`. Those two keys are the whole spec; any other key raises rather than being ignored. `Configuration#principal_sources` normalizes either an explicit `config.principal_sources` Hash or a bare `config.principals` (plus `config.principal_populations`) into one implicit `:default` source, so every downstream consumer only ever handles "one or more sources." `PrincipalSelection` runs `PrincipalSampler` independently per source -- never materializing sources together -- and allocates combined candidates fairly within one overall `limit`, tagging `source=<name>` on candidates only once more than one source is actually configured.
|
|
14
12
|
- `Karst::Access::PopulationDiscovery` / `PopulationApprovals` / `ApprovedPopulations` — the local approval workflow that removes hand-written `config.principal_populations` from the normal path. `PopulationDiscovery` parses application model source with Ripper and lists statically named, zero-parameter Rails `scope` declarations; it executes no scope, issues no query, and mutates nothing. `PopulationApprovals` is the machine-local record of which of those a developer explicitly approved (`tmp/karst/approved_populations.json`) -- plain model and scope names, never executable Ruby, never user data, never evaluated, and fails closed on any malformed, unreadable, or version-mismatched document. `ApprovedPopulations` folds an approval back into ordinary `PrincipalSource` populations inside `Configuration#principal_sources`, but only when Karst is in development/test, the entry's model name matches the Active Record class of an already-configured principal source (the class always comes from that source, never from the file), and current discovery still confirms that exact scope. Explicit configuration wins outright on name conflict and keeps its position first. Because the merge happens in the one effective-configuration method, the panel, `bin/rails karst:verify`, and the MCP `verify_access` tool inherit approvals identically and none of them reads approval state itself.
|
|
15
|
-
- `Karst::Access::ResourceEvidence` — a read-only, downstream step for one already-selected `Access::Sweep` outcome. Reports simple, directly observed foreign-key relationships (column name plus id equality, nothing else) between the exact resource a route addresses and one exact principal; never a join, a `has_many` traversal, or any other attribute. Resource resolution from a route path trusts only Rails' own route recognition plus controller-to-model naming convention, while principal resolution always goes through `Karst::Identity.resolve` and therefore cannot escape `config.principals`; when either step is ambiguous it reports a limitation string rather than guessing. Deliberately a separate class from both `Sweep` and `PrincipalSampler`: it runs no route and selects no candidates, only compares two already-identified records.
|
|
16
13
|
|
|
17
14
|
Each area is deliberately narrow and composable; none of them depends on the others' internals beyond the public objects listed above.
|
|
18
15
|
|
|
@@ -24,11 +21,11 @@ The guiding rule: **core Karst functionality works on Rails 6.1 even where an op
|
|
|
24
21
|
|
|
25
22
|
### Capability degradation
|
|
26
23
|
|
|
27
|
-
| Rails / Rack | `require "karst"` | Access search (`/karst`, CLI, MCP) |
|
|
28
|
-
|
|
29
|
-
| 6.1 / Rack 2 | yes | yes | yes |
|
|
30
|
-
| 7.0 / Rack 2 | yes | yes | yes |
|
|
31
|
-
| 7.1, 7.2, 8.x / Rack 3 | yes | yes | yes | yes |
|
|
24
|
+
| Rails / Rack | `require "karst"` | Access search (`/karst`, CLI, MCP) | `/karst` panel | Page-local badge |
|
|
25
|
+
|---------------------|:---:|:---:|:---:|:---:|
|
|
26
|
+
| 6.1 / Rack 2 | yes | yes | yes | **unavailable** |
|
|
27
|
+
| 7.0 / Rack 2 | yes | yes | yes | **unavailable** |
|
|
28
|
+
| 7.1, 7.2, 8.x / Rack 3 | yes | yes | yes | yes |
|
|
32
29
|
|
|
33
30
|
The badge is the one capability that degrades. `Karst::Web::Badge` only ever rewrites a Rack response body that reports itself bufferable via Rack's own `to_ary` idiom (the same check `Rack::ETag` relies on for the same reason). Under Rack 2, `ActionDispatch`'s response body wrapper never exposes `to_ary`, so every response reports non-bufferable and Badge leaves it untouched — this is a real, per-response runtime check, not a hardcoded Rails-version branch, so it needs no maintenance as new Rack/Rails combinations appear. `/karst` itself does not depend on badge injection at all: it is a small, independent Rack middleware branch keyed on `PATH_INFO`, so it is unaffected. Karst never monkey-patches `ActionView`, never consumes a streaming body to work around this, and never weakens `Content-Length`, CSP, or host middleware semantics to force badge parity onto Rack 2.
|
|
34
31
|
|
|
@@ -38,12 +35,12 @@ Ruby 2.7 has no `Data.define` (added in Ruby 3.2). Every former `Data.define` si
|
|
|
38
35
|
|
|
39
36
|
### Request-local state: `Karst::ExecutionContext`
|
|
40
37
|
|
|
41
|
-
The page badge
|
|
38
|
+
The page badge needs request-local (not global, not thread-shared-and-racy) correlation storage: evidence captured inside a notification callback, read back out after the call returns. Modern Rails provides exactly this via `ActiveSupport::IsolatedExecutionState`, added in Rails 7.0. `Karst::ExecutionContext` is the seam:
|
|
42
39
|
|
|
43
40
|
- When `ActiveSupport::IsolatedExecutionState` is defined, `Karst::ExecutionContext` delegates directly to it — modern Rails keeps using its own preferred primitive, with no extra indirection cost.
|
|
44
41
|
- Otherwise (Rails 6.1), it falls back to `ThreadLocalStore`, a plain per-thread `Hash` reached through `Thread#thread_variable_get`/`thread_variable_set` — deliberately not `Thread#[]`/`[]=`, which are fiber-local and would silently miss context under a Fiber scheduler.
|
|
45
42
|
|
|
46
|
-
The fallback mirrors `IsolatedExecutionState`'s own default `:thread` isolation level: storage is shared by every Fiber running on one OS thread, not isolated per Fiber. Karst's own usage (one badge
|
|
43
|
+
The fallback mirrors `IsolatedExecutionState`'s own default `:thread` isolation level: storage is shared by every Fiber running on one OS thread, not isolated per Fiber. Karst's own usage (one badge correlation captured and read back within a single synchronous request) never spans multiple concurrently-scheduled Fibers, so this has no observable effect on Karst's supported behavior — it is documented so a future caller does not assume Fiber isolation the fallback cannot provide.
|
|
47
44
|
|
|
48
45
|
Both backends share the same three-method contract (`[]`, `[]=`, `delete`), cleanup happens in the caller's own `ensure` block exactly as before, and neither backend introduces global mutable state: each thread only ever sees its own slot, so concurrent Puma requests cannot cross-contaminate each other's context.
|
|
49
46
|
|
|
@@ -56,4 +53,4 @@ Compatibility decisions live behind exactly two narrow seams — `Karst::Value`
|
|
|
56
53
|
- `unit-test` — Ruby 3.2, the root `Gemfile` (RSpec + RuboCop against everything except `spec/integration`).
|
|
57
54
|
- `rails-integration` matrix — `spec/integration` against a version-pinned `Gemfile` per row, each a real Rails application booted through Rack: Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, Rails 7.2 and 8.0 on Ruby 3.3. Every row is a required, blocking job.
|
|
58
55
|
|
|
59
|
-
The Rails 6.1 row is what backs the compatibility claim in this document: it boots a real `Rails::Application`, exercises `GET` against ordinary routes and `/karst`,
|
|
56
|
+
The Rails 6.1 row is what backs the compatibility claim in this document: it boots a real `Rails::Application`, exercises `GET` against ordinary routes and `/karst`, all against genuine Ruby 2.7 syntax and Rails 6.1 APIs — not an assumption that "this probably still works."
|
data/CHANGELOG.md
CHANGED
|
@@ -1,67 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to Karst
|
|
3
|
+
All notable changes to Karst are documented in this file.
|
|
4
4
|
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
7
|
|
|
7
|
-
## [
|
|
8
|
+
## [0.2.0]
|
|
8
9
|
|
|
9
|
-
###
|
|
10
|
-
|
|
11
|
-
- **The golden path was broken for every real Devise/Warden application.** `Karst::Access::ProbeApplication`'s deliberately minimal probe Rack stack never wrapped `Warden::Manager`, so `env["warden"]` never existed inside a probe and every principal failed with `Karst::Identity::Unavailable` instead of real observed evidence. Fixed by wrapping a `Warden::Manager` (reusing Devise's own already-configured scope defaults, session serializers, and failure app when Devise is present) directly around the router, in the same position a real host application's own compiled middleware stack uses. Because `Karst::Identity.with` establishes probe identity *before* the caller's own request runs, `Karst::Identity::WardenAdapter#assume` now queues the principal (via a thread-local, request-scoped slot -- never Warden's own global `Test::Helpers` queue, which would leak across concurrent unrelated requests) and applies it the moment that exact probe's own request reaches the newly-wrapped `Warden::Manager` -- the same queue-for-next-request idiom `Warden::Test::Helpers#login_as` and `Devise::Test::IntegrationHelpers#sign_in` already use for the identical problem in integration tests.
|
|
12
|
-
- `ProbeApplication` no longer passes Devise's live `warden_config` Hash directly into `Warden::Manager.new`'s `options` argument: that constructor destructively deletes `:default_strategies` from whatever Hash it's given, silently corrupting the one shared config object every *real* host request also authenticates through -- a single Karst probe could previously break the host application's own Devise login for the rest of the process. A duplicate is used instead, and only through `Warden::Manager`'s config *block* (`Warden::Manager.new(endpoint) { |config| config.merge!(...) }`), never as constructor `options` -- passing it as `options` also mis-splats Devise's own already-per-scope `:default_strategies` Hash into `:_all`, misfiling every scope name as if it were itself a strategy and raising Warden's own `"Invalid strategy <scope>"` the moment more than one Devise model is involved.
|
|
13
|
-
- `ProbeApplication::Environment#call` built a fresh, merged copy of the incoming Rack env on every call instead of mutating it in place, so any state a wrapped middleware set during the request (in particular `env["warden"]`) was invisible to the exact env object `ActionDispatch::Integration::Session` retains afterward as `#request.env` -- `WardenAdapter#clear` could never find the very proxy `#assume` had just used, raising `Karst::Identity::Unavailable` from Access::Sweep's own cleanup path even on an otherwise fully successful probe.
|
|
14
|
-
- **"Stop testing as" could fail and leave the assumed identity active.** `Karst::Web::BrowserIdentity#return_path` raised when the panel's hidden `path` field was blank -- which it always is on a plain `/karst` visit with no `?path=` query string, exactly what a developer sees immediately after Test As redirects them to the page they were testing. Karst now falls back to `/karst` itself for a blank return path instead of raising.
|
|
15
|
-
- **A multi-Devise application's ambiguity notice never appeared until *after* a route was already selected.** `Web::Panel#access_section` rendered nothing at all -- not even the "Karst found N user types, which should Karst test?" selection form, and not the custom-authentication pointer either -- until a URL was chosen, so a fresh multi-Devise (or non-Devise) installation's very first `/karst` visit looked entirely blank with no indication anything needed attention. The same setup notice now renders regardless of whether a route is selected yet.
|
|
10
|
+
### Added
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
- Documented and acceptance-tested support for Rails 8 generated authentication.
|
|
13
|
+
- Added local selection of the user models Karst should test when a Devise application has multiple mappings.
|
|
14
|
+
- Added inline, local approval of discovered candidate populations after an unsuccessful ordinary sample.
|
|
15
|
+
- Added `config.population_retry_limit` for bounding approved-population retries.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
- **`config.artifact_source` / `config.access_scenario`.** Explicit artifact scenarios swept application records ("can any recent import be opened cross-account?") rather than answering "which user can reach this route," had no entry point in the primary workflow, and were already absent from the README. `Karst::Access::ArtifactSource`, `Access::Scenario`, and `Access::ScenarioSweep` are removed along with the panel's per-scenario buttons and the middleware's `artifact_sweep` operation.
|
|
21
|
-
- **`config.buffer_size`, and the runtime SQL evidence subsystem with it.** Karst kept a process-wide, bounded `sql.active_record` buffer from an earlier product direction; no Karst surface has reported it since `/karst` was restructured around access search, so a host application was paying a notification callback on every query for evidence nobody could see. `Karst.buffer`, `Karst.window`, `Karst.subscribe!`/`unsubscribe!`/`subscribed?`, `Karst::Buffer`, `Karst::Subscription`, and `Karst::Sql::Event`/`Canonicalizer`/`Shape`/`Window` are removed, as is the railtie's `after_initialize` subscription. **Karst now installs no `ActiveSupport::Notifications` subscriber at boot at all.** Database-write evidence during a probe is unaffected: it has always come from `Access::DatabaseIsolation`'s own scoped, per-probe subscription, not from this buffer.
|
|
17
|
+
### Changed
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
- Candidate-population approval now happens inline in the failed `/karst` result, followed by an immediate retry.
|
|
20
|
+
- `/karst/populations` is limited to inspecting and revoking stored approvals, including stale approvals.
|
|
21
|
+
- MCP support is opt-in instead of a runtime dependency of the gem.
|
|
22
|
+
- The product surface is simplified around route-access verification: an ordinary bounded sample followed, when needed, by a separate bounded search of approved candidate populations.
|
|
23
|
+
- `config.enabled` now gates all of Karst's development surfaces.
|
|
24
24
|
|
|
25
|
-
###
|
|
25
|
+
### Removed
|
|
26
26
|
|
|
27
|
-
-
|
|
28
|
-
-
|
|
27
|
+
- The `Karst::Spec::*` observer, catalog, and scenario subsystem.
|
|
28
|
+
- `Karst::Access::ResourceEvidence` and inferred resource-relationship presentation.
|
|
29
|
+
- Candidate-population preview and Ruby-snippet export.
|
|
30
|
+
- The `karst:populations` rake task.
|
|
31
|
+
- Redundant population-management and discovery UI superseded by inline approval.
|
|
32
|
+
- The unused runtime SQL buffer and its public `Karst.buffer` and `Karst.window` analysis surface.
|
|
33
|
+
- Unused artifact scenarios and configurable principal dimensions.
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
- The manual `population_sweep` operation and its "Try another population" panel section are removed, along with `Karst::Access::PopulationSuggestion` (its name-ranking heuristic only existed to order that manual choice). Retries are automatic.
|
|
32
|
-
- Panel copy now says "user" rather than "principal" in the access workflow.
|
|
33
|
-
- `/karst/populations` is reorganized around approval rather than snippet generation: candidate groups are approved with a checkbox and one **Approve selected groups** button, approvals and stale approvals are surfaced first, and Ruby snippet generation moves to an advanced export path for applications that prefer to commit their populations (or for CI, where machine-local approval state is deliberately not consulted). Preview remains available, bounded and rollback-safe, and is never required in order to approve. The page's POST operations now require a same-origin request, since approving writes local state that outlives the request.
|
|
35
|
+
## [0.1.0]
|
|
34
36
|
|
|
35
37
|
### Added
|
|
36
38
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
- Bounded, thread-safe, process-local retention of recent events through `Karst.buffer`.
|
|
46
|
-
- Experimental, conservative SQL canonicalization independent of event capture, preserving structural casts and list cardinality while normalizing supported literals, whitespace, and ordinary comments.
|
|
47
|
-
- Internal deterministic query-shape identity: a SHA-256-based fingerprint over canonicalized SQL, with declared `IN (?+)` placeholder-list arity equivalence, feeding an immutable `Karst::Sql::Shape` that aggregates count, cache hits, duration statistics, and up to three sample events (first, slowest, latest) per shape.
|
|
48
|
-
- `Karst.window`, Karst's first public analysis API: one immutable `Karst::Sql::Window` snapshot per call, derived from exactly one `Karst.buffer.to_a` read and grouped into `shapes` (deterministically ordered by count, then duration, then fingerprint) and `declined` events, with `event_count`, `capacity`, and `saturated` reporting whether older events may already have been evicted from the retained window.
|
|
49
|
-
- `Buffer#capacity`, exposing the fixed capacity of the retained buffer so `Karst.window` can report it.
|
|
50
|
-
- `GET /karst`, a development-only HTTP evidence surface served by a small Rack middleware (no engine, route, or controller) that presents `Karst.enabled?`, `Karst.subscribed?`, and basic `Karst.window` counts. Loopback-only, gated by `Rails.env.development?` at both insertion and request time, and transparent to every other request.
|
|
51
|
-
- `Karst::Spec::Observer`, an opt-in RSpec integration (`require "karst/spec/observer"`) that turns real spec execution into a deterministic JSON scenario catalog: for every example that reaches a browser-facing (HTML) Rails request, it records the request's method, recovered route pattern, controller/action, format, status, and redirect target (with any query string stripped, since redirect targets can carry the same class of secret as request paths), alongside the Warden principal immediately before and after that request and whether it changed -- raw evidence rather than a "setup versus subject" classification, since a single request offers no reliable signal for telling a signup or checkout-completion route that happens to authenticate apart from a login route. Also records the example's stable id, file/line, nested description, and outcome. Built entirely from `ActiveSupport::Notifications` and Warden's public hooks; never parses spec source, route-helper arguments, or FactoryBot calls, and never persists into the host application's database.
|
|
52
|
-
- `Karst::Spec::Catalog`, a read-only index over the JSON artifact `Karst::Spec::Observer` writes, answering `catalog.scenarios_for(controller:, action:, http_method: nil)` from an immutable `Karst::Spec::Scenario` per browser-facing request -- one example that issues several such requests (a denied attempt, then an allowed retry) legitimately produces one Scenario each. Indexed by controller/action, Rails' own stable routing identity, so dynamic id segments never fragment lookup; `http_method` narrows further only when one controller/action answers more than one verb. Loads from `tmp/karst/scenarios.json` by default (or `Rails.root`-relative when Rails is loaded), reports an explicit `:missing`/`:invalid`/`:ready` status so "not yet generated" is never confused with "zero scenarios observed," and skips malformed individual entries rather than failing the whole artifact. `observed_status`/`observed_redirect` name what a spec run observed, not what it asserted; `example_outcome` (`passed`/`failed`/`pending`) keeps a failing example's evidence visible without presenting it as verified. Reuses `Karst::Spec::Principal`'s type/id/scope evidence as-is, reintroduces no setup-versus-subject classification, and requires no RSpec, Rails, or database access to read an already-written artifact. Each Scenario keeps both `principal_before` and `principal_after` rather than collapsing to one side, since a signup or checkout-completion scenario is exactly the case where the identity a request produces is the evidence that matters.
|
|
53
|
-
- Explicit, data-only RSpec scenario names through `karst: "Name"` or `karst: { name: "Name" }` metadata, serialized alongside the existing stable example provenance. Malformed opt-in metadata fails the example with a clear configuration error; unannotated discovery remains unchanged.
|
|
54
|
-
- Ruby 2.7 and Rails 6.1 support, backed by a blocking CI job: `require "karst"`, runtime SQL evidence, the spec observer, the scenario catalog, and `/karst` all work unchanged on that floor. The page-local badge is the one capability that degrades there (Rack 2 cannot safely expose a bufferable response body to rewrite); `/karst` remains directly reachable. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy).
|
|
55
|
-
- `Karst::Value`, a small internal `Struct`-based immutable value-object helper standing in for Ruby 3.2's `Data.define` across every Karst value object (`Sql::Event`, `Sql::Shape`, `Sql::Window`, `Spec::Principal`, `Spec::RequestObservation`, `Spec::ExampleObservation`, `Spec::Scenario`), used uniformly on every supported Ruby.
|
|
56
|
-
- `Karst::ExecutionContext`, a small internal request-local storage seam used by the page badge and the spec observer: delegates to `ActiveSupport::IsolatedExecutionState` where available, and falls back to a per-thread store on Rails 6.1.
|
|
57
|
-
- `bin/rails generate karst:install`, optional Rails scaffolding for the host-specific seams Karst cannot safely infer: a documented, entirely commented-out `config/initializers/karst.rb` placeholder for every identity hook, a small explicitly named `KarstIdentityController` whose `create` action resolves the submitted principal strictly through `Karst::Identity.resolve` (never a bare `Model.find`, so it can never reach outside the configured `config.principals` scope) before raising `NotImplementedError` with a `TODO` until a developer wires up this application's real authentication against that already-resolved principal, and development-only routes for that controller (idempotent across repeated runs, via Thor's own file-collision handling and a duplicate-safe route insertion). Implements no Devise/Warden/generic authentication mechanism and never runs automatically; existing manual `Karst.configure` setups have no need to run it.
|
|
58
|
-
- `Karst::Access::PrincipalSampler`, an optional candidate-selection step ahead of the experimental access sweep: over an Active Record relation or model class it replaces "first 25 rows" with up to `access_sweep_limit` deterministic principals chosen for database-state diversity rather than whatever happens to sort first -- boolean columns, `enum` columns, nullable-foreign-key presence/absence, and other low-cardinality scalar columns (this is schema-state diversity the sampler observed in the database, not behavioral diversity; it never executes a route). Column candidacy requires an observed cardinality of 10 or fewer (via one bounded `DISTINCT ... LIMIT` query per candidate column, never a full-table scan or `COUNT(*)`), a conservative PII-aware column-name filter that unconditionally excludes anything resembling email, name, phone, address, token, password, or other sensitive fields regardless of cardinality, and a separate name-based exclusion for foreign keys shaped like a tenant/account/organization boundary (`tenant_id`, `account_id`, and similar) -- checked independently of nullability and cardinality, since a *nullable* such column would otherwise reach presence/absence sampling without ever going through the cardinality check. Query volume is bounded by dimension and limit counts, not row count (verified flat between 300 and 8,000 rows in the test suite), and is enforced as a hard invariant at every query-issuing call site via `PrincipalSampler.query_budget(limit)` -- `#call` may return fewer than `limit` principals if the budget is exhausted, but never issues more queries than that budget declares. Never escapes the configured principal scope (an already tenant-scoped relation stays tenant-scoped), and returns each selected principal alongside the minimal evidence (e.g. `"premium=true"`) that earned it a slot. Raises `Karst::Access::PrincipalSampler::UnsupportedPrimaryKey` (a `Karst::Access::Error`) for an Active Record source with a composite or missing primary key, rather than failing obscurely mid-query. Falls back to the existing bounded-first strategy, unchanged, for any non-Active-Record Enumerable source. `/karst` prefers this over "first 25" whenever the configured principal source supports it, purely as a label distinction -- selection, execution, identity assumption, and rollback semantics in `Karst::Access::Sweep` itself are unchanged.
|
|
59
|
-
- `Karst::Access::ResourceEvidence`, a read-only, opt-in follow-up step for one specific `Access::Sweep` outcome: given the exact resource a route addresses and one specific principal, it reports simple, directly observed foreign-key relationships between those two records (e.g. `Document#user_id` pointing at that exact `User#id`) -- evidence, not an authorization claim, and never phrased causally. Only foreign-key-shaped columns (ending in `_id`) are ever inspected, so no other attribute (name, email, token, ...) is read or shown; only a direct column-value comparison between the two given records is made, never a join, a `has_many` traversal, or any multi-hop graph walk. `.for_outcome` additionally resolves the resource from a route path using only Rails' own route recognition plus its controller-to-model naming convention, and only trusts that resolution when every step succeeds unambiguously (a recognized route with an `:id` segment, a controller name that classifies to a real loaded Active Record class, and a record that actually exists for that id); anything softer -- an unrecognized route, a controller with no conventional model, a missing record -- produces a reported limitation rather than a guess. `Result#to_text` renders the plain evidence format (principal, observed status, then related state).
|
|
60
|
-
- Candidate population discovery and curation, an opt-in workflow on top of `config.principal_populations`/`config.principal_sources`. `Karst::Access::PopulationDiscovery` uses Ruby's standard-library Ripper AST parser to list only statically named, zero-argument Rails `scope` declarations made directly in application model source; it never enumerates or invokes arbitrary class methods, executes scope bodies, or issues SQL. Concern-contributed scopes are explicitly outside this initial discovery boundary. Run it via `bin/rails karst:populations` or the `/karst/populations` page (linked from the main panel). The page groups scopes by model, collapsed by default behind native `<details>` (small vanilla JS only powers client-side search/filter -- no frontend framework), stays usable at 150 models/500 scopes, and surfaces the current selection first. `Karst::Access::PopulationPreview` is a separate, explicit, `LIMIT 3`-bounded validation step (never a `COUNT`) for one discovered scope at a time. `Karst::Access::PopulationConfigSnippet` renders a curated selection into copy-pasteable `config.principal_populations`/`config.principal_sources` Ruby -- nesting per source when more than one is involved, so the same population name on two different models stays distinguishable -- and never writes to the host application's files. The main `/karst` panel gains a **Try another population** guided retry, shown only once an analysis found no usable outcome: one button per already-*approved* (configured, not merely discovered) population, running a fresh bounded sweep against just that population, respecting the existing `access_sweep_limit`. When the analysis observed a halted controller callback, approved populations are optionally ranked by `Karst::Access::PopulationSuggestion`, a transparent, no-AI substring-overlap name heuristic that always shows every approved population and never claims the suggested one will change the outcome -- only running it does.
|
|
39
|
+
- Initial release of route-access verification through `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool.
|
|
40
|
+
- Bounded sampling of existing users with observed status, redirects, halted callbacks, exceptions, and database writes.
|
|
41
|
+
- Candidate-population discovery, preview, configuration export, and approval through the original population-management workflow and `karst:populations` rake task.
|
|
42
|
+
- Browser **Test as** and **Stop testing as** workflows, a page-local badge on Rack 3, and custom-authentication generator scaffolding.
|
|
43
|
+
- Automatic single-model Devise support, explicit multi-model/custom-authentication configuration, and access-search rollback isolation.
|
|
44
|
+
- The opt-in RSpec observer/catalog/scenario subsystem and inferred resource-relationship evidence.
|
|
45
|
+
- Runtime SQL capture through a bounded buffer and `Karst.window` analysis.
|
|
46
|
+
- Ruby 2.7+ and Rails 6.1+ compatibility.
|
|
61
47
|
|
|
62
48
|
### Changed
|
|
63
49
|
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
50
|
+
- Reframed the primary interface around finding an existing user who can reach a selected route.
|
|
51
|
+
- Candidate populations became an automatic second search stage after the ordinary sample found no usable user.
|
|
52
|
+
|
|
53
|
+
[0.2.0]: https://github.com/chdsbd/karst/compare/v0.1.0...v0.2.0
|
|
54
|
+
[0.1.0]: https://github.com/chdsbd/karst/releases/tag/v0.1.0
|
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
|
|
|
@@ -51,10 +53,12 @@ Sometimes the right user is rare and won't show up in a normal recent-user sampl
|
|
|
51
53
|
|
|
52
54
|
```
|
|
53
55
|
No verified usable user found
|
|
54
|
-
Karst found 3 application-defined user groups that could be tried.
|
|
56
|
+
Karst found 3 application-defined user groups that could be tried.
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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
62
|
|
|
59
63
|
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
64
|
|
|
@@ -68,8 +72,6 @@ For every user it tries, Karst reports:
|
|
|
68
72
|
- observed database writes
|
|
69
73
|
- which user was tested, and which configured population (if any) produced them
|
|
70
74
|
|
|
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
75
|
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
76
|
|
|
75
77
|
## CLI
|
|
@@ -83,7 +85,15 @@ Runs the same search as `/karst` from a shell. Exit code `0` means a usable user
|
|
|
83
85
|
|
|
84
86
|
## Coding agents
|
|
85
87
|
|
|
88
|
+
MCP support is optional. Add its runtime dependency to your application's
|
|
89
|
+
Gemfile and install it before starting the server:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
gem "mcp", "~> 0.9.0"
|
|
93
|
+
```
|
|
94
|
+
|
|
86
95
|
```bash
|
|
96
|
+
bundle install
|
|
87
97
|
bin/rails karst:mcp
|
|
88
98
|
```
|
|
89
99
|
|
|
@@ -99,7 +109,7 @@ Claude Code or another [MCP](https://modelcontextprotocol.io) client can call `v
|
|
|
99
109
|
|
|
100
110
|
## Configuration
|
|
101
111
|
|
|
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
|
|
112
|
+
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
113
|
|
|
104
114
|
The one option worth knowing is the off switch:
|
|
105
115
|
|
|
@@ -123,11 +133,13 @@ Karst.configure do |config|
|
|
|
123
133
|
end
|
|
124
134
|
```
|
|
125
135
|
|
|
126
|
-
The
|
|
136
|
+
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).
|
|
137
|
+
|
|
138
|
+
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
139
|
|
|
128
140
|
## Safety
|
|
129
141
|
|
|
130
|
-
Karst is for local development only — `/karst`, the badge, and Test As only work from loopback requests while `Rails.env.development?` is true.
|
|
142
|
+
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
143
|
|
|
132
144
|
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
145
|
|
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
|
|
|
@@ -6,6 +6,8 @@ If you are looking for an option that used to be here, check [Removed configurat
|
|
|
6
6
|
|
|
7
7
|
## Custom or non-Devise authentication
|
|
8
8
|
|
|
9
|
+
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.
|
|
10
|
+
|
|
9
11
|
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
12
|
|
|
11
13
|
```ruby
|
|
@@ -23,7 +25,7 @@ end
|
|
|
23
25
|
|
|
24
26
|
`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
27
|
|
|
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
|
|
28
|
+
`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
29
|
|
|
28
30
|
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
31
|
|
|
@@ -113,23 +115,20 @@ When the right user is too rare for this to reach — a role held by three peopl
|
|
|
113
115
|
|
|
114
116
|
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
117
|
|
|
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.
|
|
118
|
+
**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
119
|
|
|
118
|
-
**Approval.** When an analysis finds no usable user and unapproved candidates exist, `/karst`
|
|
120
|
+
**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
121
|
|
|
120
122
|
```json
|
|
121
123
|
{ "version": 1, "approved": [{ "model": "User", "scope": "system_admins" }] }
|
|
122
124
|
```
|
|
123
125
|
|
|
124
|
-
Deliberately machine-local, git-ignored development state, not project configuration
|
|
125
|
-
|
|
126
|
-
An approval only ever becomes executable for a model that is already a configured or Devise-inferred principal source (the class always comes from that source, never from the file), and only in development/test — production never reads the file. `config.principal_populations`/`config.principal_sources[...] :populations` keeps working unchanged and wins outright over an approval of the same name; Karst compares by name only, since it never inspects a configured callable's body. Approved populations reach `Access::Search` the same way configured ones do, so `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool all pick them up automatically with no adapter-specific wiring.
|
|
126
|
+
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.
|
|
127
127
|
|
|
128
|
-
The
|
|
128
|
+
**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.
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
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.
|
|
131
131
|
|
|
132
|
-
When a usable user is found for a route with an `:id` segment (`/admin/imports/123`), Karst separately checks whether that exact resource and that exact user share a direct foreign-key relationship — for example, that `Document#22`'s `user_id` column equals `User#27`'s id. Only columns ending in `_id` are ever inspected, and only a direct column-value comparison is made — never a join or a `has_many` traversal, and no other attribute (name, email, token) is ever read. This is shown as **Related state** on a usable result when available, and simply omitted otherwise.
|
|
133
132
|
|
|
134
133
|
## Full configuration reference
|
|
135
134
|
|
|
@@ -0,0 +1,171 @@
|
|
|
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 — five 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. Browser Test As
|
|
103
|
+
|
|
104
|
+
Test As mutates the developer's real browser session directly, not through
|
|
105
|
+
a sub-request — so it sets the same signed cookie
|
|
106
|
+
`start_new_session_for` sets, using a real `Session` row:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
Karst.configure do |config|
|
|
110
|
+
config.assume_browser_identity = lambda do |request, principal|
|
|
111
|
+
rails_request = ActionDispatch::Request.new(request.env)
|
|
112
|
+
probe_session = principal.sessions.create!(user_agent: "Karst Test As", ip_address: "127.0.0.1")
|
|
113
|
+
rails_request.cookie_jar.signed.permanent[:session_id] =
|
|
114
|
+
{ value: probe_session.id, httponly: true, same_site: :lax }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`request` here is a bare `Rack::Request` (Karst serves `/karst` at the Rack
|
|
120
|
+
boundary, before Action Controller) — wrapping its `env` in
|
|
121
|
+
`ActionDispatch::Request` is ordinary Rack request-object adaptation, not a
|
|
122
|
+
patch to any Rails class, and gives access to the same signed `cookie_jar`
|
|
123
|
+
a real controller's `cookies.signed` uses. Because `ActionDispatch::Cookies`
|
|
124
|
+
sits above Karst's middleware in the stack, the `Set-Cookie` header this
|
|
125
|
+
produces is flushed on the way back out exactly like it would be for a real
|
|
126
|
+
controller action.
|
|
127
|
+
|
|
128
|
+
## 5. Stop Testing As
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
Karst.configure do |config|
|
|
132
|
+
config.clear_browser_identity = lambda do |request|
|
|
133
|
+
rails_request = ActionDispatch::Request.new(request.env)
|
|
134
|
+
Session.find_by(id: rails_request.cookie_jar.signed[:session_id])&.destroy
|
|
135
|
+
rails_request.cookie_jar.delete(:session_id)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
This is the whole recipe. There is no sixth step, and nothing above reaches
|
|
141
|
+
into `Current`, `Session`, or a generated controller's internals —
|
|
142
|
+
`start_new_session_for`/`terminate_session` are called as the application's
|
|
143
|
+
own public-to-its-subclasses methods, exactly as a real controller action
|
|
144
|
+
would call them.
|
|
145
|
+
|
|
146
|
+
## Note: "database writes observed" is honest, not zero
|
|
147
|
+
|
|
148
|
+
Devise/Warden's Test As and the plain `session[:user_id] = ...` custom-auth
|
|
149
|
+
example are memory-only: neither ever performs a database write to sign a
|
|
150
|
+
probe in or out, so a route with no writes of its own reports "Database
|
|
151
|
+
writes observed: 0". Rails' generated authentication persists a `Session`
|
|
152
|
+
row per sign-in, so every probe under this recipe legitimately shows **2**
|
|
153
|
+
observed writes (the login `INSERT` and the logout `DELETE`) even when the
|
|
154
|
+
route itself never touches the database. This is not a bug and not
|
|
155
|
+
something to suppress: Karst reports what actually happened, and an insert
|
|
156
|
+
+ delete genuinely happened. It does mean write-count evidence from this
|
|
157
|
+
recipe is not directly comparable to a Devise/Warden or memory-only
|
|
158
|
+
custom-auth analysis of the same route — a "2 writes observed" result here
|
|
159
|
+
can mean only Karst's own probe login/logout wrote anything.
|
|
160
|
+
|
|
161
|
+
## Acceptance test
|
|
162
|
+
|
|
163
|
+
[`spec/integration/rails8_auth_golden_path_integration_spec.rb`](../spec/integration/rails8_auth_golden_path_integration_spec.rb)
|
|
164
|
+
boots a real `Rails::Application` using exactly this recipe against a
|
|
165
|
+
fixture that reproduces the Rails 8 generator's own files
|
|
166
|
+
([`spec/support/rails8_auth_application.rb`](../spec/support/rails8_auth_application.rb)),
|
|
167
|
+
with an ordinary user population and one rare privileged user, a restricted
|
|
168
|
+
route, a real access search, candidate-population discovery/approval, Test
|
|
169
|
+
As, Stop Testing As, and an assertion that no row is left behind afterward.
|
|
170
|
+
It runs against the real `rails ~> 8.0.0` gemfile
|
|
171
|
+
([`gemfiles/rails_8_0.gemfile`](../gemfiles/rails_8_0.gemfile)) in CI.
|