FrontEnd Architecture: Guard-Based Access Control (GBAC)
- Dominant language
- JavaScript
- Stars
- 400
- Forks
- 89
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 149
Description
## Description
This pattern moves security enforcement from the "View" layer (component-level logic) to the "Router" layer. By utilizing a **Chain of Responsibility** design pattern, we can declaratively define access requirements directly in our route configurations, ensuring that unauthorized users are redirected or intercepted before any component setup or rendering occurs.
## Key Objectives
* **Declarative Security:** Access requirements are defined via a `guards` key within the route's `meta` object.
* **Flexible Registration:** Supports three formats for guard definition:
1. **String:** Single guard shorthand (e.g., `'guest'`).
2. **Array of Strings:** Sequential execution (e.g., `['auth', 'role:owner']`).
3. **Object Configuration:** Advanced control allowing custom redirection logic if validation fails (e.g., `{ name: 'feature:x', redirectTo: () => { ... } }`).
* **Chain of Responsibility:** Middleware-style execution where guards run sequentially. If any guard fails, the chain is broken, and the specified (or default) redirect is triggered.
* **Dual-Layer Scope:**
* **Global Guards:** Registered globally to run on every navigation (e.g., PostHog listeners, Meta extraction).
* **Local (Route-Based) Guards:** Specifically assigned to routes or branches via the `guards` key.
* **Elimination of FOUC:** Prevents "Flash of Unauthorized Content" by resolving permissions during the navigation phase.
## Configuration Example
### Routes declarations
```javascript
const routes = [
{
path: '/login',
meta: { guards: 'guest' }
},
{
path: '/team',
meta: { guards: 'auth' },
children: [
{
path: ':team_slug',
name: 'Team',
component: Team,
meta: {
title: 'Team - Overview',
guards: ['role:owner']
},
children: [
{
path: 'brokers',
guards: ['feature:brokers-list'],
children: [
{
name: 'create-brokers',
path: 'create',
guards: [{
name: 'permission:broker-creation',
redirectTo: () => { return { name: 'team-brokers' } }
}]
}
]
}
]
}
]
}
]
```
### Guard example (pseudo-code)
```javascript
/**
* [Placeholder] Guard Pattern
* * Contract:
* - constructor(store): Receives the relevant Pinia/Vuex store.
* - handle({ params, redirectTo }): The execution logic.
*/
class PlaceholderGuard {
constructor(store) {
// Injected by the Registry
this.store = store;
}
/**
* @param {Array} params - Arguments parsed from the string (e.g., ['param1'])
* @param {Function|null} redirectTo - Custom redirect override from route meta
* @returns {Boolean|Object} - true to proceed; Route Object to redirect
*/
async handle({ params, redirectTo }) {
// 1. Perform logic check using this.store and params
const isAllowed = /* ... logic ... */;
if (isAllowed) {
return true;
}
// 2. Handle failure: prioritize custom override, then provide a fallback
return redirectTo ? redirectTo() : { name: 'default-redirect-route' };
}
}
```
### Guard registry example
This file initializes the guard instances once. It acts as the "Security Manifest" for the entire application.
The Singleton approach (as opposed to lazy loading them on demand) for guards is the most logical choice. Because the application state is guaranteed to be ready before navigation occurs, the guards can be instantiated as static "security units" that stand ready to evaluate that state.
```javascript
export const GuardRegistry = {
'auth': new RoleGuard($store),
'role': new RoleGuard($store),
'feature': new FeatureGuard($store),
'guest': new GuestGuard($store)
};
```
### The navigation Orchestrator
Its primary role is to de-normalize the declarative route metadata into actionable logic, ensuring that the Chain of Responsibility is respected and executed in the correct order.
This allows the new GBAC system to run in parallel with any legacy logic or global listeners (like PostHog, metadata and title handling) already in place, ensuring a stable migration without breaking existing functionality.
Eventually, this logic should be extracted into a dedicated GuardOrchestrator class to keep the router configuration clean and maintainable.
```javascript
router.beforeEach(async (to, from, next) => {
// 1. Flatten all guards from the route and its parents
// Use to.matched to ensure we inherit guards from parent routes
const guardConfigs = to.matched.flatMap(record => record.meta.guards || []);
// 2. Process the Chain of Responsibility
for (const config of guardConfigs) {
let guardName, params, redirectTo;
// Normalize: supports 'string', ['array'], or { object }
if (typeof config === 'string') {
[guardName, ...params] = config.split(':');
} else {
[guardName, ...params] = config.name.split(':');
redirectTo = config.redirectTo;
}
// 3. Retrieve the Singleton instance
const guard = GuardRegistry[guardName];
if (guard) {
// 4. Execute the guard logic
const result = await guard.handle({ params, redirectTo });
// If the guard returns false (validation failed)
if (result === false) {
// Priority 1: Custom redirect provided in the meta
if (redirectTo) return next(redirectTo());
// Priority 2: Global default fallback
return next({ name: 'home' });
}
// If the guard returns a specific route object, use it (e.g., redirect to login)
if (result !== true) {
return next(result);
}
}
}
// 6. If all guards in the chain return true, proceed to the view
next();
});
```
## Note: The code examples provided in this document are conceptual and informative in nature. They are intended to illustrate the architectural logic and "contract" of the pattern and should not be treated as literal drop-in replacements for the final implementation.
Depends on the completion of https://github.com/FlowFuse/flowfuse/issues/6520
Contributor guide
Assessment
This issue has not been assessed yet.