Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
]]],
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* limitations under the License.
**/

use App\Security\SummitScopes;
use Libs\ModelSerializers\AbstractSerializer;
use libs\utils\JsonUtils;
use models\oauth2\IResourceServerContext;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions app/Security/SummitScopes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
romanetar marked this conversation as resolved.

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';
Expand Down
1 change: 1 addition & 0 deletions app/Swagger/Security/SummitSpeakersAuthSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
),
],
Expand Down
60 changes: 60 additions & 0 deletions database/migrations/config/Version20260921120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php namespace Database\Migrations\Config;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Security\SummitScopes;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Register the ReadSpeakersDataEmail scope and grant it on the existing
* get-speaker-by-summit endpoint (GET /api/v1/summits/{id}/speakers/{speaker_id}),
* so a service account can request speaker email data without the endpoint's other
* scopes changing.
*
* Idempotent via WHERE NOT EXISTS in APIEndpointsMigrationHelper.
*/
final class Version20260921120000 extends AbstractMigration
{
use APIEndpointsMigrationHelper;

private const API_NAME = 'summits';
private const ENDPOINT_NAME = 'get-speaker-by-summit';

public function getDescription(): string
{
return 'Register ReadSpeakersDataEmail scope and grant it on get-speaker-by-summit endpoint.';
}

public function up(Schema $schema): void
{
$this->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]));
}
}
1 change: 1 addition & 0 deletions database/seeders/ApiEndpointsSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4144,6 +4144,7 @@ private function seedSummitEndpoints()
'http_method' => 'GET',
'scopes' => [
SummitScopes::ReadSpeakersData,
SummitScopes::ReadSpeakersDataEmail,
SummitScopes::ReadSummitData,
SummitScopes::ReadAllSummitData
],
Expand Down
5 changes: 5 additions & 0 deletions database/seeders/ApiScopesSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions tests/ProtectedApiTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ public function get($token_value)
SummitScopes::WriteSummitMediaFileTypes,
SummitScopes::WriteMetrics,
SummitScopes::ReadMetrics,
SummitScopes::ReadSpeakersDataEmail,
CompanyScopes::Write,
CompanyScopes::Read,
SponsoredProjectScope::Write,
Expand Down
125 changes: 125 additions & 0 deletions tests/oauth2/OAuth2SummitSpeakersApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading