Support authz for read-only web access

This updates the web UI to support the requirement for authn/z
for read-only access.

If authz is required for read access, we will automatically redirect.
If we return and still aren't authorized, we will display an
"Authorization required" page (rather than continuing and popping up
API error notifications).

The API methods are updated to send an authorization token whenever
one is present.

Change-Id: I31c13c943d05819b4122fcbcf2eaf41515c5b1d9
This commit is contained in:
James E. Blair 2022-09-30 11:05:46 -07:00
parent 95ec2c45e5
commit 9d2e1339ff
17 changed files with 242 additions and 120 deletions

View File

@ -3551,6 +3551,7 @@ class TestWebApiAccessRules(BaseTestWeb):
'/api/connections', '/api/connections',
'/api/components', '/api/components',
'/api/tenants', '/api/tenants',
'/api/authorizations',
'/api/tenant/{tenant}/status', '/api/tenant/{tenant}/status',
'/api/tenant/{tenant}/status/change/{change}', '/api/tenant/{tenant}/status/change/{change}',
'/api/tenant/{tenant}/jobs', '/api/tenant/{tenant}/jobs',

View File

@ -20,6 +20,7 @@ import PropTypes from 'prop-types'
import { matchPath, withRouter } from 'react-router' import { matchPath, withRouter } from 'react-router'
import { Link, NavLink, Redirect, Route, Switch } from 'react-router-dom' import { Link, NavLink, Redirect, Route, Switch } from 'react-router-dom'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { withAuth } from 'oidc-react'
import { import {
TimedToastNotification, TimedToastNotification,
ToastNotificationList, ToastNotificationList,
@ -65,17 +66,19 @@ import SelectTz from './containers/timezone/SelectTz'
import ConfigModal from './containers/config/Config' import ConfigModal from './containers/config/Config'
import logo from './images/logo.svg' import logo from './images/logo.svg'
import { clearNotification } from './actions/notifications' import { clearNotification } from './actions/notifications'
import { fetchConfigErrorsAction } from './actions/configErrors' import { fetchConfigErrorsAction, clearConfigErrorsAction } from './actions/configErrors'
import { routes } from './routes' import { routes } from './routes'
import { setTenantAction } from './actions/tenant' import { setTenantAction } from './actions/tenant'
import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth' import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
import { getHomepageUrl } from './api' import { getHomepageUrl } from './api'
import AuthCallbackPage from './pages/AuthCallback' import AuthCallbackPage from './pages/AuthCallback'
import AuthRequiredPage from './pages/AuthRequired'
class App extends React.Component { class App extends React.Component {
static propTypes = { static propTypes = {
notifications: PropTypes.array, notifications: PropTypes.array,
configErrors: PropTypes.array, configErrors: PropTypes.array,
configErrorsReady: PropTypes.bool,
info: PropTypes.object, info: PropTypes.object,
tenant: PropTypes.object, tenant: PropTypes.object,
timezone: PropTypes.string, timezone: PropTypes.string,
@ -85,6 +88,7 @@ class App extends React.Component {
isKebabDropdownOpen: PropTypes.bool, isKebabDropdownOpen: PropTypes.bool,
user: PropTypes.object, user: PropTypes.object,
auth: PropTypes.object, auth: PropTypes.object,
signIn: PropTypes.func,
} }
state = { state = {
@ -116,8 +120,17 @@ class App extends React.Component {
} }
} }
isAuthReady() {
const { info, auth, user } = this.props
return !(info.isFetching ||
!auth.info ||
auth.isFetching ||
(user.data && user.data.isFetching) ||
user.isFetching)
}
renderContent = () => { renderContent = () => {
const { info, tenant, auth } = this.props const { tenant, auth, user } = this.props
const allRoutes = [] const allRoutes = []
if ((window.location.origin + window.location.pathname) === if ((window.location.origin + window.location.pathname) ===
@ -126,9 +139,19 @@ class App extends React.Component {
// validation is complete (it will internally redirect when complete) // validation is complete (it will internally redirect when complete)
return <AuthCallbackPage/> return <AuthCallbackPage/>
} }
if (info.isFetching || !auth.info || auth.isFetching) { if (!this.isAuthReady()) {
return <Fetching /> return <Fetching />
} }
if (auth.info.read_protected && !user.data) {
console.log('Read-access login required')
const redirect_target = window.location.href.slice(getHomepageUrl().length)
localStorage.setItem('zuul_auth_redirect', redirect_target)
this.props.signIn()
return <Fetching />
}
if (auth.info.read_protected && user.scope.length<1) {
return <AuthRequiredPage/>
}
this.menu this.menu
// Do not include '/tenants' route in white-label setup // Do not include '/tenants' route in white-label setup
.filter(item => .filter(item =>
@ -164,9 +187,10 @@ class App extends React.Component {
componentDidUpdate() { componentDidUpdate() {
// This method is called when info property is updated // This method is called when info property is updated
const { tenant, info } = this.props const { tenant, info, auth, user, configErrorsReady } = this.props
if (info.ready) { if (info.ready) {
let tenantName, whiteLabel let tenantName = null
let whiteLabel
if (info.tenant) { if (info.tenant) {
// White label // White label
@ -188,7 +212,7 @@ class App extends React.Component {
const tenantAction = setTenantAction(tenantName, whiteLabel) const tenantAction = setTenantAction(tenantName, whiteLabel)
this.props.dispatch(tenantAction) this.props.dispatch(tenantAction)
if (tenantName) { if (tenantName) {
this.props.dispatch(fetchConfigErrorsAction(tenantAction.tenant)) this.props.dispatch(clearConfigErrorsAction())
} }
if (whiteLabel || !tenantName) { if (whiteLabel || !tenantName) {
// The app info endpoint was already a tenant info // The app info endpoint was already a tenant info
@ -199,6 +223,12 @@ class App extends React.Component {
this.props.dispatch(configureAuthFromTenant(tenantName)) this.props.dispatch(configureAuthFromTenant(tenantName))
} }
} }
if (tenant && tenant.name && !configErrorsReady && this.isAuthReady() &&
(!auth.info.read_protected || user.data)) {
// This will happen after the tenant action is complete, so we
// can use the "old" tenant now.
this.props.dispatch(fetchConfigErrorsAction(tenant))
}
} }
} }
@ -487,11 +517,12 @@ class App extends React.Component {
export default withRouter(connect( export default withRouter(connect(
state => ({ state => ({
notifications: state.notifications, notifications: state.notifications,
configErrors: state.configErrors, configErrors: state.configErrors.errors,
configErrorsReady: state.configErrors.ready,
info: state.info, info: state.info,
tenant: state.tenant, tenant: state.tenant,
timezone: state.timezone, timezone: state.timezone,
user: state.user, user: state.user,
auth: state.auth, auth: state.auth,
}) })
)(App)) )(withAuth(App)))

View File

@ -18,11 +18,19 @@ export function fetchConfigErrorsAction (tenant) {
return (dispatch) => { return (dispatch) => {
return fetchConfigErrors(tenant.apiPrefix) return fetchConfigErrors(tenant.apiPrefix)
.then(response => { .then(response => {
dispatch({type: 'FETCH_CONFIGERRORS_SUCCESS', dispatch({type: 'CONFIGERRORS_FETCH_SUCCESS',
errors: response.data}) errors: response.data})
}) })
.catch(error => { .catch(error => {
throw (error) dispatch({type: 'CONFIGERRORS_FETCH_FAIL',
error})
}) })
} }
} }
export function clearConfigErrorsAction () {
return (dispatch) => {
dispatch({type: 'CONFIGERRORS_CLEAR'})
}
}

View File

@ -37,10 +37,12 @@ export const fetchUserACLRequest = (tenant) => ({
}) })
export const userLoggedIn = (user, redirect) => (dispatch) => { export const userLoggedIn = (user, redirect) => (dispatch) => {
const token = getToken(user)
API.setAuthToken(token)
dispatch({ dispatch({
type: USER_LOGGED_IN, type: USER_LOGGED_IN,
user: user, user: user,
token: getToken(user), token: token,
redirect: redirect, redirect: redirect,
}) })
} }
@ -62,10 +64,10 @@ const fetchUserACLFail = error => ({
error error
}) })
export const fetchUserACL = (tenant, user) => (dispatch) => { export const fetchUserACL = (tenant) => (dispatch) => {
dispatch(fetchUserACLRequest(tenant)) dispatch(fetchUserACLRequest(tenant))
let apiPrefix = 'tenant/' + tenant + '/' let apiPrefix = tenant? 'tenant/' + tenant + '/' : ''
return API.fetchUserAuthorizations(apiPrefix, user.token) return API.fetchUserAuthorizations(apiPrefix)
.then(response => dispatch(fetchUserACLSuccess(response.data))) .then(response => dispatch(fetchUserACLSuccess(response.data)))
.catch(error => { .catch(error => {
dispatch(fetchUserACLFail(error)) dispatch(fetchUserACLFail(error))

View File

@ -14,6 +14,12 @@
import Axios from 'axios' import Axios from 'axios'
let authToken = undefined
export function setAuthToken(token) {
authToken = token
}
function getHomepageUrl(url) { function getHomepageUrl(url) {
// //
// Discover serving location from href. // Discover serving location from href.
@ -103,14 +109,25 @@ function getStreamUrl(apiPrefix) {
return streamUrl return streamUrl
} }
function getWithCorsHandling(url) { function makeRequest(url, method, data) {
if (method === undefined) {
method = 'get'
}
// This performs a simple GET and tries to detect if CORS errors are // This performs a simple GET and tries to detect if CORS errors are
// due to proxy authentication errors. // due to proxy authentication errors.
const instance = Axios.create({ const instance = Axios.create({
baseURL: apiUrl baseURL: apiUrl
}) })
if (authToken) {
instance.defaults.headers.common['Authorization'] = 'Bearer ' + authToken
}
const config = {method, url, data}
// First try the request as normal // First try the request as normal
let res = instance.get(url).catch(err => { let res = instance.request(config).catch(err => {
if (err.response === undefined) { if (err.response === undefined) {
// This is either a Network, DNS, or CORS error, but we can't tell which. // This is either a Network, DNS, or CORS error, but we can't tell which.
// If we're behind an authz proxy, it's possible our creds have timed out // If we're behind an authz proxy, it's possible our creds have timed out
@ -119,7 +136,7 @@ function getWithCorsHandling(url) {
// issuing a redirect if X-Requested-With is set to 'XMLHttpRequest' and // issuing a redirect if X-Requested-With is set to 'XMLHttpRequest' and
// will instead issue a 403. We can use this to detect that case. // will instead issue a 403. We can use this to detect that case.
instance.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' instance.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
let res2 = instance.get(url).catch(err2 => { let res2 = instance.request(config).catch(err2 => {
if (err2.response && err2.response.status === 403) { if (err2.response && err2.response.status === 403) {
// We might be getting a redirect or something else, // We might be getting a redirect or something else,
// so reload the page. // so reload the page.
@ -133,165 +150,165 @@ function getWithCorsHandling(url) {
}) })
return res2 return res2
} }
throw (err)
}) })
return res return res
} }
// Direct APIs // Direct APIs
function fetchInfo() { function fetchInfo() {
return getWithCorsHandling('info') return makeRequest('info')
} }
function fetchComponents() { function fetchComponents() {
return getWithCorsHandling('components') return makeRequest('components')
} }
function fetchTenantInfo(apiPrefix) { function fetchTenantInfo(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'info') return makeRequest(apiPrefix + 'info')
} }
function fetchOpenApi() { function fetchOpenApi() {
return Axios.get(getHomepageUrl() + 'openapi.yaml') return Axios.get(getHomepageUrl() + 'openapi.yaml')
} }
function fetchTenants() { function fetchTenants() {
return getWithCorsHandling(apiUrl + 'tenants') return makeRequest(apiUrl + 'tenants')
} }
function fetchConfigErrors(apiPrefix) { function fetchConfigErrors(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'config-errors') return makeRequest(apiPrefix + 'config-errors')
} }
function fetchStatus(apiPrefix) { function fetchStatus(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'status') return makeRequest(apiPrefix + 'status')
} }
function fetchChangeStatus(apiPrefix, changeId) { function fetchChangeStatus(apiPrefix, changeId) {
return getWithCorsHandling(apiPrefix + 'status/change/' + changeId) return makeRequest(apiPrefix + 'status/change/' + changeId)
} }
function fetchFreezeJob(apiPrefix, pipelineName, projectName, branchName, jobName) { function fetchFreezeJob(apiPrefix, pipelineName, projectName, branchName, jobName) {
return getWithCorsHandling(apiPrefix + return makeRequest(apiPrefix +
'pipeline/' + pipelineName + 'pipeline/' + pipelineName +
'/project/' + projectName + '/project/' + projectName +
'/branch/' + branchName + '/branch/' + branchName +
'/freeze-job/' + jobName) '/freeze-job/' + jobName)
} }
function fetchBuild(apiPrefix, buildId) { function fetchBuild(apiPrefix, buildId) {
return getWithCorsHandling(apiPrefix + 'build/' + buildId) return makeRequest(apiPrefix + 'build/' + buildId)
} }
function fetchBuilds(apiPrefix, queryString) { function fetchBuilds(apiPrefix, queryString) {
let path = 'builds' let path = 'builds'
if (queryString) { if (queryString) {
path += '?' + queryString.slice(1) path += '?' + queryString.slice(1)
} }
return getWithCorsHandling(apiPrefix + path) return makeRequest(apiPrefix + path)
} }
function fetchBuildset(apiPrefix, buildsetId) { function fetchBuildset(apiPrefix, buildsetId) {
return getWithCorsHandling(apiPrefix + 'buildset/' + buildsetId) return makeRequest(apiPrefix + 'buildset/' + buildsetId)
} }
function fetchBuildsets(apiPrefix, queryString) { function fetchBuildsets(apiPrefix, queryString) {
let path = 'buildsets' let path = 'buildsets'
if (queryString) { if (queryString) {
path += '?' + queryString.slice(1) path += '?' + queryString.slice(1)
} }
return getWithCorsHandling(apiPrefix + path) return makeRequest(apiPrefix + path)
} }
function fetchPipelines(apiPrefix) { function fetchPipelines(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'pipelines') return makeRequest(apiPrefix + 'pipelines')
} }
function fetchProject(apiPrefix, projectName) { function fetchProject(apiPrefix, projectName) {
return getWithCorsHandling(apiPrefix + 'project/' + projectName) return makeRequest(apiPrefix + 'project/' + projectName)
} }
function fetchProjects(apiPrefix) { function fetchProjects(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'projects') return makeRequest(apiPrefix + 'projects')
} }
function fetchJob(apiPrefix, jobName) { function fetchJob(apiPrefix, jobName) {
return getWithCorsHandling(apiPrefix + 'job/' + jobName) return makeRequest(apiPrefix + 'job/' + jobName)
} }
function fetchJobGraph(apiPrefix, projectName, pipelineName, branchName) { function fetchJobGraph(apiPrefix, projectName, pipelineName, branchName) {
return getWithCorsHandling(apiPrefix + return makeRequest(apiPrefix +
'pipeline/' + pipelineName + 'pipeline/' + pipelineName +
'/project/' + projectName + '/project/' + projectName +
'/branch/' + branchName + '/branch/' + branchName +
'/freeze-jobs') '/freeze-jobs')
} }
function fetchJobs(apiPrefix) { function fetchJobs(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'jobs') return makeRequest(apiPrefix + 'jobs')
} }
function fetchLabels(apiPrefix) { function fetchLabels(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'labels') return makeRequest(apiPrefix + 'labels')
} }
function fetchNodes(apiPrefix) { function fetchNodes(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'nodes') return makeRequest(apiPrefix + 'nodes')
} }
function fetchSemaphores(apiPrefix) { function fetchSemaphores(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'semaphores') return makeRequest(apiPrefix + 'semaphores')
} }
function fetchAutoholds(apiPrefix) { function fetchAutoholds(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'autohold') return makeRequest(apiPrefix + 'autohold')
} }
function fetchAutohold(apiPrefix, requestId) { function fetchAutohold(apiPrefix, requestId) {
return getWithCorsHandling(apiPrefix + 'autohold/' + requestId) return makeRequest(apiPrefix + 'autohold/' + requestId)
} }
// token-protected API function fetchUserAuthorizations(apiPrefix) {
function fetchUserAuthorizations(apiPrefix, token) { return makeRequest(apiPrefix + 'authorizations')
// Axios.defaults.headers.common['Authorization'] = 'Bearer ' + token
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.get(apiPrefix + 'authorizations')
.catch(err => { console.log('An error occurred', err) })
// Axios.defaults.headers.common['Authorization'] = ''
return res
} }
function dequeue(apiPrefix, projectName, pipeline, change, token) { function dequeue(apiPrefix, projectName, pipeline, change) {
const instance = Axios.create({ return makeRequest(
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/dequeue', apiPrefix + 'project/' + projectName + '/dequeue',
'post',
{ {
pipeline: pipeline, pipeline: pipeline,
change: change, change: change,
} }
) )
return res
} }
function dequeue_ref(apiPrefix, projectName, pipeline, ref, token) {
const instance = Axios.create({ function dequeue_ref(apiPrefix, projectName, pipeline, ref) {
baseURL: apiUrl return makeRequest(
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/dequeue', apiPrefix + 'project/' + projectName + '/dequeue',
'post',
{ {
pipeline: pipeline, pipeline: pipeline,
ref: ref, ref: ref,
} }
) )
return res
} }
function enqueue(apiPrefix, projectName, pipeline, change, token) { function enqueue(apiPrefix, projectName, pipeline, change) {
const instance = Axios.create({ return makeRequest(
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/enqueue', apiPrefix + 'project/' + projectName + '/enqueue',
'post',
{ {
pipeline: pipeline, pipeline: pipeline,
change: change, change: change,
} }
) )
return res
} }
function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, token) {
const instance = Axios.create({ function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev) {
baseURL: apiUrl return makeRequest(
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/enqueue', apiPrefix + 'project/' + projectName + '/enqueue',
'post',
{ {
pipeline: pipeline, pipeline: pipeline,
ref: ref, ref: ref,
@ -299,16 +316,13 @@ function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, toke
newrev: newrev, newrev: newrev,
} }
) )
return res
} }
function autohold(apiPrefix, projectName, job, change, ref, function autohold(apiPrefix, projectName, job, change, ref,
reason, count, node_hold_expiration, token) { reason, count, node_hold_expiration) {
const instance = Axios.create({ return makeRequest(
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/autohold', apiPrefix + 'project/' + projectName + '/autohold',
'post',
{ {
change: change, change: change,
job: job, job: job,
@ -318,33 +332,24 @@ function autohold(apiPrefix, projectName, job, change, ref,
node_hold_expiration: node_hold_expiration, node_hold_expiration: node_hold_expiration,
} }
) )
return res
} }
function autohold_delete(apiPrefix, requestId, token) { function autohold_delete(apiPrefix, requestId) {
const instance = Axios.create({ return makeRequest(
baseURL: apiUrl apiPrefix + '/autohold/' + requestId,
}) 'delete'
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.delete(
apiPrefix + '/autohold/' + requestId
) )
return res
} }
function promote(apiPrefix, pipeline, changes, token) { function promote(apiPrefix, pipeline, changes) {
const instance = Axios.create({ return makeRequest(
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + '/promote', apiPrefix + '/promote',
'post',
{ {
pipeline: pipeline, pipeline: pipeline,
changes: changes, changes: changes,
} }
) )
return res
} }

View File

@ -104,8 +104,14 @@ class AuthContainer extends React.Component {
} }
componentDidMount() { componentDidMount() {
const { user, tenant } = this.props
this.props.userManager.events.addAccessTokenExpired(this.onAccessTokenExpired) this.props.userManager.events.addAccessTokenExpired(this.onAccessTokenExpired)
this.props.userManager.events.addUserLoaded(this.onUserLoaded) this.props.userManager.events.addUserLoaded(this.onUserLoaded)
if (user.data) {
console.log('Refreshing ACL', user.tenant, tenant.name)
this.props.dispatch(fetchUserACL(tenant? tenant.name : null))
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -119,7 +125,7 @@ class AuthContainer extends React.Component {
// Make sure the token is current and the tenant is up to date. // Make sure the token is current and the tenant is up to date.
if (user.data && user.tenant !== tenant.name) { if (user.data && user.tenant !== tenant.name) {
console.log('Refreshing ACL', user.tenant, tenant.name) console.log('Refreshing ACL', user.tenant, tenant.name)
this.props.dispatch(fetchUserACL(tenant.name, user)) this.props.dispatch(fetchUserACL(tenant? tenant.name : null))
} }
} }

View File

@ -87,7 +87,7 @@ function AutoholdTable(props) {
] ]
function handleAutoholdDelete(requestId) { function handleAutoholdDelete(requestId) {
autohold_delete(tenant.apiPrefix, requestId, user.token) autohold_delete(tenant.apiPrefix, requestId)
.then(() => { .then(() => {
dispatch(addNotification( dispatch(addNotification(
{ {

View File

@ -64,7 +64,7 @@ const AutoholdModal = props => {
let ah_change = change === '' ? null : change let ah_change = change === '' ? null : change
let ah_ref = changeRef === '' ? null : changeRef let ah_ref = changeRef === '' ? null : changeRef
autohold(tenant.apiPrefix, project, job_name, ah_change, ah_ref, reason, parseInt(count), parseInt(nodeHoldExpiration), user.token) autohold(tenant.apiPrefix, project, job_name, ah_change, ah_ref, reason, parseInt(count), parseInt(nodeHoldExpiration))
.then(() => { .then(() => {
/* TODO it looks like there is a delay in the registering of the autohold request /* TODO it looks like there is a delay in the registering of the autohold request
by the backend, meaning we sometimes do not get the newly created request after by the backend, meaning we sometimes do not get the newly created request after
@ -209,4 +209,4 @@ AutoholdModal.propTypes = {
export default connect((state) => ({ export default connect((state) => ({
tenant: state.tenant, tenant: state.tenant,
user: state.user, user: state.user,
}))(AutoholdModal) }))(AutoholdModal)

View File

@ -181,7 +181,7 @@ function Buildset({ buildset, timezone, tenant, user }) {
if (buildset.change === null) { if (buildset.change === null) {
const oldrev = '0000000000000000000000000000000000000000' const oldrev = '0000000000000000000000000000000000000000'
const newrev = buildset.newrev ? buildset.newrev : '0000000000000000000000000000000000000000' const newrev = buildset.newrev ? buildset.newrev : '0000000000000000000000000000000000000000'
enqueue_ref(tenant.apiPrefix, buildset.project, buildset.pipeline, buildset.ref, oldrev, newrev, user.token) enqueue_ref(tenant.apiPrefix, buildset.project, buildset.pipeline, buildset.ref, oldrev, newrev)
.then(() => { .then(() => {
dispatch(addNotification( dispatch(addNotification(
{ {
@ -196,7 +196,7 @@ function Buildset({ buildset, timezone, tenant, user }) {
}) })
} else { } else {
const changeId = buildset.change + ',' + buildset.patchset const changeId = buildset.change + ',' + buildset.patchset
enqueue(tenant.apiPrefix, buildset.project, buildset.pipeline, changeId, user.token) enqueue(tenant.apiPrefix, buildset.project, buildset.pipeline, changeId)
.then(() => { .then(() => {
dispatch(addNotification( dispatch(addNotification(
{ {

View File

@ -58,14 +58,14 @@ class Change extends React.Component {
} }
dequeueConfirm = () => { dequeueConfirm = () => {
const { tenant, user, change, pipeline } = this.props const { tenant, change, pipeline } = this.props
let projectName = change.project let projectName = change.project
let changeId = change.id || 'N/A' let changeId = change.id || 'N/A'
let changeRef = change.ref let changeRef = change.ref
this.setState(() => ({ showDequeueModal: false })) this.setState(() => ({ showDequeueModal: false }))
// post-merge // post-merge
if (/^[0-9a-f]{40}$/.test(changeId)) { if (/^[0-9a-f]{40}$/.test(changeId)) {
dequeue_ref(tenant.apiPrefix, projectName, pipeline.name, changeRef, user.token) dequeue_ref(tenant.apiPrefix, projectName, pipeline.name, changeRef)
.then(() => { .then(() => {
this.props.dispatch(fetchStatusIfNeeded(tenant)) this.props.dispatch(fetchStatusIfNeeded(tenant))
}) })
@ -74,7 +74,7 @@ class Change extends React.Component {
}) })
// pre-merge, ie we have a change id // pre-merge, ie we have a change id
} else if (changeId !== 'N/A') { } else if (changeId !== 'N/A') {
dequeue(tenant.apiPrefix, projectName, pipeline.name, changeId, user.token) dequeue(tenant.apiPrefix, projectName, pipeline.name, changeId)
.then(() => { .then(() => {
this.props.dispatch(fetchStatusIfNeeded(tenant)) this.props.dispatch(fetchStatusIfNeeded(tenant))
}) })
@ -118,11 +118,11 @@ class Change extends React.Component {
} }
promoteConfirm = () => { promoteConfirm = () => {
const { tenant, user, change, pipeline } = this.props const { tenant, change, pipeline } = this.props
let changeId = change.id || 'NA' let changeId = change.id || 'NA'
this.setState(() => ({ showPromoteModal: false })) this.setState(() => ({ showPromoteModal: false }))
if (changeId !== 'N/A') { if (changeId !== 'N/A') {
promote(tenant.apiPrefix, pipeline.name, [changeId,], user.token) promote(tenant.apiPrefix, pipeline.name, [changeId,])
.then(() => { .then(() => {
this.props.dispatch(fetchStatusIfNeeded(tenant)) this.props.dispatch(fetchStatusIfNeeded(tenant))
}) })

View File

@ -1,4 +1,5 @@
// Copyright 2020 Red Hat, Inc // Copyright 2020 Red Hat, Inc
// Copyright 2022 Acme Gating, LLC
// //
// Licensed under the Apache License, Version 2.0 (the "License"); you may // Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain // not use this file except in compliance with the License. You may obtain

View File

@ -0,0 +1,44 @@
// Copyright 2020 Red Hat, Inc
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import {
EmptyState,
EmptyStateBody,
EmptyStateIcon,
Title,
} from '@patternfly/react-core'
import {
LockIcon,
} from '@patternfly/react-icons'
function AuthRequiredPage() {
return (
<>
<EmptyState>
<EmptyStateIcon icon={LockIcon} />
<Title headingLevel="h1">Unauthorized</Title>
<EmptyStateBody>
<p>
Authorization is required.
</p>
</EmptyStateBody>
</EmptyState>
</>
)
}
export default AuthRequiredPage

View File

@ -12,10 +12,14 @@
// License for the specific language governing permissions and limitations // License for the specific language governing permissions and limitations
// under the License. // under the License.
export default (state = [], action) => { export default (state = {errors: [], ready: false}, action) => {
switch (action.type) { switch (action.type) {
case 'FETCH_CONFIGERRORS_SUCCESS': case 'CONFIGERRORS_FETCH_SUCCESS':
return action.errors return {errors: action.errors, ready: true}
case 'CONFIGERRORS_FETCH_FAIL':
return {errors: [], ready: true}
case 'CONFIGERRORS_CLEAR':
return {errors: [], ready: false}
default: default:
return state return state
} }

View File

@ -22,6 +22,7 @@ import {
export default (state = [], action) => { export default (state = [], action) => {
// Intercept API failure // Intercept API failure
// TODO: Are these still used?
if (action.notification && action.type.match(/.*_FETCH_FAIL$/)) { if (action.notification && action.type.match(/.*_FETCH_FAIL$/)) {
action = addApiError(action.notification) action = addApiError(action.notification)
} }

View File

@ -14,7 +14,9 @@
import { TENANT_SET } from '../actions/tenant' import { TENANT_SET } from '../actions/tenant'
export default (state = {name: null}, action) => { // undefined name means we haven't loaded anything yet; null means
// outside of tenant context.
export default (state = {name: undefined}, action) => {
switch (action.type) { switch (action.type) {
case TENANT_SET: case TENANT_SET:
return action.tenant return action.tenant

View File

@ -28,7 +28,9 @@ export default (state = {
data: null, data: null,
scope: [], scope: [],
isAdmin: false, isAdmin: false,
tenant: null, // undefined tenant means we haven't loaded anything yet; null means
// outside of tenant context.
tenant: undefined,
redirect: null, redirect: null,
}, action) => { }, action) => {
switch (action.type) { switch (action.type) {
@ -39,7 +41,8 @@ export default (state = {
token: action.token, token: action.token,
redirect: action.redirect, redirect: action.redirect,
scope: [], scope: [],
isAdmin: false isAdmin: false,
tenant: undefined,
} }
} }
case USER_LOGGED_OUT: case USER_LOGGED_OUT:
@ -49,7 +52,8 @@ export default (state = {
token: null, token: null,
redirect: null, redirect: null,
scope: [], scope: [],
isAdmin: false isAdmin: false,
tenant: undefined,
} }
case USER_ACL_REQUEST: case USER_ACL_REQUEST:
return { return {

View File

@ -804,6 +804,7 @@ class ZuulWebAPI(object):
'info': '/api/info', 'info': '/api/info',
'connections': '/api/connections', 'connections': '/api/connections',
'components': '/api/components', 'components': '/api/components',
'authorizations': '/api/authorizations',
'tenants': '/api/tenants', 'tenants': '/api/tenants',
'tenant_info': '/api/tenant/{tenant}/info', 'tenant_info': '/api/tenant/{tenant}/info',
'status': '/api/tenant/{tenant}/status', 'status': '/api/tenant/{tenant}/status',
@ -897,7 +898,7 @@ class ZuulWebAPI(object):
else: else:
tenant_name = '*' tenant_name = '*'
admin_rules = [] admin_rules = []
access_rules = self.zuulweb.api_root.access_rules access_rules = self.zuulweb.abide.api_root.access_rules
override = claims.get('zuul', {}).get('admin', []) override = claims.get('zuul', {}).get('admin', [])
if (override == '*' or if (override == '*' or
(isinstance(override, list) and tenant_name in override)): (isinstance(override, list) and tenant_name in override)):
@ -952,6 +953,14 @@ class ZuulWebAPI(object):
break break
return (access, admin) return (access, admin)
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options()
@cherrypy.tools.check_root_auth(require_auth=True)
def root_authorizations(self, auth):
return {'zuul': {'admin': auth.admin,
'scope': ['*']}, }
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8') @cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options() @cherrypy.tools.handle_options()
@ -1950,6 +1959,10 @@ class ZuulWeb(object):
'/api/tenant/{tenant_name}/authorizations', '/api/tenant/{tenant_name}/authorizations',
controller=api, controller=api,
action='tenant_authorizations') action='tenant_authorizations')
route_map.connect('api',
'/api/authorizations',
controller=api,
action='root_authorizations')
route_map.connect('api', '/api/tenant/{tenant_name}/promote', route_map.connect('api', '/api/tenant/{tenant_name}/promote',
controller=api, action='promote') controller=api, action='promote')
route_map.connect( route_map.connect(