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.
- checksums.yaml +7 -0
- data/.codegraph/.gitignore +5 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +33 -0
- data/CONTRIBUTING.md +99 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/Rakefile +12 -0
- data/docs/architecture.md +1157 -0
- data/docs/proposal.md +143 -0
- data/docs/superpowers/plans/2026-09-08-access-grant-v1.md +468 -0
- data/docs/superpowers/plans/2026-09-08-gem-release.md +367 -0
- data/docs/superpowers/specs/2026-09-05-owner-role-design.md +271 -0
- data/docs/superpowers/specs/2026-09-07-proposal-review.md +71 -0
- data/docs/superpowers/specs/2026-09-07-usage-scenarios.md +301 -0
- data/docs/superpowers/specs/2026-09-08-gem-release-design.md +82 -0
- data/lib/access_grant/catalog/dsl.rb +138 -0
- data/lib/access_grant/catalog.rb +76 -0
- data/lib/access_grant/configuration.rb +55 -0
- data/lib/access_grant/controller_methods.rb +104 -0
- data/lib/access_grant/models/permission.rb +36 -0
- data/lib/access_grant/models/role.rb +152 -0
- data/lib/access_grant/models/role_permission.rb +11 -0
- data/lib/access_grant/owner.rb +144 -0
- data/lib/access_grant/permission_key.rb +29 -0
- data/lib/access_grant/railtie.rb +17 -0
- data/lib/access_grant/recovery.rb +90 -0
- data/lib/access_grant/sync.rb +68 -0
- data/lib/access_grant/tenant.rb +47 -0
- data/lib/access_grant/user.rb +102 -0
- data/lib/access_grant/version.rb +5 -0
- data/lib/access_grant.rb +125 -0
- data/lib/generators/access_grant/install/install_generator.rb +22 -0
- data/lib/generators/access_grant/install/templates/create_access_grant_tables.rb.tt +39 -0
- data/lib/generators/access_grant/setup/setup_generator.rb +188 -0
- data/lib/generators/access_grant/setup/templates/access_grant.rb.tt +80 -0
- data/lib/generators/access_grant/setup/templates/create_access_grant_user_roles.rb.tt +14 -0
- data/lib/generators/access_grant/setup/templates/permissions.rb.tt +10 -0
- data/lib/generators/access_grant/setup/templates/roles.rb.tt +27 -0
- data/lib/tasks/access_grant_tasks.rake +27 -0
- 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
|
+
|