karst 0.2.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 +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- 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_discovery.rb +25 -1
- 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 +52 -6
- 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/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/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 513a3fddea6f4ffa3b738ec37e53fd3b21ad39efb5f0f7997efb926cf9acfe37
|
|
4
|
+
data.tar.gz: 22608af3514ea1172109a1ab746ef8a7c76059dae2ef212159d4567ba90025bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12ff6ed9b797cd6c8cf4856c3eb95ace456211d9c73dd5f769696d308e8b23da9885823250727f6bf8dace8b3b8c6d5cbb8e90baab59390fc9ac18abd7cf75a2
|
|
7
|
+
data.tar.gz: f856fbd9d25cea5b4da16aa0ee0764346f29c9faaa104b9073489c4e3aef986a35680ffb5e435270c8945d3878727259fad3956783f224ca484c2b3aa10a47cc
|
data/ARCHITECTURE.md
CHANGED
|
@@ -9,6 +9,11 @@ This document describes how Karst is put together and the policy that governs wh
|
|
|
9
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.
|
|
10
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.
|
|
11
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.
|
|
12
|
+
- `Karst::Identity::Observer` / `Identity::EvidenceBuilder` / `Access::IdentityProbe` / `Access::ObservedEndpoint` — the runtime-identity seam, which exists to keep three things that are constantly confused apart: the identity Karst was *asked* to run as (intent), the one its own seam *established* (setup), and the one the Rails application itself *observed* while running the request (evidence). `Observer` reads the latter from the probe request's own env — through `config.observe_identity` when configured, otherwise the application's own Warden proxy via Warden's public `Proxy#user`, which deserializes an existing session and never runs strategies — and fails closed to "unobservable" when neither is available, rather than echoing the requested identity back. `ObservedEndpoint` wraps the probe endpoint for exactly one request so Karst holds that request's live Rack env, which is what makes *halt-time* observation possible: Rails puts the dispatching controller on the env before any callback runs, so the identity the application had established when an access callback halted the request is observable at that moment, not merely after the request unwound. `IdentityProbe` owns one probe's whole identity lifecycle and deliberately never raises out of establishment or release — a probe whose identity could not be established is exactly the probe whose runtime observation matters most. `EvidenceBuilder` turns the observations into one `Identity::Evidence`, whose `confirmation` is the only field a machine consumer may read to decide whether a principal claim is true. `Reproduction::Exercise` (below) reuses this same seam rather than maintaining a second identity implementation.
|
|
13
|
+
- `Karst::Reproduction::Exercise` / `Observation` / `Sanitizer` / `Curl` — request reproduction: "something calls this endpoint, what request exercises the same behavior?". `Exercise` issues **exactly one** caller-specified request through the same `Access::ProbeApplication` endpoint and the same same-connection rollback `Access::Sweep` uses, and drives the request's identity through the identical `Access::IdentityProbe` lifecycle `Access::Sweep` uses — requested, established, runtime-observed (captured at `halted_callback` and again at request completion, with halt-time evidence preferred for the reported `identity`, exactly as `Access::Sweep` prefers it for its access decision), confirmed, released — so a reproduced request's `identity` document is a `Karst::Identity::Evidence`, never a bare echo of the identity Karst was asked to send. `Exercise` reports only what it observed -- the controller/action that actually dispatched, the router's own `path_parameters`, the halted callback, the response, the write count. Deliberately not `Access::Sweep` with the GET restriction lifted: a sweep runs one route as up to 25 users, so a mutating method there would mean 25 real creates, 25 enqueued jobs, and 25 delivered mails; `Sweep` stays GET-only and reproduction's blast radius stays one always-explicitly-asked-for request. Anything `Exercise` could not observe is `nil` and named in `Observation#unobserved` rather than inferred from routes, controller source, or strong parameters. `Sanitizer` is the single redaction boundary: it builds an `ActiveSupport::ParameterFilter` from *the reproduced application's own* `config.filter_parameters` (never `Rails.application`, so one application's rules can never filter another's parameters) and applies one conservative credential-name net on top of that result, never instead of it; headers are decided by name alone, so a credential header's value is never read, compared, or echoed. `Referer` and `Origin` are the one exception -- their *value* is a URL, not a credential, so it is parsed and stripped to scheme/host/path (query, fragment, userinfo discarded) rather than judged by name; a value that does not parse as a URL is masked outright. `Access::Probe`'s own redirect sanitization strips a redirect's query string and fragment the same way, for the same reason. `Curl` renders one `Observation` as a runnable command containing exactly what Karst sent -- never a header or credential invented to make the command look complete. `Karst::CLI::Reproduction` is the one adapter over all of it, shared verbatim by `bin/rails karst:reproduce`, the MCP `reproduce_request` tool, and the `/karst` panel, so no surface can show a recipe another would not; its identity serialization is the same one `Karst::CLI::Verification` uses, so the two schemas describe identity evidence identically.
|
|
14
|
+
- `Karst::Access::OutcomeGroups` — the single definition of "these probes observed the same thing", shared by `Access::Result#groups`, the CLI/MCP evidence document, the human CLI summary (`Karst::CLI::OutcomeSummary`), and the `/karst` panel. The key is every observed fact about the request (`SEMANTIC_FIELDS`: status, redirect, exception, halted callback, dispatched controller/action, body marker, writes, rollback) plus the configured `usable_access_outcome` verdict; everything about the probe that made it (`PROBE_METADATA`: requested principal, identity evidence, sampling reasons, elapsed time) is excluded, so equivalent outcomes are never split by timing or identity and distinct ones are never merged. Groups are ordered largest first with a deterministic tie-break over the key itself. A spec requires every `Access::Outcome` field to be classified as one or the other.
|
|
15
|
+
- `Karst::Identity::Snapshot` — one high-level operation's view of the effective identity configuration (principal sources with approvals and selections folded in, setup readiness, browser Test As support), taken once from a single `Configuration#principal_sources` resolution and passed down explicitly, never cached beyond that resolution. Each resolution's `PrincipalSource` copies evaluate their records callable at most once (`PrincipalSource#evaluated_once`). `CLI::Verification` takes one per run. `Web::Middleware` takes one per `/karst` request, after any local-state write that request makes -- reusing the same already-loaded `PopulationApprovals::Record` and `PopulationDiscovery` instance for whatever else that request resolves, including discovering candidate populations to suggest when the sample found nothing usable (`Web::Middleware#inline_population_candidates`). Approving a population is the one request that necessarily takes two resolutions -- pre-write validation, then the post-write render -- each still exactly one file read and one parse (`Web::Middleware#inline_population_approval_result`; see "When configuration is read" in [docs/advanced-configuration.md](docs/advanced-configuration.md#when-configuration-is-read)).
|
|
16
|
+
- `Karst::Access::LocalPath` — the single definition of "a local application path Karst may execute", used by both `Access::Sweep` and `Reproduction::Exercise`. A security check duplicated per caller is a security check that eventually diverges.
|
|
12
17
|
- `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.
|
|
13
18
|
|
|
14
19
|
Each area is deliberately narrow and composable; none of them depends on the others' internals beyond the public objects listed above.
|
|
@@ -51,6 +56,6 @@ Compatibility decisions live behind exactly two narrow seams — `Karst::Value`
|
|
|
51
56
|
### CI
|
|
52
57
|
|
|
53
58
|
- `unit-test` — Ruby 3.2, the root `Gemfile` (RSpec + RuboCop against everything except `spec/integration`).
|
|
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.
|
|
59
|
+
- `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, and Rails 7.2, 8.0, and 8.1 on Ruby 3.3. Every row is a required, blocking job. Each row also runs the Devise, multi-Devise, custom-authentication, and Rails-generated-authentication golden paths in isolated processes.
|
|
55
60
|
|
|
56
61
|
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
|
@@ -5,6 +5,85 @@ All notable changes to Karst are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0]
|
|
9
|
+
|
|
10
|
+
0.4.0 is the first release since 0.2.0. A 0.3.0 version was prepared on
|
|
11
|
+
`main` (version constant and changelog section) but was never tagged or
|
|
12
|
+
published to RubyGems; there is no 0.3.0 release. Everything prepared for it
|
|
13
|
+
ships here, together with the changes made on `main` since.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `--as MODEL:ID` on `bin/rails karst:verify` and `bin/rails karst:reproduce` — a human-only way to run a probe as one specific existing record instead of a sampled one (e.g. `karst:verify GET /admin/imports/123 --as User:72`). Resolved exclusively through the same `Identity.resolve` lookup the panel's own **Test as** button already uses, so a record outside a configured principal source never resolves; there is no `constantize`/`find` bypass. Cannot be combined with `--anonymous`, and is not exposed by either MCP tool — an agent can pick a request, never a principal.
|
|
18
|
+
- Runtime-confirmed identity: every probe outcome now carries an `identity` evidence object separating the *requested* identity (intent), the *established* one (setup), and the *observed* one (what the Rails application itself resolved while running the request). `identity.confirmation` fails closed — `confirmed`, `confirmed_anonymous`, `absent`, `mismatch`, `contaminated`, or `unobservable` — and only the first two are evidence about identity.
|
|
19
|
+
- Anonymous probes as a first-class probe kind: `bin/rails karst:verify --anonymous`, and `verify_access(identity: "anonymous")` over MCP. Karst establishes no identity and then verifies the application really did resolve none; stale state that produces a principal anyway is reported as `contaminated`, never as anonymous. An anonymous probe requires no principal source at all.
|
|
20
|
+
- `config.observe_identity`, the observation seam for non-Devise authentication. Devise/Warden applications need no configuration: Karst reads the application's own Warden proxy from the probe request.
|
|
21
|
+
- Halt-time identity observation: when an access callback halts a request, Karst records the identity the application had established *at that decision*, plus `identity.observed_at` and `identity.changed_during_request`.
|
|
22
|
+
- Each outcome now reports the `controller` and `action` the probed request actually dispatched to, and the evidence document carries `provenance` (Karst/Rails/Ruby versions, environment, observation timestamp).
|
|
23
|
+
- **Request reproduction.** Answers "something calls this endpoint -- what
|
|
24
|
+
request do I send to exercise the same behavior?". Karst issues exactly one
|
|
25
|
+
caller-specified request through the real application, inside the same
|
|
26
|
+
rolled-back transaction the access sweep uses, and drives that request's
|
|
27
|
+
identity through the same requested/established/observed/confirmed
|
|
28
|
+
lifecycle `verify_access` uses, reporting what it observed: the
|
|
29
|
+
controller/action that dispatched, the router's own route parameters, the
|
|
30
|
+
halted callback, the response, database writes -- plus a cURL command for
|
|
31
|
+
exactly what it sent. Available at `/karst` under **Reproduce request**, as
|
|
32
|
+
`bin/rails karst:reproduce METHOD PATH`, and as the `reproduce_request` MCP
|
|
33
|
+
tool. Secrets never come back out: parameters pass through the application's
|
|
34
|
+
own `config.filter_parameters` (Rails' own `ActiveSupport::ParameterFilter`)
|
|
35
|
+
plus a conservative credential-name net, and credential-bearing headers
|
|
36
|
+
become placeholders such as `<API_KEY>` without their values ever being read.
|
|
37
|
+
See [docs/request-reproduction.md](docs/request-reproduction.md).
|
|
38
|
+
- MCP now exposes two tools. `verify_access` is unchanged and stays GET-only.
|
|
39
|
+
- Reproduction evidence now reports `execution.controller_completed` (did the
|
|
40
|
+
Rails controller lifecycle finish without raising -- distinct from whether
|
|
41
|
+
it dispatched at all), `execution.exception_phase` (the most specific phase
|
|
42
|
+
-- `controller`, `render`, or `unknown` -- that instrumentation actually
|
|
43
|
+
proves a raised exception occurred in, derived from
|
|
44
|
+
`ActiveSupport::Notifications` events rather than the exception's class or
|
|
45
|
+
message), and `execution.rendered` (the templates, partials, and layouts
|
|
46
|
+
Karst observed being rendered or raising, by structural virtual path only).
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **`bin/rails karst:mcp` reported a missing `mcp` gem when the installed one was merely outside Karst's supported range.** An application whose bundle resolved `mcp` to a version Karst did not admit -- for example 1.6.1, selected through another gem's `mcp ~> 1.5` -- was told to add the dependency it already had. The command now names the version it found and the range it requires, and reports the dependency as missing only when it is.
|
|
51
|
+
- **Request reproduction could echo a secret through Referer, Origin, or a redirect.** `Reproduction::Sanitizer` treated `Referer` and `Origin` as safe, request-shape headers and echoed them verbatim, but both are URLs whose query string (and, for an OAuth-style redirect, fragment) can carry a real credential — `Referer: https://example.test/reset?token=...`, `Referer: .../callback#access_token=...`. They are now parsed as URLs and stripped to scheme/host/path (query, fragment, and userinfo discarded) rather than treated as safe or matched against the credential-name net; a value that does not parse as a URL at all is masked outright. `Access::Probe`'s own redirect sanitization had the same gap in the other direction: it stripped a redirect's query string but not its fragment (`/callback#access_token=...` came back whole), and its own parse-failure fallback split only on `?`, not `#`. Both are now stripped, in both the happy path and the fallback.
|
|
52
|
+
- **A reproduced application's own `filter_parameters` raising silently fell back to the global `Rails.application`'s, not to "no application filters."** `Reproduction::Exercise` already documented that it must never let `Sanitizer` reach for `Rails.application` in place of the application Karst actually issued the request against, but its own error handling returned `nil` on failure, which `Sanitizer` treats as "no filters were given, read the global" — exactly the fallback the documented contract rules out. It now returns an empty Array, so a broken or unavailable `filter_parameters` means this application declared none, never a stranger's rules applied to its parameters.
|
|
53
|
+
- **Outcome groups were split by timing.** The `karst:verify --json` / MCP `verify_access` evidence grouped outcomes by a key that included each probe's own `elapsed_ms`, so equivalent outcomes (25 users all halted at `require_admin` with `403`) came back as up to 25 separate groups. Outcomes are now grouped by what the request observed only — status, redirect, halted callback, exception, dispatched controller/action, writes, and the configured usable verdict — through one shared definition (`Karst::Access::OutcomeGroups`) that the CLI, MCP, and `/karst` panel all use.
|
|
54
|
+
- The human `karst:verify` output described a whole sample using only its *first* probe's status and identity; it now summarizes every outcome.
|
|
55
|
+
- Repeated configuration work during one operation. Setup checks, the search, and every **Test as** button on `/karst` each re-resolved the effective principal sources — re-reading the approval file, re-parsing model source, and re-running the `principals` callable — up to once per listed user on the inferred-Devise path. Each operation now resolves them once (`Karst::Identity::Snapshot`); a later operation still sees changed configuration.
|
|
56
|
+
- A `/karst` request that found no usable outcome and went on to discover candidate populations to suggest still re-read the approval file and re-parsed model source independently of the resolution `Identity::Snapshot` had already just taken (`Web::Middleware#inline_population_candidates`) — now it reuses that same read and parse. Approving a population still resolves configuration twice (once to validate the submission before the write, once more for the fresh render after it), each exactly once, not several times over.
|
|
57
|
+
- **Stale cross-request response evidence in reproduction.** If a request run
|
|
58
|
+
on the same session immediately before the target (identity establishment,
|
|
59
|
+
most often) completed, and the target then raised before producing a
|
|
60
|
+
response of its own, Karst could report the *earlier* request's status,
|
|
61
|
+
content type, and redirect as if it had observed them for the target --
|
|
62
|
+
because `ActionDispatch::Integration::Session` only updates its
|
|
63
|
+
request/response objects after the target's own `app.call` returns without
|
|
64
|
+
raising. Response evidence (`status`, `response_content_type`, `redirect`)
|
|
65
|
+
is now read only when Karst can prove it belongs to the target request;
|
|
66
|
+
otherwise it is `nil` and named in `unobserved`, exactly like any other
|
|
67
|
+
fact Karst did not observe. See
|
|
68
|
+
[docs/request-reproduction.md](docs/request-reproduction.md).
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- Optional MCP support now targets the tested MCP 1.5 and 1.6 series
|
|
73
|
+
(`gem "mcp", ">= 1.5.0", "< 1.7"`), replacing 0.2.0's stale MCP 0.9.x
|
|
74
|
+
constraint that conflicted with current integrations and failed schema
|
|
75
|
+
validation with JSON 3.x. CI exercises both the oldest admitted release
|
|
76
|
+
(1.5.0) and the newest 1.6.x.
|
|
77
|
+
- **Verification evidence schema is now version 3** (0.2.0 emitted version 1; version 2 existed only on unreleased main).
|
|
78
|
+
- The ambiguous `principal` / `verified_principal` keys are gone rather than renamed in place: a consumer reading "principal" and believing it described the request that actually ran is exactly the false attribution this schema exists to make impossible. Outcomes carry `identities` (each with `requested`, `observed`, `confirmation`), and the top level carries `verified_identity`.
|
|
79
|
+
- Grouped `outcomes` (under `sample` and each population) no longer carry `elapsed_ms` — a group of several probes has no single elapsed time; `verified_outcome.elapsed_ms` and `summary.elapsed_ms` are unchanged — and gain `verified_usable`. Groups are ordered largest first.
|
|
80
|
+
- **Reproduction evidence schema is version 3.** Request reproduction is new in this release, so version 3 is the first one published; versions 1 and 2 existed only on unreleased main. Relative to those:
|
|
81
|
+
- Its `identity` document reports `requested`/`observed`/`confirmation` (a `Karst::Identity::Evidence`) rather than echoing back the identity Karst assumed as if it were what ran; a mismatch, absence, or unobservable outcome now stays visible instead of being reported as "sent as User X."
|
|
82
|
+
- `execution` gained `controller_completed`, `exception_phase`, and `rendered` (additive), but `response.status`/`response_content_type`/`redirect` also stopped trusting `session.request`/`session.response` once a target request raised before producing one -- see Fixed, above. A consumer that read those fields after `execution.exception_class` was already set was reading a value from an earlier request; it now reads `nil`, named in `unobserved`.
|
|
83
|
+
- A probe whose identity setup fails now still runs and is still observed, and reports the failure as `identity.establishment` rather than as an application exception — "asked for User #123, application saw nobody, halted at `authorize_admin`" is the evidence that matters most there.
|
|
84
|
+
- Per-probe write and halted-callback observation now ignores notifications raised on other threads, so a concurrent request in a development server cannot be counted as a probe's own evidence.
|
|
85
|
+
- `karst:verify` prints a bounded outcome summary: one line per observed outcome with its user count, largest first, a few example users for each outcome other than a single largest one, identity confirmation counts, and write warnings. `--json` still carries every user.
|
|
86
|
+
|
|
8
87
|
## [0.2.0]
|
|
9
88
|
|
|
10
89
|
### Added
|
|
@@ -50,5 +129,6 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
50
129
|
- Reframed the primary interface around finding an existing user who can reach a selected route.
|
|
51
130
|
- Candidate populations became an automatic second search stage after the ordinary sample found no usable user.
|
|
52
131
|
|
|
53
|
-
[0.
|
|
54
|
-
[0.
|
|
132
|
+
[0.4.0]: https://github.com/SilenceDogood1984/karst/releases/tag/v0.4.0
|
|
133
|
+
[0.2.0]: https://rubygems.org/gems/karst/versions/0.2.0
|
|
134
|
+
[0.1.0]: https://rubygems.org/gems/karst/versions/0.1.0
|
data/CONTRIBUTING.md
CHANGED
|
@@ -32,7 +32,7 @@ Every behavior change requires tests at the narrowest useful level. Bug fixes sh
|
|
|
32
32
|
|
|
33
33
|
### Rails compatibility
|
|
34
34
|
|
|
35
|
-
Karst's compatibility harness currently covers Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2 and 8.
|
|
35
|
+
Karst's compatibility harness currently covers Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2, 8.0, and 8.1 on Ruby 3.3. The Rails 6.1 / Ruby 2.7 job is blocking, the same as every other row: Karst claims that floor because CI proves it, not the other way around. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for how optional features (currently: the page-local badge) degrade on that floor instead of raising.
|
|
36
36
|
|
|
37
37
|
The repository uses version-specific Gemfiles under `gemfiles/`. This keeps each dependency set explicit and lets Bundler and CI use their standard `BUNDLE_GEMFILE` behavior without an additional dependency-management tool. Matrix lockfiles are intentionally not committed: compatibility CI resolves the current dependency set allowed by each Rails line, so it detects dependency-resolution regressions instead of only testing a previously locked snapshot. To run one target locally:
|
|
38
38
|
|
|
@@ -43,3 +43,11 @@ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile EXPECTED_RAILS_VERSION=7.2 \
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
Run every compatibility target with `bin/test-rails`. To add a Rails version, add a version-specific Gemfile, add it to `bin/test-rails`, and add the matching Rails/Ruby entry to the `rails-integration` matrix in `.github/workflows/ci.yml`. Choose a Ruby version on which that Rails release installs and runs, and keep linting out of the compatibility matrix.
|
|
46
|
+
|
|
47
|
+
The optional MCP dependency has its own, smaller matrix. `Karst::Mcp::Compatibility::REQUIREMENTS` is the one range both the gemspec and `bin/rails karst:mcp` enforce, and the `mcp-compatibility` CI job runs the MCP specs and the shared-process integration suite against its oldest admitted release (`gemfiles/mcp_1_5_0.gemfile`) and the newest release of its latest admitted series (`gemfiles/mcp_1_6.gemfile`). To admit a new MCP series, test it the same way first, then widen `REQUIREMENTS` and move the newest-series Gemfile and matrix entry to it. To run one MCP target locally:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
BUNDLE_GEMFILE=gemfiles/mcp_1_6.gemfile bundle install
|
|
51
|
+
BUNDLE_GEMFILE=gemfiles/mcp_1_6.gemfile EXPECTED_RAILS_VERSION=8.0 EXPECTED_MCP_REQUIREMENT="~> 1.6.0" \
|
|
52
|
+
bundle exec rspec spec/mcp spec/integration --exclude-pattern "spec/integration/*golden_path*"
|
|
53
|
+
```
|
data/README.md
CHANGED
|
@@ -47,6 +47,23 @@ Custom or non-Devise authentication needs a few lines of setup — see [Custom a
|
|
|
47
47
|
4. Every result shows what actually happened: status, redirect, halted callback, exception, and observed database writes.
|
|
48
48
|
5. Found a usable user? Click **Test as** to become them in your own browser and keep working.
|
|
49
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
|
+
|
|
50
67
|
## Candidate populations
|
|
51
68
|
|
|
52
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:
|
|
@@ -83,13 +100,85 @@ bin/rails karst:verify GET /admin/imports/123 --json
|
|
|
83
100
|
|
|
84
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.
|
|
85
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
|
+
|
|
86
175
|
## Coding agents
|
|
87
176
|
|
|
88
177
|
MCP support is optional. Add its runtime dependency to your application's
|
|
89
178
|
Gemfile and install it before starting the server:
|
|
90
179
|
|
|
91
180
|
```ruby
|
|
92
|
-
gem "mcp", "
|
|
181
|
+
gem "mcp", ">= 1.5.0", "< 1.7"
|
|
93
182
|
```
|
|
94
183
|
|
|
95
184
|
```bash
|
|
@@ -105,7 +194,28 @@ bin/rails karst:mcp
|
|
|
105
194
|
}
|
|
106
195
|
```
|
|
107
196
|
|
|
108
|
-
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.
|
|
109
219
|
|
|
110
220
|
## Configuration
|
|
111
221
|
|
|
@@ -130,9 +240,13 @@ Karst.configure do |config|
|
|
|
130
240
|
session.post "/karst_test_login", params: { account_id: account.id }
|
|
131
241
|
end
|
|
132
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 }
|
|
133
245
|
end
|
|
134
246
|
```
|
|
135
247
|
|
|
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
|
+
|
|
136
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).
|
|
137
251
|
|
|
138
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.
|
|
@@ -143,6 +257,8 @@ Karst is for local development only — `/karst`, the badge, and Test As only wo
|
|
|
143
257
|
|
|
144
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.
|
|
145
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
|
+
|
|
146
262
|
## Compatibility
|
|
147
263
|
|
|
148
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.
|
|
@@ -4,6 +4,24 @@ 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
|
|
|
9
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.
|
|
@@ -23,6 +41,16 @@ Karst.configure do |config|
|
|
|
23
41
|
end
|
|
24
42
|
```
|
|
25
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
|
+
|
|
26
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.
|
|
27
55
|
|
|
28
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.
|
|
@@ -44,6 +72,75 @@ When both are configured, every usable user in the results gets a **Test as** bu
|
|
|
44
72
|
|
|
45
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.
|
|
46
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
|
+
|
|
47
144
|
## Authentication identifiers in local human output
|
|
48
145
|
|
|
49
146
|
Without `config.principal_label`, Karst normally labels a principal `Model #id`.
|
|
@@ -129,6 +226,14 @@ Deliberately machine-local, git-ignored development state, not project configura
|
|
|
129
226
|
|
|
130
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.
|
|
131
228
|
|
|
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.
|
|
234
|
+
|
|
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.
|
|
236
|
+
|
|
132
237
|
|
|
133
238
|
## Full configuration reference
|
|
134
239
|
|
|
@@ -148,6 +253,7 @@ The single switch that turns Karst's whole development surface off — `/karst`,
|
|
|
148
253
|
| --- | --- |
|
|
149
254
|
| `principals` | Custom or non-Devise authentication ([above](#custom-or-non-devise-authentication)) |
|
|
150
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)) |
|
|
151
257
|
| `assume_browser_identity` / `clear_browser_identity` | Browser **Test as** under custom authentication |
|
|
152
258
|
| `principal_label` | A display label for a non-Active-Record principal |
|
|
153
259
|
| `principal_sources` | Identity spread across several models ([above](#multiple-user-models-configprincipal_sources)) |
|
|
@@ -14,7 +14,7 @@ Karst to read. Guessing from column names (`password_digest`), class names
|
|
|
14
14
|
be exactly the kind of heuristic inference Karst deliberately never does for
|
|
15
15
|
custom authentication (see [Custom or non-Devise
|
|
16
16
|
authentication](advanced-configuration.md#custom-or-non-devise-authentication)).
|
|
17
|
-
So this is a small, explicit recipe instead of a one-liner —
|
|
17
|
+
So this is a small, explicit recipe instead of a one-liner — six short
|
|
18
18
|
`Karst.configure` blocks below cover the whole thing, using this
|
|
19
19
|
application's own generated `User`, `Session`, and `Authentication`
|
|
20
20
|
concern exactly as the generator created them. Nothing here monkey-patches
|
|
@@ -99,7 +99,27 @@ observed" note](#note-database-writes-observed-is-honest-not-zero) below
|
|
|
99
99
|
for the one visible side effect of this being a *real* database write,
|
|
100
100
|
even though it never persists.
|
|
101
101
|
|
|
102
|
-
## 4.
|
|
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
|
|
103
123
|
|
|
104
124
|
Test As mutates the developer's real browser session directly, not through
|
|
105
125
|
a sub-request — so it sets the same signed cookie
|
|
@@ -125,7 +145,7 @@ sits above Karst's middleware in the stack, the `Set-Cookie` header this
|
|
|
125
145
|
produces is flushed on the way back out exactly like it would be for a real
|
|
126
146
|
controller action.
|
|
127
147
|
|
|
128
|
-
##
|
|
148
|
+
## 6. Stop Testing As
|
|
129
149
|
|
|
130
150
|
```ruby
|
|
131
151
|
Karst.configure do |config|
|
|
@@ -137,7 +157,7 @@ Karst.configure do |config|
|
|
|
137
157
|
end
|
|
138
158
|
```
|
|
139
159
|
|
|
140
|
-
This is the whole recipe. There is no
|
|
160
|
+
This is the whole recipe. There is no seventh step, and nothing above reaches
|
|
141
161
|
into `Current`, `Session`, or a generated controller's internals —
|
|
142
162
|
`start_new_session_for`/`terminate_session` are called as the application's
|
|
143
163
|
own public-to-its-subclasses methods, exactly as a real controller action
|