From cfeafbfc51b98b35aaa412aaf3798b92015c2b0e Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Aug 2025 11:55:41 +1000 Subject: [PATCH 01/11] Update API docs with latest version --- swagger/dfc.yaml | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index f0bf8177fb..ea664ea867 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -578,13 +578,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 +628,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: From 2a7754edbf81f7dd2cb37c673a50a0c5e4aacfe1 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 13 Jun 2025 14:24:27 +1000 Subject: [PATCH 02/11] Add test for current token validation --- .../spec/services/authorization_control_spec.rb | 9 +++++++++ spec/fixtures/files/les_communs_access_token.jwt | 1 + 2 files changed, 10 insertions(+) create mode 100644 spec/fixtures/files/les_communs_access_token.jwt diff --git a/engines/dfc_provider/spec/services/authorization_control_spec.rb b/engines/dfc_provider/spec/services/authorization_control_spec.rb index af340d3105..310acedbcf 100644 --- a/engines/dfc_provider/spec/services/authorization_control_spec.rb +++ b/engines/dfc_provider/spec/services/authorization_control_spec.rb @@ -8,6 +8,15 @@ 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 "finds the right user" do create(:oidc_user) # another user token = allow_token_for(email: user.email) diff --git a/spec/fixtures/files/les_communs_access_token.jwt b/spec/fixtures/files/les_communs_access_token.jwt new file mode 100644 index 0000000000..bb901ebbea --- /dev/null +++ b/spec/fixtures/files/les_communs_access_token.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJKVjg1bVRtUmh1MGtSeGNNb0FGUFl5azJMbS1WTExYV25HOG9HbUxNUkowIn0.eyJleHAiOjE3NDk3ODkyMzYsImlhdCI6MTc0OTc4NzQzNiwiYXV0aF90aW1lIjoxNzQ5Nzg3NDMzLCJqdGkiOiJmM2Q2ZGNmMi1lNGMwLTQyNzItODQzNC00NWFhZDczOTllYzUiLCJpc3MiOiJodHRwczovL2xvZ2luLmxlc2NvbW11bnMub3JnL2F1dGgvcmVhbG1zL2RhdGEtZm9vZC1jb25zb3J0aXVtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjVmZjNhZjc4LTk0YTItNGI1Yi04ZGFkLWE3YzFkZWE4ODE2YSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImNvb3BjaXJjdWl0cyIsIm5vbmNlIjoiMTZkNmM3OGZkNTcwOWRkMjVkNzNkYzYwMmViNDBiZGYiLCJzZXNzaW9uX3N0YXRlIjoiYmE4Y2M0ZWYtMDJmMC00ZjVmLWFiMWEtMDUyNGRiNGViNzI5IiwiYWxsb3dlZC1vcmlnaW5zIjpbIiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJzaWQiOiJiYThjYzRlZi0wMmYwLTRmNWYtYWIxYS0wNTI0ZGI0ZWI3MjkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJ0ZXN0IGRmYyIsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3RkZmNAcHJvdG9ubWFpbC5jb20iLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJ0ZXN0IiwiZmFtaWx5X25hbWUiOiJkZmMiLCJlbWFpbCI6InRlc3RkZmNAcHJvdG9ubWFpbC5jb20ifQ.NTuzVgy8es0GHKqGPmHgVaV8Kzz9uuFAiWgixLubfh8fl2OccFDxccNKyiTczj4wHD4jItdHPIxz-x9ZX2Ao7lwMFLno69KWjAK2eLpA8Fnu4stftlswfHqD0W-wzG0Cx24H6jXZbsM5tm1FYgQYwrlZ-uqwQOabN_cA_cTrwHmMTNVCjwCisScq7Np7r1me-4YEABmTGR362_eJVn2bRppG_7s12yjEAH_mcTyALXqlXNaF0XihDCxjmK8ybJiGy6_QwhEJci6EWqJ-w9H6ckheq94xTM5WpanQ4-ZHEm2TZlq2MOfMBVsknhwnGI0b-GbtSJrs7urWsopQyWSuhw From 9be27842e1264e4db15923821f38a39bdc71355e Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 13 Jun 2025 15:14:54 +1000 Subject: [PATCH 03/11] Accepts tokens from Startin'Blox OIDC server The API controllers don't know the new type of user yet and will raise errors but we can work on that bit by bit. --- engines/dfc_provider/app/services/api_user.rb | 20 +++++++++ .../app/services/authorization_control.rb | 42 ++++++++++++++----- .../services/authorization_control_spec.rb | 8 ++++ .../files/startinblox_access_token.jwt | 1 + 4 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 engines/dfc_provider/app/services/api_user.rb create mode 100644 spec/fixtures/files/startinblox_access_token.jwt diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb new file mode 100644 index 0000000000..4b9ca93220 --- /dev/null +++ b/engines/dfc_provider/app/services/api_user.rb @@ -0,0 +1,20 @@ +# 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 +end diff --git a/engines/dfc_provider/app/services/authorization_control.rb b/engines/dfc_provider/app/services/authorization_control.rb index a68db2dc6e..ffcef33982 100644 --- a/engines/dfc_provider/app/services/authorization_control.rb +++ b/engines/dfc_provider/app/services/authorization_control.rb @@ -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 diff --git a/engines/dfc_provider/spec/services/authorization_control_spec.rb b/engines/dfc_provider/spec/services/authorization_control_spec.rb index 310acedbcf..4eca3fba91 100644 --- a/engines/dfc_provider/spec/services/authorization_control_spec.rb +++ b/engines/dfc_provider/spec/services/authorization_control_spec.rb @@ -17,6 +17,14 @@ RSpec.describe AuthorizationControl do 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) diff --git a/spec/fixtures/files/startinblox_access_token.jwt b/spec/fixtures/files/startinblox_access_token.jwt new file mode 100644 index 0000000000..ddca0faafd --- /dev/null +++ b/spec/fixtures/files/startinblox_access_token.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRRlJES1daQ2hzMllQYnRqYl9yQUtwQzNzMFo0T2FCMEtFQ056NnlxWHQ0In0.eyJleHAiOjE3NDk3ODk3MDcsImlhdCI6MTc0OTc4OTQwNywianRpIjoiOWE4ODU4NDAtODhjNy00OTliLWIyOGUtMmE5ZmViM2EyNmU0IiwiaXNzIjoiaHR0cHM6Ly9rYy5jcWNtLnN0YXJ0aW5ibG94LmNvbS9yZWFsbXMvc3RhcnRpbmJsb3giLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNWZlM2MyNmMtMjczNi00OGE0LWI2Y2YtYTllM2JjZmNkZjAwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly93YXRlcmxvb3JlZ2lvbmZvb2QuY2EvcG9ydGFsL3Byb2ZpbGUiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtc3RhcnRpbmJsb3giXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6IlJlYWRFbnRlcnByaXNlIFJlYWRQcm9kdWN0cyIsImNsaWVudEhvc3QiOiIxNzIuMTguMC4xIiwiY2xpZW50QWRkcmVzcyI6IjE3Mi4xOC4wLjEiLCJjbGllbnRfaWQiOiJodHRwczovL3dhdGVybG9vcmVnaW9uZm9vZC5jYS9wb3J0YWwvcHJvZmlsZSJ9.Ln7wY0_ptRAza7M8w3yXU02TvluH028uaoJ5VHiN9-PnakokzHve7SCuSd1hvVikYAivWFIBRP97vwfpb_DW-d9Afk_XcQqcA0L36ynUIZ69X5uQ2zakEW0kB6pwqd8AL8tlWVUg2PixBXJ6daJcgWNF7RlKXg6wgy4JYL_VxD3VJjST911-z4_TMuQ2OC-3SJNwNv3BspSmUXm7F6y8xGFN7wuCPjU90WIiZ_vxTbVdM0zNtBM0uMJFeFv2_ZzoJIIiNHYLWtD3LrKcXePLSejpo-DPVWR_lGdDdM7BmzOHPKZ9KMaV-oa3lYNYC5shhJOpoB3vHngtdYdv8jq7Cg From c12d494de30bf1222e8af5ebba09b82e230eeff7 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 7 Aug 2025 13:51:43 +1000 Subject: [PATCH 04/11] Demonstrate authentication as DFC client app --- engines/dfc_provider/app/services/api_user.rb | 4 ++++ .../spec/requests/catalog_items_spec.rb | 16 ++++++++++++++++ swagger/dfc.yaml | 2 ++ 3 files changed, 22 insertions(+) diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb index 4b9ca93220..4902c05ccc 100644 --- a/engines/dfc_provider/app/services/api_user.rb +++ b/engines/dfc_provider/app/services/api_user.rb @@ -17,4 +17,8 @@ class ApiUser def initialize(id) @id = id end + + def enterprises + Enterprise.none + end end diff --git a/engines/dfc_provider/spec/requests/catalog_items_spec.rb b/engines/dfc_provider/spec/requests/catalog_items_spec.rb index ac8b091c9a..ac387c7214 100644 --- a/engines/dfc_provider/spec/requests/catalog_items_spec.rb +++ b/engines/dfc_provider/spec/requests/catalog_items_spec.rb @@ -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,23 @@ 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 + let(:enterprise_id) { 10_000 } + let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } + let(:Authorization) { "Bearer #{sib_token}" } + + before { login_as nil } + + around do |example| + Timecop.travel(Date.parse("2025-06-13")) { example.run } + end + + run_test! + end + context "without enterprises" do let(:enterprise_id) { "default" } diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index ea664ea867..edd2f6db59 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -137,6 +137,8 @@ paths: type: string get: summary: List CatalogItems + security: + - oidc_token: [] tags: - CatalogItems responses: From b16e541a81fa50c80d8fefd5ca5933b889263e10 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 7 Aug 2025 15:19:07 +1000 Subject: [PATCH 05/11] Show DFC catalog to authorised platform --- app/models/enterprise.rb | 1 + engines/dfc_provider/app/services/api_user.rb | 3 ++- .../spec/requests/catalog_items_spec.rb | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/models/enterprise.rb b/app/models/enterprise.rb index a1c5061a9a..aefc96f9c5 100644 --- a/app/models/enterprise.rb +++ b/app/models/enterprise.rb @@ -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 diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb index 4902c05ccc..50afc792bd 100644 --- a/engines/dfc_provider/app/services/api_user.rb +++ b/engines/dfc_provider/app/services/api_user.rb @@ -19,6 +19,7 @@ class ApiUser end def enterprises - Enterprise.none + permissions = DfcPermission.where(grantee: id, scope: "ReadProducts") + Enterprise.where(dfc_permissions: permissions) end end diff --git a/engines/dfc_provider/spec/requests/catalog_items_spec.rb b/engines/dfc_provider/spec/requests/catalog_items_spec.rb index ac387c7214..c3f4c889cf 100644 --- a/engines/dfc_provider/spec/requests/catalog_items_spec.rb +++ b/engines/dfc_provider/spec/requests/catalog_items_spec.rb @@ -69,6 +69,26 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do response "200", "success" do before { product } + context "as platform user" do + let(:enterprise_id) { 10_000 } + let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } + let(:Authorization) { "Bearer #{sib_token}" } + + before { + login_as nil + DfcPermission.create!( + user:, enterprise_id:, + scope: "ReadProducts", grantee: "cqcm-dev", + ) + } + + around do |example| + Timecop.travel(Date.parse("2025-06-13")) { example.run } + end + + run_test! + end + context "with default enterprise id" do let(:enterprise_id) { "default" } From 82d0e1bf680cbc9db542e5e65497d23fd43b92cf Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 7 Aug 2025 16:05:08 +1000 Subject: [PATCH 06/11] Show enterprise to authorised platform user --- .../dfc_provider/application_controller.rb | 6 ++++++ .../dfc_provider/catalog_items_controller.rb | 2 ++ engines/dfc_provider/app/services/api_user.rb | 7 +++++-- .../spec/requests/catalog_items_spec.rb | 4 ++++ .../spec/requests/enterprises_spec.rb | 21 +++++++++++++++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb index d403ba8958..8b5a239eb1 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb @@ -16,6 +16,12 @@ module DfcProvider private + def require_permission(scope) + return true if current_user.is_a? Spree::User + + current_user.permissions(scope).where(enterprise: current_enterprise).exists? + end + def check_authorization unauthorized if current_user.nil? end diff --git a/engines/dfc_provider/app/controllers/dfc_provider/catalog_items_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/catalog_items_controller.rb index 1fe2a0bde2..15705dc217 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/catalog_items_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/catalog_items_controller.rb @@ -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 diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb index 50afc792bd..82f671e88d 100644 --- a/engines/dfc_provider/app/services/api_user.rb +++ b/engines/dfc_provider/app/services/api_user.rb @@ -19,7 +19,10 @@ class ApiUser end def enterprises - permissions = DfcPermission.where(grantee: id, scope: "ReadProducts") - Enterprise.where(dfc_permissions: permissions) + Enterprise.where(dfc_permissions: permissions("ReadEnterprise")) + end + + def permissions(scope) + DfcPermission.where(grantee: id, scope:) end end diff --git a/engines/dfc_provider/spec/requests/catalog_items_spec.rb b/engines/dfc_provider/spec/requests/catalog_items_spec.rb index c3f4c889cf..c35917c151 100644 --- a/engines/dfc_provider/spec/requests/catalog_items_spec.rb +++ b/engines/dfc_provider/spec/requests/catalog_items_spec.rb @@ -76,6 +76,10 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do before { login_as nil + DfcPermission.create!( + user:, enterprise_id:, + scope: "ReadEnterprise", grantee: "cqcm-dev", + ) DfcPermission.create!( user:, enterprise_id:, scope: "ReadProducts", grantee: "cqcm-dev", diff --git a/engines/dfc_provider/spec/requests/enterprises_spec.rb b/engines/dfc_provider/spec/requests/enterprises_spec.rb index 331626e46e..ce77208509 100644 --- a/engines/dfc_provider/spec/requests/enterprises_spec.rb +++ b/engines/dfc_provider/spec/requests/enterprises_spec.rb @@ -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,26 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do produces "application/json" response "200", "successful" do + context "as platform user" do + let(:id) { 10_000 } + let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } + let(:Authorization) { "Bearer #{sib_token}" } + + before { + login_as nil + DfcPermission.create!( + user:, enterprise_id: id, + scope: "ReadEnterprise", grantee: "cqcm-dev", + ) + } + + around do |example| + Timecop.travel(Date.parse("2025-06-13")) { example.run } + end + + run_test! + end + context "without enterprise id" do let(:id) { "default" } From c9e829456103cdabadf27a8700e8df1b26aef10a Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 7 Aug 2025 16:33:44 +1000 Subject: [PATCH 07/11] DRY with shared context --- .../spec/requests/catalog_items_spec.rb | 19 +++---------------- .../spec/requests/enterprises_spec.rb | 9 ++------- .../authenticated_as_platform.rb | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 23 deletions(-) create mode 100644 engines/dfc_provider/spec/support/shared_contexts/authenticated_as_platform.rb diff --git a/engines/dfc_provider/spec/requests/catalog_items_spec.rb b/engines/dfc_provider/spec/requests/catalog_items_spec.rb index c35917c151..d7d8f58330 100644 --- a/engines/dfc_provider/spec/requests/catalog_items_spec.rb +++ b/engines/dfc_provider/spec/requests/catalog_items_spec.rb @@ -40,16 +40,8 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do response "404", "not found" do context "as platform user" do + include_context "authenticated as platform" let(:enterprise_id) { 10_000 } - let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } - let(:Authorization) { "Bearer #{sib_token}" } - - before { login_as nil } - - around do |example| - Timecop.travel(Date.parse("2025-06-13")) { example.run } - end - run_test! end @@ -70,12 +62,11 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do before { product } context "as platform user" do + include_context "authenticated as platform" + let(:enterprise_id) { 10_000 } - let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } - let(:Authorization) { "Bearer #{sib_token}" } before { - login_as nil DfcPermission.create!( user:, enterprise_id:, scope: "ReadEnterprise", grantee: "cqcm-dev", @@ -86,10 +77,6 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do ) } - around do |example| - Timecop.travel(Date.parse("2025-06-13")) { example.run } - end - run_test! end diff --git a/engines/dfc_provider/spec/requests/enterprises_spec.rb b/engines/dfc_provider/spec/requests/enterprises_spec.rb index ce77208509..d5b1dc0a7a 100644 --- a/engines/dfc_provider/spec/requests/enterprises_spec.rb +++ b/engines/dfc_provider/spec/requests/enterprises_spec.rb @@ -53,22 +53,17 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do response "200", "successful" do context "as platform user" do + include_context "authenticated as platform" + let(:id) { 10_000 } - let(:sib_token) { file_fixture("startinblox_access_token.jwt").read } - let(:Authorization) { "Bearer #{sib_token}" } before { - login_as nil DfcPermission.create!( user:, enterprise_id: id, scope: "ReadEnterprise", grantee: "cqcm-dev", ) } - around do |example| - Timecop.travel(Date.parse("2025-06-13")) { example.run } - end - run_test! end diff --git a/engines/dfc_provider/spec/support/shared_contexts/authenticated_as_platform.rb b/engines/dfc_provider/spec/support/shared_contexts/authenticated_as_platform.rb new file mode 100644 index 0000000000..caa69e36f7 --- /dev/null +++ b/engines/dfc_provider/spec/support/shared_contexts/authenticated_as_platform.rb @@ -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 From 6814ef43f451e5ad92b62ba2202837343661a0d2 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 7 Aug 2025 16:48:08 +1000 Subject: [PATCH 08/11] Show addresses to platform users --- .../dfc_provider/addresses_controller.rb | 10 ++++++++-- engines/dfc_provider/app/services/api_user.rb | 4 ++++ engines/dfc_provider/spec/services/api_user_spec.rb | 13 +++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 engines/dfc_provider/spec/services/api_user_spec.rb diff --git a/engines/dfc_provider/app/controllers/dfc_provider/addresses_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/addresses_controller.rb index 04c04f0bae..b85778c75d 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/addresses_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/addresses_controller.rb @@ -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) diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb index 82f671e88d..6f02e102ec 100644 --- a/engines/dfc_provider/app/services/api_user.rb +++ b/engines/dfc_provider/app/services/api_user.rb @@ -18,6 +18,10 @@ class ApiUser @id = id end + def customers + Customer.none + end + def enterprises Enterprise.where(dfc_permissions: permissions("ReadEnterprise")) end diff --git a/engines/dfc_provider/spec/services/api_user_spec.rb b/engines/dfc_provider/spec/services/api_user_spec.rb new file mode 100644 index 0000000000..adc7fe12a6 --- /dev/null +++ b/engines/dfc_provider/spec/services/api_user_spec.rb @@ -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 From 1d2115766a803b10b3fc84f795b41aaae1ae983f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Aug 2025 12:01:10 +1000 Subject: [PATCH 09/11] Show product groups to platform user I removed the caching of `managed_enterprises` in Permissions because it's just a scope and calling it again is very cheap. And that makes the method a lot easier to read now that we have a conditional here. Accessing the managed enterprises via the user instead of a separate scope on the Enterprise model also reduce the SQL queries. We may want to use this method in more places. I prefer to keep the admin-conditional in a permissions class instead of in the model. --- engines/dfc_provider/app/services/api_user.rb | 4 ++++ .../spec/requests/product_groups_spec.rb | 19 +++++++++++++++++++ lib/open_food_network/permissions.rb | 4 +++- .../admin/order_cycles_controller_spec.rb | 2 +- swagger/dfc.yaml | 2 ++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/engines/dfc_provider/app/services/api_user.rb b/engines/dfc_provider/app/services/api_user.rb index 6f02e102ec..85fe7461e1 100644 --- a/engines/dfc_provider/app/services/api_user.rb +++ b/engines/dfc_provider/app/services/api_user.rb @@ -18,6 +18,10 @@ class ApiUser @id = id end + def admin? + false + end + def customers Customer.none end diff --git a/engines/dfc_provider/spec/requests/product_groups_spec.rb b/engines/dfc_provider/spec/requests/product_groups_spec.rb index 215bfb3733..9520acab2c 100644 --- a/engines/dfc_provider/spec/requests/product_groups_spec.rb +++ b/engines/dfc_provider/spec/requests/product_groups_spec.rb @@ -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" diff --git a/lib/open_food_network/permissions.rb b/lib/open_food_network/permissions.rb index 87f43f052f..b3239d1b72 100644 --- a/lib/open_food_network/permissions.rb +++ b/lib/open_food_network/permissions.rb @@ -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 diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index fc69bfa60b..ff1e405510 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -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, diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index edd2f6db59..a6a630b9f1 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -678,6 +678,8 @@ paths: type: string get: summary: Show ProductGroup + security: + - oidc_token: [] tags: - ProductGroups responses: From 81b1169e7747a34e9b335a8e3864e841b00ae4eb Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 14 Aug 2025 11:16:53 +1000 Subject: [PATCH 10/11] Configure undercover to exclude files --- .simplecov | 11 +++++++++-- .undercover | 7 ++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.simplecov b/.simplecov index 8f18990a6a..5fb909a8ce 100755 --- a/.simplecov +++ b/.simplecov @@ -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 diff --git a/.undercover b/.undercover index 9b7d04fdb9..8cf3ae706d 100644 --- a/.undercover +++ b/.undercover @@ -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/*" From 6e489d7770b6c43a31e7eccd307be4b087b289a6 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 22 Aug 2025 16:46:59 +1000 Subject: [PATCH 11/11] Enforce required DFC permissions --- .../dfc_provider/application_controller.rb | 8 ++++-- .../spec/requests/catalog_items_spec.rb | 26 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb index 8b5a239eb1..d88823b2f2 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/application_controller.rb @@ -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 @@ -17,9 +20,10 @@ module DfcProvider private def require_permission(scope) - return true if current_user.is_a? Spree::User + return if current_user.is_a? Spree::User + return if current_user.permissions(scope).where(enterprise: current_enterprise).exists? - current_user.permissions(scope).where(enterprise: current_enterprise).exists? + raise Unauthorized end def check_authorization diff --git a/engines/dfc_provider/spec/requests/catalog_items_spec.rb b/engines/dfc_provider/spec/requests/catalog_items_spec.rb index d7d8f58330..d037fa7faa 100644 --- a/engines/dfc_provider/spec/requests/catalog_items_spec.rb +++ b/engines/dfc_provider/spec/requests/catalog_items_spec.rb @@ -102,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