Repository navigation
fix: use target route workspaceId in permission guard - #7865
Conversation
| } | ||
| } | ||
|
|
||
| /** 判断当前路由是否属于工作空间资源。 */ |
There was a problem hiding this comment.
The code is syntactically valid, and the synchronous nested save/restore logic in withRouteWorkspace is correct. However, the module-level route override is fragile and can produce incorrect results in async or concurrent scenarios.
Main issues
-
Async/concurrent safety is the biggest problem
withRouteWorkspaceonly works safely for synchronous callbacks.- If someone passes an async callback:
the
withRouteWorkspace(to, async () => { await checkPermission() getWorkspaceId() // override has already been restored })
finallyblock runs before the promise resolves, sogetWorkspaceId()no longer sees the target route. - If multiple route guards/navigations interleave, the shared module state can be overwritten.
- The generic type
TallowsPromise<T>, so TypeScript will not prevent this misuse.
-
SSR / module-global state risk
- If this code runs in SSR, module-level state is shared across requests. Any async interleaving can leak workspace context between requests.
-
Exception-safety gap in
withRouteWorkspacereadWorkspaceId(route)runs afterhasRouteWorkspace = truebut before thetryblock. IfreadWorkspaceIdever throws, the override will not be restored.- Move the read before mutation, or put all state changes inside
try.
-
Hidden coupling to global
router.currentRoute- The fallback in
getWorkspaceId()is convenient, but inside a route guard it is exactly what you are trying to avoid. If the override is accidentally removed too early, the function silently reads the previous route instead of failing.
- The fallback in
-
Minor naming issue
hasRouteWorkspacesounds like “the route has a workspace,” but it actually means “a workspace override is active.” PreferhasRouteWorkspaceOverride.
Recommended approach
Avoid the global override and make the route/workspace explicit:
type WorkspaceRouteLike = Pick<RouteLocationNormalized, 'params'>
const readWorkspaceId = (route: WorkspaceRouteLike): string | undefined => {
const value = route.params.workspaceId
return Array.isArray(value) ? value[0] : value
}
export const getWorkspaceId = (
route: WorkspaceRouteLike = router.currentRoute.value,
): string | undefined => readWorkspaceId(route)Then use it in guards as:
router.beforeEach((to) => {
const workspaceId = getWorkspaceId(to)
// check permissions with workspaceId
})This removes global mutable state entirely and works correctly with async guards.
If you must keep withRouteWorkspace
At minimum:
- Document that the callback must be synchronous.
- Fix the exception-safety order:
export const withRouteWorkspace = <T>(route: WorkspaceRouteLike, fn: () => T): T => {
const nextId = readWorkspaceId(route)
const prevHas = hasRouteWorkspace
const prevId = routeWorkspaceId
try {
hasRouteWorkspace = true
routeWorkspaceId = nextId
return fn()
} finally {
hasRouteWorkspace = prevHas
routeWorkspaceId = prevId
}
}But even with this fix, the async/concurrent problem remains. The explicit getWorkspaceId(to) approach is safer and simpler.
| if (!nextPermission || withRouteWorkspace(nextRoute, () => nextPermission(nextRoute))) { | ||
| return { name: nextName, params: to.params, query: to.query, hash: to.hash, replace: true } | ||
| } | ||
| } |
There was a problem hiding this comment.
Review
Overall the diff is small and the indentation fix is welcome, but the change adds a non-trivial dependency (withRouteWorkspace) into a security-relevant guard, so a few things are worth verifying before merging.
1. Correctness / security (highest priority)
- The wrapper must be strictly synchronous and return the callback's value. The expression
!to.meta?.permission || withRouteWorkspace(to, () => perm(to))relies on the wrapper returning the boolean result of the inner function. IfwithRouteWorkspaceisasync, or is written asfunction withRouteWorkspace(route, fn) { setContext(route); fn(); restore(); }(returningundefined), then:- an async wrapper returns a Promise → always truthy → every permission check passes (auth bypass);
- a void wrapper returns
undefined→ falsy → every guarded route is denied, including valid ones.
Please double-check (and ideally type it aswithRouteWorkspace<T>(route, fn: () => T): T).
- Context restoration must be in a
finally. If the permission function throws (or if any route permission callback is buggy), a non-finallyrestore will leak the workspace context into subsequent navigations/checks. - Validate that the workspace derived from
toactually belongs to the user. Since the wrapper presumably derives context from the route (params/query), a crafted URL pointing at another tenant's workspace could make the permission callback evaluate against the wrong/foreign context. The wrapper should treat an unknown/unauthorized workspace as "no context" rather than silently accepting it. - Routes without a workspace (system routes, error pages, public routes) must be a no-op in
withRouteWorkspace. If it throws or sets a bogus context for those, system routes will break. - Inconsistency:
getChildRouteList('system').some((item) => item.permission ? item.permission() : true)is not wrapped. If permission callbacks now depend on resource context, this check can behave differently from the main check. Either wrap it too (with a sensible route/context) or document why it's exempt.
2. Robustness
- The
beforeEachhas notry/catcharound the permission evaluation. A throw from the wrapper (e.g., malformed param) will reject the navigation and surface as an uncaught router error. Consider catching and doingreturn { name: 'error-...' }/return false, or guarantee the wrapper never throws. - Error path is asymmetric: if the wrapper fails in the first check, you fall through to the redirect loop, which may itself throw in
nextPermission. Make the two call sites behave consistently. - If the wrapper mutates global/reactive state, confirm that repeated/rapid navigations (canceled navigations) can't interleave. Here it's synchronous, so it should be fine — but this property should be enforced/documented.
3. Types
nextPermissionis typed as(route?: RouteLocationNormalized | RouteLocationResolvedGeneric | RouteLocationNormalizedLoaded) => boolean, but inside the loop it's called withnextRouteof that union type.withRouteWorkspace(nextRoute, ...)must accept the same union, not onlyRouteLocationNormalizedLoaded. Check the wrapper's signature.- The cast
to.meta.permission as (route: RouteLocationNormalizedLoaded) => booleanis applied toto, which inbeforeEachisRouteLocationNormalized. Pre-existing, but the new wrapper makes the mismatch more visible — worth tightening with a shared type/type guard.
4. Duplication / readability
- The pattern appears twice (
withRouteWorkspace(route, () => perm(route))). Extract a small helper, e.g.:Then both call sites becomeconst hasPermission = (route) => !route.meta?.permission || withRouteWorkspace(route, () => (route.meta.permission as PermissionFn)(route))
if (hasPermission(to)) return trueandif (hasPermission(nextRoute)) return {...}. This also keeps the wrapper contract in one place. - Consider a comment on the wrapper call explaining why the context is needed (e.g., "permission callbacks read the current workspace from resource-context"), since this is currently non-obvious.
5. Style / minor
- Import placement:
@/utils/resource-contextis added after the relative imports. Most codebases group alias imports before local relative ones — align with the existing convention in the file. - The block indentation fix is good; the diff would be easier to review if whitespace-only changes were in a separate commit from the behavioral change.
6. Testing suggestions
- Permission callback returns
true/false, and navigations redirect correctly in both cases. - No workspace in the route → system routes still work (wrapper is a no-op).
- Route with a workspace the user doesn't belong to → denied.
- Permission callback throws → navigation fails gracefully, context is restored.
- Verify context is restored after a failed/permitted navigation (subsequent navigation uses the correct workspace).
fix: use target route workspaceId in permission guard