Skip to content

Commit 4cda54a

Browse files
committed
fix: review feedback
Signed-off-by: romanetar <roman_ag@hotmail.com>
1 parent 4391757 commit 4cda54a

8 files changed

Lines changed: 215 additions & 26 deletions

File tree

app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ protected function addChild(Summit $summit, array $payload): IEntity
126126
if (is_null($current_member))
127127
throw new HTTP403ForbiddenException();
128128

129+
if (!$current_member->hasSponsorMembershipsFor($summit)) {
130+
throw new HTTP403ForbiddenException('Only sponsor users can add badge scans.');
131+
}
132+
129133
return $this->service->addBadgeScan($summit, $current_member, $payload);
130134
}
131135

app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,19 @@ public function handle(SponsorServicesMQJob $job): void
6060
Log::debug("UpdateSponsorMemberGroupsMQJob::handle payload {$json}");
6161

6262
$data = $payload['data'];
63+
if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) {
64+
throw new ValidationException('Invalid payload: user_external_id, group_slug, sponsor_id and summit_id are required.');
65+
}
66+
6367
$user_external_id = intval($data['user_external_id']);
6468
$group_slug = $data['group_slug'];
6569
$sponsor_id = intval($data['sponsor_id']);
6670
$summit_id = intval($data['summit_id']);
6771

72+
if ($user_external_id <= 0 || $sponsor_id <= 0 || $summit_id <= 0 || trim((string)$group_slug) === '') {
73+
throw new ValidationException('Invalid payload: identifiers must be positive and group_slug must be non-empty.');
74+
}
75+
6876
if ($event_type === EventTypes::AUTH_USER_ADDED_TO_GROUP) {
6977
$this->service->addSponsorUserToGroup($user_external_id, $group_slug, $sponsor_id, $summit_id);
7078
} else if ($event_type === EventTypes::AUTH_USER_REMOVED_FROM_GROUP) {
@@ -85,4 +93,4 @@ public function failed(array $data, Throwable $exception): void
8593
{
8694
Log::error("UpdateSponsorMemberGroupsMQJob::failed {$exception->getMessage()}");
8795
}
88-
}
96+
}

app/Models/Foundation/Main/Member.php

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Models\Foundation\Main\IGroup;
2020
use App\Models\Foundation\Main\Strategies\MemberSummitStrategyFactory;
2121
use App\Models\Foundation\Summit\Events\RSVP\RSVPInvitation;
22+
use Doctrine\DBAL\Exception;
2223
use Doctrine\ORM\Query\ResultSetMappingBuilder;
2324
use Illuminate\Support\Facades\Config;
2425
use Doctrine\DBAL\ParameterType;
@@ -1860,21 +1861,20 @@ public function getActiveSummitsSponsorMemberships()
18601861
return [];
18611862
}
18621863

1863-
// Step 2 — load each Sponsor by PK. find() uses a different code path that avoids
1864-
// the ORM 3 assertion failure triggered by the OneToOne inverse associations on Sponsor
1865-
// (lead_report_setting, sponsorservices_statistics) when using DQL/native query hydration.
1866-
$sponsors = [];
1867-
foreach ($ids as $id) {
1868-
$sponsor = $this->getEM()->find(\models\summit\Sponsor::class, $id);
1869-
if ($sponsor !== null) {
1870-
$sponsors[] = $sponsor;
1871-
}
1872-
}
1864+
// Step 2 — load all sponsors in a single IN query. findBy() uses PK-based hydration
1865+
// which avoids the ORM 3 assertion failure triggered by the OneToOne inverse associations
1866+
// on Sponsor (lead_report_setting, sponsorservices_statistics) that DQL/native-query
1867+
// hydration hits. The result set is then re-sorted to match the SQL ORDER BY.
1868+
$position = array_flip($ids);
1869+
$sponsors = EntityManager::getRepository(Sponsor::class)->findBy(['id' => $ids]);
1870+
usort($sponsors, fn($a, $b) => $position[$a->getId()] <=> $position[$b->getId()]);
18731871
return $sponsors;
18741872
}
18751873

