Skip to content

Add Symfony UX Disclose to disclose protected values on demand - #3861

Closed
GromNaN wants to merge 2 commits into
symfony:3.xfrom
GromNaN:ux-disclose
Closed

GromNaN wants to merge 2 commits into
symfony:3.xfrom
GromNaN:ux-disclose

Conversation

@GromNaN

@GromNaN GromNaN commented Sep 10, 2026

Copy link
Copy Markdown
Member

Draft. This pull request is opened as a draft on purpose. I have doubts about the value of this feature, and the demo needs more work. Read the "Author's doubts" section first.

Videos

Screen.Recording.2026-09-09.at.13.02.13.mov

Screen.Recording.2026-09-09.at.13.13.23.mov

Author's doubts

I am not convinced this pattern should land in Symfony UX. Coupling a "click-to-reveal" with an asynchronous backend call encourages an architecture that degrades both the Developer Experience (DX) and the User Experience (UX):

  1. UX friction and network latency: showing or hiding an interface element should be instantaneous. Turning it into an HTTP transaction adds wait times (spinners) and forces the component to handle complex states (timeouts, 500 errors, network loss).
  2. The rate limiting trap: a legitimate user (a developer debugging, an admin processing a file) who needs to reveal 4 or 5 fields quickly will hit the anti-scraping rule (HTTP 429), and security ends up blocking normal daily usage.
  3. Audit log pollution: logging the reveal of each individual field drowns security teams in noise. The relevant event is macroscopic: "User X accessed resource Y", and it should be handled at the controller or voter level during the initial page load, not delegated to individual UI clicks.
  4. Fragile E2E tests: this pattern makes automated functional tests (Panther, Playwright) more complex and flaky. They must systematically handle the component's asynchronicity and artificially bypass rate limits to pass the CI pipeline.

Recommended alternatives:

  1. Against shoulder surfing: strictly frontend masking (CSS/JS). The data is loaded by the backend (and access is globally audited at that time), but it is visually replaced by ***. Clicking reveals it instantly in JS, with no extra HTTP request.
  2. For secrets and API keys: the industry standard is View-Once. The data is only displayed once upon generation. There should be no backend route allowing it to be "fetched and displayed" later.
  3. Pivot this PR into a pure frontend visual masking component (dropping the Stimulus fetch and the backend call): this would answer the need for visual screen protection without introducing architectural debt.

The demo needs rework

The demo application (apps/disclose-demo) needs to be reworked before this PR can be considered ready. Feedback is welcome on the current version, and on whether the component itself is worth shipping at all.


Symfony UX Disclose renders protected values (emails, phone numbers, identifiers) behind a masked trigger. The real value never enters the initial HTML: it only reaches the browser after an explicit click, through a server endpoint that enforces authorization, a rate limit and an audit trail before returning anything.

Objective

Personally identifiable data shown to every authorized user is an exfiltration vector: any user with legitimate access, acting with bad intentions, can harvest thousands of records in seconds by opening pages or scraping the HTML, unnoticed.

Disclose changes the economics of mass disclosure:

  • the value is not in the HTML, so a page load or a scraped dump leaks nothing;
  • revealing one value is one explicit click, which is hard to automate silently;
  • every click is rate limited per user (falling back to the IP), so a bulk harvest hits the quota immediately;
  • every attempt is audited, so abuse is loud and traceable.

Design choices

The value lives server-side only

A masked display is not protection: the value stays in the DOM and is trivial to extract. The component renders a signed reference instead, and the raw value exists only on the server. The reference is an HMAC-signed disclose context, so it cannot be tampered with to target other records.

The bundle owns the endpoint

DiscloseBundle owns the route. In a single place it verifies the signed context, resolves the subject, checks the discloser authorization, consumes a rate limiting token and writes an audit record before any value is returned. Applications cannot accidentally skip one step.

Data source agnostic resolution

Subject resolution is pluggable through SubjectResolverInterface. Doctrine ORM and MongoDB ODM ship as optional built-in resolvers, and a context provider turns a subject object into a signed reference. The contracts never import Doctrine: a resolver, a discloser and a context provider all work on plain objects.

