Skip to content
Merged
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
14 changes: 12 additions & 2 deletions app/controllers/admin/oauth_applications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true

class Admin::OauthApplicationsController < Admin::BaseController
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_secret, :access_policy]
before_action :require_superadmin, only: [:access_policy]
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_secret, :access_policy, :code_of_conduct]
before_action :require_superadmin, only: [:access_policy, :code_of_conduct]

def index
@applications = Doorkeeper::Application.order(created_at: :desc)
Expand Down Expand Up @@ -88,6 +88,16 @@ def access_policy
notice: policy == "restricted" ? "Only users with an access grant can use this app now." : "Everyone can use this app now."
end

# Opts an app out of the code-of-conduct requirement, or back in (see
# AppAccess). Superadmin only: this decides who can sign in to the app.
def code_of_conduct
required = ActiveModel::Type::Boolean.new.cast(params.require(:required))
@application.update!(requires_code_of_conduct: required)
RevokeLostAppAccessJob.perform_later(application_id: @application.id) if required
redirect_to admin_oauth_application_path(@application),
notice: required ? "This app now requires the Code of Conduct." : "This app no longer requires the Code of Conduct."
end

private

def require_superadmin
Expand Down
15 changes: 13 additions & 2 deletions app/controllers/admin/users_controller.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# frozen_string_literal: true

class Admin::UsersController < Admin::BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy, :impersonate, :regen_pid, :invite_to_slack]
before_action :set_user, only: [:show, :edit, :update, :destroy, :impersonate, :regen_pid, :invite_to_slack, :code_of_conduct_exemption]
before_action :ensure_can_view_user, only: [:show]
before_action :ensure_can_manage_user, only: [:edit, :update, :destroy, :regen_pid]
before_action :ensure_can_impersonate, only: [:impersonate]
before_action :ensure_not_already_impersonating, only: [:impersonate]
before_action :require_superadmin, only: [:regen_pid]
before_action :require_superadmin, only: [:regen_pid, :code_of_conduct_exemption]
skip_before_action :authenticate_user!, only: [:stop_impersonating]
skip_before_action :require_admin, only: [:stop_impersonating]
helper_method :admin_permissions_for
Expand Down Expand Up @@ -133,6 +133,17 @@ def invite_to_slack
redirect_to admin_user_path(@user), notice: "#{verb} #{@user.email} to Slack (queued)."
end

# Lets one user use apps without accepting the code of conduct, or takes
# that away (see AppAccess). Superadmin only: this decides who can sign in to
# apps.
def code_of_conduct_exemption
exempt = ActiveModel::Type::Boolean.new.cast(params.require(:exempt))
@user.update!(code_of_conduct_exempt: exempt)
RevokeLostAppAccessJob.perform_later(user_id: @user.id) unless exempt
redirect_to admin_user_path(@user),
notice: exempt ? "#{@user.full_name} can use apps without accepting the Code of Conduct." : "#{@user.full_name} must accept the Code of Conduct to use apps."
end

private

def ensure_can_impersonate
Expand Down
13 changes: 11 additions & 2 deletions app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ module Oauth
# screen and the consent POST, so a previously approved app can't skip it.
# The user sees a Weave page instead of a redirect to the client with
# `error=access_denied`: the client can't grant access, and Weave can say who
# to ask.
# to ask. A user who only lacks the code of conduct is sent to accept it, and
# comes back here afterwards (see SlackOnboardingController).
class AuthorizationsController < Doorkeeper::AuthorizationsController
include OauthClientRedirectOrigins

Expand All @@ -45,13 +46,21 @@ def require_app_access
return unless pre_auth.authorizable?

application = pre_auth.client.application
return if AppAccess.permitted?(current_resource_owner, application)
decision = AppAccess.explain(current_resource_owner, application)
return if decision.permitted?

if decision.reason == :code_of_conduct && request.get?
session[:code_of_conduct_return_to] = request.fullpath
redirect_to slack_onboarding_path, alert: "Please accept the Code of Conduct to continue to #{application.name}."
return
end

# Ahoy drops requests it takes for bots, so the log line is the record
# that is always kept.
Rails.logger.info("OAuth access denied: user=#{current_resource_owner.p_id} application=#{application.uid}")
ahoy.track "OAuth access denied", application_uid: application.uid, user_id: current_resource_owner.id
@application = application
@decision = decision
render "doorkeeper/authorizations/access_denied", status: :forbidden
end

Expand Down
7 changes: 7 additions & 0 deletions app/controllers/slack_onboarding_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ def accept_code_of_conduct
return
end

# Sent here from an app's sign-in (see Oauth::AuthorizationsController).
return_to = session.delete(:code_of_conduct_return_to).to_s
if return_to.start_with?("/oauth/authorize")
redirect_to return_to
return
end

notice = if current_user.slack_member?
"Thanks for accepting the Code of Conduct."
else
Expand Down
7 changes: 5 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# id :bigint not null, primary key
# acknowledged_over_13_at :datetime
# birthday :date
# code_of_conduct_exempt :boolean default(FALSE), not null
# confirmation_sent_at :datetime
# confirmation_token :string
# email :string not null
Expand Down Expand Up @@ -190,8 +191,10 @@ def self.generate_secure_password

# Full Slack members who have not accepted the code of conduct. They joined
# before the code-of-conduct flow, so nothing has asked them yet (see
# CodeOfConductRequestJob).
scope :code_of_conduct_pending, -> { slack_member.where(slack_coc_accepted_at: nil).where.not(slack_id: nil) }
# CodeOfConductRequestJob). Exempt users are left out.
scope :code_of_conduct_pending, lambda {
slack_member.where(slack_coc_accepted_at: nil, code_of_conduct_exempt: false).where.not(slack_id: nil)
}

# Names the Slack import saves when a Slack profile has no name.
PLACEHOLDER_NAMES = %w[NOTSET Unknown User].freeze
Expand Down
1 change: 1 addition & 0 deletions app/serializers/user_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# id :bigint not null, primary key
# acknowledged_over_13_at :datetime
# birthday :date
# code_of_conduct_exempt :boolean default(FALSE), not null
# confirmation_sent_at :datetime
# confirmation_token :string
# email :string not null
Expand Down
11 changes: 11 additions & 0 deletions app/services/app_access.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
#
# There is no admin bypass: an admin needs a grant like anyone else.
#
# A user who has not accepted the code of conduct may use no app, open or
# restricted. An admin can opt out an app (requires_code_of_conduct) or a user
# (code_of_conduct_exempt).
#
# Every place that hands out or honors a user's token asks this module:
# the authorize endpoint, the token endpoint (code exchange and refresh),
# introspection, and userinfo. Checking at consent alone is not enough,
Expand All @@ -24,6 +28,7 @@ def to_s
when :group then "Member of #{groups.map(&:name).to_sentence}"
when :role then "Holds the #{roles.map(&:name).to_sentence} role"
when :no_user then "No user"
when :code_of_conduct then "Has not accepted the Code of Conduct"
else "No grant for this user or their groups"
end
end
Expand All @@ -33,6 +38,7 @@ class << self
def permitted?(user, application) = explain(user, application).permitted?

def explain(user, application)
return decision(false, :code_of_conduct) if code_of_conduct_missing?(user, application)
return decision(true, :open) unless restricted?(application)
return decision(false, :no_user) if user.nil?

Expand All @@ -50,6 +56,11 @@ def explain(user, application)

def restricted?(application) = application.access_policy != "everyone"

def code_of_conduct_missing?(user, application)
user.present? && application.requires_code_of_conduct && !user.code_of_conduct_exempt &&
user.slack_coc_accepted_at.nil?
end

# Groups linked to an app by an access grant or a role assignment. These
# are the only groups the app may see (the `groups` claim, the directory).
def linked_groups(application)
Expand Down
3 changes: 3 additions & 0 deletions app/views/admin/oauth_applications/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
<% if AppAccess.restricted?(application) %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orchid-soft text-orchid">Restricted</span>
<% end %>
<% unless application.requires_code_of_conduct %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-soft text-sky">CoC optional</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-ink">
<% if application.scopes.present? %>
Expand Down
21 changes: 21 additions & 0 deletions app/views/admin/oauth_applications/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@
<p class="text-sm text-ink-faint mb-4">No grants yet. Nobody can use this app.</p>
<% end %>

<div class="flex flex-wrap justify-between items-center gap-4 mb-4 py-3 border-y border-ink-soft/10">
<p class="text-sm text-ink-soft">
<% if @application.requires_code_of_conduct %>
Users must accept the Code of Conduct before they can use this app.
<% else %>
This app is opted out: users who have not accepted the Code of Conduct can use it.
<% end %>
</p>
<% if current_user.superadmin? %>
<% if @application.requires_code_of_conduct %>
<%= button_to "Opt out of the Code of Conduct", code_of_conduct_admin_oauth_application_path(@application),
method: :patch, params: { required: false }, class: "btn-stitch-ghost",
data: { turbo_confirm: "Let users who have not accepted the Code of Conduct use #{@application.name}?" } %>
<% else %>
<%= button_to "Require the Code of Conduct", code_of_conduct_admin_oauth_application_path(@application),
method: :patch, params: { required: true }, class: "btn-stitch-ghost",
data: { turbo_confirm: "Users who have not accepted the Code of Conduct will lose access to #{@application.name}. Continue?" } %>
<% end %>
<% end %>
</div>

<% if current_user.superadmin? %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<%= form_with scope: :access_grant, url: admin_oauth_application_access_grants_path(@application), local: true,
Expand Down
20 changes: 19 additions & 1 deletion app/views/admin/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@
<!-- App Access -->
<div class="patch p-6">
<h2 class="font-display text-lg font-bold text-ink mb-4">App Access</h2>
<div class="mb-4 pb-4 border-b border-ink-soft/10">
<p class="text-sm font-medium text-ink">Code of Conduct</p>
<p class="text-xs text-ink-faint mt-1">
<% if @user.slack_coc_accepted_at.present? %>
Accepted <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %>.
<% elsif @user.code_of_conduct_exempt %>
Not accepted. This user is exempt, so they can use apps anyway.
<% else %>
Not accepted. This user can only use apps that are opted out of the Code of Conduct.
<% end %>
</p>
<% if current_user.superadmin? && @user.slack_coc_accepted_at.nil? %>
<%= button_to (@user.code_of_conduct_exempt ? "Remove the exemption" : "Exempt from the Code of Conduct"),
code_of_conduct_exemption_admin_user_path(@user),
method: :patch, params: { exempt: !@user.code_of_conduct_exempt }, class: "btn-stitch-ghost mt-2",
data: { turbo_confirm: @user.code_of_conduct_exempt ? "#{@user.full_name} will lose access to apps until they accept. Continue?" : "Let #{@user.full_name} use apps without accepting the Code of Conduct?" } %>
<% end %>
</div>
<% restricted_apps = Doorkeeper::Application.where.not(access_policy: "everyone").order(:name) %>
<% if restricted_apps.any? %>
<ul class="space-y-2">
Expand All @@ -222,7 +240,7 @@
</li>
<% end %>
</ul>
<p class="mt-3 text-xs text-ink-faint">Apps not listed are open to everyone who can sign in.</p>
<p class="mt-3 text-xs text-ink-faint">Apps not listed are open to everyone who can sign in and meets the Code of Conduct requirement.</p>
<% else %>
<p class="text-sm text-ink-faint">Every app is open to everyone who can sign in.</p>
<% end %>
Expand Down
22 changes: 16 additions & 6 deletions app/views/doorkeeper/authorizations/access_denied.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,23 @@
</p>
</div>

<p class="text-xs text-ink-faint text-center">
Access to this app is limited to some people and groups. If you need it, ask a Weave admin to give you access.
</p>
<% if @decision&.reason == :code_of_conduct %>
<p class="text-xs text-ink-faint text-center">
Patchwork Labs apps are for members who have accepted the Code of Conduct.
</p>

<div class="text-center">
<%= link_to "Go to your Weave account", root_path, class: "btn-stitch-ghost" %>
</div>
<div class="text-center">
<%= link_to "Accept the Code of Conduct", slack_onboarding_path, class: "btn-stitch" %>
</div>
<% else %>
<p class="text-xs text-ink-faint text-center">
Access to this app is limited to some people and groups. If you need it, ask a Weave admin to give you access.
</p>

<div class="text-center">
<%= link_to "Go to your Weave account", root_path, class: "btn-stitch-ghost" %>
</div>
<% end %>
</div>
</div>
</div>
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
# impersonate_admin_user POST /admin/users/:id/impersonate(.:format) admin/users#impersonate
# regen_pid_admin_user PATCH /admin/users/:id/regen_pid(.:format) admin/users#regen_pid
# invite_to_slack_admin_user POST /admin/users/:id/invite_to_slack(.:format) admin/users#invite_to_slack
# code_of_conduct_exemption_admin_user PATCH /admin/users/:id/code_of_conduct_exemption(.:format) admin/users#code_of_conduct_exemption
# admin_users GET /admin/users(.:format) admin/users#index
# POST /admin/users(.:format) admin/users#create
# new_admin_user GET /admin/users/new(.:format) admin/users#new
Expand Down Expand Up @@ -130,6 +131,7 @@
# DELETE /admin/services/:id(.:format) admin/services#destroy
# regenerate_secret_admin_oauth_application PATCH /admin/oauth_applications/:id/regenerate_secret(.:format) admin/oauth_applications#regenerate_secret
# access_policy_admin_oauth_application PATCH /admin/oauth_applications/:id/access_policy(.:format) admin/oauth_applications#access_policy
# code_of_conduct_admin_oauth_application PATCH /admin/oauth_applications/:id/code_of_conduct(.:format) admin/oauth_applications#code_of_conduct
# admin_oauth_application_access_grants POST /admin/oauth_applications/:oauth_application_id/access_grants(.:format) admin/oauth_application_access_grants#create
# admin_oauth_application_access_grant DELETE /admin/oauth_applications/:oauth_application_id/access_grants/:id(.:format) admin/oauth_application_access_grants#destroy
# admin_oauth_application_role_assignments POST /admin/oauth_applications/:oauth_application_id/roles/:role_id/assignments(.:format) admin/oauth_application_role_assignments#create
Expand Down Expand Up @@ -442,6 +444,7 @@
post :impersonate
patch :regen_pid
post :invite_to_slack
patch :code_of_conduct_exemption
end
end

Expand Down Expand Up @@ -477,6 +480,7 @@
member do
patch :regenerate_secret
patch :access_policy
patch :code_of_conduct
end

resources :access_grants, controller: "oauth_application_access_grants", only: [:create, :destroy]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class AddRequiresCodeOfConductToOauthApplications < ActiveRecord::Migration[8.1]
def change
# Every app requires the code of conduct unless an admin opts it out (see
# AppAccess).
add_column :oauth_applications, :requires_code_of_conduct, :boolean, default: true, null: false
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class AddCodeOfConductExemptToUsers < ActiveRecord::Migration[8.1]
def change
# An admin can let one user use apps without accepting the code of conduct
# (see AppAccess). Exempt users are not asked to accept or demoted.
add_column :users, :code_of_conduct_exempt, :boolean, default: false, null: false
end
end
5 changes: 3 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# id :bigint not null, primary key
# acknowledged_over_13_at :datetime
# birthday :date
# code_of_conduct_exempt :boolean default(FALSE), not null
# confirmation_sent_at :datetime
# confirmation_token :string
# email :string not null
Expand Down Expand Up @@ -100,6 +101,12 @@
email_confirmed_at { nil }
end

# Every OAuth app requires the code of conduct unless it is opted out (see
# AppAccess).
trait :accepted_code_of_conduct do
slack_coc_accepted_at { 1.day.ago }
end

trait :with_magic_link do
transient do
magic_link_token { SecureRandom.urlsafe_base64(32) }
Expand Down
Loading
Loading