Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow admins to manage all resources #1511

Merged
merged 4 commits into from
Apr 1, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
108 changes: 86 additions & 22 deletions app/models/api/token_ability.rb
Original file line number Diff line number Diff line change
@@ -1,42 +1,106 @@
# frozen_string_literal: true

module Api
# Describes the abilities of someone accessing the API with an access a token.
# Describes the abilities of someone accessing the API with an access token.
# Admins can read, write, and delete all scenarios, provided they have the correct scope in the token.
# Users can read public scenarios and scenarios where they are viewers.
# Users with write scope can create, update, and clone scenarios where they are collaborators.
# Users with delete scope can delete scenarios where they are owners.
class TokenAbility
include CanCan::Ability

def initialize(token, user)
can :read, Scenario, private: false
scopes = token[:scopes]
@scopes = token[:scopes]
@user = user

allow_public_read
allow_read if read_scope?
allow_write if write_scope?
allow_delete if delete_scope?
end

# scenarios:read
# --------------
return unless scopes.include?('scenarios:read')
can :read, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::ROLES.key(:scenario_viewer)..).pluck(:scenario_id)
# scenarios:write
# ---------------
private

# Methods to allow access to scenarios based on the role.
# Everyone can read public scenarios.
def allow_public_read
can :read, Scenario, private: false
end

return unless scopes.include?('scenarios:write')
def allow_read
if admin?
can :read, Scenario
else
can :read, Scenario, id: viewer_scenario_ids
end
end

def allow_write
can :create, Scenario

# Unowned public scenario.
can :update, Scenario, private: false
cannot :update, Scenario, private: false, id: ScenarioUser.pluck(:scenario_id)
if admin?
# Admins with write scope can update and clone all scenarios.
can :update, Scenario
can :clone, Scenario
else
# Non-admins
# Allow updating unowned public scenarios except when any association exists.
can :update, Scenario, private: false
cannot :update, Scenario, private: false, id: ScenarioUser.pluck(:scenario_id)
# Allow updating scenarios where the user is a collaborator.
can :update, Scenario, id: collaborator_scenario_ids

# Self-owned scenario.
can :update, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::ROLES.key(:scenario_collaborator)..).pluck(:scenario_id)
# Allow cloning both unowned public scenarios and self-owned scenarios.
can :clone, Scenario, private: false
can :clone, Scenario, id: collaborator_scenario_ids
end
end

# Actions that involve reading one scenario and writing to another.
can :clone, Scenario, private: false
can :clone, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::ROLES.key(:scenario_collaborator)..).pluck(:scenario_id)
def allow_delete
if admin?
can :destroy, Scenario
else
can :destroy, Scenario, id: owner_scenario_ids
end
end

# scenarios:delete
# ----------------
# Methods to get the scenario ids for the user based on the role.
def viewer_scenario_ids
ScenarioUser.where(
user_id: @user.id,
role_id: User::ROLES.key(:scenario_viewer)..
).pluck(:scenario_id)
end

return unless scopes.include?('scenarios:delete')
def collaborator_scenario_ids
ScenarioUser.where(
user_id: @user.id,
role_id: User::ROLES.key(:scenario_collaborator)..
).pluck(:scenario_id)
end

def owner_scenario_ids
ScenarioUser.where(
user_id: @user.id,
role_id: User::ROLES.key(:scenario_owner)
).pluck(:scenario_id)
end

# Methods to check the scopes of the token.
def read_scope?
@scopes.include?('scenarios:read')
end

def write_scope?
@scopes.include?('scenarios:write')
end

def delete_scope?
@scopes.include?('scenarios:delete')
end

can :destroy, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::ROLES.key(:scenario_owner)).pluck(:scenario_id)
def admin?
@user&.admin?
end
end
end
7 changes: 6 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class User < ApplicationRecord

attr_accessor :identity_user

delegate :roles, :admin?, to: :identity_user, allow_nil: true
delegate :roles, to: :identity_user, allow_nil: true

has_many :scenario_users, dependent: :destroy
has_many :scenarios, through: :scenario_users
Expand Down Expand Up @@ -42,6 +42,11 @@ def couple_scenario_users
end
end

# Override admin? to fall back to the attribute when identity_user is nil.
def admin?
identity_user&.admin? || admin
end

# Performs sign-in steps for an Identity::User.
#
# If a matching user exists in the database, it will be updated with the latest data from the
Expand Down
78 changes: 76 additions & 2 deletions spec/models/api/token_ability_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
]
}
end
let(:scopes) { 'scenarios:read' }
let(:scopes) { '' }

let(:mock_decoded_token) do
{
Expand Down Expand Up @@ -58,7 +58,7 @@

# ------------------------------------------------------------------------------------------------

shared_examples_for "a token without the 'scenarios:read' scope" do
shared_examples_for 'a token without the "scenarios:read" scope' do
it 'may view an unowned public scenario' do
expect(ability).to be_able_to(:read, public_scenario)
end
Expand All @@ -74,6 +74,14 @@
it 'may not view an owned private scenario' do
expect(ability).not_to be_able_to(:read, other_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may not view an owned private scenario' do
expect(ability).not_to be_able_to(:read, other_private_scenario)
end
end
end

shared_examples_for 'a token with the "scenarios:read" scope' do
Expand All @@ -92,6 +100,14 @@
it 'may view an owned private scenario' do
expect(ability).to be_able_to(:read, owned_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may view an owned private scenario' do
expect(ability).to be_able_to(:read, other_private_scenario)
end
end
end

shared_examples_for 'a token with the "scenarios:write" scope' do
Expand Down Expand Up @@ -138,6 +154,22 @@
it 'may not clone an other-owned private scenario' do
expect(ability).not_to be_able_to(:clone, other_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may change an other-owned public scenario' do
expect(ability).to be_able_to(:update, other_public_scenario)
end

it 'may change an other-owned private scenario' do
expect(ability).to be_able_to(:update, other_private_scenario)
end

it 'may clone an other-owned private scenario' do
expect(ability).to be_able_to(:clone, other_private_scenario)
end
end
end

shared_examples_for 'a token without the "scenarios:write" scope' do
Expand Down Expand Up @@ -176,6 +208,22 @@
it 'may not clone an owned private scenario' do
expect(ability).not_to be_able_to(:clone, owned_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may not change an other-owned public scenario' do
expect(ability).not_to be_able_to(:update, other_public_scenario)
end

it 'may not change an other-owned private scenario' do
expect(ability).not_to be_able_to(:update, other_private_scenario)
end

it 'may not clone an other-owned private scenario' do
expect(ability).not_to be_able_to(:clone, other_private_scenario)
end
end
end

shared_examples_for 'a token with the "scenarios:delete" scope' do
Expand All @@ -198,6 +246,18 @@
it 'may not delete an other-owned private scenario' do
expect(ability).not_to be_able_to(:destroy, other_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may delete an other-owned public scenario' do
expect(ability).to be_able_to(:destroy, other_public_scenario)
end

it 'may delete an other-owned private scenario' do
expect(ability).to be_able_to(:destroy, other_private_scenario)
end
end
end

shared_examples_for 'a token without the "scenarios:delete" scope' do
Expand All @@ -220,11 +280,25 @@
it 'may not delete an other-owned private scenario' do
expect(ability).not_to be_able_to(:destroy, other_private_scenario)
end

context 'when admin' do
let(:user) { create(:user, admin: true, roles:) }

it 'may not delete an other-owned public scenario' do
expect(ability).not_to be_able_to(:destroy, other_public_scenario)
end

it 'may not delete an other-owned private scenario' do
expect(ability).not_to be_able_to(:destroy, other_private_scenario)
end
end
end

# ------------------------------------------------------------------------------------------------

context 'when the token scope is "public"' do
# Read
include_examples 'a token without the "scenarios:read" scope'

# Update
include_examples 'a token without the "scenarios:write" scope'
Expand Down