rubyn-code 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/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# Gem: Pundit
|
|
2
|
+
|
|
3
|
+
## What It Is
|
|
4
|
+
|
|
5
|
+
Pundit provides authorization through plain Ruby policy classes. Each model gets a policy class that defines who can do what. It's intentionally simple — no DSL, no roles table, no configuration. Just Ruby classes with methods that return true/false.
|
|
6
|
+
|
|
7
|
+
## Setup Done Right
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem 'pundit'
|
|
12
|
+
|
|
13
|
+
# app/controllers/application_controller.rb
|
|
14
|
+
class ApplicationController < ActionController::Base
|
|
15
|
+
include Pundit::Authorization
|
|
16
|
+
|
|
17
|
+
# CRITICAL: Ensure every action is authorized
|
|
18
|
+
after_action :verify_authorized, except: :index
|
|
19
|
+
after_action :verify_policy_scoped, only: :index
|
|
20
|
+
|
|
21
|
+
# Handle unauthorized access gracefully
|
|
22
|
+
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def user_not_authorized
|
|
27
|
+
flash[:alert] = "You are not authorized to perform this action."
|
|
28
|
+
redirect_back(fallback_location: root_path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# app/policies/application_policy.rb
|
|
35
|
+
class ApplicationPolicy
|
|
36
|
+
attr_reader :user, :record
|
|
37
|
+
|
|
38
|
+
def initialize(user, record)
|
|
39
|
+
@user = user
|
|
40
|
+
@record = record
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Default: deny everything. Policies opt IN to permissions.
|
|
44
|
+
def index? = false
|
|
45
|
+
def show? = false
|
|
46
|
+
def create? = false
|
|
47
|
+
def new? = create?
|
|
48
|
+
def update? = false
|
|
49
|
+
def edit? = update?
|
|
50
|
+
def destroy? = false
|
|
51
|
+
|
|
52
|
+
class Scope
|
|
53
|
+
def initialize(user, scope)
|
|
54
|
+
@user = user
|
|
55
|
+
@scope = scope
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve
|
|
59
|
+
raise NotImplementedError, "#{self.class} must implement #resolve"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :user, :scope
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# app/policies/order_policy.rb
|
|
71
|
+
class OrderPolicy < ApplicationPolicy
|
|
72
|
+
def show?
|
|
73
|
+
owner? || admin?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create?
|
|
77
|
+
user.present? && user.credit_balance > 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update?
|
|
81
|
+
owner? && record.editable?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def destroy?
|
|
85
|
+
owner? && record.pending?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Scope < ApplicationPolicy::Scope
|
|
89
|
+
def resolve
|
|
90
|
+
if user.admin?
|
|
91
|
+
scope.all
|
|
92
|
+
else
|
|
93
|
+
scope.where(user: user)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def owner?
|
|
101
|
+
record.user == user
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def admin?
|
|
105
|
+
user&.admin?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Controller usage
|
|
112
|
+
class OrdersController < ApplicationController
|
|
113
|
+
def index
|
|
114
|
+
@orders = policy_scope(Order).recent.page(params[:page])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def show
|
|
118
|
+
@order = Order.find(params[:id])
|
|
119
|
+
authorize @order
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create
|
|
123
|
+
@order = current_user.orders.build(order_params)
|
|
124
|
+
authorize @order
|
|
125
|
+
|
|
126
|
+
if @order.save
|
|
127
|
+
redirect_to @order
|
|
128
|
+
else
|
|
129
|
+
render :new, status: :unprocessable_entity
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update
|
|
134
|
+
@order = Order.find(params[:id])
|
|
135
|
+
authorize @order
|
|
136
|
+
|
|
137
|
+
if @order.update(order_params)
|
|
138
|
+
redirect_to @order
|
|
139
|
+
else
|
|
140
|
+
render :edit, status: :unprocessable_entity
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def destroy
|
|
145
|
+
@order = Order.find(params[:id])
|
|
146
|
+
authorize @order
|
|
147
|
+
@order.destroy
|
|
148
|
+
redirect_to orders_path
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Gotcha #1: Forgetting to Authorize
|
|
154
|
+
|
|
155
|
+
The #1 Pundit bug: you add a new action and forget to call `authorize`. Without `verify_authorized`, the action silently works for everyone — including users who shouldn't have access.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# WRONG: No authorization — anyone can export
|
|
159
|
+
def export
|
|
160
|
+
@orders = Order.all # SECURITY HOLE: No policy check
|
|
161
|
+
send_data generate_csv(@orders)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# RIGHT: Authorize explicitly
|
|
165
|
+
def export
|
|
166
|
+
@orders = policy_scope(Order)
|
|
167
|
+
authorize Order, :export? # Checks OrderPolicy#export?
|
|
168
|
+
send_data generate_csv(@orders)
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**The trap:** `verify_authorized` in `after_action` catches this in development — you'll get `Pundit::AuthorizationNotPerformedError`. But only if you set it up. Without the `after_action`, forgotten authorization is a silent security hole.
|
|
173
|
+
|
|
174
|
+
**Skipping verification for specific actions:**
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# When an action legitimately doesn't need authorization
|
|
178
|
+
def health_check
|
|
179
|
+
skip_authorization # Explicitly marks this action as not needing auth
|
|
180
|
+
render json: { status: "ok" }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def index
|
|
184
|
+
@orders = policy_scope(Order) # policy_scope satisfies verify_policy_scoped
|
|
185
|
+
# No need for authorize — verify_policy_scoped is separate from verify_authorized
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Gotcha #2: `authorize` Must Be Called on the Right Object
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# WRONG: Authorizing the class when you should authorize the instance
|
|
193
|
+
def update
|
|
194
|
+
@order = Order.find(params[:id])
|
|
195
|
+
authorize Order # Checks if user can update ANY order, not THIS order
|
|
196
|
+
# OrderPolicy#update? receives the Order CLASS, not the instance
|
|
197
|
+
# record.user == user will fail because Class doesn't have .user
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# RIGHT: Authorize the specific record
|
|
201
|
+
def update
|
|
202
|
+
@order = Order.find(params[:id])
|
|
203
|
+
authorize @order # Checks if user can update THIS specific order
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# RIGHT: Authorize the class for collection actions
|
|
207
|
+
def create
|
|
208
|
+
@order = current_user.orders.build(order_params)
|
|
209
|
+
authorize @order # Instance is fine here — Pundit infers the policy
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# RIGHT: Authorize with explicit policy action
|
|
213
|
+
def publish
|
|
214
|
+
@order = Order.find(params[:id])
|
|
215
|
+
authorize @order, :publish? # Calls OrderPolicy#publish?, not #update?
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**The trap:** The action name maps to the policy method automatically (`create` action → `create?` policy method). If your action has a non-standard name (like `publish`, `export`, `approve`), you MUST pass the policy method explicitly: `authorize @order, :publish?`.
|
|
220
|
+
|
|
221
|
+
## Gotcha #3: Policy Scope vs Authorize
|
|
222
|
+
|
|
223
|
+
They're different things for different purposes:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# policy_scope: Filters a COLLECTION. Returns only records the user can see.
|
|
227
|
+
# Used in index actions. Satisfies verify_policy_scoped.
|
|
228
|
+
def index
|
|
229
|
+
@orders = policy_scope(Order) # Calls OrderPolicy::Scope#resolve
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# authorize: Checks permission on a SINGLE record. Returns the record or raises.
|
|
233
|
+
# Used in show/create/update/destroy. Satisfies verify_authorized.
|
|
234
|
+
def show
|
|
235
|
+
@order = Order.find(params[:id])
|
|
236
|
+
authorize @order # Calls OrderPolicy#show?
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**The trap:** Using `authorize` in an index action doesn't filter records — it just checks if the user can access the index page. You still need `policy_scope` to filter WHICH records they see.
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# WRONG: Authorizes index access but shows ALL orders to everyone
|
|
244
|
+
def index
|
|
245
|
+
authorize Order, :index?
|
|
246
|
+
@orders = Order.all # Everyone sees everything!
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# RIGHT: policy_scope filters to only the user's orders
|
|
250
|
+
def index
|
|
251
|
+
@orders = policy_scope(Order).page(params[:page])
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Gotcha #4: The User Can Be Nil
|
|
256
|
+
|
|
257
|
+
Pundit passes `current_user` as the first argument to the policy. If the user isn't signed in and you don't handle nil, you get `NoMethodError` inside the policy.
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# WRONG: Assumes user is always present
|
|
261
|
+
class OrderPolicy < ApplicationPolicy
|
|
262
|
+
def show?
|
|
263
|
+
record.user == user || user.admin? # NoMethodError if user is nil
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# RIGHT: Handle nil user
|
|
268
|
+
class OrderPolicy < ApplicationPolicy
|
|
269
|
+
def show?
|
|
270
|
+
return false unless user # Guest users can't see anything
|
|
271
|
+
|
|
272
|
+
owner? || admin?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def index?
|
|
276
|
+
user.present? # Must be signed in to list orders
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def admin?
|
|
282
|
+
user&.admin? # Safe navigation
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
If you use the Null Object pattern (`GuestUser` instead of nil), this is handled automatically — but make sure `GuestUser` responds correctly to all methods the policy calls.
|
|
288
|
+
|
|
289
|
+
## Gotcha #5: Permitted Attributes Per Role
|
|
290
|
+
|
|
291
|
+
Pundit can control WHICH fields a user can update, not just WHETHER they can update:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# app/policies/order_policy.rb
|
|
295
|
+
class OrderPolicy < ApplicationPolicy
|
|
296
|
+
def permitted_attributes
|
|
297
|
+
if user.admin?
|
|
298
|
+
[:shipping_address, :notes, :status, :total, :assigned_to]
|
|
299
|
+
else
|
|
300
|
+
[:shipping_address, :notes]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Or per-action permitted attributes
|
|
305
|
+
def permitted_attributes_for_create
|
|
306
|
+
[:shipping_address, :notes, :line_items_attributes]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def permitted_attributes_for_update
|
|
310
|
+
if record.pending?
|
|
311
|
+
[:shipping_address, :notes]
|
|
312
|
+
else
|
|
313
|
+
[:notes] # Can only edit notes after confirmation
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Controller
|
|
319
|
+
class OrdersController < ApplicationController
|
|
320
|
+
def update
|
|
321
|
+
@order = Order.find(params[:id])
|
|
322
|
+
authorize @order
|
|
323
|
+
|
|
324
|
+
if @order.update(permitted_attributes(@order))
|
|
325
|
+
redirect_to @order
|
|
326
|
+
else
|
|
327
|
+
render :edit, status: :unprocessable_entity
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
# This uses Pundit's permitted_attributes — NOT params.require().permit()
|
|
334
|
+
# Don't mix the two approaches
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**The trap:** Using `params.require(:order).permit(:status)` in the controller bypasses Pundit's attribute control. If you use Pundit for permitted attributes, use `permitted_attributes(@order)` everywhere — don't mix approaches.
|
|
339
|
+
|
|
340
|
+
## Gotcha #6: Testing Policies
|
|
341
|
+
|
|
342
|
+
Policies are plain Ruby — test them directly without HTTP:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# spec/policies/order_policy_spec.rb
|
|
346
|
+
RSpec.describe OrderPolicy do
|
|
347
|
+
subject { described_class.new(user, order) }
|
|
348
|
+
|
|
349
|
+
let(:order) { build_stubbed(:order, user: owner) }
|
|
350
|
+
let(:owner) { build_stubbed(:user) }
|
|
351
|
+
|
|
352
|
+
context "when user is the owner" do
|
|
353
|
+
let(:user) { owner }
|
|
354
|
+
|
|
355
|
+
it { is_expected.to permit_action(:show) }
|
|
356
|
+
it { is_expected.to permit_action(:update) }
|
|
357
|
+
it { is_expected.to permit_action(:destroy) }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
context "when user is an admin" do
|
|
361
|
+
let(:user) { build_stubbed(:user, role: :admin) }
|
|
362
|
+
|
|
363
|
+
it { is_expected.to permit_action(:show) }
|
|
364
|
+
it { is_expected.to permit_action(:update) }
|
|
365
|
+
it { is_expected.to permit_action(:destroy) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
context "when user is a stranger" do
|
|
369
|
+
let(:user) { build_stubbed(:user) }
|
|
370
|
+
|
|
371
|
+
it { is_expected.not_to permit_action(:show) }
|
|
372
|
+
it { is_expected.not_to permit_action(:update) }
|
|
373
|
+
it { is_expected.not_to permit_action(:destroy) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
context "when user is nil (guest)" do
|
|
377
|
+
let(:user) { nil }
|
|
378
|
+
|
|
379
|
+
it { is_expected.not_to permit_action(:show) }
|
|
380
|
+
it { is_expected.not_to permit_action(:create) }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Testing scopes
|
|
384
|
+
describe "Scope" do
|
|
385
|
+
let!(:own_order) { create(:order, user: user) }
|
|
386
|
+
let!(:other_order) { create(:order) }
|
|
387
|
+
let(:user) { create(:user) }
|
|
388
|
+
|
|
389
|
+
it "returns only the user's orders" do
|
|
390
|
+
scope = described_class::Scope.new(user, Order).resolve
|
|
391
|
+
expect(scope).to include(own_order)
|
|
392
|
+
expect(scope).not_to include(other_order)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Add `pundit-matchers` gem for the `permit_action` syntax:
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
# Gemfile (test group)
|
|
402
|
+
gem 'pundit-matchers'
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Gotcha #7: Views — Checking Permissions
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
# In views, use policy() to check permissions
|
|
409
|
+
<% if policy(@order).update? %>
|
|
410
|
+
<%= link_to "Edit", edit_order_path(@order) %>
|
|
411
|
+
<% end %>
|
|
412
|
+
|
|
413
|
+
<% if policy(@order).destroy? %>
|
|
414
|
+
<%= button_to "Delete", order_path(@order), method: :delete %>
|
|
415
|
+
<% end %>
|
|
416
|
+
|
|
417
|
+
# For collection-level checks
|
|
418
|
+
<% if policy(Order).create? %>
|
|
419
|
+
<%= link_to "New Order", new_order_path %>
|
|
420
|
+
<% end %>
|
|
421
|
+
|
|
422
|
+
# DON'T check roles directly in views
|
|
423
|
+
# WRONG:
|
|
424
|
+
<% if current_user.admin? %>
|
|
425
|
+
<%= link_to "Edit", edit_order_path(@order) %>
|
|
426
|
+
<% end %>
|
|
427
|
+
# This duplicates policy logic. If admin rules change, you update the policy AND the view.
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Do's and Don'ts Summary
|
|
431
|
+
|
|
432
|
+
**DO:**
|
|
433
|
+
- Add `verify_authorized` and `verify_policy_scoped` after_actions immediately
|
|
434
|
+
- Default all permissions to `false` in `ApplicationPolicy`
|
|
435
|
+
- Handle nil user in every policy method
|
|
436
|
+
- Use `policy_scope` for collections, `authorize` for single records
|
|
437
|
+
- Test policies directly — they're plain Ruby, no HTTP needed
|
|
438
|
+
- Use `policy()` in views instead of role checks
|
|
439
|
+
|
|
440
|
+
**DON'T:**
|
|
441
|
+
- Don't forget to `authorize` in every controller action (or `skip_authorization` explicitly)
|
|
442
|
+
- Don't authorize the class when you mean the instance
|
|
443
|
+
- Don't mix `params.permit()` with Pundit's `permitted_attributes`
|
|
444
|
+
- Don't put authorization logic in controllers or views — keep it in policies
|
|
445
|
+
- Don't check `current_user.admin?` in views — use `policy(@record).action?`
|
|
446
|
+
- Don't assume user is present in policy methods — always guard for nil
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Gems: Redis
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use Redis for caching, rate limiting, sessions, job queues, and real-time features. Use `connection_pool` for thread-safe access. Keep data ephemeral — Redis is a cache, not a database.
|
|
6
|
+
|
|
7
|
+
### Setup
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "redis", "~> 5.0"
|
|
12
|
+
gem "connection_pool", "~> 2.4"
|
|
13
|
+
gem "hiredis-client" # C extension for faster Redis — optional but recommended
|
|
14
|
+
|
|
15
|
+
# config/initializers/redis.rb
|
|
16
|
+
REDIS_POOL = ConnectionPool.new(size: ENV.fetch("REDIS_POOL_SIZE", 10).to_i, timeout: 5) do
|
|
17
|
+
Redis.new(
|
|
18
|
+
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"),
|
|
19
|
+
timeout: 2,
|
|
20
|
+
reconnect_attempts: 3
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Usage — always check out from pool, never hold a connection
|
|
25
|
+
REDIS_POOL.with do |redis|
|
|
26
|
+
redis.set("key", "value", ex: 3600) # Expires in 1 hour
|
|
27
|
+
value = redis.get("key")
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Caching
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# Rails cache store
|
|
35
|
+
# config/environments/production.rb
|
|
36
|
+
config.cache_store = :redis_cache_store, {
|
|
37
|
+
url: ENV.fetch("REDIS_URL"),
|
|
38
|
+
expires_in: 1.hour,
|
|
39
|
+
namespace: "rubyn:cache",
|
|
40
|
+
pool_size: ENV.fetch("REDIS_POOL_SIZE", 10).to_i,
|
|
41
|
+
error_handler: ->(method:, returning:, exception:) {
|
|
42
|
+
Rails.logger.error("Redis cache error: #{method} #{exception.message}")
|
|
43
|
+
Sentry.capture_exception(exception) if defined?(Sentry)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Usage via Rails.cache
|
|
48
|
+
Rails.cache.fetch("user:#{user.id}:credits", expires_in: 5.minutes) do
|
|
49
|
+
user.credit_ledger_entries.sum(:amount) # Only computed on cache miss
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Rails.cache.delete("user:#{user.id}:credits") # Invalidate
|
|
53
|
+
Rails.cache.delete_matched("user:#{user.id}:*") # Invalidate all user caches
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Rate Limiting
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Simple sliding window rate limiter
|
|
60
|
+
class RateLimiter
|
|
61
|
+
def initialize(pool: REDIS_POOL)
|
|
62
|
+
@pool = pool
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def allowed?(key, limit:, period:)
|
|
66
|
+
@pool.with do |redis|
|
|
67
|
+
current = redis.get(key).to_i
|
|
68
|
+
return true if current < limit
|
|
69
|
+
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def increment(key, period:)
|
|
75
|
+
@pool.with do |redis|
|
|
76
|
+
count = redis.incr(key)
|
|
77
|
+
redis.expire(key, period) if count == 1 # Set TTL on first increment
|
|
78
|
+
count
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remaining(key, limit:)
|
|
83
|
+
@pool.with do |redis|
|
|
84
|
+
current = redis.get(key).to_i
|
|
85
|
+
[limit - current, 0].max
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Usage in middleware or controller
|
|
91
|
+
limiter = RateLimiter.new
|
|
92
|
+
key = "rate:#{current_user.id}:#{Time.current.beginning_of_minute.to_i}"
|
|
93
|
+
|
|
94
|
+
unless limiter.allowed?(key, limit: 60, period: 60)
|
|
95
|
+
render json: { error: "Rate limited" }, status: :too_many_requests
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
limiter.increment(key, period: 60)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Distributed Locks
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# Prevent concurrent execution of the same job
|
|
106
|
+
class DistributedLock
|
|
107
|
+
def initialize(pool: REDIS_POOL)
|
|
108
|
+
@pool = pool
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def with_lock(key, ttl: 30, &block)
|
|
112
|
+
token = SecureRandom.hex(16)
|
|
113
|
+
|
|
114
|
+
@pool.with do |redis|
|
|
115
|
+
acquired = redis.set("lock:#{key}", token, nx: true, ex: ttl)
|
|
116
|
+
raise LockNotAcquired, "Could not acquire lock: #{key}" unless acquired
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
yield
|
|
120
|
+
ensure
|
|
121
|
+
# Only release if we still own the lock (compare token)
|
|
122
|
+
release_script = <<~LUA
|
|
123
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
124
|
+
return redis.call("del", KEYS[1])
|
|
125
|
+
else
|
|
126
|
+
return 0
|
|
127
|
+
end
|
|
128
|
+
LUA
|
|
129
|
+
redis.eval(release_script, keys: ["lock:#{key}"], argv: [token])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Usage
|
|
136
|
+
lock = DistributedLock.new
|
|
137
|
+
lock.with_lock("index:project:#{project.id}", ttl: 60) do
|
|
138
|
+
Embeddings::CodebaseIndexer.call(project)
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Pub/Sub for Real-Time
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# Publishing events
|
|
146
|
+
REDIS_POOL.with do |redis|
|
|
147
|
+
redis.publish("order:updates", { order_id: order.id, status: "shipped" }.to_json)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Subscribing (in a dedicated thread or process)
|
|
151
|
+
Thread.new do
|
|
152
|
+
Redis.new(url: ENV["REDIS_URL"]).subscribe("order:updates") do |on|
|
|
153
|
+
on.message do |channel, message|
|
|
154
|
+
data = JSON.parse(message)
|
|
155
|
+
ActionCable.server.broadcast("order_#{data['order_id']}", data)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Key Design
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# Use namespaced, structured keys
|
|
165
|
+
"rubyn:cache:user:42:credits" # Cache key
|
|
166
|
+
"rubyn:rate:user:42:1710892800" # Rate limit (epoch minute)
|
|
167
|
+
"rubyn:lock:index:project:17" # Distributed lock
|
|
168
|
+
"rubyn:session:abc123" # Session data
|
|
169
|
+
|
|
170
|
+
# GOOD: Include version for cache invalidation
|
|
171
|
+
"rubyn:v2:user:42:dashboard" # Bump v2→v3 to invalidate all dashboard caches
|
|
172
|
+
|
|
173
|
+
# GOOD: Include TTL in the key name for debugging
|
|
174
|
+
# Not in the key itself — use Redis TTL — but document expected TTLs:
|
|
175
|
+
# credits cache: 5 min
|
|
176
|
+
# dashboard: 15 min
|
|
177
|
+
# session: 24 hours
|
|
178
|
+
# rate limit: 60 seconds
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Why This Is Good
|
|
182
|
+
|
|
183
|
+
- **Connection pool prevents thread contention.** Without a pool, threads fight over a single Redis connection. `ConnectionPool` manages N connections and hands them out safely.
|
|
184
|
+
- **Namespaced keys prevent collisions.** `rubyn:cache:` vs `rubyn:rate:` vs `rubyn:lock:` — you can flush caches without losing rate limits.
|
|
185
|
+
- **TTLs prevent unbounded growth.** Every key should expire. Redis is memory-bound — keys without TTLs leak memory until OOM.
|
|
186
|
+
- **Lua scripts for atomic operations.** The distributed lock release uses a Lua script to atomically check-and-delete. Two separate commands would have a race condition.
|
|
187
|
+
- **Error handler on cache store.** If Redis goes down, the app degrades gracefully (cache misses) instead of crashing.
|
|
188
|
+
|
|
189
|
+
## Anti-Pattern
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# BAD: Global Redis connection shared across threads
|
|
193
|
+
$redis = Redis.new # NOT thread-safe under load
|
|
194
|
+
$redis.get("key") # Race conditions in multi-threaded Puma
|
|
195
|
+
|
|
196
|
+
# BAD: No TTL — keys live forever
|
|
197
|
+
redis.set("data", value) # Never expires — memory leak
|
|
198
|
+
redis.set("data", value, ex: 3600) # GOOD: expires in 1 hour
|
|
199
|
+
|
|
200
|
+
# BAD: No error handling — Redis down crashes the app
|
|
201
|
+
value = redis.get("key") # Redis::ConnectionError crashes the request
|
|
202
|
+
# GOOD: Rescue and degrade
|
|
203
|
+
value = redis.get("key") rescue nil
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## When To Apply
|
|
207
|
+
|
|
208
|
+
- **Caching** — Rails.cache with Redis store. Most common use case.
|
|
209
|
+
- **Rate limiting** — API endpoints, login attempts, credit usage.
|
|
210
|
+
- **Sidekiq** — already uses Redis for job queues.
|
|
211
|
+
- **ActionCable** — WebSocket pub/sub backend.
|
|
212
|
+
- **Distributed locks** — prevent duplicate job execution across workers.
|
|
213
|
+
- **Session store** — faster than database sessions for high-traffic apps.
|
|
214
|
+
|
|
215
|
+
## When NOT To Apply
|
|
216
|
+
|
|
217
|
+
- **Persistent data.** Redis can lose data on restart (unless using AOF/RDB). Don't store data you can't recompute.
|
|
218
|
+
- **Large values.** Redis is optimized for small values (<1KB). Don't store 10MB JSON blobs.
|
|
219
|
+
- **Complex queries.** Redis is a key-value store, not a database. No JOINs, no WHERE clauses, no full-text search.
|