Skip to content

Commit

Permalink
Support OIDC authentication in the Lookout UI (armadaproject#3056)
Browse files Browse the repository at this point in the history
Signed-off-by: Noah Held <[email protected]>
  • Loading branch information
zuqq authored Oct 25, 2023
1 parent 46f2581 commit 0dc6f4e
Show file tree
Hide file tree
Showing 25 changed files with 238 additions and 68 deletions.
9 changes: 9 additions & 0 deletions internal/lookout/configuration/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import (
type LookoutUIConfig struct {
CustomTitle string

// We have a separate flag here (instead of making the Oidc field optional)
// so that clients can override the server's preference.
OidcEnabled bool
Oidc struct {
Authority string
ClientId string
Scope string
}

ArmadaApiBaseUrl string
UserAnnotationPrefix string
BinocularsEnabled bool
Expand Down
1 change: 1 addition & 0 deletions internal/lookout/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"jest-junit": "^16.0.0",
"js-yaml": "^4.0.0",
"notistack": "^2.0.8",
"oidc-client-ts": "^2.3.0",
"prettier": "^2.8.8",
"qs": "^6.11.0",
"query-string": "^6.13.7",
Expand Down
42 changes: 40 additions & 2 deletions internal/lookout/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createGenerateClassName } from "@material-ui/core/styles"
import { ThemeProvider as ThemeProviderV5, createTheme as createThemeV5 } from "@mui/material/styles"
import { JobsTableContainer } from "containers/lookoutV2/JobsTableContainer"
import { SnackbarProvider } from "notistack"
import { UserManager, WebStorageStateStore } from "oidc-client-ts"
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"
import { IGetJobsService } from "services/lookoutV2/GetJobsService"
import { IGroupJobsService } from "services/lookoutV2/GroupJobsService"
Expand All @@ -14,12 +15,14 @@ import { withRouter } from "utils"

import NavBar from "./components/NavBar"
import JobSetsContainer from "./containers/JobSetsContainer"
import { UserManagerContext, useUserManager } from "./oidc"
import { JobService } from "./services/JobService"
import LogService from "./services/LogService"
import { ICordonService } from "./services/lookoutV2/CordonService"
import { IGetJobSpecService } from "./services/lookoutV2/GetJobSpecService"
import { IGetRunErrorService } from "./services/lookoutV2/GetRunErrorService"
import { ILogService } from "./services/lookoutV2/LogService"
import { OidcConfig } from "./utils"

import "./App.css"

Expand Down Expand Up @@ -63,6 +66,7 @@ const themeV5 = createThemeV5(theme)

type AppProps = {
customTitle: string
oidcConfig?: OidcConfig
jobService: JobService
v2GetJobsService: IGetJobsService
v2GroupJobsService: IGroupJobsService
Expand All @@ -79,17 +83,32 @@ type AppProps = {
debugEnabled: boolean
}

function OidcCallback(): JSX.Element {
const userManager = useUserManager()
const [error, setError] = React.useState<string | undefined>()
React.useEffect(() => {
userManager &&
userManager.signinPopupCallback().catch((e) => {
setError(`${e}`)
console.error(e)
})
}, [])
if (error !== undefined) return <p>Something went wrong; more details are available in the console.</p>
return <p>Authenticating...</p>
}

// Version 2 of the Lookout UI used to be hosted under /v2, so we try our best
// to redirect users to the new location while preserving the rest of the URL.
const V2Redirect = withRouter(({ router }) => <Navigate to={{ ...router.location, pathname: "/" }} />)

export function App(props: AppProps) {
export function App(props: AppProps): JSX.Element {
useEffect(() => {
if (props.customTitle) {
document.title = `${props.customTitle} - Armada Lookout`
}
}, [props.customTitle])
return (

const result = (
<StylesProvider generateClassName={generateClassName}>
<ThemeProviderV4 theme={themeV4}>
<ThemeProviderV5 theme={themeV5}>
Expand Down Expand Up @@ -119,6 +138,7 @@ export function App(props: AppProps) {
}
/>
<Route path="/job-sets" element={<JobSetsContainer {...props} />} />
<Route path="/oidc" element={<OidcCallback />} />
<Route path="/v2" element={<V2Redirect />} />
<Route
path="*"
Expand All @@ -138,4 +158,22 @@ export function App(props: AppProps) {
</ThemeProviderV4>
</StylesProvider>
)

if (props.oidcConfig === undefined) return result

return (
<UserManagerContext.Provider
value={
new UserManager({
authority: props.oidcConfig.authority,
client_id: props.oidcConfig.clientId,
redirect_uri: `${window.location.origin}/oidc`,
scope: props.oidcConfig.scope,
userStore: new WebStorageStateStore({ store: window.localStorage }),
})
}
>
{result}
</UserManagerContext.Provider>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatJobState } from "utils/jobsTableFormatters"
import dialogStyles from "./DialogStyles.module.css"
import { JobStatusTable } from "./JobStatusTable"
import { useCustomSnackbar } from "../../hooks/useCustomSnackbar"
import { getAccessToken, useUserManager } from "../../oidc"

interface CancelDialogProps {
onClose: () => void
Expand All @@ -40,6 +41,8 @@ export const CancelDialog = ({
const [isPlatformCancel, setIsPlatformCancel] = useState(false)
const openSnackbar = useCustomSnackbar()

const userManager = useUserManager()

// Actions
const fetchSelectedJobs = useCallback(async () => {
if (!mounted.current) {
Expand All @@ -64,7 +67,8 @@ export const CancelDialog = ({
setIsCancelling(true)

const reason = isPlatformCancel ? PlatformCancelReason : ""
const response = await updateJobsService.cancelJobs(cancellableJobs, reason)
const accessToken = userManager && (await getAccessToken(userManager))
const response = await updateJobsService.cancelJobs(cancellableJobs, reason, accessToken)

if (response.failedJobIds.length === 0) {
openSnackbar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getUniqueJobsMatchingFilters } from "utils/jobsDialogUtils"
import dialogStyles from "./DialogStyles.module.css"
import { JobStatusTable } from "./JobStatusTable"
import { useCustomSnackbar } from "../../hooks/useCustomSnackbar"
import { getAccessToken, useUserManager } from "../../oidc"

interface ReprioritiseDialogProps {
onClose: () => void
Expand Down Expand Up @@ -50,6 +51,8 @@ export const ReprioritiseDialog = ({
const [hasAttemptedReprioritise, setHasAttemptedReprioritise] = useState(false)
const openSnackbar = useCustomSnackbar()

const userManager = useUserManager()

// Actions
const fetchSelectedJobs = useCallback(async () => {
if (!mounted.current) {
Expand All @@ -76,7 +79,8 @@ export const ReprioritiseDialog = ({

setIsReprioritising(true)

const response = await updateJobsService.reprioritiseJobs(reprioritisableJobs, newPriority)
const accessToken = userManager && (await getAccessToken(userManager))
const response = await updateJobsService.reprioritiseJobs(reprioritisableJobs, newPriority, accessToken)

if (response.failedJobIds.length === 0) {
openSnackbar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Job, JobRun } from "models/lookoutV2Models"
import styles from "./SidebarTabJobLogs.module.css"
import { useCustomSnackbar } from "../../../hooks/useCustomSnackbar"
import { useJobSpec } from "../../../hooks/useJobSpec"
import { getAccessToken, useUserManager } from "../../../oidc"
import { IGetJobSpecService } from "../../../services/lookoutV2/GetJobSpecService"
import { ILogService, LogLine } from "../../../services/lookoutV2/LogService"
import { getErrorMessage, RequestStatus } from "../../../utils"
Expand Down Expand Up @@ -107,17 +108,20 @@ export const SidebarTabJobLogs = ({ job, jobSpecService, logService }: SidebarTa
}
}, [containers])

const userManager = useUserManager()

const loadLogs = async (sinceTime: string, tailLines: number | undefined): Promise<LogLine[]> => {
setLogsRequestStatus("Loading")
try {
const accessToken = userManager && (await getAccessToken(userManager))
const logLines = await logService.getLogs(
cluster,
namespace,
job.jobId,
selectedContainer,
sinceTime,
tailLines,
undefined,
accessToken,
)
setLogsRequestErrorFull(undefined)
return logLines
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CodeBlock } from "./CodeBlock"
import { KeyValuePairTable } from "./KeyValuePairTable"
import styles from "./SidebarTabJobRuns.module.css"
import { useCustomSnackbar } from "../../../hooks/useCustomSnackbar"
import { getAccessToken, useUserManager } from "../../../oidc"
import { ICordonService } from "../../../services/lookoutV2/CordonService"
import { IGetRunErrorService } from "../../../services/lookoutV2/GetRunErrorService"
import { getErrorMessage } from "../../../utils"
Expand Down Expand Up @@ -51,7 +52,7 @@ export const SidebarTabJobRuns = ({ job, runErrorService, cordonService }: Sideb
for (const run of job.runs) {
results.push({
runId: run.runId,
promise: runErrorService.getRunError(run.runId, undefined),
promise: runErrorService.getRunError(run.runId),
})
}

Expand Down Expand Up @@ -99,9 +100,12 @@ export const SidebarTabJobRuns = ({ job, runErrorService, cordonService }: Sideb
setOpen(false)
}

const userManager = useUserManager()

const cordon = async (cluster: string, node: string) => {
try {
await cordonService.cordonNode(cluster, node, undefined)
const accessToken = userManager && (await getAccessToken(userManager))
await cordonService.cordonNode(cluster, node, accessToken)
openSnackbar("Successfully cordoned node " + node, "success")
} catch (e) {
const errMsg = await getErrorMessage(e)
Expand Down
5 changes: 5 additions & 0 deletions internal/lookout/ui/src/containers/CancelJobSetsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogTitle } from "@material-ui/core"

import CancelJobSets from "../components/job-sets/cancel-job-sets/CancelJobSets"
import CancelJobSetsOutcome from "../components/job-sets/cancel-job-sets/CancelJobSetsOutcome"
import { getAccessToken, useUserManager } from "../oidc"
import { ApiJobState } from "../openapi/armada"
import { JobSet } from "../services/JobService"
import { CancelJobSetsResponse, UpdateJobSetsService } from "../services/lookoutV2/UpdateJobSetsService"
Expand Down Expand Up @@ -51,18 +52,22 @@ export default function CancelJobSetsDialog(props: CancelJobSetsDialogProps) {

const statesToCancel = getStatesToCancel(includeQueued, includeRunning)

const userManager = useUserManager()

async function cancelJobSets() {
if (requestStatus === "Loading") {
return
}

setRequestStatus("Loading")
const reason = isPlatformCancel ? PlatformCancelReason : ""
const accessToken = userManager && (await getAccessToken(userManager))
const cancelJobSetsResponse = await props.updateJobSetsService.cancelJobSets(
props.queue,
jobSetsToCancel,
statesToCancel,
reason,
accessToken,
)
setRequestStatus("Idle")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogTitle } from "@material-ui/core"

import ReprioritizeJobSets from "../components/job-sets/reprioritize-job-sets/ReprioritizeJobSets"
import ReprioritizeJobSetsOutcome from "../components/job-sets/reprioritize-job-sets/ReprioritizeJobSetsOutcome"
import { getAccessToken, useUserManager } from "../oidc"
import { JobSet } from "../services/JobService"
import { ReprioritizeJobSetsResponse, UpdateJobSetsService } from "../services/lookoutV2/UpdateJobSetsService"
import { ApiResult, priorityIsValid, RequestStatus } from "../utils"
Expand Down Expand Up @@ -36,16 +37,20 @@ export default function ReprioritizeJobSetsDialog(props: ReprioritizeJobSetsDial

const jobSetsToReprioritize = getReprioritizeableJobSets(props.selectedJobSets)

const userManager = useUserManager()

async function reprioritizeJobSets() {
if (requestStatus == "Loading" || !priorityIsValid(priority)) {
return
}

setRequestStatus("Loading")
const accessToken = userManager && (await getAccessToken(userManager))
const reprioritizeJobSetsResponse = await props.updateJobSetsService.reprioritizeJobSets(
props.queue,
jobSetsToReprioritize,
Number(priority),
accessToken,
)
setRequestStatus("Idle")

Expand Down
1 change: 1 addition & 0 deletions internal/lookout/ui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import "./index.css"
ReactDOM.render(
<App
customTitle={uiConfig.customTitle}
oidcConfig={uiConfig.oidcEnabled ? uiConfig.oidc : undefined}
jobService={jobService}
v2GetJobsService={v2GetJobsService}
v2GroupJobsService={v2GroupJobsService}
Expand Down
21 changes: 21 additions & 0 deletions internal/lookout/ui/src/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react"

import { UserManager } from "oidc-client-ts"

export const UserManagerContext = React.createContext<UserManager | undefined>(undefined)

export function useUserManager(): UserManager | undefined {
return React.useContext(UserManagerContext)
}

export async function getAccessToken(userManager: UserManager): Promise<string> {
const user = await userManager.getUser()
if (user !== null && !user.expired) return user.access_token
return (await userManager.signinPopup()).access_token
}

export function getAuthorizationHeaders(accessToken: string): Headers {
const headers = new Headers()
headers.append("Authorization", `Bearer ${accessToken}`)
return headers
}
16 changes: 10 additions & 6 deletions internal/lookout/ui/src/services/lookoutV2/CordonService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getAuthorizationHeaders } from "../../oidc"
import { ConfigurationParameters } from "../../openapi/binoculars"
import { getBinocularsApi } from "../../utils"

export interface ICordonService {
cordonNode(cluster: string, node: string, signal: AbortSignal | undefined): Promise<void>
cordonNode(cluster: string, node: string, accessToken?: string, signal?: AbortSignal): Promise<void>
}

export class CordonService implements ICordonService {
Expand All @@ -14,12 +15,15 @@ export class CordonService implements ICordonService {
this.baseUrlPattern = baseUrlPattern
}

async cordonNode(cluster: string, node: string): Promise<void> {
async cordonNode(cluster: string, node: string, accessToken?: string): Promise<void> {
const api = getBinocularsApi(cluster, this.baseUrlPattern, this.config)
await api.cordon({
body: {
nodeName: node,
await api.cordon(
{
body: {
nodeName: node,
},
},
})
accessToken === undefined ? undefined : { headers: getAuthorizationHeaders(accessToken) },
)
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export interface IGetJobSpecService {
getJobSpec(jobId: string, abortSignal: AbortSignal | undefined): Promise<Record<string, any>>
getJobSpec(jobId: string, abortSignal?: AbortSignal): Promise<Record<string, any>>
}

export class GetJobSpecService implements IGetJobSpecService {
constructor(private apiBase: string) {}

async getJobSpec(jobId: string, abortSignal: AbortSignal | undefined): Promise<Record<string, any>> {
async getJobSpec(jobId: string, abortSignal?: AbortSignal): Promise<Record<string, any>> {
const response = await fetch(this.apiBase + "/api/v1/jobSpec", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export interface IGetRunErrorService {
getRunError(runId: string, abortSignal: AbortSignal | undefined): Promise<string>
getRunError(runId: string, abortSignal?: AbortSignal): Promise<string>
}

export class GetRunErrorService implements IGetRunErrorService {
constructor(private apiBase: string) {}

async getRunError(runId: string, abortSignal: AbortSignal | undefined): Promise<string> {
async getRunError(runId: string, abortSignal?: AbortSignal): Promise<string> {
const response = await fetch(this.apiBase + "/api/v1/jobRunError", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down
Loading

0 comments on commit 0dc6f4e

Please sign in to comment.