Files
sso-manager-node/directory_spec.md
T
wmantly fd98854628 spec: readiness review of the five planned directory consumers
Records what each planned consumer of the directory data needs — end-user
catalog + access requests, SSH jump host, firewall rule generation/drift
testing, local DNS/mDNS, and host access control — what the current
model/API already covers, and the concrete gaps.

Verdict: the graph model is sufficient for all five. Gaps are one new
model (AccessRequest), service-token auth for the read API, documented
metadata conventions (portMappings, sshPort, dnsNames, icon, ...), and
cheap change detection (updated_on/etag). Also flags a real issue found
while auditing: /api/discovery/resources exposes full metadata —
including OAuth client_secret_hash — to any authenticated user; a
metadata privacy projection tops the work list.

Also updates the spec's stale status line (it still said "no code yet").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:33:19 -04:00

370 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Home-Lab Directory / Inventory — Design Spec
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-23
---
## 1. Overview & goals
The SSO app manages LDAP groups that gate access across the home lab:
- `app_*` — applications (Home Assistant, Gitea, Emby, …)
- `host_*` — machines (Proxmox nodes, LXC containers, VMs, bare metal)
A bootstrap script joins each Debian container to LDAP and grants SSH by group
membership. Access control works — but the groups are **bare**. In LDAP a group
(`models/group_ldap.js`) only carries `cn`, `description`, `member`, `owner`.
There is:
- **No metadata** — no URL, IP, FQDN, port, icon, or notes per app/host.
- **No relationships** — nothing links a **Proxmox host → the container running on
it → the service the container provides → the LDAP group that gates access**.
Consequences:
- **Users can't see what they have access to or how to reach it.** Today
`views/profile.ejs` hardcodes a static "Services" list (Emby, Git, Proxmox…)
with fixed URLs that are unrelated to the viewer's actual group membership.
- **CI/CD and service discovery have no source of truth.** Pipelines and tooling
can't answer "which host runs service X", "what's the IP of `host_ct101`", or
"which group grants access to this".
### Goals
1. A machine-readable **discovery API** (build this first) that exposes apps/hosts,
their metadata, and the infra graph — for CI/CD, dynamic inventory, and dashboards.
2. Answer, per user, **"what can I access and how do I reach it"** (later dashboard,
powered by the same API).
3. Model the **full graph**: Proxmox host ← container/VM ← service/app.
### Non-goals (v1)
- No write/admin UI yet (management comes in a later phase).
- No auto-provisioning/sync from Proxmox yet (manual population first).
- No change to how access is granted — **LDAP remains the access-control truth.**
---
## 2. Architecture
Two stores, one join key.
```
┌──────────────────────────────────────────────┐
│ SSO app (this repo) │
│ │
Identity │ LDAP ──────────────► access-control truth │
& access │ (users, app_*/host_* groups, membership) │
│ │ │
│ │ join on group cn │
│ ▼ │
Inventory │ SQL ──────────────► metadata + graph │
│ (resources, edges, resource↔group links) │
│ │ │
│ ▼ │
│ /api/discovery/* (read-first API) │
└──────────────────────────────────────────────┘
```
- **LDAP = access truth.** Who is in `app_homeassistant` / `host_ct101` stays in
LDAP, unchanged. Read via `Group.list(user.dn)` (`models/group_ldap.js`).
- **SQL = inventory truth.** Metadata and the host→container→service graph.
- **Join key = group `cn`.** A SQL resource references the LDAP group(s) that gate
it by name (`app_*` / `host_*`). "Who can access resource X" is a join:
LDAP membership ∩ `resource_group`.
### Tech choice (to confirm)
- **PostgreSQL** recommended (JSONB for flexible metadata, real relational edges).
**SQLite** is a lighter alternative acceptable for a single-node home lab.
- Thin query layer: `node-postgres` (`pg`) directly, or `knex` for migrations +
query building. This is the app's **first SQL dependency** (today it uses LDAP +
Redis via `model-redis`); the directory is a self-contained module and should not
disturb the existing stores.
---
## 3. Data model (full graph)
Three tables. Metadata is a JSONB bag so fields can evolve without migrations;
common query fields can be promoted to columns later.
### `resource` — a node in the graph
| column | type | notes |
|--------------|-------------|-------|
| `id` | uuid / pk | |
| `kind` | enum | `site` \| `host` \| `service` |
| `name` | text | display name ("Home Assistant", "ct101") |
| `slug` | text unique | url-safe id used by the API |
| `description`| text | free text |
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
| `created_at` / `updated_at` | timestamptz | |
**Parent Enforcement Rules:**
- A **Host** MUST have a parent **Site** or **Host**.
- A **Service** MUST have a parent **Host**.
- An **OAuth Integration** MUST have a parent **Service**.
**LDAP Group Auto-Creation:**
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
- `<slug>_access` (for standard user access)
- `<slug>_admin` (for administrative access)
Additional groups can still be linked manually.
### `resource_edge` — directed relationships (the graph)
| column | type | notes |
|--------------|--------|-------|
| `parent_id` | fk → resource | |
| `child_id` | fk → resource | |
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
Represents site←host←service (`hosts`/`runs_on`) and service→service
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
multiple parents/children and multiple relation types.
### `resource_group` — link a resource to the LDAP group(s) that gate it
| column | type | notes |
|---------------|--------|-------|
| `resource_id` | fk → resource | |
| `group_cn` | text | LDAP group name, `app_*` / `host_*` |
| `access_level`| enum | `user` \| `admin` \| `owner` |
This is the bridge to auth. It lets one resource be gated by several groups
(e.g. `app_gitea` for users, `host_ct_gitea` for shell/admin).
**Naming:** the `app_*` / `host_*` prefix convention is retained in LDAP; in SQL the
distinction is captured explicitly by `resource.kind` + `resource_group`, so the API
never has to parse group-name prefixes.
### Example
```
resource: pve1 (proxmox_node) ── hosts ──▶ ct101 (container) ── exposes ──▶ gitea (service)
resource_group: gitea ↔ app_gitea (user), ct101 ↔ host_ct101 (user), pve1 ↔ host_pve1 (admin)
```
A user in `app_gitea` sees the Gitea service + how to reach it; a user in
`host_ct101` additionally sees SSH to the container; `host_pve1` sees the node.
---
## 4. Discovery API (v1 — read-first)
JSON. Mounted at `/api/discovery` behind `middleware.auth` (see §5).
| method & path | purpose |
|---------------|---------|
| `GET /api/discovery/resources?kind=&tag=&group=&parent=` | Filtered list of nodes + metadata. |
| `GET /api/discovery/resources/:slug` | One node with its edges (parents + children). |
| `GET /api/discovery/graph[?root=<slug>]` | Whole graph, or the subtree under a root. |
| `GET /api/discovery/me` | Only the resources the **caller** is entitled to — LDAP membership (`Group.list(user.dn)`) ∩ `resource_group`. Powers the future dashboard and the "what can I access" question. |
### CI/CD-friendly output (optional formats, same data)
- **Ansible dynamic inventory** shape (`?format=ansible`): groups of hosts with
`ansible_host`/vars pulled from `metadata`.
- **Dashboard** shape (Homepage/Glance): flat list of services with `href`, `icon`,
`description`.
Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is manual
(SQL seed / migration) until the admin UI phase.
---
## 5. AuthN / AuthZ
- **Interactive users:** existing session auth — `middleware.auth` validating the
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
- **Read visibility (decision to confirm):** either (a) any authenticated user may
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
and infra metadata isn't secret — with `/me` as the personalized view.
- **Management (later):** gate write/admin endpoints behind a new
`app_sso_directory_admin` LDAP group, mirroring the existing
`app_sso_oauth_admin` pattern (`routes/oauth_client.js` + `utils/permission.js`).
---
## 6. Integration points with the existing app
- **Replace the hardcoded Services list** in `views/profile.ejs` (lines ~82126)
with a render of `GET /api/discovery/me`.
- **Reuse the group selector** `app.ui.groupSelect` (`public/js/app.js`) for linking
resources ↔ LDAP groups in the future admin UI — no new group-picker needed.
- **Join convention:** `resource_group.group_cn` must equal an LDAP group `cn`
exactly; the discovery layer never invents groups, it only references existing ones.
- **Reuse `utils/permission.byGroup`** for the admin gate in the management phase.
---
## 7. Roadmap
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
`app_sso_directory_admin`.
4. **v4 — Sync from Proxmox** (optional): auto-populate nodes/containers/VMs from the
Proxmox API so inventory stays current without manual entry.
---
## 8. Open questions
1. **DB engine:** PostgreSQL (recommended) vs SQLite for a single-node lab.
2. **Population:** manual seed vs Proxmox pull for v1 (spec assumes manual).
3. **Metadata mirroring:** should any metadata be written back to the LDAP group
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
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).