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
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:
Consequences:
views/profile.ejs hardcodes a static "Services" list (Emby, Git, Proxmox…)
with fixed URLs that are unrelated to the viewer's actual group membership.host_ct101", or
"which group grants access to this".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) │
└──────────────────────────────────────────────┘
app_homeassistant / host_ct101 stays in
LDAP, unchanged. Read via Group.list(user.dn) (models/group_ldap.js).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.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.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:
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.
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.
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. |
?format=ansible): groups of hosts with
ansible_host/vars pulled from metadata.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.
middleware.auth validating the
auth-token header (an AuthToken, models/token.js). No change.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./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.app_sso_directory_admin LDAP group, mirroring the existing
app_sso_oauth_admin pattern (routes/oauth_client.js + utils/permission.js).views/profile.ejs (lines ~82–126)
with a render of GET /api/discovery/me.app.ui.groupSelect (public/js/app.js) for linking
resources ↔ LDAP groups in the future admin UI — no new group-picker needed.resource_group.group_cn must equal an LDAP group cn
exactly; the discovery layer never invents groups, it only references existing ones.utils/permission.byGroup for the admin gate in the management phase./api/discovery/* endpoints, ApiToken for CI/CD.profile.ejs's static list for /me.app.ui
widgets and the oauth_clients.ejs card+modal pattern); gated by
app_sso_directory_admin.description so LDAP-only external consumers see it? Default: no — keep LDAP
for auth, SQL for inventory.ApiToken shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.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.
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.Gaps:
/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.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.icon, tagline (card-length blurb),
requestable: false for resources that shouldn't be advertised.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:
ip (and host_<hostname> slugs to resolve by name).<slug>_access), checkable via LDAP alone —
the jump host can run entirely off LDAP (SSSD) + one directory query.Gaps:
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").sshPort (default 22),
optional fqdn (when IP is dynamic), optional jumpVia edge relation if
multi-hop topologies ever appear.{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-]).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:
metadata.portMappings: [{proto: "tcp"|"udp", external: n, internal: n, comment}] as the authoritative form
(port/externalPort stay as the simple single-mapping case).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.)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:
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.updated_on / etag; push later).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:
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.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.Ordered by how much they unblock:
client_secret_hash exposure regardless of any consumer)./api/discovery/* + access/:uid endpoint
(blocks 9.2, 9.3; ServiceToken model already exists).AccessRequest model + endpoints (blocks 9.1's request half).sshPort, portMappings,
dnsNames, icon, tagline, requestable) in docs/directory.md —
conventions, not schema changes; the json column already holds them.updated_on in graph output / graph etag (blocks drift/DNS
freshness; trivial once surfaced).