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:
Ethan Vizitei 2021-06-01 15:16:54 -05:00
parent d65ac23815
commit bb6fdba9f3
13 changed files with 673 additions and 5 deletions

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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