canvas-lms/app/controllers/feature_flags_controller.rb

319 lines
13 KiB
Ruby

#
# Copyright (C) 2013 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# @API Feature Flags
#
# Manage optional features in Canvas
#
# @model Feature
# {
# "id": "Feature",
# "description": "",
# "properties": {
# "name": {
# "description": "The symbolic name of the feature, used in FeatureFlags",
# "example": "fancy_wickets",
# "type": "string"
# },
# "display_name": {
# "description": "The user-visible name of the feature",
# "example": "Fancy Wickets",
# "type": "string"
# },
# "applies_to": {
# "description": "The type of object the feature applies to (RootAccount, Account, Course, or User):\n * RootAccount features may only be controlled by flags on root accounts.\n * Account features may be controlled by flags on accounts and their parent accounts.\n * Course features may be controlled by flags on courses and their parent accounts.\n * User features may be controlled by flags on users and site admin only.",
# "example": "Course",
# "type": "string",
# "allowableValues": {
# "values": [
# "Course",
# "RootAccount",
# "Account",
# "User"
# ]
# }
# },
# "enable_at": {
# "description": "The date this feature will be globally enabled, or null if this is not planned. (This information is subject to change.)",
# "example": "2014-01-01T00:00:00Z",
# "type": "datetime"
# },
# "feature_flag": {
# "description": "The FeatureFlag that applies to the caller",
# "example": "\{\"feature\"=>\"fancy_wickets\", \"state\"=>\"allowed\", \"locking_account_id\"=>nil\}",
# "$ref": "FeatureFlag"
# },
# "root_opt_in": {
# "description": "If true, a feature that is 'allowed' globally will be 'off' by default in root accounts. Otherwise, root accounts inherit the global 'allowed' setting, which allows sub-accounts and courses to turn features on with no root account action.",
# "example": true,
# "type": "boolean"
# },
# "beta": {
# "description": "Whether the feature is a beta feature. If true, the feature may not be fully polished and may be subject to change in the future.",
# "example": true,
# "type": "boolean"
# },
# "development": {
# "description": "Whether the feature is in active development. Features in this state are only visible in test and beta instances and are not yet available for production use.",
# "example": false,
# "type": "boolean"
# },
# "release_notes_url": {
# "description": "A URL to the release notes describing the feature",
# "example": "http://canvas.example.com/release_notes#fancy_wickets",
# "type": "string"
# }
# }
# }
# @model FeatureFlag
# {
# "id": "FeatureFlag",
# "description": "",
# "properties": {
# "context_type": {
# "description": "The type of object to which this flag applies (Account, Course, or User). (This field is not present if this FeatureFlag represents the global Canvas default)",
# "example": "Account",
# "type": "string",
# "allowableValues": {
# "values": [
# "Course",
# "Account",
# "User"
# ]
# }
# },
# "context_id": {
# "description": "The id of the object to which this flag applies (This field is not present if this FeatureFlag represents the global Canvas default)",
# "example": 1038,
# "type": "integer"
# },
# "feature": {
# "description": "The feature this flag controls",
# "example": "fancy_wickets",
# "type": "string"
# },
# "state": {
# "description": "The policy for the feature at this context. can be 'off', 'allowed', or 'on'.",
# "example": "allowed",
# "type": "string",
# "allowableValues": {
# "values": [
# "off",
# "allowed",
# "on"
# ]
# }
# },
# "locked": {
# "description": "If set, this feature flag cannot be changed in the caller's context because the flag is set 'off' or 'on' in a higher context, or the flag is locked by an account the caller does not have permission to administer",
# "type": "boolean",
# "example": false
# },
# "locking_account_id": {
# "description": "If set, this FeatureFlag can only be modified by someone with administrative rights in the specified account",
# "type": "integer"
# }
# }
# }
#
class FeatureFlagsController < ApplicationController
include Api::V1::FeatureFlag
before_filter :get_context
# @API List features
#
# List all features that apply to a given Account, Course, or User.
#
# @example_request
#
# curl 'http://<canvas>/api/v1/courses/1/features' \
# -H "Authorization: Bearer "
#
# @returns [Feature]
def index
if authorized_action(@context, @current_user, :read)
route = polymorphic_url([:api_v1, @context, :features])
features = Feature.applicable_features(@context)
features = Api.paginate(features, self, route)
flags = features.map { |fd|
@context.lookup_feature_flag(fd.feature, Account.site_admin.grants_right?(@current_user, session, :read))
}.compact
render json: flags.map { |flag| feature_with_flag_json(flag, @context, @current_user, session) }
end
end
# @API List enabled features
#
# List all features that are enabled on a given Account, Course, or User.
# Only the feature names are returned.
#
# @example_request
#
# curl 'http://<canvas>/api/v1/courses/1/features/enabled' \
# -H "Authorization: Bearer "
#
# @example_response
#
# ["fancy_wickets", "automatic_essay_grading", "telepathic_navigation"]
def enabled_features
if authorized_action(@context, @current_user, :read)
features = Feature.applicable_features(@context).map { |fd| @context.lookup_feature_flag(fd.feature) }.compact.
select { |ff| ff.enabled? }.map(&:feature)
render json: features
end
end
# @API Get feature flag
#
# Get the feature flag that applies to a given Account, Course, or User.
# The flag may be defined on the object, or it may be inherited from a parent
# account. You can look at the context_id and context_type of the returned object
# to determine which is the case. If these fields are missing, then the object
# is the global Canvas default.
#
# @example_request
#
# curl 'http://<canvas>/api/v1/courses/1/features/flags/fancy_wickets' \
# -H "Authorization: Bearer "
#
# @returns FeatureFlag
def show
if authorized_action(@context, @current_user, :read)
return render json: { message: "missing feature parameter" }, status: :bad_request unless params[:feature].present?
flag = @context.lookup_feature_flag(params[:feature], Account.site_admin.grants_right?(@current_user, session, :read))
raise ActiveRecord::RecordNotFound unless flag
render json: feature_flag_json(flag, @context, @current_user, session)
end
end
# @API Set feature flag
#
# Set a feature flag for a given Account, Course, or User. This call will fail if a parent account sets
# a feature flag for the same feature in any state other than "allowed".
#
# @argument state [String, "off"|"allowed"|"on"]
# "off":: The feature is not available for the course, user, or account and sub-accounts.
# "allowed":: (valid only on accounts) The feature is off in the account, but may be enabled in
# sub-accounts and courses by setting a feature flag on the sub-account or course.
# "on":: The feature is turned on unconditionally for the user, course, or account and sub-accounts.
#
# @argument locking_account_id [Integer]
# If set, this FeatureFlag may only be modified by someone with administrative rights
# in the specified account. The locking account must be above the target object in the
# account chain.
#
# @example_request
#
# curl -X PUT 'http://<canvas>/api/v1/courses/1/features/flags/fancy_wickets' \
# -H "Authorization: Bearer " \
# -F "state=on"
#
# @returns FeatureFlag
def update
if authorized_action(@context, @current_user, :manage_feature_flags)
return render json: { message: "must specify feature" }, status: :bad_request unless params[:feature].present?
feature_def = Feature.definitions[params[:feature]]
return render json: { message: "invalid feature" }, status: :bad_request unless feature_def
# check whether the feature is locked
MultiCache.delete(@context.feature_flag_cache_key(params[:feature]))
current_flag = @context.lookup_feature_flag(params[:feature])
if current_flag
return render json: { message: "higher account disallows setting feature flag" }, status: :forbidden if current_flag.locked?(@context, @current_user)
prior_state = current_flag.state
end
# if this is a hidden feature, require site admin privileges to create (but not update) a root account flag
if !current_flag && feature_def.hidden?
return render json: { message: "invalid feature" }, status: :bad_request unless ((@context.is_a?(Account) && @context.root_account?) || @context.is_a?(User)) && Account.site_admin.grants_right?(@current_user, session, :read)
prior_state = 'hidden'
end
new_attrs = { feature: params[:feature] }
# check transition
if params[:state].present?
transitions = Feature.transitions(params[:feature], @current_user, @context, prior_state)
if transitions[params[:state]] && transitions[params[:state]]['locked']
return render json: { message: "state change not allowed" }, status: :forbidden
end
new_attrs[:state] = params[:state]
end
# check locking account
if params.has_key?(:locking_account_id)
unless params[:locking_account_id].blank?
locking_account = api_find(Account, params[:locking_account_id])
return render json: { message: "locking account not found" }, status: :bad_request unless locking_account
return render json: { message: "locking account access denied" }, status: :forbidden unless locking_account.grants_right?(@current_user, session, :manage_feature_flags)
end
new_attrs[:locking_account] = locking_account
end
new_flag, saved = create_or_update_feature_flag(new_attrs, current_flag)
if saved
if prior_state != new_flag.state && feature_def.after_state_change_proc.is_a?(Proc)
feature_def.after_state_change_proc.call(@context, prior_state, new_flag.state)
end
render json: feature_flag_json(new_flag, @context, @current_user, session)
else
render json: new_flag.errors.to_json, status: :bad_request
end
end
end
# @API Remove feature flag
#
# Remove feature flag for a given Account, Course, or User. (Note that the flag must
# be defined on the Account, Course, or User directly.) The object will then inherit
# the feature flags from a higher account, if any exist. If this flag was 'on' or 'off',
# then lower-level account flags that were masked by this one will apply again.
#
# @example_request
#
# curl -X DELETE 'http://<canvas>/api/v1/courses/1/features/flags/fancy_wickets' \
# -H "Authorization: Bearer "
#
# @returns FeatureFlag
def delete
if authorized_action(@context, @current_user, :manage_feature_flags)
return render json: { message: "must specify feature" }, status: :bad_request unless params[:feature].present?
flag = @context.feature_flags.where(feature: params[:feature]).first!
return render json: { message: "flag is locked" }, status: :forbidden if flag.locked?(@context, @current_user)
flag.destroy
render json: feature_flag_json(flag, @context, @current_user, session)
end
end
private
def create_or_update_feature_flag(attributes, current_flag = nil)
FeatureFlag.unique_constraint_retry do
new_flag = @context.feature_flags.find(current_flag) if current_flag &&
!current_flag.default? && !current_flag.new_record? &&
current_flag.context_type == @context.class.name && current_flag.context_id == @context.id
new_flag ||= @context.feature_flags.build
new_flag.assign_attributes(attributes)
result = new_flag.save
[new_flag, result]
end
end
end