Auditors::Authentication

fixes CNVS-390

stores and allows querying by user/account/pseudonym of login/logout
events.

test-plan:
  [setup]
    - set up an 'auditors' keyspace in cassandra and run migrations
    - have shardX and shardY on one database server, and shardZ on a
      different database server
    - have accountW and accountX on shardX
    - have accountY and accountZ on shardY and shardZ, respectively
    - have userA on shardX with pseudonymAW in accountW and pseudonymAX in
      accountX (cross-account, single-shard user)
    - have userB on shardY with pseudonymBY in accountY and pseudonymBX in
      accountX (cross-shard user)
    - have userC on shardZ with pseudonymCZ in accountZ and pseudonymCX in
      accountX (cross-db-server user)
    - log in and out of each pseudonym above multiple times

  [index isolation]
    - /api/v1/audit/authentication/pseudonyms/<pseudonymAX> should
      include logins and logouts from pseudonymAX only
    - /api/v1/audit/authentication/accounts/<accountX> should include
      logins and logouts from pseudonymAX, pseudonymBX, and pseudonymCX
      but not pseudonymAW
    - /api/v1/audit/authentication/users/<userA> should include logins
      and logouts from both pseudonymAW and pseudonymAX but not
      pseudonymBX or pseudonymCX

  [permission isolation]
    (in each of these, either :view_statistics or :manage_user_logins on
    an account qualifies as "having permission")
    - /api/v1/audit/authentication/pseudonyms/<pseudonymAX> should be
      unauthorized if the current user doesn't have permission on
      accountX
    - /api/v1/audit/authentication/accounts/<accountX> should be
      unauthorized if the current user doesn't have permission on
      accountX
    - /api/v1/audit/authentication/users/<userA> should be unauthorized
      if the current user doesn't have permission on either of accountW
      or accountX
    - /api/v1/audit/authentication/users/<userA> should include logins
      and logouts from accountW but not from accountX if the current
      user has permission on accountW but not on accountX

  [sharding]
    - /api/v1/audit/authentication/users/<userB> should include logins
      and logouts from both pseudonymBY and pseudonymBX
    - /api/v1/audit/authentication/users/<userB> should not include
      duplicate logins and logouts from either pseudonymBY and
      pseudonymBX (potential for bug due to both pseudonyms' shards
      being on the same database server)
    - /api/v1/audit/authentication/users/<userC> should include logins
      and logouts from both pseudonymCZ and pseudonymCX

Change-Id: I74b1573b346935f733fe5b07919d2d450cf07592
Reviewed-on: https://gerrit.instructure.com/21829
Reviewed-by: Brian Palmer <brianp@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Jeremy Putnam <jeremyp@instructure.com>
Product-Review: Jacob Fugal <jacob@instructure.com>
This commit is contained in:
Jacob Fugal 2013-06-27 16:43:15 -06:00
parent a91e6c32b5
commit 59737086e7
17 changed files with 1016 additions and 48 deletions

View File

@ -0,0 +1,123 @@
#
# 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 Authentications Log
#
# Query audit log of authentication events (logins and logouts).
#
# For each endpoint, a compound document is returned. The primary collection of
# event objects is paginated, ordered by date descending. Secondary collections
# of pseudonyms (logins), accounts, and users related to the returned events
# are also included. Refer to the Logins, Accounts, and Users APIs for
# descriptions of the objects in those collections.
#
# @object AuthenticationEvent
# {
# // timestamp of the event
# "created_at": "2012-07-19T15:00:00-06:00",
#
# // authentication event type ('login' or 'logout')
# "event_type": "login",
#
# // ID of the pseudonym (login) associated with the event
# "pseudonym_id": 9478,
#
# // ID of the account associated with the event. will match the
# // account_id in the associated pseudonym.
# "account_id": 2319,
#
# // ID of the user associated with the event will match the user_id in
# // the associated pseudonym.
# "user_id": 362
# }
#
class AuthenticationAuditApiController < ApplicationController
include Api::V1::AuthenticationEvent
# @API Query by pseudonym.
#
# List authentication events for a given pseudonym.
#
def for_pseudonym
@pseudonym = Pseudonym.active.find(params[:pseudonym_id])
if account_visible(@pseudonym.account) || account_visible(Account.site_admin)
events = Auditors::Authentication.for_pseudonym(@pseudonym)
render_events(events, @pseudonym)
else
render_unauthorized_action(@pseudonym)
end
end
# @API Query by account.
#
# List authentication events for a given account.
#
def for_account
@account = api_find(Account.active, params[:account_id])
if account_visible(@account) || account_visible(Account.site_admin)
events = Auditors::Authentication.for_account(@account)
render_events(events, @account)
else
render_unauthorized_action(@account)
end
end
# @API Query by user.
#
# List authentication events for a given user.
#
def for_user
@user = api_find(User.active, params[:user_id])
if @user == @current_user || account_visible(Account.site_admin)
events = Auditors::Authentication.for_user(@user)
render_events(events, @user)
else
accounts = Shard.with_each_shard(@user.associated_shards) do
Account.joins(:pseudonyms).where(:pseudonyms => {
:user_id => @user,
:workflow_state => 'active'
}).all
end
visible_accounts = accounts.select{ |a| account_visible(a) }
if visible_accounts == accounts
events = Auditors::Authentication.for_user(@user)
render_events(events, @user)
elsif visible_accounts.present?
pseudonyms = Shard.partition_by_shard(visible_accounts) do |shard_accounts|
@user.active_pseudonyms.where(:account_id => shard_accounts).all
end
events = Auditors::Authentication.for_pseudonyms(pseudonyms)
render_events(events, @user)
else
render_unauthorized_action(@user)
end
end
end
private
def account_visible(account)
account.grants_rights?(@current_user, nil, :view_statistics, :manage_user_logins).values.any?
end
def render_events(events, context)
route = polymorphic_url([:api_v1, :audit_authentication, context])
events = Api.paginate(events, self, route)
render :json => authentication_events_compound_json(events, @current_user, session)
end
end

View File

@ -1,5 +1,5 @@
#
# Copyright (C) 2011 Instructure, Inc.
# Copyright (C) 2011-2013 Instructure, Inc.
#
# This file is part of Canvas.
#
@ -525,6 +525,7 @@ class PseudonymSessionsController < ApplicationController
def successful_login(user, pseudonym, otp_passed = false)
@current_user = user
@current_pseudonym = pseudonym
Auditors::Authentication.record(@current_pseudonym, 'login')
otp_passed ||= cookies['canvas_otp_remember_me'] &&
@current_user.validate_otp_secret_key_remember_me_cookie(cookies['canvas_otp_remember_me'])
@ -577,6 +578,11 @@ class PseudonymSessionsController < ApplicationController
end
end
def logout_current_user
Auditors::Authentication.record(@current_pseudonym, 'logout')
super
end
def oauth2_auth
if params[:code] || params[:error]
# hopefully the user never sees this, since it's an oob response and the

View File

@ -860,6 +860,12 @@ FakeRails3Routes.draw do
delete 'courses/:course_id/enrollments/:id', :action => :destroy
end
scope(:controller => :authentication_audit_api) do
get 'audit/authentication/pseudonyms/:pseudonym_id', :action => :for_pseudonym, :path_name => 'audit_authentication_pseudonym'
get 'audit/authentication/accounts/:account_id', :action => :for_account, :path_name => 'audit_authentication_account'
get 'audit/authentication/users/:user_id', :action => :for_user, :path_name => 'audit_authentication_user'
end
scope(:controller => :assignments_api) do
get 'courses/:course_id/assignments', :action => :index, :path_name => 'course_assignments'
get 'courses/:course_id/assignments/:id', :action => :show, :path_name => 'course_assignment'

View File

