Skip to content

Commit

Permalink
Authenticate requests to Audiences endpoints (#438)
Browse files Browse the repository at this point in the history
Authenticate requests to Audiences contexts and SCIM proxy APIs

- **Authenticate Audiences requests**
- **Add authentication specs for contexts controller**
  • Loading branch information
xjunior authored Nov 1, 2024
1 parent 909f000 commit 8f84383
Show file tree
Hide file tree
Showing 13 changed files with 173 additions and 142 deletions.
5 changes: 4 additions & 1 deletion audiences/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
audiences (1.3.1)
audiences (1.4.0)
rails (>= 6.0)

GEM
Expand Down Expand Up @@ -131,6 +131,8 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
Expand Down Expand Up @@ -276,6 +278,7 @@ GEM
zeitwerk (2.7.1)

PLATFORMS
aarch64-linux
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
Expand Down
9 changes: 9 additions & 0 deletions audiences/app/controllers/audiences/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,14 @@

module Audiences
class ApplicationController < ActionController::API
before_action unless: :authenticate! do
render json: { error: "Unauthorized" }, status: :unauthorized
end

private

def authenticate!
instance_exec(request, &Audiences.config.authenticate)
end
end
end
4 changes: 4 additions & 0 deletions audiences/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Unreleased

# Version 1.4.0 (2024-11-01)

- Add authentication hooks for Audiences controllers [#438](https://github.com/powerhome/audiences/pull/438)

# Version 1.3.1 (2024-10-11)

- Forward pagination parameters to SCIM on proxy [#397](https://github.com/powerhome/audiences/pull/397)
Expand Down
2 changes: 1 addition & 1 deletion audiences/gemfiles/rails_6_1.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
audiences (1.3.1)
audiences (1.4.0)
rails (>= 6.0)

GEM
Expand Down
2 changes: 1 addition & 1 deletion audiences/gemfiles/rails_7_0.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
audiences (1.3.1)
audiences (1.4.0)
rails (>= 6.0)

GEM
Expand Down
2 changes: 1 addition & 1 deletion audiences/gemfiles/rails_7_1.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
audiences (1.3.1)
audiences (1.4.0)
rails (>= 6.0)

GEM
Expand Down
28 changes: 28 additions & 0 deletions audiences/lib/audiences/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ module Audiences

# Configuration options

#
# Authentication configuration. This defaults to true, meaning that the audiences
# endpoints are open to the public.
#
# To authenticate requests, set this configuration to a lambda that will receive
# the request and return true if the request is authenticated.
#
# Raising an exception will also prevent the execution of the request, but the
# exception will not be caught and should be handled by the application middlewares.
#
# I.e.:
#
# Audiences.configure do |config|
# config.authentication = ->(*) { authenticate_request }
# end
#
# I.e:
#
# Audiences.configure do |config|
# config.authentication = ->(request) do
# request.env["warden"].authenticate!
# end
# end
#
config_accessor :authentication do
->(*) { true }
end

#
# Identity model representing a SCIM User in the current application. I.e.: "User"
#
Expand Down
2 changes: 1 addition & 1 deletion audiences/lib/audiences/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Audiences
VERSION = "1.3.1"
VERSION = "1.4.0"
end
14 changes: 14 additions & 0 deletions audiences/spec/controllers/authenticated_endpoint_examples.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

RSpec.shared_examples "authenticated endpoint" do
routes { Audiences::Engine.routes }

it "requires authentication" do
config_before = Audiences.config.authenticate
Audiences.config.authenticate = ->(*) { false }

expect(subject).to have_http_status(:unauthorized)
ensure
Audiences.config.authenticate = config_before
end
end
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
# frozen_string_literal: true

require "rails_helper"
require_relative "authenticated_endpoint_examples"

RSpec.describe Audiences::ContextsController do
routes { Audiences::Engine.routes }

RSpec.describe "/audiences" do
let(:example_owner) { ExampleOwner.create!(name: "Example Owner") }
let(:example_context) { Audiences::Context.for(example_owner, relation: :members) }

describe "GET /audiences/:context_key" do
it_behaves_like "authenticated endpoint" do
subject { get :show, params: { key: example_context.signed_key } }
end

it "responds with the audience context json" do
get audience_context_path(example_owner, :members)
get :show, params: { key: example_context.signed_key }

expect(response.parsed_body).to match({
"match_all" => false,
Expand All @@ -19,22 +27,24 @@
end

describe "PUT /audiences/:context_key" do
let(:users_response) do
{
Resources: [{ externalId: 1 }, { externalId: 2 }],
}
it_behaves_like "authenticated endpoint" do
subject { put :update, params: { key: example_context.signed_key } }
end

it "updates the audience context to match all" do
stub_request(:get, "http://example.com/scim/v2/Users?attributes=id,externalId,displayName,active,photos.type,photos.value&filter=active%20eq%20true")
.to_return(status: 200, body: users_response.to_json, headers: {})
stub_request(:get, "http://example.com/scim/v2/Users")
.with(query: {
attributes: "id,externalId,displayName,active,photos.type,photos.value",
filter: "active eq true",
})
.to_return(status: 200, body: { "Resources" => [{ "displayName" => "John Doe", "externalId" => 123 }] }.to_json)

put audience_context_path(example_owner, :members), as: :json, params: { match_all: true }
put :update, params: { key: example_context.signed_key, match_all: true }

context = example_owner.members_context.reload
example_context.reload

expect(context).to be_match_all
expect(context.users.count).to eql 2
expect(example_context).to be_match_all
expect(example_context.memberships.count).to eq(1)
end

it "updates the context extra users" do
Expand All @@ -45,14 +55,14 @@
})
.to_return(status: 200, body: { "Resources" => [{ "displayName" => "John Doe", "externalId" => 123 }] }.to_json)

put audience_context_path(example_owner, :members),
as: :json,
params: { extra_users: [{ externalId: 123, displayName: "John Doe",
photos: [{ value: "http://example.com" }] }] }
put :update, params: {
key: example_context.signed_key,
extra_users: [{ externalId: 123, displayName: "John Doe", photos: [{ value: "http://example.com" }] }],
}

context = example_owner.members_context.reload
example_context.reload

expect(context.extra_users).to eql [{
expect(example_context.extra_users).to eql [{
"externalId" => 123,
"displayName" => "John Doe",
}]
Expand Down Expand Up @@ -82,24 +92,15 @@
stub_request(:get, "http://example.com/scim/v2/Users?attributes=#{attrs}" \
"&filter=(active eq true) and (groups.value eq 321)")
.to_return(status: 200, body: users_response.to_json, headers: {})
stub_request(:get, "http://example.com/scim/v2/Users?attributes=#{attrs}" \
"&filter=(active eq true) and (groups.value eq 789)")
.to_return(status: 200, body: users_response.to_json, headers: {})
stub_request(:get, "http://example.com/scim/v2/Users?attributes=#{attrs}" \
"&filter=(active eq true) and (groups.value eq 987)")
.to_return(status: 200, body: users_response.to_json, headers: {})

put audience_context_path(example_owner, :members),
as: :json,
params: {
match_all: false,
criteria: [
{ groups: { Departments: [{ id: 123, displayName: "Finance" }],
Territories: [{ id: 321, displayName: "Philadelphia" }] } },
{ groups: { Departments: [{ id: 789, displayName: "Sales" }],
Territories: [{ id: 987, displayName: "Detroit" }] } },
],
}
put :update, params: {
key: example_context.signed_key,
match_all: false,
criteria: [
{ groups: { Departments: [{ id: 123, displayName: "Finance" }],
Territories: [{ id: 321, displayName: "Philadelphia" }] } },
],
}

expect(response.parsed_body).to match({
"match_all" => false,
Expand All @@ -110,34 +111,30 @@
"id" => anything,
"count" => 2,
"groups" => {
"Departments" => [{ "id" => 123, "displayName" => "Finance" }],
"Territories" => [{ "id" => 321,
"Departments" => [{ "id" => "123", "displayName" => "Finance" }],
"Territories" => [{ "id" => "321",
"displayName" => "Philadelphia" }],
},
},
{
"id" => anything,
"count" => 2,
"groups" => {
"Departments" => [{ "id" => 789, "displayName" => "Sales" }],
"Territories" => [{ "id" => 987, "displayName" => "Detroit" }],
},
},
],
})
end
end
end

describe "GET /audiences/:context_key/users" do
it_behaves_like "authenticated endpoint" do
subject { get :users, params: { key: example_context.signed_key } }
end

it "is the list of users from an audience context" do
example_owner.members_context.users.create([
{ user_id: 123, data: { "externalId" => 123 } },
{ user_id: 456, data: { "externalId" => 456 } },
{ user_id: 789, data: { "externalId" => 789 } },
])

get audiences.users_path(example_owner.members_context.signed_key)
get :users, params: { key: example_context.signed_key }

expect(response.parsed_body).to match({
"count" => 3,
Expand All @@ -151,21 +148,20 @@
end

describe "GET /audiences/:context_key/users/:criterion_id" do
let(:criterion) { example_owner.members_context.criteria.create! }

it_behaves_like "authenticated endpoint" do
subject { get :users, params: { key: example_context.signed_key, criterion_id: criterion.id } }
end

it "is the list of users from an audience context's criterion" do
criterion = example_owner.members_context.criteria.create!
criterion.users.create!([
{ user_id: 1,
data: { "externalId" => 1,
"displayName" => "John" } },
{ user_id: 2,
data: { "externalId" => 2,
"displayName" => "Jose" } },
{ user_id: 3,
data: { "externalId" => 3,
"displayName" => "Nelson" } },
{ user_id: 1, data: { "externalId" => 1, "displayName" => "John" } },
{ user_id: 2, data: { "externalId" => 2, "displayName" => "Jose" } },
{ user_id: 3, data: { "externalId" => 3, "displayName" => "Nelson" } },
])

get audiences.users_path(example_owner.members_context.signed_key, criterion_id: criterion.id)
get :users, params: { key: example_context.signed_key, criterion_id: criterion.id }

expect(response.parsed_body).to match_array({
"count" => 3,
Expand Down
55 changes: 55 additions & 0 deletions audiences/spec/controllers/scim_proxy_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require "rails_helper"
require_relative "authenticated_endpoint_examples"

RSpec.describe Audiences::ScimProxyController do
routes { Audiences::Engine.routes }

context "GET /audiences/scim" do
let(:resource_query) { double }
before do
allow(Audiences::Scim).to(
receive(:resource)
.with(:MyResources)
.and_return(resource_query)
)
end

it_behaves_like "authenticated endpoint" do
subject { get :get }
end

it "returns the Resources key from the response" do
allow(resource_query).to receive(:query).and_return({ "response" => "body" })

get :get, params: { scim_path: "MyResources", filter: "name eq John" }

expect(response.parsed_body).to eq({ "response" => "body" })
end

it "proxies queries with arguments" do
expect(resource_query).to(
receive(:query)
.with(filter: "name eq John", startIndex: "12", count: "21")
.and_return({ "response" => "body" })
)

get :get, params: { scim_path: "MyResources", count: 21, startIndex: 12, filter: "name eq John" }

expect(response.parsed_body).to eq({ "response" => "body" })
end

it "removes the schemas and meta from the resources" do
allow(resource_query).to receive(:query).and_return({
"response" => "body",
"schemas" => ["schema:a"],
"meta" => "meta",
})

get :get, params: { scim_path: "MyResources", filter: "name eq John" }

expect(response.parsed_body).to eq({ "response" => "body" })
end
end
end
2 changes: 2 additions & 0 deletions audiences/spec/dummy/config/initializers/audiences.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Audiences.configure do |config|
config.identity_class = "ExampleUser"

config.authenticate = ->(*) { true }

config.scim = {
uri: ENV.fetch("SCIM_V2_API", "http://example.com/scim/v2/"),
headers: { "Authorization" => ENV.fetch("SCIM_AUTHORIZATION", "Bearer 123456789") },
Expand Down
Loading

0 comments on commit 8f84383

Please sign in to comment.