seams 0.1.0 → 0.2.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -12
  3. data/README.md +235 -82
  4. data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
  5. data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
  6. data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
  7. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
  8. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
  9. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
  10. data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
  11. data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
  12. data/lib/generators/seams/admin/admin_generator.rb +51 -4
  13. data/lib/generators/seams/admin/templates/README.md.tt +33 -3
  14. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
  15. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
  16. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
  17. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
  18. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
  19. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
  20. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
  21. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
  22. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
  23. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
  24. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
  25. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
  26. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
  27. data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
  28. data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
  29. data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
  30. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
  31. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
  32. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
  33. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
  34. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
  35. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
  36. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
  37. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
  38. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
  39. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
  40. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
  41. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
  42. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
  43. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
  44. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
  45. data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
  46. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
  47. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
  48. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
  49. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
  50. data/lib/generators/seams/auth/auth_generator.rb +1 -2
  51. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
  52. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
  53. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
  54. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
  55. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
  56. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
  57. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
  58. data/lib/generators/seams/billing/billing_generator.rb +4 -3
  59. data/lib/generators/seams/billing/templates/README.md.tt +26 -0
  60. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
  61. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
  62. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
  63. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
  64. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
  65. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
  66. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
  67. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
  68. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
  69. data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
  70. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
  71. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
  72. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
  73. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
  74. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
  75. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
  76. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
  77. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
  78. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
  79. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
  80. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
  81. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
  82. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
  83. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
  84. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
  85. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
  86. data/lib/generators/seams/design/design_generator.rb +647 -0
  87. data/lib/generators/seams/design/templates/README.md.tt +129 -0
  88. data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
  89. data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
  90. data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
  91. data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
  92. data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
  93. data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
  94. data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
  95. data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
  96. data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
  97. data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
  98. data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
  99. data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
  100. data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
  101. data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
  102. data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
  103. data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
  104. data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
  105. data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
  106. data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
  107. data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
  108. data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
  109. data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
  110. data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
  111. data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
  112. data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
  113. data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
  114. data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
  115. data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
  116. data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
  117. data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
  118. data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
  119. data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
  120. data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
  121. data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
  122. data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
  123. data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
  124. data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
  125. data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
  126. data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
  127. data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
  128. data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
  129. data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
  130. data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
  131. data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
  132. data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
  133. data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
  134. data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
  135. data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
  136. data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
  137. data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
  138. data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
  139. data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
  140. data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
  141. data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
  142. data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
  143. data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
  144. data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
  145. data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
  146. data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
  147. data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
  148. data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
  149. data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
  150. data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
  151. data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
  152. data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
  153. data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
  154. data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
  155. data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
  156. data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
  157. data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
  158. data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
  159. data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
  160. data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
  161. data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
  162. data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
  163. data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
  164. data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
  165. data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
  166. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
  167. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
  168. data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
  169. data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
  170. data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
  171. data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
  172. data/lib/generators/seams/engine/engine_generator.rb +6 -0
  173. data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
  174. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  175. data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
  176. data/lib/generators/seams/install/install_generator.rb +72 -6
  177. data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
  178. data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
  179. data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
  180. data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
  181. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
  182. data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
  183. data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
  184. data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
  185. data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
  186. data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
  187. data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
  188. data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
  189. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
  190. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
  191. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
  192. data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
  193. data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
  194. data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
  195. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
  196. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
  197. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
  198. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
  199. data/lib/seams/cli/list.rb +15 -0
  200. data/lib/seams/cli/quality.rb +46 -5
  201. data/lib/seams/cli/resolve.rb +9 -5
  202. data/lib/seams/cli/test_changed.rb +5 -1
  203. data/lib/seams/cli.rb +20 -0
  204. data/lib/seams/configuration.rb +24 -1
  205. data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
  206. data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
  207. data/lib/seams/event_registry.rb +19 -0
  208. data/lib/seams/events/adapter.rb +9 -0
  209. data/lib/seams/events/adapters/active_support.rb +4 -0
  210. data/lib/seams/events/publisher.rb +29 -0
  211. data/lib/seams/events.rb +1 -0
  212. data/lib/seams/generators/dummy_app_writer.rb +21 -0
  213. data/lib/seams/generators/follow_up_generator.rb +1 -2
  214. data/lib/seams/generators/host_injector.rb +9 -2
  215. data/lib/seams/generators/splicer.rb +2 -2
  216. data/lib/seams/observability/adapter.rb +22 -0
  217. data/lib/seams/observability.rb +6 -0
  218. data/lib/seams/permission_registry.rb +66 -0
  219. data/lib/seams/permissions.rb +120 -0
  220. data/lib/seams/version.rb +1 -1
  221. data/lib/seams.rb +7 -0
  222. metadata +107 -3
@@ -77,8 +77,10 @@ Seams::Admin.configure { |c| c.tenancy_scope = :platform }
77
77
  Seams::Admin.configure { |c| c.tenancy_scope = :tenant }
78
78
  ```
79
79
 
80
- - **Gate:** `Accounts::Membership#role == "admin"` (read off
81
- `Accounts::Current.membership`).
80
+ - **Gate:** `Seams::Permissions.can?(role:, ability:)` — each tenant
81
+ policy resolves its resource's ability code against the membership
82
+ role read off `Accounts::Current.membership` (no `role == "admin"`
83
+ literal). Platform `staff?` and the `system` role still bypass.
82
84
  - **Visibility:** only the current Account's data — every Pundit
83
85
  `Scope` filters by `account_id`.
84
86
  - **Use case:** a tenant-facing admin surface where each customer's
@@ -87,6 +89,34 @@ Seams::Admin.configure { |c| c.tenancy_scope = :tenant }
87
89
  - **Requires:** the `accounts` engine. Without it the admin engine
88
90
  fails fast at boot with a clear error message.
89
91
 
92
+ Tenant mode needs two more settings in the same initializer. With only
93
+ `tenancy_scope = :tenant`, a tenant admin who is not platform staff gets
94
+ a 403 from the gate, and no membership is resolved for admin requests:
95
+
96
+ 1. **Authenticator.** The default gate is `current_identity&.staff?`,
97
+ which only lets platform staff in. Let signed-in identities through
98
+ and leave per-record decisions to the tenant policies.
99
+ 2. **Membership resolver.** The admin controllers do not inherit your
100
+ `ApplicationController`, so whatever sets `Accounts::Current.membership`
101
+ there does not run for admin requests. Resolve it here.
102
+
103
+ ```ruby
104
+ # config/initializers/seams_admin.rb
105
+ Seams::Admin.configure do |c|
106
+ c.tenancy_scope = :tenant
107
+ c.authenticator = ->(ctrl) { ctrl.current_identity.present? }
108
+ # Example: the identity's first active membership. Pick the account the
109
+ # way your app does (subdomain, session, a switcher).
110
+ c.current_membership_resolver = lambda do |ctrl|
111
+ identity = ctrl.current_identity
112
+ identity && Accounts::Membership.find_by(identity_id: identity.id, active: true)
113
+ end
114
+ end
115
+ ```
116
+
117
+ With both set, a member-role identity still gets 403 from the policies,
118
+ and an admin sees only its own account's rows.
119
+
90
120
  Switching modes is a config flip — no schema change, no regenerate.
91
121
  The `policy_namespace` lookup on `Seams::Admin::ApplicationController`
92
122
  reads `Seams::Admin.config.tenancy_scope` on every request.
@@ -170,7 +200,7 @@ bin/rails generate administrate:dashboard MyHost::Project
170
200
  # Move app/dashboards/my_host/project_dashboard.rb to
171
201
  # engines/admin/app/dashboards/admin/project_dashboard.rb
172
202
  # Move the controller similarly under app/controllers/admin/.
173
- # Add `resources :projects, controller: "admin/projects"` to
203
+ # Add `resources :projects, controller: "/admin/projects"` to
174
204
  # engines/admin/config/routes.rb (or splice via the
175
205
  # admin.routes.after_resources insertion point in a follow-up generator).