18761874
/**
1875+
* @param Summit $summit
18771876
* @return array
1877+
* @throws Exception
18781878
*/
18791879
public function getSponsorMembershipIds(Summit $summit): array
18801880
{
@@ -1902,8 +1902,9 @@ public function getSponsorMembershipIds(Summit $summit): array
19021902
public function hasSponsorMembershipsFor(Summit $summit, Sponsor $sponsor = null): bool
19031903
{
19041904
try {
1905-
$canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser();
1906-
if(!$canHaveSponsorMemberships) return false;
1905+
$canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser();
1906+
if(!$canHaveSponsorMemberships) return false;
1907+
19071908
$sql = <<<SQL
19081909
SELECT COUNT(Sponsor_Users.SponsorID)
19091910
FROM Sponsor_Users
@@ -3466,9 +3467,23 @@ public function getIndividualMemberJoinDate(): ?\DateTime
34663467
* Appends $group_slug to the Permissions JSON array on the Sponsor_Users row
34673468
* for this member and the given sponsor. Idempotent: the slug is only added
34683469
* when it is not already present.
3470+
*
3471+
* An exclusive row lock (SELECT … FOR UPDATE) is acquired first so that
3472+
* concurrent jobs for the same (member, sponsor, slug) serialize here and
3473+
* the second job always reads the post-first-job value, preventing duplicates.
3474+
*
3475+
* Returns the number of rows matched by the WHERE clause (0 when the
3476+
* Sponsor_Users row does not yet exist, 1 when it does).
34693477
*/
3470-
public function addSponsorPermission(int $sponsor_id, string $group_slug): void
3478+
public function addSponsorPermission(int $sponsor_id, string $group_slug): int
34713479
{
3480+
// Lock the row before the read-modify-write so concurrent transactions
3481+
// serialize and the IF(JSON_CONTAINS) in the UPDATE sees the committed state.
3482+
$this->prepareRawSQL(
3483+
'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE',
3484+
['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()]
3485+
)->executeQuery();
3486+
34723487
$sql = <<<SQL
34733488
UPDATE Sponsor_Users
34743489
SET Permissions = IF(
@@ -3478,45 +3493,65 @@ public function addSponsorPermission(int $sponsor_id, string $group_slug): void
34783493
)
34793494
WHERE SponsorID = :sponsor_id AND MemberID = :member_id
34803495
SQL;
3481-
$stmt = $this->prepareRawSQL($sql, [
3496+
return $this->prepareRawSQL($sql, [
34823497
'group_slug' => $group_slug,
34833498
'sponsor_id' => $sponsor_id,
34843499
'member_id' => $this->getId(),
3485-
]);
3486-
$stmt->executeStatement();
3500+
])->executeStatement();
34873501
}
34883502

34893503
/**
34903504
* Removes $group_slug from the Permissions JSON array on the Sponsor_Users row
34913505
* for this member and the given sponsor, then returns how many other Sponsor_Users
34923506
* rows for this member still carry that slug. The caller uses the count to decide
34933507
* whether to also revoke the global group membership.
3508+
*
3509+
* An exclusive row lock is acquired first so the remove UPDATE and the
3510+
* remaining-count SELECT are not interleaved with concurrent operations.
3511+
* All occurrences of the slug are removed (via JSON_ARRAYAGG filter) to
3512+
* prevent stale entries if a prior race introduced duplicates.
34943513
*/
34953514
public function removeSponsorPermission(int $sponsor_id, string $group_slug): int
34963515
{
3516+
// Serialize concurrent removals for the same row.
3517+
$this->prepareRawSQL(
3518+
'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE',
3519+
['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()]
3520+
)->executeQuery();
3521+
3522+
// Remove ALL occurrences (not just the first) so duplicate slugs
3523+
// introduced by any prior race cannot leave stale entries behind.
34973524
$removeSQL = <<<SQL
34983525
UPDATE Sponsor_Users
3499-
SET Permissions = JSON_REMOVE(Permissions, JSON_UNQUOTE(JSON_SEARCH(Permissions, 'one', :group_slug)))
3526+
SET Permissions = COALESCE(
3527+
(
3528+
SELECT JSON_ARRAYAGG(element)
3529+
FROM JSON_TABLE(
3530+
COALESCE(Permissions, '[]'), '$[*]'
3531+
COLUMNS(element VARCHAR(255) PATH '$')
3532+
) AS jt
3533+
WHERE element != :group_slug
3534+
),
3535+
'[]'
3536+
)
35003537
WHERE SponsorID = :sponsor_id AND MemberID = :member_id
35013538
AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35023539
SQL;
3503-
$stmt = $this->prepareRawSQL($removeSQL, [
3540+
$this->prepareRawSQL($removeSQL, [
35043541
'group_slug' => $group_slug,
35053542
'sponsor_id' => $sponsor_id,
35063543
'member_id' => $this->getId(),
3507-
]);
3508-
$stmt->executeStatement();
3544+
])->executeStatement();
35093545

35103546
$countSQL = <<<SQL
35113547
SELECT COUNT(*) FROM Sponsor_Users
35123548
WHERE MemberID = :member_id
35133549
AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35143550
SQL;
3515-
$stmt = $this->prepareRawSQL($countSQL, [
3551+
return intval($this->prepareRawSQL($countSQL, [
35163552
'member_id' => $this->getId(),
35173553
'group_slug' => $group_slug,
3518-
]);
3519-
return intval($stmt->executeQuery()->fetchOne());
3554+
])->executeQuery()->fetchOne());
35203555
}
35213556

35223557
public function addSponsorMembership(Sponsor $sponsor):void

app/Services/Model/Imp/SponsorUserInfoGrantService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data
193193
throw new ValidationException("Current member does not belong to the selected summit sponsor.");
194194
}
195195

196+
if(!$current_member->hasSponsorMembershipsFor($summit, $sponsor))
197+
throw new ValidationException("Current member does not have badge scan permissions for the selected sponsor.");
198+
196199
$scan = new SponsorBadgeScan();
197200
$scan->setScanDate($scan_date);
198201
$scan->setQRCode($qr_code);

app/Services/Model/Imp/SponsorUserSyncService.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,23 @@ public function addSponsorUserToGroup(int $user_id, string $group_slug, int $spo
154154
}
155155

156156
// Add permission entry to the Sponsor_Users JSON column for this sponsor-member pair.
157-
$member->addSponsorPermission($sponsor_id, $group_slug);
157+
// If the row does not exist yet (MQ ordering race: group event arrived before membership
158+
// event), create it eagerly so the permission is never silently dropped.
159+
if ($member->addSponsorPermission($sponsor_id, $group_slug) === 0) {
160+
Log::warning(
161+
"SponsorUserSyncService::addSponsorUserToGroup no Sponsor_Users row found for " .
162+
"member {$member->getId()} / sponsor {$sponsor_id} — creating it eagerly");
163+
164+
$summit = $this->summit_repository->getById($summit_id);
165+
if (!$summit instanceof Summit) {
166+
throw new EntityNotFoundException("Summit {$summit_id} not found");
167+
}
168+
169+
$this->summit_sponsor_service->addSponsorUser($summit, $sponsor_id, $member->getId());
170+
171+
// Retry now that the row exists.
172+
$member->addSponsorPermission($sponsor_id, $group_slug);
173+
}
158174

159175
// Add to global group only if not already a member.
160176
if (!$member->belongsToGroup($group_slug)) {

app/Services/Model/Imp/SummitSponsorService.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,9 @@ public function addSponsorUser(Summit $summit, int $sponsor_id, int $member_id):
394394
// due a member could be on 2 diff places at same time ...
395395
// (StartA <= EndB) and (EndA >= StartB)
396396

397-
if ($current_summit_begin_date <= $former_summit_end_date && $current_summit_end_date >= $former_summit_begin_date) {
397+
if ($summit->getId() != $former_summit->getId() &&
398+
$current_summit_begin_date <= $former_summit_end_date &&
399+
$current_summit_end_date >= $former_summit_begin_date) {
398400
throw new ValidationException
399401
(
400402
sprintf
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php namespace Database\Migrations\Model;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Doctrine\DBAL\Schema\Schema;
16+
use Doctrine\Migrations\AbstractMigration;
17+
18+
/**
19+
* Class Version20260408120000
20+
* @package Database\Migrations\Model
21+
*
22+
* Backfills the Permissions JSON column on Sponsor_Users with the group codes
23+
* (Group.Code) of every group the member belongs to, derived from Group_Members.
24+
* Only rows where Permissions IS NULL are updated (i.e. rows that pre-date the
25+
* per-sponsor permission tracking feature added in Version20260402153110).
26+
*/
27+
final class Version20260408102410 extends AbstractMigration
28+
{
29+
public function getDescription(): string
30+
{
31+
return 'Backfill Sponsor_Users.Permissions JSON from existing Group_Members rows.';
32+
}
33+
34+
public function up(Schema $schema): void
35+
{
36+
// For each Sponsor_Users row whose Permissions have not been set yet,
37+
// aggregate all Group.Code values for the member and store them as a
38+
// JSON array. JSON_ARRAYAGG returns NULL when there are no matching
39+
// groups, which is a valid (empty-permissions) state.
40+
$this->addSql(<<<SQL
41+
UPDATE Sponsor_Users su
42+
SET su.Permissions = (
43+
SELECT JSON_ARRAYAGG(g.Code)
44+
FROM Group_Members gm
45+
INNER JOIN `Group` g ON g.ID = gm.GroupID
46+
WHERE gm.MemberID = su.MemberID
47+
)
48+
WHERE su.Permissions IS NULL
49+
SQL
50+
);
51+
}
52+
53+
public function down(Schema $schema): void
54+
{
55+
// Revert the backfill by clearing all Permissions values. This is the
56+
// only safe inverse: we cannot distinguish backfilled values from those
57+
// written by the application after the migration ran.
58+
$this->addSql("UPDATE Sponsor_Users SET Permissions = NULL");
59+
}
60+
}

tests/oauth2/OAuth2SummitBadgeScanApiControllerTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ protected function setUp():void
4949
$this->sponsor_group->setCode(IGroup::Sponsors);
5050
$this->sponsor_group->setTitle(IGroup::Sponsors);
5151
self::$em->persist($this->sponsor_group);
52+
53+
// Pre-wire self::$member as a permissioned sponsor user for sponsors[0] so that
54+
// tests which exercise the happy path do not need per-test boilerplate.
55+
self::$member->add2Group($this->sponsor_group);
56+
$sponsor0 = self::$sponsors[0];
57+
$sponsor0->addUser(self::$member);
58+
self::$em->persist(self::$member);
59+
self::$em->persist($sponsor0);
60+
self::$em->flush();
61+
62+
// Write IGroup::Sponsors into Sponsor_Users.Permissions so hasSponsorMembershipsFor passes.
63+
self::$member->addSponsorPermission($sponsor0->getId(), IGroup::Sponsors);
5264
}
5365

5466
protected function tearDown():void
@@ -60,6 +72,7 @@ protected function tearDown():void
6072
public function testAddEncryptedBadgeScan(){
6173
// set test data
6274
self::$member->clearGroups();
75+
self::$member->add2Group($this->sponsor_group);
6376
self::$member->add2Group($this->external_sponsor_group);
6477
self::$em->persist(self::$member);
6578
self::$em->flush();
@@ -69,6 +82,8 @@ public function testAddEncryptedBadgeScan(){
6982
self::$em->persist($sponsor);
7083
self::$em->flush();
7184

85+
self::$member->addSponsorPermission($sponsor->getId(), IGroup::SponsorExternalUsers);
86+
7287
$attendee = self::$summit->getAttendees()[0];
7388

7489
self::$summit->setQRCodesEncKey('35NVOF4I5T6AAM28IJPKB8KRUW98KPDO');
@@ -259,6 +274,52 @@ public function testAddBadgeScanMissingQrCodeAndEmailFails(){
259274
$this->assertResponseStatus(412);
260275
}
261276

277+
public function testAddBadgeScanFailsWhenSponsorHasNoPermissionSlug()
278+
{
279+
// member2 is in the global sponsors group so hasSponsorMembershipsFor doesn't
280+
// short-circuit, but the Sponsor_Users row has no permission slug
281+
// (simulates the MQ group event never arriving).
282+
self::$member2->add2Group($this->sponsor_group);
283+
$sponsor = self::$sponsors[0];
284+
$sponsor->addUser(self::$member2);
285+
self::$em->persist(self::$member2);
286+
self::$em->persist($sponsor);
287+
self::$em->flush();
288+
// Sponsor_Users row was created with Permissions = NULL — deliberately no addSponsorPermission call.
289+
290+
// Impersonate member2 for this request.
291+
self::$service->setUserId(self::$member2->getUserExternalId());
292+
self::$service->setUserExternalId(self::$member2->getUserExternalId());
293+
self::$service->setUserEmail(self::$member2->getEmail());
294+
self::$service->setUserFirstName(self::$member2->getFirstName());
295+
self::$service->setUserLastName(self::$member2->getLastName());
296+
297+
$params = [
298+
'id' => self::$summit->getId(),
299+
];
300+
301+
$attendee = self::$summit->getAttendeeByMemberId(self::$defaultMember->getId());
302+
$badge = $attendee->getFirstTicket()->getBadge();
303+
304+
$data = [
305+
'qr_code' => $badge->generateQRCode(),
306+
'scan_date' => 1572019200,
307+
];
308+
309+
$this->action(
310+
"POST",
311+
"OAuth2SummitBadgeScanApiController@add",
312+
$params,
313+
[],
314+
[],
315+
[],
316+
$this->getAuthHeaders(),
317+
json_encode($data)
318+
);
319+
320+
$this->assertResponseStatus(412);
321+
}
322+
262323
public function testAddBadgeScanByUnknownAttendeeEmailFails(){
263324
self::$member->clearGroups();
264325
self::$member->add2Group($this->sponsor_group);

0 commit comments

Comments
 (0)