add auditors trail for feature flags
closes FOO-2029 flag=none Intended to make it easy to track down who changed a feature flag at any level with minimal write load (skips writing logs for user-context flags). TEST PLAN: 1) change state of a flag 2) check the auditors ff table, record should have been added with before and after state. Change-Id: I23c1c9e0608e292d5e10f057adb33ed3b57b52d0 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/266129 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com> QA-Review: Ethan Vizitei <evizitei@instructure.com> Product-Review: Ethan Vizitei <evizitei@instructure.com>
This commit is contained in:
parent
d65ac23815
commit
bb6fdba9f3
|
@ -324,6 +324,7 @@ class FeatureFlagsController < ApplicationController
|
|||
flag = @context.feature_flags.find_by!(feature: params[:feature])
|
||||
prior_state = flag.state
|
||||
return render json: { message: "flag is locked" }, status: :forbidden if flag.locked?(@context)
|
||||
flag.current_user = @current_user # necessary step for audit log
|
||||
if flag.destroy
|
||||
feature_def = Feature.definitions[params[:feature]]
|
||||
feature_def.after_state_change_proc&.call(@current_user, @context, prior_state, feature_def.state)
|
||||
|
@ -341,6 +342,7 @@ class FeatureFlagsController < ApplicationController
|
|||
current_flag.context_type == @context.class.name && current_flag.context_id == @context.id
|
||||
new_flag ||= @context.feature_flags.build
|
||||
new_flag.assign_attributes(attributes)
|
||||
new_flag.current_user = @current_user # necessary step for audit log
|
||||
result = new_flag.save
|
||||
[new_flag, result]
|
||||
end
|
||||
|
|
|
@ -106,6 +106,11 @@ class Account < ActiveRecord::Base
|
|||
class_name: 'Auditors::ActiveRecord::GradeChangeRecord',
|
||||
dependent: :destroy,
|
||||
inverse_of: :root_account
|
||||
has_many :auditor_feature_flag_records,
|
||||
foreign_key: 'root_account_id',
|
||||
class_name: 'Auditors::ActiveRecord::FeatureFlagRecord',
|
||||
dependent: :destroy,
|
||||
inverse_of: :root_account
|
||||
has_many :lti_resource_links,
|
||||
as: :context,
|
||||
inverse_of: :context,
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
#
|
||||
module Auditors::ActiveRecord
|
||||
class FeatureFlagRecord < ActiveRecord::Base
|
||||
include Auditors::ActiveRecord::Attributes
|
||||
include CanvasPartman::Concerns::Partitioned
|
||||
self.partitioning_strategy = :by_date
|
||||
self.partitioning_interval = :months
|
||||
self.partitioning_field = 'created_at'
|
||||
self.table_name = 'auditor_feature_flag_records'
|
||||
|
||||
belongs_to :account, inverse_of: :auditor_grade_change_records
|
||||
belongs_to :root_account,
|
||||
class_name: 'Account',
|
||||
inverse_of: :auditor_feature_flag_records,
|
||||
foreign_key: 'root_account_id'
|
||||
|
||||
class << self
|
||||
include Auditors::ActiveRecord::Model
|
||||
|
||||
def ar_attributes_from_event_stream(record)
|
||||
record.attributes.except('id').tap do |attrs_hash|
|
||||
attrs_hash['request_id'] ||= "MISSING"
|
||||
attrs_hash['uuid'] = record.id
|
||||
# could be nil in the rare case of an unprovisioned console user.
|
||||
# 0 is therefore the signal that there was no inferrable user at
|
||||
# the time of the feature flag flip.
|
||||
attrs_hash['user_id'] = Shard.relative_id_for(record.user_id, Shard.current, Shard.current) || 0
|
||||
attrs_hash['feature_flag_id'] = Shard.relative_id_for(record.feature_flag_id, Shard.current, Shard.current)
|
||||
attrs_hash['context_id'] = Shard.relative_id_for(record.context_id, Shard.current, Shard.current)
|
||||
attrs_hash['root_account_id'] = Shard.relative_id_for(record.root_account_id, Shard.current, Shard.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -21,7 +21,12 @@ module Auditors::ActiveRecord
|
|||
class Partitioner
|
||||
cattr_accessor :logger
|
||||
|
||||
AUDITOR_CLASSES = [ AuthenticationRecord, CourseRecord, GradeChangeRecord ].freeze
|
||||
AUDITOR_CLASSES = [
|
||||
AuthenticationRecord,
|
||||
CourseRecord,
|
||||
GradeChangeRecord,
|
||||
FeatureFlagRecord
|
||||
].freeze
|
||||
|
||||
def self.precreate_tables
|
||||
Setting.get('auditors_precreate_tables', 2).to_i
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
#
|
||||
|
||||
class Auditors::FeatureFlag
|
||||
class Record < Auditors::Record
|
||||
attributes :feature_flag_id,
|
||||
:user_id,
|
||||
:root_account_id,
|
||||
:state_before,
|
||||
:state_after,
|
||||
:context_id,
|
||||
:context_type,
|
||||
:feature_name
|
||||
|
||||
# usually we can infer the "after the change" state
|
||||
# from the feature flag object itself. Sometimes that doesn't
|
||||
# make sense, though, like when "deleting" a feature flag. In that
|
||||
# case the "after" state is going to be something explicit like "removed",
|
||||
# but there would be no reason to put that on the object-that's-being-deleted.
|
||||
# the "post_state" argument here lets the caller specify an explicit state
|
||||
# if they need to.
|
||||
def self.generate(feature_flag, user, previous_state, post_state=nil)
|
||||
new(
|
||||
'feature_flag' => feature_flag,
|
||||
'user' => user,
|
||||
'state_before' => previous_state,
|
||||
'state_after' => post_state || feature_flag.state
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(*args)
|
||||
super(*args)
|
||||
|
||||
if attributes['feature_flag']
|
||||
self.feature_flag = attributes.delete('feature_flag')
|
||||
end
|
||||
|
||||
if attributes.key?('user')
|
||||
# might be nil in a rare circumstance with an unprovisioned console user
|
||||
self.user = attributes.delete('user')
|
||||
end
|
||||
end
|
||||
|
||||
def feature_flag
|
||||
@feature_flag ||= FeatureFlag.find(feature_flag_id)
|
||||
end
|
||||
|
||||
# this method is used to infer all the "event stream"
|
||||
# attributes for this stream from the model. Later, if
|
||||
# the write destination is postgres, we can transform
|
||||
# some of these into the necessary attributes for storing
|
||||
# in the shard db itself (especially things like global vs local ids).
|
||||
# That's taken care of in the active_record/feature_flag_record.rb file.
|
||||
def feature_flag=(feature_flag)
|
||||
@feature_flag = feature_flag
|
||||
attributes['feature_flag_id'] = @feature_flag.global_id
|
||||
attributes['context_id'] = Shard.global_id_for(@feature_flag.context_id)
|
||||
attributes['context_type'] = @feature_flag.context_type
|
||||
attributes['feature_name'] = @feature_flag.feature
|
||||
# this should be safe because we don't care about auditing feature
|
||||
# flags that are for specific users.
|
||||
attributes['root_account_id'] = Shard.global_id_for(@feature_flag.context.root_account_id)
|
||||
if attributes['root_account_id'].nil? && @feature_flag.context.is_a?(Account)
|
||||
# if the context IS a root account, we still need to tie it to this
|
||||
# record, and it's root_account_id will be nil.
|
||||
attributes['root_account_id'] = @feature_flag.context.global_id
|
||||
end
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= User.find(user_id)
|
||||
end
|
||||
|
||||
def user=(user)
|
||||
@user ||= user
|
||||
# might be nil in a rare circumstance with an unprovisioned console user
|
||||
attributes['user_id'] = @user&.global_id || 0
|
||||
end
|
||||
end
|
||||
|
||||
Stream = Audits.stream do
|
||||
auth_ar_type = Auditors::ActiveRecord::FeatureFlagRecord
|
||||
backend_strategy -> { Audits.backend_strategy }
|
||||
active_record_type auth_ar_type
|
||||
database -> { CanvasCassandra::DatabaseBuilder.from_config(:auditors) }
|
||||
table :feature_flags
|
||||
record_type Auditors::FeatureFlag::Record
|
||||
read_consistency_level -> { CanvasCassandra::DatabaseBuilder.read_consistency_setting(:auditors) }
|
||||
|
||||
add_index :feature_flag do
|
||||
table :feature_flag_changes_by_feature_flag
|
||||
entry_proc lambda{ |record| record.feature_flag }
|
||||
key_proc lambda{ |feature_flag| feature_flag.global_id }
|
||||
ar_scope_proc lambda { |feature_flag| auth_ar_type.where(feature_flag_id: feature_flag.id) }
|
||||
end
|
||||
end
|
||||
|
||||
def self.for_feature_flag(feature_flag, options={})
|
||||
feature_flag.shard.activate do
|
||||
Auditors::FeatureFlag::Stream.for_feature_flag(feature_flag, Audits.read_stream_options(options))
|
||||
end
|
||||
end
|
||||
|
||||
def self.record(feature_flag, user, previous_state, post_state: nil)
|
||||
return unless feature_flag
|
||||
|
||||
event_record = nil
|
||||
post_state ||= feature_flag.state
|
||||
feature_flag.shard.activate do
|
||||
event_record = Auditors::FeatureFlag::Record.generate(feature_flag, user, previous_state, post_state)
|
||||
Auditors::FeatureFlag::Stream.insert(event_record, {backend_strategy: :cassandra}) if Audits.write_to_cassandra?
|
||||
Auditors::FeatureFlag::Stream.insert(event_record, {backend_strategy: :active_record}) if Audits.write_to_postgres?
|
||||
end
|
||||
event_record
|
||||
end
|
||||
end
|
|
@ -19,12 +19,21 @@
|
|||
#
|
||||
|
||||
class FeatureFlag < ActiveRecord::Base
|
||||
# this field is used for audit logging.
|
||||
# if a request is changing the state of a feature
|
||||
# flag, it should set this value before persisting
|
||||
# the change.
|
||||
attr_writer :current_user
|
||||
|
||||
belongs_to :context, polymorphic: [:account, :course, :user]
|
||||
|
||||
self.ignored_columns = %i[visibility manipulate]
|
||||
|
||||
validate :valid_state, :feature_applies
|
||||
before_save :check_cache
|
||||
after_create :audit_log_create # to make sure we have an ID, must be after
|
||||
before_update :audit_log_update
|
||||
before_destroy :audit_log_destroy
|
||||
before_destroy :clear_cache
|
||||
|
||||
def default?
|
||||
|
@ -72,6 +81,36 @@ class FeatureFlag < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
def audit_log_update(operation: :update)
|
||||
# kill switch in case something goes crazy in rolling this out.
|
||||
# TODO: we can yank this guard clause once we're happy with it's stability.
|
||||
return unless Setting.get('write_feature_flag_audit_logs', 'true') == 'true'
|
||||
|
||||
# User feature flags only get changed by the target user,
|
||||
# are much higher volume than higher level flags, and are generally
|
||||
# uninteresting from a forensics standpoint. We can save a lot of writes
|
||||
# by not caring about them.
|
||||
unless context.is_a?(User)
|
||||
# this should catch a programatic/console user if one is acting
|
||||
# outside the request/response cycle
|
||||
acting_user = @current_user || Canvas.infer_user
|
||||
prior_state = if operation == :create
|
||||
'nonexistant'
|
||||
else
|
||||
self.state_in_database
|
||||
end
|
||||
post_state = (operation == :destroy ? 'removed' : self.state)
|
||||
Auditors::FeatureFlag.record(self, acting_user, prior_state, post_state: post_state)
|
||||
end
|
||||
end
|
||||
|
||||
def audit_log_create
|
||||
audit_log_update(operation: :create)
|
||||
end
|
||||
|
||||
def audit_log_destroy
|
||||
audit_log_update(operation: :destroy)
|
||||
end
|
||||
private
|
||||
|
||||
def valid_state
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
|
||||
class AddFlagAuditors < ActiveRecord::Migration[6.0]
|
||||
tag :predeploy
|
||||
|
||||
def up
|
||||
create_table :auditor_feature_flag_records do |t|
|
||||
t.string :uuid, null: false
|
||||
t.bigint :feature_flag_id, null: false
|
||||
t.bigint :root_account_id, null: false
|
||||
t.string :context_type
|
||||
t.bigint :context_id
|
||||
t.string :feature_name
|
||||
t.string :event_type, null: false
|
||||
t.string :state_before, null: false
|
||||
t.string :state_after, null: false
|
||||
t.string :request_id, null: false
|
||||
t.bigint :user_id, null: false
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
add_index :auditor_feature_flag_records, :uuid
|
||||
add_index :auditor_feature_flag_records, :feature_flag_id
|
||||
add_index :auditor_feature_flag_records, :root_account_id
|
||||
add_foreign_key :auditor_feature_flag_records, :accounts, column: :root_account_id
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :auditor_feature_flag_records
|
||||
end
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
|
||||
class AddFlagAuditorPartitions < ActiveRecord::Migration[6.0]
|
||||
tag :predeploy
|
||||
|
||||
def up
|
||||
auditor_cls = Auditors::ActiveRecord::FeatureFlagRecord
|
||||
partman = CanvasPartman::PartitionManager.create(auditor_cls)
|
||||
partman.create_initial_partitions
|
||||
end
|
||||
|
||||
def down
|
||||
auditor_cls = Auditors::ActiveRecord::FeatureFlagRecord
|
||||
partman = CanvasPartman::PartitionManager.create(auditor_cls)
|
||||
partman.partition_tables.each do |partition|
|
||||
drop_table partition
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
|
||||
class AddFlagAuditorsCassandra < ActiveRecord::Migration[6.0]
|
||||
# Instructure won't be using any cassandra tables
|
||||
# for auditor changes to feature flags,
|
||||
# but some open source users may still be within the deprecation
|
||||
# window, so we'll include these tables so they can keep using
|
||||
# cassandra as a write target if they want until that gets remove.
|
||||
# TODO: When cassandra auditors are fully removed,
|
||||
# this migration can be deleted entirely.
|
||||
tag :predeploy
|
||||
|
||||
include Canvas::Cassandra::Migration
|
||||
|
||||
def self.cassandra_cluster
|
||||
'auditors'
|
||||
end
|
||||
|
||||
def self.indexes
|
||||
%w(
|
||||
feature_flag_changes_by_feature_flag
|
||||
)
|
||||
end
|
||||
|
||||
def self.up
|
||||
compression_params = cassandra.db.use_cql3? ?
|
||||
"WITH compression = { 'sstable_compression' : 'DeflateCompressor' }" :
|
||||
"WITH compression_parameters:sstable_compression='DeflateCompressor'"
|
||||
|
||||
cassandra.execute %{
|
||||
CREATE TABLE feature_flags (
|
||||
id text PRIMARY KEY,
|
||||
created_at timestamp,
|
||||
feature_flag_id bigint,
|
||||
root_account_id bigint,
|
||||
context_id bigint,
|
||||
context_type text,
|
||||
feature_name text,
|
||||
event_type text,
|
||||
state_before text,
|
||||
state_after text,
|
||||
user_id bigint,
|
||||
request_id text
|
||||
) #{compression_params}}
|
||||
|
||||
indexes.each do |index_name|
|
||||
cassandra.execute %{
|
||||
CREATE TABLE #{index_name} (
|
||||
key text,
|
||||
ordered_id text,
|
||||
id text,
|
||||
PRIMARY KEY (key, ordered_id)
|
||||
) #{compression_params}}
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
indexes.each do |index_name|
|
||||
cassandra.execute %{DROP TABLE #{index_name};}
|
||||
end
|
||||
cassandra.execute %{DROP TABLE feature_flags;}
|
||||
end
|
||||
end
|
|
@ -314,7 +314,7 @@ describe "Feature Flags API", type: :request do
|
|||
{}, {}, { expected_status: 400 })
|
||||
end
|
||||
|
||||
it "should create a new flag" do
|
||||
it "should create a new flag with an audit log" do
|
||||
allow(Feature).to receive(:definitions).and_return({
|
||||
'granular_permissions_manage_courses' => granular_permissions_feature,
|
||||
'course_feature' => Feature.new(
|
||||
|
@ -327,9 +327,24 @@ describe "Feature Flags API", type: :request do
|
|||
description: "srsly"
|
||||
)
|
||||
})
|
||||
api_call_as_user(t_teacher, :put, "/api/v1/courses/#{t_course.id}/features/flags/course_feature?state=on",
|
||||
{ controller: 'feature_flags', action: 'update', format: 'json', course_id: t_course.to_param, feature: 'course_feature', state: 'on' })
|
||||
expect(t_course.feature_flags.map(&:state)).to eql ['on']
|
||||
params = {
|
||||
controller: 'feature_flags',
|
||||
action: 'update',
|
||||
format: 'json',
|
||||
course_id: t_course.to_param,
|
||||
feature: 'course_feature',
|
||||
state: 'on'
|
||||
}
|
||||
url_path = "/api/v1/courses/#{t_course.id}/features/flags/course_feature?state=on"
|
||||
api_call_as_user(t_teacher, :put, url_path, params)
|
||||
flags = t_course.feature_flags
|
||||
expect(flags.size).to eq(1)
|
||||
flag = flags.first
|
||||
expect(flag.state).to eql 'on'
|
||||
log = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 1).first
|
||||
expect(log.context_type).to eq('Course')
|
||||
expect(log.context_id).to eq(t_course.id)
|
||||
expect(log.state_after).to eq('on')
|
||||
end
|
||||
|
||||
it "should update an existing flag" do
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2022 - present 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/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../../sharding_spec_helper.rb')
|
||||
|
||||
describe Auditors::ActiveRecord::FeatureFlagRecord do
|
||||
let(:request_id){ 'abcde-12345'}
|
||||
let(:feature_name){ 'root_account_feature'}
|
||||
|
||||
before(:each) do
|
||||
allow(RequestContextGenerator).to receive_messages(request_id: request_id)
|
||||
allow(Feature).to receive(:definitions).and_return({
|
||||
feature_name => Feature.new(feature: feature_name, applies_to: 'RootAccount')
|
||||
})
|
||||
end
|
||||
|
||||
it "it appropriately connected to a table" do
|
||||
Auditors::ActiveRecord::FeatureFlagRecord.delete_all
|
||||
expect(Auditors::ActiveRecord::FeatureFlagRecord.count).to eq(0)
|
||||
end
|
||||
|
||||
describe "mapping from event stream record" do
|
||||
let(:flag_record) do
|
||||
flag = Account.site_admin.feature_flags.build
|
||||
flag.feature = feature_name
|
||||
flag.state = 'on'
|
||||
flag.id = -1
|
||||
flag
|
||||
end
|
||||
let(:user){ user_model }
|
||||
let(:es_record){ Auditors::FeatureFlag::Record.generate(flag_record, user, 'nonexistent') }
|
||||
|
||||
it "is creatable from an event_stream record of the correct type" do
|
||||
ar_rec = Auditors::ActiveRecord::FeatureFlagRecord.create_from_event_stream!(es_record)
|
||||
expect(ar_rec.id).to_not be_nil
|
||||
expect(ar_rec.uuid).to eq(es_record.id)
|
||||
expect(ar_rec.request_id).to eq(request_id)
|
||||
expect(ar_rec.user_id).to eq(user.id)
|
||||
expect(ar_rec.context_id).to eq(es_record.context_id)
|
||||
expect(ar_rec.context_type).to eq(es_record.context_type)
|
||||
expect(ar_rec.feature_name).to eq(es_record.feature_name)
|
||||
end
|
||||
|
||||
it "is updatable from ES record" do
|
||||
ar_rec = Auditors::ActiveRecord::FeatureFlagRecord.create_from_event_stream!(es_record)
|
||||
es_record.request_id = "aaa-111-bbb-222"
|
||||
Auditors::ActiveRecord::FeatureFlagRecord.update_from_event_stream!(es_record)
|
||||
expect(ar_rec.reload.request_id).to eq("aaa-111-bbb-222")
|
||||
end
|
||||
|
||||
it "fails predictably on attempted update to missing value" do
|
||||
unpersisted_rec = es_record
|
||||
expect do
|
||||
Auditors::ActiveRecord::FeatureFlagRecord.update_from_event_stream!(unpersisted_rec)
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,111 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2021 - present 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/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../sharding_spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../cassandra_spec_helper')
|
||||
|
||||
describe Auditors::FeatureFlag do
|
||||
let(:request_id) { 42 }
|
||||
let(:feature_name){ 'root_account_feature'}
|
||||
|
||||
before(:each) do
|
||||
allow(Feature).to receive(:definitions).and_return({
|
||||
feature_name => Feature.new(feature: feature_name, applies_to: 'RootAccount')
|
||||
})
|
||||
allow(Audits).to receive(:config).and_return({'write_paths' => ['active_record'], 'read_path' => 'active_record'})
|
||||
@flag = Account.site_admin.feature_flags.build
|
||||
@flag.feature = feature_name
|
||||
@flag.state = 'on'
|
||||
@user = user_with_pseudonym(active_all: true)
|
||||
@flag.current_user = @user
|
||||
@flag.save!
|
||||
Auditors::ActiveRecord::FeatureFlagRecord.delete_all
|
||||
shard_class = Class.new {
|
||||
define_method(:activate) { |&b| b.call }
|
||||
}
|
||||
EventStream.current_shard_lookup = lambda {
|
||||
shard_class.new
|
||||
}
|
||||
allow(RequestContextGenerator).to receive_messages(request_id: request_id)
|
||||
end
|
||||
|
||||
describe "with cassandra backend" do
|
||||
include_examples "cassandra audit logs"
|
||||
|
||||
before do
|
||||
allow(Audits).to receive(:config).and_return({'write_paths' => ['cassandra'], 'read_path' => 'cassandra'})
|
||||
@event = Auditors::FeatureFlag.record(@flag, @user, 'off')
|
||||
end
|
||||
|
||||
context "nominal cases" do
|
||||
it "returns the event on generation" do
|
||||
expect(@event.class).to eq(Auditors::FeatureFlag::Record)
|
||||
end
|
||||
|
||||
it "should include event for feature_flag index" do
|
||||
expect(Auditors::FeatureFlag.for_feature_flag(@flag).paginate(:per_page => 10)).
|
||||
to include(@event)
|
||||
end
|
||||
|
||||
it "should set request_id" do
|
||||
expect(@event.request_id).to eq request_id.to_s
|
||||
end
|
||||
|
||||
it "doesn't record an error when not configured" do
|
||||
allow(Auditors::FeatureFlag::Stream).to receive(:database).and_return(nil)
|
||||
expect(CanvasCassandra::DatabaseBuilder).to receive(:configured?).with("auditors").once.and_return(false)
|
||||
expect(EventStream::Logger).to receive(:error).never
|
||||
Auditors::FeatureFlag.record(@flag, @user, 'off')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "with dual writing enabled" do
|
||||
before do
|
||||
allow(Audits).to receive(:config).and_return({'write_paths' => ['cassandra', 'active_record'], 'read_path' => 'cassandra'})
|
||||
@event = Auditors::FeatureFlag.record(@flag, @user, 'off')
|
||||
end
|
||||
|
||||
it "writes to cassandra" do
|
||||
expect(Audits.write_to_cassandra?).to eq(true)
|
||||
expect(Auditors::FeatureFlag.for_feature_flag(@flag).paginate(per_page: 10)).
|
||||
to include(@event)
|
||||
end
|
||||
|
||||
it "writes to postgres" do
|
||||
expect(Audits.write_to_postgres?).to eq(true)
|
||||
pg_record = Auditors::ActiveRecord::FeatureFlagRecord.where(uuid: @event.id).first
|
||||
expect(pg_record.feature_flag_id).to eq(@flag.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "with postgres backend" do
|
||||
before do
|
||||
allow(Audits).to receive(:config).and_return({'write_paths' => ['active_record'], 'read_path' => 'active_record'})
|
||||
@event = Auditors::FeatureFlag.record(@flag, @user, 'off')
|
||||
end
|
||||
|
||||
it "can be read from postgres" do
|
||||
expect(Audits.read_from_postgres?).to eq(true)
|
||||
pg_record = Auditors::ActiveRecord::FeatureFlagRecord.where(uuid: @event.id).first
|
||||
expect(Auditors::FeatureFlag.for_feature_flag(@flag).paginate(per_page: 10)).to include(pg_record)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -119,4 +119,73 @@ describe FeatureFlag do
|
|||
expect(t_course.feature_flag(:hidden_root_opt_in_feature)).to be_unhides_feature
|
||||
end
|
||||
end
|
||||
|
||||
describe "audit log" do
|
||||
let_once(:acting_user) { user_model }
|
||||
|
||||
before(:each) do
|
||||
allow(Audits).to receive(:config).and_return({'write_paths' => ['active_record'], 'read_path' => 'active_record'})
|
||||
end
|
||||
|
||||
it "logs account feature creation" do
|
||||
flag = t_root_account.feature_flags.build(feature: 'root_account_feature')
|
||||
flag.current_user = acting_user
|
||||
flag.save!
|
||||
log = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 1).first
|
||||
expect(log.feature_flag_id).to eq(flag.id)
|
||||
expect(log.state_before).to eq("nonexistant")
|
||||
expect(log.state_after).to eq(flag.state)
|
||||
expect(log.context_type).to eq("Account")
|
||||
expect(log.context_id).to eq(t_root_account.id)
|
||||
end
|
||||
|
||||
it "logs course feature creation" do
|
||||
flag = t_course.feature_flags.build(feature: 'course_feature', state: 'on')
|
||||
flag.current_user = acting_user
|
||||
flag.save!
|
||||
log = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 1).first
|
||||
expect(log.feature_flag_id).to eq(flag.id)
|
||||
expect(log.state_before).to eq("nonexistant")
|
||||
expect(log.state_after).to eq("on")
|
||||
expect(log.context_type).to eq("Course")
|
||||
expect(log.context_id).to eq(t_course.id)
|
||||
end
|
||||
|
||||
it "does not log user feature creation" do
|
||||
flag = acting_user.feature_flags.build(feature: 'user_feature', state: 'on')
|
||||
flag.current_user = acting_user
|
||||
flag.save!
|
||||
logs = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 1)
|
||||
expect(logs).to be_empty
|
||||
end
|
||||
|
||||
it "logs feature state changes" do
|
||||
flag = t_root_account.feature_flags.build(feature: 'root_account_feature', state: 'allowed')
|
||||
flag.current_user = acting_user
|
||||
flag.save!
|
||||
flag.state = 'off'
|
||||
flag.save!
|
||||
logs = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 3).to_a
|
||||
log = logs.detect{|l| l.state_after == 'off' }
|
||||
expect(log.feature_flag_id).to eq(flag.id)
|
||||
expect(log.state_before).to eq("allowed")
|
||||
expect(log.state_after).to eq('off')
|
||||
expect(log.context_type).to eq("Account")
|
||||
expect(log.context_id).to eq(t_root_account.id)
|
||||
end
|
||||
|
||||
it "logs feature destruction" do
|
||||
flag = t_root_account.feature_flags.build(feature: 'root_account_feature', state: 'allowed')
|
||||
flag.current_user = acting_user
|
||||
flag.save!
|
||||
flag.destroy
|
||||
logs = Auditors::FeatureFlag.for_feature_flag(flag).paginate(per_page: 3).to_a
|
||||
log = logs.detect{|l| l.state_after == 'removed' }
|
||||
expect(log.feature_flag_id).to eq(flag.id)
|
||||
expect(log.state_after).to eq("removed")
|
||||
expect(log.state_before).to eq("allowed")
|
||||
expect(log.context_type).to eq("Account")
|
||||
expect(log.context_id).to eq(t_root_account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue