Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller
def create
block = Course::Assessment::Marketplace::AccessBlock.new(
user_id: params[:user_id], creator: current_user
)
if block.save
render json: { id: block.id, userId: block.user_id }, status: :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end

def destroy
block = Course::Assessment::Marketplace::AccessBlock.find(params[:id])
if block.destroy
head :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/system/admin/marketplace_access_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessController < System::Admin::Controller
def index
query = Course::Assessment::Marketplace::AccessListQuery.new
@rows = query.rows
@summary = query.summary
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller
# `preview` is a collection action with no id, so CanCan's default loader would try
# `find(params[:id])`. It builds its own unsaved rule; System::Admin::Controller's
# `authorize_admin` already gates the whole controller.
load_and_authorize_resource :allowlist_rule,
class: 'Course::Assessment::Marketplace::AllowlistRule',
parent: false, except: [:preview]

def index
# "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule`
# and show only the scoped rules in the table.
@everyone_rule = @allowlist_rules.rule_type_everyone.first
@allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance)
end

def create
if @allowlist_rule.save
# `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors
# System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`).
render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok
else
render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request
end
end

def preview
rule = Course::Assessment::Marketplace::AllowlistRule.new(allowlist_rule_params)
unless rule.valid?
render json: { errors: rule.errors.full_messages.to_sentence }, status: :bad_request
return
end

query = Course::Assessment::Marketplace::RulePreviewQuery.new(rule)
@rows = query.rows
@summary = query.summary
end

def destroy
if @allowlist_rule.destroy
head :ok
else
render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request
end
end

private

def allowlist_rule_params
params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain, :email)
end
end
13 changes: 13 additions & 0 deletions app/helpers/system/admin/marketplace_access_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module System::Admin::MarketplaceAccessHelper
# The value half of a rule's label ("Email domain · <value>"), for the audit list's reason column.
# @param [Course::Assessment::Marketplace::AllowlistRule] rule
# @return [String, nil]
def marketplace_rule_label_value(rule)
case rule.rule_type
when 'user' then rule.user&.name
when 'instance' then rule.instance&.name
when 'email_domain' then rule.email_domain
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,46 @@ module Course::AssessmentMarketplaceAbilityComponent

def define_permissions
allow_admins_publish_to_marketplace if user&.administrator?
allow_managers_access_marketplace if course_user&.manager_or_owner?
# System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not
# emit a `cannot` for them or it would revoke that. For everyone else, access is per-person.
if course && !user&.administrator?
if can_access_marketplace?
allow_managers_access_marketplace
else
# `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`,
# which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace`
# regardless of the allow-list. This component runs after that one in the `define_permissions`
# super chain, so a `cannot` here takes precedence. This line is load-bearing.
cannot :access_marketplace, Course, id: course.id
end
end
super
end

private

# Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns
# >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list
# may browse, whatever their role in the course they are viewing.
def can_access_marketplace?
marketplace_baseline_capable? && marketplace_visible_to_user?
end

# The two peer baseline capabilities for the marketplace. Either qualifies; the allow-list narrows.
def marketplace_baseline_capable?
user&.course_manager_or_owner? || user&.instance_instructor_or_administrator?
end

# Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`).
# When the allow-list is retired this whole method is deleted; the block check goes with it.
def marketplace_visible_to_user?
return true if user&.administrator?

Course::Assessment::Marketplace::AllowlistRule.grants_access?(user) &&
!Course::Assessment::Marketplace::AccessBlock.blocked?(user)
end


def allow_admins_publish_to_marketplace
can :publish_to_marketplace, Course::Assessment
end
Expand All @@ -23,4 +57,4 @@ def allow_managers_access_marketplace
assessment.marketplace_listing&.published? || false
end
end
end
end
23 changes: 23 additions & 0 deletions app/models/course/assessment/marketplace/access_block.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::AccessBlock < ApplicationRecord
belongs_to :user, inverse_of: false
belongs_to :creator, class_name: 'User', inverse_of: false

# Paired with the DB unique index on user_id: a user is blocked at most once.
validates :user_id, uniqueness: true

