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