feat(auth): harden Authentik and guest capability boundaries
This commit is contained in:
@@ -26,6 +26,7 @@ function App(): JSX.Element {
|
||||
<LandingPage
|
||||
serviceError={sessionError}
|
||||
guestAccessEnabled={session.guest_access_enabled}
|
||||
authentikEnabled={session.authentik_enabled}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ const operatorSession = {
|
||||
expires_at: '2026-07-27T20:00:00Z',
|
||||
role: 'operator' as const,
|
||||
guest_access_enabled: true,
|
||||
authentik_enabled: false,
|
||||
guest_project_id: null,
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ const guestSession = {
|
||||
expires_at: '2026-07-27T20:00:00Z',
|
||||
role: 'guest' as const,
|
||||
guest_access_enabled: true,
|
||||
authentik_enabled: false,
|
||||
guest_project_id: '00000000-0000-0000-0000-000000000123',
|
||||
}
|
||||
|
||||
@@ -71,6 +73,15 @@ describe('LandingPage', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers Authentik without removing the local operator recovery login', () => {
|
||||
render(<LandingPage onAuthenticated={vi.fn()} authentikEnabled />)
|
||||
|
||||
const authentik = screen.getByRole('link', { name: 'Aanmelden met Authentik' })
|
||||
expect(authentik.getAttribute('href')).toBe('/api/v1/auth/authentik/start')
|
||||
expect(screen.getByLabelText('Gebruikersnaam')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Wachtwoord')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a useful authentication error without entering the workbench', async () => {
|
||||
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
||||
render(<LandingPage onAuthenticated={vi.fn()} />)
|
||||
|
||||
@@ -28,6 +28,7 @@ interface LandingPageProps {
|
||||
onAuthenticated: (session: AuthSession) => void
|
||||
serviceError?: string | null
|
||||
guestAccessEnabled?: boolean
|
||||
authentikEnabled?: boolean
|
||||
}
|
||||
|
||||
const capabilityItems = [
|
||||
@@ -62,6 +63,7 @@ export function LandingPage({
|
||||
onAuthenticated,
|
||||
serviceError = null,
|
||||
guestAccessEnabled = false,
|
||||
authentikEnabled = false,
|
||||
}: LandingPageProps): JSX.Element {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -79,6 +81,16 @@ export function LandingPage({
|
||||
return () => document.body.classList.remove('landing-body')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
if (query.get('authentik') !== 'error') return
|
||||
setAttempted(true)
|
||||
setAuthError('Aanmelden via Authentik is niet gelukt. Probeer opnieuw of gebruik de lokale operatorlogin.')
|
||||
query.delete('authentik')
|
||||
const suffix = query.toString()
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${suffix ? `?${suffix}` : ''}${window.location.hash}`)
|
||||
}, [])
|
||||
|
||||
const scrollAccessPanelIntoView = () => {
|
||||
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
@@ -247,6 +259,17 @@ export function LandingPage({
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
|
||||
{authentikEnabled ? (
|
||||
<a className="landing-authentik-submit" href="/api/v1/auth/authentik/start">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
Aanmelden met Authentik
|
||||
</a>
|
||||
) : null}
|
||||
{authentikEnabled ? (
|
||||
<div className="landing-access-divider">
|
||||
<span>of met lokale operatorgegevens</span>
|
||||
</div>
|
||||
) : null}
|
||||
<label htmlFor="login-username">Gebruikersnaam</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
|
||||
@@ -10,6 +10,7 @@ const signedOutSession: AuthSession = {
|
||||
expires_at: null,
|
||||
role: null,
|
||||
guest_access_enabled: false,
|
||||
authentik_enabled: false,
|
||||
guest_project_id: null,
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useOperatorSession() {
|
||||
setSession((current) => ({
|
||||
...signedOutSession,
|
||||
guest_access_enabled: current?.guest_access_enabled ?? false,
|
||||
authentik_enabled: current?.authentik_enabled ?? false,
|
||||
}))
|
||||
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getWorkbenchAccessCapabilities } from './accessCapabilities'
|
||||
|
||||
describe('workbench access capabilities', () => {
|
||||
it('keeps the complete analysis journey available to the demo', () => {
|
||||
const guest = getWorkbenchAccessCapabilities('guest')
|
||||
|
||||
expect(guest).toMatchObject({
|
||||
analyzePersistedData: true,
|
||||
selectModels: true,
|
||||
runQualityChecks: true,
|
||||
exportResults: true,
|
||||
acquireSources: true,
|
||||
writeDerivedDatasets: true,
|
||||
runChangeDetection: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not advertise operator-only mutations to a demo session', () => {
|
||||
const guest = getWorkbenchAccessCapabilities('guest')
|
||||
|
||||
expect(guest).toMatchObject({
|
||||
manageWorkspace: false,
|
||||
manageModels: false,
|
||||
reviewEvidence: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps local open mode and an operator fully capable', () => {
|
||||
expect(getWorkbenchAccessCapabilities('open')).toEqual(getWorkbenchAccessCapabilities('operator'))
|
||||
expect(Object.values(getWorkbenchAccessCapabilities('operator')).every(Boolean)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
export type WorkbenchAccessMode = 'open' | 'operator' | 'guest'
|
||||
|
||||
export interface WorkbenchAccessCapabilities {
|
||||
analyzePersistedData: boolean
|
||||
selectModels: boolean
|
||||
runQualityChecks: boolean
|
||||
exportResults: boolean
|
||||
acquireSources: boolean
|
||||
manageWorkspace: boolean
|
||||
manageModels: boolean
|
||||
writeDerivedDatasets: boolean
|
||||
reviewEvidence: boolean
|
||||
runChangeDetection: boolean
|
||||
}
|
||||
|
||||
const OPERATOR_CAPABILITIES: WorkbenchAccessCapabilities = {
|
||||
analyzePersistedData: true,
|
||||
selectModels: true,
|
||||
runQualityChecks: true,
|
||||
exportResults: true,
|
||||
acquireSources: true,
|
||||
manageWorkspace: true,
|
||||
manageModels: true,
|
||||
writeDerivedDatasets: true,
|
||||
reviewEvidence: true,
|
||||
runChangeDetection: true,
|
||||
}
|
||||
|
||||
const GUEST_CAPABILITIES: WorkbenchAccessCapabilities = {
|
||||
analyzePersistedData: true,
|
||||
selectModels: true,
|
||||
runQualityChecks: true,
|
||||
exportResults: true,
|
||||
acquireSources: true,
|
||||
manageWorkspace: false,
|
||||
manageModels: false,
|
||||
writeDerivedDatasets: true,
|
||||
reviewEvidence: false,
|
||||
runChangeDetection: true,
|
||||
}
|
||||
|
||||
/** Mirrors the backend's explicit guest route boundary without weakening it. */
|
||||
export function getWorkbenchAccessCapabilities(mode: WorkbenchAccessMode): WorkbenchAccessCapabilities {
|
||||
return mode === 'guest' ? GUEST_CAPABILITIES : OPERATOR_CAPABILITIES
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface AuthSession {
|
||||
expires_at: string | null
|
||||
role: 'operator' | 'guest' | null
|
||||
guest_access_enabled: boolean
|
||||
authentik_enabled: boolean
|
||||
guest_project_id: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user