@ -0,0 +1,51 @@
class AddAuthenticationAuditorTables < ActiveRecord::Migration
tag :predeploy
include Canvas::Cassandra::Migration
def self.cassandra_cluster
'auditors'
end
def self.indexes
%w(
authentications_by_pseudonym
authentications_by_account
authentications_by_user
)
end
def self.up
cassandra.execute %{
CREATE TABLE authentications (
id text PRIMARY KEY,
created_at timestamp,
pseudonym_id bigint,
account_id bigint,
user_id bigint,
event_type text
) WITH
compression_parameters:sstable_compression='DeflateCompressor';
}
indexes.each do |index_name|
cassandra.execute %{
CREATE TABLE #{index_name} (
key text,
ordered_id text,
id text,
PRIMARY KEY (key, ordered_id)
) WITH
compression_parameters:sstable_compression='DeflateCompressor';
}
end
end
def self.down
indexes.each do |index_name|
cassandra.execute %{DROP TABLE #{index_name};}
end
cassandra.execute %{DROP TABLE authentications;}
end
end

View File

@ -49,5 +49,9 @@ module Api::V1::Account
end
end
end
def accounts_json(accounts, user, session, includes)
accounts.map{ |account| account_json(account, user, session, includes) }
end
end

View File

@ -0,0 +1,62 @@
#
# 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/>.
#
module Api::V1::AuthenticationEvent
include Api::V1::Pseudonym
include Api::V1::Account
include Api::V1::User
def authentication_event_json(event, user, session)
{
:created_at => event.created_at.in_time_zone,
:event_type => event.event_type,
:pseudonym_id => Shard.relative_id_for(event.pseudonym_id),
:account_id => Shard.relative_id_for(event.account_id),
:user_id => Shard.relative_id_for(event.user_id)
}
end
def authentication_events_json(events, user, session)
events.map{ |event| authentication_event_json(event, user, session) }
end
def authentication_events_compound_json(events, user, session)
pseudonyms = []
accounts = []
pseudonym_ids = events.map{ |event| event.pseudonym_id }.uniq
Shard.partition_by_shard(pseudonym_ids) do |shard_pseudonym_ids|
shard_pseudonyms = Pseudonym.where(:id => shard_pseudonym_ids).all
account_ids = shard_pseudonyms.map{ |pseudonym| pseudonym.account_id }.uniq
accounts.concat Account.where(:id => account_ids).all
pseudonyms.concat shard_pseudonyms
end
user_ids = events.map{ |event| event.user_id }.uniq
users = Shard.partition_by_shard(user_ids) do |shard_user_ids|
User.where(:id => shard_user_ids).all
end
{
meta: {primaryCollection: 'events'},
events: authentication_events_json(events, user, session),
pseudonyms: pseudonyms_json(pseudonyms, user, session),
accounts: accounts_json(accounts, user, session, []),
users: users_json(users, user, session, [], @domain_root_account)
}
end
end

View File

@ -8,5 +8,9 @@ module Api::V1::Pseudonym
opts = opts.reject { |opt| opt == :sis_user_id } unless pseudonym.account.grants_rights?(current_user, :read_sis, :manage_sis).values.any?
api_json(pseudonym, current_user, session, :only => opts)
end
def pseudonyms_json(pseudonyms, current_user, session)
pseudonyms.map{ |p| pseudonym_json(p, current_user, session) }
end
end

View File

@ -75,6 +75,10 @@ module Api::V1::User
end
end
def users_json(users, current_user, session, includes = [], context = @context, enrollments = nil)
users.map{ |user| user_json(user, current_user, session, includes, context, enrollments) }
end
# this mini-object is used for secondary user responses, when we just want to
# provide enough information to display a user.
# for instance, discussion entries return this json as a sub-object.

View File

@ -0,0 +1,153 @@
#
# 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/>.
#
module Auditors; end
class Auditors::Authentication
class Record < ::EventStream::Record
def self.generate(pseudonym, event_type)
new('id' => UUIDSingleton.instance.generate,
'created_at' => Time.zone.now,
'pseudonym' => pseudonym,
'event_type' => event_type)
end
def initialize(*args)
super(*args)
if attributes['created_at']
attributes['created_at'] = Time.zone.at(attributes['created_at'].to_i)
end
if attributes['pseudonym']
self.pseudonym = attributes.delete('pseudonym')
end
end
def event_type
attributes['event_type']
end
def pseudonym_id
attributes['pseudonym_id']
end
def account_id
attributes['account_id']
end
def user_id
attributes['user_id']
end
def pseudonym
@pseudonym ||= Pseudonym.find(pseudonym_id)
end
def pseudonym=(pseudonym)
@pseudonym = pseudonym
attributes['pseudonym_id'] = @pseudonym.global_id
attributes['account_id'] = Shard.global_id_for(@pseudonym.account_id)
attributes['user_id'] = Shard.global_id_for(@pseudonym.user_id)
end
def user
pseudonym.user
end
def account
pseudonym.account
end
end
Stream = ::EventStream.new do
database_name :auditors
table :authentications
record_type Auditors::Authentication::Record
add_index :pseudonym do
table :authentications_by_pseudonym
entry_proc lambda{ |record| record.pseudonym }
key_proc lambda{ |pseudonym| pseudonym.global_id }
end
add_index :user do
table :authentications_by_user
entry_proc lambda{ |record| record.user }
key_proc lambda{ |user| user.global_id }
end
add_index :account do
table :authentications_by_account
entry_proc lambda{ |record| record.account }
key_proc lambda{ |account| account.global_id }
end
end
def self.record(pseudonym, event_type)
return unless pseudonym
pseudonym.shard.activate do
record = Auditors::Authentication::Record.generate(pseudonym, event_type)
Auditors::Authentication::Stream.insert(record)
end
end
def self.for_account(account)
account.shard.activate do
Auditors::Authentication::Stream.for_account(account)
end
end
def self.for_pseudonym(pseudonym)
pseudonym.shard.activate do
Auditors::Authentication::Stream.for_pseudonym(pseudonym)
end
end
def self.for_pseudonyms(pseudonyms)
# each for_pseudonym does a shard.activate, so this partition_by_shard is
# not necessary for correctness. but it improves performance (prevents
# shard-thrashing)
collections = Shard.partition_by_shard(pseudonyms) do |shard_pseudonyms|
shard_pseudonyms.map do |pseudonym|
[pseudonym.global_id, Auditors::Authentication.for_pseudonym(pseudonym)]
end
end
BookmarkedCollection.merge(*collections)
end
def self.for_user(user)
collections = []
dbs_seen = Set.new
Shard.with_each_shard(user.associated_shards) do
# EventStream is shard-sensitive, but multiple shards may share
# a database. if so, we only need to query from it once
db = Auditors::Authentication::Stream.database
next if dbs_seen.include?(db)
dbs_seen << db
# query from that database, and label it with the database server's id
# for merge
collections << [
Shard.current.database_server.id,
Auditors::Authentication::Stream.for_user(user)
]
end
BookmarkedCollection.merge(*collections)
end
end

View File

@ -34,12 +34,21 @@ class EventStream
Canvas::Cassandra::Database.from_config(database_name)
end
def available?
Canvas::Cassandra::Database.configured?(database_name)
end
def on_insert(&callback)
add_callback(:insert, callback)
end
def insert(record)
execute(:insert, record)
if available?
execute(:insert, record)
record
else
nil
end
end
def on_update(&callback)
@ -47,14 +56,19 @@ class EventStream
end
def update(record)
execute(:update, record)
if available?
execute(:update, record)
record
else
nil
end
end
def fetch(ids)
rows = []
if ids.present?
if available? && ids.present?
database.execute(fetch_cql, ids).fetch do |row|
rows << record_type.from_attributes(row)
rows << record_type.from_attributes(row.to_hash)
end
end
rows
@ -66,7 +80,7 @@ class EventStream
on_insert do |record|
if entry = index.entry_proc.call(record)
key = index.key_proc ? index.key_proc.call(entry) : entry
index.insert(record.id, key, record.created_at)
index.insert(record, key)
end
end
@ -90,8 +104,8 @@ class EventStream
"#{database_name}.#{table}"
end
def ttl_seconds(created_at)
((created_at + time_to_live) - Time.now).to_i
def ttl_seconds(record)
((record.created_at + time_to_live) - Time.now).to_i
end
private
@ -106,7 +120,7 @@ class EventStream
end
def execute(operation, record)
ttl_seconds = self.ttl_seconds(record.created_at)
ttl_seconds = self.ttl_seconds(record)
return if ttl_seconds < 0
database.batch do

View File

@ -52,30 +52,46 @@ class EventStream::Index
time.to_i - (time.to_i % bucket_size)
end
def insert(id, key, timestamp)
ttl_seconds = event_stream.ttl_seconds(timestamp)
def bookmark_for(record)
prefix = record.id.to_s[0,8]
bucket = bucket_for_time(record.created_at)
ordered_id = "#{record.created_at.to_i}/#{prefix}"
[bucket, ordered_id]
end
def insert(record, key)
ttl_seconds = event_stream.ttl_seconds(record)
return if ttl_seconds < 0
prefix = id.to_s[0,8]
bucket = bucket_for_time(timestamp)
bucket, ordered_id = bookmark_for(record)
key = "#{key}/#{bucket}"
ordered_id = "#{timestamp.to_i}/#{prefix}"
database.update(insert_cql, key, ordered_id, id, ttl_seconds)
database.update(insert_cql, key, ordered_id, record.id, ttl_seconds)
end
def for_key(key)
shard = Shard.current
BookmarkedCollection.build(EventStream::Index::Bookmarker) do |pager|
shard.activate { history(key, pager) }
bookmarker = EventStream::Index::Bookmarker.new(self)
BookmarkedCollection.build(bookmarker) do |pager|
if event_stream.available?
shard.activate { history(key, pager) }
end
end
end
module Bookmarker
def self.bookmark_for(item)
[item['bucket'], item['ordered_id']]
class Bookmarker
def initialize(index)
@index = index
end
def self.validate(bookmark)
def bookmark_for(item)
if item.is_a?(@index.event_stream.record_type)
@index.bookmark_for(item)
else
[item['bucket'], item['ordered_id']]
end
end
def validate(bookmark)
bookmark.is_a?(Array) && bookmark.size == 2
end
end
@ -123,9 +139,6 @@ class EventStream::Index
end
end
# possible optimization: query page_views_counters_by_context_and_day ,
# and use it as a secondary index to skip days where the user didn't
# have any page views
ordered_id = nil
bucket -= bucket_size
end

View File

@ -0,0 +1,365 @@
#
# 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/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../../cassandra_spec_helper')
describe "AuthenticationAudit API", type: :integration do
it_should_behave_like "cassandra audit logs"
before do
@viewing_user = site_admin_user
@account = Account.default
user_with_pseudonym(active_all: true)
@event = Auditors::Authentication.record(@pseudonym, 'login')
end
def fetch_for_context(context, options={})
type = context.class.to_s.downcase
id = context.id.to_s
path = "/api/v1/audit/authentication/#{type.pluralize}/#{id}"
arguments = { controller: 'authentication_audit_api', action: "for_#{type}", :"#{type}_id" => id, format: 'json' }
if per_page = options.delete(:per_page)
path += "?per_page=#{per_page}"
arguments[:per_page] = per_page.to_s
end
api_call_as_user(@viewing_user, :get, path, arguments, {}, {}, options.slice(:expected_status))
end
def expect_event_for_context(context, event)
json = fetch_for_context(context)
json['events'].map{ |e| [Shard.global_id_for(e['pseudonym_id']), e['event_type']] }.
should include([event.pseudonym_id, event.event_type])
end
def forbid_event_for_context(context, event)
json = fetch_for_context(context)
json['events'].map{ |e| [e['pseudonym_id'], e['event_type']] }.
should_not include([event.pseudonym_id, event.event_type])
end
describe "formatting" do
before do
@json = fetch_for_context(@user)
end
it "should have a meta key with primaryCollection=events" do
@json['meta']['primaryCollection'].should == 'events'
end
describe "events collection" do
before do
@json = @json['events']
end
it "should be formatted as an array of AuthenticationEvent objects" do
@json.should == [{
"created_at" => @event.created_at.in_time_zone.iso8601,
"event_type" => @event.event_type,
"pseudonym_id" => @pseudonym.id,
"account_id" => @account.id,
"user_id" => @user.id
}]
end
end
describe "pseudonyms collection" do
before do
@json = @json['pseudonyms']
end
it "should be formatted as an array of Pseudonym objects" do
@json.should == [{
"id" => @pseudonym.id,
"account_id" => @account.id,
"user_id" => @user.id,
"unique_id" => @pseudonym.unique_id,
"sis_user_id" => nil
}]
end
end
describe "accounts collection" do
before do
@json = @json['accounts']
end
it "should be formatted as an array of Account objects" do
@json.should == [{
"id" => @account.id,
"name" => @account.name,
"parent_account_id" => nil,
"root_account_id" => nil,
"default_time_zone" => @account.default_time_zone,
"default_storage_quota_mb" => @account.default_storage_quota_mb,
"default_user_storage_quota_mb" => @account.default_user_storage_quota_mb,
"default_group_storage_quota_mb" => @account.default_group_storage_quota_mb
}]
end
end
describe "users collection" do
before do
@json = @json['users']
end
it "should be formatted as an array of User objects" do
@json.should == [{
"id" => @user.id,
"name" => @user.name,
"sortable_name" => @user.sortable_name,
"short_name" => @user.short_name,
"login_id" => @pseudonym.unique_id
}]
end
end
end
context "nominal cases" do
it "should include events at pseudonym endpoint" do
expect_event_for_context(@pseudonym, @event)
end
it "should include events at account endpoint" do
expect_event_for_context(@account, @event)
end
it "should include events at user endpoint" do
expect_event_for_context(@user, @event)
end
end
context "with a second account (same user)" do
before do
@account = account_model
user_with_pseudonym(user: @user, account: @account, active_all: true)
end
it "should not include cross-account events at pseudonym endpoint" do
forbid_event_for_context(@pseudonym, @event)
end
it "should not include cross-account events at account endpoint" do
forbid_event_for_context(@account, @event)
end
it "should include cross-account events at user endpoint" do
expect_event_for_context(@user, @event)
end
end
context "with a second user (same account)" do
before do
user_with_pseudonym(active_all: true)
end
it "should not include cross-user events at pseudonym endpoint" do
forbid_event_for_context(@pseudonym, @event)
end
it "should include cross-user events at account endpoint" do
expect_event_for_context(@account, @event)
end
it "should not include cross-user events at user endpoint" do
forbid_event_for_context(@user, @event)
end
end
context "deleted entities" do
it "should 404 for inactive pseudonyms" do
@pseudonym.destroy
fetch_for_context(@pseudonym, expected_status: 404)
end
it "should 404 for inactive accounts" do
# can't just delete Account.default
@account = account_model
@account.destroy
fetch_for_context(@account, expected_status: 404)
end
it "should 404 for inactive users" do
@user.destroy
fetch_for_context(@user, expected_status: 404)
end
end
describe "permissions" do
before do
@user, @viewing_user = @user, user_model
end
context "no permission on account" do
it "should not authorize the pseudonym endpoint" do
fetch_for_context(@pseudonym, expected_status: 401)
end
it "should not authorize the account endpoint" do
fetch_for_context(@account, expected_status: 401)
end
it "should not authorize the user endpoint" do
fetch_for_context(@user, expected_status: 401)
end
end
context "with :view_statistics permission on account" do
before do
@user, _ = @user, account_admin_user_with_role_changes(
:account => @account, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:view_statistics => true})
end
it "should authorize the pseudonym endpoint" do
fetch_for_context(@pseudonym, expected_status: 200)
end
it "should authorize the account endpoint" do
fetch_for_context(@account, expected_status: 200)
end
it "should authorize the user endpoint" do
fetch_for_context(@user, expected_status: 200)
end
end
context "with :manage_user_logins permission on account" do
before do
@user, _ = @user, account_admin_user_with_role_changes(
:account => @account, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:manage_user_logins => true})
end
it "should authorize the pseudonym endpoint" do
fetch_for_context(@pseudonym, expected_status: 200)
end
it "should authorize the account endpoint" do
fetch_for_context(@account, expected_status: 200)
end
it "should authorize the user endpoint" do
fetch_for_context(@user, expected_status: 200)
end
end
context "with :view_statistics permission on site admin account" do
before do
@user, _ = @user, account_admin_user_with_role_changes(
:account => Account.site_admin, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:view_statistics => true})
end
it "should authorize the pseudonym endpoint" do
fetch_for_context(@pseudonym, expected_status: 200)
end
it "should authorize the account endpoint" do
fetch_for_context(@account, expected_status: 200)
end
it "should authorize the user endpoint" do
fetch_for_context(@user, expected_status: 200)
end
end
context "with :manage_user_logins permission on site admin account" do
before do
@user, _ = @user, account_admin_user_with_role_changes(
:account => Account.site_admin, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:manage_user_logins => true})
end
it "should authorize the pseudonym endpoint" do
fetch_for_context(@pseudonym, expected_status: 200)
end
it "should authorize the account endpoint" do
fetch_for_context(@account, expected_status: 200)
end
it "should authorize the user endpoint" do
fetch_for_context(@user, expected_status: 200)
end
end
describe "per-account permissions when fetching by user" do
before do
@account = account_model
user_with_pseudonym(user: @user, account: @account, active_all: true)
@user, _ = @user, account_admin_user_with_role_changes(
:account => @account, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:manage_user_logins => true})
end
context "without permission on the second account" do
it "should not include cross-account events at user endpoint" do
forbid_event_for_context(@user, @event)
end
end
context "with permission on the site admin account" do
before do
@user, _ = @user, account_admin_user_with_role_changes(
:account => Account.site_admin, :user => @viewing_user,
:membership_type => 'CustomAdmin',
:role_changes => {:manage_user_logins => true})
end
it "should include cross-account events at user endpoint" do
expect_event_for_context(@user, @event)
end
end
context "when viewing self" do
before do
@viewing_user = @user
end
it "should include cross-account events at user endpoint" do
expect_event_for_context(@user, @event)
end
end
end
end
describe "pagination" do
before do
# 3 events total
Auditors::Authentication.record(@pseudonym, 'logout')
Auditors::Authentication.record(@pseudonym, 'login')
@json = fetch_for_context(@user, :per_page => 2)
end
it "should only return one page of results" do
@json['events'].size.should == 2
end
it "should have pagination headers" do
response.headers['Link'].should match(/rel="next"/)
end
end
end

