Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc |
@@ -4,6 +4,26 @@ All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [1.4.0] - 2026-07-25
|
||||
|
||||
### Security
|
||||
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
|
||||
|
||||
### Fixed
|
||||
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
|
||||
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
|
||||
|
||||
### Added
|
||||
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
|
||||
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
|
||||
- `@simpleworkjs/ldap` — `models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
|
||||
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
|
||||
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
|
||||
|
||||
### Changed
|
||||
- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
|
||||
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
|
||||
|
||||
## [1.3.2] - 2026-07-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -87,6 +87,14 @@ The seed is idempotent and non-destructive: a resource whose slug already exists
|
||||
|
||||
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
|
||||
|
||||
## Consumers of the directory
|
||||
|
||||
The inventory graph isn't just documentation — other components read it to make decisions:
|
||||
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
|
||||
|
||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||
|
||||
## API
|
||||
|
||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||
|
||||
+3
-1
@@ -58,7 +58,7 @@ backend, that's the niche.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -78,5 +78,7 @@ That's the standalone quick start. For the full set of install options
|
||||
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||
reverse proxy, designed to sit in front of this SSO.
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||
uses this SSO's directory to decide who may reach which machine.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||
Manager and the proxy together with one command.
|
||||
|
||||
@@ -108,9 +108,6 @@ app.use(function(req, res, next) {
|
||||
next(err);
|
||||
});
|
||||
|
||||
// Discovery API
|
||||
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
|
||||
|
||||
// Error handling
|
||||
app.use(function(err, req, res, next) {
|
||||
const SILENT_404S = ['/.well-known/'];
|
||||
|
||||
@@ -3,44 +3,18 @@
|
||||
const { Client, Attribute, Change } = require('ldapts');
|
||||
const { LRUCache } = require('lru-cache');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
// Escape a value used inside an LDAP search filter (RFC 4515).
|
||||
function escapeLDAPSearchValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\0/g, '\\00');
|
||||
}
|
||||
|
||||
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
|
||||
// are normally alphanumeric, but this prevents metacharacter injection.
|
||||
function escapeLDAPDNValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
||||
}
|
||||
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||
const escapeLDAPSearchValue = escapeFilter;
|
||||
const escapeLDAPDNValue = escapeDN;
|
||||
|
||||
function makeClient() {
|
||||
return new Client({ url: conf.url });
|
||||
return _makeClient(conf);
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
return _withClient(conf, fn);
|
||||
}
|
||||
|
||||
async function getGroups(client, member){
|
||||
|
||||
+10
-31
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
||||
const {Group} = require('./group_ldap');
|
||||
const {UserVerification} = require('./verification');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
||||
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
||||
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
||||
// tlsOptions, which the shared client forwards as undefined — identical to the
|
||||
// previous `new Client({ url: conf.url })`.
|
||||
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||
const escapeLDAPSearchValue = escapeFilter;
|
||||
const escapeLDAPDNValue = escapeDN;
|
||||
|
||||
function hashPasswordSSHA512(password) {
|
||||
const salt = crypto.randomBytes(8);
|
||||
@@ -23,40 +31,11 @@ const cache = new LRUCache({
|
||||
});
|
||||
|
||||
function makeClient() {
|
||||
return new Client({ url: conf.url });
|
||||
return _makeClient(conf);
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to escape LDAP filter values (crucial for security)
|
||||
function escapeLDAPSearchValue(val) {
|
||||
return val.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\0/g, '\\00');
|
||||
}
|
||||
|
||||
// Escape a value used in an LDAP DN (RFC 4514).
|
||||
function escapeLDAPDNValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
||||
return _withClient(conf, fn);
|
||||
}
|
||||
|
||||
// Compute the next available uid/gidNumber: the highest existing value below
|
||||
|
||||
Generated
+37
-1
@@ -11,7 +11,10 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -23,7 +26,7 @@
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^3.7.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapts": "^8.1.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"lru-cache": "^11.5.1",
|
||||
"marked": "^9.1.6",
|
||||
"model-redis": "^1.6.0",
|
||||
@@ -1242,6 +1245,18 @@
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/app-stack": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
@@ -1254,6 +1269,27 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/directory-schema": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
||||
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/ldap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ldapts": "^8.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/orm": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -24,6 +24,9 @@
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -35,7 +38,7 @@
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^3.7.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapts": "^8.1.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"lru-cache": "^11.5.1",
|
||||
"marked": "^9.1.6",
|
||||
"model-redis": "^1.6.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||
const { Group } = require('../models/group_ldap');
|
||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
// Require the admin group
|
||||
router.use(async (req, res, next) => {
|
||||
@@ -18,7 +19,9 @@ router.use(async (req, res, next) => {
|
||||
router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
const resources = await Resource.list();
|
||||
res.json({ results: resources });
|
||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||
// the wire; projectResources strips it unconditionally.
|
||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
|
||||
// GET /api/discovery/me
|
||||
// Returns the list of resources the current user has access to.
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const userGroups = req.user.groups || []; // array of LDAP group CNs
|
||||
const accessibleResourceIds = new Set();
|
||||
|
||||
if (req.user.isMachine) {
|
||||
// Machines only have access to themselves by default
|
||||
accessibleResourceIds.add(req.resourceId);
|
||||
} else {
|
||||
// End users get access via groups
|
||||
const allGroups = await ResourceGroup.list();
|
||||
for (const rg of allGroups) {
|
||||
if (userGroups.includes(rg.groupCn)) {
|
||||
accessibleResourceIds.add(rg.resourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all resources and filter
|
||||
const allResources = await Resource.list();
|
||||
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
|
||||
|
||||
res.json({ results: accessible });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,4 +1,79 @@
|
||||
const autoRouter = require('./autoRouter');
|
||||
const { Resource } = require('../models/resource');
|
||||
'use strict';
|
||||
|
||||
module.exports = autoRouter(Resource);
|
||||
// Public directory discovery API. Mounted at /api/discovery (app.js, before
|
||||
// the 404 catcher). Every response uses the `{ results }` envelope and the
|
||||
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
|
||||
// OAuth client's client_secret_hash) never leave the server and non-admins only
|
||||
// see the public metadata allowlist.
|
||||
//
|
||||
// This replaces the autoRouter mount (which returned bare arrays — the shape
|
||||
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
||||
// bridge) and absorbs the dead /me handler that used to live in
|
||||
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
||||
|
||||
const router = require('express').Router();
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
const {
|
||||
envelope,
|
||||
projectResource,
|
||||
projectResources,
|
||||
isDirectoryAdmin,
|
||||
} = require('@simpleworkjs/directory-schema');
|
||||
|
||||
// GET /api/discovery/resources[?kind=&group=&parent=]
|
||||
router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
const resources = await Resource.search(req.query);
|
||||
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/discovery/resources/:slug
|
||||
router.get('/resources/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.getBySlug(req.params.slug);
|
||||
// parents/children are edges (no secrets); project only the resource body.
|
||||
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
|
||||
projected.parents = resource.parents;
|
||||
projected.children = resource.children;
|
||||
res.json(envelope(projected));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/discovery/graph
|
||||
router.get('/graph', async (req, res, next) => {
|
||||
try {
|
||||
const graph = await Resource.getGraph();
|
||||
res.json(envelope({
|
||||
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
|
||||
edges: graph.edges,
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/discovery/me
|
||||
// Returns the resources the current caller can reach. Machines see only their
|
||||
// own resource; humans get the union of their LDAP groups' resources plus
|
||||
// anything flagged isPublic. Uses req.user.groups (populated by the auth
|
||||
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
|
||||
// works for every auth transport without assuming a .dn is present.
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
let accessible;
|
||||
if (req.user && req.user.isMachine) {
|
||||
accessible = await Resource.list({ where: { id: req.resourceId } });
|
||||
} else {
|
||||
const userGroups = (req.user && req.user.groups) || [];
|
||||
const ids = new Set();
|
||||
if (userGroups.length) {
|
||||
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
|
||||
for (const rg of rgs) ids.add(rg.resourceId);
|
||||
}
|
||||
const all = await Resource.list();
|
||||
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
|
||||
}
|
||||
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+7
-14
@@ -10,6 +10,7 @@ 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` : '',
|
||||
@@ -20,24 +21,16 @@ const values ={
|
||||
}
|
||||
|
||||
// List of front end node modules to be served
|
||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
|
||||
'moment', '@popper', 'jq-repeat',
|
||||
];
|
||||
|
||||
// Server front end modules
|
||||
// https://stackoverflow.com/a/55700773/3140931
|
||||
// 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.
|
||||
frontEndModules.forEach(dep => {
|
||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
||||
// 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'],
|
||||
});
|
||||
|
||||
// Have express server static content( images, CSS, browser JS) from the public
|
||||
// local folder. Shorter maxAge than /static-modules since this is the app's
|
||||
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
|
||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
// Directory discovery API — security + contract regression coverage.
|
||||
//
|
||||
// These tests run under the jest + docker harness (redis + the test seed).
|
||||
// They lock in the two fixes from the @simpleworkjs/directory-schema release:
|
||||
// 1. /api/discovery/* returns the { results } envelope (not a bare array —
|
||||
// the drift that made jump-host's `data.results || []` collapse to []).
|
||||
// 2. No response path leaks secret metadata (e.g. an OAuth client's
|
||||
// client_secret_hash), regardless of caller.
|
||||
//
|
||||
// The core assertions hold for any authenticated caller. The admin-projection
|
||||
// assertion (fullMetadata for directory admins) additionally requires the `test`
|
||||
// seed user to be a member of app_sso_directory_admin — see setup.js.
|
||||
|
||||
const { login, request, app } = require('./setup');
|
||||
|
||||
let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
token = await login();
|
||||
});
|
||||
|
||||
function assertNoSecrets(results, path) {
|
||||
for (const r of results || []) {
|
||||
// toBeUndefined() in this jest version takes no message arg, so assert
|
||||
// manually and throw with context — this also surfaces the leaked value
|
||||
// if the projection ever regresses.
|
||||
const secretHash = r.metadata && r.metadata.client_secret_hash;
|
||||
if (secretHash !== undefined) {
|
||||
throw new Error(
|
||||
`client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})`
|
||||
);
|
||||
}
|
||||
if (r.metadata) {
|
||||
for (const k of Object.keys(r.metadata)) {
|
||||
if (/secret|password|privatekey/i.test(k)) {
|
||||
throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Discovery — envelope + security', () => {
|
||||
test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => {
|
||||
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
expect(Array.isArray(res.body)).toBe(false); // never a bare array
|
||||
});
|
||||
|
||||
test('GET /api/discovery/resources never leaks client_secret_hash', async () => {
|
||||
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||
assertNoSecrets(res.body.results, '/resources');
|
||||
});
|
||||
|
||||
test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/discovery/resources?group=host_web01_access')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
});
|
||||
|
||||
test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => {
|
||||
const res = await request(app).get('/api/discovery/graph').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toBeDefined();
|
||||
expect(Array.isArray(res.body.results.resources)).toBe(true);
|
||||
assertNoSecrets(res.body.results.resources, '/graph');
|
||||
});
|
||||
|
||||
test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => {
|
||||
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
assertNoSecrets(res.body.results, '/me');
|
||||
});
|
||||
|
||||
test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => {
|
||||
// Seed-dependent: pick the first slug from the list, then fetch it.
|
||||
const list = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||
const slug = list.body.results[0] && list.body.results[0].slug;
|
||||
if (!slug) return; // empty seed — skip rather than fail
|
||||
const res = await request(app)
|
||||
.get(`/api/discovery/resources/${encodeURIComponent(slug)}`)
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toBeDefined();
|
||||
expect(res.body.results.slug).toBe(slug);
|
||||
assertNoSecrets([res.body.results], '/resources/:slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => {
|
||||
// If the seed `test` user is a directory admin, /resources should keep
|
||||
// admin-only (non-secret) metadata like redirect_uris/token_lifetime for
|
||||
// them. If not, this assertion is skipped — the no-secrets assertion above
|
||||
// already covers the security guarantee for every caller.
|
||||
test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => {
|
||||
const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token);
|
||||
const oauth = (res.body.results || []).find(r => r.kind === 'oauth');
|
||||
if (!oauth) return; // no oauth resource seeded
|
||||
// Only meaningful if the caller is an admin; non-admins correctly get
|
||||
// the public allowlist (no redirect_uris). We assert the absence of
|
||||
// secrets regardless, and skip the positive admin check without a known
|
||||
// admin seed.
|
||||
expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+12
-25
@@ -1,29 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
|
||||
// cwd is utils/ for the bare-metal git fallback.
|
||||
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { version: buildVersion } = require('../package.json');
|
||||
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
||||
// runs have no baked file, so they fall back to asking git directly.
|
||||
function readBuildHash() {
|
||||
try {
|
||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
||||
if (baked) return baked;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildVersion,
|
||||
buildHash: readBuildHash(),
|
||||
buildYear: new Date().getFullYear(),
|
||||
};
|
||||
module.exports = createBuildInfo({
|
||||
version,
|
||||
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||
cwd: __dirname,
|
||||
});
|
||||
Reference in New Issue
Block a user