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/components',
'/api/tenants',
'/api/authorizations',
'/api/tenant/{tenant}/status',
'/api/tenant/{tenant}/status/change/{change}',
'/api/tenant/{tenant}/jobs',

View File

@ -20,6 +20,7 @@ import PropTypes from 'prop-types'
import { matchPath, withRouter } from 'react-router'
import { Link, NavLink, Redirect, Route, Switch } from 'react-router-dom'
import { connect } from 'react-redux'
import { withAuth } from 'oidc-react'
import {
TimedToastNotification,
ToastNotificationList,
@ -65,17 +66,19 @@ import SelectTz from './containers/timezone/SelectTz'
import ConfigModal from './containers/config/Config'
import logo from './images/logo.svg'
import { clearNotification } from './actions/notifications'
import { fetchConfigErrorsAction } from './actions/configErrors'
import { fetchConfigErrorsAction, clearConfigErrorsAction } from './actions/configErrors'
import { routes } from './routes'
import { setTenantAction } from './actions/tenant'
import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
import { getHomepageUrl } from './api'
import AuthCallbackPage from './pages/AuthCallback'
import AuthRequiredPage from './pages/AuthRequired'
class App extends React.Component {
static propTypes = {
notifications: PropTypes.array,
configErrors: PropTypes.array,
configErrorsReady: PropTypes.bool,
info: PropTypes.object,
tenant: PropTypes.object,
timezone: PropTypes.string,
@ -85,6 +88,7 @@ class App extends React.Component {
isKebabDropdownOpen: PropTypes.bool,
user: PropTypes.object,
auth: PropTypes.object,
signIn: PropTypes.func,
}
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 = () => {
const { info, tenant, auth } = this.props
const { tenant, auth, user } = this.props
const allRoutes = []
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)
return <AuthCallbackPage/>
}
if (info.isFetching || !auth.info || auth.isFetching) {
if (!this.isAuthReady()) {
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
// Do not include '/tenants' route in white-label setup
.filter(item =>
@ -164,9 +187,10 @@ class App extends React.Component {
componentDidUpdate() {
// 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) {
let tenantName, whiteLabel
let tenantName = null
let whiteLabel
if (info.tenant) {
// White label
@ -188,7 +212,7 @@ class App extends React.Component {
const tenantAction = setTenantAction(tenantName, whiteLabel)
this.props.dispatch(tenantAction)
if (tenantName) {
this.props.dispatch(fetchConfigErrorsAction(tenantAction.tenant))
this.props.dispatch(clearConfigErrorsAction())
}
if (whiteLabel || !tenantName) {
// The app info endpoint was already a tenant info
@ -199,6 +223,12 @@ class App extends React.Component {
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(
state => ({
notifications: state.notifications,
configErrors: state.configErrors,
configErrors: state.configErrors.errors,
configErrorsReady: state.configErrors.ready,
info: state.info,
tenant: state.tenant,
timezone: state.timezone,
user: state.user,
auth: state.auth,
})
)(App))
)(withAuth(App)))

View File

@ -18,11 +18,19 @@ export function fetchConfigErrorsAction (tenant) {
return (dispatch) => {
return fetchConfigErrors(tenant.apiPrefix)
.then(response => {
dispatch({type: 'FETCH_CONFIGERRORS_SUCCESS',
dispatch({type: 'CONFIGERRORS_FETCH_SUCCESS',
errors: response.data})
})
.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) => {
const token = getToken(user)
API.setAuthToken(token)
dispatch({
type: USER_LOGGED_IN,
user: user,
token: getToken(user),
token: token,
redirect: redirect,
})
}
@ -62,10 +64,10 @@ const fetchUserACLFail = error => ({
error
})
export const fetchUserACL = (tenant, user) => (dispatch) => {
export const fetchUserACL = (tenant) => (dispatch) => {
dispatch(fetchUserACLRequest(tenant))
let apiPrefix = 'tenant/' + tenant + '/'
return API.fetchUserAuthorizations(apiPrefix, user.token)
let apiPrefix = tenant? 'tenant/' + tenant + '/' : ''
return API.fetchUserAuthorizations(apiPrefix)
.then(response => dispatch(fetchUserACLSuccess(response.data)))
.catch(error => {
dispatch(fetchUserACLFail(error))

View File

@ -14,6 +14,12 @@
import Axios from 'axios'
let authToken = undefined
export function setAuthToken(token) {
authToken = token
}
function getHomepageUrl(url) {
//
// Discover serving location from href.
@ -103,14 +109,25 @@ function getStreamUrl(apiPrefix) {
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
// due to proxy authentication errors.
const instance = Axios.create({
baseURL: apiUrl
})
if (authToken) {
instance.defaults.headers.common['Authorization'] = 'Bearer ' + authToken
}
const config = {method, url, data}
// First try the request as normal
let res = instance.get(url).catch(err => {
let res = instance.request(config).catch(err => {
if (err.response === undefined) {
// 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
@ -119,7 +136,7 @@ function getWithCorsHandling(url) {
// 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.
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) {
// We might be getting a redirect or something else,
// so reload the page.
@ -133,165 +150,165 @@ function getWithCorsHandling(url) {
})
return res2
}
throw (err)
})
return res
}
// Direct APIs
function fetchInfo() {
return getWithCorsHandling('info')
return makeRequest('info')
}
function fetchComponents() {
return getWithCorsHandling('components')
return makeRequest('components')
}
function fetchTenantInfo(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'info')
return makeRequest(apiPrefix + 'info')
}
function fetchOpenApi() {
return Axios.get(getHomepageUrl() + 'openapi.yaml')
}
function fetchTenants() {
return getWithCorsHandling(apiUrl + 'tenants')
return makeRequest(apiUrl + 'tenants')
}
function fetchConfigErrors(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'config-errors')
return makeRequest(apiPrefix + 'config-errors')
}
function fetchStatus(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'status')
return makeRequest(apiPrefix + 'status')
}
function fetchChangeStatus(apiPrefix, changeId) {
return getWithCorsHandling(apiPrefix + 'status/change/' + changeId)
return makeRequest(apiPrefix + 'status/change/' + changeId)
}
function fetchFreezeJob(apiPrefix, pipelineName, projectName, branchName, jobName) {
return getWithCorsHandling(apiPrefix +
return makeRequest(apiPrefix +
'pipeline/' + pipelineName +
'/project/' + projectName +
'/branch/' + branchName +
'/freeze-job/' + jobName)
}
function fetchBuild(apiPrefix, buildId) {
return getWithCorsHandling(apiPrefix + 'build/' + buildId)
return makeRequest(apiPrefix + 'build/' + buildId)
}
function fetchBuilds(apiPrefix, queryString) {
let path = 'builds'
if (queryString) {
path += '?' + queryString.slice(1)
}
return getWithCorsHandling(apiPrefix + path)
return makeRequest(apiPrefix + path)
}
function fetchBuildset(apiPrefix, buildsetId) {
return getWithCorsHandling(apiPrefix + 'buildset/' + buildsetId)
return makeRequest(apiPrefix + 'buildset/' + buildsetId)
}
function fetchBuildsets(apiPrefix, queryString) {
let path = 'buildsets'
if (queryString) {
path += '?' + queryString.slice(1)
}
return getWithCorsHandling(apiPrefix + path)
return makeRequest(apiPrefix + path)
}
function fetchPipelines(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'pipelines')
return makeRequest(apiPrefix + 'pipelines')
}
function fetchProject(apiPrefix, projectName) {
return getWithCorsHandling(apiPrefix + 'project/' + projectName)
return makeRequest(apiPrefix + 'project/' + projectName)
}
function fetchProjects(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'projects')
return makeRequest(apiPrefix + 'projects')
}
function fetchJob(apiPrefix, jobName) {
return getWithCorsHandling(apiPrefix + 'job/' + jobName)
return makeRequest(apiPrefix + 'job/' + jobName)
}
function fetchJobGraph(apiPrefix, projectName, pipelineName, branchName) {
return getWithCorsHandling(apiPrefix +
return makeRequest(apiPrefix +
'pipeline/' + pipelineName +
'/project/' + projectName +
'/branch/' + branchName +
'/freeze-jobs')
}
function fetchJobs(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'jobs')
return makeRequest(apiPrefix + 'jobs')
}
function fetchLabels(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'labels')
return makeRequest(apiPrefix + 'labels')
}
function fetchNodes(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'nodes')
return makeRequest(apiPrefix + 'nodes')
}
function fetchSemaphores(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'semaphores')
return makeRequest(apiPrefix + 'semaphores')
}
function fetchAutoholds(apiPrefix) {
return getWithCorsHandling(apiPrefix + 'autohold')
return makeRequest(apiPrefix + 'autohold')
}
function fetchAutohold(apiPrefix, requestId) {
return getWithCorsHandling(apiPrefix + 'autohold/' + requestId)
return makeRequest(apiPrefix + 'autohold/' + requestId)
}
// token-protected API
function fetchUserAuthorizations(apiPrefix, token) {
// 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 fetchUserAuthorizations(apiPrefix) {
return makeRequest(apiPrefix + 'authorizations')
}
function dequeue(apiPrefix, projectName, pipeline, change, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
function dequeue(apiPrefix, projectName, pipeline, change) {
return makeRequest(
apiPrefix + 'project/' + projectName + '/dequeue',
'post',
{
pipeline: pipeline,
change: change,
}
)
return res
}
function dequeue_ref(apiPrefix, projectName, pipeline, ref, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
function dequeue_ref(apiPrefix, projectName, pipeline, ref) {
return makeRequest(
apiPrefix + 'project/' + projectName + '/dequeue',
'post',
{
pipeline: pipeline,
ref: ref,
}
)
return res
}
function enqueue(apiPrefix, projectName, pipeline, change, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
function enqueue(apiPrefix, projectName, pipeline, change) {
return makeRequest(
apiPrefix + 'project/' + projectName + '/enqueue',
'post',
{
pipeline: pipeline,
change: change,
}
)
return res
}
function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev) {
return makeRequest(
apiPrefix + 'project/' + projectName + '/enqueue',
'post',
{
pipeline: pipeline,
ref: ref,
@ -299,16 +316,13 @@ function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, toke
newrev: newrev,
}
)
return res
}
function autohold(apiPrefix, projectName, job, change, ref,
reason, count, node_hold_expiration, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
reason, count, node_hold_expiration) {
return makeRequest(
apiPrefix + 'project/' + projectName + '/autohold',
'post',
{
change: change,
job: job,
@ -318,33 +332,24 @@ function autohold(apiPrefix, projectName, job, change, ref,
node_hold_expiration: node_hold_expiration,
}
)
return res
}
function autohold_delete(apiPrefix, requestId, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.delete(
apiPrefix + '/autohold/' + requestId
function autohold_delete(apiPrefix, requestId) {
return makeRequest(
apiPrefix + '/autohold/' + requestId,
'delete'
)
return res
}
function promote(apiPrefix, pipeline, changes, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
function promote(apiPrefix, pipeline, changes) {
return makeRequest(
apiPrefix + '/promote',
'post',
{
pipeline: pipeline,
changes: changes,
}
)
return res
}

View File

@ -104,8 +104,14 @@ class AuthContainer extends React.Component {
}
componentDidMount() {
const { user, tenant } = this.props
this.props.userManager.events.addAccessTokenExpired(this.onAccessTokenExpired)
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() {
@ -119,7 +125,7 @@ class AuthContainer extends React.Component {
// Make sure the token is current and the tenant is up to date.
if (user.data && 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) {
autohold_delete(tenant.apiPrefix, requestId, user.token)
autohold_delete(tenant.apiPrefix, requestId)
.then(() => {
dispatch(addNotification(
{

View File

@ -64,7 +64,7 @@ const AutoholdModal = props => {
let ah_change = change === '' ? null : change
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(() => {
/* 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
@ -209,4 +209,4 @@ AutoholdModal.propTypes = {
export default connect((state) => ({
tenant: state.tenant,
user: state.user,
}))(AutoholdModal)
}))(AutoholdModal)

View File

@ -181,7 +181,7 @@ function Buildset({ buildset, timezone, tenant, user }) {
if (buildset.change === null) {
const oldrev = '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(() => {
dispatch(addNotification(
{
@ -196,7 +196,7 @@ function Buildset({ buildset, timezone, tenant, user }) {
})
} else {
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(() => {
dispatch(addNotification(
{

View File

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

View File

@ -1,4 +1,5 @@
// 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

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
// under the License.
export default (state = [], action) => {
export default (state = {errors: [], ready: false}, action) => {
switch (action.type) {
case 'FETCH_CONFIGERRORS_SUCCESS':
return action.errors
case 'CONFIGERRORS_FETCH_SUCCESS':
return {errors: action.errors, ready: true}
case 'CONFIGERRORS_FETCH_FAIL':
return {errors: [], ready: true}
case 'CONFIGERRORS_CLEAR':
return {errors: [], ready: false}
default:
return state
}

View File

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

View File

@ -14,7 +14,9 @@
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) {
case TENANT_SET:
return action.tenant

View File

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

View File

@ -804,6 +804,7 @@ class ZuulWebAPI(object):
'info': '/api/info',
'connections': '/api/connections',
'components': '/api/components',
'authorizations': '/api/authorizations',
'tenants': '/api/tenants',
'tenant_info': '/api/tenant/{tenant}/info',
'status': '/api/tenant/{tenant}/status',
@ -897,7 +898,7 @@ class ZuulWebAPI(object):
else:
tenant_name = '*'
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', [])
if (override == '*' or
(isinstance(override, list) and tenant_name in override)):
@ -952,6 +953,14 @@ class ZuulWebAPI(object):
break
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.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options()
@ -1950,6 +1959,10 @@ class ZuulWeb(object):
'/api/tenant/{tenant_name}/authorizations',
controller=api,
action='tenant_authorizations')
route_map.connect('api',
'/api/authorizations',
controller=api,
action='root_authorizations')
route_map.connect('api', '/api/tenant/{tenant_name}/promote',
controller=api, action='promote')
route_map.connect(