Merge pull request #13475 from mkllnk/dfc-sib-tokens

Accept tokens from Startin'Blox OIDC server
This commit is contained in:
Maikel
2025-08-27 14:55:30 +10:00
committed by GitHub
19 changed files with 251 additions and 40 deletions

View File

@@ -2,10 +2,17 @@
# frozen_string_literal: true
SimpleCov.start 'rails' do
# The rails profile contains some filters already:
#
# - "/test/"
# - "/features/"
# - "/spec/"
# - "/autotest/"
# - /^\/config\//
# - /^\/db\//
add_filter '/bin/'
add_filter '/config/'
add_filter '/config/' # to include engine config
add_filter '/script'
add_filter '/db'
formatter SimpleCov::Formatter::SimpleFormatter
end

View File

@@ -1,4 +1,9 @@
#!/bin/env ruby
# frozen_string_literal: true
-c master
--compare master
# This shouldn't be needed in undercover > 0.7.4
#
# * https://github.com/grodowski/undercover/issues/233
--exclude-files "bin/*,db/*,config/*,spec/*,engines/*/config/*,engines/*/spec/*"

View File

@@ -75,6 +75,7 @@ class Enterprise < ApplicationRecord
has_one :stripe_account, dependent: :destroy
has_many :vouchers, dependent: :restrict_with_exception
has_many :connected_apps, dependent: :destroy
has_many :dfc_permissions, dependent: :destroy
has_one :custom_tab, dependent: :destroy
delegate :latitude, :longitude, :city, :state_name, to: :address

View File

