diff --git a/directory_spec.md b/directory_spec.md new file mode 100644 index 0000000..0c27c39 --- /dev/null +++ b/directory_spec.md @@ -0,0 +1,216 @@ +# Home-Lab Directory / Inventory — Design Spec + +Status: **Draft / agreed direction** (no code yet) +Owner: wmantly +Last updated: 2026-07-02 + +--- + +## 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 | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` | +| `name` | text | display name ("Home Assistant", "ct101") | +| `slug` | text unique | url-safe id used by the API | +| `description`| text | free text | +| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` | +| `created_at` / `updated_at` | timestamptz | | + +### `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 host←container←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=]` | 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:** the app does **not yet** have a long-lived service + token — `AuthToken` is session-oriented. **Proposed small addition:** a + `ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`), + long-lived, read-only, passed in the same `auth-token` header. Track as its own + task; the discovery API should assume it exists but degrade to normal auth tokens + until then. +- **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 ~82–126) + 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, `ServiceToken` 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:** read-only globally, or per-token resource/kind scoping? diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 576718a..1640742 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -44,3 +44,70 @@ a:hover, button:hover, div.form-group:hover, header nav a{ transform: scale(1.05); /* Standard syntax */ z-index: 999999; } + +/* ── Reusable tag / token input (app.ui.tagInput / groupSelect) ────────────── */ +.tag-input{ + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .25rem; + min-height: calc(1.5em + .75rem + 2px); + height: auto; + cursor: text; +} +.tag-input .tag-chips{ + display: contents; +} +.tag-input .tag-chip{ + display: inline-flex; + align-items: center; + gap: .35rem; + background-color: #0d6efd; + color: #fff; + font-size: .85em; + font-weight: 500; + padding: .25rem .5rem; + border-radius: .5rem; +} +.tag-input .tag-chip .tag-remove{ + color: #fff; + opacity: .8; + text-decoration: none; + font-weight: 700; + line-height: 1; + cursor: pointer; +} +.tag-input .tag-chip .tag-remove:hover{ opacity: 1; transform: none; } +.tag-input .tag-typeahead{ + flex: 1 1 8ch; + min-width: 8ch; + border: none; + outline: none; + background: transparent; + padding: .15rem 0; +} +.tag-input .tag-menu{ + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1080; + background: #fff; + border: 1px solid rgba(0,0,0,.15); + border-radius: .375rem; + box-shadow: 0 .5rem 1rem rgba(0,0,0,.15); + max-height: 12rem; + overflow-y: auto; + margin-top: .15rem; +} +.tag-input .tag-option{ + padding: .35rem .6rem; + cursor: pointer; +} +.tag-input .tag-option:hover, +.tag-input .tag-option.active{ + background-color: #0d6efd; + color: #fff; + transform: none; +} diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index c700385..125833c 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -128,6 +128,130 @@ app.group = (function(app){ return {list, get, remove} })(app) +// Reusable UI widgets. app.ui.tagInput is a generic tag/token field with +// autocomplete; app.ui.groupSelect is a tagInput preloaded with every LDAP +// group, meant to be dropped in anywhere group selection is needed. +app.ui = (function(app){ + + // All LDAP group CNs, fetched once and shared across every group selector. + var _groupsPromise = null; + function loadGroups(){ + if(!_groupsPromise){ + _groupsPromise = new Promise(function(resolve){ + app.group.list(function(error, data){ + if(error || !data || !data.results){ resolve([]); return; } + resolve(data.results.map(function(g){ return g.cn || g; }).filter(Boolean).sort()); + }); + }); + } + return _groupsPromise; + } + // Drop the cache (e.g. after a group is created) so the next selector refetches. + function refreshGroups(){ _groupsPromise = null; return loadGroups(); } + + // opts: { values, options, freeSolo, placeholder, name, separator } + // Returns a handle: { get, set, add, clear, setOptions, element }. + function tagInput(mount, opts){ + opts = opts || {}; + var separator = opts.separator != null ? opts.separator : '\n'; + var values = (opts.values || []).slice(); + var options = (opts.options || []).slice(); + var freeSolo = opts.freeSolo !== false; // default: allow custom entries + + var $mount = $(mount).addClass('tag-input form-control shadow').empty(); + var $chips = $(''); + var $input = $('') + .attr('placeholder', opts.placeholder || 'Type to add…'); + var $menu = $('
').hide(); + var $hidden = $('').attr('name', opts.name || ''); + $mount.append($chips, $input, $menu, $hidden); + + function syncHidden(){ + var joined = values.join(separator); + $hidden.val(joined).attr('value', joined); + $mount.trigger('change'); + } + function renderChips(){ + $chips.empty(); + values.forEach(function(v){ + var $remove = $('×').on('click', function(e){ + e.preventDefault(); e.stopPropagation(); + values = values.filter(function(x){ return x !== v; }); + renderChips(); syncHidden(); + }); + $chips.append($('').text(v).append(' ', $remove)); + }); + } + function suggestions(){ + var q = ($input.val() || '').toLowerCase(); + return options.filter(function(o){ + return values.indexOf(o) === -1 && o.toLowerCase().indexOf(q) !== -1; + }).slice(0, 10); + } + function showMenu(){ + var items = suggestions(); + if(!items.length){ return hideMenu(); } + $menu.empty(); + items.forEach(function(o){ + $('
').text(o).on('mousedown', function(e){ + e.preventDefault(); add(o); + }).appendTo($menu); + }); + $menu.show(); + } + function hideMenu(){ $menu.hide(); } + function add(val){ + val = (val || '').trim(); + if(!val){ return; } + if(!freeSolo && options.indexOf(val) === -1){ return; } // reject invalid + if(values.indexOf(val) === -1){ values.push(val); renderChips(); syncHidden(); } + $input.val(''); hideMenu(); + } + + $input.on('input focus', showMenu); + $input.on('keydown', function(e){ + if(e.key === 'Enter' || e.key === ','){ + e.preventDefault(); + add(freeSolo ? $input.val() : (suggestions()[0] || '')); + }else if(e.key === 'Backspace' && !$input.val() && values.length){ + values.pop(); renderChips(); syncHidden(); + } + }); + $input.on('blur', function(){ setTimeout(hideMenu, 150); }); + $mount.on('click', function(e){ + if(e.target === $mount[0] || e.target === $chips[0]){ $input.focus(); } + }); + + renderChips(); syncHidden(); + + return { + get: function(){ return values.slice(); }, + set: function(v){ values = (v || []).slice(); renderChips(); syncHidden(); }, + add: add, + clear: function(){ values = []; renderChips(); syncHidden(); }, + setOptions: function(o){ options = (o || []).slice(); }, + element: $mount, + }; + } + + // Universal group selector. Preloads all LDAP groups for autocomplete. + function groupSelect(mount, opts){ + opts = opts || {}; + var handle = tagInput(mount, { + name: opts.name || 'groups', + values: opts.values || [], + options: [], + freeSolo: opts.freeSolo !== false, + separator: opts.separator != null ? opts.separator : '\n', + placeholder: opts.placeholder || 'Type a group name…', + }); + loadGroups().then(function(groups){ handle.setOptions(groups); }); + return handle; + } + + return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups }; +})(app); + app.oauthClient = (function(app){ function list(callack){ return app.api.get('oauth/client/', function(error, data){ diff --git a/nodejs/views/oauth_clients.ejs b/nodejs/views/oauth_clients.ejs index ac3c3be..78374b4 100644 --- a/nodejs/views/oauth_clients.ejs +++ b/nodejs/views/oauth_clients.ejs @@ -1,5 +1,55 @@ <%- include('top') %> + + +