claude-agent-framework 1.0.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.
- package/README.md +128 -0
- package/bin/claude-framework +3 -0
- package/framework/agents/design-lead.md +240 -0
- package/framework/agents/product-owner.md +179 -0
- package/framework/agents/tech-lead.md +226 -0
- package/framework/commands/ayuda.md +127 -0
- package/framework/commands/a/303/261adir.md +98 -0
- package/framework/commands/backup.md +397 -0
- package/framework/commands/cambiar.md +110 -0
- package/framework/commands/cloud.md +457 -0
- package/framework/commands/code.md +142 -0
- package/framework/commands/debug.md +334 -0
- package/framework/commands/deploy.md +383 -0
- package/framework/commands/deshacer.md +120 -0
- package/framework/commands/estado.md +218 -0
- package/framework/commands/explica.md +227 -0
- package/framework/commands/feature.md +120 -0
- package/framework/commands/git.md +427 -0
- package/framework/commands/historial.md +202 -0
- package/framework/commands/learn.md +408 -0
- package/framework/commands/movil.md +245 -0
- package/framework/commands/nuevo.md +118 -0
- package/framework/commands/plan.md +134 -0
- package/framework/commands/prd.md +113 -0
- package/framework/commands/probar.md +148 -0
- package/framework/commands/revisar.md +208 -0
- package/framework/commands/seeds.md +230 -0
- package/framework/commands/seguridad.md +226 -0
- package/framework/commands/tasks.md +157 -0
- package/framework/skills/architecture/algorithms.md +970 -0
- package/framework/skills/architecture/clean-code.md +1080 -0
- package/framework/skills/architecture/design-patterns.md +1984 -0
- package/framework/skills/architecture/functional-programming.md +972 -0
- package/framework/skills/architecture/solid.md +991 -0
- package/framework/skills/cloud/cloud-aws.md +848 -0
- package/framework/skills/cloud/cloud-azure.md +931 -0
- package/framework/skills/cloud/cloud-gcp.md +848 -0
- package/framework/skills/cloud/message-queues.md +1229 -0
- package/framework/skills/core/accessibility.md +401 -0
- package/framework/skills/core/api.md +474 -0
- package/framework/skills/core/authentication.md +306 -0
- package/framework/skills/core/authorization.md +388 -0
- package/framework/skills/core/background-jobs.md +341 -0
- package/framework/skills/core/caching.md +473 -0
- package/framework/skills/core/code-review.md +341 -0
- package/framework/skills/core/controllers.md +290 -0
- package/framework/skills/core/cua.md +285 -0
- package/framework/skills/core/documentation.md +472 -0
- package/framework/skills/core/file-uploads.md +351 -0
- package/framework/skills/core/hotwire-native.md +296 -0
- package/framework/skills/core/hotwire.md +278 -0
- package/framework/skills/core/i18n.md +334 -0
- package/framework/skills/core/imports-exports.md +750 -0
- package/framework/skills/core/infrastructure.md +337 -0
- package/framework/skills/core/models.md +228 -0
- package/framework/skills/core/notifications.md +672 -0
- package/framework/skills/core/payments.md +581 -0
- package/framework/skills/core/performance.md +361 -0
- package/framework/skills/core/rails-scaffold.md +131 -0
- package/framework/skills/core/search.md +518 -0
- package/framework/skills/core/security.md +565 -0
- package/framework/skills/core/seeds.md +307 -0
- package/framework/skills/core/seo.md +542 -0
- package/framework/skills/core/testing.md +393 -0
- package/framework/skills/core/views.md +260 -0
- package/framework/skills/core/websockets.md +564 -0
- package/framework/skills/data/advanced-sql.md +1204 -0
- package/framework/skills/data/nosql.md +1141 -0
- package/framework/skills/devops/containers-advanced.md +1237 -0
- package/framework/skills/devops/debugging.md +834 -0
- package/framework/skills/devops/git-workflow.md +752 -0
- package/framework/skills/devops/networking.md +932 -0
- package/framework/skills/devops/shell-scripting.md +1132 -0
- package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
- package/framework/sub-agents/cloud-agent.md +677 -0
- package/framework/sub-agents/data.md +504 -0
- package/framework/sub-agents/debugging-agent.md +554 -0
- package/framework/sub-agents/devops.md +483 -0
- package/framework/sub-agents/docs.md +176 -0
- package/framework/sub-agents/frontend-dev.md +349 -0
- package/framework/sub-agents/git-workflow-agent.md +697 -0
- package/framework/sub-agents/integrations.md +630 -0
- package/framework/sub-agents/native-dev.md +434 -0
- package/framework/sub-agents/qa.md +138 -0
- package/framework/sub-agents/rails-dev.md +375 -0
- package/framework/sub-agents/security.md +526 -0
- package/framework/sub-agents/ui.md +437 -0
- package/framework/sub-agents/ux.md +284 -0
- package/framework/templates/api-spec.md +500 -0
- package/framework/templates/component-spec.md +248 -0
- package/framework/templates/feature.json +13 -0
- package/framework/templates/model-spec.md +318 -0
- package/framework/templates/prd-template.md +80 -0
- package/framework/templates/task-plan.md +122 -0
- package/framework/templates/task-user-story.md +52 -0
- package/framework/templates/technical-spec.md +260 -0
- package/framework/templates/user-story.md +95 -0
- package/package.json +42 -0
- package/project-templates/CLAUDE.md +42 -0
- package/project-templates/contexts/architecture.md +25 -0
- package/project-templates/contexts/conventions.md +46 -0
- package/project-templates/contexts/design-system.md +47 -0
- package/project-templates/contexts/requirements.md +38 -0
- package/project-templates/contexts/stack.md +30 -0
- package/project-templates/history/active/models.md +11 -0
- package/project-templates/history/changelog.md +15 -0
- package/project-templates/workspace/.gitkeep +0 -0
- package/src/cli.js +52 -0
- package/src/init.js +104 -0
- package/src/status.js +75 -0
- package/src/update.js +88 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Skill: Authentication
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
Implement user authentication using Rails 8's built-in authentication generator.
|
|
5
|
+
|
|
6
|
+
## Rails 8 Authentication Generator
|
|
7
|
+
|
|
8
|
+
### Generate Authentication
|
|
9
|
+
```bash
|
|
10
|
+
rails generate authentication
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Generated Files
|
|
14
|
+
```
|
|
15
|
+
app/models/user.rb
|
|
16
|
+
app/models/session.rb
|
|
17
|
+
app/models/current.rb
|
|
18
|
+
app/controllers/sessions_controller.rb
|
|
19
|
+
app/controllers/passwords_controller.rb
|
|
20
|
+
app/views/sessions/new.html.erb
|
|
21
|
+
app/views/passwords/new.html.erb
|
|
22
|
+
app/views/passwords/edit.html.erb
|
|
23
|
+
db/migrate/TIMESTAMP_create_users.rb
|
|
24
|
+
db/migrate/TIMESTAMP_create_sessions.rb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## User Model
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/models/user.rb
|
|
31
|
+
class User < ApplicationRecord
|
|
32
|
+
has_secure_password
|
|
33
|
+
has_many :sessions, dependent: :destroy
|
|
34
|
+
|
|
35
|
+
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
|
36
|
+
|
|
37
|
+
validates :email_address, presence: true, uniqueness: true,
|
|
38
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
39
|
+
validates :password, length: { minimum: 8 }, allow_nil: true
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Session Model
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/models/session.rb
|
|
47
|
+
class Session < ApplicationRecord
|
|
48
|
+
belongs_to :user
|
|
49
|
+
|
|
50
|
+
before_create do
|
|
51
|
+
self.token = SecureRandom.urlsafe_base64(32)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Current Model
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# app/models/current.rb
|
|
60
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
61
|
+
attribute :session
|
|
62
|
+
delegate :user, to: :session, allow_nil: true
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Authentication Concern
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# app/controllers/concerns/authentication.rb
|
|
70
|
+
module Authentication
|
|
71
|
+
extend ActiveSupport::Concern
|
|
72
|
+
|
|
73
|
+
included do
|
|
74
|
+
before_action :require_authentication
|
|
75
|
+
helper_method :signed_in?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class_methods do
|
|
79
|
+
def allow_unauthenticated_access(**options)
|
|
80
|
+
skip_before_action :require_authentication, **options
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def signed_in?
|
|
87
|
+
Current.session.present?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def require_authentication
|
|
91
|
+
resume_session || request_authentication
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resume_session
|
|
95
|
+
Current.session = find_session_by_cookie
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_session_by_cookie
|
|
99
|
+
Session.find_by(token: cookies.signed[:session_token])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def request_authentication
|
|
103
|
+
session[:return_to_after_authenticating] = request.url
|
|
104
|
+
redirect_to new_session_path, alert: "Please sign in to continue."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def after_authentication_url
|
|
108
|
+
session.delete(:return_to_after_authenticating) || root_url
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def start_new_session_for(user)
|
|
112
|
+
user.sessions.create!.tap do |session|
|
|
113
|
+
Current.session = session
|
|
114
|
+
cookies.signed.permanent[:session_token] = {
|
|
115
|
+
value: session.token,
|
|
116
|
+
httponly: true,
|
|
117
|
+
same_site: :lax
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def terminate_session
|
|
123
|
+
Current.session.destroy
|
|
124
|
+
cookies.delete(:session_token)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Sessions Controller
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# app/controllers/sessions_controller.rb
|
|
133
|
+
class SessionsController < ApplicationController
|
|
134
|
+
allow_unauthenticated_access only: %i[new create]
|
|
135
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> {
|
|
136
|
+
redirect_to new_session_url, alert: "Too many attempts. Try again later."
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def new
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def create
|
|
143
|
+
if user = User.authenticate_by(email_address: params[:email_address], password: params[:password])
|
|
144
|
+
start_new_session_for user
|
|
145
|
+
redirect_to after_authentication_url, notice: "Signed in successfully."
|
|
146
|
+
else
|
|
147
|
+
redirect_to new_session_path, alert: "Invalid email or password."
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def destroy
|
|
152
|
+
terminate_session
|
|
153
|
+
redirect_to new_session_path, notice: "Signed out successfully."
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Registration
|
|
159
|
+
|
|
160
|
+
### Add Registration Controller
|
|
161
|
+
```ruby
|
|
162
|
+
# app/controllers/registrations_controller.rb
|
|
163
|
+
class RegistrationsController < ApplicationController
|
|
164
|
+
allow_unauthenticated_access
|
|
165
|
+
|
|
166
|
+
def new
|
|
167
|
+
@user = User.new
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def create
|
|
171
|
+
@user = User.new(user_params)
|
|
172
|
+
|
|
173
|
+
if @user.save
|
|
174
|
+
start_new_session_for @user
|
|
175
|
+
redirect_to root_path, notice: "Welcome! Your account has been created."
|
|
176
|
+
else
|
|
177
|
+
render :new, status: :unprocessable_entity
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def user_params
|
|
184
|
+
params.require(:user).permit(:email_address, :password, :password_confirmation)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Registration View
|
|
190
|
+
```erb
|
|
191
|
+
<%# app/views/registrations/new.html.erb %>
|
|
192
|
+
<div class="max-w-md mx-auto mt-10">
|
|
193
|
+
<h1 class="text-2xl font-bold mb-6">Create Account</h1>
|
|
194
|
+
|
|
195
|
+
<%= form_with model: @user, url: registration_path, class: "space-y-4" do |f| %>
|
|
196
|
+
<% if @user.errors.any? %>
|
|
197
|
+
<div class="bg-red-50 border border-red-200 rounded p-4">
|
|
198
|
+
<ul class="list-disc list-inside text-red-700 text-sm">
|
|
199
|
+
<% @user.errors.full_messages.each do |error| %>
|
|
200
|
+
<li><%= error %></li>
|
|
201
|
+
<% end %>
|
|
202
|
+
</ul>
|
|
203
|
+
</div>
|
|
204
|
+
<% end %>
|
|
205
|
+
|
|
206
|
+
<div>
|
|
207
|
+
<%= f.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
|
208
|
+
<%= f.email_field :email_address, required: true, autofocus: true,
|
|
209
|
+
class: "mt-1 w-full rounded-lg border-gray-300" %>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div>
|
|
213
|
+
<%= f.label :password, class: "block text-sm font-medium text-gray-700" %>
|
|
214
|
+
<%= f.password_field :password, required: true,
|
|
215
|
+
class: "mt-1 w-full rounded-lg border-gray-300" %>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div>
|
|
219
|
+
<%= f.label :password_confirmation, class: "block text-sm font-medium text-gray-700" %>
|
|
220
|
+
<%= f.password_field :password_confirmation, required: true,
|
|
221
|
+
class: "mt-1 w-full rounded-lg border-gray-300" %>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<%= f.submit "Create Account",
|
|
225
|
+
class: "w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg" %>
|
|
226
|
+
<% end %>
|
|
227
|
+
|
|
228
|
+
<p class="mt-4 text-center text-sm text-gray-600">
|
|
229
|
+
Already have an account?
|
|
230
|
+
<%= link_to "Sign in", new_session_path, class: "text-blue-600 hover:underline" %>
|
|
231
|
+
</p>
|
|
232
|
+
</div>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Routes
|
|
236
|
+
```ruby
|
|
237
|
+
# config/routes.rb
|
|
238
|
+
Rails.application.routes.draw do
|
|
239
|
+
resource :session, only: [:new, :create, :destroy]
|
|
240
|
+
resource :registration, only: [:new, :create]
|
|
241
|
+
resource :password, only: [:new, :create, :edit, :update]
|
|
242
|
+
|
|
243
|
+
# ...
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Login View
|
|
248
|
+
|
|
249
|
+
```erb
|
|
250
|
+
<%# app/views/sessions/new.html.erb %>
|
|
251
|
+
<div class="max-w-md mx-auto mt-10">
|
|
252
|
+
<h1 class="text-2xl font-bold mb-6">Sign In</h1>
|
|
253
|
+
|
|
254
|
+
<%= form_with url: session_path, class: "space-y-4" do |f| %>
|
|
255
|
+
<div>
|
|
256
|
+
<%= f.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
|
257
|
+
<%= f.email_field :email_address, required: true, autofocus: true,
|
|
258
|
+
class: "mt-1 w-full rounded-lg border-gray-300" %>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div>
|
|
262
|
+
<%= f.label :password, class: "block text-sm font-medium text-gray-700" %>
|
|
263
|
+
<%= f.password_field :password, required: true,
|
|
264
|
+
class: "mt-1 w-full rounded-lg border-gray-300" %>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<%= f.submit "Sign In",
|
|
268
|
+
class: "w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg" %>
|
|
269
|
+
<% end %>
|
|
270
|
+
|
|
271
|
+
<div class="mt-4 text-center text-sm text-gray-600">
|
|
272
|
+
<%= link_to "Forgot password?", new_password_path, class: "text-blue-600 hover:underline" %>
|
|
273
|
+
<span class="mx-2">|</span>
|
|
274
|
+
<%= link_to "Create account", new_registration_path, class: "text-blue-600 hover:underline" %>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Application Controller
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# app/controllers/application_controller.rb
|
|
283
|
+
class ApplicationController < ActionController::Base
|
|
284
|
+
include Authentication
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Current User Helper
|
|
289
|
+
|
|
290
|
+
```erb
|
|
291
|
+
<%# In views %>
|
|
292
|
+
<% if signed_in? %>
|
|
293
|
+
<span>Welcome, <%= Current.user.email_address %></span>
|
|
294
|
+
<%= button_to "Sign Out", session_path, method: :delete %>
|
|
295
|
+
<% else %>
|
|
296
|
+
<%= link_to "Sign In", new_session_path %>
|
|
297
|
+
<% end %>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Security Best Practices
|
|
301
|
+
|
|
302
|
+
1. **Rate limiting** - Prevent brute force attacks
|
|
303
|
+
2. **Secure cookies** - HttpOnly, SameSite, Secure in production
|
|
304
|
+
3. **Password requirements** - Minimum 8 characters
|
|
305
|
+
4. **Session management** - Token-based, revocable
|
|
306
|
+
5. **Email normalization** - Consistent email handling
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Skill: Authorization
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
Implement role-based access control using Pundit for fine-grained authorization.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
### Install Pundit
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "pundit"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle install
|
|
16
|
+
rails generate pundit:install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Include in Application Controller
|
|
20
|
+
```ruby
|
|
21
|
+
# app/controllers/application_controller.rb
|
|
22
|
+
class ApplicationController < ActionController::Base
|
|
23
|
+
include Pundit::Authorization
|
|
24
|
+
|
|
25
|
+
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def user_not_authorized
|
|
30
|
+
flash[:alert] = "You are not authorized to perform this action."
|
|
31
|
+
redirect_back(fallback_location: root_path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## User Roles
|
|
37
|
+
|
|
38
|
+
### Add Role to User
|
|
39
|
+
```ruby
|
|
40
|
+
# Migration
|
|
41
|
+
class AddRoleToUsers < ActiveRecord::Migration[8.0]
|
|
42
|
+
def change
|
|
43
|
+
add_column :users, :role, :string, default: "user", null: false
|
|
44
|
+
add_index :users, :role
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### User Model with Roles
|
|
50
|
+
```ruby
|
|
51
|
+
# app/models/user.rb
|
|
52
|
+
class User < ApplicationRecord
|
|
53
|
+
ROLES = %w[user admin moderator].freeze
|
|
54
|
+
|
|
55
|
+
validates :role, inclusion: { in: ROLES }
|
|
56
|
+
|
|
57
|
+
def admin?
|
|
58
|
+
role == "admin"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def moderator?
|
|
62
|
+
role == "moderator"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def at_least_moderator?
|
|
66
|
+
admin? || moderator?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Policies
|
|
72
|
+
|
|
73
|
+
### Application Policy (Base)
|
|
74
|
+
```ruby
|
|
75
|
+
# app/policies/application_policy.rb
|
|
76
|
+
class ApplicationPolicy
|
|
77
|
+
attr_reader :user, :record
|
|
78
|
+
|
|
79
|
+
def initialize(user, record)
|
|
80
|
+
@user = user
|
|
81
|
+
@record = record
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def index?
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def show?
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create?
|
|
93
|
+
user.present?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def new?
|
|
97
|
+
create?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def update?
|
|
101
|
+
owner_or_admin?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def edit?
|
|
105
|
+
update?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def destroy?
|
|
109
|
+
owner_or_admin?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def owner?
|
|
115
|
+
record.respond_to?(:user) && record.user == user
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def owner_or_admin?
|
|
119
|
+
owner? || user&.admin?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def admin?
|
|
123
|
+
user&.admin?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class Scope
|
|
127
|
+
def initialize(user, scope)
|
|
128
|
+
@user = user
|
|
129
|
+
@scope = scope
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve
|
|
133
|
+
scope.all
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
attr_reader :user, :scope
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Resource Policy
|
|
144
|
+
```ruby
|
|
145
|
+
# app/policies/article_policy.rb
|
|
146
|
+
class ArticlePolicy < ApplicationPolicy
|
|
147
|
+
def index?
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def show?
|
|
152
|
+
record.published? || owner_or_admin?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def create?
|
|
156
|
+
user.present?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def update?
|
|
160
|
+
owner_or_admin?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def destroy?
|
|
164
|
+
owner_or_admin?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def publish?
|
|
168
|
+
owner_or_admin?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class Scope < ApplicationPolicy::Scope
|
|
172
|
+
def resolve
|
|
173
|
+
if user&.admin?
|
|
174
|
+
scope.all
|
|
175
|
+
elsif user
|
|
176
|
+
scope.where(published: true).or(scope.where(user: user))
|
|
177
|
+
else
|
|
178
|
+
scope.where(published: true)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Admin-Only Policy
|
|
186
|
+
```ruby
|
|
187
|
+
# app/policies/admin/user_policy.rb
|
|
188
|
+
module Admin
|
|
189
|
+
class UserPolicy < ApplicationPolicy
|
|
190
|
+
def index?
|
|
191
|
+
admin?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def show?
|
|
195
|
+
admin?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def update?
|
|
199
|
+
admin? && record != user # Can't edit self
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def destroy?
|
|
203
|
+
admin? && record != user # Can't delete self
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Controller Usage
|
|
210
|
+
|
|
211
|
+
### Basic Authorization
|
|
212
|
+
```ruby
|
|
213
|
+
class ArticlesController < ApplicationController
|
|
214
|
+
def show
|
|
215
|
+
@article = Article.find(params[:id])
|
|
216
|
+
authorize @article
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def create
|
|
220
|
+
@article = current_user.articles.build(article_params)
|
|
221
|
+
authorize @article
|
|
222
|
+
|
|
223
|
+
if @article.save
|
|
224
|
+
redirect_to @article
|
|
225
|
+
else
|
|
226
|
+
render :new, status: :unprocessable_entity
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def update
|
|
231
|
+
@article = Article.find(params[:id])
|
|
232
|
+
authorize @article
|
|
233
|
+
|
|
234
|
+
if @article.update(article_params)
|
|
235
|
+
redirect_to @article
|
|
236
|
+
else
|
|
237
|
+
render :edit, status: :unprocessable_entity
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Policy Scopes
|
|
244
|
+
```ruby
|
|
245
|
+
class ArticlesController < ApplicationController
|
|
246
|
+
def index
|
|
247
|
+
@articles = policy_scope(Article).recent
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Authorize in Before Action
|
|
253
|
+
```ruby
|
|
254
|
+
class ArticlesController < ApplicationController
|
|
255
|
+
before_action :set_article, only: [:show, :edit, :update, :destroy]
|
|
256
|
+
|
|
257
|
+
def edit
|
|
258
|
+
# authorize called in set_article
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def set_article
|
|
264
|
+
@article = Article.find(params[:id])
|
|
265
|
+
authorize @article
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## View Helpers
|
|
271
|
+
|
|
272
|
+
### Conditional Display
|
|
273
|
+
```erb
|
|
274
|
+
<% if policy(@article).edit? %>
|
|
275
|
+
<%= link_to "Edit", edit_article_path(@article) %>
|
|
276
|
+
<% end %>
|
|
277
|
+
|
|
278
|
+
<% if policy(@article).destroy? %>
|
|
279
|
+
<%= button_to "Delete", @article, method: :delete %>
|
|
280
|
+
<% end %>
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### New Record Check
|
|
284
|
+
```erb
|
|
285
|
+
<% if policy(Article).create? %>
|
|
286
|
+
<%= link_to "New Article", new_article_path %>
|
|
287
|
+
<% end %>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Admin-Only Section
|
|
291
|
+
```erb
|
|
292
|
+
<% if policy([:admin, User]).index? %>
|
|
293
|
+
<%= link_to "Manage Users", admin_users_path %>
|
|
294
|
+
<% end %>
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Headless Policies
|
|
298
|
+
|
|
299
|
+
For actions not tied to a model:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
# app/policies/dashboard_policy.rb
|
|
303
|
+
class DashboardPolicy < ApplicationPolicy
|
|
304
|
+
def initialize(user, _record = nil)
|
|
305
|
+
@user = user
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def show?
|
|
309
|
+
user.present?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def admin?
|
|
313
|
+
user&.admin?
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
# Controller
|
|
320
|
+
class DashboardController < ApplicationController
|
|
321
|
+
def show
|
|
322
|
+
authorize :dashboard, :show?
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def admin
|
|
326
|
+
authorize :dashboard, :admin?
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Testing Policies
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
# spec/policies/article_policy_spec.rb
|
|
335
|
+
require "rails_helper"
|
|
336
|
+
|
|
337
|
+
RSpec.describe ArticlePolicy do
|
|
338
|
+
subject { described_class }
|
|
339
|
+
|
|
340
|
+
let(:user) { create(:user) }
|
|
341
|
+
let(:admin) { create(:user, role: "admin") }
|
|
342
|
+
let(:article) { create(:article, user: user) }
|
|
343
|
+
let(:other_article) { create(:article) }
|
|
344
|
+
|
|
345
|
+
permissions :update?, :destroy? do
|
|
346
|
+
it "allows owner" do
|
|
347
|
+
expect(subject).to permit(user, article)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it "denies non-owner" do
|
|
351
|
+
expect(subject).not_to permit(user, other_article)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "allows admin" do
|
|
355
|
+
expect(subject).to permit(admin, other_article)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
describe "Scope" do
|
|
360
|
+
let!(:published) { create(:article, published: true) }
|
|
361
|
+
let!(:draft) { create(:article, published: false) }
|
|
362
|
+
let!(:own_draft) { create(:article, published: false, user: user) }
|
|
363
|
+
|
|
364
|
+
it "shows only published to guests" do
|
|
365
|
+
scope = ArticlePolicy::Scope.new(nil, Article).resolve
|
|
366
|
+
expect(scope).to contain_exactly(published)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "shows published + own drafts to users" do
|
|
370
|
+
scope = ArticlePolicy::Scope.new(user, Article).resolve
|
|
371
|
+
expect(scope).to contain_exactly(published, own_draft)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "shows all to admin" do
|
|
375
|
+
scope = ArticlePolicy::Scope.new(admin, Article).resolve
|
|
376
|
+
expect(scope).to contain_exactly(published, draft, own_draft)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Best Practices
|
|
383
|
+
|
|
384
|
+
1. **Authorize early** - Call authorize at the start of actions
|
|
385
|
+
2. **Use scopes** - Filter collections with policy_scope
|
|
386
|
+
3. **Test policies** - Write specs for all permission scenarios
|
|
387
|
+
4. **Keep policies focused** - One policy per resource
|
|
388
|
+
5. **Use permitted_attributes** - Let policy control params
|