access_grant 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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.codegraph/.gitignore +5 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +98 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +33 -0
  7. data/CONTRIBUTING.md +99 -0
  8. data/Gemfile +11 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +123 -0
  11. data/Rakefile +12 -0
  12. data/docs/architecture.md +1157 -0
  13. data/docs/proposal.md +143 -0
  14. data/docs/superpowers/plans/2026-09-08-access-grant-v1.md +468 -0
  15. data/docs/superpowers/plans/2026-09-08-gem-release.md +367 -0
  16. data/docs/superpowers/specs/2026-09-05-owner-role-design.md +271 -0
  17. data/docs/superpowers/specs/2026-09-07-proposal-review.md +71 -0
  18. data/docs/superpowers/specs/2026-09-07-usage-scenarios.md +301 -0
  19. data/docs/superpowers/specs/2026-09-08-gem-release-design.md +82 -0
  20. data/lib/access_grant/catalog/dsl.rb +138 -0
  21. data/lib/access_grant/catalog.rb +76 -0
  22. data/lib/access_grant/configuration.rb +55 -0
  23. data/lib/access_grant/controller_methods.rb +104 -0
  24. data/lib/access_grant/models/permission.rb +36 -0
  25. data/lib/access_grant/models/role.rb +152 -0
  26. data/lib/access_grant/models/role_permission.rb +11 -0
  27. data/lib/access_grant/owner.rb +144 -0
  28. data/lib/access_grant/permission_key.rb +29 -0
  29. data/lib/access_grant/railtie.rb +17 -0
  30. data/lib/access_grant/recovery.rb +90 -0
  31. data/lib/access_grant/sync.rb +68 -0
  32. data/lib/access_grant/tenant.rb +47 -0
  33. data/lib/access_grant/user.rb +102 -0
  34. data/lib/access_grant/version.rb +5 -0
  35. data/lib/access_grant.rb +125 -0
  36. data/lib/generators/access_grant/install/install_generator.rb +22 -0
  37. data/lib/generators/access_grant/install/templates/create_access_grant_tables.rb.tt +39 -0
  38. data/lib/generators/access_grant/setup/setup_generator.rb +188 -0
  39. data/lib/generators/access_grant/setup/templates/access_grant.rb.tt +80 -0
  40. data/lib/generators/access_grant/setup/templates/create_access_grant_user_roles.rb.tt +14 -0
  41. data/lib/generators/access_grant/setup/templates/permissions.rb.tt +10 -0
  42. data/lib/generators/access_grant/setup/templates/roles.rb.tt +27 -0
  43. data/lib/tasks/access_grant_tasks.rake +27 -0
  44. metadata +121 -0
