Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion autobot-frontend/src/constants/__tests__/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss
import { describe, it, expect } from 'vitest'
import { isAdminRole, ADMIN_ROLES } from '../roles'
import { isAdminRole, ADMIN_ROLES, ROLE_RANK, meetsMinRole } from '../roles'

describe('isAdminRole (#14937)', () => {
it('admits admin', () => {
Expand Down Expand Up @@ -35,3 +35,39 @@ describe('isAdminRole (#14937)', () => {
expect([...ADMIN_ROLES].sort()).toEqual(['admin', 'superadmin'])
})
})

describe('ROLE_RANK / meetsMinRole (#16244)', () => {
it('ranks superadmin above admin, mirroring the backend _ROLE_META comment', () => {
expect(ROLE_RANK.superadmin).toBeGreaterThan(ROLE_RANK.admin)
})

it('matches the backend _ROLE_META ordering exactly', () => {
expect(ROLE_RANK).toEqual({
superadmin: 110,
admin: 100,
operator: 80,
analyst: 60,
editor: 55,
user: 50,
readonly: 10
})
})

it('meetsMinRole admits a role above the floor', () => {
expect(meetsMinRole('admin', 'operator')).toBe(true)
})

it('meetsMinRole admits a role exactly at the floor', () => {
expect(meetsMinRole('operator', 'operator')).toBe(true)
})

it('meetsMinRole rejects a role below the floor', () => {
expect(meetsMinRole('user', 'operator')).toBe(false)
})

it('meetsMinRole fails safe on missing/unknown role', () => {
expect(meetsMinRole(null, 'readonly')).toBe(false)
expect(meetsMinRole(undefined, 'readonly')).toBe(false)
expect(meetsMinRole('not-a-real-role', 'readonly')).toBe(false)
})
})
27 changes: 27 additions & 0 deletions autobot-frontend/src/constants/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,30 @@ export function isAdminRole(role: string | null | undefined): boolean {
if (!role) return false
return (ADMIN_ROLES as readonly string[]).includes(role.toLowerCase())
}

/**
* Role rank, mirroring the backend's authoritative ordering in
* `autobot_shared/auth/permissions.py::_ROLE_META` (#16244). `superadmin`
* ranks above `admin` for the same reason `_ROLE_META`'s own comment gives:
* it is administrative at every gate that admits `admin`, and a lower rank
* here would sort the most privileged role below `readonly`.
*
* UI ROUTING ONLY — this never grants access the backend refuses. It backs
* `meta.minRole`'s router guard alone; every actual permission decision
* still goes through the backend's own gates.
*/
export const ROLE_RANK: Record<Role, number> = {
superadmin: 110,
admin: 100,
operator: 80,
analyst: 60,
editor: 55,
user: 50,
readonly: 10
} as const satisfies Record<Role, number>

/** True when `role` meets or exceeds `minRole` in the ranking above. Unknown/missing `role` never meets any `minRole`. */
export function meetsMinRole(role: string | null | undefined, minRole: Role): boolean {
if (!role || !(role in ROLE_RANK)) return false
return ROLE_RANK[role as Role] >= ROLE_RANK[minRole]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss

// #16244: meta.minRole was declared and read by no guard. These tests pin
// the guard's actual behavior -- a user below a route's minRole is
// redirected home, one at or above it is let through -- without mocking
// the whole router or user store, since resolveMinRoleRedirect is a pure
// function of (to, userRole).

import { describe, it, expect, vi } from 'vitest'
import type { RouteLocationNormalized, RouteRecordNormalized } from 'vue-router'

vi.mock('@/plugins/api', () => ({
useApiClient: () => ({ get: vi.fn().mockResolvedValue([]) })
}))

vi.mock('@/utils/debugUtils', () => ({
createLogger: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() })
}))

import { resolveMinRoleRedirect } from '@/router'

function toWithMinRole(minRole: string | undefined): RouteLocationNormalized {
const matched = [
{ meta: minRole ? { minRole } : {} } as unknown as RouteRecordNormalized
]
return { matched } as unknown as RouteLocationNormalized
}

describe('resolveMinRoleRedirect (#16244)', () => {
it('redirects a user below the route minRole', () => {
const to = toWithMinRole('operator')
expect(resolveMinRoleRedirect(to, 'user')).toEqual({ path: '/home' })
})

it('allows a user exactly at the route minRole', () => {
const to = toWithMinRole('operator')
expect(resolveMinRoleRedirect(to, 'operator')).toBeNull()
})

it('allows a user above the route minRole', () => {
const to = toWithMinRole('operator')
expect(resolveMinRoleRedirect(to, 'admin')).toBeNull()
})

it('allows any authenticated user when the route sets no minRole', () => {
const to = toWithMinRole(undefined)
expect(resolveMinRoleRedirect(to, 'readonly')).toBeNull()
})

it('redirects a user with no role at all when the route sets a minRole', () => {
const to = toWithMinRole('user')
expect(resolveMinRoleRedirect(to, undefined)).toEqual({ path: '/home' })
})

it('applies the strictest minRole among nested matched records', () => {
const to = {
matched: [
{ meta: { minRole: 'user' } } as unknown as RouteRecordNormalized,
{ meta: { minRole: 'operator' } } as unknown as RouteRecordNormalized
]
} as unknown as RouteLocationNormalized

expect(resolveMinRoleRedirect(to, 'user')).toEqual({ path: '/home' })
expect(resolveMinRoleRedirect(to, 'operator')).toBeNull()
})
})
41 changes: 40 additions & 1 deletion autobot-frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
* - /tls-certificates - TLS Certificates
*/

import { createRouter, createWebHistory, type RouteLocationNormalized, type RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory, type RouteLocationNormalized, type RouteLocationRaw, type RouteRecordRaw } from 'vue-router'
import { useAppStore } from '@/stores/useAppStore'
import { useUserStore } from '@/stores/useUserStore'
import { meetsMinRole, ROLE_RANK } from '@/constants/roles'
import type { Role } from '@/types/_generated/workflow'
import { setupAsyncComponentErrorHandler } from '@/utils/asyncComponentHelpers'
import { isChunkLoadError } from '@/utils/chunkLoadError'
import { createLogger } from '@/utils/debugUtils'
Expand Down Expand Up @@ -1324,6 +1326,31 @@ router.onError((error) => {
}
})

