Search by

vagabit / fos-js-routing-security-bundle

m.piccinato@vagabit.com

Role-filtered and security.yaml access-control-aware route exposure for FOSJsRoutingBundle

Package info

github.com/vagabit-dev/fos-js-routing-security-bundle

Type:symfony-bundle

pkg:composer/vagabit/fos-js-routing-security-bundle

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-11-01 23:37 UTC

This package is auto-updated.

Last update: 2026-11-02 00:26:09 UTC


README

CI Test Suite Latest Stable Version PHP Version Symfony Version License

Extends FOSJsRoutingBundle to dynamically filter and serve exposed routes based on user roles and permissions.

The fundamental purpose of FOSJsRoutingBundle is to maximize developer productivity by eliminating manual URL hardcoding in JavaScript. It brings Symfony’s route generation mechanism (Routing.generate('route_name', { id: 1 })) directly to frontend applications.

Standard FOSJsRouting operates on a public, all-or-nothing model. Any route marked expose: true is exposed to every visitor. While this is not a security issue "per se", it presents a clear security and efficiency drawback: internal and administrative endpoints (path structures, parameter schemas, and route names) are leaked to users who cannot use them, increasing the attack surface without providing any functional benefit.

This extension optimizes the system by aligning route delivery with actual user utility and access permissions:

  1. Automated Route Filtering: for every exposed route, it evaluates Symfony’s security.yaml access_control rules and custom route-level role / roles options against the current user's security token.
  2. Information Minimization: Users only receive route definitions for endpoints they are actually authorized to access. Anonymous users receive only public routes; administrative routes are only transmitted to users holding ROLE_ADMIN.
  3. Security and Payload Reduction: Removing inaccessible routes shrinks payload size across the wire and prevents reconnaissance on sensitive backend structures.

The only drawback of this bundle is that routes can not be dumped altogether in a single static cacheable file bust must be served dynamically, a different caching mechanism is provided to avoid performance overhead.

Features

  • Zero-Configuration Activation: Automatically registers and decorates fos_js_routing.extractor as soon as the bundle is enabled in bundles.php—no manual service wiring in services.yaml needed.
  • Centralized Security Access Control: Intercepts route extraction and matches route paths against security.yaml access_control rules via Symfony\Component\Security\Http\AccessMapInterface.
  • Single & Multiple Role Route Options: Supports declaring single (role) or multiple (roles) required roles directly on the route definition (e.g. options: ['expose' => true, 'role' => 'ROLE_MANAGER'] or options: ['expose' => true, 'roles' => ['ROLE_ADMIN', 'ROLE_MANAGER']]).
  • Role-Based Server-Side Caching: Controller endpoint (/vagabit-secure-js-routes.json) caches route definitions keyed by the user's role combination (js_routes_<hash>), isolating cache entries per role configuration without repetitive path matching on every request.
  • Role-Based Route Debugger Command: Interactive console command (vagabit:js-routing:debug) to inspect route exposure, separating exposed from non-exposed routes and debugging role accessibility rules.
  • Configurable HTTP Cache & Security Headers: Secure by default with private, no-store headers, with configurable browser caching (max_age), revalidation (must_revalidate), public/private visibility, and conditional ETag handling (304 Not Modified).

Installation & Setup

1. Install via Composer

composer require vagabit/fos-js-routing-security-bundle:^1.0.0-beta1

2. Enable the Bundle

If you use Symfony Flex, the bundle is enabled automatically upon installation. Otherwise, add it to config/bundles.php:

// config/bundles.php
return [
    // ...
    FOS\JsRoutingBundle\FOSJsRoutingBundle::class => ['all' => true],
    Vagabit\FOSJsRoutingSecurityBundle\FOSJsRoutingSecurityBundle::class => ['all' => true],
];

3. Configure

3.1 Register Controller Routing

Because Symfony bundles do not automatically inject route definitions into host applications, register the bundle's controller routes in your application's routing configuration (e.g. config/routes/fos_js_routing_security.yaml):

