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,71 @@
1
+ # AccessGrant — Proposal Review
2
+
3
+ > Adopted into this repo from an external design review (2026-09-07).
4
+ > Author responses / resolutions live in
5
+ > [architecture.md](../../architecture.md) under **Resolved public-contract
6
+ > decisions**. Scenario IDs: [2026-09-07-usage-scenarios.md](2026-09-07-usage-scenarios.md).
7
+
8
+ The core design makes sense: a code-defined catalog, database-backed roles and permission mappings, additive permissions, and explicit tenant context. The separation between capability checks and host-owned record/ownership rules is also well explained.
9
+
10
+ I’d keep that shape. My main focus before implementation would be making the public API complete and predictable. This is already a good entry point:
11
+
12
+ ```ruby
13
+ maya.permitted?(:manage_billing, tenant: acme)
14
+ ```
15
+
16
+ A few targeted things would help:
17
+
18
+ ## 1. Show the ordinary role-management flow end to end
19
+
20
+ Owner has a clear assignment API, but the everyday workflow is less concrete. Could we show creating a Billing role, selecting permissions, assigning it to Maya, checking access, editing its permissions, and removing the assignment?
21
+
22
+ Use the actual intended model associations and methods—no additional wrappers needed where ActiveRecord is already clear. Include how an admin form reads the available catalog and selected permissions.
23
+
24
+ That example would let us judge the API as a whole: can someone understand and use this without reading the implementation?
25
+
26
+ ## 2. Resolve two edge cases in `permitted?`
27
+
28
+ ```ruby
29
+ maya.permitted?(:manage_billing) # Multi-tenant install
30
+ maya.permitted?(:misspelled_key, tenant: acme)
31
+ ```
32
+
33
+ The tenant argument is described as optional, but the text also says it must be passed. I’d recommend raising clearly when it is missing in multi-tenant mode.
34
+
35
+ For unknown keys, the normal database query appears to return false, while Owner bypass appears to return true. Please make the intended behavior explicit. I lean toward surfacing an unknown-key error consistently, since otherwise typos can be hidden while developing as Owner.
36
+
37
+ ## 3. Define when permission edits take effect
38
+
39
+ The proposal specifies per-instance memoization. What happens here?
40
+
41
+ ```ruby
42
+ maya.permitted?(:manage_billing, tenant: acme) # => true
43
+
44
+ # An admin removes that grant through a separately loaded role instance.
45
+ # The change commits.
46
+
47
+ maya.permitted?(:manage_billing, tenant: acme) # => ?
48
+ ```
49
+
50
+ Runtime editing is the central feature, so freshness should be part of the public contract. Specify the cache lifetime/invalidation behavior, or start without gem-level memoization.
51
+
52
+ For mutations, also document that repeated assignment does not create duplicates and replacing a permission set cannot leave a partially applied change if validation fails.
53
+
54
+ ## 4. Tighten the enforcement boundary around Owner
55
+
56
+ The modes and their rationale are already documented. The remaining questions concern how their guarantees hold:
57
+
58
+ - Since bypass uses the configured Owner name, can creating or renaming an ordinary role accidentally make it privileged? Reserve/protect that identity explicitly.
59
+ - Last-Owner revocation is prohibited, but what happens with concurrent revocations, direct assignment deletion, or deletion of the person?
60
+
61
+ These don’t require more public configuration. They need a precise statement of which paths uphold the invariant and tests for those paths.
62
+
63
+ ## 5. Confirm catalog retirement behavior
64
+
65
+ Sync deliberately retains permission rows, and checks query those rows. My reading is that removing a key from the code catalog does **not** revoke existing grants. Is that intended?
66
+
67
+ If so, document it directly, along with the supported way to retire a permission. That makes the existing non-destructive sync decision understandable operationally.
68
+
69
+ Two smaller integration points: update the README to the approved person-model DSL/setup, and consider namespaced tables and associations so installation does not collide with an existing `roles` structure.
70
+
71
+ The quality bar I’m aiming for is a small Rails gem whose normal usage is obvious and whose edge cases are unsurprising. I’d prioritize that complete example and these guarantees before expanding the API. A sub-1,000-line production implementation is a useful constraint—including generators and migration templates—but clarity and correctness should determine whether the shape is right.
@@ -0,0 +1,301 @@
1
+ # AccessGrant — Usage Scenarios and Proposal Coverage
2
+
3
+ > Adopted into this repo from an external design review (2026-09-07).
4
+ > Companion to [architecture.md](../../architecture.md),
5
+ > [2026-09-05-owner-role-design.md](2026-09-05-owner-role-design.md), and
6
+ > [2026-09-07-proposal-review.md](2026-09-07-proposal-review.md).
7
+ >
8
+ > This is a scenario inventory for acceptance checks — **not** a v1 feature
9
+ > shopping list. Status meanings and priorities below still apply. Where
10
+ > architecture later **resolves** an Open/Partial item, treat architecture
11
+ > as authoritative and update the row when implementing tests.
12
+
13
+ > Terminology note (2026-09-07): the assignable identity is **user**
14
+ > (`access_grant :user`, `user_class`, `current_user`), not “person.”
15
+ > Older rows in this inventory may still say “person”; treat that as **user**.
16
+
17
+ Companion to the proposal review; this is a scenario inventory, not a replacement RFC.
18
+
19
+ **Reviewed baseline:** commit `66c7fcc20e167c45160ea285cc1766dbab2d6e4d` (and later design brainstorm). Database-backed catalog and grants, configured person model, explicit tenant checks, and all four Owner modes are preserved. The repository contains design documentation and scaffolding only. **Covered means specified, not implemented or tested.**
20
+
21
+ This is a broad inventory of realistic happy paths, edge cases, and integration boundaries—not a claim to enumerate every possible application. Missing optional features are not automatically v1 requirements. Use the IDs to record decisions and later link executable tests.
22
+
23
+ ## How to read this
24
+
25
+ | Status | Meaning |
26
+ |---|---|
27
+ | Covered | Explicitly specified, or a direct consequence of an explicit rule. Preserve and test. |
28
+ | Partial | Intent exists, but the public contract is incomplete or ambiguous. |
29
+ | Inferred | Likely behavior from the described schema/query, not an explicit promise. Confirm. |
30
+ | Open | No clear contract found. Decide, document a limitation, or defer. |
31
+ | Host | Host owns this application behavior; document integration rather than expanding the gem. |
32
+ | Deferred | Latest proposal explicitly excludes it. Not a design defect. |
33
+ | Conflict | Documents disagree, or the prescribed sequence needs correction for the stated scenario. |
34
+
35
+ “Desired behavior / interpretation” is review guidance unless marked Covered. It must not be confused with a promise already made by the author. Public APIs not present in the proposal are intentionally not invented here.
36
+
37
+ ## Source map
38
+
39
+ - **A — [Architecture](../../architecture.md):** model, DSL, sync, defaults, guardrails, installation, resolved public-contract decisions.
40
+ - **O — [Owner specification](2026-09-05-owner-role-design.md):** approved Owner and person-model decisions.
41
+ - **P — [Proposal](../../proposal.md):** purpose, product scope, non-goals.
42
+ - **R — [README](../../../README.md):** planned install/usage (kept aligned with architecture).
43
+ - **Review — [Proposal review](2026-09-07-proposal-review.md):** external review that drove several resolutions.
44
+
45
+ ## The usage we should be able to demonstrate
46
+
47
+ These calls are actually proposed:
48
+
49
+ ```ruby
50
+ class Organization < ApplicationRecord
51
+ access_grant :tenant
52
+ end
53
+
54
+ class User < ApplicationRecord
55
+ access_grant :person
56
+ end
57
+
58
+ AccessGrant.permissions do
59
+ category "billing" do
60
+ permission :view_billing, "View invoices"
61
+ permission :manage_billing, "Create and edit invoices"
62
+ end
63
+ end
64
+
65
+ # After deployment and access_grant:sync_permissions:
66
+ acme.grant_owner!(maya)
67
+ maya.permitted?(:manage_billing, tenant: acme)
68
+ ```
69
+
70
+ The missing ordinary-role walkthrough should use the author's intended ActiveRecord associations/methods to: create Billing Viewer → select view_billing → assign Maya → check Acme and Beta → edit grants → check again → revoke assignment. Show catalog enumeration and current selections for an admin form. The model supports this intent; the exact public workflow is not fully documented.
71
+
72
+ **Important:** Maya can legitimately hold roles in both Acme and Beta. Assigning a Beta role to a global person is not inherently a tenant mismatch. The host authorizes the acting administrator; any mutation that separately accepts tenant context must validate consistency.
73
+
74
+
75
+ **Inventory:** 116 scenarios. Covered: 31, Partial: 11, Open: 43, Conflict: 2, Inferred: 5, Host: 21, Deferred: 3.
76
+
77
+
78
+ ## Setup and adoption
79
+
80
+ Evidence: A: Extension points, Generators, Compatibility; O: Decisions; R. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
81
+
82
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
83
+ |---|---|---|---|---|
84
+ | S001 | Install with Organization and User | Covered | Use install then setup with explicit flags; generate tenant/person wiring. | Exercise the documented generated installation. |
85
+ | S002 | Install a single-tenant application | Covered | Roles are global; checks omit tenant. | Exercise setup, role creation, checks, and recovery in this mode. |
86
+ | S003 | Use a differently named person/tenant model | Covered | Configured classes replace hardcoded User/Organization. | Test custom names and associations. |
87
+ | S004 | Run setup when a model file is missing | Covered | Setup fails clearly rather than silently skipping wiring. | Keep as acceptance test. |
88
+ | S005 | Run setup on already-wired models | Partial | Existing DSL declarations are skipped. | Specify migration/config file conflict behavior; do not overwrite host edits. |
89
+ | S006 | Install into an app already using roles/permissions tables | Open | Installation should avoid collisions or stop clearly. | Choose namespacing/configuration; generic names currently collide. |
90
+ | S007 | Install with UUID or namespaced host models | Open | Generated foreign keys and model lookup should match supported host types. | Declare and test support; avoid implying every Rails model layout works. |
91
+ | S008 | Follow README versus latest design | Conflict | One canonical install and identity example. | README uses old Membership DSL; align with approved User/person setup. |
92
+ | S009 | Adopt during a rolling deployment | Conflict | Old processes must retain columns they still use. | Architecture drops legacy column in cutover deploy; defer drop until old readers/writers are gone. |
93
+ | S010 | Backfill while legacy roles continue changing | Open | Changes during migration must not disappear. | Document coordinated writes or a controlled maintenance cutover. |
94
+
95
+ ## Permission catalog and deployment
96
+
97
+ Evidence: A: Data model, Catalog sync mechanism, Permission catalog convention. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
98
+
99
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
100
+ |---|---|---|---|---|
101
+ | S011 | Declare a new capability | Covered | Add code key and metadata, deploy, run sync. | Keep documented path. |
102
+ | S012 | Admin invents a permission in the UI | Covered | Not supported; admins select existing catalog entries. | Verify host UI does not expose arbitrary catalog creation. |
103
+ | S013 | Update description or category | Covered | Sync updates metadata on existing keys. | Test that role mappings remain intact. |
104
+ | S014 | Repeat an unchanged sync | Covered | Upsert catalog rows without duplicating keys. | Verify idempotence including protected Owner attachment. |
105
+ | S015 | Deploy a key but forget sync | Open | No accidental allow; clear operational diagnosis. | Define check/assignment behavior for code-known but absent database keys. |
106
+ | S016 | Remove a declaration but retain database grants | Inferred | Non-deleting sync plus database joins suggests existing grants remain effective. | Confirm this explicitly; removal from code is not established revocation. |
107
+ | S017 | Rename a capability | Open | Explicitly migrate grants or intentionally reset them. | Document supported sequence; do not infer rename from key removal/addition. |
108
+ | S018 | Reintroduce a removed key | Open | No surprising reuse of old authority. | Explain retained grants and prohibit semantic key reuse without deliberate cleanup. |
109
+ | S019 | Run sync concurrently or interrupt it halfway | Open | Retry should converge; partial completion must be understandable. | Specify transaction/retry behavior for catalog and Owner reattachment. |
110
+ | S020 | Old/new application versions run simultaneously | Open | Catalog/check behavior during overlap is documented. | Test additive rollout and retirement; define authoritative key validation source. |
111
+ | S021 | Declare duplicate, blank, or malformed keys | Open | Fail clearly before ambiguous catalog data is accepted. | Specify validation and duplicate declaration rules. |
112
+ | S022 | Boot/reload Rails or run generators before migrations | Open | Catalog loading is repeatable and does not require unavailable tables. | Test development reload and fresh installation boot. |
113
+
114
+ ## Ordinary roles and admin forms
115
+
116
+ Evidence: A: Data model, Extension points, Role name uniqueness; P: Confirmed product requirements. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
117
+
118
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
119
+ |---|---|---|---|---|
120
+ | S023 | Create Billing Viewer at runtime | Covered | Create tenant role and attach existing view permission without deployment. | Show canonical model-based Ruby example. |
121
+ | S024 | Rename or delete an ordinary role | Covered | Ordinary roles are editable/deletable. | Document supported CRUD and deletion effects on joins. |
122
+ | S025 | Create Admin in Acme and Admin in Beta | Covered | Names are unique within scope, not across tenants. | Keep as tenant separation test. |
123
+ | S026 | Create Admin and admin within one scope | Covered | Case-insensitive uniqueness rejects collision. | Test database enforcement on supported adapters. |
124
+ | S027 | List catalog options for a role editor | Partial | Permission table supplies keys/descriptions/categories. | Show supported query, ordering, and selected-key lookup; no wrapper required. |
125
+ | S028 | List a person’s roles in Acme only | Partial | Person roles association exists and roles carry tenant. | Show canonical scoped query; avoid listing every tenant in an Acme form. |
126
+ | S029 | Replace a role’s selected permission set | Partial | Runtime editing is intended. | Choose supported operation; validate and commit entire replacement atomically. |
127
+ | S030 | Save an empty permission selection | Inferred | Ordinary role with no grants should confer no capabilities. | Specify empty-set semantics and test. |
128
+ | S031 | Add the same permission twice | Partial | Role-permission pair is unique. | Define API outcome: harmless repeat or clear validation error. |
129
+ | S032 | Move an existing role from Acme to Beta | Open | Existing assignments must not silently acquire authority in another scope. | Recommend immutable tenant or an explicitly defined migration operation. |
130
+
131
+ ## Role assignment and removal
132
+
133
+ Evidence: A: Data model, Extension points, Lockout escape hatch. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
134
+
135
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
136
+ |---|---|---|---|---|
137
+ | S033 | Assign several roles to Maya | Covered | Many-to-many roles are a hard requirement. | Show ordinary assignment and removal API. |
138
+ | S034 | Assign Acme and Beta roles to the same Maya | Covered | Valid: person is global and checks select tenant. | Do not incorrectly prohibit cross-tenant roles on one person. |
139
+ | S035 | Assign the same role repeatedly | Open | Prefer one effective assignment without duplicate rows. | Specify join uniqueness and idempotent public behavior. |
140
+ | S036 | Revoke an assignment that does not exist | Open | Prefer harmless repeat for retryable administration. | Define outcome; do not let retries create surprising errors. |
141
+ | S037 | Remove one of two roles granting the same permission | Covered | Union means the other role continues granting it. | Test with cache freshness accounted for. |
142
+ | S038 | Remove the final role granting a permission | Inferred | Fresh normal evaluation should deny. | Specify when memoized checks observe removal. |
143
+ | S039 | Assign an unsaved, wrong-class, or deleted role/person | Open | Reject invalid inputs without partial data. | Define supported persisted inputs and public errors. |
144
+ | S040 | Replace all roles on a person | Open | Useful admin workflow; must preserve selected tenant boundaries. | Document scoped replacement or explicit add/remove sequence; no new helper required. |
145
+ | S041 | Submit a Beta role ID through an Acme admin page | Host | Host must validate the acting admin’s authority and target scope. | If gem API also accepts tenant, enforce consistency; role-only assignment is not intrinsically invalid. |
146
+ | S042 | Two requests concurrently assign the same role | Open | One assignment; predictable retry behavior. | Test uniqueness plus mutation handling, not only validations. |
147
+
148
+ ## Permission-check API
149
+
150
+ Evidence: A: Data model, Guardrail 2; O: Permission checks, Error cases, Decisions. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
151
+
152
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
153
+ |---|---|---|---|---|
154
+ | S043 | Check a granted key in Acme | Covered | permitted?(:key, tenant: acme) returns true. | Keep existing predicate shape. |
155
+ | S044 | Check Acme role against Beta | Covered | False when person has only Acme grants, including Acme Owner. | Keep as acceptance test. |
156
+ | S045 | Check a known key with no matching roles | Inferred | Normal join yields false. | State deny-by-default explicitly. |
157
+ | S046 | Omit tenant in multi-tenant mode | Partial | Signature allows nil; prose says pass tenant. | Resolve ambiguity; recommend clear missing-context error. |
158
+ | S047 | Supply tenant in single-tenant mode | Open | Do not silently imply tenant isolation in a global install. | Define rejection or explicitly documented handling. |
159
+ | S048 | Check unknown/misspelled permission as ordinary person | Inferred | Database join suggests false; validation is not specified. | Choose and document false versus error; error is a review preference. |
160
+ | S049 | Check unknown/misspelled permission as Owner | Partial | Bypass says short-circuit true; key validation order is unstated. | Make behavior explicit across all modes. |
161
+ | S050 | Pass symbol versus string key | Partial | Symbol examples and string storage imply normal usage. | Specify normalization and case sensitivity. |
162
+ | S051 | Pass nil, unsaved, deleted, or wrong-class tenant | Open | No global fallback or accidental allow. | Define invalid-context behavior. |
163
+ | S052 | Change Current.organization after loading a record | Covered | Explicit input prevents ambient state from altering checks. | Retain existing guardrail example. |
164
+ | S053 | Ask why a check passed or list effective permissions | Open | Admin troubleshooting should be possible through documented queries. | Show role/grant inspection; a new explanation API is optional. |
165
+
166
+ ## Freshness, transactions, and failures
167
+
168
+ Evidence: A: Data model (per-instance memoization); P: Runtime-editable permissions. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
169
+
170
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
171
+ |---|---|---|---|---|
172
+ | S054 | Edit a role after an earlier successful check on same instance | Open | Define whether next check sees committed change. | Specify memoization invalidation or lifetime. |
173
+ | S055 | Edit through a second process or loaded instance | Open | Freshness promise must cover ordinary admin edits elsewhere. | Test existing instance against separately committed mutation. |
174
+ | S056 | Check Acme then Beta on the same person instance | Partial | Explicit scope must remain effective under memoization. | Cache key must distinguish tenant and permission. |
175
+ | S057 | Check false, then grant permission | Open | Cached denial must have a defined refresh path too. | Test grants as well as revocations. |
176
+ | S058 | Check while a permission replacement is uncommitted | Open | No partially applied set should be visible. | Define transaction boundary and supported isolation assumptions. |
177
+ | S059 | Two admins replace a role’s permissions concurrently | Open | Each edit is atomic; final result is a coherent set. | Choose serialized replacement or conflict detection, not accidental merging. |
178
+ | S060 | Host transaction rolls back an assignment | Open | Database and cached decisions must not retain rolled-back access. | Test check-after-rollback behavior. |
179
+ | S061 | Read permission from a lagging replica | Open | Document that freshness depends on connection routing. | State writer requirement if post-commit revocation freshness is promised. |
180
+ | S062 | Database times out during permission check | Open | Failure must never become an allow. | Document error propagation; do not disguise outage as successful authorization. |
181
+ | S063 | Render many buttons or check many people | Open | Performance should remain bounded and understandable. | Measure actual query count; specify any preload/cache contract before optimizing. |
182
+
183
+ ## Membership and person lifecycle
184
+
185
+ Evidence: A: Data model; O: Out of scope, Decisions. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
186
+
187
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
188
+ |---|---|---|---|---|
189
+ | S064 | Require Membership as a gem-owned model | Deferred | Latest design intentionally does not use Membership as identity. | Do not treat README’s stale example as intended support. |
190
+ | S065 | Remove Acme membership while User role joins remain | Host | Membership is host-owned; joins are independent in proposed model. | Document active-membership gate or cleanup so removed members cannot use retained grants. |
191
+ | S066 | Suspend membership or disable a user | Host | Host eligibility must deny even when capabilities remain assigned. | Include one integration example; gem cannot infer custom status fields. |
192
+ | S067 | Delete a person with role assignments | Open | No dangling assignments; last-Owner policy must be considered. | Specify foreign-key/dependent cleanup and privileged deletion behavior. |
193
+ | S068 | Delete a tenant with roles and Owners | Open | Define intended cleanup; tenant deletion differs from locking out an existing tenant. | Specify cascade/restriction behavior. |
194
+ | S069 | Soft-delete then restore a user or membership | Host | Restoration policy decides whether access returns. | Document retained grants and explicit host cleanup/restore policy. |
195
+ | S070 | Invite someone before a persisted person exists | Host | Host invitation flow decides when to create person and assignments. | Document persistence requirement; pending invitations need not be gem models. |
196
+ | S071 | Anonymous visitor or unauthenticated request | Host | Host decides public access before person checks. | Do not assume nil recipient support or public-access grants. |
197
+
198
+ ## Record reads and application integration
199
+
200
+ Evidence: A: Guardrails 1–2, Non-goals; P: Non-goals. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
201
+
202
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
203
+ |---|---|---|---|---|
204
+ | S072 | View an invoice in the selected tenant | Host | Capability check plus tenant-scoped record lookup. | Use acme.invoices.find(id), not unrestricted Invoice.find(id). |
205
+ | S073 | List/search/paginate/export authorized records | Host | Apply host policy relation before counts, pagination, and export. | Add an end-to-end collection example; gem does not infer row scopes. |
206
+ | S074 | Allow own timesheets plus broader manager access | Covered | Independent ownership condition plus additive capability. | Keep existing explicit self-service guardrail. |
207
+ | S075 | Hide a button when access is denied | Host | Predicate is suitable for UI, but server must still enforce. | Document controller/service enforcement alongside view example. |
208
+ | S076 | Restrict fields or filter writable parameters | Host | Host policy/serializer decides field-level behavior. | Do not interpret a tenant capability as every field being accessible. |
209
+ | S077 | Enforce invoice state, plan limits, or separation of duties | Host | Business predicates compose with capability check. | Owner bypass should not silently become a host-wide policy bypass. |
210
+ | S078 | Authorize a background export after permission was revoked | Host | Host decides execution-time recheck and uses explicit person/tenant. | Show reauthorization for delayed sensitive work, with freshness caveats. |
211
+ | S079 | Check once then perform a concurrent sensitive mutation | Host | A predicate alone cannot eliminate check/use races. | Document host transaction/locking needs when atomic authorization is required. |
212
+ | S080 | Cache a response containing permission-controlled data | Host | Host response cache must account for access changes. | Gem memoization policy does not invalidate application/CDN caches. |
213
+
214
+ ## Who may administer access
215
+
216
+ Evidence: P: Per-tenant scoping of admin control; A: Data model, Non-goals. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
217
+
218
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
219
+ |---|---|---|---|---|
220
+ | S081 | Tenant admin creates or edits a role | Partial | Host admin UI invokes supplied models; per-tenant admin control is intended. | State that mutation methods do not automatically authenticate/authorize caller. |
221
+ | S082 | Billing admin attempts to grant payroll access | Host | Host decides grantable subset; editing some roles does not imply granting everything. | Provide policy example if delegated administration is a supported host use case. |
222
+ | S083 | Admin edits or grants a role to themselves | Host | Host decides self-escalation rules. | Make caller authorization boundary clear. |
223
+ | S084 | Admin submits arbitrary permission IDs | Host | Host validates allowed grants, gem preserves relationship integrity. | Document catalog selection and rejection of invalid foreign keys. |
224
+ | S085 | Support staff administer another tenant | Host | Host explicitly authorizes target tenant and person. | No ambient bypass is introduced. |
225
+ | S086 | Record who changed grants and why | Host | Audit storage and acting-user capture are host concerns unless an extension is promised. | Show model/service integration; do not demand an audit subsystem in v1. |
226
+
227
+ ## Owner role and recovery
228
+
229
+ Evidence: O: Assignment API, Mechanisms, Error cases, Decisions; A: Lockout escape hatch. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
230
+
231
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
232
+ |---|---|---|---|---|
233
+ | S087 | Create tenant without first Owner | Covered | Allowed until host calls grant_owner! or ops recovers. | Keep explicit first-owner responsibility. |
234
+ | S088 | Grant Owner explicitly after tenant creation | Covered | Find/create scope Owner, apply configured mechanism, assign person. | Keep documented API. |
235
+ | S089 | Add a second Owner and revoke first | Covered | Multiple Owners allowed; revoke succeeds while another remains. | Test both scope modes. |
236
+ | S090 | Revoke the final Owner | Covered | Fails in protected, bypass, and both modes. | Test stated invariant. |
237
+ | S091 | Use protected Owner | Covered | All catalog keys through normal join; strip/delete role prohibited. | Test sync reattachment and protection. |
238
+ | S092 | Use bypass or both Owner | Covered | Bypass short-circuits; both also maintains protected grant rows. | Keep distinct documented behavior; test known keys and scope. |
239
+ | S093 | Use owner_role none | Covered | No special Owner; grant_owner! raises; ops can grant ordinary roles. | Test recovery without special privilege. |
240
+ | S094 | Sync new permission with protected/both Owners | Covered | Attach new catalog permissions to those Owner roles. | Test across existing tenants. |
241
+ | S095 | Create/rename ordinary role to privileged Owner name | Open | Name changes must not accidentally establish privilege. | Define reserved-name or stable-identity enforcement. |
242
+ | S096 | Repeat grant_owner! or run it concurrently | Open | No duplicate Owner role/assignment or partial setup. | Specify idempotence and database constraints. |
243
+ | S097 | Two final Owners concurrently revoke themselves | Open | Stated at-least-one rule must survive concurrency. | Specify transaction/locking strategy and test. |
244
+ | S098 | Delete last Owner assignment/person outside revoke_owner! | Open | Clarify whether invariant covers every supported mutation path. | Protect or document restricted APIs; do not imply callbacks cover raw SQL. |
245
+ | S099 | Suspend the only Owner | Host | A retained Owner row does not guarantee an eligible human can log in. | Document recovery and host suspension policy. |
246
+ | S100 | Change Owner mode or configured name after adoption | Open | Privilege changes must be deliberate and reproducible. | Specify supported transition/migration or declare configuration immutable. |
247
+ | S101 | Recover using operational role-grant task | Covered | Name role/person/tenant in multi-tenant; omit tenant in single-tenant. | Finalize syntax; explain unknown role/person and duplicate grant behavior. |
248
+
249
+ ## Default roles and extensibility
250
+
251
+ Evidence: A: Default roles at tenant creation, Non-goals. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
252
+
253
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
254
+ |---|---|---|---|---|
255
+ | S102 | Seed host-defined roles when a tenant is created | Covered | Configured callback invokes default-role helper; no fixed product roles. | Keep separation of definitions and creator assignment. |
256
+ | S103 | Admin edits a previously seeded role | Covered | Ordinary defaults remain editable and deletable. | Test that runtime edits are allowed. |
257
+ | S104 | Re-run default helper after admin customizes roles | Open | Do not unexpectedly overwrite customer choices. | Define ensure semantics: create-only, merge, or replacement. |
258
+ | S105 | Default-role callback fails during tenant creation | Open | No unexplained partial tenant/role state. | Define rollback and retry behavior; test unsynced catalog keys. |
259
+ | S106 | Use resource-scoped roles on one invoice/forum | Deferred | Explicitly outside v1. | Preserve boundary; host policy may cover simpler ownership needs. |
260
+ | S107 | Use NoSQL or gem-provided admin controllers | Deferred | Explicitly outside core v1. | Do not count intentional non-goals as defects. |
261
+ | S108 | Need direct user grants, deny rules, or role inheritance | Open | No such semantics are proposed; current model is additive role union. | State unsupported unless a concrete v1 requirement warrants expansion. |
262
+ | S109 | Need global superadmin across all tenants | Open | Tenant Owner explicitly is not global superadmin. | Keep host policy responsibility or explicitly defer; do not infer global bypass. |
263
+
264
+ ## Additional boundary and interaction cases
265
+
266
+ Evidence: A: Data model, Catalog sync mechanism, Default roles; O: Assignment API, Mechanisms. “Open” denotes a missing contract in the reviewed documents, not an observed bug.
267
+
268
+ | ID | Scenario | Status | Desired behavior / interpretation | Follow-up or acceptance check |
269
+ |---|---|---|---|---|
270
+ | S110 | Assign Owner through an ordinary role association | Open | Owner setup/protection should not be silently bypassed. | Specify whether helper is required, and how supported association writes preserve invariants. |
271
+ | S111 | Create tenant/Owner concurrently with catalog sync | Open | New protected Owner should have a coherent catalog set after operations complete. | Test interleaving; define completeness and retry expectations. |
272
+ | S112 | Create tenant succeeds but initial Owner grant fails | Covered | Zero-Owner tenant is explicitly allowed. | Host wraps create-and-grant in a transaction if atomic onboarding is required. |
273
+ | S113 | Run recovery for a role named Owner when mode is none | Partial | No implicit bypass should arise from the name. | Clarify ordinary role creation/lookup and missing-role behavior. |
274
+ | S114 | Give temporary access that expires tomorrow | Open | Time-limited grants are not proposed. | Explicitly defer or use host scheduling/revocation with documented delay; avoid implying native expiry. |
275
+ | S115 | Authorize multiple person classes simultaneously | Open | One configured person class is described. | Declare support boundary; do not assume polymorphic User/Bot/ServiceAccount support. |
276
+ | S116 | Use API token narrower than its owner’s roles | Host | Token scopes must further constrain person capabilities. | Host combines token and role checks; gem must not imply token restrictions are automatic. |
277
+
278
+ ## Priorities for the author
279
+
280
+ **Before implementing the public contract:** finish the ordinary-role walkthrough; resolve missing tenant and unknown/unsynced key behavior; define freshness, duplicate assignment, and atomic replacement; specify Owner name protection and last-Owner enforcement paths.
281
+
282
+ **Before claiming drop-in readiness:** align README, define cleanup and supported schema/key types, check naming collisions, document catalog retirement, and correct rolling migration guidance. Exercise a generated host installation rather than relying only on isolated models.
283
+
284
+ **Keep host responsibilities visible:** membership eligibility, record scopes, delegated administration, business rules, and response caching remain application responsibilities. A complete integration example is more valuable than adding abstractions for every row above.
285
+
286
+ **Keep the gem small:** these scenarios are a test and documentation inventory, not a feature shopping list. A sub-1,000-line production budget should include generators/templates; tests can be longer. Use explicit non-support where it is honest and compatible with the intended v1.
287
+
288
+ ## Suggested first integration exercise
289
+
290
+ 1. Install using the current person/tenant DSL and sync billing keys.
291
+ 2. Create Acme and Beta; give Maya an ordinary Acme billing role using the intended public operations.
292
+ 3. Confirm Acme allow, Beta deny, and the chosen missing/unknown-input behavior.
293
+ 4. Give a second Acme role the same permission; revoke the first and confirm the union remains.
294
+ 5. Edit the last grant through another instance/connection and check the documented freshness boundary.
295
+ 6. Prove a failed permission-set edit leaves the prior set intact; retry assignment without duplicates.
296
+ 7. Protect invoice detail and collection with host membership and tenant scoping.
297
+ 8. Exercise each configured Owner mode, concurrent last-owner revocation, and allowed cleanup paths.
298
+ 9. Repeat relevant checks in single-tenant mode and verify recovery.
299
+
300
+ Record expected results before coding; then attach test names and observed results to the relevant scenario IDs. No execution results are claimed by this file.
301
+
@@ -0,0 +1,82 @@
1
+ # Gem release automation — design
2
+
3
+ ## Goal
4
+
5
+ Publish `access_grant` to RubyGems.org with version sync across git tags,
6
+ GitHub Releases, and RubyGems, using a manually triggered GitHub Actions
7
+ workflow and Trusted Publishing (OIDC). First public version is `1.0.0`.
8
+
9
+ ## Decisions
10
+
11
+ - First public version: **1.0.0** (bump from unpublished `0.1.0`).
12
+ - Release is **fully automated** once started, but triggered **manually** via `workflow_dispatch` (not on push/tag for now).
13
+ - RubyGems auth: **Trusted Publishing (OIDC)** — no API key secret.
14
+ - Version ownership: **hybrid** — human bumps `version.rb` + `CHANGELOG.md` in a PR on `main`; workflow takes a `version` input and **fails** if `AccessGrant::VERSION` does not match.
15
+ - Implementation approach: **official `rubygems/release-gem`** + version guard + GitHub Release + README badges.
16
+ - CHANGELOG for the first release must also move to **1.0.0** (same PR as the version bump).
17
+
18
+ ## Release flow
19
+
20
+ 1. Maintainer opens a PR that:
21
+ - Sets `AccessGrant::VERSION` to `X.Y.Z`
22
+ - Moves `[Unreleased]` notes under `## [X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md`
23
+ - Leaves a fresh empty `[Unreleased]` section
24
+ - Commits as `chore: release vX.Y.Z` (or similar Conventional Commit)
25
+ 2. After merge to `main` (and after CI has passed on that commit), maintainer runs **Actions → Release → Run workflow** with input `version: X.Y.Z`.
26
+ 3. Workflow (on `main` only):
27
+ - Checks out the repo at the current `main` tip
28
+ - Asserts `AccessGrant::VERSION == inputs.version`
29
+ - Asserts `CHANGELOG.md` contains a `## [X.Y.Z]` heading
30
+ - Does **not** re-run the full CI matrix (relies on CI already green on that commit)
31
+ - Uses `rubygems/release-gem@v1` (OIDC) → builds gem, creates/pushes tag `vX.Y.Z`, pushes gem to RubyGems.org
32
+ - Creates a GitHub Release for `vX.Y.Z` with body extracted from that CHANGELOG section
33
+ 4. README badges reflect RubyGems version / CI / license automatically after publish.
34
+
35
+ ## Components
36
+
37
+ ### Workflow — `.github/workflows/release.yml`
38
+
39
+ - Trigger: `workflow_dispatch` with required input `version` (e.g. `1.0.0`)
40
+ - Guard: fail unless `github.ref == refs/heads/main`
41
+ - Permissions: `id-token: write`, `contents: write`
42
+ - GitHub Environment: omit by default; use `release` only if the Trusted Publisher on RubyGems is configured with that same environment name
43
+ - Steps:
44
+ 1. Checkout + Ruby setup (`bundler-cache`)
45
+ 2. Assert `AccessGrant::VERSION == inputs.version`
46
+ 3. Assert `CHANGELOG.md` has `## [X.Y.Z]` for that version
47
+ 4. `rubygems/release-gem@v1` (build, tag `vX.Y.Z`, push gem via OIDC)
48
+ 5. Create GitHub Release for tag `vX.Y.Z` with notes from that CHANGELOG section
49
+
50
+ ### First release content (in-repo)
51
+
52
+ - Bump `lib/access_grant/version.rb` to `1.0.0`
53
+ - Move current `[Unreleased]` CHANGELOG entries under `## [1.0.0] - YYYY-MM-DD`; leave a fresh empty `[Unreleased]`
54
+ - Update `CONTRIBUTING.md` release section to the hybrid + Actions flow
55
+ - Add README badges (RubyGems version, CI, license); adjust status wording for published gem
56
+
57
+ ### One-time human setup (outside repo)
58
+
59
+ - RubyGems account with MFA
60
+ - Configure Trusted Publisher for gem `access_grant`: owner `SahSantoshh`, repo `access_grant`, workflow `release.yml`, environment matching the workflow (blank or `release`)
61
+ - First publish may use RubyGems’ pending/trusted-publisher flow for a gem that does not yet exist on the index
62
+
63
+ ## Badges & docs
64
+
65
+ - README badges under the title: RubyGems version, CI on `main`, MIT license
66
+ - CONTRIBUTING.md documents the hybrid release process (PR bump → manual workflow)
67
+ - README remains install-focused; status wording updated for a published gem
68
+
69
+ ## Failure modes
70
+
71
+ - Wrong branch or missing `version` input → fail before any publish side effects
72
+ - `AccessGrant::VERSION` ≠ input, or missing `## [X.Y.Z]` in CHANGELOG → fail before `release-gem`
73
+ - Trusted Publisher / OIDC misconfiguration → `release-gem` fails (no silent API-key fallback)
74
+ - Existing tag or already-published gem version → fail; no overwrite
75
+ - GitHub Release is created only after successful gem publish
76
+
77
+ ## Out of scope
78
+
79
+ - Auto-publish on tag push (future upgrade)
80
+ - Automated version bumping / changelog generation (release-please, etc.)
81
+ - Yanking / yank workflows
82
+ - Re-running the full CI matrix inside the release workflow
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessGrant
4
+ class Catalog
5
+ # DSL evaluated inside {AccessGrant.permissions} / {Catalog#replace}.
6
+ #
7
+ # @example
8
+ # AccessGrant.permissions do
9
+ # resource :invoices do
10
+ # action :couple, description: "Can couple invoices"
11
+ # end
12
+ # category "billing" do
13
+ # permission "billing.export", "Export billing CSV"
14
+ # end
15
+ # end
16
+ class DSL
17
+ # @param catalog [AccessGrant::Catalog]
18
+ def initialize(catalog)
19
+ @catalog = catalog
20
+ @category_override = nil
21
+ @current_resource = nil
22
+ @default_keys = nil
23
+ @explicit_keys = nil
24
+ @satisfied_untemplated = nil
25
+ end
26
+
27
+ # Declare a resource; emits {AccessGrant::Configuration#default_permission_actions}
28
+ # with description templates, then yields for custom actions.
29
+ #
30
+ # @param name [String, Symbol] singular or plural; stored segment is pluralized
31
+ # @yield optional block for +action+ declarations
32
+ # @return [void]
33
+ def resource(name, &block)
34
+ segment = name.to_s.pluralize
35
+ category = @category_override || segment
36
+ untemplated = untemplated_default_actions
37
+
38
+ if block.nil? && untemplated.any?
39
+ raise Error, "Default action :#{untemplated.first} requires an explicit description"
40
+ end
41
+
42
+ @current_resource = segment
43
+ @default_keys = Set.new
44
+ @explicit_keys = Set.new
45
+ @satisfied_untemplated = Set.new
46
+ emit_defaults(segment, category)
47
+ instance_eval(&block) if block
48
+ verify_untemplated_defaults!(untemplated) if block
49
+ ensure
50
+ @current_resource = nil
51
+ @default_keys = nil
52
+ @explicit_keys = nil
53
+ @satisfied_untemplated = nil
54
+ end
55
+
56
+ # Nest declarations under an explicit category string (UI grouping).
57
+ #
58
+ # @param name [String, Symbol]
59
+ # @yield
60
+ # @return [void]
61
+ def category(name, &)
62
+ previous = @category_override
63
+ @category_override = name.to_s
64
+ instance_eval(&)
65
+ ensure
66
+ @category_override = previous
67
+ end
68
+
69
+ # Add or override an action under the current {#resource}.
70
+ #
71
+ # @param action_name [String, Symbol]
72
+ # @param description [String] required
73
+ # @param key [String, nil] optional full +resource.action+ override
74
+ # @return [void]
75
+ # @raise [AccessGrant::Error]
76
+ def action(action_name, description: nil, key: nil)
77
+ raise Error, "action requires a description" if description.nil?
78
+
79
+ resource = @current_resource or raise Error, "action must be inside a resource block"
80
+
81
+ permission_key = key || "#{resource}.#{action_name}"
82
+ raise Error, "Duplicate permission key: #{permission_key}" if @explicit_keys.include?(permission_key)
83
+
84
+ @explicit_keys.add(permission_key)
85
+ mark_untemplated_satisfied(action_name, permission_key)
86
+ category = @category_override || resource
87
+ override = @default_keys.include?(permission_key)
88
+ @catalog.add(permission_key, description: description, category: category, override: override)
89
+ end
90
+
91
+ # Ad-hoc catalog entry inside a {#category} block.
92
+ #
93
+ # @param key [String] must match +resource.action+
94
+ # @param description [String]
95
+ # @return [void]
96
+ # @raise [AccessGrant::Error]
97
+ def permission(key, description)
98
+ category = @category_override or raise Error, "permission must be inside a category block"
99
+
100
+ @catalog.add(key, description: description, category: category)
101
+ end
102
+
103
+ private
104
+
105
+ def untemplated_default_actions
106
+ AccessGrant.config.default_permission_actions.reject do |action|
107
+ Catalog::DEFAULT_TEMPLATES.key?(action.to_s)
108
+ end
109
+ end
110
+
111
+ def emit_defaults(segment, category)
112
+ AccessGrant.config.default_permission_actions.each do |action|
113
+ next unless Catalog::DEFAULT_TEMPLATES.key?(action.to_s)
114
+
115
+ key = "#{segment}.#{action}"
116
+ description = @catalog.template_description(action, segment)
117
+ @catalog.add(key, description: description, category: category)
118
+ @default_keys.add(key)
119
+ end
120
+ end
121
+
122
+ def mark_untemplated_satisfied(action_name, permission_key)
123
+ untemplated_default_actions.each do |action|
124
+ if action_name.to_s == action.to_s || permission_key == "#{@current_resource}.#{action}"
125
+ @satisfied_untemplated.add(action.to_s)
126
+ end
127
+ end
128
+ end
129
+
130
+ def verify_untemplated_defaults!(untemplated)
131
+ missing = untemplated.map(&:to_s) - @satisfied_untemplated.to_a
132
+ return if missing.empty?
133
+
134
+ raise Error, "Default action :#{missing.first} requires an explicit description"
135
+ end
136
+ end
137
+ end
138
+ end