access_grant 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.codegraph/.gitignore +5 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +98 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +33 -0
  7. data/CONTRIBUTING.md +99 -0
  8. data/Gemfile +11 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +123 -0
  11. data/Rakefile +12 -0
  12. data/docs/architecture.md +1157 -0
  13. data/docs/proposal.md +143 -0
  14. data/docs/superpowers/plans/2026-09-08-access-grant-v1.md +468 -0
  15. data/docs/superpowers/plans/2026-09-08-gem-release.md +367 -0
  16. data/docs/superpowers/specs/2026-09-05-owner-role-design.md +271 -0
  17. data/docs/superpowers/specs/2026-09-07-proposal-review.md +71 -0
  18. data/docs/superpowers/specs/2026-09-07-usage-scenarios.md +301 -0
  19. data/docs/superpowers/specs/2026-09-08-gem-release-design.md +82 -0
  20. data/lib/access_grant/catalog/dsl.rb +138 -0
  21. data/lib/access_grant/catalog.rb +76 -0
  22. data/lib/access_grant/configuration.rb +55 -0
  23. data/lib/access_grant/controller_methods.rb +104 -0
  24. data/lib/access_grant/models/permission.rb +36 -0
  25. data/lib/access_grant/models/role.rb +152 -0
  26. data/lib/access_grant/models/role_permission.rb +11 -0
  27. data/lib/access_grant/owner.rb +144 -0
  28. data/lib/access_grant/permission_key.rb +29 -0
  29. data/lib/access_grant/railtie.rb +17 -0
  30. data/lib/access_grant/recovery.rb +90 -0
  31. data/lib/access_grant/sync.rb +68 -0
  32. data/lib/access_grant/tenant.rb +47 -0
  33. data/lib/access_grant/user.rb +102 -0
  34. data/lib/access_grant/version.rb +5 -0
  35. data/lib/access_grant.rb +125 -0
  36. data/lib/generators/access_grant/install/install_generator.rb +22 -0
  37. data/lib/generators/access_grant/install/templates/create_access_grant_tables.rb.tt +39 -0
  38. data/lib/generators/access_grant/setup/setup_generator.rb +188 -0
  39. data/lib/generators/access_grant/setup/templates/access_grant.rb.tt +80 -0
  40. data/lib/generators/access_grant/setup/templates/create_access_grant_user_roles.rb.tt +14 -0
  41. data/lib/generators/access_grant/setup/templates/permissions.rb.tt +10 -0
  42. data/lib/generators/access_grant/setup/templates/roles.rb.tt +27 -0
  43. data/lib/tasks/access_grant_tasks.rake +27 -0
  44. metadata +121 -0
