diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c4b64c8b..f05826f93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [cyberark/conjur#2691](https://github.com/cyberark/conjur/pull/2691) - Show resource request (`GET /resources/:account/:kind/*identifier`) now produce audit events. [cyberark/conjur#2695](https://github.com/cyberark/conjur/pull/2695) +- List memberships request (`GET /roles/:account/:kind/*identifier?memberships`) now produce audit events. + [cyberark/conjur#2693](https://github.com/cyberark/conjur/pull/2693) ## [1.19.0] - 2022-11-29 diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 6a80925a75..9aae4f6a0e 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -55,7 +55,12 @@ def all_memberships # def direct_memberships memberships = filtered_roles(role.direct_memberships_dataset(filter_params), membership_filter) - render_dataset(memberships) + render_result = render_dataset(memberships) + audit_memberships_success(membership_filter) + return render_result + rescue => e + audit_memberships_failure(membership_filter, e) + raise e end # Find all members of this role. @@ -260,4 +265,35 @@ def audit_list_failure(err) ) end + def audit_memberships_success(filter) + additional_params = %i[account count search kind filter] + options = params.permit(*additional_params).to_h.symbolize_keys + options[:filter] = filter if filter + options[:role] = role_id + Audit.logger.log( + Audit::Event::Memberships.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: options, + success: true + ) + ) + end + + def audit_memberships_failure(filter, err) + additional_params = %i[account count search kind filter] + options = params.permit(*additional_params).to_h.symbolize_keys + options[:filter] = filter if filter + options[:role] = role_id + Audit.logger.log( + Audit::Event::Memberships.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: options, + success: false, + error_message: err.message + ) + ) + end + end diff --git a/app/models/audit/event/memberships.rb b/app/models/audit/event/memberships.rb new file mode 100644 index 0000000000..f036cfa63f --- /dev/null +++ b/app/models/audit/event/memberships.rb @@ -0,0 +1,89 @@ +module Audit + module Event + class Memberships + def initialize( + user_id:, + client_ip:, + success:, + subject:, + error_message: nil + ) + @user_id = user_id + @client_ip = client_ip + @subject = subject + @success = success + @error_message = error_message + + # Implements `==` for audit events + @comparable_evt = ComparableEvent.new(self) + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + attempted_action.severity + end + + def to_s + message + end + + # action_sd means "action structured data" + def action_sd + attempted_action.action_sd + end + + def message + attempted_action.message( + success_msg: "#{@user_id} successfully listed memberships with parameters: #{@subject}", + failure_msg: "#{@user_id} failed to list memberships with parameters: #{@subject}", + error_msg: @error_message + ) + end + + def message_id + 'membership' + end + + def structured_data + { + SDID::AUTH => { user: @user_id }, + SDID::SUBJECT => @subject, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/master/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + def ==(other) + @comparable_evt == other + end + + private + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: @success, + operation: 'list' + ) + end + end + + end + +end diff --git a/cucumber/api/features/memberships.feature b/cucumber/api/features/memberships.feature index cbd0fea528..9db2fc1108 100644 --- a/cucumber/api/features/memberships.feature +++ b/cucumber/api/features/memberships.feature @@ -61,6 +61,7 @@ Feature: Obtain the memberships of a role And I create a new user "carol" And I grant user "carol" to user "bob" And I grant user "bob" to user "alice" + Given I save my place in the audit log file for remote When I successfully GET "/roles/cucumber/user/alice?memberships" Then the JSON should be: """ @@ -73,6 +74,16 @@ Feature: Obtain the memberships of a role } ] """ + And there is an audit record matching: + """ + <86>1 * * conjur * membership + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" kind="user" role="cucumber:user:alice"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="list"] + cucumber:user:admin successfully listed memberships with parameters: {:account=>"cucumber", :kind=>"user", :role=>"cucumber:user:alice"} + """ + @smoke Scenario: Direct memberships can be counted @@ -80,13 +91,23 @@ Feature: Obtain the memberships of a role And I create a new user "carol" And I grant user "carol" to user "bob" And I grant user "bob" to user "alice" - When I successfully GET "/roles/cucumber/user/alice?memberships&count" + Given I save my place in the audit log file for remote + When I successfully GET "/roles/cucumber/user/alice?memberships&count=true" Then the JSON should be: """ { "count": 1 } """ + And there is an audit record matching: + """ + <86>1 * * conjur * membership + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" count="true" kind="user" role="cucumber:user:alice"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="list"] + cucumber:user:admin successfully listed memberships with parameters: {:account=>"cucumber", :count=>"true", :kind=>"user", :role=>"cucumber:user:alice"} + """ @smoke Scenario: Direct memberships can be searched @@ -94,6 +115,7 @@ Feature: Obtain the memberships of a role And I create a new user "carol" And I grant user "alice" to user "bob" And I grant user "carol" to user "bob" + Given I save my place in the audit log file for remote When I successfully GET "/roles/cucumber/user/bob?memberships&search=alice" Then the JSON should be: """ @@ -106,6 +128,15 @@ Feature: Obtain the memberships of a role } ] """ + And there is an audit record matching: + """ + <86>1 * * conjur * membership + [auth@43868 user="cucumber:user:admin"] + [subject@43868 account="cucumber" search="alice" kind="user" role="cucumber:user:bob"] + [client@43868 ip="\d+\.\d+\.\d+\.\d+"] + [action@43868 result="success" operation="list"] + cucumber:user:admin successfully listed memberships with parameters: {:account=>"cucumber", :search=>"alice", :kind=>"user", :role=>"cucumber:user:bob"} + """ @smoke Scenario: The role memberships list can be filtered. @@ -134,3 +165,4 @@ Feature: Obtain the memberships of a role "cucumber:user:charles" ] """ + diff --git a/spec/models/audit/event/memberships_spec.rb b/spec/models/audit/event/memberships_spec.rb new file mode 100644 index 0000000000..d34569d0b9 --- /dev/null +++ b/spec/models/audit/event/memberships_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe Audit::Event::Memberships do + let(:user_id) { 'rspec:user:my_user' } + let(:client_ip) { 'my-client-ip' } + let(:list_param) { { "limit"=> "1000" } } + let(:success) { true } + let(:error_message) { nil } + + + subject do + Audit::Event::Memberships.new( + user_id: user_id, + client_ip: client_ip, + subject: list_param, + success: success, + error_message: error_message + ) + end + + context 'when successful' do + it 'produces the expected message' do + expect(subject.message).to eq( + 'rspec:user:my_user successfully listed memberships with parameters: {"limit"=>"1000"}' + ) + end + + it 'uses the INFO log level' do + expect(subject.severity).to eq(Syslog::LOG_INFO) + end + + it 'renders to string correctly' do + expect(subject.to_s).to eq( + 'rspec:user:my_user successfully listed memberships with parameters: {"limit"=>"1000"}' + ) + end + + it 'contains the user field' do + expect(subject.structured_data).to match(hash_including({ + Audit::SDID::AUTH => { user: user_id } + })) + end + + it 'contains the ip field' do + expect(subject.structured_data).to match(hash_including({ + Audit::SDID::CLIENT => { ip: client_ip } + })) + end + + it 'produces the expected action_sd' do + expect(subject.action_sd).to eq({ "action@43868": { operation: "list", result: "success" } }) + end + + it_behaves_like 'structured data includes client IP address' + end + + context 'when a failure occurs' do + let(:success) { false } + + it 'produces the expected message' do + expect(subject.message).to eq( + 'rspec:user:my_user failed to list memberships with parameters: {"limit"=>"1000"}' + ) + end + + it 'uses the WARNING log level' do + expect(subject.severity).to eq(Syslog::LOG_WARNING) + end + + it 'produces the expected action_sd' do + expect(subject.action_sd).to eq({ "action@43868": { operation: "list", result: "failure" } }) + end + + it_behaves_like 'structured data includes client IP address' + end + + + +end \ No newline at end of file