Skip to content

Allow anonymous read access to already-public content (CONTENTBOX-1516) - #696

Merged
lmajano merged 12 commits into
developmentfrom
claude/content-box-setup-routing-4tto6x
Sep 25, 2026
Merged

lmajano merged 12 commits into
developmentfrom
claude/content-box-setup-routing-4tto6x

Conversation

@lmajano

@lmajano lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Description

Currently the ContentBox API (/cbapi/v1) is entirely locked down — every endpoint requires a JWT, including read-only listing/viewing of content that is already publicly visible on the front-end website. This makes index/show on entries, pages, and contentStore needlessly require authentication for data anyone can already see by just visiting the site.

This PR makes those three "read" actions publicly reachable, matching content parity with the public front-end, while leaving everything else (write actions, sites, menus, categories, comments, authors, etc.) exactly as authenticated as before.

How it works

  • The real gate for anonymous traffic is the contentbox-api-v1 module's firewall rule (secureList covers every event, whitelist previously only excluded auth/echo). The whitelist regex is widened to also match entries.index, entries.show, pages.index, pages.show, contentStore.index, contentStore.show.
  • The per-action secured="..." annotations on those same six actions are removed — cbSecurity checks annotations in a second, independent pass after the rule pass, so both had to change for anonymous access to actually work.
  • index() was already safe for anonymous use: it exclusively goes through ContentService.findPublishedContent(), which filters to isPublished / publishedDate <= now / expireDate null-or-future — the same rule the public site uses.
  • show() was not safe as-is: it resolves by raw id/slug with no visibility filtering (it's built for editors previewing drafts). baseContentHandler.show() now additionally rejects (404, same EntityNotFound shape as an unknown id) any anonymous request for content that isn't published, is expired, or is password protected — so drafts can't be enumerated by id.
  • Write verbs (create/update/delete) map to different event names and keep their existing secured="..." annotations, so they remain fully gated. sites and menus keep their class-level secured="SITES_ADMIN" / "MENUS_ADMIN" and are untouched.

Jira Issues

https://ortussolutions.atlassian.net/browse/CONTENTBOX-1516

Type of change

  • Bug Fix
  • Improvement
  • New Feature
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist

  • My code follows the style guidelines of this project cfformat
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Test plan

  • Added tests/specs/contentbox-api/PublicContentAccessSpec.cfc: anonymous index/show on entries/pages/contentStore succeed and only return published data; a freshly created draft item's show 404s anonymously; anonymous writes on those resources are still rejected (401); sites/menus stay fully locked anonymously.
  • Ran the full tests.specs.contentbox-api bundle (125 specs) locally before and after this change: 122 pass / 3 fail in both runs, and the 3 failures are an identical, pre-existing CONTENTVERSIONS ORM error in the create stories, unrelated to this change (confirmed by stashing this diff and re-running).
  • Manual curl spot checks against a running instance: anonymous GET /cbapi/v1/sites/default/entries and .../entries/:slug → 200; anonymous POST /cbapi/v1/sites/default/entries → 401; anonymous GET /cbapi/v1/sites and .../menus → 401.

Generated by Claude Code

- Add server-boxlang@1.json (pure BoxLang engine, mirrors coldbox-platform's
  config) since only the boxlang-cfml compat variant existed before.
- box.json installPaths updated by `box install` for cbdebugger and
  route-visualizer devDependencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5
ContentBox's ORM event handler (modules/contentbox/models/system/EventHandler.cfc)
implements the legacy CFIDE.orm.IEventHandler interface. Pure BoxLang doesn't
ship that compatibility shim by default; without it, ORM's MappingGenerator
fails to compile the class during entity scanning and the whole app 500s
before ever reaching a route. bx-compat-cfml (already used by
server-boxlang-cfml@1.json) provides it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5
Pure BoxLang engine isn't the target for this environment; the
boxlang-cfml compat server (which already includes bx-compat-cfml) is
the one to use going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5
…TENTBOX-1516)

Widen the contentbox-api-v1 firewall whitelist and drop the per-action
secured="" annotations so that index/show on entries, pages, and
contentStore no longer require authentication, since those actions
already only return published content via findPublishedContent().

show() additionally now rejects (404) anonymous requests for content
that is not published, is expired, or is password protected, since it
resolves by raw id/slug with no visibility filtering otherwise. Every
other endpoint (sites, menus, categories, comments, authors, all write
verbs) remains fully authenticated.

Adds PublicContentAccessSpec.cfc covering anonymous index/show, the
draft-content 404 guard, write-verb rejection, and that admin-only
resources stay locked.
Copilot AI lite review requested due to automatic review settings September 24, 2026 13:10

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

