Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9a3cb6044 | |||
| 0ee6825a01 | |||
| 14b6ed5ae0 | |||
| fd98854628 | |||
| 3babf18fe4 | |||
| d78f1dfabf | |||
| c3c206b830 | |||
| 0a21dce0d7 | |||
| 5940880d9b | |||
| 17fcf2fed0 |
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
||||||
|
|
||||||
## [1.3.0] - 2026-07-23
|
## [1.3.0] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-3
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
|||||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||||
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
|
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||||
|
|
||||||
### API tokens (personal access tokens)
|
### API tokens (personal access tokens)
|
||||||
|
|
||||||
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
|
|||||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||||
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
|
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||||
use `ldaps://…:636` or StartTLS.
|
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||||
|
|||||||
+2
-1
@@ -131,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
|||||||
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
||||||
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||||
# if you want LAN clients to bind without TLS (not recommended).
|
# if you want LAN clients to bind without TLS (not recommended).
|
||||||
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
|
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
||||||
|
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
||||||
EXPOSE 3001 389 636
|
EXPOSE 3001 389 636
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
- **Web management UI** — manage users, groups, and OAuth clients from a
|
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||||
browser; invite and password-reset flows over email; user self-service for
|
browser; invite and password-reset flows over email; user self-service for
|
||||||
profile and API tokens.
|
profile and API tokens.
|
||||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||||
directory, so you don't maintain a second user database for them.
|
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
|
||||||
|
same directory, so you don't maintain a second user database for them.
|
||||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||||
@@ -151,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
|||||||
┌────────────────────────┐
|
┌────────────────────────┐
|
||||||
│ OpenLDAP (slapd) │
|
│ OpenLDAP (slapd) │
|
||||||
│ - users / groups │
|
│ - users / groups │
|
||||||
│ - LDAPS :636 │─── legacy apps bind directly
|
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||||
│ - StartTLS :389 │
|
│ - StartTLS :389 │
|
||||||
└────────────────────────┘
|
└────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
+149
-2
@@ -1,8 +1,10 @@
|
|||||||
# Home-Lab Directory / Inventory — Design Spec
|
# 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
|
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.
|
for auth, SQL for inventory.
|
||||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
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.
|
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).
|
||||||
|
|||||||
@@ -57,3 +57,41 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
|||||||
## Navigating the UI
|
## Navigating the UI
|
||||||
|
|
||||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
||||||
|
|
||||||
|
## Slug conventions
|
||||||
|
|
||||||
|
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||||
|
|
||||||
|
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||||
|
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||||
|
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
||||||
|
|
||||||
|
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
|
||||||
|
|
||||||
|
## Automatic registration
|
||||||
|
|
||||||
|
You don't have to build the graph by hand — the theta42 tooling registers itself:
|
||||||
|
|
||||||
|
### The stack itself (theta-env)
|
||||||
|
|
||||||
|
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
|
||||||
|
|
||||||
|
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||||
|
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||||
|
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||||
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||||
|
|
||||||
|
### Linux hosts (ldap-client)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
|
|
||||||
|
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
||||||
|
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||||
|
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
||||||
|
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||||
|
|||||||
+4
-2
@@ -52,11 +52,13 @@ backend, that's the niche.
|
|||||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||||
invite and password-reset flows over email; self-service profile + API
|
invite and password-reset flows over email; self-service profile + API
|
||||||
tokens.
|
tokens.
|
||||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||||
|
directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
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.
|
- **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.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
|||||||
|
|
||||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||||
|
theta42/proxy, etc.
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,21 @@ class OAuthClient {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
static async get(client_id) {
|
static async get(client_id) {
|
||||||
|
const notFound = () => {
|
||||||
|
const e = new Error('OAuthClient not found');
|
||||||
|
e.status = 404;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await Resource.get(client_id);
|
r = await Resource.get(client_id);
|
||||||
} catch (_) {
|
} 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
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||||
r.client_id = r.id;
|
r.client_id = r.id;
|
||||||
r.client_secret_hash = r.metadata.client_secret_hash;
|
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||||
@@ -69,6 +77,29 @@ class OAuthClient {
|
|||||||
return raw_secret;
|
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
|
// proxy update to handle metadata correctly
|
||||||
const originalUpdate = r.update.bind(r);
|
const originalUpdate = r.update.bind(r);
|
||||||
r.update = async (data) => {
|
r.update = async (data) => {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.0",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.0",
|
"version": "1.3.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.0",
|
"version": "1.3.2",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const DOCS = {
|
|||||||
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -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', () => {
|
describe('OIDC Discovery', () => {
|
||||||
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
||||||
const res = await request(app).get('/.well-known/openid-configuration');
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
||||||
|
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="card-body table table-striped mb-0">
|
<table class="card-body table table-striped mb-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user