Merge pull request #6614 from overleaf/jpa-msm-separate-admin-app

[misc] move admin capability from www. to admin. subdomain

GitOrigin-RevId: e0daeacf3c06b856ffb9fd35dce76e71f14e8459
This commit is contained in:
Jakob Ackermann 2022-03-31 11:34:49 +01:00 committed by Copybot
parent c96fbc526e
commit e82a053c85
25 changed files with 223 additions and 20 deletions

View file

@ -67,6 +67,7 @@ RUN chmod +x /usr/local/bin/grunt
ENV SHARELATEX_CONFIG /etc/sharelatex/settings.js
ENV WEB_API_USER "sharelatex"
ENV ADMIN_PRIVILEGE_AVAILABLE "true"
ENV SHARELATEX_APP_NAME "Overleaf Community Edition"

View file

@ -24,6 +24,7 @@ const {
acceptsJson,
} = require('../../infrastructure/RequestContentTypeDetection')
const { ParallelLoginError } = require('./AuthenticationErrors')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
function send401WithChallenge(res) {
res.setHeader('WWW-Authenticate', 'OverleafLogin')
@ -107,6 +108,12 @@ const AuthenticationController = {
return res.redirect('/login')
} // OAuth2 'state' mismatch
if (Settings.adminOnlyLogin && !hasAdminAccess(user)) {
return res.status(403).json({
message: { type: 'error', text: 'Admin only panel' },
})
}
const auditInfo = AuthenticationController.getAuditInfo(req)
const anonymousAnalyticsId = req.session.analyticsId
@ -380,7 +387,7 @@ const AuthenticationController = {
return next()
}
const user = SessionManager.getSessionUser(req.session)
if (!(user && user.isAdmin)) {
if (!hasAdminAccess(user)) {
return next()
}
const email = user.email

View file

@ -8,6 +8,8 @@ const PrivilegeLevels = require('./PrivilegeLevels')
const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler')
const PublicAccessLevels = require('./PublicAccessLevels')
const Errors = require('../Errors/Errors')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const Settings = require('@overleaf/settings')
function isRestrictedUser(userId, privilegeLevel, isTokenMember) {
if (privilegeLevel === PrivilegeLevels.NONE) {
@ -91,8 +93,7 @@ async function getPrivilegeLevelForProjectWithUser(
}
if (!opts.ignoreSiteAdmin) {
const isAdmin = await isUserSiteAdmin(userId)
if (isAdmin) {
if (await isUserSiteAdmin(userId)) {
return PrivilegeLevels.OWNER
}
}
@ -216,8 +217,9 @@ async function isUserSiteAdmin(userId) {
if (!userId) {
return false
}
if (!Settings.adminPrivilegeAvailable) return false
const user = await User.findOne({ _id: userId }, { isAdmin: 1 }).exec()
return user != null && user.isAdmin === true
return hasAdminAccess(user)
}
module.exports = {

View file

@ -133,8 +133,7 @@ async function ensureUserCanAdminProject(req, res, next) {
async function ensureUserIsSiteAdmin(req, res, next) {
const userId = _getUserId(req)
const isAdmin = await AuthorizationManager.promises.isUserSiteAdmin(userId)
if (isAdmin) {
if (await AuthorizationManager.promises.isUserSiteAdmin(userId)) {
logger.log({ userId }, 'allowing user admin access to site')
return next()
}

View file

@ -10,6 +10,7 @@ const TagsHandler = require('../Tags/TagsHandler')
const Errors = require('../Errors/Errors')
const logger = require('@overleaf/logger')
const { expressify } = require('../../util/promises')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
module.exports = {
removeUserFromProject: expressify(removeUserFromProject),
@ -82,7 +83,7 @@ async function transferOwnership(req, res, next) {
projectId,
toUserId,
{
allowTransferToNonCollaborators: sessionUser.isAdmin,
allowTransferToNonCollaborators: hasAdminAccess(sessionUser),
sessionUserId: ObjectId(sessionUser._id),
}
)

View file

@ -0,0 +1,11 @@
const Settings = require('@overleaf/settings')
module.exports = {
hasAdminAccess,
}
function hasAdminAccess(user) {
if (!Settings.adminPrivilegeAvailable) return false
if (!user) return false
return Boolean(user.isAdmin)
}

View file

@ -1,11 +1,12 @@
const { UserSchema } = require('../../models/User')
const { hasAdminAccess } = require('./AdminAuthorizationHelper')
module.exports = {
hasAnyStaffAccess,
}
function hasAnyStaffAccess(user) {
if (user.isAdmin) {
if (hasAdminAccess(user)) {
return true
}
if (!user.staffAccess) {

View file

@ -39,6 +39,7 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const SpellingHandler = require('../Spelling/SpellingHandler')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
if (!affiliation.institution) return false
@ -965,7 +966,7 @@ const ProjectController = {
refProviders: _.mapValues(user.refProviders, Boolean),
alphaProgram: user.alphaProgram,
betaProgram: user.betaProgram,
isAdmin: user.isAdmin,
isAdmin: hasAdminAccess(user),
},
userSettings: {
mode: user.ace.mode,

View file

@ -2,6 +2,7 @@ const { ObjectId } = require('mongodb')
const _ = require('lodash')
const { promisify } = require('util')
const Settings = require('@overleaf/settings')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const ENGINE_TO_COMPILER_MAP = {
latex_dvipdf: 'latex',
@ -165,7 +166,7 @@ function _addNumericSuffixToProjectName(name, allProjectNames, maxLength) {
function getAllowedImagesForUser(sessionUser) {
const images = Settings.allowedImageNames || []
if (sessionUser && sessionUser.isAdmin) {
if (hasAdminAccess(sessionUser)) {
return images
} else {
return images.filter(image => !image.adminOnly)

View file

@ -1,10 +1,11 @@
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const UserMembershipAuthorization = {
hasStaffAccess(requiredStaffAccess) {
return req => {
if (!req.user) {
return false
}
if (req.user.isAdmin) {
if (hasAdminAccess(req.user)) {
return true
}
return (

View file

@ -15,6 +15,8 @@
const csurf = require('csurf')
const csrf = csurf()
const { promisify } = require('util')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
// Wrapper for `csurf` middleware that provides a list of routes that can be excluded from csrf checks.
//
@ -35,6 +37,18 @@ class Csrf {
this.excluded_routes = {}
}
static blockCrossOriginRequests() {
return function (req, res, next) {
const { origin } = req.headers
// NOTE: Only cross-origin requests must have an origin header set.
if (origin && !Settings.allowedOrigins.includes(origin)) {
logger.warn({ req }, 'blocking cross-origin request')
return res.sendStatus(403)
}
next()
}
}
disableDefaultCsrfProtection(route, method) {
if (!this.excluded_routes[route]) {
this.excluded_routes[route] = {}

View file

@ -12,6 +12,9 @@ const SessionManager = require('../Features/Authentication/SessionManager')
const PackageVersions = require('./PackageVersions')
const Modules = require('./Modules')
const SafeHTMLSubstitute = require('../Features/Helpers/SafeHTMLSubstitution')
const {
hasAdminAccess,
} = require('../Features/Helpers/AdminAuthorizationHelper')
let webpackManifest
switch (process.env.NODE_ENV) {
@ -299,6 +302,8 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
res.locals.getLoggedInUserId = () =>
SessionManager.getLoggedInUserId(req.session)
res.locals.getSessionUser = () => SessionManager.getSessionUser(req.session)
res.locals.hasAdminAccess = () =>
hasAdminAccess(SessionManager.getSessionUser(req.session))
next()
})

View file

@ -40,6 +40,9 @@ const HttpErrorHandler = require('../Features/Errors/HttpErrorHandler')
const UserSessionsManager = require('../Features/User/UserSessionsManager')
const AuthenticationController = require('../Features/Authentication/AuthenticationController')
const SessionManager = require('../Features/Authentication/SessionManager')
const {
hasAdminAccess,
} = require('../Features/Helpers/AdminAuthorizationHelper')
const STATIC_CACHE_AGE = Settings.cacheStaticAssets
? oneDayInMilliseconds * 365
@ -141,6 +144,11 @@ app.use(methodOverride())
app.use(bearerToken())
app.use(metrics.http.monitor(logger))
if (Settings.blockCrossOriginRequests) {
app.use(Csrf.blockCrossOriginRequests())
}
RedirectManager.apply(webRouter)
ProxyManager.apply(publicApiRouter)
@ -228,10 +236,7 @@ webRouter.use(SessionAutostartMiddleware.invokeCallbackMiddleware)
webRouter.use(function (req, res, next) {
if (Settings.siteIsOpen) {
next()
} else if (
SessionManager.getSessionUser(req.session) &&
SessionManager.getSessionUser(req.session).isAdmin
) {
} else if (hasAdminAccess(SessionManager.getSessionUser(req.session))) {
next()
} else {
HttpErrorHandler.maintenance(req, res)

View file

@ -15,7 +15,7 @@ nav.navbar.navbar-default.navbar-main
else
a(href='/', aria-label=settings.appName).navbar-brand
- var canDisplayAdminMenu = getSessionUser() && getSessionUser().isAdmin
- var canDisplayAdminMenu = hasAdminAccess()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
.navbar-collapse.collapse(data-ol-navbar-main-collapse)
ul.nav.navbar-nav.navbar-right

View file

@ -10,7 +10,7 @@ nav.navbar.navbar-default.navbar-main
else
a(href='/', aria-label=settings.appName).navbar-brand
- var canDisplayAdminMenu = getSessionUser() && getSessionUser().isAdmin
- var canDisplayAdminMenu = hasAdminAccess()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
.navbar-collapse.collapse(collapse="navCollapsed")
ul.nav.navbar-nav.navbar-right

View file

@ -283,6 +283,12 @@ module.exports = {
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS, 10) || 12,
}, // number of rounds used to hash user passwords (raised to power 2)
adminUrl: process.env.ADMIN_URL,
adminOnlyLogin: process.env.ADMIN_ONLY_LOGIN === 'true',
adminPrivilegeAvailable: process.env.ADMIN_PRIVILEGE_AVAILABLE === 'true',
blockCrossOriginRequests: process.env.BLOCK_CROSS_ORIGIN_REQUESTS === 'true',
allowedOrigins: (process.env.ALLOWED_ORIGINS || siteUrl).split(','),
httpAuthUsers,
// Default features

View file

@ -1,3 +1,4 @@
ADMIN_PRIVILEGE_AVAILABLE=true
BCRYPT_ROUNDS=1
REDIS_HOST=redis
QUEUES_REDIS_HOST=redis

View file

@ -25,6 +25,9 @@ const UserGetter = require('../../../../app/src/Features/User/UserGetter')
const { User } = require('../../../../app/src/models/User')
const AuthenticationController = require('../../../../app/src/Features/Authentication/AuthenticationController')
const SessionManager = require('../../../../app/src/Features/Authentication/SessionManager')
const {
hasAdminAccess,
} = require('../../../../app/src/Features/Helpers/AdminAuthorizationHelper')
module.exports = LaunchpadController = {
_getAuthMethod() {
@ -67,7 +70,7 @@ module.exports = LaunchpadController = {
if (err != null) {
return next(err)
}
if (user && user.isAdmin) {
if (hasAdminAccess(user)) {
return res.render(Path.resolve(__dirname, '../views/launchpad'), {
wsUrl: Settings.wsUrl,
adminUserExists,

View file

@ -31,7 +31,9 @@ describe('LaunchpadController', function () {
this.User = {}
this.LaunchpadController = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.Settings = {}),
'@overleaf/settings': (this.Settings = {
adminPrivilegeAvailable: true,
}),
'@overleaf/metrics': (this.Metrics = {}),
'../../../../app/src/Features/User/UserRegistrationHandler':
(this.UserRegistrationHandler = {}),

View file

@ -0,0 +1,64 @@
const Settings = require('@overleaf/settings')
const { expect } = require('chai')
const User = require('./helpers/User').promises
describe('AdminOnlyLogin', function () {
let adminUser, regularUser
const flagBefore = Settings.adminOnlyLogin
after(function () {
Settings.adminOnlyLogin = flagBefore
})
beforeEach('create admin user', async function () {
adminUser = new User()
await adminUser.ensureUserExists()
await adminUser.ensureAdmin()
})
beforeEach('create regular user', async function () {
regularUser = new User()
await regularUser.ensureUserExists()
})
async function expectCanLogin(user) {
const response = await user.login()
expect(response.statusCode).to.equal(200)
expect(response.body).to.deep.equal({ redir: '/project' })
}
async function expectRejectedLogin(user) {
const response = await user.login()
expect(response.statusCode).to.equal(403)
expect(response.body).to.deep.equal({
message: { type: 'error', text: 'Admin only panel' },
})
}
describe('adminOnlyLogin=true', function () {
beforeEach(function () {
Settings.adminOnlyLogin = true
})
it('should allow the admin user to login', async function () {
await expectCanLogin(adminUser)
})
it('should block a regular user from login', async function () {
await expectRejectedLogin(regularUser)
})
})
describe('adminOnlyLogin=false', function () {
beforeEach(function () {
Settings.adminOnlyLogin = false
})
it('should allow the admin user to login', async function () {
await expectCanLogin(adminUser)
})
it('should allow a regular user to login', async function () {
await expectCanLogin(regularUser)
})
})
})

View file

@ -0,0 +1,62 @@
const Settings = require('@overleaf/settings')
const { expect } = require('chai')
const User = require('./helpers/User').promises
describe('AdminPrivilegeAvailable', function () {
let adminUser
const flagBefore = Settings.adminPrivilegeAvailable
after(function () {
Settings.adminPrivilegeAvailable = flagBefore
})
beforeEach('create admin user', async function () {
adminUser = new User()
await adminUser.ensureUserExists()
await adminUser.ensureAdmin()
await adminUser.login()
})
let projectIdOwned, otherUsersProjectId
beforeEach('create owned project', async function () {
projectIdOwned = await adminUser.createProject('owned project')
})
beforeEach('create other user and project', async function () {
const otherUser = new User()
await otherUser.login()
otherUsersProjectId = await otherUser.createProject('other users project')
})
async function hasAccess(projectId) {
const { response } = await adminUser.doRequest(
'GET',
`/project/${projectId}`
)
return response.statusCode === 200
}
describe('adminPrivilegeAvailable=true', function () {
beforeEach(function () {
Settings.adminPrivilegeAvailable = true
})
it('should grant the admin access to owned project', async function () {
expect(await hasAccess(projectIdOwned)).to.equal(true)
})
it('should grant the admin access to non-owned project', async function () {
expect(await hasAccess(otherUsersProjectId)).to.equal(true)
})
})
describe('adminPrivilegeAvailable=false', function () {
beforeEach(function () {
Settings.adminPrivilegeAvailable = false
})
it('should grant the admin access to owned project', async function () {
expect(await hasAccess(projectIdOwned)).to.equal(true)
})
it('should block the admin from non-owned project', async function () {
expect(await hasAccess(otherUsersProjectId)).to.equal(false)
})
})
})

View file

@ -33,6 +33,10 @@ describe('AuthenticationController', function () {
this.AuthenticationController = SandboxedModule.require(modulePath, {
requires: {
'../Helpers/AdminAuthorizationHelper': (this.AdminAuthorizationHelper =
{
hasAdminAccess: sinon.stub().returns(false),
}),
'./AuthenticationErrors': AuthenticationErrors,
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(null),
@ -158,6 +162,7 @@ describe('AuthenticationController', function () {
this.SessionManager.getSessionUser = sinon
.stub()
.returns({ isAdmin: true })
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
this.AuthenticationController.validateAdmin(this.req, this.res, err => {
this.SessionManager.getSessionUser.called.should.equal(true)
expect(err).to.exist
@ -167,6 +172,7 @@ describe('AuthenticationController', function () {
it('should block an admin with a bad domain', function (done) {
this.SessionManager.getSessionUser = sinon.stub().returns(this.badAdmin)
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
this.AuthenticationController.validateAdmin(this.req, this.res, err => {
this.SessionManager.getSessionUser.called.should.equal(true)
expect(err).to.exist

View file

@ -54,7 +54,10 @@ describe('AuthorizationManager', function () {
'../Project/ProjectGetter': this.ProjectGetter,
'../../models/User': { User: this.User },
'../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
'@overleaf/settings': { passwordStrengthOptions: {} },
'@overleaf/settings': {
passwordStrengthOptions: {},
adminPrivilegeAvailable: true,
},
},
})
})

View file

@ -1,4 +1,5 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
@ -6,6 +7,9 @@ describe('AuthorizationHelper', function () {
beforeEach(function () {
this.AuthorizationHelper = SandboxedModule.require(modulePath, {
requires: {
'./AdminAuthorizationHelper': (this.AdminAuthorizationHelper = {
hasAdminAccess: sinon.stub().returns(false),
}),
'../../models/User': {
UserSchema: {
obj: {
@ -38,11 +42,13 @@ describe('AuthorizationHelper', function () {
it('with admin user', function () {
const user = { isAdmin: true }
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
})
it('with staff user', function () {
const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
})

View file

@ -22,6 +22,7 @@ describe('ProjectHelper', function () {
}
this.Settings = {
adminPrivilegeAvailable: true,
allowedImageNames: [
{ imageName: 'texlive-full:2018.1', imageDesc: 'TeX Live 2018' },
{ imageName: 'texlive-full:2019.1', imageDesc: 'TeX Live 2019' },