-
Notifications
You must be signed in to change notification settings - Fork 2
fix: gate public presentation serialization on display_on_site for media uploads #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,26 @@ protected function getMediaUploadsSerializerType():string{ | |
| return $serializerType; | ||
| } | ||
|
|
||
| /** | ||
| * Media uploads visible to the resolved serializer type. A Public caller (no admin/editor | ||
| * privilege on this presentation) only ever sees uploads marked display_on_site=true — an | ||
| * uploaded-but-not-yet-approved draft (display_on_site=false, the model's own default) must | ||
| * never reach an unauthenticated/public response, even via ?expand=media_uploads on the | ||
| * public events/published endpoints. | ||
| * @return \Doctrine\Common\Collections\Collection|PresentationMediaUpload[] | ||
| */ | ||
| protected function getVisibleMediaUploads() | ||
| { | ||
| $presentation = $this->object; | ||
| $mediaUploads = $presentation->getMediaUploads(); | ||
| if ($this->getMediaUploadsSerializerType() === SerializerRegistry::SerializerType_Private) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JpMaxMan Delegating to Compare Concrete failure, and it is circular: an operator in Suggested: align
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Concrete patch for this, since it is two files and easy to get subtly wrong. 1) const ReadSummitData = SCOPE_BASE_REALM.'/summits/read';
const ReadAllSummitData = SCOPE_BASE_REALM.'/summits/read/all';
const ReadOverflowEvents = SCOPE_BASE_REALM.'/summits/events/overflow/read';
+ const ReadAllPresentationMediaUploads = SCOPE_BASE_REALM.'/summits/presentations/media-uploads/read/all';2) +use App\Security\SummitScopes;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Libs\ModelSerializers\AbstractSerializer;
+use models\oauth2\IResourceServerContext;
use models\summit\Presentation;
use models\summit\PresentationType;3) Same file - replace protected function getMediaUploadsSerializerType(): string
{
$currentUser = $this->resource_server_context->getCurrentUser();
$presentation = $this->object;
// Service account (client_credentials => no current user) holding the dedicated
// snapshot scope. The content pipeline needs unapproved uploads in order to stage
// files pre-event. Narrowed by scope on purpose: ApplicationType_Service on its own
// would hand drafts to every service client.
$isSnapshotClient =
$this->resource_server_context->getApplicationType() === IResourceServerContext::ApplicationType_Service
&& in_array(
SummitScopes::ReadAllPresentationMediaUploads,
$this->resource_server_context->getCurrentScope()
);
if ($isSnapshotClient)
return SerializerRegistry::SerializerType_Private;
if (!is_null($currentUser)
&& ($currentUser->isAdmin()
|| $currentUser->isSummitAdmin() // <- show admin operators, currently missing
|| $presentation->memberCanEdit($currentUser))) {
return SerializerRegistry::SerializerType_Private;
}
return SerializerRegistry::SerializerType_Public;
}Notes on the shape:
Out-of-code step, easy to forget: the new scope has to be provisioned on the content-snapshot client in the IDP, and added to pub-api's Worth a test per branch on this method: anonymous, attendee, speaker on the presentation, summit admin, service account with the scope, service account without it. Six cases, all cheap, and they pin the exact behaviour this PR depends on.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up on the scope: it needs a seeder entry and a migration, and the answer to "which endpoint does it attach to" is none. Worth spelling out because the obvious precedent points the wrong way. Why no endpoint association. That is the difference from Two registries, only one of which is load-bearing.
1) [
'name' => SummitScopes::ReadAllPresentationMediaUploads,
'short_description' => 'Read All Presentation Media Uploads',
'description' => 'Grants read access to presentation media uploads regardless of display_on_site, for trusted service accounts feeding the content pipeline',
],2) <?php namespace Database\Migrations\Config;
use App\Security\SummitScopes;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Register the ReadAllPresentationMediaUploads scope.
*
* Adds 1 api_scopes row and NO endpoint association, on purpose: endpoint scopes are
* validated with array_intersect (any-of, see OAuth2BearerAccessTokenRequestValidator:193),
* so associating this scope with an existing endpoint would admit a token holding only
* this scope. The scope is read directly off the token by
* PresentationSerializer::getMediaUploadsSerializerType(), which never consults
* endpoint_api_scopes.
*
* Idempotent via WHERE NOT EXISTS.
*/
final class Version20260804120000 extends AbstractMigration
{
use APIEndpointsMigrationHelper;
private const API_NAME = 'summits';
public function getDescription(): string
{
return 'Register ReadAllPresentationMediaUploads scope (no endpoint association).';
}
public function up(Schema $schema): void
{
$this->addSql($this->insertApiScope(
self::API_NAME,
SummitScopes::ReadAllPresentationMediaUploads,
'Read All Presentation Media Uploads',
'Grants read access to presentation media uploads regardless of display_on_site, for trusted service accounts feeding the content pipeline'
));
}
public function down(Schema $schema): void
{
$this->addSql($this->deleteApiScopes(self::API_NAME, [SummitScopes::ReadAllPresentationMediaUploads]));
}
}
Rollout order matters: IDP scope + client grant first, then pub-api's |
||
| return $mediaUploads; | ||
| } | ||
| return $mediaUploads->filter(function ($mediaUpload) { | ||
| return $mediaUpload->getDisplayOnSite(); | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * @param null $expand | ||
|
|
@@ -130,7 +150,7 @@ public function serialize($expand = null, array $fields = [], array $relations = | |
| { | ||
| $media_uploads = []; | ||
|
|
||
| foreach ($presentation->getMediaUploads() as $mediaUpload) { | ||
| foreach ($this->getVisibleMediaUploads() as $mediaUpload) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Expect private/editor callers to bypass this cache, or cache keys to include serializer type.
rg -n -C 12 'PresentationSerializer|public_presentation_|use_cache' .Repository: OpenStackweb/summit-api Length of output: 50380 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Inspect the relevant PresentationSerializer paths and all passing of use_cache.
printf '--- PresentationSerializer relevant lines ---\n'
sed -n '1,240p;400,430p' app/ModelSerializers/Summit/Presentation/PresentationSerializer.php | cat -n
printf '\n--- use_cache usages (concise) ---\n'
rg -n --glob '!adr/**' 'use_cache|SerializerRegistry::getInstance\(\)->getSerializer\(|serialize\(' app tests tests-api || trueRepository: OpenStackweb/summit-api Length of output: 252 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Inspect the relevant PresentationSerializer paths and all passing of use_cache.
printf '%s\n' '--- PresentationSerializer relevant lines ---'
sed -n '1,240p;400,430p' app/ModelSerializers/Summit/Presentation/PresentationSerializer.php | cat -n
printf '%s\n' ''
printf '%s\n' '--- use_cache usages (concise) ---'
rg -n --glob '!adr/**' 'use_cache|SerializerRegistry::getInstance\(\)->getSerializer\(|serialize\(' app tests tests-api || trueRepository: OpenStackweb/summit-api Length of output: 50380 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- exact use_cache references outside tests ---'
rg -n --glob '!adr/**' --glob '!tests/**' 'use_cache' app
printf '%s\n' ''
printf '%s\n' '--- exact use_cache references in tests ---'
rg -n 'use_cache' tests || true
printf '%s\n' ''
printf '%s\n' '--- parent cache key helpers/usages ---'
rg -n --glob '!adr/**' 'build.*[Cc]ache[Kk]ey|get.*Cache.*Key|Cache::put|Cache::has' app/ModelSerializers | head -n 120Repository: OpenStackweb/summit-api Length of output: 1247 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- OAuth2SummitEventsApiController cache calls context ---'
sed -n '520,700p' app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php | cat -n
printf '%s\n' ''
printf '%s\n' '--- route/controller declarations around protected events ---'
rg -n --glob '!adr/**' --glob '!tests/**' 'OAuth2SummitEventsApiController|Protected/Summit|summit/events|/presentations' app/routes app/Http/Controllers/Apis app/Http/Controllers -g '*.php' | head -n 200Repository: OpenStackweb/summit-api Length of output: 40762 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- SerializerRegistry resolved serializer and toArray ---'
sed -n '750,835p' app/ModelSerializers/SerializerRegistry.php | cat -n
printf '%s\n' ''
printf '%s\n' '--- OAuth2SummitEventsApiController serializer type logic ---'
sed -n '86,150p' app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php | cat -nRepository: OpenStackweb/summit-api Length of output: 7021 Keep private presentation cache keys isolated. Private/admin endpoints pass Also applies to: 215-219 🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JpMaxMan Pre-existing rather than introduced here, but it undercuts this PR's premise, so flagging it on the branch you are touching: the cache key is not privilege-scoped. It is built from presentation id, Recomputing Suggested: add the resolved serializer type to the key. Worth its own ticket rather than expanding this PR's scope. |
||
| $media_uploads[] = SerializerRegistry::getInstance()->getSerializer | ||
| ( | ||
| $mediaUpload, $this->getMediaUploadsSerializerType() | ||
|
|
@@ -195,7 +215,7 @@ public function serialize($expand = null, array $fields = [], array $relations = | |
| if(in_array('media_uploads', $relations)) | ||
| { | ||
| $media_uploads = []; | ||
| foreach ($presentation->getMediaUploads() as $mediaUpload) { | ||
| foreach ($this->getVisibleMediaUploads() as $mediaUpload) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JpMaxMan This call site never executes when caching is on, so the The cached branch above returns at line 170, and on a cache hit it only recomputes
Suggested: handle the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Supporting evidence: track-chairs exercises this path in production. |
||
| $media_uploads[] = $mediaUpload->getId(); | ||
| } | ||
|
|
||
|
|
@@ -337,7 +357,7 @@ public function serialize($expand = null, array $fields = [], array $relations = | |
| case 'media_uploads':{ | ||
| $media_uploads = []; | ||
|
|
||
| foreach ($presentation->getMediaUploads() as $mediaUpload) { | ||
| foreach ($this->getVisibleMediaUploads() as $mediaUpload) { | ||
| $media_uploads[] = SerializerRegistry::getInstance()->getSerializer | ||
| ( | ||
| $mediaUpload, $this->getMediaUploadsSerializerType() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@JpMaxMan This filter is an off switch rather than a draft gate, because
display_on_siteis false on essentially every existing row.Nothing in the submission flow ever sets it true.
PresentationMaterial::__construct(PresentationMaterial.php:190) defaults it false,PresentationMediaUploadFactory::populate(:39-40) only callssetDisplayOnSitewhen the key is present in the payload, and call-for-presentations - the app speakers actually upload through - has zero occurrences ofdisplay_on_siteinsrc/. The only writer is summit-admin's material form checkbox, which itself starts unchecked (event-material-reducer.js:30).Concrete failure: pub-api's snapshot job requests
expand=media_uploadswith a client_credentials token, which resolves Public here, so everymedia_uploadsarray inevents.jsonandpresentations.jsoncomes back empty. pub-api does not validate response shape - it only raises on transport errors - so the snapshot publishes normally andSnapshotCompletedfires. dropbox-materializer then iteratessess["media_uploads"]with no filter of its own and stages nothing, for every session, silently.Suggested: get a production count first -
and gate this behind a backfill plus a defined write path for the flag. Also worth a test pinning both branches - the PR's own test plan is still unchecked, and this is exactly the case where behaviour depends entirely on data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update: the counts are in, and they change this recommendation rather than just confirming it.
Global split for
PresentationMediaUpload: 707 rows atDisplayOnSite = 1, 4514 at 0. Per summit, every show since 63 is at exactly zero approved - summit 71, the one in the report and currently live, is 0 visible of 303 uploads across 166 published presentations. The 707 are concentrated in 63 (316), 31 (189) and 12 (169), where the flag was genuinely maintained: summit 63 has 282 hidden uploads but only 18 presentations that lose everything, which is what a working approval flow looks like. So the filter behaves exactly as designed where the practice was kept, and the practice stopped after 63.I previously suggested gating this behind a backfill. Withdrawing that. There is nothing to preserve:
DocumentsComponent.js:23filters the same flag client-side, so event-site already renders zero media uploads for summit 71 and every recent show. Those files have always been in the payload and have never been displayed. This is by design, and documented - the internal JSON-contract doc listsmedia_uploadsshippingdisplay_on_siteandpublic_urltogether, with the filtering deliberately placed in the client.A blanket backfill is also wrong in both directions: on 12/31/63 it would publish material somebody deliberately left unapproved, and on the shows at zero the flag means "never triaged" rather than "reviewed and rejected", so approval cannot be inferred from it.
Net effect on this PR: no backfill needed, and no user-visible change on event-site. The remaining blockers are the serializer-type alignment and the dropbox-materializer feed, both noted in the other threads.