View File

@ -27,3 +27,11 @@ shared_examples_for "cassandra page views" do
end
end
end
shared_examples_for "cassandra audit logs" do
before do
unless Canvas::Cassandra::Database.configured?('auditors')
pending "needs cassandra auditors configuration"
end
end
end

View File

@ -771,7 +771,7 @@ describe PseudonymSessionsController do
@user.otp_secret_key = ROTP::Base32.random_base32
@user.save!
user_session(@user)
user_session(@user, @pseudonym)
session[:pending_otp] = true
end
@ -812,7 +812,7 @@ describe PseudonymSessionsController do
context "enrollment" do
before do
user_session(@user)
user_session(@user, @pseudonym)
end
it "should generate a secret key" do

View File

@ -0,0 +1,143 @@
#
# 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/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../../sharding_spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/../../cassandra_spec_helper')
describe AuthenticationAuditApiController do
it_should_behave_like "cassandra audit logs"
before do
@account = Account.default
user_with_pseudonym(active_all: true)
@event = Auditors::Authentication.record(@pseudonym, 'login')
end
context "nominal cases" do
it "should include event for pseudonym" do
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
should include(@event)
end
it "should include event for account" do
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
should include(@event)
end
it "should include event at user" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
should include(@event)
end
end
context "with a second account (same user)" do
before do
@account = account_model
user_with_pseudonym(user: @user, account: @account, active_all: true)
end
it "should not include cross-account events for pseudonym" do
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
should_not include(@event)
end
it "should not include cross-account events for account" do
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
should_not include(@event)
end
it "should include cross-account events for user" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
should include(@event)
end
end
context "with a second user (same account)" do
before do
user_with_pseudonym(active_all: true)
end
it "should not include cross-user events for pseudonym" do
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
should_not include(@event)
end
it "should include cross-user events for account" do
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
should include(@event)
end
it "should not include cross-user events for user" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
should_not include(@event)
end
end
describe "sharding" do
specs_require_sharding
context "different shard, same database server" do
before do
@shard1.activate do
@account = account_model
user_with_pseudonym(account: @account, active_all: true)
@event1 = Auditors::Authentication.record(@pseudonym, 'login')
end
user_with_pseudonym(user: @user, active_all: true)
@event2 = Auditors::Authentication.record(@pseudonym, 'login')
end
it "should include events from the user's native shard" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
should include(@event1)
end
it "should include events from the other pseudonym's shard" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
should include(@event2)
end
it "should not include duplicate events" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 4).
size.should == 2
end
end
context "different shard, different database server" do
before do
@shard2.activate do
@account = account_model
user_with_pseudonym(account: @account, active_all: true)
@event1 = Auditors::Authentication.record(@pseudonym, 'login')
end
user_with_pseudonym(user: @user, active_all: true)
@event2 = Auditors::Authentication.record(@pseudonym, 'login')
end
it "should include events from the user's native shard" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
should include(@event1)
end
it "should include events from the other pseudonym's shard" do
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
should include(@event2)
end
end
end
end