176
206
  ```
@@ -41,7 +41,67 @@ module Seams
41
41
  # policy; a request that fails a policy never reaches the action;
42
42
  # the audit row only writes after a successful action.
43
43
  class ApplicationController < ::Administrate::ApplicationController
44
+ # Administrate's base controller does not inherit the host's
45
+ # ApplicationController, so it never picks up the auth engine's
46
+ # session lookup. Include it here, before the gate, so
47
+ # `current_identity` resolves and `Auth::Current.identity` is set
48
+ # for pundit_user and the audit actor.
49
+ include ::Auth::Authentication if defined?(::Auth::Authentication)
44
50
  include ::Seams::Admin::Authenticator
51
+
52
+ # The dashboard controllers are top-level `Admin::*`, outside the
53
+ # isolated Seams::Admin namespace, so Rails wires them to the host
54
+ # app's routes. Administrate's views call url_for against the
55
+ # current controller's routes, so give each subclass the engine's
56
+ # route set instead (the later include wins).
57
+ def self.inherited(subclass)
58
+ super
59
+ subclass.include(::Seams::Admin.railtie_routes_url_helpers)
60
+ end
61
+
62
+ # [route_key, action] pairs for this engine's dashboard routes.
63
+ def self.admin_routes
64
+ ::Seams::Admin::Engine.routes.routes.filter_map do |route|
65
+ controller = route.defaults[:controller]
66
+ next unless controller&.start_with?("admin/")
67
+
68
+ [controller.delete_prefix("admin/"), route.defaults[:action]]
69
+ end.to_set
70
+ end
71
+
72
+ # "Auth::Identity" => "identities", derived from each dashboard's
73
+ # declared model so follow-up dashboards are picked up too.
74
+ def self.admin_route_keys_by_model
75
+ admin_routes.map(&:first).uniq.each_with_object({}) do |key, map|
76
+ dashboard = "Admin::#{key.classify}Dashboard".safe_constantize
77
+ map[dashboard.model.name] = key if dashboard.respond_to?(:model)
78
+ end
79
+ end
80
+
81
+ # Accounts::Membership and Teams::Membership both have the route
82
+ # key `memberships` (isolated-engine model naming), so
83
+ # Administrate's polymorphic `[:admin, membership]` calls
84
+ # admin_membership(s)_path, which does not exist. These helpers
85
+ # route by the record's class, or by the current controller for a
86
+ # new record's form URL.
87
+ MEMBERSHIP_ROUTE_KEYS = {
88
+ "Accounts::Membership" => "accounts_membership",
89
+ "Teams::Membership" => "teams_membership"
90
+ }.freeze
91
+
92
+ {
93
+ "admin_memberships" => "admin_%ss",
94
+ "admin_membership" => "admin_%s",
95
+ "new_admin_membership" => "new_admin_%s",
96
+ "edit_admin_membership" => "edit_admin_%s"
97
+ }.each do |name, target|
98
+ %w[path url].each do |suffix|
99
+ define_method(:"#{name}_#{suffix}") do |*args, **opts|
100
+ public_send(:"#{format(target, membership_route_key(args.first))}_#{suffix}", *args, **opts)
101
+ end
102
+ helper_method :"#{name}_#{suffix}"
103
+ end
104
+ end
45
105
  # Administrate's canonical Pundit integration. Internally:
46
106
  # - includes Pundit::Authorization
47
107
  # - overrides `authorized_action?` to call
@@ -78,10 +138,12 @@ module Seams
78
138
  # `authorize` / `policy_scope`. Administrate's `Punditize`
79
139
  # already calls them on every standard action, so any future
80
140
  # custom action added to a subclass that forgets the call gets
81
- # caught loudly in development. `:only` excludes `:index`
82
- # (covered by `verify_policy_scoped` instead) and the rescue
83
- # responders themselves (which never authorise — they 403).
84
- after_action :verify_authorized, except: %i[index respond_with_admin_unauthorised]
141
+ # caught loudly in development. `:except` skips `:index`, which
142
+ # is covered by `verify_policy_scoped` instead. The 403 rescue
143
+ # responder needs no exclusion: after_actions do not run when
144
+ # rescue_from handles the exception, and naming a non-action
145
+ # here raises ActionNotFound on Rails 7.1+.
146
+ after_action :verify_authorized, except: :index
85
147
  after_action :verify_policy_scoped, only: %i[index]
86
148
 
87
149
  # Helper exposed to dashboards so they can read the active
@@ -89,6 +151,7 @@ module Seams
89
151
  # policy scopes; dashboards may also branch on it for
90
152
  # account_id columns.
91
153
  helper_method :seams_admin_tenancy_scope
154
+ helper_method :seams_admin_navigation
92
155
 
93
156
  def seams_admin_tenancy_scope
94
157
  ::Seams::Admin.config.tenancy_scope
@@ -125,6 +188,13 @@ module Seams
125
188
  # at runtime per-request (rare; typically configured once at boot)
126
189
  # override this method on a subclass and consult their own request
127
190
  # state.
191
+ # Administrate's `create` never sets `@requested_resource`, and
192
+ # calling `requested_resource` there looks up `params[:id]` (nil).
193
+ # Keep the saved record for record_admin_audit.
194
+ def create
195
+ super { |resource| @created_resource = resource }
196
+ end
197
+
128
198
  def policy_namespace
129
199
  case ::Seams::Admin.config.tenancy_scope
130
200
  when :tenant then [::Admin::Tenant]
@@ -134,6 +204,82 @@ module Seams
134
204
 
135
205
  private
136
206
 
207
+ # Pundit's array lookup cannot reach these policies on its own: it
208
+ # turns a Module namespace element into `Module`, and it keeps the
209
+ # record's namespace (`Admin::Platform::Auth::IdentityPolicy`).
210
+ # Each dashboard controller maps 1:1 to a policy instead
211
+ # (Admin::AccountsMembershipsController ->
212
+ # Admin::Platform::AccountsMembershipPolicy), so resolve the class
213
+ # explicitly and hand it to Pundit's own `policy_class:` options.
214
+ # That keeps verify_authorized / verify_policy_scoped working.
215
+ # Administrate's flash messages name the resource through a
216
+ # resolver that guesses the model from the controller path
217
+ # ("admin/plans" -> Plan). Use the dashboard's real model instead.
218
+ def translate_with_resource(key)
219
+ t("administrate.controller.#{key}", resource: resource_class.model_name.human)
220
+ end
221
+
222
+ def membership_route_key(record)
223
+ MEMBERSHIP_ROUTE_KEYS[record.class.name] || controller_name.singularize
224
+ end
225
+
226
+ def admin_policy_class
227
+ "#{policy_namespace.first}::#{controller_name.classify}Policy".constantize
228
+ end
229
+
230
+ def scoped_resource
231
+ policy_scope(resource_class.default_scoped, policy_scope_class: admin_policy_class::Scope)
232
+ end
233
+
234
+ def authorize_resource(resource)
235
+ authorize(resource, policy_class: admin_policy_class)
236
+ end
237
+
238
+ # Views ask about other resources too (navigation, association
239
+ # lists), so pick the policy for the resource being linked, not
240
+ # this controller's. Unknown resources are denied.
241
+ def authorized_action?(resource, action)
242
+ key = admin_route_key_for(resource) || controller_name
243
+ policy = "#{policy_namespace.first}::#{key.classify}Policy".safe_constantize
244
+ return false if policy.nil?
245
+
246
+ policy.new(pundit_user, resource).public_send(:"#{action}?")
247
+ end
248
+
249
+ # Administrate decides which links to render with existing_action?,
250
+ # built from Rails.application.routes. A mounted engine's routes are
251
+ # not in that set, so every show/edit/new/destroy link and the
252
+ # navigation vanished. Read this engine's routes instead.
253
+ def existing_action?(resource, action_name)
254
+ key = admin_route_key_for(resource)
255
+ key.present? && self.class.admin_routes.include?([key, action_name.to_s])
256
+ end
257
+
258
+ # Route key ("accounts_memberships") for a record, a model class,
259
+ # or a name Administrate passes ("admin/accounts_membership",
260
+ # :identities).
261
+ def admin_route_key_for(resource)
262
+ case resource
263
+ when ::ActiveRecord::Base then self.class.admin_route_keys_by_model[resource.class.name]
264
+ when Class then self.class.admin_route_keys_by_model[resource.name]
265
+ else resource.to_s.delete_prefix("admin/").pluralize.presence
266
+ end
267
+ end
268
+
269
+ # Sidebar entries for the navigation partial: every dashboard with
270
+ # an index route that the current user may list.
271
+ def seams_admin_navigation
272
+ self.class.admin_routes.select { |_key, action| action == "index" }.map(&:first).uniq.filter_map do |key|
273
+ dashboard = "Admin::#{key.classify}Dashboard".safe_constantize
274
+ next unless dashboard.respond_to?(:model) && authorized_action?(dashboard.model, :index)
275
+
276
+ { key: key,
277
+ label: dashboard.model.model_name.human(count: 2),
278
+ path: url_for(controller: "/admin/#{key}", action: :index),
279
+ active: key == controller_name }
280
+ end
281
+ end
282
+
137
283
  # Resolve the current Accounts::Membership for tenant-mode
138
284
  # policy decisions. Delegates to
139
285
  # `Seams::Admin.config.current_membership_resolver` (a callable
@@ -170,10 +316,10 @@ module Seams
170
316
  # row from this controller would double-log. We detect the
171
317
  # `audit_logs` association the concern installs and skip.
172
318
  #
173
- # `requested_resource` is Administrate's resolved record. On
174
- # `create` it's the just-saved record; on `update` it's the
175
- # just-updated record; on `destroy` it's the record that was just
176
- # destroyed (frozen but still readable for `id` / `class.name`).
319
+ # The audited record: on `create` the record our `create` override
320
+ # captured (Administrate never sets requested_resource there); on
321
+ # `update` the just-updated record; on `destroy` the record that was
322
+ # just destroyed (frozen but still readable for `id` / `class.name`).
177
323
  def record_admin_audit
178
324
  return unless defined?(::Core::AuditLog)
179
325
  return unless response.successful? || response.redirect?
@@ -215,6 +361,8 @@ module Seams
215
361
  # subclass that overrode `requested_resource` to raise) we don't
216
362
  # want the audit hook to take down the whole response.
217
363
  def requested_resource_for_audit
364
+ return @created_resource if action_name == "create"
365
+
218
366
  respond_to?(:requested_resource, true) ? requested_resource : nil
219
367
  rescue StandardError
220
368
  nil
@@ -11,6 +11,12 @@ require "administrate/base_dashboard"
11
11
  # secondary identifier and renders fine as Field::Number.
12
12
  module Admin
13
13
  class AccountDashboard < Administrate::BaseDashboard
14
+ # Administrate derives the model from the class name
15
+ # (AccountDashboard -> Admin::Account), which does not exist.
16
+ def self.model
17
+ ::Accounts::Account
18
+ end
19
+
14
20
  ATTRIBUTE_TYPES = {
15
21
  # UUID PK — Field::String, not Field::Number.
16
22
  id: Field::String,
@@ -18,7 +24,9 @@ module Admin
18
24
  external_account_id: Field::Number,
19
25
  cancelled_at: Field::DateTime,
20
26
  incinerated_at: Field::DateTime,
21
- memberships: Field::HasMany,
27
+ memberships: Admin::Fields::HasMany.with_options(
28
+ class_name: "Accounts::Membership", dashboard: "Admin::AccountsMembershipDashboard"
29
+ ),
22
30
  created_at: Field::DateTime,
23
31
  updated_at: Field::DateTime
24
32
  }.freeze
@@ -17,10 +17,18 @@ require "administrate/base_dashboard"
17
17
  # the admin surface treats identity_id as a typed integer reference.
18
18
  module Admin
19
19
  class AccountsMembershipDashboard < Administrate::BaseDashboard
20
+ # Administrate derives the model from the class name
21
+ # (AccountsMembershipDashboard -> Admin::AccountsMembership), which does not exist.
22
+ def self.model
23
+ ::Accounts::Membership
24
+ end
25
+
20
26
  ATTRIBUTE_TYPES = {
21
27
  # UUID PK — Field::String, not Field::Number.
22
28
  id: Field::String,
23
- account: Field::BelongsTo.with_options(class_name: "Accounts::Account"),
29
+ account: Admin::Fields::BelongsTo.with_options(
30
+ class_name: "Accounts::Account", dashboard: "Admin::AccountDashboard"
31
+ ),
24
32
  identity_id: Field::Number,
25
33
  name: Field::String,
26
34
  role: Field::Select.with_options(collection: %w[owner admin member system]),
@@ -13,6 +13,12 @@ require "administrate/base_dashboard"
13
13
  # auth engine's reset flow, not through the admin surface.
14
14
  module Admin
15
15
  class IdentityDashboard < Administrate::BaseDashboard
16
+ # Administrate derives the model from the class name
17
+ # (IdentityDashboard -> Admin::Identity), which does not exist.
18
+ def self.model
19
+ ::Auth::Identity
20
+ end
21
+
16
22
  # ATTRIBUTE_TYPES describes how Administrate renders each column.
17
23
  # Administrate auto-discovers attributes from the model; declaring
18
24
  # them explicitly here makes the rendering deterministic and lets
@@ -8,9 +8,15 @@ require "administrate/base_dashboard"
8
8
  # read the full token (they re-issue if lost).
9
9
  module Admin
10
10
  class InvitationDashboard < Administrate::BaseDashboard
11
+ # Administrate derives the model from the class name
12
+ # (InvitationDashboard -> Admin::Invitation), which does not exist.
13
+ def self.model
14
+ ::Teams::Invitation
15
+ end
16
+
11
17
  ATTRIBUTE_TYPES = {
12
18
  id: Field::Number,
13
- team: Field::BelongsTo.with_options(class_name: "Teams::Team"),
19
+ team: Admin::Fields::BelongsTo.with_options(class_name: "Teams::Team", dashboard: "Admin::TeamDashboard"),
14
20
  email: Field::String.with_options(searchable: true),
15
21
  # Truncated to 16 chars so the table view doesn't get blown up
16
22
  # by the 43-char base64 token. Full value still available on the
@@ -12,6 +12,12 @@ require "administrate/base_dashboard"
12
12
  # column on the same row.
13
13
  module Admin
14
14
  class InvoiceDashboard < Administrate::BaseDashboard
15
+ # Administrate derives the model from the class name
16
+ # (InvoiceDashboard -> Admin::Invoice), which does not exist.
17
+ def self.model
18
+ ::Billing::Invoice
19
+ end
20
+
15
21
  ATTRIBUTE_TYPES = {
16
22
  id: Field::Number,
17
23
  # UUID — Field::String, not Field::Number.
@@ -8,6 +8,12 @@ require "administrate/base_dashboard"
8
8
  # track the human (Auth::Identity bigint) who pressed the button.
9
9
  module Admin
10
10
  class LifetimePassDashboard < Administrate::BaseDashboard
11
+ # Administrate derives the model from the class name
12
+ # (LifetimePassDashboard -> Admin::LifetimePass), which does not exist.
13
+ def self.model
14
+ ::Billing::LifetimePass
15
+ end
16
+
11
17
  ATTRIBUTE_TYPES = {
12
18
  id: Field::Number,
13
19
  # UUID — Field::String, not Field::Number.
@@ -12,6 +12,12 @@ require "administrate/base_dashboard"
12
12
  # collection view and only shown on the show page.
13
13
  module Admin
14
14
  class NotificationDashboard < Administrate::BaseDashboard
15
+ # Administrate derives the model from the class name
16
+ # (NotificationDashboard -> Admin::Notification), which does not exist.
17
+ def self.model
18
+ ::Notifications::Notification
19
+ end
20
+
15
21
  ATTRIBUTE_TYPES = {
16
22
  id: Field::Number,
17
23
  type: Field::String,
@@ -8,6 +8,12 @@ require "administrate/base_dashboard"
8
8
  # false` is an explicit opt-out.
