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