Race-safe rate limiting

Rate limiter consume() is a read-modify-write on the cache. Without a lock, a burst of simultaneous requests can race past the quota. The Lock component is a hard requirement: the bundle registers its own flock lock factory and proposes it as the default lock_factory. Several framework limiters can be combined through the RateLimiter CompoundLimiter (for example a burst window plus a daily quota). Persisting the quotas in a DBAL-backed cache pool keeps them across restarts.

Audit and events share one status

DiscloseStatus is a single enum shared by the audit logger and the disclosure events. The log level is decided in the logger, the consumer. The events (attempt, success, rejected) never carry the disclosed value: listeners cannot observe the sensitive data.

Frontend weight stays on the server

The Twig component exposes two customizable views. A toggle mode keeps a single trigger in place (an eye that swaps between a slashed and an open pictogram). The revealed content can be authored as an inline reveal block inside the component tag: it is compiled into the tag but never rendered on the initial mount, and the endpoint renders it on click with the resolved subject. By default the fetched value is inserted as plain text; HTML injection is opt-in and only for your own server-side templates.

Packages

  • symfony/ux-disclose, with PHPUnit, Vitest and Playwright tests, package documentation and generated dist assets.
  • apps/disclose-demo, a runnable Symfony app: EasyAdmin CRUD with disclose columns, a Turbo Frame paginated client list backed by the Doctrine ORM Paginator, a DBAL-backed rate limiter and lock store in sqlite, and a theme-agnostic component style that follows light and dark EasyAdmin themes.
  • The e2e testing app gets a client list demo and discloses the values through the same endpoint.

Security notes

  • Text values are inserted with textContent, never as HTML. HTML injection is opt-in and only for server-side templates (the inline reveal block).
  • The signed context cannot be tampered with to target other records.
  • The lock makes concurrent consume() calls race-safe.
  • The demo stores the rate limit budget and the lock in sqlite, so quotas survive a restart.

@github-actions

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
Disclose
controller.d.ts Added 1.81 kB / 509 B
controller.js Added 4.36 kB / 1.17 kB
style.min.css Added 2.04 kB / 697 B

@GromNaN
GromNaN requested a review from Kocal September 10, 2026 10:19
@carsonbot carsonbot added the Status: Needs Review Needs to be reviewed label Sep 10, 2026
@smnandre

Copy link
Copy Markdown
Member

After first read (code and your PR message), I'm also a bit doubtfull... for two main reasons:

  • the current implementation requires more code (in order to be generic) that a user would need to write directly in its app using a LiveComponent (in which they would inject the RateLimiter and Logger services if they want for instance..)
  • the overall component feels to me more like an EasyAdmin plugin than a generic Symfony / UX abstraction.. I would have recommended a toolkit recipe here... but with the amount of PHP i really don't know..

I'm all open to change my mind if you guys think this is one of the must-have components for UI in the years to come.

But really have no definitive opinion here, simply observing this shows once more there is a whole lot of UI/UX patterns that requires the same elements in back-end, and I'm more and more thinking we either introduce some components/bundle and generic way to configure RateLimiting, Logging, and even stateless TwigComponent with endpoints (LiveComponents without all the form/model part but with actions) .. or we don't but should probably not multiply such bundle/package, everytime with their own templates, css, security, exception, configuration logic.

Not clear answers here in my mind 🤷

@GromNaN

GromNaN commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Thanks for your feedback @smnandre

The easyadmin part is only on the demo app. But I agree that this component is quite opiniated and could lead to many configuration options that we don't want to maintain.

You pointed me to using Live Components for this feature, which is very smart.
Instead of a full-featured component, I will work on improving various parts:

  • Support the #[RateLimit] attribute on live components
  • Improve DX for live components with multiple views. This is similar to a lazy, triggered by a click instead of scrolling
  • URL signature
  • Status codes

Then we can document a tutorial recipe to create such component for app developers.

@GromNaN GromNaN closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Needs Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants