Skip to content

Deploying fixes to stage - #339

Merged
jaydevb merged 20 commits into
stagefrom
develop
Aug 26, 2026
Merged

jaydevb merged 20 commits into
stagefrom
develop

Conversation

@jaydevb

@jaydevb jaydevb commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

jaydevb and others added 20 commits August 25, 2026 12:13
Every role named in a menu link's show_role was stored correctly, but the
target routes denied most of them, so the links never rendered: global_admin
was dead on seven links, sme on five, and a plain authenticated user saw
nothing at all.

Grant the missing permissions instead of trimming the menu, so the approved
spec and the effective access finally agree:

  global_admin  administer languages, administer users, create country group
  reviewer      administer users, create media
  se            administer users
  sme           accept translation jobs, the 15 create-content permissions
                editor and se already hold
  translator    the same 15 create-content permissions

Widen the two report views to match the links that point at them:
country_reports admits authenticated, users_reports admits se.

Give Manage Reports the authenticated and sme roles its children already
grant. menu_per_role hides a parent whose show_role omits a role, taking the
whole subtree with it, so a parent must always cover its children.

Drop the Copy keywords link: its route demands administer site configuration,
and handing that to global_admin and translator would open every admin config
form on the site.

Roles carry no per-site split override, so these files apply to all seven
sites. Menu links are content entities and do not move on config import --
each environment needs drush bebbo:menu-sync after cim.
…page

A user restricted to a set of languages could not open the translation
they were assigned. Clicking Edit on their own language returned Access
denied, while the same form opened fine once a language prefix was added
to the URL.

ContentTranslationAccessCheck read the {target} route parameter, which
only the add route carries. On the edit and delete routes {target} is
empty, so the check fell back to the current content language and tested
the language of the page instead of the language of the translation.

That cut both ways. Under /en it denied every language, including the
ones the user was assigned. Under an assigned language prefix it allowed
every language, including ones the user was never assigned, so a
translator browsing /ro-ro could open the Bulgarian or Slovak
translation.

Read {language} when the route supplies it and fall back to {target}
otherwise, so the add route keeps its existing behaviour and the edit and
delete routes finally test the translation they are acting on.

Verified on all seven sites with real accounts: editing an assigned
language is allowed, editing an unassigned one is forbidden, and adding a
translation is unchanged. On Pakistan no account is assigned an installed
language, so the fix shows there as en allowed and ur forbidden for an
en-assigned user, which the old code would have allowed.

Still open and not addressed here: core forbids updating the default
translation, so a per-language original still offers no Edit link without
node update access, and the sme role holds translate any entity but not
update content translations, which the overview controller requires
before it will render an Edit link at all.
fix(allowed_languages): check the language being translated, not the page
Every top-level entry that owns children becomes an unclickable, expanded
group, so the menu reads as eleven sections rather than a flat list of
links that happened to have children hanging off them.

Eleven top-level entries, twenty-seven children, thirty-eight links in
total. Dashboard and Redirect Management keep their paths because nothing
sits under them; the other nine parents move to route:<nolink> with
expanded set, and their old destinations survive on a child.

Manage Media, View Taxonomies, User List and Add user were top-level and
are now children of Media, Taxonomies and Users. The taxonomy feed links
flatten into Taxonomies alongside View Taxonomies. Content overview is
new. Manage Translation Jobs becomes Translation Overview and moves from
/admin/tmgmt/job_items to /admin/tmgmt/jobs, which is a real route
(view.tmgmt_job_overview.page_1) despite an earlier note claiming
otherwise. Google Analytics is gone; the module is not installed and its
path never resolved.

Every parent carries the union of its own roles and its children's,
because menu_per_role hides a parent whose show_role omits a role and
takes the subtree with it. That adds administrator to Content,
Translation and Taxonomies, reviewer to Users, and authenticated plus sme
to Reports.

Surviving links keep their UUIDs so bebbo:menu-sync updates in place
instead of deleting and recreating; only the four genuinely new entries
get fresh ones.

Verified on bebbo: cim clean, menu-sync reports 4 created, 31 updated,
1 deleted, and a second run reports no changes at all. A per-role tree
build finds no role named in a show_role that cannot reach its link.

/admin/tmgmt is the one path that leaves the menu entirely, since the
Translations parent no longer carries it and no child covers it.
…ruth

Two mechanisms could write the editorial menu and only one was
authoritative, which is an easy way to lose a menu: bebbo:menu-sync
applies bebbo_custom_general.editorial_menu, while menu_export offered an
admin screen that could export or import the same links from its own
snapshot.

menu_export.export_data was already deleted, so the module had nothing
left to import from, but the module and its settings were still installed
and still pointed at editorial-menu. Uninstall it and drop
menu_export.settings so the admin path disappears with it.

The package stays in composer.json for now, patch included, so the module
remains on disk and can be reinstated by re-adding it to core.extension.

Also drop update hooks 10002 and 10003 from bebbo_custom_general. Both
read menu_export.export_data and neither can run again: every local site
is at schema 10004 or 10005, and stage and prod both report no database
updates required. There is no hook_install, so a fresh install starts
above them too. Hook 10001 is unrelated and stays.

Verified: cim uninstalls the module cleanly on all seven local sites and
the editorial menu is untouched by its removal.
…orts

Seven editorial report paths were readable without logging in. Confirmed
against stage: /keyword-link-to-content, /keyword-notlink-to-content and
/taxonomy-export all returned 200 to an anonymous request, as did
/keyword-term-english, /keyword-link-to-content-group and /keyword-og,
which are not in the menu at all. /taxonomy-data-export went further and
started a batch export job for an unauthenticated caller.

Both views granted access by the 'access content' permission, which the
anonymous role holds, and every display inherits that default, so a
handful of menu links dragged four more paths into public view with them.

Switch both views to role access matching what the menu already claims:
administrator, editor and se. The menu grants Taxonomy Report and the two
keyword links to editor and se only, so global_admin and reviewer lose
access here by design.

Verified on all seven sites: every one of the 29 linked editorial menu
paths is now denied to anonymous, the four unlinked paths with it, while
editor and se keep access.

The three country-site config_split patches for keyword_term_count touch
filters only and never access, so the fix survives on every site.
Mailer and Email TFA settings were reachable only by full administrators.
Delegating them meant handing out an admin account, and the TFA email step
makes recovery circular: when Office365 mail dies, the only people who can
fix the mailer are the ones locked out by the OTP it can no longer send.

mfa_admin holds administer mailer plus administer email tfa and nothing
else, and is listed in email_tfa ignore_role so its holders log in without
the OTP step — a way back in when mail is down.

Scoped to the mailer and TFA settings pages only. The config translation
tab is not included: core's translate configuration permission is global
and would grant translating every config object on the site.
Role tables and the Email TFA sections still described nine roles and an
empty ignore_role, so the documented recovery path was "there isn't one".
The category vocabulary in the taxonomies endpoints returned
field_type_of_article as the referenced term's localized label, so clients
had to match on "Article for birth to 6 years" and again on every
translation of it. Every sibling key in the same payload is already a
machine name.

The type_of_article vocabulary carries no fields at all, so there is no
field_unique_name to read. The value is derived from the English label with
the same slug rules the endpoints already apply to terms whose
field_unique_name is empty, which is why those rules move to the shared
trait rather than gaining a third and fourth copy.

The label JOINs now prefer English over the requested language, since the
machine name must not change with langcode.

Breaking for clients reading the label. V1 and V2 change together to keep
the two payloads identical.
The category term shape still showed a localized label, which reads as if
the value tracks {lang}.
Non-English /api/articles and /v2/api/articles cold renders took 3-5x
longer than English and hit the request time limit on stage. Two core
behaviours combined:

- EntityRepository::getTranslationFromContext() runs the full language
  fallback negotiation whenever the loaded entity's active language differs
  from the requested one, even when that translation exists. Nodes load with
  their original language active, so every non-English field render made
  content_translation walk all ~24 translations with access checks.
- The entity_reference_entity_id formatter loads every referenced entity
  (hundreds of nodes and terms per listing) just to print its ID, and runs
  the same fallback for each target.

Row entities are now switched to the requested translation before Views
renders them, and the ID-only reference fields use a formatter that reads
target_id without loading the target. Output is unchanged; measured on the
ru listing: CPU 41.5s -> 6.8s, peak memory 2.0GB -> 0.56GB.
The per-node fragment cache no longer earns its keep: Views renders every
row of the result on the first field access, so one stale fragment cost a
full cold render anyway, and the response-level tag stack (bebbo_api_tag
views cache plugin + per-bundle, per-language listing tags fired from
hook_node_update) together with the contrib warmer now cover both caching
and invalidation.

Rows on the article displays still render in an isolated render context so
per-entity node tags never reach the response: acquia_purge turns response
tags into CDN Surrogate-Keys and hundreds of them overflow the header.

Removes RowFragmentCache, its kernel test, the bebbo_api_fragment bin and
its database-backend pin, and the fragment-only language tags.
The v1 API listings render in 13+ minutes cold on the default site, so
the caches have to be filled before the app asks for them. The contrib
warmer could not do it: its queue drained 60 seconds a day against a
list of nearly a thousand URLs, and its URL lists were 967 hand-written
config lines that went stale whenever a language changed.

Derive the list instead. Every v1 path that takes a langcode, crossed
with the languages each site serves, plus one update check per country
group. The paths are shared because all four v1 views live in
config/sync and are identical everywhere; the languages and group IDs
come from the site being warmed.

bebbo:warm-all is what a single Acquia scheduled job calls. It walks the
sites in turn, spawning one bootstrapped subprocess per site with that
site's public hostname as --uri: a Drush process can only bootstrap one
site, and the warm URLs resolve against --uri, so passing the site
directory would build http://bangladesh/api/... and resolve nowhere. A
lock refreshed at each site boundary keeps two passes from fighting over
the same PHP-FPM workers, and expires in an hour so a killed pass does
not block the next one for the length of a whole pass.

Pass and fail are judged on the JSON envelope, not the HTTP status: the
v1 API answers 200 OK with {"status": 403} when it refuses a request, so
an HTTP-only check would record a warm cache that never happened.

Runs land in bebbo_warmer_log on the site that was warmed, whether they
came from the scheduled job or the button on the form, with start and
finish times, duration, status and the URLs that failed.
bebbo:warm-all replaces it. Keeping both would mean two warmers racing
for the same PHP-FPM workers against two different URL lists, one of
them the 967 hand-written config lines this deletes.

Only the configuration goes here. The modules have to be uninstalled by
config import on each environment while their code is still on disk, so
drupal/warmer stays in composer.json until this has been deployed
everywhere; removing the package first would leave cim unable to
uninstall anything.

Deleted with it: the per-site URL lists in all six splits, their
complete_list entries, and the ultimate_cron job that enqueued the
warmer every two hours. That job was disabled anyway, and its queue
drained sixty seconds a day, which is why the caches were never warm.
PHPCS reads .install through phpcs.xml.dist, which the ad-hoc
--standard=Drupal,DrupalPractice invocation does not, so this slipped
past the checks run before the commit.
@jaydevb
jaydevb merged commit 4fbe6df into stage Aug 26, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant