Skip to content
7 changes: 5 additions & 2 deletions app/avo/resources/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def filters
filter TrustedPublisherFilter, arguments: { default: { trusted_publisher: true, not_trusted_publisher: true } }
end

def fields
def fields # rubocop:disable Metrics/MethodLength
main_panel do
field :id, as: :id, hide_on: :index

Expand All @@ -30,6 +30,7 @@ def fields
field :last_accessed_at, as: :date_time
field :soft_deleted_at, as: :date_time
field :soft_deleted_rubygem_name, as: :text
field :soft_deleted_organization_name, as: :text
field :expires_at, as: :date_time

field :enabled_scopes, as: :tags
Expand All @@ -49,7 +50,9 @@ def fields
end

field :api_key_rubygem_scope, as: :has_one
field :ownership, as: :has_one
field :ownership, as: :has_one, visible: -> { resource.record.ownership.present? }
field :api_key_organization_scope, as: :has_one
field :membership, as: :has_one, visible: -> { resource.record.membership.present? }
field :oidc_id_token, as: :has_one
end
end
13 changes: 13 additions & 0 deletions app/avo/resources/api_key_organization_scope.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class Avo::Resources::ApiKeyOrganizationScope < Avo::BaseResource
self.title = :cache_key
self.includes = []

def fields
field :id, as: :id

field :api_key, as: :belongs_to
field :membership, as: :belongs_to
end
end
1 change: 1 addition & 0 deletions app/avo/resources/oidc_api_key_role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def fields
field :valid_for, as: :text, format_using: -> { value&.iso8601 }
field :scopes, as: :tags, suggestions: ApiKey::API_SCOPES.map { { label: it, value: it } }, enforce_suggestions: true
field :gems, as: :tags, suggestions: -> { Rubygem.limit(10).pluck(:name).map { { value: it, label: it } } }
field :organization, as: :text
end
field :access_policy, as: :nested do
field :statements, as: :array_of, field: :nested do
Expand Down
1 change: 1 addition & 0 deletions app/avo/resources/oidc_pending_trusted_publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def fields

field :rubygem_name, as: :text
field :user, as: :belongs_to
field :organization, as: :belongs_to
field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher
field :expires_at, as: :date_time
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def find_rubygem_by_name
end

def verify_api_key_gem_scope
return unless @api_key.rubygem && @api_key.rubygem != @rubygem
return if @api_key.scoped_to?(@rubygem)

render_forbidden t(:api_key_insufficient_scope)
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/api_keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def key_param
end

def api_key_create_params
ApiKeysHelper.api_key_params(params.permit(:name, *ApiKey::API_SCOPES, :mfa, :rubygem_name, :expires_at, scopes: [ApiKey::API_SCOPES]))
ApiKeysHelper.api_key_params(params.permit(:name, *ApiKey::API_SCOPES, :mfa, :rubygem_name, :organization, :expires_at, scopes: [ApiKey::API_SCOPES]))
end

def api_key_update_params(key)
Expand Down
11 changes: 10 additions & 1 deletion app/controllers/api/v1/oidc/api_key_roles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def assume_role
api_key = @api_key_role.user.api_keys.create!(
hashed_key: hashed_key(key),
name: "#{@api_key_role.name}-#{@jwt[:jti]}",
**@api_key_role.api_key_permissions.create_params(@api_key_role.user)
**api_key_create_params
)
OIDC::IdToken.create!(
api_key:,
Expand All @@ -56,12 +56,21 @@ def assume_role
name: api_key.name,
scopes: api_key.scopes,
gem: api_key.rubygem,
organization: api_key.organization&.handle,
expires_at: api_key.expires_at
}.compact, status: :created
rescue ActiveRecord::RecordNotFound
render json: {
errors: ["Unable to create API key: the configured gem or organization scope is no longer valid"]
}, status: :unprocessable_content
end

private

def api_key_create_params
@api_key_role.api_key_permissions.create_params(@api_key_role.user)
end

def set_api_key_role
@api_key_role = OIDC::ApiKeyRole.active.find_by!(token: params.expect(:token))
@provider = @api_key_role.provider
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/api_keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def index
api_keys = current_user.api_keys.not_oidc
@has_expired_keys = api_keys.expired.exists?
scope = @expired_view ? api_keys.expired.order(expires_at: :desc, id: :desc) : api_keys.unexpired
@api_keys = scope.preload(ownership: :rubygem).page(@page)
@api_keys = scope.preload(ownership: :rubygem, membership: :organization).page(@page)
redirect_to new_profile_api_key_path if !@expired_view && @api_keys.empty? && !@has_expired_keys
end

Expand Down Expand Up @@ -45,7 +45,7 @@ def create

if @api_key.errors.present?
flash.now[:error] = @api_key.errors.full_messages.to_sentence
@api_key = current_user.api_keys.build(api_key_create_params.merge(rubygem_id: nil))
@api_key = current_user.api_keys.build(api_key_create_params.merge(rubygem_id: nil, organization_id: nil))
return render :new
end

Expand Down Expand Up @@ -115,12 +115,12 @@ def verify_session_redirect_path
end

def api_key_create_params
ApiKeysHelper.api_key_params(params.expect(api_key: [:name, *ApiKey::API_SCOPES, :mfa, :rubygem_id, :expires_at]))
ApiKeysHelper.api_key_params(params.expect(api_key: [:name, *ApiKey::API_SCOPES, :mfa, :rubygem_id, :organization_id, :expires_at]))
end