data/docs/proposal.md ADDED
@@ -0,0 +1,143 @@
1
+ # AccessGrant: Proposal
2
+
3
+ > Status: **design finalized** for v1 shape. No engine code in this repo yet.
4
+ > This document is the "why" and "what"; see [architecture.md](architecture.md)
5
+ > for the "how" and resolved public-contract decisions.
6
+
7
+ ## Problem statement
8
+
9
+ Rails has no canonical gem for the pattern where **roles and permissions live
10
+ in the database, and application/tenant admins can create roles and change
11
+ what those roles are allowed to do at runtime, without a code deploy**. This
12
+ is the pattern popularized in the Laravel ecosystem by
13
+ [`spatie/laravel-permission`](https://github.com/spatie/laravel-permission),
14
+ and it is a real, common product requirement: an org admin adds a new
15
+ "Billing Viewer" role and decides it should see invoices but not edit them —
16
+ today, in Rails, that requires a developer to open a pull request.
17
+
18
+ Every mainstream Rails authorization gem instead pushes permission logic into
19
+ Ruby code:
20
+
21
+ - A **policy class** (Pundit, Action Policy) or an **`Ability` class**
22
+ (CanCanCan) hardcodes which permissions exist and what each role/user can
23
+ do. Changing "who can do what" means editing and redeploying that class.
24
+ - These libraries are excellent at *enforcing* authorization decisions
25
+ (`authorize!`, `can?`, `policy.edit?`) but say nothing about *where the
26
+ decision data lives* — that's left entirely to the app.
27
+
28
+ The one gem that does model roles as data — [Rolify](https://github.com/rolifycommunity/rolify)
29
+ — solves the *role assignment* half of the problem (a `roles` table, a join
30
+ table, `add_role`/`has_role?`, resource-scoped roles) but, by its own
31
+ documentation, is "a simple roles library without any authorization
32
+ enforcement." It has no concept of a permission at all: no permission
33
+ catalog, no `role_permissions` table, no `permitted?(:key)` method, nothing
34
+ that decides what a role can actually *do*. Rolify is typically paired with
35
+ CanCanCan or Pundit, at which point the permission logic is right back to
36
+ being hardcoded in an `Ability`/policy class — the exact problem this project
37
+ exists to solve.
38
+
39
+ **AccessGrant's contribution is the piece none of these provide**: a
40
+ database-backed permission catalog, a `role_permissions` mapping that tenant
41
+ admins can edit at runtime, and a `permitted?(key)` check — built as its own
42
+ Rails engine rather than as a layer bolted onto Rolify, since building that
43
+ mapping is the actual work regardless of what handles role assignment
44
+ underneath it.
45
+
46
+ ## Prior art / competitive analysis
47
+
48
+ | Gem | Roles as data | Permission catalog | Runtime-editable by admins | Per-tenant scoping | Enforcement primitives |
49
+ |---|---|---|---|---|---|
50
+ | **Pundit** | No (policy classes) | No | No — requires a deploy | App-defined, not built in | Yes (`authorize!`, `policy.action?`) |
51
+ | **CanCanCan** | No (`Ability` class) | No | No — requires a deploy | App-defined, not built in | Yes (`can?`, `authorize!`) |
52
+ | **Action Policy** | No (policy classes) | No | No — requires a deploy | App-defined, not built in | Yes (`allowed_to?`, `authorize!`) |
53
+ | **Rolify** | Yes (`roles` table, join table, resource-scoped roles) | No — no concept of permissions at all | N/A (nothing to edit — no permission model) | Partial (resource-scoped roles), no tenant concept | No — "a simple roles library without any authorization enforcement" (Rolify's own description) |
54
+ | **AccessGrant** | Yes (from scratch, not on Rolify) | Yes — code-defined catalog, synced into the DB | Yes — role→permission mapping is fully runtime/admin-controlled | Yes — first-class tenant concept, admin control is per-tenant | Yes — `permitted?(key)` |
55
+
56
+ ### Why not build AccessGrant on top of Pundit?
57
+
58
+ Evaluated and rejected for v1 (**foundation = from scratch**).
59
+
60
+ Pundit enforces decisions in **policy classes** (`authorize @record`).
61
+ AccessGrant’s product requirement is a **database-backed** catalog and
62
+ runtime-editable role→permission map. Putting the gem “on top of Pundit”
63
+ would still require building 100% of that data model, while forcing every
64
+ host to depend on Pundit and maintain two mental models (policy files + DB
65
+ roles).
66
+
67
+ Hosts that already implemented this workflow with Pundit can migrate
68
+ incrementally: thin policies that call `permitted?("resource.action",
69
+ tenant:)`, then drop Pundit if the controller hook is enough. That migration
70
+ path is documentation, not a runtime dependency.
71
+
72
+ ### Why not just add a `role_permissions` table on top of Rolify?
73
+
74
+ This was evaluated and rejected. Rolify's actual surface area — a roles
75
+ table, a role-assignment join table, and `add_role`/`has_role?` — is small
76
+ and directly reimplementable to fit exactly this gem's tenant/permission
77
+ model (see [architecture.md](architecture.md)). Adopting Rolify as a runtime
78
+ dependency would still require building 100% of AccessGrant's actual
79
+ contribution (the permission catalog, `role_permissions`, `permitted?`) on
80
+ top of it, while adding an external dependency whose own scoping model
81
+ (global/class-scoped/instance-scoped roles) doesn't line up cleanly with the
82
+ tenant-scoped model this gem needs. Building roles and role-assignment
83
+ directly keeps the whole data model — tenant, roles, permissions, and their
84
+ joins — coherent and fully owned.
85
+
86
+ ## Confirmed product requirements
87
+
88
+ - **Permission catalog is code-defined, not admin-creatable.** A new
89
+ capability always requires a code change (adding a key to the catalog)
90
+ before it can be granted to anyone. Admins cannot invent permission keys
91
+ from a UI.
92
+ - **Roles are fully dynamic and runtime-editable.** Tenant admins can create,
93
+ rename, delete roles, and decide which catalog permissions each role
94
+ grants — at runtime, no deploy. Ordinary seeded roles (e.g.
95
+ Admin/Manager/Worker) are not special-cased; they are editable and
96
+ deletable like any other role. **Owner** is the exception: an optional
97
+ privileged role whose mechanism is host-configured
98
+ (`:protected` / `:bypass` / `:both` / `:none`). See
99
+ [architecture.md](architecture.md#owner-role) and the
100
+ [Owner design spec](superpowers/specs/2026-09-05-owner-role-design.md).
101
+ - **Many-to-many roles per user** is a hard requirement, not a single
102
+ role column. The user model is host-named (e.g. `User`); the gem does
103
+ not require a Membership model.
104
+ - **Configurable Owner floor; host-owned assignment.** When Owner is
105
+ enabled, it is the in-app floor (explicit permissions and/or a
106
+ `permitted?` short-circuit, per config). The host assigns Owner by
107
+ calling `grant_owner!(user)` after creating a tenant (or in seeds for
108
+ single-tenant) — the gem does not auto-detect creators. When Owner is
109
+ `:none`, recovery is the operational rake task only. There is still no
110
+ ambient bypass outside the configured Owner rules.
111
+ - **Per-tenant scoping of admin control.** Who can assign permissions to
112
+ roles, and roles to people, is controllable per tenant — not a single
113
+ global permission matrix shared across every tenant using the host app.
114
+ - **Ships as a mountable Rails engine with generators**, following the
115
+ Devise/Pundit convention: `rails g access_grant:install` copies migrations
116
+ into the host app; the gem supplies models, concerns, and (optionally,
117
+ later) controller helpers.
118
+ - **License**: MIT. **Ruby**: >= 3.1. **Rails**: >= 7.0.
119
+
120
+ ## Non-goals for v1
121
+
122
+ - **NoSQL support.** This gem targets ActiveRecord/SQL only for v1. If a
123
+ NoSQL-backed variant is ever warranted, the intent is a separate gem (e.g.
124
+ `access_grant_mongo`) rather than bolting a second persistence layer onto
125
+ this one — see [architecture.md](architecture.md#non-goals--future-considerations)
126
+ for the reasoning.
127
+ - **Admin-facing UI, controllers, or serializers.** The gem may optionally
128
+ ship these later; v1 is the data model, extension points, and enforcement
129
+ primitive only.
130
+ - **KaamSathi integration.** KaamSathi (the motivating consumer) migrating
131
+ its controllers and `Organization` / user models onto this gem is
132
+ explicit future, separate work — not designed or scheduled here.
133
+ - **Auto-granting Owner from request ambient state.** Detecting the org
134
+ creator (`Current.user`, creator associations, etc.) is host-app
135
+ responsibility; the gem only exposes `grant_owner!`.
136
+
137
+ ## Where to go next
138
+
139
+ See [architecture.md](architecture.md) for the resolved data model,
140
+ extension points, proposed DSL, design guardrails, and
141
+ [resolved public-contract decisions](architecture.md#resolved-public-contract-decisions).
142
+ Acceptance scenarios:
143
+ [superpowers/specs/2026-09-07-usage-scenarios.md](superpowers/specs/2026-09-07-usage-scenarios.md).
@@ -0,0 +1,468 @@
1
+ # AccessGrant v1 Core Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship a working AccessGrant Rails gem: configurable tables, catalog DSL + sync, `access_grant :tenant` / `:user`, `permitted?`, Owner grant/revoke, generators, authorize hook, and recovery rake task.
6
+
7
+ **Architecture:** Pure ActiveRecord models + Ruby modules (no Pundit/Rolify). Host DB owns tables. Catalog is code (`permissions.rb`) synced by rake. Checks are SQL unions through user↔role↔permission joins. Controller hook maps `controller#action` → `resource.action` using `current_user` / `current_tenant`.
8
+
9
+ **Tech Stack:** Ruby >= 3.1, Rails/ActiveRecord >= 7.0, RSpec, SQLite for gem tests, Rails generators.
10
+
11
+ **Spec:** [docs/architecture.md](../architecture.md), [docs/superpowers/specs/2026-09-05-owner-role-design.md](../specs/2026-09-05-owner-role-design.md), [docs/superpowers/specs/2026-09-07-usage-scenarios.md](../specs/2026-09-07-usage-scenarios.md)
12
+
13
+ ## Global Constraints
14
+
15
+ - Ruby >= 3.1; Rails/ActiveRecord >= 7.0
16
+ - No Pundit/Rolify/CanCanCan dependencies
17
+ - Permission keys: `\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z` only
18
+ - Identity naming: **user** (`access_grant :user`, `user_class`, `current_user`)
19
+ - Multi-tenant: `permitted?(key, tenant:)` required; single-tenant: omit tenant
20
+ - No gem-level memoization of `permitted?`
21
+ - Permission description/category: sync-only; role description: admin-editable
22
+ - Production code budget aim: ~1000 lines (generators/templates included as soft budget)
23
+ - TDD: failing test before implementation for every behavior task
24
+ - Commits: only when the human asks (or at task end if they opted into frequent commits during execution)
25
+
26
+ ## File structure (target)
27
+
28
+ ```
29
+ lib/access_grant.rb
30
+ lib/access_grant/version.rb
31
+ lib/access_grant/engine.rb
32
+ lib/access_grant/configuration.rb
33
+ lib/access_grant/permission_key.rb
34
+ lib/access_grant/catalog.rb
35
+ lib/access_grant/catalog/dsl.rb
36
+ lib/access_grant/models/permission.rb
37
+ lib/access_grant/models/role.rb
38
+ lib/access_grant/models/role_permission.rb
39
+ lib/access_grant/tenant.rb # access_grant :tenant
40
+ lib/access_grant/user.rb # access_grant :user + permitted?
41
+ lib/access_grant/owner.rb
42
+ lib/access_grant/recovery.rb
43
+ lib/access_grant/controller_methods.rb
44
+ lib/access_grant/railtie.rb
45
+ lib/generators/access_grant/install/install_generator.rb
46
+ lib/generators/access_grant/setup/setup_generator.rb
47
+ lib/generators/access_grant/install/templates/...
48
+ lib/generators/access_grant/setup/templates/...
49
+ lib/tasks/access_grant_tasks.rake
50
+ spec/support/active_record.rb
51
+ spec/access_grant/...
52
+ ```
53
+
54
+ ---
55
+
56
+ ### Task 1: Gem dependencies + ActiveRecord test harness
57
+
58
+ **Files:**
59
+ - Modify: `access_grant.gemspec`
60
+ - Modify: `Gemfile`
61
+ - Create: `spec/support/active_record.rb`
62
+ - Modify: `spec/spec_helper.rb`
63
+
64
+ **Produces:** SQLite in-memory AR connection usable by later model specs.
65
+
66
+ - [ ] **Step 1: Add runtime/dev dependencies**
67
+
68
+ In gemspec:
69
+
70
+ ```ruby
71
+ spec.add_dependency "activerecord", ">= 7.0"
72
+ spec.add_dependency "railties", ">= 7.0"
73
+
74
+ spec.add_development_dependency "sqlite3", ">= 1.4"
75
+ ```
76
+
77
+ In Gemfile keep `gemspec` and existing test gems.
78
+
79
+ - [ ] **Step 2: Write failing smoke that AR connects**
80
+
81
+ ```ruby
82
+ # spec/support/active_record_spec.rb
83
+ RSpec.describe "ActiveRecord test harness" do
84
+ it "connects" do
85
+ expect(ActiveRecord::Base.connection).to be_active
86
+ end
87
+ end
88
+ ```
89
+
90
+ - [ ] **Step 3: Implement `spec/support/active_record.rb` and require it from spec_helper**
91
+
92
+ ```ruby
93
+ require "active_record"
94
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
95
+ ActiveRecord::Base.logger = Logger.new(IO::NULL)
96
+ ```
97
+
98
+ Run: `bundle install && bundle exec rspec spec/support/active_record_spec.rb`
99
+
100
+ - [ ] **Step 4: Commit if human requested commits**
101
+
102
+ ---
103
+
104
+ ### Task 2: Configuration object
105
+
106
+ **Files:**
107
+ - Create: `lib/access_grant/configuration.rb`
108
+ - Modify: `lib/access_grant.rb`
109
+ - Test: `spec/access_grant/configuration_spec.rb`
110
+
111
+ **Produces:** `AccessGrant.configure` / `AccessGrant.config` with defaults from architecture.
112
+
113
+ - [ ] **Step 1: Failing spec for defaults**
114
+
115
+ ```ruby
116
+ RSpec.describe AccessGrant::Configuration do
117
+ it "defaults owner_role to :protected and user_class to User" do
118
+ config = described_class.new
119
+ expect(config.owner_role).to eq(:protected)
120
+ expect(config.owner_role_name).to eq("Owner")
121
+ expect(config.user_class).to eq("User")
122
+ expect(config.tenant_class).to be_nil
123
+ expect(config.default_permission_actions).to eq(%w[index show create update destroy])
124
+ expect(config.tables).to include(
125
+ roles: "roles",
126
+ permissions: "permissions",
127
+ role_permissions: "role_permissions",
128
+ user_roles: "user_roles"
129
+ )
130
+ end
131
+ end
132
+ ```
133
+
134
+ - [ ] **Step 2: Implement Configuration + AccessGrant.configure**
135
+
136
+ ```ruby
137
+ module AccessGrant
138
+ class Configuration
139
+ attr_accessor :tenant_class, :user_class, :owner_role, :owner_role_name,
140
+ :tables, :default_permission_actions, :current_user_method,
141
+ :current_tenant_method, :on_tenant_created, :recover_access
142
+
143
+ def initialize
144
+ @user_class = "User"
145
+ @owner_role = :protected
146
+ @owner_role_name = "Owner"
147
+ @default_permission_actions = %w[index show create update destroy]
148
+ @current_user_method = :current_user
149
+ @current_tenant_method = :current_tenant
150
+ @tables = {
151
+ roles: "roles",
152
+ permissions: "permissions",
153
+ role_permissions: "role_permissions",
154
+ user_roles: "user_roles"
155
+ }
156
+ end
157
+ end
158
+
159
+ def self.config = @config ||= Configuration.new
160
+ def self.configure = yield(config)
161
+ def self.reset_config! = @config = Configuration.new
162
+ end
163
+ ```
164
+
165
+ Reset config in `RSpec.before` for isolation.
166
+
167
+ ---
168
+
169
+ ### Task 3: PermissionKey validation
170
+
171
+ **Files:**
172
+ - Create: `lib/access_grant/permission_key.rb`
173
+ - Test: `spec/access_grant/permission_key_spec.rb`
174
+
175
+ **Produces:** `AccessGrant::PermissionKey.normalize!` / `valid?`
176
+
177
+ - [ ] **Step 1: Failing specs**
178
+
179
+ ```ruby
180
+ expect(AccessGrant::PermissionKey.normalize!("invoices.index")).to eq("invoices.index")
181
+ expect(AccessGrant::PermissionKey.normalize!(:invoices_update)).to raise... # invalid
182
+ expect { AccessGrant::PermissionKey.normalize!("Invoices.Index") }.to raise_error(AccessGrant::Error)
183
+ expect { AccessGrant::PermissionKey.normalize!("manage_billing") }.to raise_error(AccessGrant::Error)
184
+ ```
185
+
186
+ Pattern: `/\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z/`
187
+
188
+ - [ ] **Step 2: Implement and pass**
189
+
190
+ ---
191
+
192
+ ### Task 4: Catalog DSL
193
+
194
+ **Files:**
195
+ - Create: `lib/access_grant/catalog.rb`
196
+ - Create: `lib/access_grant/catalog/dsl.rb`
197
+ - Test: `spec/access_grant/catalog_spec.rb`
198
+
199
+ **Produces:** `AccessGrant.permissions { resource :invoices; action :couple, description: "..." }` → enumerable entries `{ key:, description:, category: }`
200
+
201
+ - [ ] **Step 1: Spec — resource emits default actions with templates**
202
+
203
+ ```ruby
204
+ AccessGrant.permissions do
205
+ resource :invoices
206
+ end
207
+ entries = AccessGrant.catalog.entries
208
+ expect(entries.map { |e| e[:key] }).to include("invoices.index", "invoices.destroy")
209
+ expect(entries.find { |e| e[:key] == "invoices.index" }[:description]).to match(/list/i)
210
+ expect(entries.find { |e| e[:key] == "invoices.index" }[:category]).to eq("invoices")
211
+ ```
212
+
213
+ - [ ] **Step 2: Spec — custom action + category block + invalid key raises**
214
+
215
+ - [ ] **Step 3: Implement DSL**
216
+
217
+ `resource` name: pluralize for key segment (`:invoice` / `:invoices` → `invoices`). Use ActiveSupport inflector.
218
+
219
+ Default description templates (architecture table). Clear catalog on each `AccessGrant.permissions` block (replace, don't append) unless documented otherwise — **replace** for predictability.
220
+
221
+ ---
222
+
223
+ ### Task 5: Schema helpers + Permission / Role / RolePermission models
224
+
225
+ **Files:**
226
+ - Create: `lib/access_grant/models/permission.rb`
227
+ - Create: `lib/access_grant/models/role.rb`
228
+ - Create: `lib/access_grant/models/role_permission.rb`
229
+ - Create: `spec/support/schema.rb` (create tables using `AccessGrant.config.tables`)
230
+ - Test: `spec/access_grant/models/permission_spec.rb`, `role_spec.rb`
231
+
232
+ **Produces:** AR models with `self.table_name` from config; Permission validates key; Role `permission_keys=` atomic replace; case-insensitive name uniqueness scoped by tenant_id.
233
+
234
+ - [ ] **Step 1: Schema support**
235
+
236
+ ```ruby
237
+ ActiveRecord::Schema.define do
238
+ create_table AccessGrant.config.tables[:permissions], force: true do |t|
239
+ t.string :key, null: false
240
+ t.text :description
241
+ t.string :category
242
+ t.timestamps
243
+ end
244
+ add_index ..., :key, unique: true
245
+ # roles, role_permissions similarly
246
+ end
247
+ ```
248
+
249
+ - [ ] **Step 2: Permission model specs (valid key, reject bad key)**
250
+
251
+ - [ ] **Step 3: Role `#permission_keys=` atomic replace spec**
252
+
253
+ ```ruby
254
+ role.permission_keys = %w[invoices.index invoices.update]
255
+ expect(role.permission_keys).to match_array(%w[invoices.index invoices.update])
256
+ expect {
257
+ role.permission_keys = %w[bad]
258
+ }.to raise_error
259
+ expect(role.reload.permission_keys).to match_array(%w[invoices.index invoices.update])
260
+ ```
261
+
262
+ - [ ] **Step 4: Implement models**
263
+
264
+ `Role` belongs_to tenant optionally (polymorphic **or** configured class — prefer `belongs_to :tenant, polymorphic: true, optional: true` for test flexibility, matching multi-tenant FK in real migrations as `organization_id` when setup runs — **decision for generators:** concrete FK column named after tenant model; models use `belongs_to :tenant, class_name: config.tenant_class` with foreign_key inferred).
265
+
266
+ For gem models in tests without a host tenant class, use polymorphic `tenant` (`tenant_type`, `tenant_id`) **or** integer `tenant_id` only. Architecture says concrete FK (`organization_id`). Prefer **concrete foreign key via config** at setup time; for in-gem Role model:
267
+
268
+ ```ruby
269
+ # Role uses tenant_id + optional tenant_type if polymorphic;
270
+ # v1 generators create organization_id (or configured name) WITHOUT polymorphic.
271
+ ```
272
+
273
+ **v1 lock-in:** Role has `tenant_id` bigint nullable (null = single-tenant / global). Host generator adds FK + index. Association:
274
+
275
+ ```ruby
276
+ belongs_to :tenant, class_name: AccessGrant.config.tenant_class, optional: true
277
+ ```
278
+
279
+ When `tenant_class` nil, skip association definition or use a no-op.
280
+
281
+ ---
282
+
283
+ ### Task 6: Catalog sync
284
+
285
+ **Files:**
286
+ - Create: `lib/access_grant/sync.rb`
287
+ - Create: `lib/tasks/access_grant_tasks.rake`
288
+ - Test: `spec/access_grant/sync_spec.rb`
289
+
290
+ **Produces:** `AccessGrant::Sync.call` upserts catalog; never deletes; for `:protected`/`:both` re-attaches all keys to Owner roles.
291
+
292
+ - [ ] **Step 1: Spec upsert + no delete of orphaned DB keys**
293
+
294
+ - [ ] **Step 2: Spec Owner reattach when owner_role is :protected**
295
+
296
+ - [ ] **Step 3: Implement + rake `access_grant:sync_permissions`**
297
+
298
+ ---
299
+
300
+ ### Task 7: `access_grant :user` + `permitted?`
301
+
302
+ **Files:**
303
+ - Create: `lib/access_grant/user.rb`
304
+ - Modify: load DSL on ActiveRecord::Base
305
+ - Test: `spec/access_grant/user_spec.rb`
306
+ - Support: minimal `User` + `Organization` + `user_roles` table in schema
307
+
308
+ **Produces:**
309
+
310
+ ```ruby
311
+ class User < ActiveRecord::Base
312
+ access_grant :user
313
+ end
314
+ user.permitted?("invoices.index", tenant: org) # true/false
315
+ ```
316
+
317
+ - [ ] **Step 1: Specs**
318
+
319
+ - missing tenant in multi-tenant (`tenant_class` set) → raise
320
+ - tenant supplied in single-tenant (`tenant_class` nil) → raise
321
+ - unknown/malformed key → raise (all owner modes)
322
+ - grant via role → true; other tenant → false
323
+ - union of two roles
324
+
325
+ - [ ] **Step 2: Implement join association + SQL exists/query**
326
+
327
+ ```ruby
328
+ # Pseudo
329
+ roles for tenant → role_permissions → permissions.where(key: key).exists?
330
+ ```
331
+
332
+ ---
333
+
334
+ ### Task 8: `access_grant :tenant` + Owner API
335
+
336
+ **Files:**
337
+ - Create: `lib/access_grant/tenant.rb`
338
+ - Create: `lib/access_grant/owner.rb`
339
+ - Test: `spec/access_grant/owner_spec.rb`
340
+
341
+ **Produces:** `org.grant_owner!(user)`, `org.revoke_owner!(user)`, last-Owner protection, reserved name, mechanisms `:protected` / `:bypass` / `:both` / `:none`.
342
+
343
+ - [ ] **Step 1: Specs per architecture Owner table**
344
+
345
+ - `:none` → `grant_owner!` raises
346
+ - `:protected` → Owner has all catalog keys; strip/delete blocked
347
+ - `:bypass` → `permitted?` short-circuits true for known valid keys when user has Owner role
348
+ - multiple owners; revoke last → raise
349
+ - `on_tenant_created` callback after create
350
+
351
+ - [ ] **Step 2: Implement**
352
+
353
+ Single-tenant: `AccessGrant.grant_owner!(user)` when no tenant_class.
354
+
355
+ Owner name reserved: Role validation rejects ordinary create/rename to owner name when mode ≠ `:none`.
356
+
357
+ ---
358
+
359
+ ### Task 9: Recovery
360
+
361
+ **Files:**
362
+ - Create: `lib/access_grant/recovery.rb`
363
+ - Extend rake task `access_grant:grant_role`
364
+ - Test: `spec/access_grant/recovery_spec.rb`
365
+
366
+ **Produces:** Env-based rake `ROLE=Owner USER_ID=1 TENANT_ID=2 rake access_grant:grant_role` calling `config.recover_access` or default `Recovery.grant_role!`.
367
+
368
+ ---
369
+
370
+ ### Task 10: Controller authorize hook
371
+
372
+ **Files:**
373
+ - Create: `lib/access_grant/controller_methods.rb`
374
+ - Test: `spec/access_grant/controller_methods_spec.rb` (lightweight controller class)
375
+
376
+ **Produces:** `access_grant_authorize!`, `skip_access_grant_authorize!`, maps to catalog key via resource inflection from controller path (`InvoicesController` → `invoices` + `action_name`).
377
+
378
+ - [ ] **Step 1: Spec index → invoices.index; couple → invoices.couple when declared**
379
+
380
+ - [ ] **Step 2: Uses `send(config.current_user_method)` and `send(config.current_tenant_method)`**
381
+
382
+ - [ ] **Step 3: Raise / head 403 on failure — use `AccessGrant::NotAuthorizedError` rescued in Railtie optional; for v1 raise error and document host rescue**
383
+
384
+ ---
385
+
386
+ ### Task 11: Generators (install + setup)
387
+
388
+ **Files:**
389
+ - `lib/generators/access_grant/install/...`
390
+ - `lib/generators/access_grant/setup/...`
391
+ - Templates for migrations, initializer, `permissions.rb`, `roles.rb`
392
+ - Test: generator specs with `rails/generators/test_case` if feasible; else manual checklist in plan execution notes
393
+
394
+ **Produces:**
395
+
396
+ - `rails g access_grant:install` — migration for permissions, roles (name + nullable tenant_id), role_permissions
397
+ - `rails g access_grant:setup --multi-tenant --tenant=Organization --user=User --owner-role=protected --tables=auto`
398
+ - collision-aware table names
399
+ - write **fully commented** initializer documenting every `config.*` option with defaults and examples (mirror architecture Configuration reference)
400
+ - write `permissions.rb` / `roles.rb`
401
+ - patch models
402
+ - print deploy sync reminder
403
+
404
+ Also add YARD (or RDoc) comments on each `Configuration` attribute in
405
+ `lib/access_grant/configuration.rb` so IDE hover help matches the docs.
406
+
407
+ **Template requirement for `config/initializers/access_grant.rb`:** every
408
+ option from the architecture Configuration reference appears as an
409
+ assignment or a commented example (`tenant_class`, `user_class`,
410
+ `owner_role`, `owner_role_name`, `tables`, `default_permission_actions`,
411
+ `current_user_method`, `current_tenant_method`, `on_tenant_created`,
412
+ `recover_access`).
413
+
414
+ ---
415
+
416
+ ### Task 12: Engine/Railtie load + README smoke path
417
+
418
+ **Files:**
419
+ - Create: `lib/access_grant/engine.rb` or `railtie.rb`
420
+ - Modify: `lib/access_grant.rb` requires
421
+ - Update: `CHANGELOG.md` Unreleased notes
422
+
423
+ **Produces:** `require "access_grant"` loads rake tasks and ActiveSupport.on_load hooks for `access_grant` macro.
424
+
425
+ ---
426
+
427
+ ### Task 13: Acceptance scenario smoke (subset)
428
+
429
+ **Files:**
430
+ - Create: `spec/integration/happy_path_spec.rb`
431
+
432
+ Cover scenario IDs from inventory where feasible in-gem: S023-ish ordinary role, S043/S044 checks, S088–S090 Owner, sync idempotence.
433
+
434
+ Mark Covered scenarios exercised in a short comment header listing IDs.
435
+
436
+ ---
437
+
438
+ ## Spec coverage checklist (self-review)
439
+
440
+ | Architecture area | Task |
441
+ |---|---|
442
+ | Config / tables / user naming | 2, 11 |
443
+ | Permission key format | 3, 4, 5 |
444
+ | Catalog DSL + templates + no auto-discover | 4 |
445
+ | Sync + Owner reattach | 6, 8 |
446
+ | permitted? edge cases | 7 |
447
+ | Owner mechanisms + last owner | 8 |
448
+ | Recovery rake | 9 |
449
+ | Controller hook + current_user/tenant | 10 |
450
+ | Generators + deploy reminder + commented config reference | 11 |
451
+ | Row filters host-owned | docs only (no gem task) |
452
+
453
+ ## Placeholder scan
454
+
455
+ No TBD steps; generator collision algorithm detail lives in Task 11 implementation (check `connection.table_exists?` + `Object.const_defined?`).
456
+
457
+ ---
458
+
459
+ ## Execution handoff
460
+
461
+ Plan complete and saved to `docs/superpowers/plans/2026-09-08-access-grant-v1.md`.
462
+
463
+ **Two execution options:**
464
+
465
+ 1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks
466
+ 2. **Inline Execution** — execute tasks in this session with checkpoints
467
+
468
+ **Which approach?**