organizations 0.5.0 → 0.7.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/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/Appraisals +2 -0
- data/CHANGELOG.md +112 -0
- data/README.md +714 -32
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +6 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +155 -2
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +217 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +79 -8
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +96 -15
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +198 -36
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31b245f4e0966a2e2dd4c3eb7f29b6d226981bc608816d7b69e07fe4f8397082
|
|
4
|
+
data.tar.gz: 285885977f86a5695f75bc1a2ed6ad4ce63287f76fe7d8b1fa07590e9f13f148
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58c3126cda6583ca0b315c784bff2d3a53bb78edd999e32e047c62fcf621bf3f65c0424e682cd8cd3e95eaff8470bae1b35092d8012d4df6a5464b9c4c40e036
|
|
7
|
+
data.tar.gz: e52343ef673c5279f35ac7ba765188dcc3715daee9581fc847704d93065d4f4612c23d3fe6d314b8f4b017e6794ae6f8c9fab708c5a61052fd6939dc3486278d
|
data/.rubocop.yml
CHANGED
|
@@ -9,9 +9,11 @@ plugins:
|
|
|
9
9
|
AllCops:
|
|
10
10
|
TargetRubyVersion: 3.2
|
|
11
11
|
NewCops: enable
|
|
12
|
+
SuggestExtensions: false
|
|
12
13
|
Exclude:
|
|
13
14
|
- 'bin/**/*'
|
|
14
15
|
- 'vendor/**/*'
|
|
16
|
+
- 'tmp/**/*' # generator test output
|
|
15
17
|
- 'test/dummy/**/*'
|
|
16
18
|
- 'db/migrate/**/*' # Generated migrations
|
|
17
19
|
- 'lib/generators/**/templates/**/*' # Generator templates
|
data/.rubocop_todo.yml
CHANGED
|
@@ -147,20 +147,27 @@ Metrics/BlockNesting:
|
|
|
147
147
|
Metrics/ClassLength:
|
|
148
148
|
Exclude:
|
|
149
149
|
- 'lib/organizations/configuration.rb'
|
|
150
|
+
- 'lib/organizations/models/domain.rb'
|
|
150
151
|
- 'lib/organizations/models/invitation.rb'
|
|
151
152
|
- 'lib/organizations/models/organization.rb'
|
|
153
|
+
- 'test/account_deletion_test.rb'
|
|
152
154
|
- 'test/callbacks_test.rb'
|
|
153
155
|
- 'test/concerns/has_organizations_test.rb'
|
|
154
156
|
- 'test/configuration_test.rb'
|
|
155
157
|
- 'test/controller_helpers_test.rb'
|
|
156
158
|
- 'test/data_integrity_test.rb'
|
|
159
|
+
- 'test/domain_authority_test.rb'
|
|
157
160
|
- 'test/models/invitation_test.rb'
|
|
158
161
|
- 'test/models/membership_test.rb'
|
|
159
162
|
- 'test/models/organization_test.rb'
|
|
163
|
+
- 'test/personal_organizations_test.rb'
|
|
160
164
|
- 'test/regression/bugfix_regression_test.rb'
|
|
161
165
|
- 'test/regression/security_regression_test.rb'
|
|
166
|
+
- 'test/resource_grants_test.rb'
|
|
167
|
+
- 'test/role_assignment_cap_test.rb'
|
|
162
168
|
- 'test/roles_test.rb'
|
|
163
169
|
- 'test/view_helpers_test.rb'
|
|
170
|
+
- 'test/vocabulary_test.rb'
|
|
164
171
|
|
|
165
172
|
# Offense count: 8
|
|
166
173
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
data/.simplecov
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# SimpleCov configuration
|
|
4
|
-
#
|
|
3
|
+
# SimpleCov configuration (auto-loaded by `require "simplecov"`). It holds
|
|
4
|
+
# configuration only: test/test_helper.rb calls SimpleCov.start, as
|
|
5
|
+
# SimpleCov 1.x requires.
|
|
5
6
|
|
|
6
|
-
SimpleCov.
|
|
7
|
+
SimpleCov.configure do
|
|
7
8
|
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
9
|
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
10
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
# Report the gem's source (lib/ and app/), unloaded files included.
|
|
12
|
+
# skip/cover are the SimpleCov 1.x names (Ruby 3.3+); 0.22 on Ruby 3.2
|
|
13
|
+
# knows add_filter/track_files.
|
|
14
|
+
if respond_to?(:cover)
|
|
15
|
+
skip "/test/"
|
|
16
|
+
cover "{lib,app}/**/*.rb"
|
|
17
|
+
else
|
|
18
|
+
add_filter "/test/"
|
|
19
|
+
track_files "{lib,app}/**/*.rb"
|
|
20
|
+
end
|
|
15
21
|
|
|
16
22
|
# Enable branch coverage for more detailed metrics
|
|
17
23
|
enable_coverage :branch
|
|
18
24
|
|
|
19
|
-
#
|
|
20
|
-
|
|
25
|
+
# A floor for the full suite in CI only: running one test file locally
|
|
26
|
+
# must not fail on coverage.
|
|
27
|
+
minimum_coverage line: 78, branch: 72 if ENV["CI"]
|
|
21
28
|
|
|
22
29
|
# Disambiguate parallel test runs
|
|
23
30
|
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
data/Appraisals
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.7.0] - 2026-10-18
|
|
4
|
+
|
|
5
|
+
**Personal organizations, safe account deletion, domain authority, join screens, vocabularies and scoped resource grants.** This release closes issue #21. Every new feature is opt-in: an existing app that upgrades and runs `rails g organizations:upgrade` keeps its behaviour, apart from the changes listed under "Changed".
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Personal organizations** (issue #21). `config.record_personal_organizations = true` records the auto-created organization as the user's personal organization. New API: `user.personal_organization` (a lookup, never creates), `user.create_personal_organization!` (idempotent, the repair path after a failed sign-up provisioning, converges under concurrency), `org.personal?`, `org.personal_owner`, `org.convert_to_shared!`, and the scopes `Organization.personal` and `Organization.not_personal`. The link is `organizations_organizations.personal_owner_id`: unique per user, `ON DELETE SET NULL`.
|
|
10
|
+
- `Organizations::Organization.backfill_personal_owners!(mapping, dry_run:)` links reviewed users to existing organizations. It never guesses. It reports conflicts (`:organization_not_found`, `:not_the_owner`, `:organization_personal_for_another_user`, `:user_has_another_personal_organization`, `:organization_mapped_twice`, `:user_mapped_twice`) and writes nothing when any pair conflicts. It writes in its own savepoint, also inside a host transaction.
|
|
11
|
+
- **Safe account deletion.** `user.account_deletion_plan` lists what `user.destroy` would do, with each blocking organization and its reason, and changes nothing. `config.account_deletion_policy = :destroy_personal_organization` deletes the user's personal organization with the account when the user is its only member. Any other owned organization still blocks. The check covers the complete owned set first, and one transaction holds the organization and the user deletion, so a veto or a failure rolls back everything.
|
|
12
|
+
- **Domain authority** (issue #21). Proof that an organization controls a domain, separate from proof of one inbox. `domain.start_verification!` issues a token bound to one claim (one organization, one exact domain); `domain.verify!` checks the DNS TXT record (`verification_record_name`, `verification_record_value`) and raises `Organizations::DomainVerificationFailed` with a `reason` (`:not_started`, `:expired`, `:changed`, `:record_not_found`, `:lookup_failed`). `domain.verify_by_operator!(operator:, note:)` records a platform operator's approval; a member of the organization cannot approve its own claim. `revoke_verification!`, `recheck_verification!`, `verified?`, `revoked?`, and the scopes `Domain.verified`, `Domain.joinable` and `Domain.with_join_policy`.
|
|
13
|
+
- `config.require_verified_domains` (default `false`): admit through a domain only when it is verified. It covers the emailed challenge, the account-email shortcut, domain-restricted codes and `JoinFlow`.
|
|
14
|
+
- `config.blocked_email_domains` with the opt-in preset `Organizations::PUBLIC_EMAIL_DOMAINS`: a blocked domain cannot be enrolled and no longer admits. Invitations and allowlists are not affected.
|
|
15
|
+
- **Sign-up domain policies.** `domain.join_policy` is `"none"` (default), `"suggest"` or `"auto_join"`, and acts only while the domain is verified and the account email is confirmed. `user.suggested_organizations` lists the organizations to offer. `user.join_organizations_by_domain!` joins the auto-join ones through the membership gate; it runs on its own after sign-up and after the email confirmation. `org.add_domain!` takes `join_policy:`.
|
|
16
|
+
- Configuration: `domain_verification_record_prefix`, `domain_verification_ttl`, `domain_verification_timeout`, `domain_txt_resolver`.
|
|
17
|
+
- **`rails g organizations:join`** (issue #21). It copies a host-owned join screen (`OrganizationJoinsController`: code, work email, 6-digit code, request, withdraw) and an access screen (`OrganizationAccessController`: request queue, domains with DNS verification and the sign-up policy, join codes, allowlist import and removal), and adds their routes under `/orgs/:organization_id`. `--skip-access` and `--skip-routes` narrow it, and a second run adds only the routes that are missing. The generated code requires a signed-in user (through `config.current_user_method`), changes data only with POST and DELETE, redirects with 303 after a successful POST (Turbo), cuts inputs to fixed lengths, never joins on a GET (a `?code=` link only fills in the form), keeps the generic invalid-code message, rate-limits (Rails 7.2+), scopes every admin lookup to the URL's organization, and shows a disclosure before any submit. The engine adds no route, so existing apps change nothing.
|
|
18
|
+
- `OrganizationScoped.require_organization_permission(permission, **options)` gates actions by a permission of the viewer's role, with the same 404 posture as `require_organization_role`.
|
|
19
|
+
- README: a recipe for URL handles (slugs) with `OrganizationScoped` (issue #21).
|
|
20
|
+
- **Vocabulary** (issue #21). `config.vocabulary` selects organization-specific wording: a Symbol for the whole app, or a callable that receives the organization. `Organizations.t(key, organization: record)` uses `organizations.vocabularies.<name>.<key>` when that key exists (in the locale or its I18n fallbacks); otherwise the call is exactly the ordinary one, defaults and options included. `%{organization}` interpolates the record's name. `Organizations.i18n_keys(key, organization:)` gives the chain for the Rails `t` helper (`_html` keys). `organization_role_label` and `organization_role_info` take `organization:`. The gem's models (organization, membership, invitation, join request and domain messages), mailers and controller notices pass the organization they act on; the join-code errors stay generic on purpose. The default (`nil`) changes nothing.
|
|
21
|
+
- **Scoped resource grants** (optional). A membership can be limited to some records of the host app, with a capability list per record: `membership.grant!(record, capabilities:, granted_by:)`, `revoke_grant!`, `grant_for`, `granted?`, `can?(capability, record)`, `capabilities_for`, `limited_by_grants?`, `visible(relation, capability:)` and `granted_ids(type, capability:)`. Members whose role ranks below `config.full_resource_access_role` (`:admin`) see only granted records; the role's permissions stay the ceiling, so a grant narrows and never widens. `include Organizations::Grantable` adds `Model.visible_to(membership, capability:)` and `record.organization_grants`. `Organizations::Resource.new(type, id)` grants records that are not ActiveRecord models. Invitations carry grants (`send_invite_to!(..., grants: { record => caps })`, `invitation.grant!`), which move to the membership on acceptance. `Membership.with_grant_on(record)`, `Organization#grants` and `Grant.for_resource` answer "who has access". Granting checks `config.grant_management_permission` (`:edit_member_roles`), the rank rule, and, for a limited granter, its own access; an actor that is not a member is refused. Records of a model with `organization_id` stay inside their organization for `can?`, `visible_to` and `grant!`. Invitation grants are checked again at acceptance. `config.grant_capabilities` lists the allowed names. `rails g organizations:grants` adds the table; nothing changes until you run it.
|
|
22
|
+
- `rails g organizations:upgrade` now adds only the migrations an install lacks: the 0.5.0 verified-joining migration and the two 0.7.0 migrations (personal organizations, domain verification). It asks the database when it can and reads `db/migrate` otherwise. `--steps NAME…` generates exactly the named steps, for squashed histories. Before, it always generated the 0.5.0 migration, which broke installs made on 0.5.0 or later.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `transfer_ownership_to!` raises `Organization::CannotTransferPersonalOrganization` for a personal organization. Call `convert_to_shared!` first. Organizations that are not personal are not affected.
|
|
27
|
+
- `User#destroy` (from `has_organizations`, prepended so a host override cannot drop it) runs in its own savepoint, so an aborted destroy inside a host transaction rolls back what the deletion guard did.
|
|
28
|
+
- `transfer_ownership_to!` locks the new owner's user row before the organization row (the same order as the deletion guard, so the two cannot deadlock), so a transfer cannot race the deletion of that user. `create_with_owner!` and `create_organization!` raise `ArgumentError` for a `personal_owner` who is not the new owner.
|
|
29
|
+
- The account-deletion guard now locks the user row and the owned organization rows before it checks ownership. A concurrent organization creation for the user can no longer slip past the check and lose its owner membership. The default policy (`:prevent`) and its error message are unchanged, and the guard keeps its method name (`prevent_deletion_while_owning_organizations`), so a host `skip_callback` for it still works.
|
|
30
|
+
- A domain-backed admission checks its domain (or allowlist entry) again: `verify_email_code!` refuses with `VerificationEmailNotEligible` when the matched domain was removed, revoked or blocked, or the roster entry was removed, while the code was in flight. The request stays pending, and an admin can still approve it by hand. `JoinFlow` reports this as `:email_not_eligible`. Before, the code still granted membership.
|
|
31
|
+
- A revoked domain never admits, whatever `require_verified_domains` says. The admission locks the matched domain or roster row, so a concurrent revocation or removal either finishes first or waits.
|
|
32
|
+
- `Organizations.t` treats an `organization:` that is an `Organizations::Organization` record as the vocabulary selector, and interpolates its name. A String keeps working as before.
|
|
33
|
+
- `handle_unauthorized` takes `organization:` (default: the current organization) for the message's vocabulary and for `NotAuthorized#organization`. The organization CRUD controller passes the organization it checked, so the error now names that organization.
|
|
34
|
+
- The views of `rails g organizations:views` print role labels through `organization_role_label(role, organization:)` instead of `role.capitalize`. A custom role such as `team_lead` now reads "Team lead", the same as the gem's other labels. Views you copied earlier are not changed.
|
|
35
|
+
- The dummy app's reference join and access controllers are now `OrganizationJoinsController` and `OrganizationAccessController`, with `organization_*` route names. They are the generator's templates.
|
|
36
|
+
- Engine controllers answer validation failures with status 422 instead of the `:unprocessable_entity` symbol, which Rack 3.2 (Rails 8.1) deprecates. The status code is the same.
|
|
37
|
+
- `organization_members_data`, `organization_invitations_data` and `organization_role_info` pass the organization to the role labels (vocabulary). A host override of `organization_role_label` that takes only the role keeps working.
|
|
38
|
+
|
|
39
|
+
### Development
|
|
40
|
+
|
|
41
|
+
- RuboCop runs in CI, and the 8 open offenses are fixed (`rubocop:disable-next`, RuboCop 1.92+).
|
|
42
|
+
- SimpleCov uses its 1.x API (`skip`, `cover`, `SimpleCov.start` in the test helper) with no deprecation output, and still works with 0.22 on Ruby 3.2. CI enforces a coverage floor for the full suite.
|
|
43
|
+
- The Appraisal gemfiles are regenerated from the Gemfile.
|
|
44
|
+
- New coverage for the permission helpers and the hierarchy with custom roles (`test/custom_role_helpers_test.rb`).
|
|
45
|
+
- README: the public API of the models, the helpers, `Roles` and `JoinState` is documented, with the authorization limits of `authorize_role_change!`, the join request decisions and `grant.allows?`; every configuration option is in the configuration block; the "Future API" example for bulk invitations, which does not exist, is gone.
|
|
46
|
+
- The CI step that uploaded a test report directory that nothing writes is gone.
|
|
47
|
+
|
|
48
|
+
### Upgrading
|
|
49
|
+
|
|
50
|
+
1. Coming from 0.6.0: read the 0.6.1 notes below first (audit pending invitations and recent role changes; `resend!` overrides must accept `resent_by:`).
|
|
51
|
+
2. Run `rails g organizations:upgrade`. It generates the migrations for `organizations_organizations.personal_owner_id` and the domain verification columns, and nothing else. If your user model is not `User`, change `to_table: :users` in them. Then run `rails db:migrate`.
|
|
52
|
+
3. Existing organizations stay unclassified. To link reviewed personal organizations: `Organizations::Organization.backfill_personal_owners!({ user_id => organization_id }, dry_run: true)`, then the same call without `dry_run`.
|
|
53
|
+
4. Existing domains start unverified with the `"none"` policy. Verify them before you turn on `require_verified_domains`.
|
|
54
|
+
5. Optional features need their own step:
|
|
55
|
+
- scoped resource grants: `rails g organizations:grants`, check the type of `grantable_id` (and `to_table: :users`), `rails db:migrate`, then `include Organizations::Grantable` in the models you grant;
|
|
56
|
+
- join and access screens: `rails g organizations:join`.
|
|
57
|
+
6. Check the behaviour changes under "Changed": `transfer_ownership_to!` on a personal organization, the re-check of domain-backed admissions, and `NotAuthorized#organization` from the organization CRUD controller.
|
|
58
|
+
|
|
59
|
+
## [0.6.1] - 2026-10-18
|
|
60
|
+
|
|
61
|
+
**Upgrade note.** Before this release, a custom role with `invite_members` or `edit_member_roles` could invite to, or promote to, a role above its own. An admin invitation crafted that way on 0.6.0 still grants admin when it is accepted after the upgrade. If your app has custom roles with those permissions, audit pending invitations and recent role changes made by members of those roles.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **Security: a member could act on roles above their own.** Found by a template reviewer with a real request.
|
|
66
|
+
- What was wrong: invitations, role changes and removals only refused or protected the `owner` role. Invitation revoke and resend checked no role at all.
|
|
67
|
+
- Who hit it: apps with 0.6.0 custom roles. A role such as `role :lead, inherits: :member do can :invite_members; can :edit_member_roles end` passes the configuration rules. A crafted request from such a member could create an `admin` invitation, promote a viewer to `admin`, or demote an admin. With `remove_members` it could remove an admin. With `invite_members` it could revoke or resend an admin's invitation.
|
|
68
|
+
- The rule now: a role acts only on roles at or below its own in `Roles.hierarchy`. That covers invitations (the invited role), role changes (the old and the new role), removals (the removed member's role) and invitation management (the invitation's role).
|
|
69
|
+
- Below admin, rank is not enough. Two custom roles with the same parent are ranked only by definition order. So a role below admin must also hold every permission of the role it acts on. A `lead` defined before a `finance` role cannot hand out `finance`. Admins and the owner keep the rank rule alone.
|
|
70
|
+
- Where it is enforced: in the models. `Organization#send_invite_to!` (and `user.send_organization_invite_to!`), `Organization#change_role_of!`, `Membership#promote_to!`, `Membership#demote_to!`, `Organization#remove_member!`, `Invitation#revoke!` and `Invitation#resend!`. The view helpers `can_remove_member?` and `can_change_member_role?` follow the same rule.
|
|
71
|
+
- Who is checked: the member passed as `changed_by:`, `removed_by:`, `revoked_by:` or `resent_by:`, and the inviter. A nil actor, or one who is not a member of the organization, is only recorded for callbacks, as before.
|
|
72
|
+
- Not re-checked: `add_member!` and `approve_join_request!` take no actor. `Invitation#accept!` trusts the role the invitation was sent with. Direct `Membership` writes bypass every rule. Authorize those yourself.
|
|
73
|
+
- The owner keeps every power it had. Owner changes still go through `transfer_ownership_to!`.
|
|
74
|
+
- A refusal raises `Organizations::NotAuthorized` with a localized message (en and es). There is one message per action, and one for a role with permissions the actor lacks.
|
|
75
|
+
- `remove_member!` read the member's role before taking the lock. A member promoted to admin, or made owner, in that window could still be removed, and the owner case left the organization with no owner. The role is now read again under a row lock.
|
|
76
|
+
- The engine controllers answer a refused role, an attempt to change your own role, and a refused removal like a failed permission guard. They go through `config.unauthorized_handler` when one is set, with the reason in `context.metadata[:message]`. Otherwise they show the alert, or return a 403 over JSON. Before, an attempt to change your own role was an error page. Removing yourself or the owner now also returns 403 instead of 422.
|
|
77
|
+
- `InvitationsController#create` answered an `owner` invitation with an error page, because `CannotInviteAsOwner` was not rescued. It now re-renders the form with the alert and a 422.
|
|
78
|
+
- `current_membership` in controllers returned nil for the rest of a request that switched organization. `current_organization=`, and the fallback when the organization in the session is gone, assigned nil to the memoized membership. `current_membership` treated that nil as the answer. Host forms that read it on the first request of a session, such as an invitation form, rendered empty. The memoized value is now removed instead, so the next read finds the membership again.
|
|
79
|
+
- `current_organization` and `current_membership` memoized nil when they were read before a user was resolved. They now memoize nothing until there is a user.
|
|
80
|
+
- The generated views (`rails g organizations:views`) and the dummy reference views offered every role but owner in their role pickers, whatever the viewer's own role. The pickers now list only roles the viewer can assign. The members page shows the role picker only with `edit_member_roles` and the remove button only with `remove_members`. Members and invitations the viewer may not act on show no actions.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
|
|
84
|
+
- A custom role without `inherits:` sits below viewer. With the new rule, such a role with `invite_members` can invite only to its own role, so an invitation with the default `member` role now raises `NotAuthorized`. Give the role the parent it should invite to, for example `role :recruiter, inherits: :member do can :invite_members end`.
|
|
85
|
+
- `InvitationsController#resend` now calls `resend!(resent_by: current_user)`. A host that overrides `Invitation#resend!` without keyword arguments gets an `ArgumentError` from the controller. Accept `resent_by:` in the override.
|
|
86
|
+
- `InvitationsController#destroy` now calls `Invitation#revoke!` instead of `destroy!`.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- `Roles.can_assign?(actor_role, role)` and `Membership#can_assign_role?(role)` tell whether an actor may act on a role, for example to filter a role picker. `Roles.assignment_refusal(actor_role, role)` says why not (`:outranks` or `:lacks_permissions`).
|
|
91
|
+
- `Membership#authorize_role_change!(new_role, changed_by:)` raises `NotAuthorized` when the actor may not make that change.
|
|
92
|
+
- `Invitation#revoke!(revoked_by: nil)` deletes an invitation after the check. `Invitation#resend!` takes an optional `resent_by:`.
|
|
93
|
+
|
|
94
|
+
## [0.6.0] - 2026-10-18
|
|
95
|
+
|
|
96
|
+
**Custom role names.** Register a role outside owner > admin > member > viewer (a `client`, an `auditor`, a `guest`) in the existing `config.roles` block. No migration is needed. Installs without custom roles change nothing.
|
|
97
|
+
|
|
98
|
+
### Added
|
|
99
|
+
|
|
100
|
+
- **Custom roles.** `role :client, inherits: :viewer do can :answer_questions end` in `config.roles` adds a role with its own permissions plus its parent's. A custom role sits directly above its parent in `Roles.hierarchy`. A custom role without `inherits:` sits at the bottom. Two custom roles with the same parent stack in the order you define them.
|
|
101
|
+
- Custom roles work in `add_member!`, memberships, invitations, `default_invitation_role`, promotions, demotions, `at_least?`, `by_role_hierarchy` and every permission helper. The generated views and the dummy reference views list them in the role pickers.
|
|
102
|
+
- **Safety rules, checked when the configuration loads.** A custom role inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it ranks below admin. A custom role cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A built-in role cannot inherit a custom role that ranks above it. A role name is lowercase letters, digits and underscores, and a parent must be defined above its child.
|
|
103
|
+
- `Roles.hierarchy_for(definition)` returns the hierarchy of a roles definition.
|
|
104
|
+
|
|
105
|
+
### Changed
|
|
106
|
+
|
|
107
|
+
- `RoleBuilder` keeps roles outside the four built-in ones as custom roles. Before, it dropped them without an error. A `config.roles` block with such a role now registers it, or raises `ConfigurationError` when the role breaks a safety rule.
|
|
108
|
+
- A block that only adds custom roles keeps the default permissions of the built-in roles. A block that redefines a built-in role defines only the built-in roles it lists, as before. An empty block still defines no permissions.
|
|
109
|
+
- `Membership` and `Invitation` check the role against the configured roles only when the role is set or changed. A membership of a role that you remove from the configuration stays readable and updatable, and ranks below every configured role. Move such memberships with `org.change_role_of!(user, to: :member)`. A pending invitation for a removed role cannot be accepted.
|
|
110
|
+
- `by_role_hierarchy` follows the configured hierarchy, custom roles included, with unknown roles last.
|
|
111
|
+
- The hierarchy and the permissions are one immutable snapshot for each roles definition, and each comparison reads one snapshot. A reconfiguration, or a rolled-back one, can no longer leave a stale hierarchy in place.
|
|
112
|
+
|
|
1
113
|
## [0.5.0] - 2026-07-16
|
|
2
114
|
|
|
3
115
|
**Verified Joining** — users can now join organizations by proving they belong, closing three roadmap items (domain-based joining, request-to-join, roster/bulk import). Fully additive: existing installs change nothing until they enroll a domain, generate a code, or import a roster. Run `rails g organizations:upgrade` + `rails db:migrate` to get the new tables/columns.
|