@@ -0,0 +1,367 @@
1
+ # Gem Release Automation Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-ruby:subagent-driven-development (recommended) or superpowers-ruby:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship a manually triggered GitHub Actions release that publishes `access_grant` 1.0.0 to RubyGems via Trusted Publishing, creates a GitHub Release, and shows version/CI/license badges on the README.
6
+
7
+ **Architecture:** Hybrid release — humans bump `version.rb` + `CHANGELOG.md` on `main`; `workflow_dispatch` with a `version` input guards those values, then `rubygems/release-gem` (OIDC) tags and pushes the gem; a follow-up step opens the GitHub Release from the matching CHANGELOG section.
8
+
9
+ **Tech Stack:** GitHub Actions, `rubygems/release-gem@v1`, Bundler gem tasks, Keep a Changelog, shields.io / badge.fury badges.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-08-gem-release-design.md`
12
+
13
+ ---
14
+
15
+ ## File map
16
+
17
+ | File | Responsibility |
18
+ |------|----------------|
19
+ | `.github/workflows/release.yml` | Manual release: version guards, gem publish, GitHub Release |
20
+ | `lib/access_grant/version.rb` | Canonical gem version (`1.0.0`) |
21
+ | `CHANGELOG.md` | Keep a Changelog notes for `1.0.0` |
22
+ | `README.md` | Badges + published-gem status wording |
23
+ | `CONTRIBUTING.md` | Human release steps + Trusted Publisher setup |
24
+ | `access_grant.gemspec` | Align `homepage` / metadata URIs with GitHub owner `SahSantoshh` if needed |
25
+
26
+ ---
27
+
28
+ ### Task 1: Bump version and CHANGELOG to 1.0.0
29
+
30
+ **Files:**
31
+ - Modify: `lib/access_grant/version.rb`
32
+ - Modify: `CHANGELOG.md`
33
+
34
+ - [ ] **Step 1: Set version constant to 1.0.0**
35
+
36
+ Replace contents of `lib/access_grant/version.rb` with:
37
+
38
+ ```ruby
39
+ # frozen_string_literal: true
40
+
41
+ module AccessGrant
42
+ VERSION = "1.0.0"
43
+ end
44
+ ```
45
+
46
+ - [ ] **Step 2: Move Unreleased notes under `[1.0.0]`**
47
+
48
+ Update `CHANGELOG.md` so that:
49
+
50
+ 1. `## [Unreleased]` remains, with no entries yet (empty section or just the heading).
51
+ 2. Current bullet list under `[Unreleased]` moves under `## [1.0.0] - 2026-09-08`.
52
+ 3. Keep the existing `## [0.1.0] - 2026-09-05` skeleton entry below.
53
+
54
+ Expected shape:
55
+
56
+ ```markdown
57
+ ## [Unreleased]
58
+
59
+ ## [1.0.0] - 2026-09-08
60
+
61
+ ### Added
62
+
63
+ - **Configuration** — …
64
+ (…all former Unreleased Added bullets…)
65
+
66
+ ## [0.1.0] - 2026-09-05
67
+
68
+ - Initial gem skeleton (pre-release, unpublished).
69
+ ```
70
+
71
+ - [ ] **Step 3: Commit**
72
+
73
+ ```bash
74
+ git add lib/access_grant/version.rb CHANGELOG.md
75
+ git commit -m "$(cat <<'EOF'
76
+ chore: bump version to 1.0.0
77
+
78
+ Prepare the first public release notes and version constant for
79
+ RubyGems / GitHub Release publishing.
80
+ EOF
81
+ )"
82
+ ```
83
+
84
+ ---
85
+
86
+ ### Task 2: Add release workflow
87
+
88
+ **Files:**
89
+ - Create: `.github/workflows/release.yml`
90
+
91
+ - [ ] **Step 1: Create the workflow file**
92
+
93
+ Create `.github/workflows/release.yml` with exactly this content (no GitHub Environment block — Trusted Publisher environment must stay blank to match):
94
+
95
+ ```yaml
96
+ name: Release
97
+
98
+ on:
99
+ workflow_dispatch:
100
+ inputs:
101
+ version:
102
+ description: "Gem version to release (must match AccessGrant::VERSION)"
103
+ required: true
104
+ type: string
105
+
106
+ permissions:
107
+ contents: read
108
+
109
+ jobs:
110
+ release:
111
+ name: Publish gem ${{ inputs.version }}
112
+ runs-on: ubuntu-latest
113
+ if: github.ref == 'refs/heads/main'
114
+
115
+ permissions:
116
+ contents: write
117
+ id-token: write
118
+
119
+ steps:
120
+ - name: Checkout
121
+ uses: actions/checkout@v4
122
+ with:
123
+ fetch-depth: 0
124
+
125
+ - name: Set up Ruby
126
+ uses: ruby/setup-ruby@v1
127
+ with:
128
+ ruby-version: "3.1"
129
+ bundler-cache: true
130
+
131
+ - name: Assert version matches
132
+ env:
133
+ EXPECTED_VERSION: ${{ inputs.version }}
134
+ run: |
135
+ set -euo pipefail
136
+ ACTUAL="$(ruby -r./lib/access_grant/version -e 'print AccessGrant::VERSION')"
137
+ if [ "$ACTUAL" != "$EXPECTED_VERSION" ]; then
138
+ echo "::error::AccessGrant::VERSION ($ACTUAL) != workflow input ($EXPECTED_VERSION)"
139
+ exit 1
140
+ fi
141
+ if ! grep -qE "^## \[${EXPECTED_VERSION}\]" CHANGELOG.md; then
142
+ echo "::error::CHANGELOG.md missing heading ## [${EXPECTED_VERSION}]"
143
+ exit 1
144
+ fi
145
+ echo "Releasing access_grant ${EXPECTED_VERSION}"
146
+
147
+ - name: Publish to RubyGems
148
+ uses: rubygems/release-gem@v1
149
+
150
+ - name: Extract changelog notes
151
+ id: notes
152
+ env:
153
+ VERSION: ${{ inputs.version }}
154
+ run: |
155
+ set -euo pipefail
156
+ NOTES_FILE="${RUNNER_TEMP}/release_notes.md"
157
+ awk -v ver="$VERSION" '
158
+ $0 ~ "^## \\[" ver "\\]" {capture=1; next}
159
+ /^## \[/ {if (capture) exit}
160
+ capture {print}
161
+ ' CHANGELOG.md > "$NOTES_FILE"
162
+ if [ ! -s "$NOTES_FILE" ]; then
163
+ echo "::error::No CHANGELOG body found for ${VERSION}"
164
+ exit 1
165
+ fi
166
+ {
167
+ echo "path=${NOTES_FILE}"
168
+ } >> "$GITHUB_OUTPUT"
169
+
170
+ - name: Create GitHub Release
171
+ uses: softprops/action-gh-release@v2
172
+ with:
173
+ tag_name: v${{ inputs.version }}
174
+ name: v${{ inputs.version }}
175
+ body_path: ${{ steps.notes.outputs.path }}
176
+ fail_on_unmatched_files: true
177
+ ```
178
+
179
+ Notes for implementers:
180
+
181
+ - `rubygems/release-gem` runs Bundler’s `rake release` (build, tag `vX.Y.Z`, push tag, `gem push` via OIDC).
182
+ - Do **not** add `environment: release` unless RubyGems Trusted Publisher is also configured with that environment name.
183
+ - Trusted Publisher on RubyGems must use workflow filename `release.yml` and repository `SahSantoshh/access_grant`.
184
+
185
+ - [ ] **Step 2: Commit**
186
+
187
+ ```bash
188
+ git add .github/workflows/release.yml
189
+ git commit -m "$(cat <<'EOF'
190
+ ci: add manual RubyGems release workflow
191
+
192
+ Add workflow_dispatch release with version guards, Trusted Publishing
193
+ via rubygems/release-gem, and GitHub Release notes from CHANGELOG.
194
+ EOF
195
+ )"
196
+ ```
197
+
198
+ ---
199
+
200
+ ### Task 3: README badges and status wording
201
+
202
+ **Files:**
203
+ - Modify: `README.md`
204
+
205
+ - [ ] **Step 1: Add badges and update status blurb**
206
+
207
+ At the top of `README.md`, immediately after `# AccessGrant`, insert:
208
+
209
+ ```markdown
210
+ [![Gem Version](https://badge.fury.io/rb/access_grant.svg)](https://rubygems.org/gems/access_grant)
211
+ [![CI](https://github.com/SahSantoshh/access_grant/actions/workflows/ci.yml/badge.svg)](https://github.com/SahSantoshh/access_grant/actions/workflows/ci.yml)
212
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
213
+ ```
214
+
215
+ Replace the status blockquote:
216
+
217
+ ```markdown
218
+ > **Status: v1 implementation.** Full design:
219
+ > [`docs/architecture.md`](docs/architecture.md),
220
+ > [`docs/proposal.md`](docs/proposal.md).
221
+ ```
222
+
223
+ with:
224
+
225
+ ```markdown
226
+ > **Status:** v1.0 published on [RubyGems](https://rubygems.org/gems/access_grant).
227
+ > Design: [`docs/architecture.md`](docs/architecture.md),
228
+ > [`docs/proposal.md`](docs/proposal.md).
229
+ ```
230
+
231
+ Preserve the rest of the README unchanged unless badges were already partially added — do not duplicate badge lines.
232
+
233
+ - [ ] **Step 2: Align gemspec homepage with GitHub owner (if mismatched)**
234
+
235
+ If `access_grant.gemspec` still points at `https://github.com/santoshsah/access_grant`, update `spec.homepage` (and derived metadata URIs) to `https://github.com/SahSantoshh/access_grant` so RubyGems links match the real repo.
236
+
237
+ - [ ] **Step 3: Commit**
238
+
239
+ ```bash
240
+ git add README.md access_grant.gemspec
241
+ git commit -m "$(cat <<'EOF'
242
+ docs: add release badges and published status
243
+
244
+ Surface RubyGems/CI/license badges and point homepage metadata at the
245
+ canonical GitHub repository.
246
+ EOF
247
+ )"
248
+ ```
249
+
250
+ ---
251
+
252
+ ### Task 4: Update CONTRIBUTING release docs
253
+
254
+ **Files:**
255
+ - Modify: `CONTRIBUTING.md` (section `## Versioning & releases`)
256
+
257
+ - [ ] **Step 1: Replace the release section**
258
+
259
+ Replace the entire `## Versioning & releases` section with:
260
+
261
+ ```markdown
262
+ ## Versioning & releases
263
+
264
+ This project follows [Semantic Versioning](https://semver.org/).
265
+
266
+ ### Cut a release
267
+
268
+ 1. Open a PR on `main` that:
269
+ - Bumps `AccessGrant::VERSION` in `lib/access_grant/version.rb`
270
+ - Moves `[Unreleased]` entries in `CHANGELOG.md` under
271
+ `## [X.Y.Z] - YYYY-MM-DD` and leaves a fresh empty `[Unreleased]`
272
+ - Uses commit message `chore: release vX.Y.Z`
273
+ 2. Merge the PR and wait for CI on `main` to pass.
274
+ 3. In GitHub Actions, run the **Release** workflow on `main` with input
275
+ `version` set to `X.Y.Z` (must match `AccessGrant::VERSION`).
276
+ 4. The workflow:
277
+ - Fails fast if the version or CHANGELOG heading does not match
278
+ - Publishes the gem to RubyGems.org via Trusted Publishing (OIDC)
279
+ - Creates git tag `vX.Y.Z` and a GitHub Release from the CHANGELOG section
280
+
281
+ Do **not** run `bundle exec rake release` locally for production publishes;
282
+ Actions owns tagging and `gem push`.
283
+
284
+ ### One-time RubyGems setup
285
+
286
+ 1. Create a RubyGems.org account and enable MFA.
287
+ 2. Configure a [Trusted Publisher](https://guides.rubygems.org/trusted-publishing/)
288
+ for gem `access_grant`:
289
+ - Repository owner: `SahSantoshh`
290
+ - Repository name: `access_grant`
291
+ - Workflow filename: `release.yml`
292
+ - Environment: leave blank (must match the workflow — no `environment:` key)
293
+ 3. For the first publish, use RubyGems’ pending trusted-publisher flow if the
294
+ gem name is not on the index yet.
295
+ ```
296
+
297
+ - [ ] **Step 2: Commit**
298
+
299
+ ```bash
300
+ git add CONTRIBUTING.md
301
+ git commit -m "$(cat <<'EOF'
302
+ docs: document hybrid Actions release process
303
+
304
+ Replace local rake release instructions with the manual workflow_dispatch
305
+ flow and Trusted Publisher setup notes.
306
+ EOF
307
+ )"
308
+ ```
309
+
310
+ ---
311
+
312
+ ### Task 5: Verify locally (no publish)
313
+
314
+ **Files:** none (verification only)
315
+
316
+ - [ ] **Step 1: Confirm version loads**
317
+
318
+ Run:
319
+
320
+ ```bash
321
+ ruby -r./lib/access_grant/version -e 'puts AccessGrant::VERSION'
322
+ ```
323
+
324
+ Expected: `1.0.0`
325
+
326
+ - [ ] **Step 2: Confirm CHANGELOG heading exists**
327
+
328
+ Run:
329
+
330
+ ```bash
331
+ grep -E '^## \[1\.0\.0\]' CHANGELOG.md
332
+ ```
333
+
334
+ Expected: a line starting with `## [1.0.0]`
335
+
336
+ - [ ] **Step 3: Confirm workflow file is valid YAML-ish**
337
+
338
+ Run:
339
+
340
+ ```bash
341
+ ruby -ryaml -e 'YAML.load_file(".github/workflows/release.yml"); puts "ok"'
342
+ ```
343
+
344
+ Expected: `ok`
345
+
346
+ - [ ] **Step 4: Do not publish from the agent**
347
+
348
+ Publishing requires the maintainer to configure Trusted Publishing and click **Run workflow**. After merge to `main`, the human runs Release with `version: 1.0.0`.
349
+
350
+ ---
351
+
352
+ ## Spec coverage checklist
353
+
354
+ | Spec requirement | Task |
355
+ |------------------|------|
356
+ | Manual `workflow_dispatch` + version input | Task 2 |
357
+ | Hybrid version bump + CHANGELOG 1.0.0 | Task 1 |
358
+ | Trusted Publishing / `rubygems/release-gem` | Task 2 |
359
+ | GitHub Release from CHANGELOG | Task 2 |
360
+ | README badges | Task 3 |
361
+ | CONTRIBUTING update | Task 4 |
362
+ | No CI matrix in release job | Task 2 (omitted by design) |
363
+ | No yank / no changelog automation | Out of scope (omitted) |
364
+
365
+ ## Handoff note for humans
366
+
367
+ After this lands on `main`: configure Trusted Publisher on RubyGems, then run **Actions → Release → Run workflow** with `version: 1.0.0`.
@@ -0,0 +1,271 @@
1
+ # Owner role for tenant creator
2
+
3
+ > Status: **design finalized** (docs only; not yet implemented)
4
+ > Date: 2026-09-05; updated 2026-09-07
5
+
6
+ Privileged Owner role with explicit host-side assignment. The gem
7
+ provides the Owner API and rules; the host app decides who becomes
8
+ Owner and when (for example after creating an organization).
9
+
10
+ ## Responsibility split
11
+
12
+ **Gem owns**
13
+
14
+ - Privileged Owner behavior via `config.owner_role`
15
+ (`:protected` | `:bypass` | `:both` | `:none`)
16
+ - `grant_owner!(user)` / `revoke_owner!(user)` on the tenant
17
+ (multi-tenant) or `AccessGrant.grant_owner!` /
18
+ `AccessGrant.revoke_owner!` (single-tenant)
19
+ - Enforce “at least one Owner” when revoking, once an Owner exists
20
+ - Seed/protect Owner rows and re-attach catalog keys on sync for
21
+ `:protected` / `:both`
22
+ - Generators, DSL, `permitted?`, and optional controller authorize hook
23
+ (no Policy classes)
24
+
25
+ **Host app owns**
26
+
27
+ - Who the org creator is
28
+ - When to call `grant_owner!` (controller, service, callback, seeds)
29
+ - Single-tenant first Owner (seeds or console)
30
+
31
+ The gem does **not** auto-detect a creator for `grant_owner!`. The authorize
32
+ hook defaults to controller methods `current_user` / `current_tenant` only
33
+ at the edge (host implements `current_tenant` when multi-tenant).
34
+
35
+ ```ruby
36
+ # Host app — project responsibility
37
+ org = Organization.create!(name: "Acme")
38
+ org.grant_owner!(current_user)
39
+ ```
40
+
41
+ ## Data model
42
+
43
+ **Phase 1 — `rails g access_grant:install`**
44
+
45
+ Creates core tables only (no tenant or user assumptions):
46
+
47
+ - `permissions` — catalog keys
48
+ - `roles` — `name` only (no tenant column yet)
49
+ - `role_permissions` — role ↔ permission
50
+
51
+ **Phase 2 — `rails g access_grant:setup`**
52
+
53
+ Accepts flags (non-interactive) or asks interactively when omitted:
54
+
55
+ ```
56
+ rails g access_grant:setup \
57
+ --multi-tenant \
58
+ --tenant=Organization \
59
+ --user=User \
60
+ --owner-role=protected
61
+ ```
62
+
63
+ | Flag | Meaning | Default |
64
+ |---|---|---|
65
+ | `--multi-tenant` / `--single-tenant` | Scope mode | asked if omitted |
66
+ | `--tenant=Organization` | Tenant class (multi-tenant only) | `Organization` |
67
+ | `--user=User` | User class | `User` |
68
+ | `--owner-role=protected` | Owner mechanism | `protected` |
69
+
70
+ Writes:
71
+
72
+ - Migrations for the tenant FK on `roles` (multi-tenant only) and the
73
+ user↔role join (`user_roles`, `account_roles`, …)
74
+ - `config/initializers/access_grant.rb` with the chosen classes and
75
+ Owner settings
76
+ - **Patches host models:** inserts `access_grant :tenant` into the
77
+ tenant model file and `access_grant :user` into the user model
78
+ file (skips if already present; fails clearly if the model file
79
+ cannot be found)
80
+
81
+ ```
82
+ Organization ──< Role >── role_permissions ── Permission
83
+
84
+ └──< user_roles >── User
85
+ ```
86
+
87
+ No Membership model. Membership remains a host concern if the app
88
+ needs it; AccessGrant attaches roles to the configured user.
89
+
90
+ ## DSL
91
+
92
+ ```ruby
93
+ class Organization < ApplicationRecord
94
+ access_grant :tenant
95
+ end
96
+
97
+ class User < ApplicationRecord
98
+ access_grant :user
99
+ end
100
+ ```
101
+
102
+ Replaces the earlier proposed `acts_as_permission_tenant` /
103
+ `acts_as_permissible`. One method, two hats. The `setup` generator
104
+ inserts these lines into the named model files; the host can still
105
+ add or edit them by hand if needed.
106
+
107
+ ## Owner behavior
108
+
109
+ ### Config
110
+
111
+ ```ruby
112
+ AccessGrant.configure do |config|
113
+ config.owner_role = :protected # :protected | :bypass | :both | :none
114
+ config.owner_role_name = "Owner"
115
+ end
116
+ ```
117
+
118
+ Default mechanism is `:protected` when the host skips the setup
119
+ question.
120
+
121
+ ### Assignment API
122
+
123
+ ```ruby
124
+ # Multi-tenant
125
+ org.grant_owner!(maya)
126
+ org.revoke_owner!(maya)
127
+ org.grant_owner!(jordan) # multiple Owners allowed
128
+
129
+ # Single-tenant
130
+ AccessGrant.grant_owner!(maya)
131
+ AccessGrant.revoke_owner!(maya)
132
+ ```
133
+
134
+ `grant_owner!`:
135
+
136
+ 1. Finds or creates the Owner role for that scope
137
+ 2. Applies the configured mechanism (attach all catalog keys when
138
+ `:protected` or `:both`)
139
+ 3. Inserts the user↔role row
140
+
141
+ Creating a tenant without calling `grant_owner!` is allowed. The gem
142
+ does not enforce a first Owner on create. The “at least one Owner”
143
+ rule applies only when **revoking**: you cannot go from one Owner to
144
+ zero. Ops can still recover via `access_grant:grant_role`.
145
+
146
+ If `owner_role` is `:none`, `grant_owner!` raises so the host notices
147
+ Owner is turned off.
148
+
149
+ ### Mechanisms
150
+
151
+ | `owner_role` | Role row | Permission rows | `permitted?` | Strip / delete Owner | Remove last Owner |
152
+ |---|---|---|---|---|---|
153
+ | `:none` | not seeded | — | normal join | n/a | n/a |
154
+ | `:protected` | seeded | every catalog key; sync re-attaches | normal join | no | no |
155
+ | `:bypass` | seeded | optional | short-circuit true if user has Owner | role assignable | no (still block last) |
156
+ | `:both` | seeded | every catalog key + sync | short-circuit true | no | no |
157
+
158
+ Plain-language examples:
159
+
160
+ - **`:protected`** — Owner is a full keyring in the database. Maya
161
+ cannot uncheck permissions on Owner or delete the role. She can
162
+ make Jordan an Owner, then give up her own key.
163
+ - **`:bypass`** — Having the Owner role name is enough for
164
+ `permitted?` to return true, even with empty permission rows.
165
+ - **`:both`** — Full keyring and short-circuit.
166
+ - **`:none`** — No special Owner; host builds roles only; lockout
167
+ rake task is the floor.
168
+
169
+ ### Catalog sync
170
+
171
+ `access_grant:sync_permissions` upserts catalog keys. For
172
+ `:protected` and `:both`, it also re-attaches every catalog
173
+ permission to every Owner role in scope.
174
+
175
+ ## Permission checks
176
+
177
+ ```ruby
178
+ # Multi-tenant — “Can Maya run invoices#index in Acme?”
179
+ maya.permitted?("invoices.index", tenant: acme)
180
+
181
+ # Single-tenant
182
+ maya.permitted?("invoices.index")
183
+ ```
184
+
185
+ Missing `tenant:` in multi-tenant mode **raises**. Unknown keys **raise**
186
+ (including under Owner bypass). No gem-level memoization — see
187
+ architecture resolved decisions.
188
+
189
+ ## Error cases
190
+
191
+ - **Forgot first Owner** — Acme exists with zero Owners until the
192
+ host calls `grant_owner!` or ops runs the rake task. Allowed.
193
+ - **Revoke last Owner** — fails with a clear error that the scope
194
+ must keep at least one Owner (`:protected`, `:bypass`, `:both`).
195
+ - **Wrong tenant** — `maya.permitted?(:key, tenant: beta)` is false
196
+ if Maya only has roles on Acme.
197
+ - **Owner off** — `grant_owner!` raises when `owner_role` is `:none`.
198
+
199
+ ## Lockout escape hatch
200
+
201
+ Operational rake task remains for recovery (including zero-Owner
202
+ scopes after a forgotten grant, or data repair):
203
+
204
+ ```
205
+ bundle exec rake access_grant:grant_role[role_name,user_id]
206
+ ```
207
+
208
+ Out of band only — not an ambient bypass in `permitted?` beyond the
209
+ configured Owner mechanism.
210
+
211
+ ## Out of scope
212
+
213
+ - Auto-granting Owner from `Current.user` or a creator association
214
+ - Membership as the gem’s identity model
215
+ - Fully tenant-less installs beyond single-tenant mode (roles with
216
+ no tenant column)
217
+ - Engine implementation (this document is design only)
218
+
219
+ ## Decisions
220
+
221
+ - **Owner is privileged (configurable floor).** Revises the earlier
222
+ “no unrevokable super-admin” product requirement. Rationale: orgs
223
+ need a floor so the creator cannot lock themselves out by editing
224
+ Owner’s permissions.
225
+ - **Privilege mechanism is a host choice** — `:protected` (default),
226
+ `:bypass`, `:both`, or `:none`. Rationale: different hosts want
227
+ different floors; the gem should not hardcode one mechanism.
228
+ - **Assignment is always explicit via `grant_owner!`.** The host
229
+ decides who the creator is and when to grant. Rationale: detecting
230
+ “who created the org” is project responsibility, not the gem’s.
231
+ - **Creating a tenant without an Owner is allowed.** The gem does
232
+ not enforce first Owner on create. Rationale: follows from
233
+ host-owned assignment.
234
+ - **Multiple Owners per scope are allowed.** Rationale: orgs have
235
+ more than one person who needs the floor.
236
+ - **At least one Owner when revoking.** You cannot remove the last
237
+ Owner in a scope (when Owner is enabled). Rationale: the house
238
+ must keep a key-holder; the rake task is for ops, not accidental
239
+ zero Owners.
240
+ - **No Membership in the gem.** User model is named at setup; join
241
+ is `#{user}_roles` / `user_roles`. Rationale: Membership is a host concern;
242
+ roles already belong to the tenant.
243
+ - **`permitted?` takes an optional tenant** in multi-tenant mode.
244
+ Rationale: without Membership, tenant must be passed when a user
245
+ can belong to many orgs.
246
+ - **Two-phase generator** — `access_grant:install` then
247
+ `access_grant:setup`. Setup accepts `--tenant`, `--user`,
248
+ `--multi-tenant` / `--single-tenant`, and `--owner-role` (or asks
249
+ interactively). Rationale: tenant wiring may not exist on day one;
250
+ flags keep CI/scripts non-interactive.
251
+ - **Setup patches model files** with `access_grant :tenant` and
252
+ `access_grant :user`. Rationale: install should leave the host
253
+ wired without a manual model edit; skip if already present.
254
+ - **DSL: `access_grant :tenant` / `access_grant :user`.** Replaces
255
+ `acts_as_permission_tenant` / `acts_as_permissible`. Rationale:
256
+ one method, two hats, simpler naming.
257
+ - **Default `owner_role` is `:protected`.** Rationale: explicit
258
+ permission rows for everything, no `permitted?` short-circuit
259
+ unless the host opts in.
260
+ - **No gem auto-detect of creator** (`Current.user`, creator
261
+ columns, etc.). Rationale: that responsibility stays on the
262
+ project side; the gem only exposes the API to configure and call.
263
+ - **Permission keys are only `resource.action`** (strict regex; nothing
264
+ else stored). Keys are never taken from request params. Rationale:
265
+ predictable catalog, no injection-style free-text keys, harder to
266
+ misuse in authorize paths.
267
+ - **Public-contract edge cases** (missing tenant, unknown keys,
268
+ freshness, Owner name reservation, catalog retirement, prefixed
269
+ tables) are resolved in architecture — see
270
+ [architecture.md](../../architecture.md#resolved-public-contract-decisions)
271
+ and the [usage scenarios](2026-09-07-usage-scenarios.md) inventory.