# config/routes/fos_js_routing_security.yaml
vagabit_fos_js_routing_security:
    resource: '@FOSJsRoutingSecurityBundle/src/Controller/'
    type: attribute

Important: Do not import the old FOSJsRouting route file (@FOSJsRoutingBundle/Resources/config/routing/routing-sf4.xml or fos_js_routing_js). The legacy endpoint bypasses this bundle's role-based response cache and HTTP security headers. Always use the secured endpoint (/vagabit-secure-js-routes.json or route name vagabit_secure_js_routes).

3.2 Configure Security Access Rules (security.yaml)

If you haven't already done so, configure route role access in security.yaml. The bundle uses these rules to determine which routes to serve based on the user's role:

# config/packages/security.yaml
security:
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/account, roles: ROLE_USER }

3.3 Exposing Routes with Options

Alternatively (or additionally) routes can be mae available to roles with the custom role option.

Via security.yaml Matching

#[Route('/admin/dashboard', name: 'admin_dashboard', options: ['expose' => true])]
public function dashboard(): Response
{
    // ...
}

Via Custom Single role Option

#[Route('/manager/reports', name: 'manager_reports', options: ['expose' => true, 'role' => 'ROLE_MANAGER'])]
public function reports(): Response
{
    // ...
}

Via Custom Multiple roles Option

You can supply an array of roles using either the roles or role option. If the authenticated user has at least one of the specified roles, the route is exposed:

#[Route(
    '/staff/reports',
    name: 'staff_reports',
    options: [
        'expose' => true,
        'roles'  => ['ROLE_ADMIN', 'ROLE_MANAGER'], // Accessible to either ROLE_ADMIN or ROLE_MANAGER
    ]
)]
public function staffReports(): Response
{
    // ...
}

4. Usage

To maximize developer productivity and application responsiveness, client-side route generation should integrate cleanly with your frontend architecture while preserving role-based endpoint isolation.

Note: Given the dynamic way of serving the js routes by role:

  • routes can not be dumped with fos:js-routing:dump command
  • routes can not be used with Webpack plugin (FosRouting)

Option A: Modern Frontend Bundlers (Vite, Webpack, AssetMapper, npm/yarn)

Install the client-side routing library:

npm install fos-routing --save
# or
yarn add fos-routing
Initializing Routes Before Application Bootstrap

Because route generation (Routing.generate()) is synchronous, route definitions must be loaded and registered into the router instance before the frontend application initializes or mounts its root view/components. If components or lifecycle hooks attempt to generate URLs before route data is registered, route resolution will fail.

A standard pattern is to wrap application initialization in an asynchronous bootstrap routine that fetches /vagabit-secure-js-routes.json, registers the payload via Routing.setData(), and then mounts/boots the application:

import Routing from 'fos-routing';

async function bootstrapApp() {
    // 1. Fetch role-filtered route definitions
    const response = await fetch('/vagabit-secure-js-routes.json', {
        headers: { 'Accept': 'application/json' }
    });
    const routesData = await response.json();

    // 2. Register route data in the router
    Routing.setData(routesData);

    // 3. Initialize / mount your application once routes are ready
    // initApp();
}

bootstrapApp().catch((error) => {
    console.error('Failed to initialize routes and bootstrap application:', error);
});

Once initialized during bootstrap, Routing can be imported and called synchronously across any component, utility, or module:

// In any application module or component:
import Routing from 'fos-routing';

const url = Routing.generate('admin_dashboard');

Option B: Server-Rendered Twig Layouts

For traditional multi-page applications, publish bundle assets, include the router library, and initialize route data by fetching the secured endpoint in your base template (templates/base.html.twig):

php bin/console assets:install --symlink
<!-- 1. Load Router Library -->
<script src="{{ asset('bundles/fosjsrouting/js/router.min.js') }}"></script>

