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,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
|
+
[](https://rubygems.org/gems/access_grant)
|
|
211
|
+
[](https://github.com/SahSantoshh/access_grant/actions/workflows/ci.yml)
|
|
212
|
+
[](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.
|