CI on the first push (d986c66e0) showed failures across boxlang-cfml and Adobe CF jobs, all in the create stories of EntriesSpec/PagesSpec/ContentStoreSpec — none in anything this PR touches (index/show on those handlers, or the firewall/annotation changes). Investigated each:

  • All failures shared one root cause: BaseContent.cfc's addNewContentVersion() failing to find a CONTENTVERSIONS key on freshly-.new()'d entities (a KeyNotFoundException on BoxLang engines, the equivalent UndefinedElementException on Adobe CF 2023) — a relationship-collection-not-initialized bug, unrelated to auth/security. Confirmed pre-existing by stashing this PR's diff locally and reproducing the identical failure on unmodified development.
  • The Adobe CF 2023 job additionally hit a already-known, already-fixed issue: RestHandler.onEntityNotFoundException()'s MissingArgumentException on that engine (COLDBOX-1453), fixed by PR Fix entity not found handler for Adobe ColdFusion 2023 #695 which merged into development while this PR was in flight — my branch's base predated it.
  • The boxlang-cfml jobs showing green on one workflow run and red on a duplicate run of the same commit confirmed the underlying issue is non-deterministic (an initialization-order race), not something this diff introduces.

Action taken: merged origin/development into this branch (82ff6a739), which pulls in both the COLDBOX-1453 workaround and a same-day fix (5b4cc4e3c, "fix: bug on non initialized content") that initializes contentVersions and the other relationship collections in BaseContent.init() — resolving the root cause directly. Re-ran the full tests.specs.contentbox-api bundle locally against the merged branch: 125/125 passing, no regressions.

No code changes of my own were needed beyond the merge — waiting on CI to confirm green on 82ff6a739.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical access-control issues remain for inactive sites and authenticated users lacking content permissions.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity

Open (2)
What changed in this PR

This pull request enables anonymous reads of published entries, pages, and content store items while preserving authenticated writes and administrative access.

Changes:

  • Expands the API whitelist for public read actions.
  • Filters anonymous content access by publication visibility.
  • Adds access-control tests and development module paths.
File Summary
tests/​specs/​contentbox-api/​PublicContentAccessSpec.cfc Tests anonymous reads and protected operations.
modules/​contentbox/​modules/​contentbox-api/​modules/​contentbox-api-v1/​ModuleConfig.cfc Expands the API firewall whitelist.
modules/​contentbox/​modules/​contentbox-api/​modules/​contentbox-api-v1/​handlers/​pages.cfc Makes page reads publicly accessible.
modules/​contentbox/​modules/​contentbox-api/​modules/​contentbox-api-v1/​handlers/​entries.cfc Makes entry reads publicly accessible.
modules/​contentbox/​modules/​contentbox-api/​modules/​contentbox-api-v1/​handlers/​contentStore.cfc Makes content store reads publicly accessible.
modules/​contentbox/​modules/​contentbox-api/​modules/​contentbox-api-v1/​handlers/​baseContentHandler.cfc Adds anonymous visibility checks for content access.
box.json Adds development module installation paths.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Address Copilot findings on PR #696:

- An anonymous request could read a site's published content even when
  the site itself is inactive, since baseContentHandler's preHandler()
  never checked isActive (SiteService.discoverSite() already applies
  that rule for the front-end). preHandler() now 404s an inactive site
  for any caller without full content access, exactly like an unknown
  site slug.

- Removing entries/pages/contentStore's secured="" annotations meant
  any authenticated user, regardless of permission, skipped the
  visibility guard entirely and could read drafts/expired/password-
  protected content. show() now checks for ENTRIES_ADMIN/EDITOR-style
  permission (added as variables.contentType per handler) rather than
  merely being logged in.

These actions are whitelisted out of the JWT firewall entirely, so
prc.oCurrentAuthor is never populated for them by the framework;
hasFullContentAccess() parses the incoming token itself via
jwtAuth().parseToken() before checking permissions.

Adds regression coverage for an authenticated admin still being able
to view a draft. Also fixes three existing draft-content fixtures that
were wrapped in withRollback(): the simulated test request runs its
own ORM transaction and can't see an uncommitted row, so those tests
were passing because the row was invisible, not because the visibility
check ran. They now commit the fixture for real and clean up after.
…istency

Adobe ColdFusion doesn't apply an ORM property's `default` attribute to a
freshly new()'d entity the way Lucee/BoxLang do, so Site.getIsActive()
came back falsy on Adobe CF for sites created without an explicit
isActive key (e.g. the installer's default site fixture). This broke the
new preHandler() active-site check for anonymous API callers on Adobe CF
CI jobs only. Author.cfc already works around the same engine quirk by
setting variables.isActive in init(); Site.cfc now does the same.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

CI red on commit 69bba414 was real, not a flake: 9 failures, all on the Adobe ColdFusion jobs (adobe@2023, adobe@2025), all tracing to PublicContentAccessSpec.cfc via baseContentHandler.cfc:40 — No site found for ID/Slug default.

Root cause: Site.cfc's isActive property declares default="true", but on Adobe CF (unlike Lucee/BoxLang) that default attribute isn't applied to a freshly new()'d ORM entity until explicitly set. The installer creates the default test-fixture site without an explicit isActive key, so on Adobe CF it comes back falsy — and my new preHandler() active-site check (correctly) 404s it for anonymous callers. Author.cfc already works around this exact same engine quirk (variables.isActive = true; in init()); Site.cfc was missing that safeguard.

Fixed in 324684533 by adding the same explicit default to Site.cfc's init(). Verified locally: 126/126 contentbox-api specs + 114/118 contentbox-web specs (4 pre-existing skips) pass on BoxLang, including SitesSpec's explicit inactive-site test.


Generated by Claude Code

…flag

The "I want to edit a site" test PUT isActive:false onto the real
"default" site (used as a fixture by nearly every other API spec),
relying on withRollback() to undo it. That undo isn't reliable for an
UPDATE on every engine, so on Adobe CF the default site was being left
permanently inactive mid test-run, which the new preHandler() active-site
check then (correctly) enforced against every later anonymous request for
it. Edit a disposable site instead, same pattern the delete-site test
already uses, so the shared fixture is never at risk.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Found the actual root cause of the "No site found for ID/Slug default" flakiness — my previous fix (324684533) was necessary but not sufficient.

SitesSpec.cfc's "I want to edit a site" test does PUT /cbapi/v1/sites/default with isActive: false directly against the real default site (the shared fixture nearly every other API spec depends on), wrapped in withRollback(). withRollback() isn't reliable for undoing an UPDATE on every engine — on Adobe CF it was leaving the default site permanently marked inactive for the rest of that test run. Nothing downstream ever read isActive before, so this pre-existing test-isolation bug was invisible until my new preHandler() check started (correctly) enforcing it — explaining both the earlier consistent failure and the later run-to-run flakiness (whichever spec ran after SitesSpec in a given execution paid the price).

Fixed in 328f6182d: that test now edits a disposable throwaway site instead of the real "default" one, matching the pattern the adjacent delete-site test already uses.

Verified locally on BoxLang: SitesSpec → PublicContentAccessSpec back-to-back (126/126 full contentbox-api suite, 114/118 contentbox-web with 4 pre-existing skips) all pass.


Generated by Claude Code

…ORM directly

The finally block called siteService.delete() on the testSite reference
captured before the PUT ran. That PUT executed in a separate simulated
request with its own ORM session, leaving testSite stale, which made
Hibernate throw "An exception occurred when committing the transaction"
on Adobe CF when deleting it directly. The adjacent delete-site test
avoids this by going through the HTTP API instead of the ORM service;
do the same here for cleanup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Down to a single failure on 328f6182d (progress from 9 → 1): SitesSpec.cfc's new disposable-site edit test now fails only in its finally cleanup — siteService.delete(testSite) throws "An exception occurred when committing the transaction" on Adobe CF.

Cause: testSite was captured before the PUT, which runs as a separate simulated request with its own ORM session — so by the time finally runs, testSite is a stale entity. Calling siteService.delete() directly on a stale entity hits the exact Hibernate cascade quirk SiteService.delete() already comments on for Adobe. The sibling "delete a site" test in the same file avoids this by deleting through the HTTP API instead of the ORM service directly.

Fixed in e5676bc80 the same way: cleanup now goes through this.delete("/cbapi/v1/sites/..."). Verified locally: 126/126 contentbox-api specs pass, including SitesSpec → PublicContentAccessSpec back-to-back.


Generated by Claude Code

…s-77caf fixture

That test PUT new content onto "disk-queues-77caf" under withRollback(),
the same pattern already found unreliable for undoing an UPDATE on Adobe
CF (Site.cfc/SitesSpec). This spec bundle runs before
PublicContentAccessSpec, and on Adobe CF the fixture was coming out of
this test no longer publicly visible, breaking the new anonymous-show
visibility check for a slug used across the whole suite. Edit a
disposable entry instead, created and torn down through the API, so the
shared fixture is never at risk.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Root-caused the failure on e5676bc80 (100% reproducible on all Adobe CF jobs, 0% on Lucee/BoxLang — confirmed across two duplicate Adobe runs each): PublicContentAccessSpec's anonymous "show" test for disk-queues-77caf got a 404 instead of 200, meaning the fixture entry was no longer publicly visible.

EntriesSpec runs before PublicContentAccessSpec in bundle order, and its "edit an entry" test PUTs new content directly onto the real disk-queues-77caf fixture under withRollback() — the exact same pattern already found unreliable for undoing an UPDATE on Adobe CF (the Site.cfc/SitesSpec issues fixed earlier in this thread). On Adobe, that update wasn't being cleanly rolled back, leaving the shared fixture in a state my new anonymous-visibility check correctly rejected.

Fixed in 71f76de0f the same way as before: that test now edits a disposable entry (created and torn down through the API) instead of the shared fixture.

Verified locally: EntriesSpec → PublicContentAccessSpec back-to-back, plus the full 126/126 contentbox-api suite, all pass.


Generated by Claude Code

…onymous-show test

Even after isolating EntriesSpec's edit test (71f76de), the anonymous
"show a published entry" assertion still 404'd on Adobe CF for
disk-queues-77caf specifically, while the equivalent page/contentStore
assertions passed. That slug is referenced by several other specs'
uniqueness-validation tests (duplicate-slug checks in EntriesSpec's
create/edit stories), any of which could still be leaving it in a bad
state on Adobe without an isolated repro to pin down which one. Rather
than keep chasing it spec by spec, test anonymous visibility against a
disposable published entry this test owns end to end, same pattern
already used for the draft-entry case in this file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

71f76de0f didn't fully fix it: EntriesSpec itself now passes cleanly (16/16), but disk-queues-77caf was still 404ing for anonymous show on Adobe CF, while the equivalent pages/contentStore show-by-slug assertions passed fine. So this isn't a blanket Adobe boolean/ORM issue with published content in general — it's specific to that one seeded row.

disk-queues-77caf's slug is referenced by several other tests purely for uniqueness-validation checks (duplicate-slug 400 assertions in EntriesSpec's create/edit stories) that are expected to fail validation and never persist — I couldn't isolate which one (if any) is the actual culprit without reproducing on Adobe CF directly, which I don't have access to.