/**
* Resolve whether `to`'s declared minimum role (#16244) blocks `userRole` --
* returns the redirect target if so, `null` if the route is allowed. Nested
* routes may each set `meta.minRole`; the strictest (highest-ranked) one
* among matched records applies. A pure function, kept separate from the
* navigation guard below so it's testable without mocking the router or
* user store.
*/
export function resolveMinRoleRedirect(
to: RouteLocationNormalized,
userRole: string | null | undefined
): RouteLocationRaw | null {
const requiredMinRole = to.matched
.map(record => record.meta.minRole)
.filter((role): role is Role => role != null)
.reduce<Role | undefined>(
(strictest, role) => (strictest === undefined || ROLE_RANK[role] > ROLE_RANK[strictest] ? role : strictest),
undefined
)
if (requiredMinRole && !meetsMinRole(userRole, requiredMinRole)) {
return { path: '/home' }
}
return null
}

// Global navigation guards with enhanced error handling
// Issue #2676: Migrated from next() callback to return-value pattern (vue-router v5)
router.beforeEach(async (to, from) => {
Expand Down Expand Up @@ -1385,6 +1412,18 @@ router.beforeEach(async (to, from) => {
return { path: '/home' }
}

// Block routes below their declared minimum role (#16244). UI routing
// only, same as the admin check above -- the backend's own permission
// gates remain the actual authority; this never grants access the
// backend would refuse.
if (userStore.isAuthenticated) {
const minRoleRedirect = resolveMinRoleRedirect(to, userStore.currentUser?.role)
if (minRoleRedirect) {
logger.debug('Route blocked by minRole, redirecting to home')
return minRoleRedirect
}
}

// If user is authenticated and trying to access login page, redirect to home
if (to.name === 'login' && userStore.isAuthenticated) {
logger.debug('User already authenticated, redirecting to home')
Expand Down
7 changes: 7 additions & 0 deletions changelog/unreleased/16244-minrole-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
type: feature
scope: frontend
issue: 16244
pr: 0
---
Routes can now declare a minimum role (`meta.minRole`) that the router guard actually enforces, ranked against the same ordering the backend uses. This is a UI-routing convenience only; the backend's own permission gates remain the real authority.
Loading