Skip to content

Push new gems to organizations - #6762

Draft
anton-bondaronok wants to merge 8 commits into
masterfrom
ab/push-new-gems-to-organizations
Draft

Push new gems to organizations#6762
anton-bondaronok wants to merge 8 commits into
masterfrom
ab/push-new-gems-to-organizations

Conversation

@anton-bondaronok

Copy link
Copy Markdown

Description

Closes #6328

Right now pushing a new gem always creates personal ownership for the pusher. Getting it into an organization means transferring/onboarding afterward.

This adds an optional organization scope on API keys (and the OIDC / trusted-publishing equivalents) so a first push can assign the gem to the org directly. Scope goes through the user's admin/owner membership, so it goes away when they lose that role.

Covers all three publish paths: classic gem push, OIDC assume_role, and pending trusted publishers.

Approach

  • New api_key_organization_scopes table linking an API key to a Membership (admin/owner only). Mutually exclusive with gem scope.
  • On push of an unowned gem with an org-scoped key: set rubygem.organization_id instead of creating personal ownership.
  • Same idea for OIDC roles (api_key_permissions.organization) and pending trusted publishers (organization_id).
  • Yank/owner endpoints honor the org scope via ApiKey#scoped_to?.
  • Demote below admin or remove membership and the key is soft-deleted (same pattern as gem-scoped keys when ownership is removed).
  • Create keys from the profile UI (new org selector) or POST /api/v1/api_keys with organization=<handle>.

How to test

1. Profile UI key + gem push

  1. Sign in as an org admin. Go to Settings / API keys / New.
  2. Enable Push rubygem, pick the org under Organization Scope. Create the key and copy it.
  3. Build and push a gem that doesn't exist yet:
    gem build your.gemspec
    GEM_HOST_API_KEY=<key> gem push your-gem-0.1.0.gem
  4. Open the gem page / console: Rubygem.find_by!(name: "your-gem").organization should be the org, and ownerships should be empty. You should still be able to manage it as an org member.
  5. Push another version of the same gem; it should succeed.
  6. Try pushing someone else's gem (or a gem in a different org) with the same key; expect 403.

Also worth checking:

  • Maintainer cannot create an org-scoped key for that org.
  • After demoting the admin to maintainer (or removing them), the key shows as invalid in the API keys list and push with it fails.

2. HTTP API key creation

curl -u "$USER:$PASSWORD" -X POST https://rubygems.org/api/v1/api_keys \
  -d "name=org-push" -d "push_rubygem=true" -d "organization=your-org-handle"

Same push checks as above. Bad handle / org you don't admin should 422.

3. OIDC assume_role

  1. Create an OIDC API key role, set Organization Scope to the org (no gem), scopes include push_rubygem.
  2. assume_role with a valid JWT; response should include "organization": "<handle>".
  3. Push a new gem with the short-lived key; gem ends up under the org.
  4. Demote the role owner below admin and assume_role again; expect 422 (not a confusing 404).

4. Trusted publishing

  1. Settings / Pending Trusted Publishers / New, fill in the gem name + GitHub Action config, select the org.
  2. Index page should show the org next to the pending publisher.
  3. Push the first version via that trusted publisher; gem owned by the org, trusted publisher attached, no personal ownership.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.82%. Comparing base (5eaddc9) to head (611b6f5).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6762      +/-   ##
==========================================
+ Coverage   97.79%   97.82%   +0.03%     
==========================================
  Files         533      537       +4     
  Lines       11584    11730     +146     
==========================================
+ Hits        11328    11475     +147     
+ Misses        256      255       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@anton-bondaronok
anton-bondaronok force-pushed the ab/push-new-gems-to-organizations branch 2 times, most recently from 7520d3b to 7ae8903 Compare August 12, 2026 14:47
@anton-bondaronok anton-bondaronok self-assigned this Aug 12, 2026
Why this change is being made:
To let a gem push be attributed to an organization, an API key needs to
carry an organization scope. Scoping through Membership (rather than
Organization directly) ties the grant to the user's continued membership,
so it can be revoked automatically when the membership changes or ends.

What were the changes made to support this:
- Migration creating api_key_organization_scopes (unique FK to api_keys,
  FK to memberships) and adding api_keys.soft_deleted_organization_name
