diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index 2a22f56ea9..99895e1e79 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 415082f9d9..087801bd42 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,6 +42,17 @@ final class AdminPresentationSpeakerSerializer extends PresentationSpeakerSerial ]; protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ + 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 + $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()); + } return $values; } @@ -81,15 +93,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 b8ed477e26..b3e48b59bb 100644 --- a/app/Security/SummitScopes.php +++ b/app/Security/SummitScopes.php @@ -82,6 +82,9 @@ 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'; const WriteTrackQuestionTemplateData = SCOPE_BASE_REALM.'/track-question-templates/write'; const WriteMySpeakersData = SCOPE_BASE_REALM.'/speakers/write/me'; diff --git a/app/Swagger/Security/SummitSpeakersAuthSchema.php b/app/Swagger/Security/SummitSpeakersAuthSchema.php index 0d50bc988d..83901d40d4 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 0000000000..f010d19175 --- /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 5fc5be1f02..11ced9f4b4 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 de32ab4773..6ed8526642 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 8df12223e9..78d26387ad 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 6dffa16b0c..4f709cd304 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();