Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/models/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class AuditEvent < ApplicationRecord
user_project_pins_updated: 43,
guest_user_created: 44, # Cloud-specific
guest_profile_completed: 45, # Cloud-specific
user_custom_attribute_updated: 46, # Cloud-specific
}.with_indifferent_access

# rubocop:disable-next Lint/StructNewOverride
Expand Down
2 changes: 2 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ class UserPolicy < BasePolicy

rule { deletion_restriction }.prevent :delete_user
end

UserPolicy.prepend_extensions
1 change: 1 addition & 0 deletions app/services/error_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def self.error_codes
invalid_setting: { description: 'Invalid setting provided' },
invalid_user: { description: 'The user is invalid because of active model errors' },
invalid_user_namespace_pin: { description: 'The user namespace pin is invalid because of active model errors' },
invalid_user_custom_attribute: { description: 'The user custom attribute is invalid because of active model errors' },
invalid_user_project_pin: { description: 'The user project pin is invalid because of active model errors' },
invalid_password_repeat: { description: 'The provided password repeat does not match the password' },
cannot_modify_admin: { description: 'Only administrators can modify admin status of users' },
Expand Down
1 change: 1 addition & 0 deletions docs/graphql/enum/errorcodeenum.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Represents the available error responses
| `INVALID_SETTING` | Invalid setting provided |
| `INVALID_TOTP_SECRET` | The TOTP secret is invalid or cannot be verified |
| `INVALID_USER` | The user is invalid because of active model errors |
| `INVALID_USER_CUSTOM_ATTRIBUTE` | The user custom attribute is invalid because of active model errors |
| `INVALID_USER_IDENTITY` | The user identity is invalid because of active model errors |
| `INVALID_USER_NAMESPACE_PIN` | The user namespace pin is invalid because of active model errors |
| `INVALID_USER_PROJECT_PIN` | The user project pin is invalid because of active model errors |
Expand Down
21 changes: 21 additions & 0 deletions docs/graphql/mutation/userssetactivesubscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: usersSetActiveSubscription
---

(Cloud only) Set or unset the active subscription status for a user. Used by Crater.

## Arguments

| Name | Type | Description |
|------|------|-------------|
| `active` | [`Boolean!`](../scalar/boolean.md) | Whether the user has an active subscription. |
| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. |
| `userId` | [`UserID!`](../scalar/userid.md) | ID of the user to update. |

## Fields

| Name | Type | Description |
|------|------|-------------|
| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. |
| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. |
| `user` | [`User`](../object/user.md) | The updated user. |
1 change: 1 addition & 0 deletions extensions/cloud/app/graphql/cloud/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module MutationType
mount_mutation Mutations::Users::CompleteGuestProfile
mount_mutation Mutations::Users::CreateCraterToken
mount_mutation Mutations::Users::CreateGuestUser
mount_mutation Mutations::Users::SetActiveSubscription
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module Mutations
module Users
class SetActiveSubscription < BaseMutation
description '(Cloud only) Set or unset the active subscription status for a user. Used by Crater.'

field :user, Types::UserType, null: true, description: 'The updated user.'

argument :active, Boolean, required: true, description: 'Whether the user has an active subscription.'
argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.'

def resolve(user_id:, active:)
user = SagittariusSchema.object_from_id(user_id)

return { user: nil, errors: [create_error(:user_not_found, 'Invalid user with provided id')] } if user.nil?

::Users::SetActiveSubscriptionService.new(
current_authentication,
user,
active: active
).execute.to_mutation_response(success_key: :user)
end
end
end
end
1 change: 1 addition & 0 deletions extensions/cloud/app/policies/cloud/base_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module BasePolicy
except :create_license
except :delete_license
except :create_guest_user
except :update_user_custom_attribute
end

# Guests never get a session today (they only exist until they complete their profile,
Expand Down
15 changes: 15 additions & 0 deletions extensions/cloud/app/policies/cloud/user_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module CLOUD
module UserPolicy
extend ActiveSupport::Concern

prepended do
condition(:crater_user) { user&.crater? }

rule { crater_user }.policy do
enable :update_user_custom_attribute
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

module Users
class SetActiveSubscriptionService
include Sagittarius::Database::Transactional

ACTIVE_SUBSCRIPTION_KEY = 'active_subscription'

attr_reader :current_authentication, :user, :active

def initialize(current_authentication, user, active:)
@current_authentication = current_authentication
@user = user
@active = active
end

def execute
unless Ability.allowed?(current_authentication, :update_user_custom_attribute, user)
return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission)
end

active ? set_active_subscription : unset_active_subscription
end

private

def set_active_subscription
transactional do |t|
attribute = user.user_custom_attributes.find_or_initialize_by(key: ACTIVE_SUBSCRIPTION_KEY)
attribute.value = true

unless attribute.save
t.rollback_and_return! ServiceResponse.error(
message: 'Failed to set active subscription',
error_code: :invalid_user_custom_attribute,
details: attribute.errors
)
end

audit(active: true)

ServiceResponse.success(message: 'Set active subscription', payload: user)
end
end

def unset_active_subscription
transactional do |_t|
user.user_custom_attributes.where(key: ACTIVE_SUBSCRIPTION_KEY).destroy_all

audit(active: false)

ServiceResponse.success(message: 'Unset active subscription', payload: user)
end
end

def audit(active:)
AuditService.audit(
:user_custom_attribute_updated,
author_id: current_authentication.user.id,
entity: user,
target: AuditEvent::GLOBAL_TARGET,
details: { key: ACTIVE_SUBSCRIPTION_KEY, active: active }
)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Mutations::Users::SetActiveSubscription do
it { expect(described_class.graphql_name).to eq('UsersSetActiveSubscription') }
end
35 changes: 35 additions & 0 deletions extensions/cloud/spec/policies/cloud/user_policy_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe UserPolicy do
it { expect(described_class).to include_module(CLOUD::UserPolicy) }

describe 'crater authentication' do
subject(:policy) { described_class.new(authentication, target_user) }

let(:crater_user) { create(:user, :crater) }
let(:target_user) { create(:user) }

let(:authentication) do
Sagittarius::Authentication.new(
:crater,
CLOUD::ApplicationController::CraterToken.new(user: crater_user)
)
end

it { is_expected.to be_allowed(:update_user_custom_attribute) }
it { is_expected.not_to be_allowed(:read_user) }
it { is_expected.not_to be_allowed(:update_user) }
it { is_expected.not_to be_allowed(:delete_user) }
end

describe 'session authentication' do
subject(:policy) { described_class.new(create_authentication(current_user), target_user) }

let(:current_user) { create(:user) }
let(:target_user) { create(:user) }

it { is_expected.not_to be_allowed(:update_user_custom_attribute) }
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Users::SetActiveSubscriptionService do
subject(:service_response) { described_class.new(authentication, user, active: active).execute }

let(:user) { create(:user) }

context 'when the current authentication is not crater' do
let(:authentication) { create_authentication(create(:user)) }
let(:active) { true }

it { is_expected.to be_error }
it { expect(service_response.payload[:error_code]).to eq(:missing_permission) }

it 'does not create a custom attribute' do
expect { service_response }.not_to change { user.user_custom_attributes.count }
end

it { expect { service_response }.not_to create_audit_event }
end

context 'when the current authentication is crater' do
let(:crater_user) { create(:user, :crater) }
let(:authentication) do
Sagittarius::Authentication.new(:crater, CLOUD::ApplicationController::CraterToken.new(user: crater_user))
end

context 'when setting active to true' do
let(:active) { true }

it { is_expected.to be_success }

it 'creates the active_subscription custom attribute' do
service_response

expect(user.user_custom_attributes.find_by(key: 'active_subscription').value).to be(true)
end

it do
is_expected.to create_audit_event(
:user_custom_attribute_updated,
author_id: crater_user.id,
entity_id: user.id,
entity_type: 'User',
details: { key: 'active_subscription', active: true }
)
end

context 'when the attribute already exists' do
before { create(:user_custom_attribute, user: user, key: 'active_subscription', value: 'pending') }

it 'does not create a duplicate' do
expect { service_response }.not_to change { user.user_custom_attributes.count }
end

it 'updates the value' do
service_response

expect(user.user_custom_attributes.find_by(key: 'active_subscription').value).to be(true)
end
end
end

context 'when setting active to false' do
let(:active) { false }

context 'when the attribute exists' do
before { create(:user_custom_attribute, user: user, key: 'active_subscription', value: true) }

it { is_expected.to be_success }

it 'removes the custom attribute' do
expect { service_response }.to change { user.user_custom_attributes.count }.by(-1)
end

it do
is_expected.to create_audit_event(
:user_custom_attribute_updated,
author_id: crater_user.id,
entity_id: user.id,
entity_type: 'User',
details: { key: 'active_subscription', active: false }
)
end
end

context 'when the attribute does not exist' do
it { is_expected.to be_success }

it 'does not change the custom attribute count' do
expect { service_response }.not_to change { user.user_custom_attributes.count }
end
end
end
end
end