<!-- 2. Fetch and register dynamic role-filtered routes -->
<script>
    fetch('{{ path('vagabit_secure_js_routes') }}')
        .then(response => response.json())
        .then(data => fos.Router.setData(data));
</script>

Route Generation Examples

Once initialized, use Routing.generate() across frontend modules:

// 1. Basic route generation
const homeUrl = Routing.generate('public_home');

// 2. Route generation with parameters
const userUrl = Routing.generate('account_profile', { id: 42 });

// 3. Absolute URL generation (pass true as the third argument)
const absoluteUrl = Routing.generate('public_home', {}, true);

5. Configure Cache Lifetime, Prefix, Tag & HTTP Cache (Optional)

Generating js routes json on each request can be a heavy task especially for a complex application, for this reason the bundle provides a specific cache system.

You can configure server-side and HTTP response caching behavior in your Symfony configuration (config/packages/fos_js_routing_security.yaml):

# config/packages/fos_js_routing_security.yaml
fos_js_routing_security:
    # Server-side Cache TTL in seconds (integer or null).
    # - Positive integer (> 0): Cache expires after N seconds (e.g. 3600 for 1 hour).
    # - null (or ~ or "never"): Indefinite cache lifetime (entries never expire automatically).
    # - 0: Immediate expiration (disables caching / fresh extraction on every request).
    cache_ttl: 3600
    
    # Custom cache key prefix prepended to role hashes (default: 'js_routes_')
    cache_prefix: 'js_routes_'
    
    # Cache tag applied to route items for selective tag-based invalidation (default: 'fos_js_routing_security')
    # Set to null or '' to disable cache tagging.
    cache_tag: 'fos_js_routing_security'

    # Role combinations to pre-warm in cache during cache:warmup or via console command
    warmup_roles:
        - ROLE_ANONYMOUS
        - ROLE_USER
        - [ROLE_ADMIN, ROLE_USER]

    # HTTP Client / Browser Cache-Control settings for /vagabit-secure-js-routes.json
    http_cache:
        # Mark response as private (recommended for user-specific routes, default: true)
        private: true
        
        # Disallow storage completely with no-store (default: true)
        no_store: true
        
        # Max-age in seconds for browser caching when no_store is false (default: 0)
        max_age: 0
        
        # Require cache revalidation before using cached entry (default: false)
        must_revalidate: false
        
        # Enable ETag header generation and 304 Not Modified conditional responses (default: false)
        etag: false

HTTP Cache Configurations

Depending on your application's architecture and performance requirements, you can adjust http_cache:

  • Maximum Security & Session Isolation (Default): private: true, no_store: true prevents storing routes in browser or intermediate caches, ensuring role changes or logouts take effect instantly.
  • Short-Lived Browser Cache: private: true, no_store: false, max_age: 300 caches route definitions privately in the user's browser for 5 minutes, eliminating repeat network requests during active navigation.
  • Conditional Validation via ETag (304 Not Modified): private: true, no_store: false, etag: true, must_revalidate: true allows the browser to revalidate via If-None-Match. On match, the server returns an empty 304 Not Modified payload without transferring route data.

Cache Warmer

The bundle includes Vagabit\FOSJsRoutingSecurityBundle\Service\JsRoutesCacheWarmer, which pre-populates role-filtered routes in your application cache so end users always hit a warm cache on first request:

1. Automatic Warmup on cache:warmup

The cache warmer implements Symfony's CacheWarmerInterface (kernel.cache_warmer) and is executed automatically whenever cache:warmup or cache:clear runs:

php bin/console cache:warmup
2. On-Demand Warmup Command

You can also warm up routes on-demand (e.g. during CI/CD deployment pipelines):

# Warm up all configured role combinations
php bin/console vagabit:js-routing:warmup

# Warm up specific role combinations on the fly
php bin/console vagabit:js-routing:warmup --role=ROLE_ADMIN --role=ROLE_USER