# Whether +user+ has been individually disabled from the marketplace. Global (not tenant-scoped),
# mirroring AllowlistRule.
# @param [User] user
# @return [Boolean]
def self.blocked?(user)
return false unless user

where(user_id: user.id).exists?
end

# @return [Array<Integer>] user ids of every block (for per-page status annotation).
def self.blocked_user_ids
pluck(:user_id)
end
end
133 changes: 133 additions & 0 deletions app/models/course/assessment/marketplace/access_list_query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true
# Computes the marketplace access audit list: every user who is baseline-capable (manages/owns >=1
# course, OR is an instructor/administrator in any instance) AND cleared by the allow-list, PLUS
# every individually blocked user regardless of rule match — an orphaned block must stay visible and
# clearable. Blocked users are INCLUDED and flagged. Not paginated server-side - the eligible set is
# bounded (managers + instance staff) and the frontend paginates/searches client-side, matching the
# rules page which also fetches its whole list at once.
class Course::Assessment::Marketplace::AccessListQuery
AllowlistRule = Course::Assessment::Marketplace::AllowlistRule
AccessBlock = Course::Assessment::Marketplace::AccessBlock
RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery

# `allowed_by_rules` holds EVERY rule matching the user, not one precedence winner: the admin uses
# it to answer "if I delete this rule, who loses access?", and one reason answers that wrongly.
Row = Struct.new(:user, :course_count, :instance_role, :allowed_by_rules, :block_id,
:system_admin, keyword_init: true) do
def blocked?
block_id.present?
end

def system_admin?
system_admin.present?
end
end

# @return [Array<Row>]
def rows
@rows ||= annotate(listed_users.to_a)
end

# @return [Hash]
def summary
{
total_with_access: rows.count { |row| !row.blocked? },
total_blocked: rows.count(&:blocked?),
open_to_everyone: everyone?
}
end

# Baseline-eligible users the allow-list currently clears. A block does not remove someone from
# this set — being blocked is a separate decision layered on top of being allowed.
# @return [Set<Integer>]
def allowed_user_ids
# System admins hold a blanket `can :manage, :all`, so they have the marketplace whatever the
# rules say. This table is the audit source of truth, so they are always in it — omitting them
# would show an admin as having no access while they in fact bypass every gate.
@allowed_user_ids ||= (everyone? ? baseline_ids.to_set : rules_by_user.keys.to_set) | admin_ids
end

private

# Batches every per-user annotation into one query each, keyed by user id.
def annotate(users)
ids = users.map(&:id)
counts = managed_course_counts(ids)
staff = instance_staff_roles(ids)
block_ids = block_ids_by_user(ids)

users.map do |user|
Row.new(user: user, course_count: counts[user.id] || 0,
instance_role: staff[user.id], allowed_by_rules: rules_by_user[user.id] || [],
block_id: block_ids[user.id], system_admin: admin_ids.include?(user.id))
end
end

def managed_course_counts(ids)
CourseUser.managers.where(user_id: ids).group(:user_id).count
end

def block_ids_by_user(ids)
AccessBlock.where(user_id: ids).pluck(:user_id, :id).to_h
end

def blocked_ids
@blocked_ids ||= AccessBlock.pluck(:user_id).to_set
end

def listed_users
User.where(id: (allowed_user_ids | blocked_ids).to_a).includes(:emails).order(:name)
end

def admin_ids
@admin_ids ||= User.administrator.pluck(:id).to_set
end

def baseline_ids
@baseline_ids ||= baseline_scope.pluck(:id)
end

# CourseUser is not tenant-scoped ("any course"); InstanceUser IS, so .unscoped for "any instance".
def baseline_scope
User.where(id: CourseUser.managers.select(:user_id)).
or(User.where(id: instance_staff_scope.select(:user_id))).
or(User.administrator)
end

def instance_staff_scope
InstanceUser.unscoped.where(role: [:instructor, :administrator])
end

def everyone?
return @everyone if defined?(@everyone)

@everyone = AllowlistRule.rule_type_everyone.exists?
end