View File

@ -25,7 +25,11 @@ describe EventStream::Index do
def @database.update_record(*args); end
def @database.update(*args); end
@stream = stub('stream', :database => @database, :ttl_seconds => 1.year)
@stream = stub('stream',
:database => @database,
:available? => true,
:record_type => EventStream::Record,
:ttl_seconds => 1.year)
end
context "setup block" do
@ -173,16 +177,17 @@ describe EventStream::Index do
@id = stub('id', :to_s => '1234567890')
@key = stub('key', :to_s => 'key_value')
@timestamp = stub('timestamp', :to_i => 12345)
@record = stub('record', :id => @id, :created_at => @timestamp)
end
it "should use the stream's database" do
@database.expects(:update).once
@index.insert(@id, @key, @timestamp)
@index.insert(@record, @key)
end
it "should use the configured table" do
@database.expects(:update).once.with(regexp_matches(/ INTO #{@table} /), anything, anything, anything, anything)
@index.insert(@id, @key, @timestamp)
@index.insert(@record, @key)
end
it "should combine the key and timestamp bucket into the configured key column" do
@ -191,25 +196,25 @@ describe EventStream::Index do
@index.key_column key_column
@index.expects(:bucket_for_time).once.with(@timestamp).returns(bucket)
@database.expects(:update).once.with(regexp_matches(/\(#{key_column}, /), "#{@key}/#{bucket}", anything, anything, anything)
@index.insert(@id, @key, @timestamp)
@index.insert(@record, @key)
end
it "should take a prefix off the id and the bucket for the ordered_id" do
prefix = @id.to_s[0,8]
@database.expects(:update).once.with(regexp_matches(/, ordered_id,/), anything, "#{@timestamp.to_i}/#{prefix}", anything, anything)
@index.insert(@id, @key, @timestamp)
@index.insert(@record, @key)
end
it "should pass through the id into the configured id column" do
id_column = stub(:to_s => "expected_id_column")
@index.id_column id_column
@database.expects(:update).once.with(regexp_matches(/, #{id_column}\)/), anything, anything, @id, anything)
@index.insert(@id, @key, @timestamp)
@index.insert(@record, @key)
end
it "should include the ttl" do
@database.expects(:update).once.with(regexp_matches(/ USING TTL /), anything, anything, anything, @stream.ttl_seconds(@timestamp))
@index.insert(@id, @key, @timestamp)
@database.expects(:update).once.with(regexp_matches(/ USING TTL /), anything, anything, anything, @stream.ttl_seconds(@record))
@index.insert(@record, @key)
end
end
@ -219,14 +224,9 @@ describe EventStream::Index do
@index.scrollback_default @index.bucket_size
@pager = @index.for_key('key')
type = Struct.new(:id)
@ids = (1..4).to_a
@typed_results = @ids.map{ |id| type.new(id) }
@raw_results = @ids.map{ |id| { 'id' => id, 'ordered_id' => id } }
end
it "should return a bookmarked collection" do
@pager.should be_a BookmarkedCollection::Proxy
@typed_results = @ids.map{ |id| @stream.record_type.new('id' => id, 'created_at' => id.minutes.ago) }
@raw_results = @typed_results.map{ |record| { 'id' => record.id, 'ordered_id' => "#{record.created_at.to_i}/#{record.id}" } }
end
def setup_fetch(start, requested)
@ -236,6 +236,16 @@ describe EventStream::Index do
@stream.expects(:fetch).once.with(@ids[start, requested]).returns(@typed_results[start, requested])
end
it "should return a bookmarked collection" do
@pager.should be_a BookmarkedCollection::Proxy
end
it "should be able to get bookmark from a typed item" do
setup_fetch(0, 2)
page = @pager.paginate(:per_page => 2)
page.bookmark_for(page.last).should == page.next_bookmark
end
context "one page of results" do
before do
setup_fetch(0, 4)

View File

@ -27,6 +27,7 @@ describe EventStream do
def @database.insert_record(*args); end
def @database.update(*args); end
::Canvas::Cassandra::Database.stubs(:from_config).with(@database_name.to_s).returns(@database)
::Canvas::Cassandra::Database.stubs(:configured?).with(@database_name.to_s).returns(true)
end
context "setup block" do
@ -152,7 +153,7 @@ describe EventStream do
end
it "should set the record's ttl" do
@database.expects(:insert_record).with(anything, anything, anything, @stream.ttl_seconds(@record.created_at))
@database.expects(:insert_record).with(anything, anything, anything, @stream.ttl_seconds(@record))
@stream.insert(@record)
end
@ -223,7 +224,7 @@ describe EventStream do
end
it "should set the record's ttl" do
@database.expects(:update_record).with(anything, anything, anything, @stream.ttl_seconds(@record.created_at))
@database.expects(:update_record).with(anything, anything, anything, @stream.ttl_seconds(@record))
@stream.update(@record)
end
@ -268,11 +269,12 @@ describe EventStream do
it "should map the returned rows to the configured record type" do
record_type = stub('record_type')
raw_result = stub('raw_result')
cql_result = stub('cql_result', :to_hash => raw_result)
typed_result = stub('typed_result')
record_type.expects(:from_attributes).with(raw_result).returns(typed_result)
@stream.record_type record_type
@results.expects(:fetch).yields(raw_result)
@results.expects(:fetch).yields(cql_result)
@database.expects(:execute).once.returns(@results)
results = @stream.fetch([1])
results.should == [typed_result]
@ -308,13 +310,13 @@ describe EventStream do
)
end
it "should insert the record with id and created_at" do
@index.expects(:insert).once.with(@record.id, anything, @record.created_at)
it "should insert the provided record into the index" do
@index.expects(:insert).once.with(@record, anything)
@stream.insert(@record)
end
it "should translate the record through the entry_proc for the key" do
@index.expects(:insert).once.with(anything, @entry, anything)
@index.expects(:insert).once.with(anything, @entry)
@stream.insert(@record)
end
@ -326,7 +328,7 @@ describe EventStream do
it "should translate the result of the entry_proc through the key_proc if present" do
@index.key_proc lambda{ |entry| entry.key }
@index.expects(:insert).once.with(anything, @key, anything)
@index.expects(:insert).once.with(anything, @key)
@stream.insert(@record)
end
end