Debugging Routes by Role (vagabit:js-routing:debug)

The bundle provides a dedicated debugging command to inspect route exposure and verify which security roles are granted access to each route in the JavaScript router.

1. Default Route Dump (Exposed vs Non-Exposed)

By default, the command inspects all registered application routes, separating exposed routes from non-exposed routes, and lists the authorized roles for each exposed route (evaluating route role/roles options and security.yaml access_control rules):

php bin/console vagabit:js-routing:debug

Example output:

 ------------------ ------------------ ----------- -------------------------------------- 
  Route Name         Path               Method(s)   Accessible Roles in JS                
 ------------------ ------------------ ----------- -------------------------------------- 
  public_home        /public/home       ANY         PUBLIC (All roles / Anonymous)        
  admin_dashboard    /admin/dashboard   GET         access_control: [ROLE_ADMIN]          
  account_profile    /account/profile   ANY         access_control: [ROLE_USER]           
  manager_reports    /manager/reports   ANY         Route Option: [ROLE_MANAGER]          
 ------------------ ------------------ ----------- -------------------------------------- 

 ------------------- -------------------------- ----------- 
  Route Name          Path                       Method(s)   
 ------------------- -------------------------- ----------- 
  fos_js_routing_js   /js/routing.{_format}      GET         
  fos_js_routing_data /js_routes.{_format}       GET         
 ------------------- -------------------------- ----------- 
2. Filter by Role(s)

To see only the exposed routes that can be accessed by specific roles, use the --role (or -r) option with one or more roles (comma-separated or multiple flags):

# Filter routes accessible to ROLE_ADMIN
php bin/console vagabit:js-routing:debug --role=ROLE_ADMIN

# Filter routes accessible to users holding either ROLE_ADMIN or ROLE_USER
php bin/console vagabit:js-routing:debug --role=ROLE_ADMIN,ROLE_USER

# Alternative syntax using multiple flags
php bin/console vagabit:js-routing:debug -r ROLE_ADMIN -r ROLE_USER
3. Inspect a Specific Route by Name or Path

To inspect a single route's full configuration, JavaScript exposure state, and security access rules, use the --route option (or --name, --path, or as a positional argument):

# Inspect by route name
php bin/console vagabit:js-routing:debug --route=admin_dashboard

# Inspect by route path
php bin/console vagabit:js-routing:debug --route=/admin/dashboard

# Positional argument syntax
php bin/console vagabit:js-routing:debug admin_dashboard

# Combine with --role to check if a specific route is accessible for given role(s)
php bin/console vagabit:js-routing:debug --route=admin_dashboard --role=ROLE_ADMIN

Example inspection output:

Route Details: admin_dashboard
+------------------------+------------------------------------+
| Property               | Value                              |
+------------------------+------------------------------------+
| Route Name             | admin_dashboard                    |
| Path                   | /admin/dashboard                   |
| Method(s)              | GET                                |
| Scheme(s)              | ANY                                |
| Host                   | ANY                                |
| Exposed to JavaScript  | Yes                                |
| Route Option Role(s)   | None                               |
| access_control Rule(s) | ROLE_ADMIN                         |
| Accessible Roles in JS | access_control: [ROLE_ADMIN]       |
+------------------------+------------------------------------+

Selective Cache Invalidation

When using a Symfony tag-aware cache pool (such as TagAwareAdapter, Redis, or Memcached), all cached role combinations can be invalidated simultaneously when routing or security rules change without clearing the entire application cache:

Via PHP Code:

use Symfony\Contracts\Cache\TagAwareCacheInterface;

// Invalidate all role route caches at once
$cache->invalidateTags(['fos_js_routing_security']);

Via Symfony Console:

php bin/console cache:pool:invalidate-tags fos_js_routing_security

Contributing

Please see if you want to contribute to the library CONTRIBUTING.md.

License

This project is licensed under the MIT License - see the LICENSE file for details.