Rather than keep chasing this spec by spec, c58371801 stops the anonymous "show a published entry" test from depending on that shared fixture at all — it now creates, verifies, and tears down its own disposable published entry, the same pattern already used for the draft-entry case right below it in the same file. This removes the dependency on whatever is touching disk-queues-77caf, regardless of root cause.

Verified locally: EntriesSpec → PublicContentAccessSpec back-to-back, plus the full 126/126 contentbox-api suite.


Generated by Claude Code

lmajano commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

All actual tests are green on c58371801: every tests / Test Suites (...) job passed (12/12 across both duplicate workflow runs — boxlang, adobe@2023, adobe@2025, all coldbox/jdk combos), and the Test Results check itself completed with conclusion: success.

The only red item is the tests / Publish Test Results job, and it's unrelated to this PR: .github/workflows/tests.yml's Publish Test Results job (line 162) grants only permissions: { checks: write }, but the EnricoMi/publish-unit-test-result-action@v2 step also runs with comment_mode: always, which tries to POST an issue comment on the PR — that needs pull-requests: write, which isn't granted. The job log confirms it: the check run itself is created successfully ("Publishing success results... Created check ..."), and only the follow-up POST .../issues/696/comments call 403s with "Resource not accessible by integration." This is deterministic (missing permission, not a flake) and pre-existing — unrelated to this PR's diff, so I'm not touching that workflow file here. A one-line fix would be adding pull-requests: write to that job's permissions block, or dropping comment_mode: always to comment_mode: off/errors if PR comments from this job aren't actually wanted.

With that aside, this PR's own CI is clean. Waiting on reviewers.


Generated by Claude Code

cbSecurity's own CBSecurity.has() already does exactly what
hasFullContentAccess() was hand-rolling: an "any of these permissions"
check against the currently authenticated user, via the cbSecure() mixin
alongside jwtAuth(). jwtAuth().parseToken() logs the resolved author into
cbSecurity's SecurityService as a side effect, so cbSecure().has() sees it
correctly. Drops the manual listToArray()/loop and the direct
prc.oCurrentAuthor dependency, so hasFullContentAccess() no longer needs
prc at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoEgT3ggNEKM1nGjqB5Qd5
@lmajano
lmajano merged commit 438b458 into development Sep 25, 2026
14 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.

3 participants