Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc | |||
| d9a3cb6044 | |||
| 0ee6825a01 | |||
| 14b6ed5ae0 | |||
| fd98854628 |
@@ -4,6 +4,32 @@ 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
|
||||
- **OAuth client management API returned `client_id: undefined` on every GET.** The ORM's `Model.toJSON()` only serializes schema fields, so the mapped `client_id`/`scopes`/`redirect_uris`/… that `OAuthClient.get()` attaches to the wrapped Resource were stripped from `GET /api/oauth/client` and `GET /api/oauth/client/:id` responses. The theta-env bootstrap (which lists clients and rotates by the returned `client_id`) then called `/api/oauth/client/undefined/rotate` and got a 500, aborting stack bring-up when `proxy-secrets.js` had no usable secret. `OAuthClient.get()` now emits an explicit public JSON shape (and deliberately omits `client_secret_hash`, so the secret hash no longer leaks over the API).
|
||||
- `OAuthClient.get()` no longer 500s on an unknown/`undefined` client id: `Resource.get()` returns `null` (it doesn't throw), which was dereferenced as `r.kind`. It now returns a clean 404.
|
||||
|
||||
## [1.3.1] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
+149
-2
@@ -1,8 +1,10 @@
|
||||
# Home-Lab Directory / Inventory — Design Spec
|
||||
|
||||
Status: **Draft / agreed direction** (no code yet)
|
||||
Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
|
||||
registration from theta-env + ldap-client). §9 adds the planned-consumer
|
||||
readiness review.
|
||||
Owner: wmantly
|
||||
Last updated: 2026-07-02
|
||||
Last updated: 2026-07-23
|
||||
|
||||
---
|
||||
|
||||
@@ -220,3 +222,148 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
||||
for auth, SQL for inventory.
|
||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
|
||||
|
||||
---
|
||||
|
||||
## 9. Planned consumers — data-model & API readiness
|
||||
|
||||
Five consumers the directory data should be able to power. None are being
|
||||
built yet; this section records what each needs, what already exists, and the
|
||||
gaps to close so the model/API never paints us into a corner.
|
||||
|
||||
The recurring theme: **the graph model itself (Resource / ResourceEdge /
|
||||
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
|
||||
(a) one new model (access requests), (b) machine-to-machine auth for the read
|
||||
API, (c) documented metadata conventions instead of new columns, and
|
||||
(d) change detection for the drift/sync consumers.
|
||||
|
||||
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
|
||||
|
||||
A user browses everything that exists — part advertisement, part
|
||||
documentation — sees what they already have, and requests access to the rest.
|
||||
|
||||
Already there:
|
||||
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
|
||||
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
|
||||
who approves, and which group an approval means joining.
|
||||
- The Notification model — the approval-request delivery mechanism.
|
||||
|
||||
Gaps:
|
||||
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
|
||||
returns full `metadata` to any authenticated user — including the OAuth
|
||||
kind's `client_secret_hash`, and operator notes that may name internal
|
||||
IPs. Needed: a per-kind public projection (name, description, kind,
|
||||
subType, icon, address, hasAccess, requestable) and a private-key
|
||||
convention for the rest (e.g. only `app_sso_directory_admin` sees full
|
||||
metadata). This is a **fix worth doing before any catalog UI exists**.
|
||||
2. **`AccessRequest` model** — the one genuinely new model:
|
||||
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
|
||||
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
|
||||
Endpoints: user POST/GET own; resource owner / directory admin
|
||||
list/approve/deny.
|
||||
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
|
||||
`requestable: false` for resources that shouldn't be advertised.
|
||||
|
||||
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
|
||||
|
||||
A public jump host parses the target out of the SSH username, checks the user
|
||||
may reach that host, and proxies the connection (WinSCP-friendly: one
|
||||
username string, no interactive menu needed — though an interactive picker on
|
||||
plain `username@` login is the same query).
|
||||
|
||||
Already there:
|
||||
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
|
||||
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
|
||||
the jump host can run entirely off LDAP (SSSD) + one directory query.
|
||||
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
|
||||
real user without local accounts.
|
||||
|
||||
Gaps:
|
||||
1. **Machine auth for the access query.** The jump host must ask "may user X
|
||||
reach host Y" / "list hosts user X may reach" *about another user*.
|
||||
`getMyAccess` only answers for the calling user. Needed: a
|
||||
service-token-authenticated endpoint (`GET
|
||||
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
|
||||
is even linked to a resource (`resource_id`) — what's missing is an auth
|
||||
middleware that accepts it and a permission rule ("service tokens may
|
||||
read access info, scoped read-only").
|
||||
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
|
||||
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
|
||||
multi-hop topologies ever appear.
|
||||
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
|
||||
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
|
||||
strips everything but `[a-z0-9-]`).
|
||||
|
||||
### 9.3 Firewall port-forward rules (build / update / drift-test)
|
||||
|
||||
An automation renders the public firewall's forwarding table from the
|
||||
directory, applies it, and alerts on drift in either direction.
|
||||
|
||||
Already there:
|
||||
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
|
||||
`metadata.isExternalReachable` — the core mapping data, already seeded for
|
||||
the stack's own services.
|
||||
|
||||
Gaps:
|
||||
1. **Port-mapping convention is too thin for real rules**: no protocol, no
|
||||
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
|
||||
external: n, internal: n, comment}]` as the authoritative form
|
||||
(`port`/`externalPort` stay as the simple single-mapping case).
|
||||
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
|
||||
on resources surfaced in the graph API, or a graph-level etag/hash, so
|
||||
the runner can poll without diffing full payloads. (The ORM already
|
||||
publishes create/update events internally — a future push feed can ride
|
||||
that; polling comes first.)
|
||||
3. Same **service-token read auth** as 9.2 — automation must not run on a
|
||||
human's session token.
|
||||
|
||||
### 9.4 Local DNS / mDNS
|
||||
|
||||
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
|
||||
A records from `metadata.ip`, services get CNAMEs/records from their
|
||||
addresses, sites map to zones.
|
||||
|
||||
Already there:
|
||||
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
|
||||
boundary; service `address` yields names.
|
||||
|
||||
Gaps:
|
||||
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
|
||||
documented rule for which name wins (slug vs `address` hostname). TTL
|
||||
only if someone actually needs per-record TTLs — default is fine.
|
||||
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
|
||||
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
|
||||
|
||||
### 9.5 Access control for hosts
|
||||
|
||||
Who may log in to / sudo on which machine, driven by the directory.
|
||||
|
||||
Already there — this is the original point of the system:
|
||||
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
|
||||
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
|
||||
openssh-lpk schemas cover sudo and SSH keys.
|
||||
|
||||
Gaps:
|
||||
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
|
||||
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
|
||||
membership) so directory group membership *is* login permission, not just
|
||||
identity. Today the registration exists but enforcement is host-side
|
||||
convention.
|
||||
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
|
||||
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
|
||||
`admin`), extend the enum — the join-table shape already supports it.
|
||||
|
||||
### 9.6 Consolidated work list (model/API only, no consumers)
|
||||
|
||||
Ordered by how much they unblock:
|
||||
|
||||
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
|
||||
`client_secret_hash` exposure regardless of any consumer).
|
||||
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
|
||||
(blocks 9.2, 9.3; ServiceToken model already exists).
|
||||
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
|
||||
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
|
||||
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md` —
|
||||
conventions, not schema changes; the json column already holds them.
|
||||
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||
freshness; trivial once surfaced).
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -44,13 +44,21 @@ class OAuthClient {
|
||||
return r;
|
||||
}
|
||||
static async get(client_id) {
|
||||
const notFound = () => {
|
||||
const e = new Error('OAuthClient not found');
|
||||
e.status = 404;
|
||||
return e;
|
||||
};
|
||||
let r;
|
||||
try {
|
||||
r = await Resource.get(client_id);
|
||||
} catch (_) {
|
||||
throw new Error('OAuthClient not found');
|
||||
throw notFound();
|
||||
}
|
||||
if (r.kind !== 'oauth') throw new Error('OAuthClient not found');
|
||||
// Resource.get() returns null (does not throw) for a missing id —
|
||||
// guard it so a bad/undefined client_id is a clean 404, not a
|
||||
// "Cannot read properties of null (reading 'kind')" 500.
|
||||
if (!r || r.kind !== 'oauth') throw notFound();
|
||||
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||
r.client_id = r.id;
|
||||
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||
@@ -69,6 +77,29 @@ class OAuthClient {
|
||||
return raw_secret;
|
||||
};
|
||||
|
||||
// The ORM Model.toJSON() only serializes schema fields, so the mapped
|
||||
// properties above (client_id, scopes, redirect_uris, …) would be
|
||||
// stripped from any res.json() — that's why GET /api/oauth/client
|
||||
// returned client_id: undefined and the bootstrap's rotate blew up.
|
||||
// Emit the public shape explicitly. client_secret_hash is deliberately
|
||||
// omitted so it never leaks over the API.
|
||||
r.toJSON = function () {
|
||||
return {
|
||||
client_id: r.id,
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
owner: r.owner,
|
||||
description: r.description,
|
||||
redirect_uris: r.redirect_uris,
|
||||
scopes: r.scopes,
|
||||
allowed_groups: r.allowed_groups,
|
||||
token_lifetime: r.token_lifetime,
|
||||
is_valid: r.is_valid,
|
||||
};
|
||||
};
|
||||
|
||||
// proxy update to handle metadata correctly
|
||||
const originalUpdate = r.update.bind(r);
|
||||
r.update = async (data) => {
|
||||
|
||||
+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
+39
-3
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"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.1",
|
||||
"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();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,65 @@ afterAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('OAuth client management API — /api/oauth/client', () => {
|
||||
// Regression: the ORM Model.toJSON() strips non-schema fields, so the
|
||||
// mapped client_id/scopes/etc. used to vanish from GET responses —
|
||||
// client_id came back undefined and the theta-env bootstrap's rotate
|
||||
// crashed with a 500. GET must expose client_id (and never the secret hash).
|
||||
test('GET / list exposes client_id and hides client_secret_hash', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/client/')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
const mine = res.body.results.find((c) => c.client_id === clientId);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine.client_id).toBe(clientId);
|
||||
expect(mine).toHaveProperty('scopes');
|
||||
expect(mine).not.toHaveProperty('client_secret_hash');
|
||||
});
|
||||
|
||||
test('GET /:id exposes client_id', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/oauth/client/${clientId}`)
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.client_id).toBe(clientId);
|
||||
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||
});
|
||||
|
||||
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||
// find by name, rotate by the client_id from the list response. Uses a
|
||||
// throwaway client so the shared flow client's secret is untouched.
|
||||
const created = await request(app)
|
||||
.post('/api/oauth/client/')
|
||||
.set('auth-token', token)
|
||||
.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
|
||||
expect(created.status).toBe(200);
|
||||
|
||||
const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
|
||||
const found = list.body.results.find((c) => c.name === 'rotate-regression');
|
||||
expect(found).toBeDefined();
|
||||
expect(found.client_id).toBeTruthy(); // was undefined before the fix
|
||||
|
||||
const rotated = await request(app)
|
||||
.post(`/api/oauth/client/${found.client_id}/rotate`)
|
||||
.set('auth-token', token);
|
||||
expect(rotated.status).toBe(200);
|
||||
expect(rotated.body.client_secret).toBeTruthy();
|
||||
|
||||
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||
});
|
||||
|
||||
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC Discovery', () => {
|
||||
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
|
||||
+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