cache_bro 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/MIT-LICENSE +20 -0
- data/README.md +114 -0
- data/Rakefile +3 -0
- data/app/controllers/cache_bro/application_controller.rb +91 -0
- data/app/controllers/cache_bro/audit_events_controller.rb +38 -0
- data/app/controllers/cache_bro/flushes_controller.rb +32 -0
- data/app/controllers/cache_bro/keys_controller.rb +39 -0
- data/app/controllers/cache_bro/purges_controller.rb +38 -0
- data/app/helpers/cache_bro/application_helper.rb +63 -0
- data/app/models/cache_bro/application_record.rb +6 -0
- data/app/models/cache_bro/audit_event.rb +25 -0
- data/app/views/cache_bro/audit_events/index.html.erb +39 -0
- data/app/views/cache_bro/audit_events/show.html.erb +48 -0
- data/app/views/cache_bro/flushes/new.html.erb +19 -0
- data/app/views/cache_bro/keys/index.html.erb +36 -0
- data/app/views/cache_bro/keys/show.html.erb +57 -0
- data/app/views/cache_bro/purges/new.html.erb +17 -0
- data/app/views/cache_bro/purges/preview.html.erb +37 -0
- data/app/views/cache_bro/shared/_store_tabs.html.erb +9 -0
- data/app/views/cache_bro/shared/forbidden.html.erb +3 -0
- data/app/views/cache_bro/shared/unsupported.html.erb +3 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20261115130000_create_cache_bro_audit_events.rb +25 -0
- data/lib/cache_bro/audit.rb +25 -0
- data/lib/cache_bro/configuration.rb +113 -0
- data/lib/cache_bro/engine.rb +25 -0
- data/lib/cache_bro/errors.rb +29 -0
- data/lib/cache_bro/redis_store.rb +220 -0
- data/lib/cache_bro/version.rb +3 -0
- data/lib/cache_bro.rb +43 -0
- data/lib/generators/cache_bro/install/install_generator.rb +35 -0
- data/lib/generators/cache_bro/install/templates/cache_bro.rb +32 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6287878eefcb94a718dfd1a6d1540355185e989bdc5bc21686933c5a09331f45
|
|
4
|
+
data.tar.gz: d9ab530ca70ac53107bd557da234b55337824a6523a2596e51f7fd4fc7512e9d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fac35f3a49ba16a2b2e87afe7c222356ee3904ff5dad45a89a5352913d96644ce23e486ff37c1d15f67f53d31fa4a68f349066ce9f8a2ffe1dc13d5c828b1e8f
|
|
7
|
+
data.tar.gz: 60fd4404a3f9e3e1d0494f14550cbd3961e0b71fd56d291fcc013be8873b936e72cc8248f4dcc39dea7b88405714e5018740cf3223ea532d1cc454c7f99da62c
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Ruby Dev SRL (https://www.rubydev.ro)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# CacheBro
|
|
2
|
+
|
|
3
|
+
Browse, inspect and purge your Rails **Redis** cache from the browser, with every action audited.
|
|
4
|
+
|
|
5
|
+
CacheBro is a section of the [BroHub](https://github.com/rubydevro/bro_hub) dashboard, next to EnvBro and SafeBro.
|
|
6
|
+
It's for teams where developers already have a console: it makes the common cache chores easier to do at scale,
|
|
7
|
+
and makes the ones done through the UI impossible to do unrecorded.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
| | |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **Keys** | Search keys (plain text matches anywhere, `*` and `?` make a pattern). See expiry and size. |
|
|
14
|
+
| **Reveal** | Show what a key holds. Its own permission, always audited. |
|
|
15
|
+
| **Delete** | Delete one key. |
|
|
16
|
+
| **Purge** | Delete every key matching a pattern. You see how many match first, and a sample. |
|
|
17
|
+
| **Flush** | Delete everything under the store's namespace. You type the store's name to confirm. |
|
|
18
|
+
| **Audit log** | Who revealed, deleted or purged what, how many, and why. Append-only. |
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "cache_bro"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
bin/rails generate cache_bro:install
|
|
28
|
+
bin/rails db:migrate
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The installer sets up BroHub if it isn't yet, adds `config/initializers/cache_bro.rb` and copies the migration.
|
|
32
|
+
CacheBro then lives at `<hub>/cache_bro`.
|
|
33
|
+
|
|
34
|
+
Give your cache store a **namespace**:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"], namespace: "myapp" }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Everything CacheBro shows or deletes is under that namespace. Without one the cache shares its Redis database
|
|
41
|
+
with whatever else lives there: you'll see a warning, and Flush is disabled.
|
|
42
|
+
|
|
43
|
+
## Permissions
|
|
44
|
+
|
|
45
|
+
Plain lambdas, like the other Bro gems. Everything is denied until you say otherwise.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
CacheBro.configure do |config|
|
|
49
|
+
config.can_view_keys = ->(user) { user&.developer? } # search keys, see expiry and size
|
|
50
|
+
config.can_view_values = ->(user) { user&.senior? } # reveal values (audited)
|
|
51
|
+
config.can_delete = ->(user) { user&.developer? } # delete one key
|
|
52
|
+
config.can_purge = ->(user) { user&.lead? } # delete by pattern
|
|
53
|
+
config.can_flush = ->(user) { user&.admin? } # delete the whole namespace
|
|
54
|
+
config.can_audit = ->(user) { user&.admin? } # read the audit log
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Higher includes lower: `flush` includes `purge` and `delete`, `purge` includes `delete`, and all of them include
|
|
59
|
+
`view_keys`. `view_values` and `audit` stand alone on purpose: values can hold personal data, and the people who
|
|
60
|
+
clean the cache shouldn't automatically read it.
|
|
61
|
+
|
|
62
|
+
## Audit
|
|
63
|
+
|
|
64
|
+
Every reveal, delete, purge and flush is recorded in `cache_bro_audit_events`: who, what (the key or the pattern,
|
|
65
|
+
never the value), how many keys, which store and environment, when, and the reason if one was given. Entries can't be
|
|
66
|
+
changed or deleted through the model.
|
|
67
|
+
|
|
68
|
+
- Purges and flushes also keep a sample of the keys deleted.
|
|
69
|
+
- A reveal is recorded **before** the value is shown. If the audit log can't be written, nothing is revealed.
|
|
70
|
+
- If a purge fails part way (Redis goes away), what was deleted up to then is still recorded, marked as incomplete.
|
|
71
|
+
- Require a typed reason with `config.reason_required_for = %i[ purge flush ]` (default). Add `:reveal` and `:delete`
|
|
72
|
+
if you want one for those too.
|
|
73
|
+
|
|
74
|
+
Keys can contain personal data too (`user/ann@example.com/...`). Anyone with `view_keys` sees them.
|
|
75
|
+
|
|
76
|
+
## Safe by construction
|
|
77
|
+
|
|
78
|
+
- Redis is only walked with `SCAN`, in small steps. Never `KEYS`. A search page that doesn't cover the whole keyspace
|
|
79
|
+
gives you a "keep looking" link.
|
|
80
|
+
- Every key is put back under the namespace before it touches Redis, so nothing outside it can be named.
|
|
81
|
+
- Purge patterns are used as typed (`posts/*` is a prefix), refuse to match everything, and stop at
|
|
82
|
+
`config.max_purge_keys` (100,000) per run. Run it again to continue.
|
|
83
|
+
- Values are shown as escaped text, truncated to `config.value_preview_length`. Reveals are `POST`s, so a link
|
|
84
|
+
prefetcher can't "view" a value.
|
|
85
|
+
- Pages are `Cache-Control: no-store`.
|
|
86
|
+
|
|
87
|
+
## Several stores
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
config.stores = {
|
|
91
|
+
"App cache" => -> { Rails.cache },
|
|
92
|
+
"Rack::Attack" => -> { Rack::Attack.cache.store }
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Only `:redis_cache_store` on a single Redis server is supported for now. Other stores are listed with an
|
|
97
|
+
explanation instead of failing.
|
|
98
|
+
|
|
99
|
+
## Not a sandbox
|
|
100
|
+
|
|
101
|
+
CacheBro is a convenience and an audit trail for people who already have console access. Someone with a console can
|
|
102
|
+
still do all of this there, unrecorded. Pair it with [SafeBro](https://github.com/rubydevro/safe_bro) if you want the
|
|
103
|
+
console recorded too.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
bin/rails test # needs Redis on localhost (REDIS_URL, db 15 by default; only the test namespace is touched)
|
|
109
|
+
bin/rubocop
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class ApplicationController < CacheBro.config.parent_controller.constantize
|
|
3
|
+
include BroHub::ControllerBase
|
|
4
|
+
bro_hub_section :cache_bro
|
|
5
|
+
|
|
6
|
+
before_action :cache_bro_require_ssl
|
|
7
|
+
before_action :cache_bro_no_store
|
|
8
|
+
before_action :cache_bro_authenticate!
|
|
9
|
+
|
|
10
|
+
helper_method :cache_bro_user, :cache_bro_user_label, :cache_bro_store, :cache_bro_allows?
|
|
11
|
+
|
|
12
|
+
rescue_from CacheBro::NotAllowed, with: :cache_bro_forbidden
|
|
13
|
+
rescue_from CacheBro::UnknownStore, with: :cache_bro_unknown_store
|
|
14
|
+
rescue_from CacheBro::UnsupportedStore, with: :cache_bro_unsupported
|
|
15
|
+
rescue_from CacheBro::ReasonRequired, CacheBro::InvalidPattern, with: :cache_bro_rejected
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
# The shell shows who is signed in, by CacheBro's rules.
|
|
19
|
+
def bro_hub_user
|
|
20
|
+
cache_bro_user
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cache_bro_user
|
|
24
|
+
return @cache_bro_user if defined?(@cache_bro_user)
|
|
25
|
+
|
|
26
|
+
@cache_bro_user = CacheBro.config.current_user.call(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cache_bro_user_label
|
|
30
|
+
CacheBro.config.user_label.call(cache_bro_user) if cache_bro_user
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cache_bro_allows?(permission)
|
|
34
|
+
CacheBro.config.allows?(cache_bro_user, permission)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authorize!(permission)
|
|
38
|
+
raise CacheBro::NotAllowed, "Not allowed" unless cache_bro_allows?(permission)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Anyone who may use some part of CacheBro gets in; each action then checks its own permission.
|
|
42
|
+
def cache_bro_authenticate!
|
|
43
|
+
raise CacheBro::NotAllowed, "Not allowed" unless cache_bro_allows?(:view_keys) || cache_bro_allows?(:audit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The store the page is about: ?store=Name, or the first configured one.
|
|
47
|
+
def cache_bro_store
|
|
48
|
+
@cache_bro_store ||= CacheBro.store(params[:store])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The typed reason, or nil. Raises when the action needs one and there is none.
|
|
52
|
+
def reason_for(action)
|
|
53
|
+
reason = params[:reason].to_s.strip.presence
|
|
54
|
+
raise CacheBro::ReasonRequired, "Say why. #{action.to_s.capitalize} needs a reason." if reason.nil? && CacheBro.config.reason_required?(action)
|
|
55
|
+
|
|
56
|
+
reason
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cache_bro_require_ssl
|
|
60
|
+
head :forbidden if CacheBro.config.require_ssl && !request.ssl?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Keys and (revealed) values are production data: keep them out of caches.
|
|
64
|
+
def cache_bro_no_store
|
|
65
|
+
response.headers["Cache-Control"] = "no-store"
|
|
66
|
+
response.headers["Pragma"] = "no-cache"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cache_bro_forbidden
|
|
70
|
+
render "cache_bro/shared/forbidden", status: :forbidden
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cache_bro_unknown_store
|
|
74
|
+
head :not_found
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cache_bro_unsupported(error)
|
|
78
|
+
@unsupported = error.message
|
|
79
|
+
render "cache_bro/shared/unsupported", status: :unprocessable_entity
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def cache_bro_rejected(error)
|
|
83
|
+
redirect_back fallback_location: root_path(store: params[:store]), alert: error.message
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A SCAN cursor is a number. Anything else starts over.
|
|
87
|
+
def cursor_param
|
|
88
|
+
params[:cursor].to_s.match?(/\A\d+\z/) ? params[:cursor].to_s : "0"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class AuditEventsController < ApplicationController
|
|
3
|
+
PER_PAGE = CacheBro::ApplicationHelper::PER_PAGE
|
|
4
|
+
|
|
5
|
+
before_action { authorize!(:audit) }
|
|
6
|
+
helper_method :filter_params
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@filter = filter_params
|
|
10
|
+
@events = filtered.recent.limit(PER_PAGE).offset(page * PER_PAGE)
|
|
11
|
+
@total = filtered.count
|
|
12
|
+
@page = page
|
|
13
|
+
@counts = filtered.group(:event).count
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
@event = AuditEvent.find(params[:id])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def filtered
|
|
22
|
+
scope = AuditEvent.all
|
|
23
|
+
scope = scope.where(event: filter_params[:event]) if AuditEvent::EVENTS.include?(filter_params[:event])
|
|
24
|
+
scope = scope.where(user_label: filter_params[:user]) if filter_params[:user].present?
|
|
25
|
+
scope = scope.where(store: filter_params[:store]) if filter_params[:store].present?
|
|
26
|
+
scope = scope.where("target LIKE ?", "%#{AuditEvent.sanitize_sql_like(filter_params[:q])}%") if filter_params[:q].present?
|
|
27
|
+
scope
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def filter_params
|
|
31
|
+
@filter_params ||= params.permit(:event, :user, :store, :q).to_h.symbolize_keys.compact_blank
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def page
|
|
35
|
+
[ params[:page].to_i, 0 ].max
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class FlushesController < ApplicationController
|
|
3
|
+
before_action { authorize!(:flush) }
|
|
4
|
+
|
|
5
|
+
def new
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Typing the store's name is the confirmation.
|
|
9
|
+
def create
|
|
10
|
+
reason = reason_for(:flush)
|
|
11
|
+
unless params[:confirm].to_s == cache_bro_store.name
|
|
12
|
+
return redirect_to flush_path(store: cache_bro_store.name), alert: "Type the store name to confirm. Nothing was deleted."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
result = begin
|
|
16
|
+
cache_bro_store.flush
|
|
17
|
+
rescue CacheBro::SweepFailed => error
|
|
18
|
+
record(error.matches, reason, completed: false)
|
|
19
|
+
raise
|
|
20
|
+
end
|
|
21
|
+
record(result, reason, completed: true)
|
|
22
|
+
|
|
23
|
+
redirect_to flush_path(store: cache_bro_store.name), notice: "Flushed #{cache_bro_store.name}: #{helpers.cache_bro_count(result.count, "key")} deleted."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
def record(result, reason, completed:)
|
|
28
|
+
Audit.record(:store_flushed, user: cache_bro_user, store: cache_bro_store.name, count: result.count, reason: reason,
|
|
29
|
+
namespace: cache_bro_store.namespace, sample: result.sample, completed: completed)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class KeysController < ApplicationController
|
|
3
|
+
before_action { authorize!(:view_keys) }
|
|
4
|
+
before_action :load_key, only: %i[ show reveal destroy ]
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@query = params[:q].to_s.strip.presence
|
|
8
|
+
@listing = cache_bro_store.list(query: @query, cursor: cursor_param)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A POST, so a link prefetcher can't "view" a value. The audit entry is written first: if it can't be
|
|
15
|
+
# recorded, nothing is shown.
|
|
16
|
+
def reveal
|
|
17
|
+
authorize!(:view_values)
|
|
18
|
+
reason = reason_for(:reveal)
|
|
19
|
+
Audit.record(:value_viewed, user: cache_bro_user, store: cache_bro_store.name, target: @key, reason: reason)
|
|
20
|
+
@value = cache_bro_store.read(@key)
|
|
21
|
+
render :show
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destroy
|
|
25
|
+
authorize!(:delete)
|
|
26
|
+
reason = reason_for(:delete)
|
|
27
|
+
deleted = cache_bro_store.delete(@key)
|
|
28
|
+
Audit.record(:key_deleted, user: cache_bro_user, store: cache_bro_store.name, target: @key, count: deleted, reason: reason)
|
|
29
|
+
redirect_to root_path(store: cache_bro_store.name), notice: "Deleted #{@key}."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def load_key
|
|
34
|
+
@key = params[:key].to_s
|
|
35
|
+
@entry = cache_bro_store.stat(@key) unless @key.empty?
|
|
36
|
+
redirect_to root_path(store: cache_bro_store.name), alert: "That key no longer exists." unless @entry
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class PurgesController < ApplicationController
|
|
3
|
+
before_action { authorize!(:purge) }
|
|
4
|
+
|
|
5
|
+
def new
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Counts what the pattern matches, without deleting, and asks to confirm.
|
|
9
|
+
def preview
|
|
10
|
+
@pattern = params[:pattern].to_s.strip
|
|
11
|
+
@reason = reason_for(:purge)
|
|
12
|
+
@matches = cache_bro_store.preview(@pattern)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
pattern = params[:pattern].to_s.strip
|
|
17
|
+
reason = reason_for(:purge)
|
|
18
|
+
result = begin
|
|
19
|
+
cache_bro_store.purge(pattern)
|
|
20
|
+
rescue CacheBro::SweepFailed => error
|
|
21
|
+
# Whatever got deleted before the failure is still recorded.
|
|
22
|
+
record(pattern, error.matches, reason, completed: false)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
record(pattern, result, reason, completed: true)
|
|
26
|
+
|
|
27
|
+
notice = "Deleted #{helpers.cache_bro_count(result.count, "key")} matching #{pattern}."
|
|
28
|
+
notice += " That is the limit for one purge, run it again to continue." if result.truncated
|
|
29
|
+
redirect_to purge_path(store: cache_bro_store.name), notice: notice
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def record(pattern, result, reason, completed:)
|
|
34
|
+
Audit.record(:keys_purged, user: cache_bro_user, store: cache_bro_store.name, target: pattern, count: result.count,
|
|
35
|
+
reason: reason, sample: result.sample, truncated: result.truncated, completed: completed)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
PER_PAGE = 50
|
|
4
|
+
|
|
5
|
+
EVENT_LABELS = {
|
|
6
|
+
"value_viewed" => "Revealed", "key_deleted" => "Deleted", "keys_purged" => "Purged", "store_flushed" => "Flushed"
|
|
7
|
+
}.freeze
|
|
8
|
+
EVENT_TONES = { "value_viewed" => "amber", "key_deleted" => "red", "keys_purged" => "red", "store_flushed" => "red" }.freeze
|
|
9
|
+
|
|
10
|
+
def cache_bro_time(time)
|
|
11
|
+
return "-" unless time
|
|
12
|
+
|
|
13
|
+
content_tag(:time, time.utc.strftime("%Y-%m-%d %H:%M"), datetime: time.iso8601, title: time.utc.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cache_bro_ttl(seconds)
|
|
17
|
+
seconds ? "in #{distance_of_time_in_words(0, seconds.round, include_seconds: true)}" : "no expiry"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cache_bro_size(bytes)
|
|
21
|
+
bytes ? number_to_human_size(bytes) : "-"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cache_bro_count(count, singular, plural = singular.pluralize)
|
|
25
|
+
"#{number_with_delimiter(count)} #{count == 1 ? singular : plural}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cache_bro_event_label(event)
|
|
29
|
+
EVENT_LABELS.fetch(event.to_s, event.to_s.humanize)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cache_bro_event_tag(event)
|
|
33
|
+
content_tag(:span, cache_bro_event_label(event), class: "bh-tag is-#{EVENT_TONES.fetch(event.to_s, "muted")}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A reason box. Required when the config says so for that action.
|
|
37
|
+
def cache_bro_reason_field(action, placeholder: "Why? (ticket, incident, ...)")
|
|
38
|
+
required = CacheBro.config.reason_required?(action)
|
|
39
|
+
label_tag(:reason, class: "bh-field") do
|
|
40
|
+
safe_join([ (required ? "Reason (required)" : "Reason (optional)"),
|
|
41
|
+
text_field_tag(:reason, nil, class: "bh-input", placeholder: placeholder, required: required, autocomplete: "off") ])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "Next →" for SCAN cursors. There is no "back": a cursor only goes forward.
|
|
46
|
+
def cache_bro_pager(listing, query:)
|
|
47
|
+
return unless listing.more?
|
|
48
|
+
|
|
49
|
+
content_tag(:div, class: "bh-meta", style: "margin-top:14px") do
|
|
50
|
+
link_to("Keep looking →", root_path(store: cache_bro_store.name, q: query, cursor: listing.cursor))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# "← Newer / Older →" for audit pages. `page` counts from 0.
|
|
55
|
+
def cache_bro_audit_pager(total:, page:)
|
|
56
|
+
newer = link_to("← Newer", url_for(request.query_parameters.merge(page: page - 1))) if page > 0
|
|
57
|
+
older = link_to("Older →", url_for(request.query_parameters.merge(page: page + 1))) if (page + 1) * PER_PAGE < total
|
|
58
|
+
content_tag(:div, class: "bh-meta", style: "display:flex;justify-content:space-between;gap:16px;margin-top:14px") do
|
|
59
|
+
safe_join([ content_tag(:span, cache_bro_count(total, "event")), content_tag(:span, safe_join([ newer, older ].compact, " ")) ])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
# Who revealed, deleted or purged what, and when. Holds keys and patterns, never values.
|
|
3
|
+
class AuditEvent < ApplicationRecord
|
|
4
|
+
EVENTS = %w[ value_viewed key_deleted keys_purged store_flushed ].freeze
|
|
5
|
+
|
|
6
|
+
validates :event, inclusion: { in: EVENTS }
|
|
7
|
+
validates :environment, :store, presence: true
|
|
8
|
+
|
|
9
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
10
|
+
|
|
11
|
+
# Audit entries are append-only.
|
|
12
|
+
def readonly?
|
|
13
|
+
persisted?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Keys the action touched, for a purge or flush (a sample, see config.audit_sample_size).
|
|
17
|
+
def sample
|
|
18
|
+
Array(details["sample"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def truncated?
|
|
22
|
+
details["truncated"] == true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<% bro_hub_crumb "Audit log" %>
|
|
2
|
+
<% content_for :bro_hub_crumb_actions do %>
|
|
3
|
+
<% [ [ "All", nil ], *CacheBro::AuditEvent::EVENTS.map { |event| [ cache_bro_event_label(event), event ] } ].each do |label, event| %>
|
|
4
|
+
<%= bro_hub_crumb_link label, audit_path(@filter.merge(event: event).compact), active: @filter[:event] == event %>
|
|
5
|
+
<% end %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<%= bro_hub_hero eyebrow: "Audit log", title: @total, subtitle: @total == 1 ? "event" : "events",
|
|
9
|
+
lead: "Who revealed, deleted or purged what. Entries can't be changed. Keys and patterns are recorded, never values.",
|
|
10
|
+
stats: [ [ @counts.fetch("value_viewed", 0), "reveals", :amber ], [ %w[ key_deleted keys_purged store_flushed ].sum { |event| @counts.fetch(event, 0) }, "deletions", :red ] ] %>
|
|
11
|
+
|
|
12
|
+
<%= form_with url: audit_path, method: :get, class: "bh-filters" do %>
|
|
13
|
+
<%= hidden_field_tag :event, @filter[:event] if @filter[:event] %>
|
|
14
|
+
<%= text_field_tag :q, @filter[:q], placeholder: "Search keys and patterns", class: "bh-input is-strong", "aria-label": "Search keys and patterns" %>
|
|
15
|
+
<%= text_field_tag :user, @filter[:user], placeholder: "User", class: "bh-input", "aria-label": "User" %>
|
|
16
|
+
<% if CacheBro.config.store_names.size > 1 %>
|
|
17
|
+
<%= select_tag :store, options_for_select([ [ "Any store", "" ], *CacheBro.config.store_names ], @filter[:store]), class: "bh-select", "aria-label": "Store" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<%= submit_tag "Filter", name: nil, class: "bh-chip" %>
|
|
20
|
+
<%= link_to "Reset", audit_path(event: @filter[:event]), class: "bh-link-quiet" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
|
|
23
|
+
<div class="bh-table-wrap">
|
|
24
|
+
<div class="bh-table" style="--cols: 150px 150px 100px 120px minmax(220px,1fr) 80px; --min: 900px">
|
|
25
|
+
<div class="bh-row is-head"><span>When</span><span>User</span><span>Action</span><span>Store</span><span>Key or pattern</span><span>Keys</span></div>
|
|
26
|
+
<% @events.each do |event| %>
|
|
27
|
+
<%= link_to audit_event_path(event), class: [ "bh-row", ("bh-edge-red" unless event.event == "value_viewed") ] do %>
|
|
28
|
+
<span class="bh-tone-violet"><%= cache_bro_time(event.created_at) %></span>
|
|
29
|
+
<span class="bh-cell-strong"><%= event.user_label %></span>
|
|
30
|
+
<span><%= cache_bro_event_tag(event.event) %></span>
|
|
31
|
+
<span class="bh-cell-muted"><%= event.store %></span>
|
|
32
|
+
<span class="bh-cell-text" style="overflow-wrap:anywhere"><%= truncate(event.target.to_s, length: 90) %></span>
|
|
33
|
+
<span><%= event.count %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if @events.empty? %><div class="bh-empty">Nothing recorded.</div><% end %>
|
|
37
|
+
<%= cache_bro_audit_pager(total: @total, page: @page) %>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<% bro_hub_crumb "Audit log", "##{@event.id}" %>
|
|
2
|
+
<% content_for :bro_hub_crumb_actions do %>
|
|
3
|
+
<%= bro_hub_crumb_link "← Audit log", audit_path %>
|
|
4
|
+
<% end %>
|
|
5
|
+
|
|
6
|
+
<%= bro_hub_hero eyebrow: "#{@event.store} · #{@event.environment}", title: cache_bro_event_label(@event.event),
|
|
7
|
+
subtitle: ("#{cache_bro_count(@event.count, "key")}" if @event.count),
|
|
8
|
+
lead: safe_join([ content_tag(:strong, @event.user_label), " at #{@event.created_at.utc.strftime("%Y-%m-%d %H:%M UTC")}" ]) %>
|
|
9
|
+
|
|
10
|
+
<div class="bh-split">
|
|
11
|
+
<section class="bh-split-main">
|
|
12
|
+
<div class="bh-block-head"><h2 class="bh-h2"><%= @event.event == "keys_purged" ? "Pattern" : "Key" %></h2></div>
|
|
13
|
+
<% if @event.target.present? %>
|
|
14
|
+
<pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= @event.target %></pre>
|
|
15
|
+
<% else %>
|
|
16
|
+
<p class="bh-hint">The whole namespace.</p>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% if @event.sample.any? %>
|
|
20
|
+
<div class="bh-block-head" style="margin-top:24px">
|
|
21
|
+
<h2 class="bh-h2">Keys deleted</h2>
|
|
22
|
+
<span class="bh-meta">a sample of <%= @event.sample.size %><%= " (more were deleted)" if @event.count.to_i > @event.sample.size %></span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="bh-log">
|
|
25
|
+
<% @event.sample.each do |key| %>
|
|
26
|
+
<div class="bh-cmd"><pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= key %></pre></div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<div class="bh-split-aside">
|
|
33
|
+
<section class="bh-block">
|
|
34
|
+
<h2 class="bh-h2">Details</h2>
|
|
35
|
+
<dl class="bh-dl">
|
|
36
|
+
<dt>User</dt><dd><%= @event.user_label %></dd>
|
|
37
|
+
<dt>Action</dt><dd><%= cache_bro_event_tag(@event.event) %></dd>
|
|
38
|
+
<dt>When</dt><dd><%= cache_bro_time(@event.created_at) %></dd>
|
|
39
|
+
<dt>Store</dt><dd><%= @event.store %></dd>
|
|
40
|
+
<dt>Environment</dt><dd><%= @event.environment %></dd>
|
|
41
|
+
<dt>Keys</dt><dd><%= @event.count || "-" %></dd>
|
|
42
|
+
<dt>Reason</dt><dd><%= @event.reason.presence || "none given" %></dd>
|
|
43
|
+
<% if @event.details["completed"] == false %><dt>Outcome</dt><dd class="bh-tone-red">failed part way: only the keys counted here were deleted</dd><% end %>
|
|
44
|
+
<% if @event.truncated? %><dt>Limit</dt><dd class="bh-tone-amber">stopped at the per-purge limit</dd><% end %>
|
|
45
|
+
</dl>
|
|
46
|
+
</section>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<% bro_hub_crumb "Flush" %>
|
|
2
|
+
<%= render "cache_bro/shared/store_tabs", path: ->(name) { flush_path(store: name) } %>
|
|
3
|
+
|
|
4
|
+
<%= bro_hub_hero eyebrow: cache_bro_store.name, title: "Flush", tone: :red,
|
|
5
|
+
lead: "Delete everything in this cache store's namespace. Every cached page, fragment and value is rebuilt on demand, which can load your database. Recorded in the audit log." %>
|
|
6
|
+
|
|
7
|
+
<% if cache_bro_store.namespaced? %>
|
|
8
|
+
<%= form_with url: flush_path, method: :post, class: "bh-filters" do %>
|
|
9
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
10
|
+
<label class="bh-field">Type <strong><%= cache_bro_store.name %></strong> to confirm
|
|
11
|
+
<%= text_field_tag :confirm, nil, class: "bh-input", autocomplete: "off", required: true %>
|
|
12
|
+
</label>
|
|
13
|
+
<%= cache_bro_reason_field(:flush) %>
|
|
14
|
+
<%= submit_tag "Flush #{cache_bro_store.name}", name: nil, class: "bh-btn is-auto", style: "background:var(--bh-red)" %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<p class="bh-hint" style="padding:0 var(--bh-pad)">Only keys under the namespace <code><%= cache_bro_store.namespace %></code> are deleted.</p>
|
|
17
|
+
<% else %>
|
|
18
|
+
<div class="bh-alert bh-alert-error">This store has no namespace, so flushing it would delete keys that aren't the cache's. Set <code>namespace:</code> on the cache store to enable this. You can still purge by pattern.</div>
|
|
19
|
+
<% end %>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<% bro_hub_crumb "Keys" %>
|
|
2
|
+
<%= render "cache_bro/shared/store_tabs", path: ->(name) { root_path(store: name) } %>
|
|
3
|
+
|
|
4
|
+
<%= bro_hub_hero eyebrow: [ cache_bro_store.name, (cache_bro_store.namespace ? "namespace #{cache_bro_store.namespace}" : "no namespace") ].join(" · "),
|
|
5
|
+
title: @listing.entries.size,
|
|
6
|
+
subtitle: @listing.more? ? "keys so far" : (@listing.entries.size == 1 ? "key" : "keys"),
|
|
7
|
+
lead: "Search what is in the cache. Plain text matches anywhere in the key; use * and ? for a pattern. " \
|
|
8
|
+
"Redis is walked in small steps, so on a big cache a page may show only part of the matches: keep looking to continue." %>
|
|
9
|
+
|
|
10
|
+
<% unless cache_bro_store.namespaced? %>
|
|
11
|
+
<div class="bh-alert bh-alert-error">This cache store has no namespace, so it shares its Redis database with whatever else lives there. Set <code>namespace:</code> on the cache store to keep CacheBro to the cache's own keys.</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<%= form_with url: root_path, method: :get, class: "bh-filters" do %>
|
|
15
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
16
|
+
<%= text_field_tag :q, @query, placeholder: "Search keys, e.g. posts/ or views/*/show*", class: "bh-input is-strong", "aria-label": "Search keys", autofocus: true %>
|
|
17
|
+
<%= submit_tag "Search", name: nil, class: "bh-chip" %>
|
|
18
|
+
<%= link_to "Reset", root_path(store: cache_bro_store.name), class: "bh-link-quiet" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div class="bh-table-wrap">
|
|
22
|
+
<div class="bh-table" style="--cols: minmax(260px,1fr) 150px 100px; --min: 560px">
|
|
23
|
+
<div class="bh-row is-head"><span>Key</span><span>Expires</span><span>Size</span></div>
|
|
24
|
+
<% @listing.entries.each do |entry| %>
|
|
25
|
+
<%= link_to key_path(store: cache_bro_store.name, key: entry.key), class: "bh-row" do %>
|
|
26
|
+
<span class="bh-cell-strong" style="overflow-wrap:anywhere"><%= entry.key %></span>
|
|
27
|
+
<span class="bh-cell-muted"><%= cache_bro_ttl(entry.ttl) %></span>
|
|
28
|
+
<span class="bh-cell-muted"><%= cache_bro_size(entry.size) %></span>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if @listing.entries.empty? %>
|
|
32
|
+
<div class="bh-empty"><%= @listing.more? ? "Nothing here yet." : "No keys." %></div>
|
|
33
|
+
<% end %>
|
|
34
|
+
<%= cache_bro_pager(@listing, query: @query) %>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|