def api_key_update_params(existing_api_key = nil)
ApiKeysHelper.api_key_params(
params.expect(api_key: [*ApiKey::API_SCOPES, :mfa, :rubygem_id, scopes: ApiKey::API_SCOPES]), existing_api_key
params.expect(api_key: [*ApiKey::API_SCOPES, :mfa, :rubygem_id, :organization_id, scopes: ApiKey::API_SCOPES]), existing_api_key
)
end
end
6 changes: 6 additions & 0 deletions app/controllers/avo/api_key_organization_scopes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::ApiKeyOrganizationScopesController < Avo::ResourcesController
end
2 changes: 1 addition & 1 deletion app/controllers/oidc/api_key_roles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def redirect_for_deleted
PERMITTED_API_KEY_ROLE_PARAMS = [
:name,
:oidc_provider_id,
api_key_permissions: [:valid_for, scopes: [], gems: []],
api_key_permissions: [:valid_for, :organization, scopes: [], gems: []],
access_policy: {
statements_attributes: [[
:effect,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class OIDC::PendingTrustedPublishersController < ApplicationController

def index
trusted_publishers = policy_scope(OIDC::PendingTrustedPublisher)
.unexpired.includes(:trusted_publisher)
.unexpired.includes(:trusted_publisher, :organization)
.order(:rubygem_name, :created_at).page(@page).strict_loading
add_breadcrumb(t("breadcrumbs.settings"), edit_settings_path)
add_breadcrumb(t(".title"))
Expand Down Expand Up @@ -59,6 +59,7 @@ def create_params
params.expect(
create_params_key => [
:rubygem_name,
:organization_id,
:trusted_publisher_type,
trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes
]
Expand Down
19 changes: 18 additions & 1 deletion app/helpers/api_keys_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
module ApiKeysHelper
def gem_scope(api_key)
return invalid_gem_tooltip(api_key.soft_deleted_rubygem_name) if api_key.soft_deleted_by_ownership?
return invalid_organization_tooltip(api_key.soft_deleted_organization_name) if api_key.soft_deleted_by_organization?

api_key.rubygem ? api_key.rubygem.name : t("api_keys.all_gems")
if api_key.membership
api_key.organization&.name || t("api_keys.unavailable_organization")
elsif api_key.rubygem
api_key.rubygem.name
else
t("api_keys.all_gems")
end
end

def api_key_checkbox(form, api_scope)
Expand Down Expand Up @@ -34,6 +41,7 @@ def self.api_key_params(params, existing_api_key = nil)
end
end
params[:scopes] = scopes.sort
params[:organization_handle] = params.delete(:organization) if params.key?(:organization)
params
end

Expand All @@ -45,4 +53,13 @@ def invalid_gem_tooltip(name)
render(TooltipComponent.new(text: t("api_keys.gem_ownership_removed", rubygem_name: name))) { "[?]" }
], " ")
end

def invalid_organization_tooltip(name)
content_tag(
:span,
"#{name} [?]",
class: "cursor-help",
title: t("api_keys.organization_membership_removed", organization_name: name)
)
end
end
77 changes: 56 additions & 21 deletions app/javascript/controllers/gem_scope_controller.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,79 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
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

this.toggleSelector();
this.hiddenFields = {};
this.toggleSelectors();
}

checkboxTargetConnected(el) {
el.addEventListener("change", () => this.toggleSelector());
el.addEventListener("change", () => this.toggleSelectors());
}

toggleSelector() {
gemSelectorTargetConnected(el) {
el.addEventListener("change", () => {
if (el.value && this.hasOrgSelectorTarget) {
this.orgSelectorTarget.value = "";
}
});
}

orgSelectorTargetConnected(el) {
el.addEventListener("change", () => {
if (el.value && this.hasGemSelectorTarget) {
this.gemSelectorTarget.value = "";
}
});
}

toggleSelectors() {
const selected = this.checkboxTargets.find((target) => target.checked);

if (selected) {
this.selectorTarget.disabled = false;
this.removeHiddenRubygemField();
if (this.hasGemSelectorTarget) {
this.toggleSelector(
"api_key[rubygem_id]",
this.gemSelectorTarget,
selected,
);
}
if (this.hasOrgSelectorTarget) {
this.toggleSelector(
"api_key[organization_id]",
this.orgSelectorTarget,
selected,
);
}
}

toggleSelector(fieldName, selectorTarget, enabled) {
if (enabled) {
selectorTarget.disabled = false;
this.removeHiddenField(fieldName);
} else {
this.selectorTarget.value = "";
this.selectorTarget.disabled = true;
this.addHiddenRubygemField();
selectorTarget.value = "";
selectorTarget.disabled = true;
this.addHiddenField(fieldName);
}
}

addHiddenRubygemField() {
if (this.hiddenField) {
addHiddenField(fieldName) {
if (this.hiddenFields[fieldName]) {
return;
}
this.hiddenField = document.createElement("input");
this.hiddenField.type = "hidden";
this.hiddenField.name = "api_key[rubygem_id]";
this.hiddenField.value = "";
this.element.appendChild(this.hiddenField);

this.hiddenFields[fieldName] = document.createElement("input");
this.hiddenFields[fieldName].type = "hidden";
this.hiddenFields[fieldName].name = fieldName;
this.hiddenFields[fieldName].value = "";
this.element.appendChild(this.hiddenFields[fieldName]);
}

removeHiddenRubygemField() {
if (this.hiddenField) {
this.hiddenField.remove();
this.hiddenField = null;
removeHiddenField(fieldName) {
if (this.hiddenFields[fieldName]) {
this.hiddenFields[fieldName].remove();
this.hiddenFields[fieldName] = null;
}
}
}
Loading