# An `everyone` rule is a page-level mode, not a per-row reason, so it contributes no scoped rules.
def scoped_rules
@scoped_rules ||= if everyone?
[]
else
# `user`/`instance` are read when labelling each row's reasons; preload them
# once here rather than once per rule per row.
AllowlistRule.where.not(rule_type: :everyone).
includes(:user, :instance).order(:id).to_a
end
end

# user id => [AllowlistRule], every rule matching that user, in rules-table order.
def rules_by_user
@rules_by_user ||= scoped_rules.each_with_object({}) do |rule, map|
RuleMatchQuery.new(rule).user_ids_within(baseline_ids).each do |id|
(map[id] ||= []) << rule
end
end
end

def instance_staff_roles(ids)
InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]).
group(:user_id).maximum(:role).
transform_values { |role| InstanceUser.roles.key(role) }
end
end
77 changes: 77 additions & 0 deletions app/models/course/assessment/marketplace/allowlist_rule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord
enum :rule_type,
{ user: 0, instance: 1, email_domain: 2, everyone: 3 },
prefix: true

belongs_to :user, class_name: 'User', inverse_of: false, optional: true
belongs_to :instance, inverse_of: false, optional: true

# Transient: the admin form identifies a `user` rule by email (user IDs are not shown anywhere
# in the admin panel). Resolved to the owning user before validation; the stored row keeps the
# `user_id` FK, so the rule means "this person" even if they later change email.
attr_accessor :email

before_validation :resolve_user_from_email, if: -> { rule_type_user? && email.present? }

# When an email was supplied, `resolve_user_from_email` reports its own failure; skip the generic
# presence check in that path so the message is exactly "No user with that email." (not a pair).
before_validation :normalize_email_domain, if: :rule_type_email_domain?

validates :user, presence: true, if: -> { rule_type_user? && email.blank? }
validates :instance, presence: true, if: :rule_type_instance?
validates :email_domain, presence: true, if: :rule_type_email_domain?

# Identical rules grant nothing extra and make the rules table unreadable. Each is paired with a
# partial unique index. `scope: :rule_type` matters on the unresolved-email path: a user rule
# whose email matched nobody keeps user_id NULL, and Rails checks that as `user_id IS NULL`,
# which matches every instance and email-domain rule — reporting a bogus duplicate on top of the
# real "No user with that email." Scoping confines the check to rules of the same type.
validates :user_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' },
if: :rule_type_user?
validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' },
if: :rule_type_instance?
validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has a rule.' },
if: :rule_type_email_domain?
# "Everyone" is the widest rule; only one may exist. Paired with a partial unique index.
validates :rule_type, uniqueness: true, if: :rule_type_everyone?

# Whether the marketplace is visible to +user+ per the allow-list. The rules table itself is
# global (not tenant-scoped), but `user.instance_users` IS tenant-scoped (acts_as_tenant), so
# in a request an `instance` rule matches only while browsing the allow-listed instance —
# it grants that instance's users access *there*, not membership-based access everywhere.
# An `everyone` rule grants every authenticated user (the `nil` guard still excludes anonymous).
# Baseline (manager/owner OR instructor/admin) is checked separately in the ability component.
# @param [User] user
# @return [Boolean]
def self.grants_access?(user)
return false unless user

rule_type_everyone.exists? ||
rule_type_user.where(user_id: user.id).exists? ||
rule_type_instance.where(instance_id: user.instance_users.select(:instance_id)).exists? ||
email_domain_matches?(user)
end

# @param [User] user
# @return [Boolean]
def self.email_domain_matches?(user)
domains = user.emails.pluck(:email).filter_map { |e| e.split('@').last&.downcase }.uniq
return false if domains.empty?

rule_type_email_domain.where('LOWER(email_domain) IN (?)', domains).exists?
end

private

# Matching is case-insensitive everywhere, so the stored form is normalized on write. This keeps
# the uniqueness index a plain column comparison instead of a functional LOWER() index.
def normalize_email_domain
self.email_domain = email_domain&.strip&.downcase
end

def resolve_user_from_email
self.user = User.with_email_addresses([email.strip.downcase]).first
errors.add(:base, 'No user with that email.') if user.nil?
end
end
Loading