9
9
  module Admin
10
10
  class NotificationPreferenceDashboard < Administrate::BaseDashboard
11
+ # Administrate derives the model from the class name
12
+ # (NotificationPreferenceDashboard -> Admin::NotificationPreference), which does not exist.
13
+ def self.model
14
+ ::Notifications::NotificationPreference
15
+ end
16
+
11
17
  ATTRIBUTE_TYPES = {
12
18
  id: Field::Number,
13
19
  identity_id: Field::Number,
@@ -12,6 +12,12 @@ require "administrate/base_dashboard"
12
12
  # `currency` from the same row and formats accordingly.
13
13
  module Admin
14
14
  class PlanDashboard < Administrate::BaseDashboard
15
+ # Administrate derives the model from the class name
16
+ # (PlanDashboard -> Admin::Plan), which does not exist.
17
+ def self.model
18
+ ::Billing::Plan
19
+ end
20
+
15
21
  ATTRIBUTE_TYPES = {
16
22
  id: Field::Number,
17
23
  gateway_ref: Field::String,
@@ -10,6 +10,12 @@ require "administrate/base_dashboard"
10
10
  # account_id as an opaque tenant reference rather than a belongs_to.
11
11
  module Admin
12
12
  class SubscriptionDashboard < Administrate::BaseDashboard
13
+ # Administrate derives the model from the class name
14
+ # (SubscriptionDashboard -> Admin::Subscription), which does not exist.
15
+ def self.model
16
+ ::Billing::Subscription
17
+ end
18
+
13
19
  ATTRIBUTE_TYPES = {
14
20
  id: Field::Number,
15
21
  # UUID — Field::String, not Field::Number.
@@ -8,12 +8,22 @@ require "administrate/base_dashboard"
8
8
  # (Teams::Membership), not via Accounts::Membership.
9
9
  module Admin
10
10
  class TeamDashboard < Administrate::BaseDashboard
11
+ # Administrate derives the model from the class name
12
+ # (TeamDashboard -> Admin::Team), which does not exist.
13
+ def self.model
14
+ ::Teams::Team
15
+ end
16
+
11
17
  ATTRIBUTE_TYPES = {
12
18
  id: Field::Number,
13
19
  name: Field::String,
14
20
  slug: Field::String,
15
- memberships: Field::HasMany.with_options(class_name: "Teams::Membership"),
16
- invitations: Field::HasMany.with_options(class_name: "Teams::Invitation"),
21
+ memberships: Admin::Fields::HasMany.with_options(
22
+ class_name: "Teams::Membership", dashboard: "Admin::TeamsMembershipDashboard"
23
+ ),
24
+ invitations: Admin::Fields::HasMany.with_options(
25
+ class_name: "Teams::Invitation", dashboard: "Admin::InvitationDashboard"
26
+ ),
17
27
  created_at: Field::DateTime,
18
28
  updated_at: Field::DateTime
19
29
  }.freeze
@@ -14,9 +14,15 @@ require "administrate/base_dashboard"
14
14
  # treats it as a typed integer reference rather than a belongs_to.
15
15
  module Admin
16
16
  class TeamsMembershipDashboard < Administrate::BaseDashboard
17
+ # Administrate derives the model from the class name
18
+ # (TeamsMembershipDashboard -> Admin::TeamsMembership), which does not exist.
19
+ def self.model
20
+ ::Teams::Membership
21
+ end
22
+
17
23
  ATTRIBUTE_TYPES = {
18
24
  id: Field::Number,
19
- team: Field::BelongsTo.with_options(class_name: "Teams::Team"),
25
+ team: Admin::Fields::BelongsTo.with_options(class_name: "Teams::Team", dashboard: "Admin::TeamDashboard"),
20
26
  identity_id: Field::Number,
21
27
  role: Field::Select.with_options(collection: %w[owner admin member]),
22
28
  created_at: Field::DateTime,
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Fields
5
+ # Administrate::Field::BelongsTo plus the `dashboard:` option (see
6
+ # Admin::Fields::DashboardOption). Renders with the stock
7
+ # fields/belongs_to partials.
8
+ class BelongsTo < ::Administrate::Field::BelongsTo
9
+ include DashboardOption
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Fields
5
+ # Administrate finds an association's dashboard by appending
6
+ # "Dashboard" to the associated class name (Teams::Team ->
7
+ # Teams::TeamDashboard). This engine's dashboards live under Admin::,
8
+ # and defining constants inside another engine's namespace would
9
+ # break the engine boundary. The `dashboard:` option names the
10
+ # dashboard explicitly:
11
+ #
12
+ # team: Admin::Fields::BelongsTo.with_options(
13
+ # class_name: "Teams::Team", dashboard: "Admin::TeamDashboard"
14
+ # )
15
+ module DashboardOption
16
+ def associated_dashboard
17
+ name = options[:dashboard]
18
+ name ? name.constantize.new : super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Fields
5
+ # Administrate::Field::HasMany plus the `dashboard:` option (see
6
+ # Admin::Fields::DashboardOption). Renders with the stock
7
+ # fields/has_many partials.
8
+ class HasMany < ::Administrate::Field::HasMany
9
+ include DashboardOption
10
+ end
11
+ end
12
+ end
@@ -21,6 +21,10 @@ module Admin
21
21
 
22
22
  private
23
23
 
24
+ def permission_ability
25
+ "account.manage.accounts"
26
+ end
27
+
24
28
  def record_in_tenant_scope?
25
29
  return true if user&.identity&.staff?
26
30
  return true if record.nil? || record.is_a?(Class)
@@ -19,6 +19,12 @@ module Admin
19
19
  scope.where(account_id: account_id)
20
20
  end
21
21
  end
22
+
23
+ private
24
+
25
+ def permission_ability
26
+ "membership.manage.accounts"
27
+ end
22
28
  end
23
29
  end
24
30
  end
@@ -3,10 +3,24 @@
3
3
  module Admin
4
4
  module Tenant
5
5
  # Abstract base for every tenant-mode admin policy. Tenant mode
6
- # gates on `Accounts::Membership.role` for the currently-resolved
7
- # membership (`Seams::Admin::Context#membership`). Staff Identities
8
- # always pass the gate too — a platform staff member can drop into
9
- # any tenant's admin without rejoining as an account-admin.
6
+ # resolves access through `Seams::Permissions.can?` — the gem's
7
+ # single source of truth for authorisation — keyed by the
8
+ # currently-resolved membership's role
9
+ # (`Seams::Admin::Context#membership.role`) and the ability code
10
+ # the concrete policy owns (see `permission_ability`). There is
11
+ # deliberately no `role == "admin"` literal here: the
12
+ # owner/admin/member hierarchy and the grant map live in
13
+ # `Seams::Permissions::ROLE_HIERARCHY` and
14
+ # `Seams.configuration.permission_grants`, so the admin surface and
15
+ # the host controllers decide from the same map and never drift.
16
+ #
17
+ # Two bypass tiers remain explicit and intentional, mirroring
18
+ # `Accounts::Authorization#authorize_permission!`:
19
+ # - Platform staff (`Auth::Identity#staff?`) always pass — a
20
+ # platform staff member can drop into any tenant's admin without
21
+ # rejoining as an account-admin.
22
+ # - The `system` pseudo-role bypasses inside
23
+ # `Seams::Permissions.can?` itself (trusted internal callers).
10
24
  #
11
25
  # The `Scope` inner class is overridden per-policy; this base
12
26
  # returns `scope.none` so a subclass that forgets to override gets
@@ -118,16 +132,43 @@ module Admin
118
132
 
119
133
  private
120
134
 
121
- # Permitted when (a) the Identity is staff (platform admins drop
122
- # into every tenant), or (b) the resolved Accounts::Membership
123
- # carries an admin/owner role for the current Account.
135
+ # Permitted when (a) the Identity is platform staff (the bypass
136
+ # tier — platform admins drop into every tenant), or (b) the
137
+ # resolved Accounts::Membership's role holds this policy's ability
138
+ # in the gem's permission registry. Resolution is delegated to
139
+ # `Seams::Permissions.can?` so the owner/admin/member hierarchy
140
+ # and the host grant map are the only place access is decided.
124
141
  def permitted?
142
+ permitted_to?(permission_ability)
143
+ end
144
+
145
+ # Shared resolution path for every tenant policy: apply the staff
146
+ # bypass, then defer to `Seams::Permissions.can?` with the
147
+ # membership role and the given ability code. Concrete policies
148
+ # never re-implement the role logic — they only name their
149
+ # ability via `permission_ability`.
150
+ #
151
+ # Fail closed: a request with no resolved membership, or a policy
152
+ # that names no ability, is denied (only platform staff pass).
153
+ def permitted_to?(ability)
125
154
  return true if user&.identity&.staff?
155
+ return false if ability.nil?
156
+
157
+ role = user&.membership&.role
158
+ return false if role.nil?
126
159
 
127
- membership = user&.membership
128
- return false if membership.nil?
160
+ ::Seams::Permissions.can?(role: role.to_s, ability: ability)
161
+ end
129
162
 
130
- %w[owner admin].include?(membership.role.to_s)
163
+ # The owning engine's ability code (resource.action.engine) this
164
+ # policy resolves through. Each concrete tenant policy overrides
165
+ # this to name its resource's `manage` ability — the code the
166
+ # owning engine registers at boot and that
167
+ # `Seams::Permissions::DEFAULT_GRANTS` grants to the admin tier.
168
+ # nil here fails closed (only platform staff pass), so a subclass
169
+ # that forgets to name its ability never silently grants access.
170
+ def permission_ability
171
+ nil
131
172
  end
132
173
 
133
174
  def account_id
@@ -48,6 +48,10 @@ module Admin
48
48
 
49
49
  private
50
50
 
51
+ def permission_ability
52
+ "identity.manage.auth"
53
+ end
54
+
51
55
  # Identity has no `account_id` column. Per-record tenant guard
52
56
  # checks instead that the Identity holds a membership in the
53
57
  # current Account.
@@ -19,6 +19,12 @@ module Admin
19
19
  scope.where(account_id: account_id)
20
20
  end
21
21
  end
22
+
23
+ private
24
+
25
+ def permission_ability
26
+ "invitation.manage.teams"
27
+ end
22
28
  end
23
29
  end
24
30
  end