feat(auth): harden Authentik and guest capability boundaries

This commit is contained in:
Jens
2026-08-30 05:59:49 +02:00
parent b93d926b94
commit 96f90373dc
15 changed files with 928 additions and 8 deletions
+1
View File
@@ -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}
+2
View File
@@ -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)
})
})
+45
View File
@@ -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
}
+1
View File
@@ -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
}