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
@@ -0,0 +1,1157 @@
1
+ # AccessGrant: Architecture & Design
2
+
3
+ > Status: **design finalized** (docs only — not yet implemented). Ready for
4
+ > an implementation plan. See [proposal.md](proposal.md) for the problem this
5
+ > solves. Owner:
6
+ > [superpowers/specs/2026-09-05-owner-role-design.md](superpowers/specs/2026-09-05-owner-role-design.md).
7
+ > Acceptance inventory:
8
+ > [superpowers/specs/2026-09-07-usage-scenarios.md](superpowers/specs/2026-09-07-usage-scenarios.md).
9
+
10
+ ## Data model
11
+
12
+ All tables are ActiveRecord/SQL, owned by the host app's database (this gem
13
+ ships migrations via a generator; it does not run its own separate
14
+ database).
15
+
16
+ ```
17
+ tenant (host app model, e.g. Organization) — multi-tenant only
18
+ └─┬─ roles (or access_grant_roles) (belongs_to tenant when multi-tenant;
19
+ │ global when single-tenant)
20
+ └─┬─ role_permissions (or prefixed)
21
+ └── permissions (or prefixed) (catalog: key, description, category)
22
+
23
+ user (host app model, e.g. User)
24
+ └── user_roles (or access_grant_user_roles / configured name)
25
+ ```
26
+
27
+ ### Class diagram
28
+
29
+ Host models are outside the gem (`Organization`, `User`). Gem models use
30
+ configurable table names; associations stay `roles` / `permissions`.
31
+
32
+ ```mermaid
33
+ classDiagram
34
+ direction LR
35
+
36
+ class Organization {
37
+ <<host :tenant>>
38
+ +id
39
+ +grant_owner!(user)
40
+ +revoke_owner!(user)
41
+ }
42
+
43
+ class User {
44
+ <<host :user>>
45
+ +id
46
+ +permitted?(key, tenant)
47
+ }
48
+
49
+ class Role {
50
+ <<AccessGrant::Role>>
51
+ +id
52
+ +name
53
+ +description
54
+ +tenant_id
55
+ }
56
+
57
+ class Permission {
58
+ <<AccessGrant::Permission>>
59
+ +id
60
+ +key
61
+ +description
62
+ +category
63
+ }
64
+
65
+ class RolePermission {
66
+ <<join>>
67
+ +role_id
68
+ +permission_id
69
+ }
70
+
71
+ class UserRole {
72
+ <<join user_roles>>
73
+ +user_id
74
+ +role_id
75
+ }
76
+
77
+ Organization "1" --> "*" Role : has_many
78
+ Role "*" --> "*" Permission : through RolePermission
79
+ Role --> RolePermission
80
+ Permission --> RolePermission
81
+ User "*" --> "*" Role : through UserRole
82
+ User --> UserRole
83
+ Role --> UserRole
84
+ ```
85
+
86
+ ```mermaid
87
+ erDiagram
88
+ ORGANIZATION ||--o{ ROLE : "tenant (multi-tenant)"
89
+ ROLE ||--o{ ROLE_PERMISSION : has
90
+ PERMISSION ||--o{ ROLE_PERMISSION : has
91
+ USER ||--o{ USER_ROLE : has
92
+ ROLE ||--o{ USER_ROLE : has
93
+
94
+ ORGANIZATION {
95
+ bigint id PK
96
+ }
97
+
98
+ ROLE {
99
+ bigint id PK
100
+ string name
101
+ text description
102
+ bigint tenant_id FK "nullable if single-tenant"
103
+ }
104
+
105
+ PERMISSION {
106
+ bigint id PK
107
+ string key UK "resource.action"
108
+ text description "sync-only"
109
+ string category "sync-only"
110
+ }
111
+
112
+ ROLE_PERMISSION {
113
+ bigint role_id FK
114
+ bigint permission_id FK
115
+ }
116
+
117
+ USER {
118
+ bigint id PK
119
+ }
120
+
121
+ USER_ROLE {
122
+ bigint user_id FK
123
+ bigint role_id FK
124
+ }
125
+ ```
126
+
127
+ **Table names are chosen at setup** (see [Configuration layout](#configuration-layout--initializers)):
128
+ use short names (`roles`, `permissions`, …) when free; if those tables (or
129
+ models) already exist, fall back to `access_grant_*` (or a custom name the
130
+ host supplies). Association names on models stay short (`has_many :roles`
131
+ via the DSL); `config.tables` holds the physical names.
132
+
133
+ ### Indexes (hot paths)
134
+
135
+ | Table | Index | Why |
136
+ |---|---|---|
137
+ | `permissions` | unique `key` | `permitted?` / sync upsert |
138
+ | `permissions` | `(category, key)` | Search/group by model/controller (`Permission.by_category("invoices")`, `ordered_for_ui`) |
139
+ | `roles` | unique `(tenant_id, name)` | Per-tenant role list + uniqueness |
140
+ | `roles` | `name` | Owner/Recovery `LOWER(name)` lookups |
141
+ | `role_permissions` | unique `(role_id, permission_id)` + FK indexes | Join integrity; `permitted?` exists |
142
+ | `user_roles` | unique `(user_id, role_id)` | Assignments from user; idempotent assign |
143
+ | `user_roles` | `role_id` | Last-Owner assignment count / reverse lookup |
144
+
145
+ Case-insensitive role uniqueness is enforced in AR validations; a DB expression unique index (`LOWER(name)`) is adapter-specific and left for hosts that need concurrent-create protection beyond validation.
146
+
147
+ ### Models (shape and who may edit)
148
+
149
+ **`AccessGrant::Permission`** (table: configured `permissions` name)
150
+
151
+ | Column | Type | Editable by |
152
+ |---|---|---|
153
+ | `key` | string, unique, format `resource.action` | **Dev only** (catalog DSL + sync). Immutable after insert except via deliberate retirement tooling. |
154
+ | `description` | text/string | **Dev only** — set/updated by `access_grant:sync_permissions` from `permissions.rb`. |
155
+ | `category` | string, optional | **Dev only** — same (sync from catalog). UI grouping label, not an auth factor. |
156
+ | timestamps | | |
157
+
158
+ **What `category` stores:** a short **grouping label for admin UIs** (and
159
+ docs), synced from the catalog. It does **not** affect `permitted?`.
160
+
161
+ | Source in `permissions.rb` | Stored `category` | Example keys |
162
+ |---|---|---|
163
+ | `resource :invoices` | `"invoices"` (default = resource name) | `invoices.index`, `invoices.couple` |
164
+ | `category "billing" do … end` | `"billing"` | `billing.export` |
165
+ | optional override on an action | whatever string you set | rare |
166
+
167
+ ```ruby
168
+ AccessGrant.permissions do
169
+ resource :invoices # category => "invoices"
170
+
171
+ category "billing" do
172
+ permission "billing.export", "Export billing CSV"
173
+ end
174
+ end
175
+ ```
176
+
177
+ Admin “edit role” screens typically group checkboxes by `category`. Values
178
+ are free-form strings from the host catalog (keep them stable;
179
+ renaming a category is a code + sync change, same as description).
180
+
181
+ **Keep it simple — no `Category` model.** A string column on `permissions`
182
+ is enough (same pattern many apps use: group by resource/model name).
183
+ Defaulting `category` to the resource name (`invoices`) matches “category
184
+ as model/resource name.” Use an explicit `category "billing"` block only
185
+ when you want a custom grouping. Do not introduce a categories table or
186
+ AR association for v1.
187
+
188
+ - Never created or updated through an admin UI. Sync is the writer for
189
+ metadata (`description`, `category`).
190
+ - If a host admin form somehow PATCHes `description`, that is a **host
191
+ mistake**: next deploy sync will overwrite it from code. The gem does
192
+ not need a hard DB lock for v1; document “do not expose permission
193
+ CRUD in admin.” Optional later: `readonly` attributes outside sync.
194
+ - Admins **select** existing permission rows when editing a role’s grants
195
+ (checkbox list). They do not invent keys or edit permission copy.
196
+
197
+ ```ruby
198
+ # config/access_grant/permissions.rb — only place for permission description
199
+ resource :invoices do
200
+ action :index, description: "List invoices"
201
+ action :couple, description: "Couple invoices together"
202
+ end
203
+ ```
204
+
205
+ **`AccessGrant::Role`** (table: configured `roles` name)
206
+
207
+ | Column | Type | Editable by |
208
+ |---|---|---|
209
+ | `name` | string | Admin (ordinary roles). Case-insensitive unique per tenant/scope. Owner name reserved when Owner enabled. |
210
+ | `description` | text/string, optional | **Admin** — free to create/update anytime (e.g. “Can view invoices, not destroy”). |
211
+ | `tenant_id` / FK | when multi-tenant | Set at create; immutable (do not move roles across tenants). |
212
+ | timestamps | | |
213
+
214
+ - Ordinary roles: full admin CRUD (name, description, permission set).
215
+ - Owner: subject to Owner protection rules (see Owner section).
216
+
217
+ **`AccessGrant::RolePermission`** — join `role_id` + `permission_id`,
218
+ unique pair. Admin edits the **set** of permissions on a role (attach /
219
+ detach catalog rows), not permission rows themselves.
220
+
221
+ **User↔role join** — e.g. `user_roles`. Assign/remove roles for a user
222
+ within a tenant. Many-to-many. No Membership model in the gem.
223
+
224
+ ### Shared user vs one user per tenant
225
+
226
+ The gem always treats **user** as whatever `user_class` is (usually
227
+ `User`) and **roles as scoped to a tenant** (or global in single-tenant
228
+ mode). It does not care whether that User row is “only in one org” or
229
+ shared across orgs — that is a **host** modeling choice.
230
+
231
+ | Host pattern | How it works with AccessGrant |
232
+ |---|---|
233
+ | **Shared user across tenants** (one `User`, many orgs) | Same Maya has Acme roles and Beta roles via `user_roles`. Checks use `permitted?("invoices.index", tenant: acme)` vs `tenant: beta`. Cross-tenant roles on one user are **valid and expected**. |
234
+ | **Unique user per tenant** (separate `User` rows, or login only inside one org) | Still the same APIs. Each user row only gets roles for the tenants they belong to. Host enforces “user belongs to one org” (validations, invitations). Gem does not enforce uniqueness of user↔tenant. |
235
+ | **Membership as join (host-only)** | Host may have `memberships` for “user in org.” AccessGrant still attaches roles to **User** (or whatever user class is). Host should revoke/cleanup `user_roles` when membership ends — that gate is host-owned (see usage scenarios S065). |
236
+
237
+ ```ruby
238
+ # Shared user — fine
239
+ acme_viewer = acme.roles.find_by!(name: "Viewer")
240
+ beta_viewer = beta.roles.find_by!(name: "Viewer")
241
+ maya.roles << acme_viewer
242
+ maya.roles << beta_viewer
243
+ maya.permitted?("invoices.index", tenant: acme) # uses Acme roles only
244
+ maya.permitted?("invoices.index", tenant: beta) # uses Beta roles only
245
+ ```
246
+
247
+ Do **not** put `tenant_id` on the user↔role join for “which org is this
248
+ assignment for?” — the **role** already belongs to the tenant. That keeps
249
+ one join table and avoids duplicating scope.
250
+
251
+ **`permitted?(key, tenant:)`** — mixed into the user model. Union of
252
+ permission keys across the user's roles (SQL join). **No gem-level
253
+ memoization in v1**. Multi-tenant: `tenant:` required. Single-tenant: omit
254
+ `tenant:`. Keys must match `resource.action`.
255
+
256
+ ### Edit boundary (summary)
257
+
258
+ | | Permission key / description / category | Role name / description | Role’s permission set |
259
+ |---|---|---|---|
260
+ | Developer (code + sync) | Yes | Seed defaults only | Seed defaults only |
261
+ | Tenant admin (runtime UI) | **No** | Yes (ordinary roles) | Yes (pick from catalog) |
262
+ | Host responsibility | Do not build admin screens that edit `Permission` | Role forms | Role forms |
263
+
264
+ Enforcing “admins can’t edit permission description” in the product UI is
265
+ primarily a **host** concern. The gem’s contract is: sync owns permission
266
+ metadata; role description is a normal attribute for admins.
267
+
268
+
269
+
270
+ ## Extension points (DSL)
271
+
272
+ "Tenant" and "user" are host app concepts. The gem needs a declaration
273
+ mechanism rather than hardcoding class names.
274
+
275
+ ```ruby
276
+ class Organization < ApplicationRecord
277
+ access_grant :tenant
278
+ end
279
+
280
+ class User < ApplicationRecord
281
+ access_grant :user
282
+ end
283
+ ```
284
+
285
+ - `access_grant :tenant` — declared on the host app's tenant model
286
+ (multi-tenant). Sets up `has_many :roles`, inverse wiring, and
287
+ `grant_owner!` / `revoke_owner!`.
288
+ - `access_grant :user` — declared on the host app's user model.
289
+ Sets up the roles association (through the host-named join table) and
290
+ mixes in `permitted?`.
291
+
292
+ Naming rationale: one method, two hats — simpler than
293
+ `acts_as_permission_tenant` / `acts_as_permissible`, and still obvious from
294
+ reading the model which classes participate.
295
+
296
+ ### Generators (two-phase)
297
+
298
+ ```
299
+ rails g access_grant:install
300
+ rails g access_grant:setup
301
+ ```
302
+
303
+ `install` writes core migrations using **placeholder table names**
304
+ resolved later by setup (or regenerates migrations once names are known —
305
+ implementation detail: prefer setup emitting the final migrations so names
306
+ are correct before `db:migrate`).
307
+
308
+ `setup` accepts flags or asks interactively when omitted:
309
+
310
+ ```
311
+ rails g access_grant:setup \
312
+ --multi-tenant \
313
+ --tenant=Organization \
314
+ --user=User \
315
+ --owner-role=protected \
316
+ --tables=auto
317
+ ```
318
+
319
+
320
+ | Flag | Meaning | Default |
321
+ | ------------------------------------ | ------------------------------------------------- | ---------------- |
322
+ | `--multi-tenant` / `--single-tenant` | Scope mode | asked if omitted |
323
+ | `--tenant=Organization` | Tenant class (multi-tenant only) | `Organization` |
324
+ | `--user=User` | User class | `User` |
325
+ | `--owner-role=protected` | Owner mechanism | `protected` |
326
+ | `--tables=auto` | Collision-aware names (see below) | `auto` |
327
+ | `--tables=simple` | Force `roles` / `permissions` / … (fail if taken) | |
328
+ | `--tables=prefixed` | Always `access_grant_*` | |
329
+
330
+
331
+ **Table naming (**`--tables=auto`**, recommended):**
332
+
333
+ 1. Check whether `roles`, `permissions`, `role_permissions`, and
334
+ `{user}_roles` / `user_roles` already exist (schema and/or models).
335
+ 2. If **free** → use those short names.
336
+ 3. If **taken** → use `access_grant_`* (or prompt for a custom name when
337
+ interactive).
338
+ 4. Write the chosen map into `config.tables` in the boot initializer.
339
+
340
+ Then writes:
341
+
342
+ - Final migrations (tenant FK when multi-tenant; user↔role join).
343
+ - Config files — see [Configuration layout](#configuration-layout--initializers).
344
+ - Patches tenant/user models with `access_grant :tenant` /
345
+ `access_grant :user` (skips if already present; fails clearly if a
346
+ model file cannot be found).
347
+
348
+
349
+
350
+ ### Installation checklist (including deploy sync)
351
+
352
+ Catalog sync is **not** a schema migration. After install, and on **every
353
+ deploy** that might change `config/access_grant/permissions.rb`, the host
354
+ must run:
355
+
356
+ ```
357
+ bundle exec rake access_grant:sync_permissions
358
+ ```
359
+
360
+ Document this in the host app’s release process. Examples:
361
+
362
+ ```yaml
363
+ # Kamal — hook after migrate (illustrative)
364
+ # .kamal/hooks/post-deploy or release command:
365
+ # bin/rails db:migrate && bin/rails access_grant:sync_permissions
366
+ ```
367
+
368
+ ```ruby
369
+ # Heroku release phase (Procfile)
370
+ # release: bundle exec rails db:migrate && bundle exec rake access_grant:sync_permissions
371
+ ```
372
+
373
+ ```ruby
374
+ # Capistrano (illustrative)
375
+ # after "deploy:migrate", "access_grant:sync_permissions"
376
+ ```
377
+
378
+ The `setup` generator should print this reminder and can optionally append a
379
+ commented snippet to `lib/tasks/access_grant_deploy.rake` or the host’s
380
+ deploy docs — the important part is the **host wires sync into deploys**,
381
+ not that developers remember a manual rake after each push.
382
+
383
+ Skipping sync after adding keys means new permissions exist in code but not
384
+ in the DB (checks raise on unknown keys once validated against the catalog /
385
+ DB — see resolved decisions). Old keys left in the DB after removal from
386
+ code still work until deliberately retired.
387
+
388
+ ## Configuration layout / initializers
389
+
390
+ One **boot** initializer plus dedicated AccessGrant config files (not three
391
+ competing Rails initializers):
392
+
393
+ ```
394
+ config/
395
+ initializers/
396
+ access_grant.rb # boot wiring + all config.* options
397
+ access_grant/
398
+ permissions.rb # catalog DSL
399
+ roles.rb # default roles / on_tenant_created
400
+ ```
401
+
402
+ The `setup` generator writes a fully commented `access_grant.rb` so every
403
+ option is visible to the host developer. Below is the reference.
404
+
405
+ ### Configuration reference
406
+
407
+ All options are set via:
408
+
409
+ ```ruby
410
+ AccessGrant.configure do |config|
411
+ # ...
412
+ end
413
+ ```
414
+
415
+ #### `tenant_class`
416
+
417
+ - **Type:** `String` or `nil`
418
+ - **Default:** `nil` (single-tenant)
419
+ - **Meaning:** Host model that owns roles (e.g. `"Organization"`). When set,
420
+ the install is multi-tenant: roles get a tenant FK, and `permitted?`
421
+ requires `tenant:`.
422
+ - **Example:**
423
+
424
+ ```ruby
425
+ config.tenant_class = "Organization"
426
+ # Single-tenant: omit or set nil
427
+ # config.tenant_class = nil
428
+ ```
429
+
430
+ #### `user_class`
431
+
432
+ - **Type:** `String`
433
+ - **Default:** `"User"`
434
+ - **Meaning:** Host model that receives roles and `permitted?` (Devise-style
435
+ user). Rename if the host uses `Account`, etc.
436
+ - **Example:**
437
+
438
+ ```ruby
439
+ config.user_class = "User"
440
+ # config.user_class = "Account"
441
+ ```
442
+
443
+ #### `owner_role`
444
+
445
+ - **Type:** `Symbol` — `:protected` | `:bypass` | `:both` | `:none`
446
+ - **Default:** `:protected`
447
+ - **Meaning:** How privileged the Owner role is. See [Owner role](#owner-role).
448
+ - **Example:**
449
+
450
+ ```ruby
451
+ config.owner_role = :protected
452
+ # config.owner_role = :none # no special Owner; grant_owner! raises
453
+ ```
454
+
455
+ #### `owner_role_name`
456
+
457
+ - **Type:** `String`
458
+ - **Default:** `"Owner"`
459
+ - **Meaning:** Reserved role name for the privileged floor (case-insensitive).
460
+ - **Example:**
461
+
462
+ ```ruby
463
+ config.owner_role_name = "Owner"
464
+ # config.owner_role_name = "Super Admin"
465
+ ```
466
+
467
+ #### `tables`
468
+
469
+ - **Type:** `Hash` with keys `:roles`, `:permissions`, `:role_permissions`,
470
+ `:user_roles`
471
+ - **Default:** short names (`"roles"`, `"permissions"`, …)
472
+ - **Meaning:** Physical table names (chosen by setup `--tables=auto|simple|prefixed`).
473
+ - **Example:**
474
+
475
+ ```ruby
476
+ config.tables = {
477
+ roles: "roles",
478
+ permissions: "permissions",
479
+ role_permissions: "role_permissions",
480
+ user_roles: "user_roles"
481
+ }
482
+ # After collision:
483
+ # config.tables = {
484
+ # roles: "access_grant_roles",
485
+ # permissions: "access_grant_permissions",
486
+ # role_permissions: "access_grant_role_permissions",
487
+ # user_roles: "access_grant_user_roles"
488
+ # }
489
+ ```
490
+
491
+ #### `default_permission_actions`
492
+
493
+ - **Type:** `Array<String>`
494
+ - **Default:** `%w[index show create update destroy]`
495
+ - **Meaning:** Actions emitted for each `resource :name` in the catalog DSL
496
+ (plus description templates). Add host-specific CRUD extras here.
497
+ - **Example:**
498
+
499
+ ```ruby
500
+ config.default_permission_actions = %w[index show create update destroy]
501
+ # Include extras used by your app:
502
+ # config.default_permission_actions = %w[index show create update destroy search attach detach]
503
+ ```
504
+
505
+ #### `current_user_method`
506
+
507
+ - **Type:** `Symbol`
508
+ - **Default:** `:current_user`
509
+ - **Meaning:** Controller method the authorize hook calls for the acting
510
+ user. Only used by `access_grant_authorize!`.
511
+ - **Example:**
512
+
513
+ ```ruby
514
+ config.current_user_method = :current_user
515
+ # config.current_user_method = :current_account
516
+ ```
517
+
518
+ #### `current_tenant_method`
519
+
520
+ - **Type:** `Symbol`
521
+ - **Default:** `:current_tenant`
522
+ - **Meaning:** Controller method the authorize hook calls for the tenant
523
+ (multi-tenant). Host must define that method. Unused when
524
+ `tenant_class` is nil.
525
+ - **Example:**
526
+
527
+ ```ruby
528
+ config.current_tenant_method = :current_tenant
529
+
530
+ # app/controllers/application_controller.rb
531
+ def current_tenant
532
+ current_user&.organization
533
+ end
534
+
535
+ # Or if you already expose current_organization:
536
+ # config.current_tenant_method = :current_organization
537
+ ```
538
+
539
+ #### `on_tenant_created`
540
+
541
+ - **Type:** `Proc` / callable `(tenant) -> void` or `nil`
542
+ - **Default:** `nil`
543
+ - **Meaning:** Invoked after a tenant record is created (`access_grant
544
+ :tenant`). Seed default **role definitions** here — not Owner assignment
545
+ (still `grant_owner!(user)`).
546
+ - **Example:**
547
+
548
+ ```ruby
549
+ # Often in config/access_grant/roles.rb (generated by setup)
550
+ config.on_tenant_created = ->(tenant) do
551
+ # Starter: Viewer + Manager per resource/category from synced permissions
552
+ AccessGrant::Role.ensure_resource_defaults_for!(tenant)
553
+
554
+ # Or explicit names:
555
+ # AccessGrant::Role.ensure_defaults_for!(
556
+ # tenant,
557
+ # "Admin" => %w[invoices.index invoices.update members.index],
558
+ # "Member" => %w[invoices.index]
559
+ # )
560
+ end
561
+ ```
562
+
563
+ #### `recover_access`
564
+
565
+ - **Type:** `Proc` / callable or `nil` (uses built-in default)
566
+ - **Default:** built-in `AccessGrant::Recovery.grant_role!`
567
+ - **Meaning:** Ops lockout recovery used by
568
+ `rake access_grant:grant_role`. Override to integrate host tooling.
569
+ - **Example:**
570
+
571
+ ```ruby
572
+ # Default behavior (no config needed):
573
+ # ROLE=Owner USER_ID=1 TENANT_ID=42 bundle exec rake access_grant:grant_role
574
+
575
+ config.recover_access = ->(role_name:, user_id:, tenant_id: nil) {
576
+ AccessGrant::Recovery.grant_role!(role_name:, user_id:, tenant_id:)
577
+ }
578
+ ```
579
+
580
+ ### Full boot initializer example
581
+
582
+ ```ruby
583
+ # config/initializers/access_grant.rb
584
+ AccessGrant.configure do |config|
585
+ config.tenant_class = "Organization"
586
+ config.user_class = "User"
587
+ config.owner_role = :protected
588
+ config.owner_role_name = "Owner"
589
+
590
+ config.tables = {
591
+ roles: "roles",
592
+ permissions: "permissions",
593
+ role_permissions: "role_permissions",
594
+ user_roles: "user_roles"
595
+ }
596
+
597
+ config.default_permission_actions = %w[index show create update destroy]
598
+ config.current_user_method = :current_user
599
+ config.current_tenant_method = :current_tenant
600
+ end
601
+
602
+ Rails.root.glob("config/access_grant/**/*.rb").sort.each { |f| require f }
603
+ ```
604
+
605
+ ```ruby
606
+ # config/access_grant/permissions.rb
607
+ AccessGrant.permissions do
608
+ resource :invoices do
609
+ action :couple, description: "Can couple invoices together"
610
+ end
611
+ end
612
+ ```
613
+
614
+ ```ruby
615
+ # config/access_grant/roles.rb
616
+ AccessGrant.configure do |config|
617
+ config.on_tenant_created = ->(tenant) do
618
+ AccessGrant::Role.ensure_defaults_for!(
619
+ tenant,
620
+ "Admin" => %w[invoices.index invoices.update members.index],
621
+ "Member" => %w[invoices.index]
622
+ )
623
+ end
624
+ end
625
+ ```
626
+
627
+ **Controller-hook note:** `current_user` / `current_tenant` are **only** for
628
+ `access_grant_authorize!`. The `permitted?(key, tenant:)` API still takes an
629
+ **explicit** `tenant:` in multi-tenant mode (guardrail 2).
630
+
631
+ ## Owner role
632
+
633
+ Owner is the configurable privileged floor. Full design:
634
+ [2026-09-05-owner-role-design.md](superpowers/specs/2026-09-05-owner-role-design.md).
635
+
636
+ **Gem vs host**
637
+
638
+ - Gem: Owner mechanisms, `grant_owner!` / `revoke_owner!`, at-least-one-Owner
639
+ on revoke, catalog re-attach for `:protected` / `:both`.
640
+ - Host: who is the creator and when to call `grant_owner!`. The gem does
641
+ not auto-detect creators (`Current.user`, creator columns, etc.).
642
+
643
+ ```ruby
644
+ # Host responsibility after creating an org
645
+ org = Organization.create!(name: "Acme")
646
+ org.grant_owner!(current_user)
647
+
648
+ # Single-tenant (e.g. seeds)
649
+ AccessGrant.grant_owner!(maya)
650
+ ```
651
+
652
+ Multiple Owners per scope are allowed. Revoking the last Owner fails when
653
+ Owner is enabled. Creating a tenant with zero Owners is allowed until the
654
+ host grants one.
655
+
656
+
657
+ | `owner_role` | Meaning |
658
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
659
+ | `:protected` (default) | Owner row has every catalog key; sync keeps it complete; cannot strip/delete the role; `permitted?` stays a normal join |
660
+ | `:bypass` | Having Owner short-circuits `permitted?` to true |
661
+ | `:both` | Protected rows plus short-circuit |
662
+ | `:none` | No special Owner; `grant_owner!` raises |
663
+
664
+
665
+
666
+
667
+ ## Catalog sync mechanism
668
+
669
+ New permission keys always require a code change (catalog DSL), but rolling
670
+ that out is a **data sync, not a schema migration**:
671
+
672
+ ```
673
+ bundle exec rake access_grant:sync_permissions
674
+ ```
675
+
676
+ Upserts into the configured permissions table (insert new keys, update
677
+ description/category, **never delete**). For `:protected` / `:both`, also
678
+ re-attaches every catalog permission to Owner roles. Removing a key from
679
+ code does not revoke grants — see resolved decisions on catalog retirement.
680
+
681
+ ## Design principles / guardrails
682
+
683
+ These are commitments this gem's implementation must honor, not aspirational
684
+ guidance — they come directly from failure modes identified while auditing
685
+ the motivating host app's current ad hoc authorization code.
686
+
687
+ ### 1. Self-service is not the complement of "lacks permission"
688
+
689
+ "Lacks permission X" and "is a restricted self-service identity" are
690
+ different facts and must be checked independently. A design where "no
691
+ `manage_x` permission" implicitly means "fall back to acting only on your own
692
+ record" breaks the moment roles are fully dynamic and deletable — there is no
693
+ guarantee a "no manage_x" identity is the self-service case at all. The
694
+ self-service/ownership check (e.g. "do you own this record?") must be its
695
+ own explicit, independent gate. Any broader permission is *additive* — it
696
+ grants more access — and must never be treated as the sole complement of "not
697
+ a manager."
698
+
699
+ **Example.** Maya is a worker with no `manage_timesheets` permission. The
700
+ app must not infer “so she may only edit her own timesheets.” That
701
+ self-service rule is a separate check (`timesheet.user_id == maya.id`).
702
+ Jordan has `manage_timesheets` — that *adds* the ability to edit anyone’s
703
+ sheet; it is not the sole definition of “not a worker.” If an admin deletes
704
+ every manage-role tomorrow, workers without an explicit ownership check
705
+ would otherwise fall into a broken “no permission ⇒ do nothing / do
706
+ everything” gap.
707
+
708
+ ### 2. No permission check rides on ambient/global mutable state
709
+
710
+ A permission check must not silently depend on ambient/global state (e.g. a
711
+ `CurrentAttributes`-style singleton) that some other code path on the same
712
+ request can mutate. Anywhere a call site's context is something other than
713
+ "the current request's own user" (for example, a check performed on
714
+ behalf of a record fetched by ID rather than the requester themselves), the
715
+ the user must be passed explicitly as an argument rather than read off a
716
+ global. This closes a real hazard class: a controller that repoints a
717
+ `Current`-style singleton mid-request (e.g. based on a note's own
718
+ organization rather than the request's own header) can silently change what
719
+ a later ambient permission check evaluates against.
720
+
721
+ Creator detection for Owner assignment stays **out** of the gem — the host
722
+ passes the user into `grant_owner!` explicitly. Reading `current_user` /
723
+ `current_tenant` inside `access_grant_authorize!` is allowed at the
724
+ controller edge only; those values are passed into `permitted?` as
725
+ explicit arguments, not read again inside the check.
726
+
727
+ **Example.** A Notes controller loads a note, then does
728
+ `Current.organization = note.organization` so a partial can render the
729
+ note’s org name. Later in the same action,
730
+ `Current.user.permitted?(:delete_notes)` would silently evaluate against
731
+ the *note’s* org if `permitted?` read ambient Current — wrong if the
732
+ requester is acting as a member of a different org. Correct pattern:
733
+ `current_user.permitted?(:delete_notes, tenant: current_organization)`
734
+ with both values taken from the request’s own auth context, not from the
735
+ loaded record.
736
+
737
+ ### 3. Migration sequencing for adopting the gem in an existing app
738
+
739
+ When a host app migrates from a legacy single-role column onto this gem's
740
+ tables, that migration path must be split into (at minimum) three separate
741
+ migrations, not one:
742
+
743
+ 1. **Create** the new tables (`roles`, `role_permissions`, the
744
+ user↔role join table).
745
+ 2. **Backfill** data from the legacy column into the new tables.
746
+ 3. **Drop** the legacy column.
747
+
748
+ Mixing "create tables" + "backfill via ActiveRecord models on those same
749
+ tables" + "drop a column" into a single migration risks schema-cache
750
+ staleness within that migration and complicates rollback. Steps 1 and 2 can
751
+ ship ahead of the code cutover safely (old code simply ignores the new,
752
+ unknown tables); step 3 must ship in the same deploy as the application code
753
+ that stops reading the dropped column. This applies both as guidance this
754
+ gem's docs give to host apps, and to how this gem's own future migrations
755
+ (e.g. adding a column to `roles`) should be structured.
756
+
757
+ **Example.** Host has `memberships.role` as a string. Ship migration A
758
+ (create AccessGrant tables) in deploy 1 with no app code change. Ship
759
+ migration B (backfill `roles` / joins from the string column) in deploy 2,
760
+ still reading the old column at runtime. Ship migration C (drop
761
+ `memberships.role`) in the same deploy that switches controllers to
762
+ `permitted?`. One big migration that creates, backfills via models, and
763
+ drops in a single transaction is harder to roll back and can see a stale
764
+ schema cache mid-run.
765
+
766
+ ## Role name uniqueness
767
+
768
+ Within a scope (one tenant when multi-tenant; the whole app when
769
+ single-tenant), `roles.name` is **case-insensitive**. `"Owner"` and
770
+ `"owner"` collide. Store a canonical form (implementation detail: e.g.
771
+ preserve display casing the host passed on create, but uniqueness and
772
+ lookups use a case-insensitive comparison / unique index).
773
+
774
+ ## Ordinary role walkthrough (public API)
775
+
776
+ Canonical everyday flow (ActiveRecord associations; no extra wrappers
777
+ required). Table/model names use the gem’s `AccessGrant::` models behind
778
+ `has_many :roles` / `has_many :permissions` as exposed by the DSL.
779
+
780
+ ```ruby
781
+ # 1. Catalog already synced — e.g. "invoices.index", "invoices.update"
782
+ # 2. Create an ordinary role in Acme
783
+ viewer = acme.roles.create!(name: "Billing Viewer")
784
+ viewer.permissions = AccessGrant::Permission.where(key: %w[invoices.index])
785
+ # or: viewer.permission_keys = %w[invoices.index] # if we expose that writer
786
+
787
+ # 3. Assign to Maya
788
+ maya.roles << viewer
789
+ # join uniqueness: repeating this is idempotent (no duplicate rows)
790
+
791
+ # 4. Checks
792
+ maya.permitted?("invoices.index", tenant: acme) # => true
793
+ maya.permitted?("invoices.index", tenant: beta) # => false
794
+
795
+ # 5. Edit grants atomically (replace whole set; failed validation → no partial apply)
796
+ viewer.permission_keys = %w[invoices.index invoices.update]
797
+
798
+ # 6. Admin form helpers (queries, not a separate admin gem)
799
+ AccessGrant::Permission.ordered_for_ui # catalog options (indexed)
800
+ AccessGrant::Permission.by_category("invoices") # one model/controller group
801
+ viewer.permission_keys # selected keys
802
+ maya.roles.merge(AccessGrant::Role.for_tenant(acme)) # roles in Acme only
803
+
804
+ # 7. Revoke assignment
805
+ maya.roles.destroy(viewer) # or delete the join; missing assignment is a no-op
806
+ ```
807
+
808
+ Public writer for a role’s grants is **`permission_keys=`** (array of
809
+ `resource.action` strings). It replaces the full set atomically. Assign /
810
+ remove user↔role is idempotent; scope role lists by tenant.
811
+
812
+ ## Permission catalog convention
813
+
814
+ The catalog is **code-defined** by the host, synced into the configured
815
+ permissions table by rake. **One permission per controller action**, keyed
816
+ strictly as `resource.action`.
817
+
818
+ ### Permission key format (enforced)
819
+
820
+ Only this shape is stored or accepted — nothing else:
821
+
822
+ ```
823
+ resource.action
824
+ ```
825
+
826
+ - **Pattern:** `\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z`
827
+ (one dot; lowercase letters, digits, underscores; both segments
828
+ start with a letter)
829
+ - **Valid:** `invoices.index`, `invoices.couple`, `billing.export`
830
+ - **Invalid / rejected:** `manage_billing`, `invoices`, `.index`,
831
+ `Invoices.Update`, `invoices.update;drop`, `invoices.update/../admin`,
832
+ empty string, keys with spaces or extra dots
833
+
834
+ Rejected at:
835
+
836
+ 1. Catalog DSL load (`permissions.rb`)
837
+ 2. `sync_permissions` upsert
838
+ 3. Attaching keys to a role (admin/API must pick from catalog rows only)
839
+ 4. `permitted?(key, …)` — malformed key **raises** (same as unknown key)
840
+
841
+ Do **not** store alternate formats, aliases, or free-text “capability
842
+ names” in the permissions table. Overrides in the DSL may change the
843
+ *resource* or *action* segments, but the stored value must still match
844
+ the pattern (e.g. `action :couple, "billing.couple"`).
845
+
846
+ ### Security: keys never come from the request
847
+
848
+ ```ruby
849
+ # BAD — attacker-controlled permission key
850
+ permitted?(params[:permission], tenant: org)
851
+
852
+ # GOOD — key derived from controller + action (hook) or a code constant
853
+ access_grant_authorize! # → "invoices.couple" for #couple
854
+ current_user.permitted?("invoices.couple", tenant: org)
855
+ ```
856
+
857
+ The authorize hook builds `#{resource}.#{action}` from the controller’s
858
+ resource mapping and `action_name` — **never** from params, headers, or
859
+ client JSON. Host admin UIs list catalog rows by id/key for checkboxes;
860
+ they must not accept an arbitrary string as a new permission key (catalog
861
+ is code-only).
862
+
863
+ **Install generates** `config/access_grant/permissions.rb` — the **one
864
+ place** to declare resources and custom actions (no Pundit Policy classes,
865
+ no `controller` / `controller_method` columns).
866
+
867
+ ### Default actions and description templates
868
+
869
+ Declaring `resource :invoice` (singular or plural normalized to a resource
870
+ segment) generates the **default action set** with templated descriptions:
871
+
872
+ | Action | Key example | Default description template |
873
+ |---|---|---|
874
+ | `index` | `invoices.index` | Can view list of %<resources>s |
875
+ | `show` | `invoices.show` | Can view details of a %<resource>s |
876
+ | `create` | `invoices.create` | Can create a new %<resource>s |
877
+ | `update` | `invoices.update` | Can update an existing %<resource>s |
878
+ | `destroy` | `invoices.destroy` | Can delete an existing %<resource>s |
879
+
880
+ Hosts may extend the default set via config (e.g. add `search`, `attach`,
881
+ `detach`, `bulk_delete`) without forking the gem:
882
+
883
+ ```ruby
884
+ config.default_permission_actions = %w[index show create update destroy search]
885
+ ```
886
+
887
+ Custom actions always pass an explicit description (or rely on a host
888
+ template if registered).
889
+
890
+ ```ruby
891
+ # config/access_grant/permissions.rb
892
+ AccessGrant.permissions do
893
+ resource :invoices do
894
+ # defaults: index show create update destroy (+ config extras)
895
+ action :couple, description: "Can couple invoices together"
896
+ # only :index, :show # optional: restrict which defaults are emitted
897
+ # skip :destroy
898
+ end
899
+
900
+ resource :providers do
901
+ action :commission_percent, description: "Can change commission percent of providers"
902
+ end
903
+
904
+ category "billing" do
905
+ permission "billing.export", "Export billing CSV"
906
+ end
907
+ end
908
+ ```
909
+
910
+ **Explicit resources only.** Sync does **not** auto-discover
911
+ `ApplicationRecord.descendants`. Optional later: a generator that
912
+ *scaffolds* `resource` lines into `permissions.rb` for the host to edit —
913
+ never silent create-from-all-models on deploy.
914
+
915
+ **No legacy columns.** Do not store `controller`, `controller_method`, or a
916
+ separate `name` alongside `key`. Authorize maps `controller#action` →
917
+ `resource.action` via the catalog / inflection; one key is enough.
918
+
919
+ Ad-hoc `permission` entries must use the same `resource.action` format.
920
+
921
+ **Host convention**
922
+
923
+ 1. Declare resources/actions in `permissions.rb`.
924
+ 2. Deploy.
925
+ 3. Run `bundle exec rake access_grant:sync_permissions` (also on every
926
+ release — see installation checklist).
927
+
928
+ Sync upserts keys (insert/update `description` / `category`). It **never
929
+ deletes** catalog rows. Removing a key from code does **not** revoke
930
+ existing role grants — see catalog retirement in resolved decisions.
931
+
932
+ ## Controller authorization hook (standalone — not a second Pundit)
933
+
934
+ AccessGrant does **not** generate Policy classes. Enforcement is:
935
+
936
+ 1. Optional controller hook that maps `controller#action` → catalog key and
937
+ calls `permitted?` once per request.
938
+ 2. Host record scoping (own vs all) remains application code (guardrail 1).
939
+
940
+ ```ruby
941
+ # app/controllers/application_controller.rb
942
+ class ApplicationController < ActionController::Base
943
+ access_grant_authorize! # defaults: current_user + current_tenant
944
+ skip_access_grant_authorize! if: :devise_controller?
945
+ end
946
+ ```
947
+
948
+ Defaults and optional overrides:
949
+ [Configuration layout](#configuration-layout--initializers). Default
950
+ placement: `ApplicationController` **+ skips**. Action→key overrides live
951
+ in `config/access_grant/permissions.rb`, not Policy classes.
952
+
953
+ Collection actions: authorize once (`invoices.index`), then scope the
954
+ relation in the host. Do not call `permitted?` per row for listing.
955
+
956
+ ## Overrides and record-level scoping (host-owned)
957
+
958
+ AccessGrant answers **capability**: “May this user do `invoices.index`
959
+ in this tenant?” It does **not** answer **which rows** they may see.
960
+ That is the same split as guardrail 1 (self-service ≠ lack of permission)
961
+ and as Pundit’s Policy vs Scope — except we do not ship Policy classes.
962
+
963
+ ### What the gem lets you override
964
+
965
+
966
+ | Hook | Where | Purpose |
967
+ | ----------------------------------- | ------------------------------------ | ------------------------------------------------ |
968
+ | Action → permission key | `config/access_grant/permissions.rb` | Remap or skip actions |
969
+ | `current_user` / `current_tenant` | controller methods (optional rename in boot) | Auth / tenant for the controller hook |
970
+ | `on_tenant_created` | `roles.rb` | Default role definitions |
971
+ | `recover_access` | boot initializer | Ops lockout recovery |
972
+ | `skip_access_grant_authorize!` | controllers | Public/Devise endpoints |
973
+ | Owner mode / table names | setup + boot config | Floor and schema |
974
+
975
+
976
+
977
+
978
+ ### What stays in the host app (like Pundit scopes)
979
+
980
+ Row filters, ID ranges, assigned territories, “only these user records,”
981
+ etc. live in **host query scopes** composed *after* (or beside) a
982
+ capability check:
983
+
984
+ ```ruby
985
+ # Capability — gem
986
+ raise Forbidden unless current_user.permitted?("users.index", tenant: org)
987
+
988
+ # Row visibility — host (your rules, not AccessGrant)
989
+ @users = org.users.merge(UserAccessible.for(current_user))
990
+ # e.g. User A → id 1..1000, B → 500..1000, C → 1100..1400
991
+ ```
992
+
993
+ ```ruby
994
+ # app/models/user_accessible.rb (host)
995
+ module UserAccessible
996
+ def self.for(user)
997
+ # whatever the product needs: ranges, join tables, tags, …
998
+ case user.access_segment
999
+ when "a" then User.where(id: 1..1000)
1000
+ when "b" then User.where(id: 500..1000)
1001
+ when "c" then User.where(id: 1100..1400)
1002
+ else User.none
1003
+ end
1004
+ end
1005
+ end
1006
+ ```
1007
+
1008
+ **Show / update one record:** check capability, then ensure the record is
1009
+ in that user’s visible set (`accessible.exists?(id: record.id)`), or
1010
+ encode both in a host method `authorize_user!(record)`.
1011
+
1012
+ **Why not put ranges in the gem?** They are product-specific, often need
1013
+ extra tables (`user_record_grants`), and change independently of the
1014
+ permission catalog. Baking them into AccessGrant would recreate Pundit
1015
+ Policy Scope inside this gem and blow the small-core budget. v1 documents
1016
+ the composition pattern; a future optional “scope registry” is a non-goal
1017
+ until a real host needs a shared convention.
1018
+
1019
+ Owner bypass (`:bypass` / `:both`) means **all catalog capabilities** in
1020
+ that tenant — it must **not** silently skip host row filters unless the
1021
+ host chooses to (`if owner || in_scope?`). Document that explicitly in
1022
+ host integration guides.
1023
+
1024
+ ## Default roles at tenant creation (not resource-scoped roles)
1025
+
1026
+
1027
+ | Idea | Meaning | v1 |
1028
+ | ------------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
1029
+ | **Default tenant roles** | When an org is created, seed named roles with default permission sets | Yes — via `config/access_grant/roles.rb` (`ensure_resource_defaults_for!` or explicit `ensure_defaults_for!`) |
1030
+ | **Per-resource starter roles** | Viewer + Manager roles per permission `category` (resource) as a generateable starting point | Yes — `Role.ensure_resource_defaults_for!` (host edits `roles.rb`) |
1031
+ | **Resource-scoped roles** | Role on one record (“moderator of Forum #5”) | Deferred — not v1 |
1032
+
1033
+
1034
+ Default-role callback is configured in `roles.rb` (see Configuration
1035
+ layout). `ensure_defaults_for!` is **create-only** for missing role names.
1036
+ Assigning a user to Owner remains `grant_owner!`. Single-tenant: call the
1037
+ same helper from seeds.
1038
+
1039
+ ## Lockout escape hatch
1040
+
1041
+ Ops recovery is a **configurable callable** (host can replace it). The gem
1042
+ ships a default implementation used by the rake task:
1043
+
1044
+ ```ruby
1045
+ AccessGrant.configure do |config|
1046
+ # Default: grant named role to user (and tenant when multi-tenant)
1047
+ config.recover_access = ->(role_name:, user_id:, tenant_id: nil) {
1048
+ AccessGrant::Recovery.grant_role!(role_name:, user_id:, tenant_id:)
1049
+ }
1050
+ end
1051
+ ```
1052
+
1053
+ ```
1054
+ # Multi-tenant
1055
+ ROLE=Owner USER_ID=1 TENANT_ID=42 bundle exec rake access_grant:grant_role
1056
+
1057
+ # Single-tenant (seed or rake; no TENANT_ID)
1058
+ ROLE=Owner USER_ID=1 bundle exec rake access_grant:grant_role
1059
+ ```
1060
+
1061
+ Env-style args are the canonical ops interface (reliable across Rake
1062
+ versions). Single-tenant first Owner is normally done in seeds via
1063
+ `AccessGrant.grant_owner!(maya)`.
1064
+
1065
+ ## Resolved public-contract decisions
1066
+
1067
+ Adopted from the external proposal review
1068
+ ([2026-09-07-proposal-review.md](superpowers/specs/2026-09-07-proposal-review.md))
1069
+ and scenario inventory
1070
+ ([2026-09-07-usage-scenarios.md](superpowers/specs/2026-09-07-usage-scenarios.md)),
1071
+ plus later brainstorming on resource keys and controller hooks.
1072
+
1073
+
1074
+ | Topic | Decision |
1075
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1076
+ | Missing `tenant:` (multi-tenant) | **Raise** a clear error — do not treat as global allow/deny. |
1077
+ | `tenant:` in single-tenant mode | **Raise** — do not silently ignore. |
1078
+ | Unknown / misspelled permission key | **Raise** in all Owner modes (including bypass) so typos are not hidden. |
1079
+ | Permission key format | **Only** `resource.action` matching `\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z`. Reject everything else at DSL, sync, role attach, and `permitted?`. No alternate stored formats. |
1080
+ | Permission key source | Never from request params. Hook derives from controller mapping + `action_name`. Admin UI selects catalog rows only. |
1081
+ | Symbol vs string keys | Normalize with `to_s`; store/compare as strings that already match the format. |
1082
+ | Freshness / memoization | **No gem-level memoization in v1.** Each `permitted?` hits the DB (or a host-chosen cache later). Runtime edits are visible on the next call. |
1083
+ | Duplicate role assignment | Idempotent; unique index on user↔role; repeat is a no-op. |
1084
+ | Revoke missing assignment | No-op (retry-safe). |
1085
+ | Replace role permissions | Atomic replace; validation failure leaves the previous set intact. |
1086
+ | Empty permission set on ordinary role | Confers no capabilities. |
1087
+ | Owner name | **Reserved** for the configured `owner_role_name` (case-insensitive). Creating/renaming an ordinary role to that name is rejected when Owner mode ≠ `:none`. |
1088
+ | Last-Owner invariant | Enforced on `revoke_owner!` and on gem-supported association/helpers that remove Owner assignments; concurrent revokes use row locking so one fails. Raw SQL is out of contract. |
1089
+ | Catalog retirement | Sync never deletes keys. Removing a key from code **does not** revoke grants. Host retires deliberately (detach from roles, then optional future purge tool). Document operationally. |
1090
+ | Table names | **Collision-aware at setup** (`--tables=auto`): short names when free; `access_grant_`* (or custom) when taken. Force with `--tables=simple` / `--tables=prefixed`. Persisted in `config.tables`. |
1091
+ | Config layout | One boot initializer (`config/initializers/access_grant.rb`) + `config/access_grant/permissions.rb` + `config/access_grant/roles.rb`. |
1092
+ | Controller actor / tenant | Defaults: **`current_user`** and **`current_tenant`** (controller methods). Host implements `current_tenant` when multi-tenant. Optional method-name overrides. Hook-only; `permitted?` still requires explicit `tenant:`. No `Current.tenant` / CurrentAttributes requirement. |
1093
+ | Implementation size | Aim for a small core (**< ~1000 lines** of production gem code including generators/templates as a budget, not a hard reject). Clarity over API sprawl. |
1094
+ | Rolify / Pundit | **Not dependencies. Build from scratch (Approach 1).** Optional Pundit adapters deferred. |
1095
+ | Auth gems | Couple via `current_user` (Devise-style). No Devise runtime dependency. Tenant via host `current_tenant`, not a specific multi-tenant gem or prior app’s `Current.*`. |
1096
+ | Record-level / row filters | **Host-owned.** Gem = capability (`permitted?`); host scopes which rows (Pundit-Scope equivalent). Owner bypass does not auto-skip host row filters. |
1097
+ | Deploy sync | Host must run `access_grant:sync_permissions` on each deploy (not a migration). Generator documents / reminds. |
1098
+ | Permission vs role description | Permission `description` / `category`: **dev + sync only**. Role `description`: **admin-editable**. Host UI must not expose Permission CRUD; sync overwrites permission metadata. |
1099
+ | Identity naming | Call it **user** (Devise-style): `access_grant :user`, `user_class`, `--user=User`, `grant_owner!(user)`. Not “person.” |
1100
+ | Permission category | String column only (no Category model). Default = resource name; optional `category "…"` blocks for custom groups. |
1101
+ | Role grant writer | **`permission_keys=`** replaces the full set atomically. |
1102
+ | `ensure_defaults_for!` keys | Accepts strings or symbols; normalized with `to_s` to `resource.action`. |
1103
+ | Catalog defaults | Default actions: `index show create update destroy` (+ optional `config.default_permission_actions`). Description templates for defaults; custom actions need explicit descriptions. |
1104
+ | Catalog discovery | **Explicit** `resource` entries only. No auto-scan of all AR models on sync. No `controller` / `controller_method` / duplicate `name` columns. |
1105
+ | Configuration docs | Every `config.*` option documented with type, default, meaning, and example in architecture; setup generator writes a commented initializer; YARD on `Configuration` attributes. |
1106
+
1107
+
1108
+
1109
+
1110
+ ## Compatibility
1111
+
1112
+ - **Ruby**: >= 3.1
1113
+ - **Rails**: >= 7.0
1114
+ - **Database**: ActiveRecord/SQL only for v1 (see Non-goals below)
1115
+
1116
+
1117
+
1118
+ ## Non-goals / future considerations
1119
+
1120
+ - **NoSQL.** Out of scope for v1.
1121
+ - **Admin-facing controllers/serializers.** Optional later add-on; v1 is
1122
+ data model, DSL, `permitted?`, sync, Owner, controller authorize hook.
1123
+ - **Resource-scoped roles** (Rolify-style per-record roles) — deferred.
1124
+ - **Pundit/CanCanCan/Rolify adapters** — deferred; v1 is from scratch.
1125
+ Document host migration (thin policy → `permitted?`) only.
1126
+ - **Auto-grant Owner from ambient request state** — out of scope.
1127
+ - **Native time-limited grants, deny-rules, role inheritance, global
1128
+ superadmin across tenants** — unsupported unless a later RFC says otherwise.
1129
+ - **Row-level ACLs / Policy Scopes inside the gem** — host composes
1130
+ query scopes with `permitted?`; see
1131
+ [Overrides and record-level scoping](#overrides-and-record-level-scoping-host-owned).
1132
+ - **Auto-discovering permissions from all ActiveRecord models** on sync —
1133
+ fragile; use explicit `permissions.rb` (optional scaffold generator later).
1134
+ - **Permission columns `controller` / `controller_method` / parallel `name`**
1135
+ — superseded by a single `key` (`resource.action`).
1136
+ - Turning every row in the scenario inventory into a gem feature — **no**;
1137
+ Host/Deferred/Open rows guide docs and tests, not scope creep.
1138
+
1139
+ ## Open questions
1140
+
1141
+ None for v1 design. Remaining choices are implementation details inside the
1142
+ contracts above (exact exception class names, migration timestamps, etc.).
1143
+
1144
+ ## Related docs
1145
+
1146
+ - [Owner design](superpowers/specs/2026-09-05-owner-role-design.md)
1147
+ - [Usage scenarios (acceptance inventory)](superpowers/specs/2026-09-07-usage-scenarios.md)
1148
+ - [Proposal review](superpowers/specs/2026-09-07-proposal-review.md)
1149
+ - [Proposal](proposal.md)
1150
+
1151
+ ## Next step
1152
+
1153
+ Design docs for v1 are finalized. Next: write the implementation plan under
1154
+ `docs/superpowers/plans/` and build against
1155
+ [usage scenarios](superpowers/specs/2026-09-07-usage-scenarios.md) as the
1156
+ acceptance checklist.
1157
+