- ApiKeyOrganizationScope model (uniqueness validation, delegate
  :organization)
- has_one :api_key_organization_scope/:membership/:organization on
  ApiKey, has_many :api_key_organization_scopes on Membership
- Factory and model tests
Why this change is being made:
Organization-scoped keys must only be created by members who can manage
the organization (admin or owner, matching OrganizationPolicy#add_gem?),
must not be combined with a gem scope, and must only be usable for gems
that belong to that organization or for claiming new gem names.

What were the changes made to support this:
- ApiKey#organization_id= / #organization_handle= setters resolving the
  membership via user.memberships.confirmed.with_minimum_role(:admin)
- Validations: gem and organization scopes are mutually exclusive;
  organization scope requires an applicable scope (push/yank/owner); the
  organization must be manageable by the key's user
- ApiKey#scoped_to?(rubygem) / #organization_allows_gem?, wired into
  #scope?
- User#manageable_organizations
- Api::BaseController#verify_api_key_gem_scope now uses scoped_to?, so
  yank and owner endpoints enforce the organization scope too
- organization attribute recorded on the API_KEY_CREATED user event
- Model tests plus deletions/owners controller tests for the enforcement
Why this change is being made:
This is the customer request itself: pushing a brand-new gem with an
organization-scoped key should place the gem under the organization
instead of the pusher's personal account.

What were the changes made to support this:
- New verify_organization_scope step in Pusher#process rejecting pushes
  to gems outside the key's organization
- Ownership assignment for unowned gems extracted into
  assign_ownership_for_unowned_gem; with an organization-scoped key it
  checks OrganizationPolicy#add_gem? and sets rubygem.organization_id
  instead of creating a personal Ownership
- Pusher unit tests and api/v1 rubygems controller tests: a new gem lands
  under the org; pushing another user's or another org's gem returns 403
Why this change is being made:
Organization admins need a way to mint organization-scoped keys; the
new-API-key form currently only offers a gem scope.

What were the changes made to support this:
- Organization selector in the API key form listing
  User#manageable_organizations
- gem_scope_controller.js extended so the gem and organization selectors
  are mutually exclusive
- ApiKeysController permits organization_id
- ApiKeysHelper#gem_scope displays the organization scope in the keys list
- Locale strings in en.yml propagated with bin/fill-locales
- System test for creating an org-scoped key
Why this change is being made:
API keys are also created programmatically (gem signin and CI tooling
use POST /api/v1/api_keys), so the organization scope must be available
there as well.

What were the changes made to support this:
- Api::V1::ApiKeysController permits an organization (handle) param
  mapped through ApiKey#organization_handle=
- Functional tests covering success, unknown handle, and insufficient
  membership role
Why this change is being made:
When a member is demoted below admin or removed from the organization,
their organization-scoped keys must stop working immediately; otherwise
they would retain publishing rights they no longer have.

What were the changes made to support this:
- Membership after_update hook soft-deletes org-scoped keys when the
  role drops below admin
- ApiKeyOrganizationScope before_destroy (when destroyed through the
  membership association) soft-deletes the key, recording
  soft_deleted_organization_name
- ApiKey#soft_delete! accepts a membership; #soft_deleted_by_organization?
  plus a tooltip in the API keys list explain why the key was invalidated
- Tests for demotion, member removal, and membership destruction
Why this change is being made:
CI pipelines that push via OIDC assume_role receive short-lived user keys
built from OIDC::ApiKeyPermissions; without an organization option those
pushes can only create gems under the personal account.

What were the changes made to support this:
- organization (handle) attribute on OIDC::ApiKeyPermissions, mutually
  exclusive with gems
- OIDC::ApiKeyRole validates the role owner can manage that organization
  (mirrors gems_belong_to_user)
- OIDC::ApiKeyPermissions#create_params resolves the organization scope
  for the minted key
- Organization field in the role form
- Tests: permissions/role models plus an assume_role integration test
  proving the minted key is scoped to the organization
Why this change is being made:
Trusted publishing of a brand-new gem reifies a pending publisher into a
personal ownership; organization members need those gems to land under
the organization so keyless CI publishing matches the other push paths.

What were the changes made to support this:
- Migration adding a nullable organization_id FK to
  oidc_pending_trusted_publishers
- Optional belongs_to :organization with a validation that the creating
  user can manage the organization
- Organization selector in the pending trusted publisher form
- Pusher reification sets rubygem.organization_id (still creating the
  RubygemTrustedPublisher) instead of a personal ownership when the
  pending publisher targets an organization
- Model, pusher, and controller tests for the new path
@anton-bondaronok
anton-bondaronok force-pushed the ab/push-new-gems-to-organizations branch from 7ae8903 to 611b6f5 Compare August 17, 2026 12:48
Comment thread app/models/api_key.rb
update_attribute(:soft_deleted_at, Time.now.utc)
update_attribute(:soft_deleted_rubygem_name, ownership.rubygem.name) if ownership
def soft_delete!(ownership: nil, membership: nil)
update_columns(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a subtle difference that I don't think matters in this case but update_columns skips the validations and callbacks and update_attribute only skips the validations not the callbacks. I think you can add touch: true to this to also change the updated_at


def update
@api_key = current_user.api_keys.find(params.expect(:id))
@api_key.assign_attributes(api_key_update_params(@api_key))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to guard here against someone passing in both the rubygem_id and the organization_id at the same time.

When you do something like

# From a test case
should "not persist either scope when a request sends a gem and an organization together" do
  @user = create(:user)
  @organization = create(:organization, owners: [@user])
  @ownership = create(:ownership, user: @user, rubygem: create(:rubygem))
  @api_key = create(:api_key, owner: @user, scopes: %i[push_rubygem], rubygem: @ownership.rubygem)
  
  verified_sign_in_as(@user)
  
  patch :update, params: {
    id: @api_key.id,
    api_key: { push_rubygem: true, rubygem_id: @ownership.rubygem.id, organization_id: @organization.id }
  }

  @api_key.reload


  refute @api_key.rubygem.present? && @api_key.organization.present?
end

the assign_attributes automatically creates the has_one ... through: for both the organization and the rubygem before the validations run but doesn't clean it up when the save fails later. After that the api_key gets stuck in an invalid state and can only be deleted.

Comment thread app/models/membership.rb
belongs_to :user
belongs_to :organization

has_many :api_key_organization_scopes, dependent: :destroy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a weird quirk here. The Organizations only get soft_deleted (the deleted_at gets set and the default scope ignores it). It's possible for an Organization to be soft_deleted without triggering the dependent: :destroy.

We will want to add a mechanism to whatever does the organization deletion to make sure we are also invalidating the api_keys related to it.

It looks like when you push a gem with an org scoped api key for an org that has been soft_deleted, it falls back to the User owning the gem

static targets = ["checkbox", "selector"];
static targets = ["checkbox", "gemSelector", "orgSelector"];

connect() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be initialize() instead of connect? I think initialize is called first and only once and connect is called every time the controller is added to the DOM

Comment thread app/models/pusher.rb
rubygem.create_ownership(pending_publisher.user)
owner.rubygem_trusted_publishers.create!(rubygem: rubygem)
end
assign_ownership_for_unowned_gem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the rubygem is unowned we check that the owner can add it to the organization in #assign_organization_ownership below, but if that fails, we return notify_unauthorized. At that point we will have already created the version record and rubygem in the #update_attributes_from_gem_specification!. Does it make sense to add the ownership check earlier or rollback the transaction for the version/rubygem updates?

I'm not really sure how that would happen unless the key owner was an owner or admin and was downgraded to maintainer and we skipped the callbacks. But now that I think about it I'm wondering if you think maintainers should also be able to gem push? I don't think we have anything existing in orgs for setting permissions for what levels can do what

div do
t(".api_key_gem_html", gem: link_to(additional.gem, rubygem_path(additional.gem)))
end
raw t(".api_key_gem_html", gem: view_context.link_to(additional.gem, rubygem_path(additional.gem))) if additional.gem.present?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need to use the #raw method?


form_with(model: pending_trusted_publisher, url: profile_oidc_pending_trusted_publishers_path) do |form|
rubygem_name_field(form)
organization_field(form)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we hid the org field if you don't belong to any orgs?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make clear when an organization publishes a gem

2 participants