4a592f9795
Closes the end-user half of the directory and adds nested LDAP groups.
The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:
- GET /api/discovery/me resolved groups from req.user.groups, which does not
exist (req.user carries memberOf), so it returned only isPublic resources
for every human caller -- "My Services" was blank for everyone. The same
read made isDirectoryAdmin() false for real admins.
- The portal's "Discover More Services" called the admin-gated endpoint and
swallowed the 403, so it never rendered for non-admins at all.
- Services reported no address, because /me had reimplemented getMyAccess
without its parent-walking resolution.
Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").
Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.
member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.
Full suite green in both resolution modes: 215 passed, 2 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
7.5 KiB
JavaScript
Executable File
213 lines
7.5 KiB
JavaScript
Executable File
'use strict';
|
|
|
|
const path = require('path');
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
const moment = require('moment');
|
|
const {marked} = require('marked');
|
|
const xss = require('xss');
|
|
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
|
const {Tos} = require('../models/tos');
|
|
const conf = require('@simpleworkjs/conf');
|
|
const buildInfo = require('../utils/build_info');
|
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
|
|
|
const values ={
|
|
title: conf.environment !== 'production' ? `dev` : '',
|
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
|
name: conf.name,
|
|
logo: conf.logo,
|
|
// Connection conventions the catalog needs to render "how to reach this"
|
|
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
|
|
// port are public connection info, not credentials.
|
|
directoryConf: {
|
|
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
|
|
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
|
|
},
|
|
...buildInfo,
|
|
}
|
|
|
|
// List of front end node modules to be served
|
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
|
// every deploy and isn't cache-busted/fingerprinted.
|
|
mountStaticModules(router, {
|
|
root: path.join(__dirname, '..'),
|
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
|
});
|
|
|
|
// Public health endpoint for container/orchestration healthchecks.
|
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
|
router.get('/health', function(req, res) {
|
|
res.json({ status: 'ok' });
|
|
});
|
|
|
|
router.get('/tos', async function(req, res, next) {
|
|
try {
|
|
const tos = await Tos.getCurrent();
|
|
res.render('tos', {...values, tosHtml: xss(marked(tos.content)), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
|
// (broadcast + history) merged into one page.
|
|
router.get('/overview', function(req, res) {
|
|
res.render('overview', {...values});
|
|
});
|
|
|
|
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
|
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
|
|
|
router.get('/directory', function(req, res) {
|
|
res.render('directory', {...values});
|
|
});
|
|
|
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
|
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
|
// use of :slug at all -- the client reads location.pathname itself and opens
|
|
// the matching resource's modal once the page's own data has loaded.
|
|
router.get('/directory/:slug', function(req, res) {
|
|
res.render('directory', {...values});
|
|
});
|
|
|
|
// Route removed since it's now in directory
|
|
|
|
router.get('/onboarding', async function(req, res, next) {
|
|
try {
|
|
const tos = await Tos.getCurrent();
|
|
res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/', async function(req, res, next) {
|
|
res.render('landing', {...values});
|
|
});
|
|
|
|
router.get('/profile', async function(req, res, next) {
|
|
res.render('profile', {...values});
|
|
});
|
|
|
|
router.get('/users', async function(req, res, next) {
|
|
res.render('users', {...values});
|
|
});
|
|
|
|
router.get('/login', async function(req, res, next) {
|
|
res.render('login', {...values, redirect: req.query.redirect});
|
|
});
|
|
|
|
// OAuth client management and LDAP connection info, merged into one page
|
|
// (tabs) -- both are "how do other apps/hosts plug into this SSO" concerns.
|
|
// LDAP values are derived from the running config + request host rather than
|
|
// hardcoded in a doc, so they're always right for *this* deployment.
|
|
router.get('/integrations', function(req, res, next) {
|
|
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
|
// The public-facing host (from the OAuth issuer). Used for OIDC links.
|
|
const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
|
|
// The hostname advertised for direct LDAPS binds may be a separate,
|
|
// internal-only name so admins don't have to port-forward 636 publicly.
|
|
// Defaults to the issuer host to preserve prior behavior.
|
|
const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
|
|
const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
|
|
|
|
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
|
|
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
|
|
// The base DN isn't stored as its own config value -- derive it by
|
|
// stripping the leading "ou=...," off userBase (ou=people,dc=example,dc=com
|
|
// -> dc=example,dc=com).
|
|
const baseDn = userBase.replace(/^ou=[^,]+,/i, '');
|
|
|
|
res.render('integrations', {
|
|
...values,
|
|
issuer,
|
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
|
ldapHost: ldapsHost,
|
|
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
|
|
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
|
|
baseDn,
|
|
userBase,
|
|
groupBase,
|
|
userFilter: (conf.ldap && conf.ldap.userFilter) || '(objectClass=posixAccount)',
|
|
userNameAttribute: (conf.ldap && conf.ldap.userNameAttribute) || 'uid',
|
|
exampleBindDn: `cn=ldapclient,${userBase}`,
|
|
ssoUrl: issuer,
|
|
});
|
|
});
|
|
router.get('/oauth-clients', (req, res) => res.redirect(301, '/integrations'));
|
|
router.get('/ldap-info', (req, res) => res.redirect(301, '/integrations'));
|
|
|
|
// API Tokens is now a section on the Profile page (own profile only).
|
|
router.get('/api-tokens', (req, res) => res.redirect(301, '/'));
|
|
|
|
|
|
|
|
router.get('/users/:uid', function(req, res, next) {
|
|
res.render('profile', {...values});
|
|
});
|
|
|
|
router.get('/groups', function(req, res, next) {
|
|
res.render('groups', {...values});
|
|
});
|
|
|
|
router.get('/token', function(req, res, next) {
|
|
res.render('token', {...values});
|
|
});
|
|
|
|
|
|
|
|
|
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
|
let token = await PasswordResetToken.get(req.params.token);
|
|
|
|
if(token.is_valid && 86400000+Number(token.created_on) > (new Date).getTime()){
|
|
res.render('reset_password', {token:token, ...values });
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
});
|
|
|
|
router.get('/login/invite/:token/:mailToken', async function(req, res, next){
|
|
try{
|
|
|
|
let token = await InviteToken.get(req.params.token);
|
|
if(token.is_valid && token.mail !== '__NONE__' && token.mail_token === req.params.mailToken){
|
|
token.created_on = moment(token.created_on, 'x').fromNow();
|
|
res.render('invite', {invite: token, ...values});
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/login/invite/:token', async function(req, res, next){
|
|
try{
|
|
let token = await InviteToken.get(req.params.token);
|
|
token.created_on = moment(token.created_on, 'x').fromNow();
|
|
|
|
if(token.is_valid){
|
|
res.render('invite_email', {invite: token, ...values});
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
|
|
router.get('/login/*splat', async function(req, res, next) {
|
|
res.render('login', {...values, redirect: req.query.redirect});
|
|
});
|
|
|
|
module.exports = router;
|