@@ -30,8 +30,7 @@ module DfcProvider
# - Spree::Shipment
# - Subscription
def authorized(address)
current_user.ship_address_id == address.id ||
current_user.bill_address_id == address.id ||
user_address(address) ||
[
customer_address(address),
public_enterprise_group_address(address),
@@ -40,6 +39,13 @@ module DfcProvider
].any?(&:exists?)
end
def user_address(address)
return false if current_user.is_a? ApiUser
current_user.ship_address_id == address.id ||
current_user.bill_address_id == address.id
end
def customer_address(address)
current_user.customers.where(bill_address: address).or(
current_user.customers.where(ship_address: address)

View File

@@ -3,12 +3,15 @@
# Controller used to provide the API products for the DFC application
module DfcProvider
class ApplicationController < ActionController::Base
class Unauthorized < StandardError; end
include ActiveStorage::SetCurrent
protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from CanCan::AccessDenied, with: :unauthorized
rescue_from Unauthorized, with: :unauthorized
before_action :check_authorization
@@ -16,6 +19,13 @@ module DfcProvider
private
def require_permission(scope)
return if current_user.is_a? Spree::User
return if current_user.permissions(scope).where(enterprise: current_enterprise).exists?
raise Unauthorized
end
def check_authorization
unauthorized if current_user.nil?
end

View File

@@ -7,6 +7,8 @@ module DfcProvider
before_action :check_enterprise
def index
require_permission "ReadProducts"
enterprises = current_user.enterprises.map do |enterprise|
EnterpriseBuilder.enterprise(enterprise)
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
# Authorised user or client using the API
class ApiUser
CLIENT_MAP = {
"https://waterlooregionfood.ca/portal/profile" => "cqcm-dev",
}.freeze
def self.from_client_id(client_id)
id = CLIENT_MAP[client_id]
new(id) if id
end
attr_reader :id
def initialize(id)
@id = id
end
def admin?
false
end
def customers
Customer.none
end
def enterprises
Enterprise.where(dfc_permissions: permissions("ReadEnterprise"))
end
def permissions(scope)
DfcPermission.where(grantee: id, scope:)
end
end

View File

@@ -1,17 +1,29 @@
# frozen_string_literal: true
# Service used to authorize the user on DCF Provider API
# Authorize the user on the DFC API
#
# It controls an OICD Access token and an enterprise.
class AuthorizationControl
# Copied from: https://login.lescommuns.org/auth/realms/data-food-consortium/
LES_COMMUNES_PUBLIC_KEY = <<~KEY
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl68JGqAILFzoi/1+6siXXp2vylu+7mPjYKjKelTtHFYXWVkbmVptCsamHlY3jRhqSQYe6M1SKfw8D+uXrrWsWficYvpdlV44Vm7uETZOr1/XBOjpWOi1vLmBVtX6jFeqN1BxfE1PxLROAiGn+MeMg90AJKShD2c5RoNv26e20dgPhshRVFPUGru+0T1RoKyIa64z/qcTcTVD2V7KX+ANMweRODdoPAzQFGGjTnL1uUqIdUwSfHSpXYnKxXOsnPC3Mowkv8UIGWWDxS/yzhWc7sOk1NmC7pb+Cg7G8NKj+Pp9qQZnXF39Dg95ZsxJrl6fyPFvTo3zf9CPG/fUM1CkkwIDAQAB
-----END PUBLIC KEY-----
KEY
PUBLIC_KEYS = {
# Copied from: https://login.lescommuns.org/auth/realms/data-food-consortium/
"https://login.lescommuns.org/auth/realms/data-food-consortium" => <<~KEY,
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl68JGqAILFzoi/1+6siXXp2vylu+7mPjYKjKelTtHFYXWVkbmVptCsamHlY3jRhqSQYe6M1SKfw8D+uXrrWsWficYvpdlV44Vm7uETZOr1/XBOjpWOi1vLmBVtX6jFeqN1BxfE1PxLROAiGn+MeMg90AJKShD2c5RoNv26e20dgPhshRVFPUGru+0T1RoKyIa64z/qcTcTVD2V7KX+ANMweRODdoPAzQFGGjTnL1uUqIdUwSfHSpXYnKxXOsnPC3Mowkv8UIGWWDxS/yzhWc7sOk1NmC7pb+Cg7G8NKj+Pp9qQZnXF39Dg95ZsxJrl6fyPFvTo3zf9CPG/fUM1CkkwIDAQAB
-----END PUBLIC KEY-----
KEY
def self.public_key
OpenSSL::PKey::RSA.new(LES_COMMUNES_PUBLIC_KEY)
# Copied from: https://kc.cqcm.startinblox.com/realms/startinblox
"https://kc.cqcm.startinblox.com/realms/startinblox" => <<~KEY,
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtvdb3BdHoLnNeMLaWd7nugPwdRAJJpdSySTtttEQY2/v1Q3byJ/kReSNGrUNkPVkOeDN3milgN5Apz+sNCwbtzOCulyFMmvuIOZFBqz5tcgwjZinSwpGBXpn6ehXyCET2LlcfLYAPA9axtaNg9wBLIHoxIPWpa2LcZstogyZY/yKUZXQTDqM5B5TyUkPN89xHFdq8SQuXPasbpYl7mGhZHkTDHiKZ9VK7K5tqsEZTD9dCuTGMKsthbOrlDnc9bAJ3PyKLRdib21Y1GGlTozo4Y/1q448E/DFp5rVC6jG6JFnsEnP0WVn+6qz7yxI7IfUU2YSAGgtGYaQkWtEfED0QIDAQAB
-----END PUBLIC KEY-----
KEY
}.freeze
def self.public_key(token)
unverified_payload = JWT.decode(token, nil, false, { algorithm: "RS256" }).first
key = PUBLIC_KEYS[unverified_payload["iss"]]
OpenSSL::PKey::RSA.new(key)
end
def initialize(request)
@@ -27,7 +39,11 @@ class AuthorizationControl
private
def oidc_user
find_ofn_user(decode_token) if access_token
return unless access_token
payload = decode_token
find_ofn_user(payload) || client_user(payload)
end
def ofn_api_user
@@ -41,7 +57,7 @@ class AuthorizationControl
def decode_token
JWT.decode(
access_token,
self.class.public_key,
self.class.public_key(access_token),
true, { algorithm: "RS256" }
).first
end
@@ -59,4 +75,8 @@ class AuthorizationControl
OidcAccount.find_by(uid: payload["email"])&.user
end
def client_user(payload)
ApiUser.from_client_id(payload["client_id"])
end
end

View File

@@ -3,6 +3,7 @@
require_relative "../swagger_helper"
RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do
let(:Authorization) { nil }
let(:user) { create(:oidc_user, id: 12_345) }
let(:enterprise) {
create(
@@ -35,8 +36,15 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do
get "List CatalogItems" do
produces "application/json"
security [oidc_token: []]
response "404", "not found" do
context "as platform user" do
include_context "authenticated as platform"
let(:enterprise_id) { 10_000 }
run_test!
end
context "without enterprises" do
let(:enterprise_id) { "default" }
@@ -53,6 +61,25 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do
response "200", "success" do
before { product }
context "as platform user" do
include_context "authenticated as platform"
let(:enterprise_id) { 10_000 }
before {
DfcPermission.create!(
user:, enterprise_id:,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
DfcPermission.create!(
user:, enterprise_id:,
scope: "ReadProducts", grantee: "cqcm-dev",
)
}
run_test!
end
context "with default enterprise id" do
let(:enterprise_id) { "default" }
@@ -75,11 +102,31 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do
end
response "401", "unauthorized" do
let(:enterprise_id) { "default" }
context "as platform user" do
include_context "authenticated as platform"
before { login_as nil }
let(:enterprise_id) { 10_000 }
run_test!
before {
product
DfcPermission.create!(
user:, enterprise_id:,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
# But no ReadProducts permission.
}
run_test!
end
context "without authorisation" do
let(:enterprise_id) { "default" }
before { login_as nil }
run_test!
end
end
end
end

View File

@@ -3,6 +3,7 @@
require_relative "../swagger_helper"
RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do
let(:Authorization) { nil }
let!(:user) { create(:oidc_user) }
let!(:enterprise) do
create(
@@ -51,6 +52,21 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do
produces "application/json"
response "200", "successful" do
context "as platform user" do
include_context "authenticated as platform"
let(:id) { 10_000 }
before {
DfcPermission.create!(
user:, enterprise_id: id,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
}
run_test!
end
context "without enterprise id" do
let(:id) { "default" }

View File

@@ -13,6 +13,7 @@ RSpec.describe "ProductGroups", swagger_doc: "dfc.yaml" do
variants: [variant]
)
}
let(:Authorization) { nil }
let(:variant) {
build(:base_variant, id: 10_001, unit_value: 1, primary_taxon: taxon, supplier: enterprise)
}
@@ -34,10 +35,28 @@ RSpec.describe "ProductGroups", swagger_doc: "dfc.yaml" do
get "Show ProductGroup" do
produces "application/json"
security [oidc_token: []]
response "200", "success" do
let(:id) { product.id }
context "as platform user" do
include_context "authenticated as platform"
before {
DfcPermission.create!(
user:, enterprise_id:,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
DfcPermission.create!(
user:, enterprise_id:,
scope: "ReadProducts", grantee: "cqcm-dev",
)
}
run_test!
end
run_test! do
expect(json_response["@id"]).to eq "http://test.host/api/dfc/product_groups/90000"

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
require_relative "../spec_helper"
RSpec.describe ApiUser do
subject(:user) { described_class.new("cqcm-dev") }
describe "#customers" do
it "returns nothing" do
expect(user.customers).to be_empty
end
end
end

View File

@@ -8,6 +8,23 @@ RSpec.describe AuthorizationControl do
let(:user) { create(:oidc_user) }
describe "with OIDC token" do
it "accepts a token from Les Communs" do
user.oidc_account.update!(uid: "testdfc@protonmail.com")
lc_token = file_fixture("les_communs_access_token.jwt").read
Timecop.travel(Date.parse("2025-06-13")) do
expect(auth(oidc_token: lc_token).user).to eq user
end
end
it "accepts a token from Startin'Blox" do
sib_token = file_fixture("startinblox_access_token.jwt").read
Timecop.travel(Date.parse("2025-06-13")) do
expect(auth(oidc_token: sib_token).user.id).to eq "cqcm-dev"
end
end
it "finds the right user" do
create(:oidc_user) # another user
token = allow_token_for(email: user.email)

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
# Authenticate via Authoriztion token
RSpec.shared_context "authenticated as platform" do
let(:Authorization) {
"Bearer #{file_fixture('startinblox_access_token.jwt').read}"
}
around do |example|
# Once upon a time when the access token hadn't expired yet...
Timecop.travel(Date.parse("2025-06-13")) { example.run }
end
# Reset any login via session cookie.
before { login_as nil }
end

View File

@@ -113,7 +113,9 @@ module OpenFoodNetwork
end
def managed_enterprises
@managed_enterprises ||= Enterprise.managed_by(@user)
return Enterprise.all if admin?
@user.enterprises
end
def coordinated_order_cycles

View File

@@ -142,7 +142,7 @@ RSpec.describe Admin::OrderCyclesController do
select: {
enterprise_fees: 3,
enterprise_groups: 1,
enterprises: 22,
enterprises: 19,
exchanges: 7,
order_cycles: 6,
proxy_orders: 1,

View File

@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJKVjg1bVRtUmh1MGtSeGNNb0FGUFl5azJMbS1WTExYV25HOG9HbUxNUkowIn0.eyJleHAiOjE3NDk3ODkyMzYsImlhdCI6MTc0OTc4NzQzNiwiYXV0aF90aW1lIjoxNzQ5Nzg3NDMzLCJqdGkiOiJmM2Q2ZGNmMi1lNGMwLTQyNzItODQzNC00NWFhZDczOTllYzUiLCJpc3MiOiJodHRwczovL2xvZ2luLmxlc2NvbW11bnMub3JnL2F1dGgvcmVhbG1zL2RhdGEtZm9vZC1jb25zb3J0aXVtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjVmZjNhZjc4LTk0YTItNGI1Yi04ZGFkLWE3YzFkZWE4ODE2YSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImNvb3BjaXJjdWl0cyIsIm5vbmNlIjoiMTZkNmM3OGZkNTcwOWRkMjVkNzNkYzYwMmViNDBiZGYiLCJzZXNzaW9uX3N0YXRlIjoiYmE4Y2M0ZWYtMDJmMC00ZjVmLWFiMWEtMDUyNGRiNGViNzI5IiwiYWxsb3dlZC1vcmlnaW5zIjpbIiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJzaWQiOiJiYThjYzRlZi0wMmYwLTRmNWYtYWIxYS0wNTI0ZGI0ZWI3MjkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJ0ZXN0IGRmYyIsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3RkZmNAcHJvdG9ubWFpbC5jb20iLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJ0ZXN0IiwiZmFtaWx5X25hbWUiOiJkZmMiLCJlbWFpbCI6InRlc3RkZmNAcHJvdG9ubWFpbC5jb20ifQ.NTuzVgy8es0GHKqGPmHgVaV8Kzz9uuFAiWgixLubfh8fl2OccFDxccNKyiTczj4wHD4jItdHPIxz-x9ZX2Ao7lwMFLno69KWjAK2eLpA8Fnu4stftlswfHqD0W-wzG0Cx24H6jXZbsM5tm1FYgQYwrlZ-uqwQOabN_cA_cTrwHmMTNVCjwCisScq7Np7r1me-4YEABmTGR362_eJVn2bRppG_7s12yjEAH_mcTyALXqlXNaF0XihDCxjmK8ybJiGy6_QwhEJci6EWqJ-w9H6ckheq94xTM5WpanQ4-ZHEm2TZlq2MOfMBVsknhwnGI0b-GbtSJrs7urWsopQyWSuhw

View File

@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRRlJES1daQ2hzMllQYnRqYl9yQUtwQzNzMFo0T2FCMEtFQ056NnlxWHQ0In0.eyJleHAiOjE3NDk3ODk3MDcsImlhdCI6MTc0OTc4OTQwNywianRpIjoiOWE4ODU4NDAtODhjNy00OTliLWIyOGUtMmE5ZmViM2EyNmU0IiwiaXNzIjoiaHR0cHM6Ly9rYy5jcWNtLnN0YXJ0aW5ibG94LmNvbS9yZWFsbXMvc3RhcnRpbmJsb3giLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNWZlM2MyNmMtMjczNi00OGE0LWI2Y2YtYTllM2JjZmNkZjAwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly93YXRlcmxvb3JlZ2lvbmZvb2QuY2EvcG9ydGFsL3Byb2ZpbGUiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtc3RhcnRpbmJsb3giXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6IlJlYWRFbnRlcnByaXNlIFJlYWRQcm9kdWN0cyIsImNsaWVudEhvc3QiOiIxNzIuMTguMC4xIiwiY2xpZW50QWRkcmVzcyI6IjE3Mi4xOC4wLjEiLCJjbGllbnRfaWQiOiJodHRwczovL3dhdGVybG9vcmVnaW9uZm9vZC5jYS9wb3J0YWwvcHJvZmlsZSJ9.Ln7wY0_ptRAza7M8w3yXU02TvluH028uaoJ5VHiN9-PnakokzHve7SCuSd1hvVikYAivWFIBRP97vwfpb_DW-d9Afk_XcQqcA0L36ynUIZ69X5uQ2zakEW0kB6pwqd8AL8tlWVUg2PixBXJ6daJcgWNF7RlKXg6wgy4JYL_VxD3VJjST911-z4_TMuQ2OC-3SJNwNv3BspSmUXm7F6y8xGFN7wuCPjU90WIiZ_vxTbVdM0zNtBM0uMJFeFv2_ZzoJIIiNHYLWtD3LrKcXePLSejpo-DPVWR_lGdDdM7BmzOHPKZ9KMaV-oa3lYNYC5shhJOpoB3vHngtdYdv8jq7Cg

View File

@@ -137,6 +137,8 @@ paths:
type: string
get:
summary: List CatalogItems
security:
- oidc_token: []
tags:
- CatalogItems
responses:
@@ -578,13 +580,7 @@ paths:
"@id": http://test.host/api/dfc/enterprises/10000/platforms
dfc-t:platforms:
"@type": rdf:List
"@list":
- "@type": dfc-t:Platform
"@id": https://api.proxy-dev.cqcm.startinblox.com/profile
localId: cqcm-dev
dfc-t:hasAssignedScopes:
"@type": rdf:List
"@list": []
"@list": []
"/api/dfc/enterprises/{enterprise_id}/platforms/{platform_id}":
parameters:
- name: enterprise_id
@@ -634,24 +630,18 @@ paths:
dfc-t:hasAssignedScopes:
"@type": rdf:List
"@list":
- "@id": https://example.com/scopes/ReadEnterprise
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#ReadEnterprise
"@type": dfc-t:Scope
dfc-t:scope: ReadEnterprise
- "@id": https://example.com/scopes/WriteEnterprise
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#WriteEnterprise
"@type": dfc-t:Scope
dfc-t:scope: WriteEnterprise
- "@id": https://example.com/scopes/ReadProducts
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#ReadProducts
"@type": dfc-t:Scope
dfc-t:scope: ReadProducts
- "@id": https://example.com/scopes/WriteProducts
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#WriteProducts
"@type": dfc-t:Scope
dfc-t:scope: WriteProducts
- "@id": https://example.com/scopes/ReadOrders
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#ReadOrders
"@type": dfc-t:Scope
dfc-t:scope: ReadOrders
- "@id": https://example.com/scopes/WriteOrders
- "@id": https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#WriteOrders
"@type": dfc-t:Scope
dfc-t:scope: WriteOrders
requestBody:
content:
application/json:
@@ -688,6 +678,8 @@ paths:
type: string
get:
summary: Show ProductGroup
security:
- oidc_token: []
tags:
- ProductGroups
responses: