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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -12
- data/README.md +235 -82
- data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
- data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
- data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
- data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
- data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
- data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
- data/lib/generators/seams/admin/admin_generator.rb +51 -4
- data/lib/generators/seams/admin/templates/README.md.tt +33 -3
- data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
- data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
- data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
- data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
- data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
- data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
- data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
- data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
- data/lib/generators/seams/auth/auth_generator.rb +1 -2
- data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
- data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
- data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
- data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
- data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
- data/lib/generators/seams/billing/billing_generator.rb +4 -3
- data/lib/generators/seams/billing/templates/README.md.tt +26 -0
- data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
- data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
- data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
- data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
- data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
- data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
- data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
- data/lib/generators/seams/design/design_generator.rb +647 -0
- data/lib/generators/seams/design/templates/README.md.tt +129 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
- data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
- data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
- data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
- data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
- data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
- data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
- data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
- data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
- data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
- data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
- data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
- data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
- data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
- data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
- data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
- data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
- data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
- data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
- data/lib/generators/seams/engine/engine_generator.rb +6 -0
- data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
- data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
- data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
- data/lib/generators/seams/install/install_generator.rb +72 -6
- data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
- data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
- data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
- data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
- data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
- data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
- data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
- data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
- data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
- data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
- data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
- data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
- data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
- data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
- data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
- data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
- data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
- data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
- data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
- data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
- data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
- data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
- data/lib/seams/cli/list.rb +15 -0
- data/lib/seams/cli/quality.rb +46 -5
- data/lib/seams/cli/resolve.rb +9 -5
- data/lib/seams/cli/test_changed.rb +5 -1
- data/lib/seams/cli.rb +20 -0
- data/lib/seams/configuration.rb +24 -1
- data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
- data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
- data/lib/seams/event_registry.rb +19 -0
- data/lib/seams/events/adapter.rb +9 -0
- data/lib/seams/events/adapters/active_support.rb +4 -0
- data/lib/seams/events/publisher.rb +29 -0
- data/lib/seams/events.rb +1 -0
- data/lib/seams/generators/dummy_app_writer.rb +21 -0
- data/lib/seams/generators/follow_up_generator.rb +1 -2
- data/lib/seams/generators/host_injector.rb +9 -2
- data/lib/seams/generators/splicer.rb +2 -2
- data/lib/seams/observability/adapter.rb +22 -0
- data/lib/seams/observability.rb +6 -0
- data/lib/seams/permission_registry.rb +66 -0
- data/lib/seams/permissions.rb +120 -0
- data/lib/seams/version.rb +1 -1
- data/lib/seams.rb +7 -0
- metadata +107 -3
|
@@ -77,6 +77,24 @@ module Seams
|
|
|
77
77
|
engine_path("lib/accounts/concerns/authorization.rb")
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Phase 4 — the one tenant-facing controller the engine ships:
|
|
81
|
+
# the memberships role picker. Scoped to Accounts::Current.account
|
|
82
|
+
# and guarded by membership.manage.accounts (see the controller).
|
|
83
|
+
def create_controllers
|
|
84
|
+
template_unless_ejected "app/controllers/memberships_controller.rb.tt",
|
|
85
|
+
engine_path("app/controllers/accounts/memberships_controller.rb")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Phase 4 — bare-bones view so the role picker renders out of the
|
|
89
|
+
# box. Plain semantic ERB: NO design-engine (ui_*) helpers, so the
|
|
90
|
+
# screen works whether or not the opt-in design engine is
|
|
91
|
+
# installed. Hosts override by dropping a file at
|
|
92
|
+
# app/views/accounts/memberships/index.html.erb in their own tree.
|
|
93
|
+
def create_views
|
|
94
|
+
template_unless_ejected "app/views/memberships/index.html.erb.tt",
|
|
95
|
+
engine_path("app/views/accounts/memberships/index.html.erb")
|
|
96
|
+
end
|
|
97
|
+
|
|
80
98
|
def create_migrations
|
|
81
99
|
template "db/migrate/create_accounts.rb.tt",
|
|
82
100
|
engine_path("db/migrate/#{timestamp(0)}_create_accounts.rb")
|
|
@@ -154,9 +172,19 @@ module Seams
|
|
|
154
172
|
end
|
|
155
173
|
|
|
156
174
|
def create_runtime_specs
|
|
157
|
-
#
|
|
158
|
-
# create_with_owner, and Accounts::Current.
|
|
159
|
-
#
|
|
175
|
+
# The boot spec (written in create_dummy_app) covers events,
|
|
176
|
+
# schema, create_with_owner, and Accounts::Current. Phase 4 adds
|
|
177
|
+
# a request-style behavioural spec for the memberships role
|
|
178
|
+
# picker: the happy path plus each of the three escalation
|
|
179
|
+
# safeguards.
|
|
180
|
+
template "spec/runtime/memberships_flow_spec.rb.tt",
|
|
181
|
+
engine_path("spec/runtime/accounts_memberships_flow_spec.rb")
|
|
182
|
+
# Wave 11B / #37 — request-layer regression for the permission
|
|
183
|
+
# tiers the Accounts::Authorization concern enforces:
|
|
184
|
+
# deny-by-role, allow-by-role, the platform-staff bypass, and the
|
|
185
|
+
# cross-tenant guarantee that the ACTIVE membership decides.
|
|
186
|
+
template "spec/runtime/authorization_spec.rb.tt",
|
|
187
|
+
engine_path("spec/runtime/accounts_authorization_spec.rb")
|
|
160
188
|
end
|
|
161
189
|
|
|
162
190
|
def wire_into_host
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Accounts
|
|
4
|
+
# Tenant-facing screen where an account admin changes a member's
|
|
5
|
+
# role. Both actions are scoped to `Accounts::Current.account` and
|
|
6
|
+
# guarded by the `membership.manage.accounts` ability — the
|
|
7
|
+
# Authorization concern resolves that against the current
|
|
8
|
+
# membership's role and applies the platform-staff bypass.
|
|
9
|
+
#
|
|
10
|
+
# `#update` enforces three hard safeguards against privilege
|
|
11
|
+
# escalation (see the guard chain). Each one redirects back with an
|
|
12
|
+
# alert rather than silently no-opping, so an admin who hits a wall
|
|
13
|
+
# is told why.
|
|
14
|
+
class MembershipsController < ApplicationController
|
|
15
|
+
include Accounts::Authorization
|
|
16
|
+
|
|
17
|
+
# owner > admin > member. The screen never assigns `owner` (it is
|
|
18
|
+
# protected — see safeguard 1) nor `system` (an internal,
|
|
19
|
+
# human-less actor), so the only roles a picker may offer are
|
|
20
|
+
# `admin` and `member`.
|
|
21
|
+
ROLE_RANK = { "member" => 1, "admin" => 2, "owner" => 3 }.freeze
|
|
22
|
+
ASSIGNABLE_ROLES = %w[admin member].freeze
|
|
23
|
+
|
|
24
|
+
before_action -> { authorize_permission!("membership.manage.accounts") }
|
|
25
|
+
before_action :set_membership, only: %i[update]
|
|
26
|
+
|
|
27
|
+
helper_method :own_membership?, :assignable_roles
|
|
28
|
+
|
|
29
|
+
def index
|
|
30
|
+
@memberships = Accounts::Current.account.memberships.active.order(:created_at)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update
|
|
34
|
+
violation = escalation_violation
|
|
35
|
+
return deny(violation) if violation
|
|
36
|
+
|
|
37
|
+
@membership.update!(role: requested_role)
|
|
38
|
+
redirect_to memberships_path, notice: "Role updated."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# The safeguard message that blocks this role change, or nil when
|
|
44
|
+
# the change is allowed. Ordered so each safeguard has a single,
|
|
45
|
+
# independently testable trigger:
|
|
46
|
+
#
|
|
47
|
+
# 2. a user cannot change their own role;
|
|
48
|
+
# 1a. an owner's role cannot be changed (no demotion);
|
|
49
|
+
# --. `system` / unknown roles are never assignable;
|
|
50
|
+
# 3. a user cannot assign a role senior to their own (owner >
|
|
51
|
+
# admin > member) — this is what stops an admin minting an
|
|
52
|
+
# owner;
|
|
53
|
+
# 1b. `owner` is never assignable, even for an acting owner who
|
|
54
|
+
# would otherwise clear the senior check.
|
|
55
|
+
def escalation_violation
|
|
56
|
+
return "You cannot change your own role." if own_membership?(@membership)
|
|
57
|
+
return "The owner's role cannot be changed through this screen." if @membership.owner?
|
|
58
|
+
return "That role cannot be assigned." unless ROLE_RANK.key?(requested_role)
|
|
59
|
+
return "You cannot assign a role senior to your own." unless actor_can_assign?(requested_role)
|
|
60
|
+
return "The owner role cannot be assigned here." unless ASSIGNABLE_ROLES.include?(requested_role)
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Always scoped to the current account so an admin can never reach
|
|
66
|
+
# a membership in another tenant by guessing an id.
|
|
67
|
+
def set_membership
|
|
68
|
+
@membership = Accounts::Current.account.memberships.find(params[:id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Role extracted on its own (never via permit) so the params shape
|
|
72
|
+
# reads as "we coerce the role ourselves", the same Brakeman-safe
|
|
73
|
+
# convention the teams engine uses.
|
|
74
|
+
def requested_role
|
|
75
|
+
params.dig(:membership, :role).to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def own_membership?(membership)
|
|
79
|
+
actor = Accounts::Current.membership
|
|
80
|
+
actor.present? && actor.id == membership.id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The acting role may assign a target role only when the target is
|
|
84
|
+
# not senior to the actor's own (owner > admin > member). An actor
|
|
85
|
+
# with no resolved membership (rank 0) can assign nothing.
|
|
86
|
+
def actor_can_assign?(role)
|
|
87
|
+
actor_rank = ROLE_RANK[current_permission_role].to_i
|
|
88
|
+
target_rank = ROLE_RANK[role].to_i
|
|
89
|
+
target_rank.positive? && target_rank <= actor_rank
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def assignable_roles
|
|
93
|
+
ASSIGNABLE_ROLES
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deny(message)
|
|
97
|
+
redirect_to memberships_path, alert: message
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%%# Members of the current account with a per-member role picker. %>
|
|
2
|
+
<%%# Owners and the acting member's own row are locked — the controller %>
|
|
3
|
+
<%%# enforces the same rules server-side; the view just doesn't offer a %>
|
|
4
|
+
<%%# control it knows will be refused. %>
|
|
5
|
+
<%%# %>
|
|
6
|
+
<%%# Plain, semantic ERB on purpose: the design engine is opt-in and may %>
|
|
7
|
+
<%%# not be installed, so this view must NOT call ui_* helpers (they would %>
|
|
8
|
+
<%%# raise when design is absent). Sensible class names let the design %>
|
|
9
|
+
<%%# system style it if present, with zero hard dependency. %>
|
|
10
|
+
<h1 class="accounts-memberships__title">Members</h1>
|
|
11
|
+
|
|
12
|
+
<%%= content_tag :p, flash[:notice], class: "accounts-memberships__notice" if flash[:notice] %>
|
|
13
|
+
<%%= content_tag :p, flash[:alert], class: "accounts-memberships__alert" if flash[:alert] %>
|
|
14
|
+
|
|
15
|
+
<table class="accounts-memberships">
|
|
16
|
+
<thead>
|
|
17
|
+
<tr>
|
|
18
|
+
<th scope="col">Member</th>
|
|
19
|
+
<th scope="col">Role</th>
|
|
20
|
+
<th scope="col">Change role</th>
|
|
21
|
+
</tr>
|
|
22
|
+
</thead>
|
|
23
|
+
<tbody>
|
|
24
|
+
<%% @memberships.each do |membership| %>
|
|
25
|
+
<tr class="accounts-memberships__row">
|
|
26
|
+
<td class="accounts-memberships__name"><%%= membership.name %></td>
|
|
27
|
+
<td class="accounts-memberships__role"><%%= membership.role.titleize %></td>
|
|
28
|
+
<td class="accounts-memberships__actions">
|
|
29
|
+
<%% if membership.owner? || own_membership?(membership) %>
|
|
30
|
+
<span class="accounts-memberships__locked">Locked</span>
|
|
31
|
+
<%% else %>
|
|
32
|
+
<%%= form_with url: membership_path(membership), method: :patch,
|
|
33
|
+
class: "accounts-memberships__form" do %>
|
|
34
|
+
<label class="accounts-memberships__label"
|
|
35
|
+
for="membership_role_<%%= membership.id %>">Role</label>
|
|
36
|
+
<select id="membership_role_<%%= membership.id %>"
|
|
37
|
+
name="membership[role]"
|
|
38
|
+
class="accounts-memberships__select">
|
|
39
|
+
<%% assignable_roles.each do |role| %>
|
|
40
|
+
<option value="<%%= role %>" <%%= "selected" if membership.role == role %>>
|
|
41
|
+
<%%= role.titleize %>
|
|
42
|
+
</option>
|
|
43
|
+
<%% end %>
|
|
44
|
+
</select>
|
|
45
|
+
<button type="submit" class="accounts-memberships__submit">Update</button>
|
|
46
|
+
<%% end %>
|
|
47
|
+
<%% end %>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<%% end %>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Wave 11 (Phase 4): the accounts engine ships a single tenant-facing
|
|
4
|
+
# surface — the memberships role picker (index + update), scoped to
|
|
5
|
+
# Accounts::Current.account and guarded by membership.manage.accounts.
|
|
6
|
+
# Account-creation flows still live in the host.
|
|
7
7
|
Accounts::Engine.routes.draw do
|
|
8
|
+
# Follow-up generators that add admin-only or token-only routes splice them here, ahead of the canonical memberships resource.
|
|
9
|
+
# seams:insertion-point accounts.routes.before_memberships
|
|
10
|
+
resources :memberships, only: %i[index update]
|
|
11
|
+
|
|
12
|
+
# Follow-up generators that add new top-level accounts routes splice them here.
|
|
13
|
+
# seams:insertion-point accounts.routes.after_memberships
|
|
8
14
|
end
|
|
@@ -85,8 +85,27 @@ module Accounts
|
|
|
85
85
|
# bypass the per-account scope (e.g. impersonation, cross-account
|
|
86
86
|
# search). Distinct from `ensure_admin`.
|
|
87
87
|
def ensure_staff
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
head :forbidden unless staff_actor?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Per-account permission guard. Resolves the current membership's
|
|
92
|
+
# role for `Accounts::Current.account` and asks the gem registry
|
|
93
|
+
# whether that role holds the given ability code (the
|
|
94
|
+
# `resource.action.engine` strings each engine registers). Platform
|
|
95
|
+
# staff (`Auth::Identity#staff?`) are let through — the request-layer
|
|
96
|
+
# bypass `Seams::Permissions.can?` deliberately leaves to callers.
|
|
97
|
+
# Denies with 403 otherwise.
|
|
98
|
+
#
|
|
99
|
+
# before_action -> { authorize_permission!("invoice.read.billing") }
|
|
100
|
+
#
|
|
101
|
+
# The role is read from the per-request membership, never a global,
|
|
102
|
+
# so the same Identity can hold different powers in different
|
|
103
|
+
# accounts.
|
|
104
|
+
def authorize_permission!(ability)
|
|
105
|
+
return if staff_actor?
|
|
106
|
+
return if Seams::Permissions.can?(role: current_permission_role, ability: ability)
|
|
107
|
+
|
|
108
|
+
head :forbidden
|
|
90
109
|
end
|
|
91
110
|
|
|
92
111
|
def redirect_existing_membership
|
|
@@ -101,5 +120,19 @@ module Accounts
|
|
|
101
120
|
def authorization_redirect_path
|
|
102
121
|
Accounts.configuration.after_account_create_url
|
|
103
122
|
end
|
|
123
|
+
|
|
124
|
+
# Role of the membership resolved for the current account/identity,
|
|
125
|
+
# or nil when there is no current membership (which denies, since
|
|
126
|
+
# no role holds any ability).
|
|
127
|
+
def current_permission_role
|
|
128
|
+
Accounts::Current.membership&.role
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Platform-admin actor? (Identity#staff?). Shared by `ensure_staff`
|
|
132
|
+
# and the `authorize_permission!` bypass.
|
|
133
|
+
def staff_actor?
|
|
134
|
+
identity = defined?(Auth::Current) ? Auth::Current.identity : nil
|
|
135
|
+
identity&.staff? || false
|
|
136
|
+
end
|
|
104
137
|
end
|
|
105
138
|
end
|
|
@@ -18,6 +18,19 @@ module Accounts
|
|
|
18
18
|
# seams:insertion-point accounts.engine.events
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
initializer "accounts.register_abilities" do
|
|
22
|
+
# Ability codes this engine owns, registered exactly like events
|
|
23
|
+
# (resource.action.engine). `read` is the member-level view;
|
|
24
|
+
# `manage` is the admin-level mutate. Host grant maps reference
|
|
25
|
+
# these codes; see Seams::Permissions::DEFAULT_GRANTS.
|
|
26
|
+
Seams::PermissionRegistry.register("account.read.accounts", owned_by: "Accounts")
|
|
27
|
+
Seams::PermissionRegistry.register("account.manage.accounts", owned_by: "Accounts")
|
|
28
|
+
Seams::PermissionRegistry.register("membership.read.accounts", owned_by: "Accounts")
|
|
29
|
+
Seams::PermissionRegistry.register("membership.manage.accounts", owned_by: "Accounts")
|
|
30
|
+
# Follow-up generators that ship new accounts abilities register them here.
|
|
31
|
+
# seams:insertion-point accounts.engine.abilities
|
|
32
|
+
end
|
|
33
|
+
|
|
21
34
|
initializer "accounts.append_migrations" do |app|
|
|
22
35
|
unless app.root == root
|
|
23
36
|
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rails_helper"
|
|
4
|
+
|
|
5
|
+
# Request-layer regression for Accounts::Authorization#authorize_permission!
|
|
6
|
+
# — the permission tiers the gem's role hierarchy resolves through at the
|
|
7
|
+
# controller boundary. The role-picker behaviour (the escalation
|
|
8
|
+
# safeguards) lives in accounts_memberships_flow_spec; this spec pins the
|
|
9
|
+
# three authorisation tiers the concern enforces BEFORE the action runs:
|
|
10
|
+
#
|
|
11
|
+
# 1. deny-by-role: a membership whose role lacks the ability -> 403.
|
|
12
|
+
# 2. allow-by-role: a membership whose role holds the ability -> through.
|
|
13
|
+
# 3. staff bypass: a platform-staff Identity is let through regardless of
|
|
14
|
+
# the membership role (the request-layer bypass the gem deliberately
|
|
15
|
+
# leaves to the caller, not Seams::Permissions.can?).
|
|
16
|
+
#
|
|
17
|
+
# Cross-tenant isolation: the decision is read from the ACTIVE membership
|
|
18
|
+
# (Accounts::Current.membership.role), never the Identity, so the same
|
|
19
|
+
# person can be admin in one account and member in another and get
|
|
20
|
+
# different powers per request. Flipping the active membership flips the
|
|
21
|
+
# decision — pinned below.
|
|
22
|
+
#
|
|
23
|
+
# The memberships controller guards every action with
|
|
24
|
+
# `authorize_permission!("membership.manage.accounts")`, so its index is a
|
|
25
|
+
# faithful probe of the gate. As in accounts_memberships_flow_spec, the
|
|
26
|
+
# per-request Current surface is stubbed with method stubs (they survive
|
|
27
|
+
# the executor reset that clears CurrentAttributes values around requests).
|
|
28
|
+
RSpec.describe "Accounts::Authorization permission tiers", type: :request do
|
|
29
|
+
def act_as(membership:, staff: false)
|
|
30
|
+
allow(Accounts::Current).to receive(:account).and_return(account)
|
|
31
|
+
allow(Accounts::Current).to receive(:membership).and_return(membership)
|
|
32
|
+
identity = staff ? Struct.new(:staff?).new(true) : nil
|
|
33
|
+
allow(Auth::Current).to receive(:identity).and_return(identity)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:owner_identity) do
|
|
37
|
+
Auth::Identity.create!(email: "owner-#{SecureRandom.hex(4)}@example.com", password: "verysecret")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
let(:account) do
|
|
41
|
+
Accounts::Account.create_with_owner(
|
|
42
|
+
account: { name: "Acme #{SecureRandom.hex(2)}" },
|
|
43
|
+
owner: Struct.new(:identity, :name).new(owner_identity, "Olive Owner")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
let(:admin_membership) { build_membership("admin", "Adam Admin") }
|
|
48
|
+
let(:member_membership) { build_membership("member", "Mia Member") }
|
|
49
|
+
|
|
50
|
+
def build_membership(role, name)
|
|
51
|
+
identity = Auth::Identity.create!(
|
|
52
|
+
email: "#{role}-#{SecureRandom.hex(4)}@example.com", password: "verysecret"
|
|
53
|
+
)
|
|
54
|
+
account.memberships.create!(role: role, name: name, identity_id: identity.id, active: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request_guarded_action
|
|
58
|
+
get "/accounts/memberships"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "tier 1: deny-by-role" do
|
|
62
|
+
it "forbids a plain member (member does not hold membership.manage.accounts)" do
|
|
63
|
+
act_as(membership: member_membership)
|
|
64
|
+
|
|
65
|
+
request_guarded_action
|
|
66
|
+
|
|
67
|
+
expect(response).to have_http_status(:forbidden)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe "tier 2: allow-by-role" do
|
|
72
|
+
it "lets an admin through (admin holds membership.manage.accounts)" do
|
|
73
|
+
act_as(membership: admin_membership)
|
|
74
|
+
|
|
75
|
+
request_guarded_action
|
|
76
|
+
|
|
77
|
+
expect(response).to have_http_status(:ok)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe "tier 3: staff bypass" do
|
|
82
|
+
it "lets a platform-staff Identity through despite a member-role membership" do
|
|
83
|
+
act_as(membership: member_membership, staff: true)
|
|
84
|
+
|
|
85
|
+
request_guarded_action
|
|
86
|
+
|
|
87
|
+
expect(response).to have_http_status(:ok)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe "cross-tenant isolation: the active membership decides, not the identity" do
|
|
92
|
+
it "flips the decision when the active membership role changes" do
|
|
93
|
+
act_as(membership: member_membership)
|
|
94
|
+
request_guarded_action
|
|
95
|
+
expect(response).to have_http_status(:forbidden)
|
|
96
|
+
|
|
97
|
+
act_as(membership: admin_membership)
|
|
98
|
+
request_guarded_action
|
|
99
|
+
expect(response).to have_http_status(:ok)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rails_helper"
|
|
4
|
+
|
|
5
|
+
# Behavioural request specs for the tenant-facing memberships role
|
|
6
|
+
# picker: the happy path plus each of the three privilege-escalation
|
|
7
|
+
# safeguards the controller enforces.
|
|
8
|
+
#
|
|
9
|
+
# The Rack stack resets ActiveSupport::CurrentAttributes around every
|
|
10
|
+
# request, so attribute values set in the spec would be wiped before
|
|
11
|
+
# the controller ran. Instead we stub the per-request Current surface
|
|
12
|
+
# (the account + the acting membership) and the Auth identity the staff
|
|
13
|
+
# bypass reads — method stubs survive the executor reset that clears
|
|
14
|
+
# attribute values.
|
|
15
|
+
RSpec.describe "Accounts memberships role picker", type: :request do
|
|
16
|
+
def act_as(membership:, staff: false)
|
|
17
|
+
allow(Accounts::Current).to receive(:account).and_return(account)
|
|
18
|
+
allow(Accounts::Current).to receive(:membership).and_return(membership)
|
|
19
|
+
identity = staff ? Struct.new(:staff?).new(true) : nil
|
|
20
|
+
allow(Auth::Current).to receive(:identity).and_return(identity)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:owner_identity) do
|
|
24
|
+
Auth::Identity.create!(email: "owner-#{SecureRandom.hex(4)}@example.com", password: "verysecret")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
let(:account) do
|
|
28
|
+
Accounts::Account.create_with_owner(
|
|
29
|
+
account: { name: "Acme #{SecureRandom.hex(2)}" },
|
|
30
|
+
owner: Struct.new(:identity, :name).new(owner_identity, "Olive Owner")
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
let(:owner_membership) { account.memberships.find_by(role: "owner") }
|
|
35
|
+
let(:admin_membership) { build_membership("admin", "Adam Admin") }
|
|
36
|
+
let(:member_membership) { build_membership("member", "Mia Member") }
|
|
37
|
+
|
|
38
|
+
def build_membership(role, name)
|
|
39
|
+
identity = Auth::Identity.create!(
|
|
40
|
+
email: "#{role}-#{SecureRandom.hex(4)}@example.com", password: "verysecret"
|
|
41
|
+
)
|
|
42
|
+
account.memberships.create!(role: role, name: name, identity_id: identity.id, active: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe "index" do
|
|
46
|
+
it "lists the current account's active memberships" do
|
|
47
|
+
act_as(membership: admin_membership)
|
|
48
|
+
member_membership
|
|
49
|
+
|
|
50
|
+
get "/accounts/memberships"
|
|
51
|
+
|
|
52
|
+
expect(response).to have_http_status(:ok)
|
|
53
|
+
expect(response.body).to include("Mia Member")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe "happy path" do
|
|
58
|
+
it "an admin promotes a member to admin" do
|
|
59
|
+
act_as(membership: admin_membership)
|
|
60
|
+
target = member_membership
|
|
61
|
+
|
|
62
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "admin" } }
|
|
63
|
+
|
|
64
|
+
expect(response).to have_http_status(:found)
|
|
65
|
+
expect(flash[:notice]).to be_present
|
|
66
|
+
expect(target.reload.role).to eq("admin")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "an admin demotes another admin to member" do
|
|
70
|
+
act_as(membership: admin_membership)
|
|
71
|
+
target = build_membership("admin", "Other Admin")
|
|
72
|
+
|
|
73
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "member" } }
|
|
74
|
+
|
|
75
|
+
expect(response).to have_http_status(:found)
|
|
76
|
+
expect(target.reload.role).to eq("member")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "safeguard 1: the owner role cannot be changed" do
|
|
81
|
+
it "refuses to demote an owner" do
|
|
82
|
+
act_as(membership: admin_membership)
|
|
83
|
+
|
|
84
|
+
patch "/accounts/memberships/#{owner_membership.id}", params: { membership: { role: "member" } }
|
|
85
|
+
|
|
86
|
+
expect(flash[:alert]).to be_present
|
|
87
|
+
expect(owner_membership.reload.role).to eq("owner")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "refuses to promote anyone to owner, even for an acting owner" do
|
|
91
|
+
act_as(membership: owner_membership)
|
|
92
|
+
target = member_membership
|
|
93
|
+
|
|
94
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "owner" } }
|
|
95
|
+
|
|
96
|
+
expect(flash[:alert]).to be_present
|
|
97
|
+
expect(target.reload.role).to eq("member")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe "safeguard 2: a user cannot change their own role" do
|
|
102
|
+
it "refuses to change the acting membership" do
|
|
103
|
+
act_as(membership: admin_membership)
|
|
104
|
+
|
|
105
|
+
patch "/accounts/memberships/#{admin_membership.id}", params: { membership: { role: "member" } }
|
|
106
|
+
|
|
107
|
+
expect(flash[:alert]).to match(/your own role/i)
|
|
108
|
+
expect(admin_membership.reload.role).to eq("admin")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe "safeguard 3: a user cannot assign a role senior to their own" do
|
|
113
|
+
it "refuses an admin assigning owner (senior to admin)" do
|
|
114
|
+
act_as(membership: admin_membership)
|
|
115
|
+
target = member_membership
|
|
116
|
+
|
|
117
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "owner" } }
|
|
118
|
+
|
|
119
|
+
expect(flash[:alert]).to match(/senior/i)
|
|
120
|
+
expect(target.reload.role).to eq("member")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "never assigns the system role through the screen" do
|
|
124
|
+
act_as(membership: admin_membership)
|
|
125
|
+
target = member_membership
|
|
126
|
+
|
|
127
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "system" } }
|
|
128
|
+
|
|
129
|
+
expect(flash[:alert]).to be_present
|
|
130
|
+
expect(target.reload.role).to eq("member")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe "authorization" do
|
|
135
|
+
it "forbids a plain member from changing roles" do
|
|
136
|
+
act_as(membership: member_membership)
|
|
137
|
+
target = build_membership("member", "Another Member")
|
|
138
|
+
|
|
139
|
+
patch "/accounts/memberships/#{target.id}", params: { membership: { role: "admin" } }
|
|
140
|
+
|
|
141
|
+
expect(response).to have_http_status(:forbidden)
|
|
142
|
+
expect(target.reload.role).to eq("member")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -51,7 +51,7 @@ module Seams
|
|
|
51
51
|
# extra column.
|
|
52
52
|
#
|
|
53
53
|
# Run with: bin/rails generate seams:admin
|
|
54
|
-
# rubocop:disable Metrics/ClassLength
|
|
54
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
55
55
|
class AdminGenerator < Rails::Generators::Base
|
|
56
56
|
include Seams::Generators::HostInjector
|
|
57
57
|
include Seams::Generators::EjectAware
|
|
@@ -69,7 +69,7 @@ module Seams
|
|
|
69
69
|
#
|
|
70
70
|
# The order here is the order routes + dashboards appear in
|
|
71
71
|
# the generated files; Identity comes first so the engine root
|
|
72
|
-
# (`root to: "admin/identities#index"`) lands on something
|
|
72
|
+
# (`root to: "/admin/identities#index"`) lands on something
|
|
73
73
|
# meaningful.
|
|
74
74
|
DASHBOARD_MODELS = [
|
|
75
75
|
# [dashboard_basename, model_class, owning_engine]
|
|
@@ -87,8 +87,11 @@ module Seams
|
|
|
87
87
|
["lifetime_pass", "Billing::LifetimePass", "billing"]
|
|
88
88
|
].freeze
|
|
89
89
|
|
|
90
|
+
# The base engine would mount `Admin::Engine` and write an
|
|
91
|
+
# `Admin.configure` initializer, but this engine's constants live
|
|
92
|
+
# under `Seams::Admin`. wire_into_host mounts the real class.
|
|
90
93
|
def create_base_engine
|
|
91
|
-
EngineGenerator.start([ENGINE_NAME], destination_root: destination_root)
|
|
94
|
+
EngineGenerator.start([ENGINE_NAME, "--skip-host-wiring"], destination_root: destination_root)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def overwrite_engine_entry_point
|
|
@@ -186,6 +189,29 @@ module Seams
|
|
|
186
189
|
end
|
|
187
190
|
end
|
|
188
191
|
|
|
192
|
+
# Administrate partial overrides: navigation built from this
|
|
193
|
+
# engine's routes, and an index header whose New link does not
|
|
194
|
+
# depend on the dashboard class name. See each partial's header.
|
|
195
|
+
def create_view_overrides
|
|
196
|
+
%w[_navigation _index_header].each do |partial|
|
|
197
|
+
template_unless_ejected(
|
|
198
|
+
"app/views/seams/admin/application/#{partial}.html.erb.tt",
|
|
199
|
+
engine_path("app/views/seams/admin/application/#{partial}.html.erb")
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Association fields that name their dashboard explicitly; see
|
|
205
|
+
# templates/app/fields/admin/fields/dashboard_option.rb.tt.
|
|
206
|
+
def create_fields
|
|
207
|
+
%w[dashboard_option belongs_to has_many].each do |field|
|
|
208
|
+
template_unless_ejected(
|
|
209
|
+
"app/fields/admin/fields/#{field}.rb.tt",
|
|
210
|
+
engine_path("app/fields/admin/fields/#{field}.rb")
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
189
215
|
# Phase 2: emit one dashboard + one controller per entry in
|
|
190
216
|
# DASHBOARD_MODELS. `template_unless_ejected` so a host that
|
|
191
217
|
# ejects an individual dashboard (e.g. to restyle the Identity
|
|
@@ -221,6 +247,7 @@ module Seams
|
|
|
221
247
|
write_auth_current_stub
|
|
222
248
|
write_accounts_current_stub
|
|
223
249
|
write_dummy_model_stubs
|
|
250
|
+
write_dummy_ability_registrations
|
|
224
251
|
amend_dummy_application_rb
|
|
225
252
|
rewrite_dummy_routes_for_namespaced_engine
|
|
226
253
|
end
|
|
@@ -424,6 +451,27 @@ module Seams
|
|
|
424
451
|
# `class Foo < ApplicationRecord; self.table_name = "..."; end`
|
|
425
452
|
# — no associations, no validations, no callbacks. The dummy
|
|
426
453
|
# schema (see #dummy_schema) ships the matching tables.
|
|
454
|
+
# The tenant policies resolve through Seams::Permissions.can?, which
|
|
455
|
+
# raises for an ability no engine registered. In a host the owning
|
|
456
|
+
# engines register them at boot; the dummy app only stubs those
|
|
457
|
+
# engines' models, so register their default abilities here.
|
|
458
|
+
def write_dummy_ability_registrations
|
|
459
|
+
path = File.join(destination_root, "engines", ENGINE_NAME,
|
|
460
|
+
"spec/dummy/config/initializers/seams_abilities.rb")
|
|
461
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
462
|
+
File.write(path, <<~RUBY)
|
|
463
|
+
# frozen_string_literal: true
|
|
464
|
+
|
|
465
|
+
# Stand-in for the ability registrations the auth, accounts,
|
|
466
|
+
# teams, billing, and notifications engines make at boot.
|
|
467
|
+
Seams::Permissions::DEFAULT_GRANTS.values.flatten.uniq.each do |ability|
|
|
468
|
+
next if Seams::PermissionRegistry.registered?(ability)
|
|
469
|
+
|
|
470
|
+
Seams::PermissionRegistry.register(ability, owned_by: ability.split(".").last.capitalize)
|
|
471
|
+
end
|
|
472
|
+
RUBY
|
|
473
|
+
end
|
|
474
|
+
|
|
427
475
|
def write_dummy_model_stubs
|
|
428
476
|
DUMMY_MODEL_STUBS.each do |relative_path, body|
|
|
429
477
|
full = File.join(destination_root, "engines", ENGINE_NAME,
|
|
@@ -847,6 +895,5 @@ module Seams
|
|
|
847
895
|
SCHEMA
|
|
848
896
|
end
|
|
849
897
|
end
|
|
850
|
-
# rubocop:enable Metrics/ClassLength
|
|
851
898
|
end
|
|
852
899
|
end
|