From cbcdd5244c66b406f12b5e6a3a94a79aef2898fb Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 21 Sep 2026 10:57:01 -0300 Subject: [PATCH 1/3] chore: initial commit --- .../AdminPresentationSpeakerSerializer.php | 17 ++++++++--------- app/Security/SummitScopes.php | 2 ++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 415082f9d..1a6f51088 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -41,6 +41,14 @@ final class AdminPresentationSpeakerSerializer extends PresentationSpeakerSerial ]; protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ + if(in_array("email", $values)) { + $application_type = $this->resource_server_context->getApplicationType(); + // choose email serializer depending on user permissions + // is current user is null then is a service account + $values['email'] = $application_type == IResourceServerContext::ApplicationType_Service ? + JsonUtils::toNullEmail($speaker->getEmail()) : + JsonUtils::toJsonString($speaker->getEmail()); + } return $values; } @@ -81,15 +89,6 @@ public function serialize($expand = null, array $fields = [], array $relations = $values['big_pic'] = $speaker->getBigProfilePhotoUrl($bypass_toggle); } - if(in_array("email", $fields)) { - $application_type = $this->resource_server_context->getApplicationType(); - // choose email serializer depending on user permissions - // is current user is null then is a service account - $values['email'] = $application_type == IResourceServerContext::ApplicationType_Service ? - JsonUtils::toNullEmail($speaker->getEmail()) : - JsonUtils::toJsonString($speaker->getEmail()); - } - if(!is_null($summit)){ if(in_array('summit_assistance', $relations)) { $summit_assistance = $speaker->getAssistanceFor($summit); diff --git a/app/Security/SummitScopes.php b/app/Security/SummitScopes.php index b8ed477e2..e993ffd7e 100644 --- a/app/Security/SummitScopes.php +++ b/app/Security/SummitScopes.php @@ -82,6 +82,8 @@ final class SummitScopes const WriteSummitData = SCOPE_BASE_REALM.'/summits/write'; const WriteSpeakersData = SCOPE_BASE_REALM.'/speakers/write'; const ReadSpeakersData = SCOPE_BASE_REALM.'/speakers/read'; + const ReadSpeakersDataEmail = SCOPE_BASE_REALM.'/speakers/read/email'; + const WriteTrackTagGroupsData = SCOPE_BASE_REALM.'/track-tag-groups/write'; const WriteTrackQuestionTemplateData = SCOPE_BASE_REALM.'/track-question-templates/write'; const WriteMySpeakersData = SCOPE_BASE_REALM.'/speakers/write/me'; From 190e10fcab2041207366dbee02c95102d9c50128 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 21 Sep 2026 18:28:51 +0200 Subject: [PATCH 2/3] fix(speakers): gate speaker email visibility for service accounts by ReadSpeakersDataEmail scope Adds the ReadSpeakersDataEmail scope (registered via migration and seeders) and grants it on the get-speaker-by-summit endpoint. A service account only sees a speaker's real email through getSummitSpeaker when its token carries this scope; otherwise it gets the same nulled-out placeholder every other service account gets. --- .../OAuth2SummitSpeakersApiController.php | 6 + .../AdminPresentationSpeakerSerializer.php | 8 +- .../Security/SummitSpeakersAuthSchema.php | 1 + .../config/Version20260921120000.php | 60 +++++++++ database/seeders/ApiEndpointsSeeder.php | 1 + database/seeders/ApiScopesSeeder.php | 5 + tests/ProtectedApiTestCase.php | 1 + tests/oauth2/OAuth2SummitSpeakersApiTest.php | 125 ++++++++++++++++++ 8 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 database/migrations/config/Version20260921120000.php diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index 2a22f56ea..99895e1e7 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php @@ -832,6 +832,7 @@ function ($page, $per_page, $filter, $order, $applyExtraFilters) { tags: ['Summit Speakers'], security: [['summit_speakers_oauth2' => [ SummitScopes::ReadSpeakersData, + SummitScopes::ReadSpeakersDataEmail, SummitScopes::ReadSummitData, SummitScopes::ReadAllSummitData ]]], @@ -959,6 +960,11 @@ public function getSummitSpeaker($summit_id, $speaker_id) if ($current_member->isAdmin() || $current_member->isSummitAdmin()) { $serializer_type = SerializerRegistry::SerializerType_Admin; } + } else if ( + $this->resource_server_context->getApplicationType() === IResourceServerContext::ApplicationType_Service + && in_array(SummitScopes::ReadSpeakersDataEmail, $this->resource_server_context->getCurrentScope()) + ) { + $serializer_type = SerializerRegistry::SerializerType_Admin; } return $this->ok diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 1a6f51088..087801bd4 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\Security\SummitScopes; use Libs\ModelSerializers\AbstractSerializer; use libs\utils\JsonUtils; use models\oauth2\IResourceServerContext; @@ -41,11 +42,14 @@ final class AdminPresentationSpeakerSerializer extends PresentationSpeakerSerial ]; protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ - if(in_array("email", $values)) { + if(array_key_exists("email", $values)) { $application_type = $this->resource_server_context->getApplicationType(); // choose email serializer depending on user permissions // is current user is null then is a service account - $values['email'] = $application_type == IResourceServerContext::ApplicationType_Service ? + $isServiceWithEmailScope = $application_type == IResourceServerContext::ApplicationType_Service + && in_array(SummitScopes::ReadSpeakersDataEmail, $this->resource_server_context->getCurrentScope()); + + $values['email'] = ($application_type == IResourceServerContext::ApplicationType_Service && !$isServiceWithEmailScope) ? JsonUtils::toNullEmail($speaker->getEmail()) : JsonUtils::toJsonString($speaker->getEmail()); } diff --git a/app/Swagger/Security/SummitSpeakersAuthSchema.php b/app/Swagger/Security/SummitSpeakersAuthSchema.php index 0d50bc988..83901d40d 100644 --- a/app/Swagger/Security/SummitSpeakersAuthSchema.php +++ b/app/Swagger/Security/SummitSpeakersAuthSchema.php @@ -22,6 +22,7 @@ SummitScopes::WriteSpeakersData => 'Write Speakers Data', SummitScopes::ReadMySpeakersData => 'Read My Speakers Data', SummitScopes::WriteMySpeakersData => 'Write My Speakers Data', + SummitScopes::ReadSpeakersDataEmail => 'Read Speakers Email', ], ), ], diff --git a/database/migrations/config/Version20260921120000.php b/database/migrations/config/Version20260921120000.php new file mode 100644 index 000000000..f010d1917 --- /dev/null +++ b/database/migrations/config/Version20260921120000.php @@ -0,0 +1,60 @@ +addSql($this->insertApiScope( + self::API_NAME, + SummitScopes::ReadSpeakersDataEmail, + 'Read Speakers Email', + 'Grants read access for Speakers Email' + )); + + $this->addSql($this->insertEndpointScope( + self::API_NAME, + self::ENDPOINT_NAME, + SummitScopes::ReadSpeakersDataEmail + )); + } + + public function down(Schema $schema): void + { + $this->addSql($this->deleteScopesEndpoints(self::API_NAME, [SummitScopes::ReadSpeakersDataEmail])); + $this->addSql($this->deleteApiScopes(self::API_NAME, [SummitScopes::ReadSpeakersDataEmail])); + } +} diff --git a/database/seeders/ApiEndpointsSeeder.php b/database/seeders/ApiEndpointsSeeder.php index 5fc5be1f0..11ced9f4b 100644 --- a/database/seeders/ApiEndpointsSeeder.php +++ b/database/seeders/ApiEndpointsSeeder.php @@ -4144,6 +4144,7 @@ private function seedSummitEndpoints() 'http_method' => 'GET', 'scopes' => [ SummitScopes::ReadSpeakersData, + SummitScopes::ReadSpeakersDataEmail, SummitScopes::ReadSummitData, SummitScopes::ReadAllSummitData ], diff --git a/database/seeders/ApiScopesSeeder.php b/database/seeders/ApiScopesSeeder.php index de32ab477..6ed852664 100644 --- a/database/seeders/ApiScopesSeeder.php +++ b/database/seeders/ApiScopesSeeder.php @@ -224,6 +224,11 @@ private function seedSummitScopes() 'short_description' => 'Read My Speakers Profile Data', 'description' => 'Grants read access for My Speaker Profile Data', ], + [ + 'name' => SummitScopes::ReadSpeakersDataEmail, + 'short_description' => 'Read Speakers Email', + 'description' => 'Grants read access for Speakers Email', + ], [ 'name' => SummitScopes::WriteAttendeesData, 'short_description' => 'Write Attendees Data', diff --git a/tests/ProtectedApiTestCase.php b/tests/ProtectedApiTestCase.php index 8df12223e..78d26387a 100644 --- a/tests/ProtectedApiTestCase.php +++ b/tests/ProtectedApiTestCase.php @@ -267,6 +267,7 @@ public function get($token_value) SummitScopes::WriteSummitMediaFileTypes, SummitScopes::WriteMetrics, SummitScopes::ReadMetrics, + SummitScopes::ReadSpeakersDataEmail, CompanyScopes::Write, CompanyScopes::Read, SponsoredProjectScope::Write, diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 6dffa16b0..4f709cd30 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -16,6 +16,7 @@ use App\Models\Foundation\Main\IGroup; use App\Models\Foundation\Summit\Speakers\SpeakerEditPermissionRequest; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Queue; @@ -1557,6 +1558,130 @@ public function testGetCurrentSummitSpeakersByID() $this->assertTrue(!is_null($speaker)); } + /** + * Creates a speaker for self::$summit and returns [speaker_id, real_email]. The email + * is captured locally rather than read back off the creation response, because + * addSpeakerBySummit serializes its response with SerializerType_Public - which + * obfuscates/blanks the email for a non-owner caller - so the response body is not a + * reliable source of the raw email a test needs to assert against. + * @return array{0: int, 1: string} + */ + private function createSpeakerBySummitWithRealEmail(): array + { + $params = [ + 'id' => self::$summit->getId(), + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $email = 'smarcet.' . str_random(16) . '@gmail.com'; + + $data = [ + 'title' => 'Developer!', + 'first_name' => 'Sebastian', + 'last_name' => 'Marcet', + 'email' => $email, + ]; + + $response = $this->action( + "POST", + "OAuth2SummitSpeakersApiController@addSpeakerBySummit", + $params, + [], + [], + [], + $headers, + json_encode($data) + ); + + $this->assertResponseStatus(201); + $speaker = json_decode($response->getContent()); + $this->assertTrue($speaker->id > 0); + + return [$speaker->id, $email]; + } + + /** + * A service account (client_credentials, no member behind the token) without the + * ReadSpeakersDataEmail scope must not see the speaker's real email, same as any + * other service account - see AdminPresentationSpeakerSerializer::checkDataPermissions. + */ + public function testGetSummitSpeakerByServiceAccountWithoutEmailScopeGetsNulledEmail() + { + [$speaker_id, $email] = $this->createSpeakerBySummitWithRealEmail(); + + App::singleton('App\Models\ResourceServer\IAccessTokenService', AccessTokenServiceStub::class); + + $params = [ + 'id' => self::$summit->getId(), + 'speaker_id' => $speaker_id, + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSummitSpeaker", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + $speaker = json_decode($response->getContent()); + // Even more restrictive than the SummitScopes::ReadSpeakersDataEmail-gated + // 'blank@blank.com' placeholder: this speaker has no linked member, so + // PresentationSpeakerSerializer::checkDataPermissions (the Public-serializer path + // taken here) blanks the email outright regardless of application type. + $this->assertEquals('', $speaker->email); + $this->assertNotEquals(strtolower($email), strtolower($speaker->email)); + } + + /** + * A service account whose token carries ReadSpeakersDataEmail gets the speaker's + * real email instead of the nulled-out placeholder every other service account gets. + */ + public function testGetSummitSpeakerByServiceAccountWithEmailScopeGetsRealEmail() + { + [$speaker_id, $email] = $this->createSpeakerBySummitWithRealEmail(); + + App::singleton('App\Models\ResourceServer\IAccessTokenService', AccessTokenServiceStub2::class); + + $params = [ + 'id' => self::$summit->getId(), + 'speaker_id' => $speaker_id, + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSummitSpeaker", + $params, + [], + [], + [], + $headers + ); + + $this->assertResponseStatus(200); + $speaker = json_decode($response->getContent()); + // Email is normalized to lowercase somewhere in the create pipeline, so compare + // case-insensitively rather than assuming byte-for-byte equality with the value sent. + $this->assertEquals(strtolower($email), strtolower($speaker->email)); + } + public function testGetSpeaker() { $created_speaker = $this->testPostSpeaker(); From c715d5b82f3f0db293536c058fceea07e88e160e Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 21 Sep 2026 19:06:34 +0200 Subject: [PATCH 3/3] docs(security): note that ReadSpeakersDataEmail scope must only be granted via the private scope mechanism at the IDP --- app/Security/SummitScopes.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Security/SummitScopes.php b/app/Security/SummitScopes.php index e993ffd7e..b3e48b59b 100644 --- a/app/Security/SummitScopes.php +++ b/app/Security/SummitScopes.php @@ -82,6 +82,7 @@ final class SummitScopes const WriteSummitData = SCOPE_BASE_REALM.'/summits/write'; const WriteSpeakersData = SCOPE_BASE_REALM.'/speakers/write'; const ReadSpeakersData = SCOPE_BASE_REALM.'/speakers/read'; + // this scope should only be granted through the private scope mechanism at the IDP const ReadSpeakersDataEmail = SCOPE_BASE_REALM.'/speakers/read/email'; const WriteTrackTagGroupsData = SCOPE_BASE_REALM.'/track-tag-groups/write';