Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecc9b62842 | |||
| e74c5cf11d | |||
| 4a592f9795 | |||
| aa2592ea4e | |||
| 9cf0ce34ca | |||
| 3b6d1ceda9 | |||
| e9b808d1c2 | |||
| 6c71c91ff6 | |||
| ac25084113 | |||
| a788a99e56 | |||
| bcd160cca2 | |||
| 724f5d8496 | |||
| 8fc7dd11f5 | |||
| e91ed6f1f7 | |||
| 874f7db037 | |||
| 013c21d4f0 | |||
| 42a61f8868 | |||
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b |
@@ -703,6 +703,10 @@ The authenticated user is automatically set as the group owner.
|
||||
{ "results": true, "message": "Added user uid to group group." }
|
||||
```
|
||||
|
||||
Returns `409` if the user is already a member — common in practice, since
|
||||
`groupOfNames` requires at least one member and so seeds whoever created the
|
||||
group into it.
|
||||
|
||||
---
|
||||
|
||||
### Remove User from Group
|
||||
@@ -716,6 +720,66 @@ The authenticated user is automatically set as the group owner.
|
||||
|
||||
---
|
||||
|
||||
### Nest a Group Inside Another
|
||||
|
||||
**`PUT /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||
|
||||
Makes `:child` a member of `:group`, so everyone in `:child` is a member of
|
||||
`:group` at any depth.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | When |
|
||||
|--------|------|
|
||||
| `400` | `:group` and `:child` are the same group |
|
||||
| `409` | already nested, or the nesting would create a loop (`:child` already contains `:group`, directly or transitively) |
|
||||
|
||||
---
|
||||
|
||||
### Un-nest a Group
|
||||
|
||||
**`DELETE /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | When |
|
||||
|--------|------|
|
||||
| `409` | `:child` is the only member — `groupOfNames` requires at least one |
|
||||
|
||||
---
|
||||
|
||||
### Effective Membership
|
||||
|
||||
**`GET /api/group/:group/effective`** — Any authenticated user
|
||||
|
||||
Who a group actually grants. `direct` is users listed on the group itself
|
||||
(never groups); `nestedGroups` is what is nested into it; `effective` is every
|
||||
user reachable through the whole chain.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"cn": "app_gitea_access",
|
||||
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
|
||||
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
|
||||
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Delete Group
|
||||
|
||||
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
||||
|
||||
+106
@@ -4,6 +4,112 @@ 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.11.0] - 2026-07-31
|
||||
|
||||
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
||||
|
||||
### Fixed
|
||||
- **`GET /api/discovery/me` returned only `isPublic` resources for every human caller.** It resolved the caller's groups from `req.user.groups`, which does not exist — `req.user` is a `User` carrying `memberOf` (DNs). The empty list failed open into "no group-granted resources", so "My Services" on the profile page and the portal's service list were blank for everyone. The same bug made `isDirectoryAdmin()` false for real directory admins, silently downgrading them to the public metadata projection. Group CNs now come from `utils/user_groups.js`.
|
||||
- **The portal's "Discover More Services" was dead for every non-admin.** It called the admin-gated `directory-admin/resources` and swallowed the 403 into an empty array — so the one discovery feature never rendered for the audience it existed for. It now calls `/api/discovery/resources`.
|
||||
- **Services reported no address.** `/api/discovery/me` had reimplemented `Resource.getMyAccess` without its parent-walking address resolution, leaving clients to guess `address || ip`, which is exactly wrong for a service that is reached at its host's IP. Both paths now share `Resource.withResolvedAddress()`.
|
||||
- **Approving access for a user already in the target group threw a 500** and left the request stuck pending. `groupOfNames` requires at least one member, so a resource's auto-created groups are seeded with the creator's DN; the grant is now idempotent.
|
||||
- **`DELETE /api/directory-admin/resources/:id` deleted the resource before its edges and group links.** With no transaction, a failure mid-way orphaned rows pointing at a nonexistent id — invisible in the UI and poisonous to `getGraph()`. Dependents go first now.
|
||||
- `PUT /api/directory-admin/resources/:id` validated the body only after loading the row, and carried a dead if/else whose branches were identical.
|
||||
- `/api/directory-admin/audit-logs` shelled out to `tail` three times via `execSync`; replaced with a bounded async file read (no `child_process`, at most the trailing 256 KB).
|
||||
|
||||
### Added
|
||||
- **End-user catalog at `/`**, and the first ungated nav item — previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a **how to reach it** block per card: the URL for a service, the SSH invocation for a host (using the jump-host `uid_-_slug@host` grammar when `directory.jumpHost` is configured).
|
||||
- **Self-service access requests** — `AccessRequest` model plus `/api/access-requests` (create, list own, list decidable, approve, deny, withdraw). Approving performs the LDAP group add, so LDAP remains the access-control truth. Requests target a resource's `member`-level group, never its `_admin` one. Replaces the "coming soon" stub.
|
||||
- **Admin access visibility**: an Access column on the directory table showing member and group counts (and flagging links whose LDAP group has been deleted), plus a "what can this user reach" lookup — the reverse question, which previously had no UI at all. Backed by `GET /api/directory-admin/access-summary` and `/user-access/:uid`.
|
||||
- `conf.directory` — `jumpHost` and `defaultSshPort`, the connection conventions the catalog renders.
|
||||
- `tests/access_request.test.js` — the request → approve → grant-is-real loop end to end, including the regression guard for the `user.groups` bug.
|
||||
|
||||
### Added — nested groups
|
||||
- **A group can now contain another group.** `groupOfNames.member` accepts any DN, so nesting needs no new schema; what it needs is *resolution*, which no released OpenLDAP performs — `memberOf` and `(member=X)` both return direct membership only. Two halves:
|
||||
- **Server-side**: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (`350e9eb3`) to get the **`nestgroup`** overlay (ITS#10161), enabled with `member-filter memberof-filter memberof-values`. `member-values` is deliberately omitted — it expands `member` when reading a group, which destroys the distinction between "listed here" and "reachable via nesting" and is not recoverable afterwards. `pw-sha2` is built from contrib in the same stage; without it every existing `{SSHA512}` password would be unverifiable.
|
||||
- **Client-side**: `Group.list(dn)` computes the transitive closure itself (cycle-detected, depth-capped) when the server can't, selected by `conf.ldap.nestedGroupsServerSide` — which `docker-entrypoint.sh` derives from probing for `nestgroup.so` rather than hardcoding. Both paths are covered by the full suite.
|
||||
- `PUT`/`DELETE /api/group/:group/nested/:child` and `GET /api/group/:group/effective`, plus a **Nested** tab on each group card. Cycles are refused (409) rather than silently depth-truncated.
|
||||
- **`app_super_admin` is now seeded** (it never was) and nested into `app_sso_admin` / `app_sso_invite` / `app_sso_oauth_admin`, so the privilege is real LDAP membership visible to SSSD and sudo — not just a special case in `utils/permission.js`. Not nested into `app_sso_service_account`, which marks non-person accounts rather than granting anything.
|
||||
- Creating a directory resource nests `app_super_admin → <slug>_admin` and `<slug>_admin → <slug>_access`. Both previously required adding every super admin to every new group by hand, so they drifted.
|
||||
- `ldap_group_nesting_level = 5` in ldap-client's SSSD template, for hosts pointed at a server without `nestgroup`. Against the bundled slapd the existing `memberof=` access filter is already transitive, so SSH login inherits nesting for free.
|
||||
|
||||
### Fixed
|
||||
- `PUT /api/group/:group/:uid` returned a bare **500** when the user was already a member — common, since `groupOfNames` requires a member and so seeds whoever created the group. Now a 409 that says so.
|
||||
- Un-nesting (or removing) the last member of a group returned a 500 `ObjectClassViolationError`; now a 409 explaining that a group must keep at least one member.
|
||||
- `GET /api/user/me` derived `isAdmin` from `memberOf`, which is only transitive when `nestgroup` is present. Against a stock server an admin holding their group via nesting would get `isAdmin=false` and lose the entire admin UI while still passing every server-side permission check.
|
||||
- `utils/permission.js`'s `byGroup` checked `group.member.includes(user.dn)` per group, seeing only direct membership.
|
||||
- `/api/directory-admin/access-summary` counted `member` values; it now counts the transitive closure, which matters precisely because `app_super_admin` is nested into every resource's admin group.
|
||||
- Broken `api.html` link in the published docs (`API.md` lives at the repo root, so Jekyll never rendered one); pointed at the source, and added an API entry to the docs nav.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/directory-schema` bumped to `^1.1.0`, which declares the ten metadata keys the admin form has always written but the schema never listed (`port`, `externalPort`, `isExternalReachable`, `os`, `gitRepo`, `isCurrentSite` as public; `vmid`, `macAddress`, `installPath`, `systemdService` as admin-only). Undeclared keys are dropped for non-admin callers, which blanked the portal's `OS:` field, hid every service's port from users, and left machine tokens unable to read the port mapping the firewall consumer exists to render.
|
||||
- Resource metadata now includes `icon` and `tagline`, collected on the admin form (with a live icon preview) and rendered on the catalog cards.
|
||||
|
||||
## [1.10.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **`app_super_admin` cross-app group**: members are full admins here regardless of `app_sso_admin` membership. Bypassed centrally in `utils/permission.js`'s `byGroup`, folded into `GET /api/user/me`'s `isAdmin` flag, and added to nav/`forceLogin` gates. The same group is now also recognized by proxy and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
|
||||
|
||||
### Changed
|
||||
- **Renamed the Executive page to Overview** (route, view, `/api/metrics/overview`, nav label, docs). `/executive` kept as a 301 redirect alongside the existing `/admin`, `/notifications`, `/dashboard` legacy redirects.
|
||||
|
||||
## [1.9.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory modal's Associated LDAP Groups tab now supports full membership management**: view, add, and remove members/owners of each associated group directly from the tab, reusing the same `PUT`/`DELETE group/:group/:uid` routes and member-mapping pattern already used on the Groups page.
|
||||
- **`app.util.revealItem()`** (in the shared `app-base.js`, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
|
||||
|
||||
### Changed
|
||||
- **Groups page's search/sort bar is now sticky**, staying visible while scrolling through a long group list. Introduces `--sw-content-offset` (set in `top.ejs` alongside `#spa-shell`'s margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them.
|
||||
- **Directory table**: Kind/Name/Env/Host merged into a single "Resource" column.
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
|
||||
|
||||
## [1.8.3] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
|
||||
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>`→`<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
|
||||
|
||||
## [1.8.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
|
||||
|
||||
## [1.8.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
|
||||
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
|
||||
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
|
||||
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
|
||||
|
||||
### Fixed
|
||||
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
|
||||
|
||||
### Operational note
|
||||
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
|
||||
|
||||
## [1.7.0] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`formAJAX`'s loading indicator showed literal HTML** ("<div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
|
||||
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
|
||||
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
|
||||
|
||||
### Changed
|
||||
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
|
||||
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
|
||||
|
||||
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
|
||||
|
||||
## [1.6.3] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -336,6 +336,17 @@ OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
|
||||
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
||||
OAuth clients live in SSO Redis and are preserved by the volume.
|
||||
|
||||
> **Note — the bundled slapd is built from source.** The all-in-one image
|
||||
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
|
||||
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
|
||||
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
|
||||
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
|
||||
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
|
||||
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
|
||||
> shape as "Restore — LDAP only" above. Verify after a rebuild:
|
||||
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
|
||||
> as available.
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Bare metal (Debian/Ubuntu)
|
||||
|
||||
+104
-25
@@ -33,39 +33,118 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||
fi
|
||||
|
||||
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
||||
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
||||
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
||||
# where any consumer that forgets silently under-grants access.
|
||||
#
|
||||
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
||||
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||
FROM node:20-alpine AS ldapbuild
|
||||
|
||||
# groff is not optional despite producing nothing we ship: the build descends
|
||||
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||
# provides. Without it the whole `make` fails at the man-page stage
|
||||
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||
RUN apk add --no-cache \
|
||||
build-base autoconf automake libtool \
|
||||
openssl-dev cyrus-sasl-dev \
|
||||
git make pkgconf util-linux-dev groff
|
||||
|
||||
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
||||
# whole lab authenticates against; an unpinned `master` would mean every image
|
||||
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
||||
# master would take out logins with no way to tell what changed.
|
||||
#
|
||||
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
||||
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
||||
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
||||
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
||||
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
||||
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
||||
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||
|
||||
WORKDIR /src
|
||||
RUN git init -q . \
|
||||
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||
|
||||
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||
# `moduleload`s them individually; nestgroup joins that set.
|
||||
RUN ./configure \
|
||||
--prefix=/opt/openldap \
|
||||
--enable-slapd \
|
||||
--enable-modules \
|
||||
--enable-mdb \
|
||||
--enable-memberof=mod \
|
||||
--enable-refint=mod \
|
||||
--enable-ppolicy=mod \
|
||||
--enable-dynlist=mod \
|
||||
--enable-nestgroup=mod \
|
||||
--enable-syncprov=mod \
|
||||
--enable-auditlog=mod \
|
||||
--with-tls=openssl \
|
||||
--with-cyrus-sasl \
|
||||
&& make depend \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install
|
||||
|
||||
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||
# built separately against the just-built tree -- omitting it would make every
|
||||
# user password unverifiable.
|
||||
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install OpenLDAP and required packages.
|
||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
||||
# the app depends on:
|
||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
||||
# openldap-overlay-memberof : reverse group membership
|
||||
# openldap-overlay-refint : referential integrity on group members
|
||||
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
|
||||
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
|
||||
# schema is built into ppolicy.so and registered when the module loads, so
|
||||
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
|
||||
# openssl : used by docker-entrypoint.sh to generate a JWT secret
|
||||
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||
# symbol not found", not at build time.
|
||||
RUN apk add --no-cache \
|
||||
openldap \
|
||||
openldap-clients \
|
||||
openldap-back-mdb \
|
||||
openldap-overlay-ppolicy \
|
||||
openldap-overlay-memberof \
|
||||
openldap-overlay-refint \
|
||||
openldap-overlay-syncprov \
|
||||
openldap-overlay-auditlog \
|
||||
openldap-passwd-sha2 \
|
||||
openssl \
|
||||
libsasl \
|
||||
libltdl \
|
||||
libuuid \
|
||||
dumb-init \
|
||||
bash \
|
||||
openssl \
|
||||
redis \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||
# Which upstream commit this slapd was built from — so a running container can
|
||||
# answer "what am I actually running" without rebuilding.
|
||||
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
||||
|
||||
# The Alpine openldap package used to create these; nothing does now, and
|
||||
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
||||
RUN addgroup -S ldap 2>/dev/null || true \
|
||||
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
||||
|
||||
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
||||
# and probes a list of candidate module directories, so putting the from-source
|
||||
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
||||
# into the conventional location because the entrypoint's slapd.conf includes
|
||||
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
||||
# openssh-lpk) are copied there too.
|
||||
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
||||
RUN mkdir -p /etc/openldap/schema \
|
||||
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
||||
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
||||
done
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+76
-2
@@ -76,11 +76,22 @@ fi
|
||||
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||
# path varies by distro; auto-detect rather than hardcode.
|
||||
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
||||
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
||||
MODULE_PATH=""
|
||||
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||
done
|
||||
|
||||
# Nested-group support is only available when slapd was built with the
|
||||
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
||||
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
||||
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
||||
NESTGROUP_AVAILABLE=0
|
||||
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
||||
NESTGROUP_AVAILABLE=1
|
||||
fi
|
||||
|
||||
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
||||
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||
@@ -140,6 +151,7 @@ moduleload ppolicy
|
||||
moduleload memberof
|
||||
moduleload refint
|
||||
moduleload auditlog
|
||||
NESTGROUP_MODULE_PLACEHOLDER
|
||||
SYNCPROV_MODULE_PLACEHOLDER
|
||||
|
||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||
@@ -188,6 +200,8 @@ memberof-memberof-ad memberOf
|
||||
overlay refint
|
||||
refint_attributes memberOf member manager owner
|
||||
|
||||
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||
|
||||
# auditlog overlay (LDIF audit trail of all changes)
|
||||
overlay auditlog
|
||||
auditlog /var/lib/ldap/auditlog.ldif
|
||||
@@ -221,6 +235,37 @@ else
|
||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||
fi
|
||||
|
||||
# ── Nested groups (nestgroup overlay) ──
|
||||
# Three of the four flags, deliberately:
|
||||
#
|
||||
# member-filter (member=X) finds parent groups transitively. This is what
|
||||
# Group.list(dn) rides on -- the core access question.
|
||||
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
||||
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
||||
# nesting without any client-side walking.
|
||||
# memberof-values expands memberOf when reading a user, so anything that
|
||||
# reads the attribute rather than searching still sees the
|
||||
# full picture.
|
||||
#
|
||||
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
||||
# when reading a *group*, which sounds symmetric but destroys the distinction
|
||||
# between "listed on this group" and "reachable through a nested one" -- and
|
||||
# that distinction is not recoverable afterwards, because the raw values are
|
||||
# simply not returned. The Groups UI needs it to show nested groups as nested
|
||||
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
||||
# it is actually removing. Transitive *answers* come from the filter flags and
|
||||
# from Group.effectiveMembers(), which computes the closure explicitly.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
info "nestgroup overlay available — nested groups resolved server-side"
|
||||
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
||||
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
||||
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
||||
else
|
||||
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
||||
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||
fi
|
||||
|
||||
# ── Multi-Master Replication Configuration ──
|
||||
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||
@@ -310,7 +355,7 @@ EOF
|
||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||
# app_sso_service_account is a marker (not a permission gate) for
|
||||
# non-person accounts -- see the Users page.
|
||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
objectClass: groupOfNames
|
||||
@@ -321,6 +366,27 @@ member: ${LDAP_BIND_DN}
|
||||
EOF
|
||||
done
|
||||
|
||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
||||
# hold those rights by membership rather than by a special case in app code.
|
||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
||||
# anything binding LDAP directly -- instead of only to callers that happen
|
||||
# to route through utils/permission.js.
|
||||
#
|
||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||
# non-person accounts, not a permission, and nesting admins into it would
|
||||
# misclassify them as service accounts on the Users page.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
||||
EOF
|
||||
done
|
||||
info "Nested app_super_admin into the SSO admin groups"
|
||||
fi
|
||||
|
||||
info "LDAP directory initialized"
|
||||
else
|
||||
info "LDAP directory already initialized — skipping seed"
|
||||
@@ -380,6 +446,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
||||
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
||||
# Tell the app whether slapd resolves nested groups for it. When true the app
|
||||
# trusts a plain (member=) search to be transitive; when false it computes
|
||||
# the closure itself. Getting this wrong in the "true" direction silently
|
||||
# under-grants, so it is derived from the same nestgroup.so probe that
|
||||
# decides whether the overlay is configured at all -- never hardcoded.
|
||||
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
||||
fi
|
||||
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||
|
||||
@@ -34,6 +34,11 @@ nav:
|
||||
- title: Directory
|
||||
page: /directory.html
|
||||
icon: fa-server
|
||||
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
|
||||
# api.html for it — link the source directly, same as the Changelog.
|
||||
- title: API
|
||||
url: https://github.com/theta42/sso-manager-node/blob/master/API.md
|
||||
icon: fa-code
|
||||
- title: Changelog
|
||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||
icon: fa-list
|
||||
|
||||
@@ -46,13 +46,36 @@ on, just like anyone else's.
|
||||
|
||||
A **group** is just a named list of accounts, used to control access. This
|
||||
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||
people in the `app_sso_admin` group can see the Users/Groups/Directory/Executive
|
||||
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
|
||||
pages at all), but you can also make your own groups for any app you
|
||||
connect — say, a group listing everyone who should be allowed into your
|
||||
photo server. Once a group exists, add or remove members from the
|
||||
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||
that group's name.
|
||||
|
||||
### Groups inside groups
|
||||
|
||||
A group can contain another group, not just people — the *Nested* tab on any
|
||||
group card. Everyone in the inner group counts as a member of the outer one,
|
||||
however many levels deep it goes.
|
||||
|
||||
This is mostly a way to stop repeating yourself. Make one `developers` group,
|
||||
nest it into the handful of things developers should reach, and adding a new
|
||||
developer to that one group grants all of them at once — instead of adding them
|
||||
to each individually and slowly drifting out of sync. The app already does this
|
||||
for itself: super admins are nested into every resource's admin group, and each
|
||||
admin group into its access group, so "can administer it" always implies "can
|
||||
use it".
|
||||
|
||||
Two things it won't let you do: put a group inside itself (directly or round a
|
||||
longer loop), and empty a group completely — every group must keep at least one
|
||||
member.
|
||||
|
||||
A note if you also manage the directory by hand: a group's member list shows
|
||||
what is *directly* listed on it. Someone who gets in through a nested group is
|
||||
a real member but won't appear there — the **Nested** tab shows what is nested,
|
||||
and the API's `effective` view lists everyone who actually gets in.
|
||||
|
||||
## Every account's personal group
|
||||
|
||||
Separately from the groups above, every single account — person or
|
||||
|
||||
@@ -8,7 +8,7 @@ description: A plain-language guide to personal access tokens in SSO Manager.
|
||||
|
||||
This page explains what an API token is and when you'd want one. For the
|
||||
full list of API endpoints a token can call, see the
|
||||
[API reference](api.html).
|
||||
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||
|
||||
## What's an API token, in plain terms?
|
||||
|
||||
@@ -54,6 +54,6 @@ it stops working right away.
|
||||
## Want more detail?
|
||||
|
||||
This page doesn't attempt to list every API endpoint or show request/
|
||||
response examples — for that, see the full [API reference](api.html).
|
||||
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
@@ -54,6 +54,28 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
||||
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
||||
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
|
||||
|
||||
### Who sees which metadata
|
||||
|
||||
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
|
||||
|
||||
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
|
||||
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
|
||||
- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
|
||||
|
||||
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
|
||||
|
||||
## Catalog & access requests
|
||||
|
||||
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
|
||||
|
||||
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
|
||||
- **Discover More** — everything else in the directory, with a **Request access** button.
|
||||
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
|
||||
|
||||
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
|
||||
|
||||
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
|
||||
|
||||
## 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.
|
||||
@@ -104,4 +126,14 @@ All of the above uses the same admin API the UI does (group `app_sso_directory_a
|
||||
- `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
|
||||
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
|
||||
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
|
||||
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||
|
||||
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
|
||||
|
||||
- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}`
|
||||
- `GET /api/access-requests/mine` — the caller's own history
|
||||
- `GET /api/access-requests` — pending requests the caller may decide
|
||||
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
|
||||
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ one command).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview dashboard" width="49%"></a>
|
||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||
|
||||
+65
-6
@@ -59,8 +59,41 @@ way or use `slappasswd -h '{SSHA512}'`.
|
||||
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
||||
attribute listing member DNs. The `memberOf` overlay populates reverse
|
||||
membership (`memberOf` on the user); `refint` keeps it consistent on
|
||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
||||
`memberOf` on the user.
|
||||
add/remove.
|
||||
|
||||
Note that `groupOfNames` requires **at least one member**, which has two
|
||||
consequences worth knowing: whoever creates a group is automatically seeded
|
||||
into it, and removing the last member (user *or* nested group) is refused with
|
||||
a 409 rather than leaving an invalid entry behind.
|
||||
|
||||
### Nested groups
|
||||
|
||||
A `member` DN may be another group's, not just a user's — that is how nesting
|
||||
is stored, with no extra schema. Everyone in the nested group is a member of
|
||||
the outer one, at any depth. Manage it on the **Groups** page under each
|
||||
group's *Nested* tab, or via the API:
|
||||
|
||||
```
|
||||
PUT /api/group/:group/nested/:child nest :child inside :group
|
||||
DELETE /api/group/:group/nested/:child un-nest
|
||||
GET /api/group/:group/effective direct users, nested groups, and the
|
||||
full transitive set of users
|
||||
```
|
||||
|
||||
Cycles are refused (409) rather than truncated — a loop makes "who is in this
|
||||
group" unanswerable. Two standing relationships are wired automatically: the
|
||||
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
|
||||
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
|
||||
something implies being able to use it.
|
||||
|
||||
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
|
||||
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
|
||||
direct membership only. The bundled slapd is therefore built from source with
|
||||
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
|
||||
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
|
||||
closure itself — same answers, more queries. Either way, **never read `memberOf`
|
||||
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
|
||||
which is correct in both modes.
|
||||
|
||||
### Personal groups
|
||||
|
||||
@@ -75,15 +108,15 @@ instead from the owning user's own profile page ("Members of `<uid>`'s
|
||||
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||
share write access to files owned by this group.
|
||||
|
||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
||||
`install.sh`):
|
||||
The SSO seeds these groups automatically (entrypoint / `install.sh`):
|
||||
|
||||
| Group | Grants |
|
||||
|-------|--------|
|
||||
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
|
||||
| `app_sso_admin` | full admin (users, groups, settings) |
|
||||
| `app_sso_oauth_admin` | OAuth client management |
|
||||
| `app_sso_invite` | invitation management |
|
||||
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below) |
|
||||
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
|
||||
|
||||
## TLS (LDAPS / StartTLS)
|
||||
|
||||
@@ -308,11 +341,37 @@ needs:
|
||||
|
||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
||||
`ppolicy`, `memberof`, `refint`.
|
||||
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
|
||||
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
|
||||
unreleased), so the bundled image builds slapd from a pinned upstream commit.
|
||||
Without it the app resolves nesting itself and everything still works — leave
|
||||
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
|
||||
configure:
|
||||
|
||||
```
|
||||
overlay nestgroup
|
||||
nestgroup-base ou=groups,<base>
|
||||
nestgroup-flags member-filter memberof-filter memberof-values
|
||||
```
|
||||
|
||||
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
|
||||
notation suggests is rejected. `member-values` is deliberately omitted — it
|
||||
expands the `member` attribute when reading a group, which destroys the
|
||||
distinction between "listed here" and "reachable through a nested group", and
|
||||
the raw values are then unrecoverable. Transitive answers come from the filter
|
||||
flags and from `GET /api/group/:group/effective`.
|
||||
|
||||
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
|
||||
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
|
||||
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
|
||||
is a `slapcat` → `slapadd` reload, not a restart.
|
||||
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`.
|
||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
|
||||
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
|
||||
also nests it into the first three).
|
||||
|
||||
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
||||
idempotently against a running slapd (auto-detects the database holding your
|
||||
|
||||
@@ -91,6 +91,9 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||
// Self-service access requests — any authenticated user may ask; deciding is
|
||||
// gated per-resource inside the router (owner or directory admin).
|
||||
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||
|
||||
@@ -29,6 +29,11 @@ module.exports = {
|
||||
// public 636 port forward. See docs/ldap.md.
|
||||
ldapsHost: '',
|
||||
ldapsPort: 636,
|
||||
// True when slapd carries the `nestgroup` overlay, which resolves nested
|
||||
// groups server-side. Set automatically by docker-entrypoint.sh for the
|
||||
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
|
||||
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
|
||||
nestedGroupsServerSide: false,
|
||||
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||
@@ -60,6 +65,16 @@ module.exports = {
|
||||
pass: '__in secrets file__',
|
||||
from: 'SSO Manager <noreply@example.com>',
|
||||
},
|
||||
directory: {
|
||||
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||
// component). When set, a host card in the catalog shows the real
|
||||
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
|
||||
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
|
||||
// the card falls back to the direct form.
|
||||
jumpHost: '',
|
||||
// Default SSH port assumed when a host carries no metadata.sshPort.
|
||||
defaultSshPort: 22,
|
||||
},
|
||||
service: {
|
||||
updateCheck: {
|
||||
enabled: true,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
// Self-service access requests: the "request" half of the directory catalog.
|
||||
//
|
||||
// A request is a *proposal to join an LDAP group*. Approving one does exactly
|
||||
// what an admin would have done by hand -- add the user to `groupCn` -- so LDAP
|
||||
// remains the single access-control truth and this table is only the paper
|
||||
// trail of who asked, who decided, and when. Nothing here grants anything on
|
||||
// its own; a row with status 'approved' whose LDAP write failed is a row that
|
||||
// grants no access, which is the safe direction.
|
||||
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
APPROVED: 'approved',
|
||||
DENIED: 'denied',
|
||||
CANCELLED: 'cancelled',
|
||||
};
|
||||
|
||||
class AccessRequest extends Model {
|
||||
static fields = {
|
||||
id: { type: 'uuid', primaryKey: true },
|
||||
// The requesting user's uid (not dn): dn changes if the directory is
|
||||
// restructured, uid is the stable handle used everywhere else in the app.
|
||||
uid: { type: 'string', isRequired: true },
|
||||
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
||||
// The group joining which satisfies this request. Captured at request time
|
||||
// so a later re-link of the resource's groups can't silently redirect a
|
||||
// pending approval at a different group than the one that was reviewed.
|
||||
groupCn: { type: 'string', isRequired: true },
|
||||
status: { type: 'string', isRequired: true, default: STATUS.PENDING },
|
||||
note: { type: 'text' },
|
||||
requestedOn: { type: 'integer' },
|
||||
decidedBy: { type: 'string' },
|
||||
decidedOn: { type: 'integer' },
|
||||
decisionNote: { type: 'text' },
|
||||
};
|
||||
|
||||
// The one request that blocks a new one: same user, same group, still open.
|
||||
// Denied/cancelled requests deliberately do not block -- circumstances change
|
||||
// and a user may ask again.
|
||||
static async findOpen(uid, groupCn) {
|
||||
const rows = await this.list({ where: { uid, groupCn, status: STATUS.PENDING } });
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
static async listForUser(uid) {
|
||||
return this.list({ where: { uid } });
|
||||
}
|
||||
|
||||
static async listPending() {
|
||||
return this.list({ where: { status: STATUS.PENDING } });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AccessRequest, STATUS };
|
||||
+182
-4
@@ -112,18 +112,190 @@ async function cachedListDetail() {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// --- Nested groups -------------------------------------------------------
|
||||
//
|
||||
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
|
||||
// a group DN is a perfectly legal member. That is how nesting is stored here:
|
||||
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
|
||||
// exactly as an admin entered it.
|
||||
//
|
||||
// What LDAP will NOT do is resolve it. The memberof overlay records only
|
||||
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
|
||||
// groups that list the DN literally. So transitivity is computed here, and
|
||||
// every membership question in the app must go through these helpers or it
|
||||
// will silently see one level and grant nothing for a nested group.
|
||||
//
|
||||
// The whole group set is one subtree search, so the closure is computed in
|
||||
// memory rather than issuing a query per level. `resolverCache` keeps that
|
||||
// search off the hot path for bursts; it is cleared by every write below, so
|
||||
// the only staleness it can introduce is from edits made outside this app.
|
||||
// Auth decisions ride on this, hence the deliberately short TTL.
|
||||
|
||||
const NESTING_TTL_MS = 15 * 1000;
|
||||
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
|
||||
|
||||
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
|
||||
|
||||
async function allGroupsForResolver() {
|
||||
const hit = resolverCache.get('all');
|
||||
if (hit) return hit;
|
||||
const promise = withClient(async (client) => {
|
||||
const groups = await getGroups(client);
|
||||
return groups.map(g => ({ ...g }));
|
||||
}).then(plain => {
|
||||
resolverCache.set('all', plain);
|
||||
return plain;
|
||||
}).catch(err => {
|
||||
resolverCache.delete('all');
|
||||
throw err;
|
||||
});
|
||||
resolverCache.set('all', promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
const lc = dn => String(dn || '').toLowerCase();
|
||||
|
||||
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
|
||||
function buildParentIndex(groups) {
|
||||
const parents = new Map();
|
||||
for (const group of groups) {
|
||||
for (const member of [].concat(group.member || []).filter(Boolean)) {
|
||||
const key = lc(member);
|
||||
if (!parents.has(key)) parents.set(key, []);
|
||||
parents.get(key).push(group);
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
// Every group `dn` belongs to, directly or through any chain of nested groups.
|
||||
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
|
||||
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
|
||||
function closureUp(dn, groups) {
|
||||
const parents = buildParentIndex(groups);
|
||||
const found = new Map(); // cn -> group
|
||||
const seen = new Set([lc(dn)]);
|
||||
let frontier = [lc(dn)];
|
||||
|
||||
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const current of frontier) {
|
||||
for (const group of parents.get(current) || []) {
|
||||
const groupDn = lc(group.dn);
|
||||
if (seen.has(groupDn)) continue;
|
||||
seen.add(groupDn);
|
||||
found.set(group.cn, group);
|
||||
// The group itself is now a member to look up: this is the step
|
||||
// that makes the walk transitive rather than one-level.
|
||||
next.push(groupDn);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
// Every member DN reachable from a group, split into the users it effectively
|
||||
// grants and the groups it nests. `direct` is kept separate so the UI can show
|
||||
// "3 members, 12 effective" and so removal stays unambiguous.
|
||||
function closureDown(group, groups) {
|
||||
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
|
||||
const users = new Set();
|
||||
const nested = new Map();
|
||||
const seen = new Set([lc(group.dn)]);
|
||||
let frontier = [group];
|
||||
|
||||
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const current of frontier) {
|
||||
for (const member of [].concat(current.member || []).filter(Boolean)) {
|
||||
const key = lc(member);
|
||||
const asGroup = byDn.get(key);
|
||||
if (asGroup) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
nested.set(asGroup.cn, asGroup);
|
||||
next.push(asGroup);
|
||||
} else {
|
||||
users.add(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return { users: [...users], nested: [...nested.values()] };
|
||||
}
|
||||
|
||||
var Group = {};
|
||||
|
||||
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
|
||||
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
|
||||
// a plain `(member=<dn>)` search already returns the full transitive set and the
|
||||
// in-app closure is redundant work on every request. Without it -- e.g. pointed
|
||||
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
|
||||
// compute the closure itself or nested groups silently grant nothing.
|
||||
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
|
||||
|
||||
// Transitive: every group CN this member belongs to, at any nesting depth.
|
||||
// Callers making an access decision must use this rather than reading
|
||||
// `memberOf`, which a server without nestgroup only ever populates one level
|
||||
// deep.
|
||||
Group.list = async function(member){
|
||||
if (member) {
|
||||
return withClient(async (client) => {
|
||||
const groups = await getGroups(client, member);
|
||||
return groups.map(group => group.cn);
|
||||
});
|
||||
if (SERVER_SIDE_NESTING) {
|
||||
return withClient(async (client) => {
|
||||
const groups = await getGroups(client, member);
|
||||
return groups.map(group => group.cn);
|
||||
});
|
||||
}
|
||||
const groups = await allGroupsForResolver();
|
||||
return closureUp(member, groups).map(group => group.cn);
|
||||
}
|
||||
return (await cachedListDetail()).map(group => group.cn);
|
||||
}
|
||||
|
||||
// The members a group effectively grants: users reached through any chain of
|
||||
// nested groups, plus the nested groups themselves for display.
|
||||
Group.effectiveMembers = async function(cn){
|
||||
const groups = await allGroupsForResolver();
|
||||
const group = groups.find(g => g.cn === cn);
|
||||
if (!group) {
|
||||
let error = new Error('GroupNotFound');
|
||||
error.name = 'GroupNotFound';
|
||||
error.message = `LDAP:${cn} does not exists`;
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
const { users, nested } = closureDown(group, groups);
|
||||
const directMembers = [].concat(group.member || []).filter(Boolean);
|
||||
const groupDns = new Set(groups.map(g => lc(g.dn)));
|
||||
return {
|
||||
cn: group.cn,
|
||||
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
|
||||
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
|
||||
effective: users,
|
||||
};
|
||||
};
|
||||
|
||||
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
|
||||
// itself, nor anything that already (transitively) contains it -- such a chain
|
||||
// makes membership unanswerable, and callers would rely on the depth cap to
|
||||
// stop rather than getting a real answer.
|
||||
Group.wouldCycle = async function(parentCn, childDn){
|
||||
const groups = await allGroupsForResolver();
|
||||
const parent = groups.find(g => g.cn === parentCn);
|
||||
if (!parent) return false;
|
||||
if (lc(parent.dn) === lc(childDn)) return true;
|
||||
const child = groups.find(g => lc(g.dn) === lc(childDn));
|
||||
if (!child) return false; // a user DN can never close a cycle
|
||||
// Adding child under parent is a cycle exactly when parent is already
|
||||
// reachable downward from child.
|
||||
const { nested } = closureDown(child, groups);
|
||||
return nested.some(g => lc(g.dn) === lc(parent.dn));
|
||||
};
|
||||
|
||||
Group.clearResolverCache = function(){ resolverCache.clear(); };
|
||||
|
||||
Group.listDetail = async function(member){
|
||||
if (member) {
|
||||
return withClient(async (client) => getGroups(client, member));
|
||||
@@ -166,6 +338,7 @@ Group.add = async function(data){
|
||||
return withClient(async (client) => {
|
||||
await addGroup(client, data);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this.get(data);
|
||||
});
|
||||
}
|
||||
@@ -174,6 +347,7 @@ Group.addMember = async function(user){
|
||||
await withClient(async (client) => addMember(client, this, user));
|
||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -186,6 +360,7 @@ Group.removeMember = async function(user){
|
||||
}
|
||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -193,6 +368,7 @@ Group.addOwner = async function(user){
|
||||
await withClient(async (client) => addOwner(client, this, user));
|
||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -205,12 +381,14 @@ Group.removeOwner = async function(user){
|
||||
}
|
||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
Group.remove = async function(){
|
||||
await withClient(async (client) => client.del(this.dn));
|
||||
cache.clear();
|
||||
resolverCache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ require('./api_token');
|
||||
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||
const { AccessRequest } = require('./access_request');
|
||||
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
@@ -28,7 +29,7 @@ async function initORM() {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup,
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
|
||||
+63
-30
@@ -103,6 +103,40 @@ class Resource extends Model {
|
||||
return { resources: resObjs, edges };
|
||||
}
|
||||
|
||||
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||
// otherwise the nearest ancestor's. A service usually carries no address of
|
||||
// its own -- it is reached at the host it runs on -- so "how do I reach this"
|
||||
// is only answerable from the graph, never from the row alone. Every caller
|
||||
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
|
||||
// must go through here, or services come back unreachable.
|
||||
static async withResolvedAddress(resources) {
|
||||
if (!resources || !resources.length) return [];
|
||||
const graph = await this.getGraph();
|
||||
|
||||
const resolve = (resId, visited = new Set()) => {
|
||||
if (visited.has(resId)) return null; // prevent cycles
|
||||
visited.add(resId);
|
||||
|
||||
const res = graph.resources.find(r => r.id === resId);
|
||||
if (!res) return null;
|
||||
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||
|
||||
for (const edge of graph.edges.filter(e => e.childId === resId)) {
|
||||
const found = resolve(edge.parentId, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return resources.map(r => {
|
||||
const data = r.toJSON ? r.toJSON() : { ...r };
|
||||
data.metadata = data.metadata || {};
|
||||
data.resolvedAddress = resolve(data.id);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
static async getMyAccess(userDn) {
|
||||
const userGroups = await Group.list(userDn);
|
||||
if (!userGroups || userGroups.length === 0) return [];
|
||||
@@ -110,38 +144,11 @@ class Resource extends Model {
|
||||
const resourceGroups = await ResourceGroup.list({
|
||||
where: { groupCn: { in: userGroups } }
|
||||
});
|
||||
|
||||
|
||||
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||
if (resourceIds.length === 0) return [];
|
||||
|
||||
const resources = await this.list({ where: { id: { in: resourceIds } } });
|
||||
|
||||
// Resolve inherited addresses from the graph
|
||||
const graph = await this.getGraph();
|
||||
|
||||
function resolveHost(resId, visited = new Set()) {
|
||||
if (visited.has(resId)) return null; // prevent cycles
|
||||
visited.add(resId);
|
||||
|
||||
const res = graph.resources.find(r => r.id === resId);
|
||||
if (!res) return null;
|
||||
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||
|
||||
const parentEdges = graph.edges.filter(e => e.childId === resId);
|
||||
for (const edge of parentEdges) {
|
||||
const found = resolveHost(edge.parentId, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return resources.map(r => {
|
||||
const data = { ...r };
|
||||
data.metadata = data.metadata || {};
|
||||
data.resolvedAddress = resolveHost(r.id);
|
||||
return data;
|
||||
});
|
||||
|
||||
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||
}
|
||||
|
||||
static fields = {
|
||||
@@ -152,10 +159,36 @@ class Resource extends Model {
|
||||
owner: { type: 'string' },
|
||||
description: { type: 'text' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||
// are set explicitly by the route handler on every create/update (see
|
||||
// routes/api_directory_admin.js). Existing rows predating this change
|
||||
// simply read back undefined -- callers must render a fallback.
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||
};
|
||||
|
||||
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||
// top-level resource with no site parent keeps its unprefixed group name).
|
||||
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return null;
|
||||
visited.add(resourceId);
|
||||
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId);
|
||||
if (!parent) continue;
|
||||
if (parent.kind === 'site') return parent.slug;
|
||||
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceEdge extends Model {
|
||||
|
||||
@@ -295,6 +295,9 @@ User.listDetail = async function(){
|
||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||
|
||||
return obj;
|
||||
}));
|
||||
|
||||
Generated
+10
-10
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.1",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.1",
|
||||
"version": "1.11.0",
|
||||
"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/frontend": "^0.2.5",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -1271,18 +1271,18 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.1.0.tgz",
|
||||
"integrity": "sha512-hTXxHl7Jz5IbIAYmn8dv9f0B50ocjEg5ju+UV8ZQSaBjJYpetOVfcFvT6v9xwMVtjXSYMDwKPvD8YgKOBz7xJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/frontend": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.6.3",
|
||||
"version": "1.11.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -25,8 +25,8 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.5",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
|
||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
// Scroll a just-added/-edited element into view and flash its
|
||||
// background, so the user's eye lands on the row that changed instead of
|
||||
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||
function revealItem(el){
|
||||
var node = el && el.jquery ? el[0] : el;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
var prevTransition = node.style.transition;
|
||||
var prevBg = node.style.backgroundColor;
|
||||
node.style.transition = 'background-color 1.5s ease';
|
||||
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||
setTimeout(function(){
|
||||
node.style.backgroundColor = prevBg;
|
||||
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
@@ -679,13 +700,10 @@ function formAJAX(btn){
|
||||
return false;
|
||||
}
|
||||
|
||||
app.messages.action(
|
||||
`<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>`,
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
'use strict';
|
||||
|
||||
// Self-service access requests. Mounted at /api/access-requests (app.js).
|
||||
//
|
||||
// The loop this closes: a user browses the catalog, finds something they cannot
|
||||
// reach, asks for it; the resource's owner (or a directory admin) approves; the
|
||||
// approval performs the LDAP group add. LDAP stays the access-control truth --
|
||||
// this router never invents a permission, it only automates the group add an
|
||||
// admin would otherwise do by hand, and records who decided.
|
||||
|
||||
const router = require('express').Router();
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
const { AccessRequest, STATUS } = require('../models/access_request');
|
||||
const { Group } = require('../models/group_ldap');
|
||||
const { User } = require('../models/user_ldap');
|
||||
const { Mail } = require('../models/email');
|
||||
const { groupCns } = require('../utils/user_groups');
|
||||
const { envelope, projectResource } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
const DIRECTORY_ADMIN_GROUPS = ['app_sso_directory_admin', 'app_sso_admin', 'app_super_admin'];
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message);
|
||||
err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
// May `user` decide requests against `resource`? The resource's own owner is
|
||||
// the primary approver -- that is the point of Resource.owner -- with directory
|
||||
// admins as the catch-all so an unowned or orphaned resource is never stuck.
|
||||
async function canDecide(user, resource, callerGroups) {
|
||||
if (resource && resource.owner && resource.owner === user.uid) return true;
|
||||
return callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||
}
|
||||
|
||||
// The group that satisfies a request for this resource. Prefers an explicit
|
||||
// choice, else the `member`-level link (the "just let me use it" group) over an
|
||||
// `owner`-level one -- requesting a resource should never silently escalate to
|
||||
// its admin group.
|
||||
async function resolveGroupCn(resourceId, requested) {
|
||||
const links = await ResourceGroup.list({ where: { resourceId } });
|
||||
if (!links.length) {
|
||||
throw httpError(409, 'This resource has no access group linked, so it cannot be requested.');
|
||||
}
|
||||
if (requested) {
|
||||
const match = links.find(l => l.groupCn === requested);
|
||||
if (!match) throw httpError(400, `"${requested}" is not an access group for this resource.`);
|
||||
return match.groupCn;
|
||||
}
|
||||
const member = links.find(l => l.accessLevel === 'member');
|
||||
return (member || links[0]).groupCn;
|
||||
}
|
||||
|
||||
// Best-effort notification. A mail failure must never fail the request itself --
|
||||
// the row is the source of truth and the approver can find it in the UI.
|
||||
async function notify(uid, subject, message) {
|
||||
try {
|
||||
const user = await User.get({ uid });
|
||||
if (!user || !user.mail) return;
|
||||
await Mail.sendTemplate(user.mail, 'notification', {
|
||||
givenName: user.givenName || uid,
|
||||
subject,
|
||||
message,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`access-request: notification to ${uid} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/access-requests { slug | resourceId, groupCn?, note? }
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
if (req.user.isMachine) throw httpError(403, 'Machine accounts cannot request access.');
|
||||
|
||||
let resource;
|
||||
if (req.body.slug) {
|
||||
const found = await Resource.list({ where: { slug: req.body.slug } });
|
||||
resource = found[0];
|
||||
} else if (req.body.resourceId) {
|
||||
resource = await Resource.get(req.body.resourceId);
|
||||
}
|
||||
if (!resource) throw httpError(404, 'Resource not found');
|
||||
|
||||
const md = resource.metadata || {};
|
||||
// Opt-out, not opt-in: everything in the catalog is requestable unless an
|
||||
// admin has explicitly marked it otherwise.
|
||||
if (md.requestable === false) {
|
||||
throw httpError(409, 'This resource is not available for self-service requests.');
|
||||
}
|
||||
|
||||
const groupCn = await resolveGroupCn(resource.id, req.body.groupCn);
|
||||
|
||||
const callerGroups = await groupCns(req.user);
|
||||
if (callerGroups.includes(groupCn)) {
|
||||
throw httpError(409, 'You already have access to this resource.');
|
||||
}
|
||||
|
||||
const existing = await AccessRequest.findOpen(req.user.uid, groupCn);
|
||||
if (existing) throw httpError(409, 'You already have a pending request for this resource.');
|
||||
|
||||
const request = await AccessRequest.create({
|
||||
uid: req.user.uid,
|
||||
resourceId: resource.id,
|
||||
groupCn,
|
||||
status: STATUS.PENDING,
|
||||
note: req.body.note || '',
|
||||
requestedOn: Date.now(),
|
||||
});
|
||||
|
||||
if (resource.owner) {
|
||||
await notify(
|
||||
resource.owner,
|
||||
`Access request: ${resource.name}`,
|
||||
`<p><strong>${req.user.uid}</strong> has requested access to <strong>${resource.name}</strong> (group <code>${groupCn}</code>).</p>` +
|
||||
(req.body.note ? `<p>Their note: ${req.body.note}</p>` : '') +
|
||||
`<p>Review it on the Directory page.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
res.json(envelope(request));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/access-requests/mine — the caller's own request history.
|
||||
router.get('/mine', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await AccessRequest.listForUser(req.user.uid);
|
||||
res.json(envelope(await decorate(rows)));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/access-requests — pending requests the caller may decide.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const callerGroups = await groupCns(req.user);
|
||||
const isAdmin = callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||
const pending = await AccessRequest.listPending();
|
||||
|
||||
let visible = pending;
|
||||
if (!isAdmin) {
|
||||
// A plain resource owner sees only requests against resources they own.
|
||||
const owned = await Resource.list({ where: { owner: req.user.uid } });
|
||||
const ownedIds = new Set(owned.map(r => r.id));
|
||||
visible = pending.filter(r => ownedIds.has(r.resourceId));
|
||||
}
|
||||
res.json(envelope(await decorate(visible)));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Attach the resource name/slug each row refers to. The UI needs it on every
|
||||
// list and would otherwise issue one lookup per row.
|
||||
async function decorate(rows) {
|
||||
if (!rows.length) return [];
|
||||
const resources = await Resource.list();
|
||||
const byId = new Map(resources.map(r => [r.id, r]));
|
||||
return rows.map(row => {
|
||||
const data = row.toJSON ? row.toJSON() : { ...row };
|
||||
const resource = byId.get(data.resourceId);
|
||||
data.resource = resource
|
||||
? { id: resource.id, name: resource.name, slug: resource.slug, kind: resource.kind }
|
||||
: null;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/access-requests/:id/approve { decisionNote? }
|
||||
router.post('/:id/approve', async (req, res, next) => {
|
||||
try {
|
||||
const request = await AccessRequest.get(req.params.id);
|
||||
if (!request) throw httpError(404, 'Request not found');
|
||||
if (request.status !== STATUS.PENDING) {
|
||||
throw httpError(409, `This request was already ${request.status}.`);
|
||||
}
|
||||
|
||||
const resource = await Resource.get(request.resourceId);
|
||||
const callerGroups = await groupCns(req.user);
|
||||
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||
throw httpError(403, 'You do not have permission to decide this request.');
|
||||
}
|
||||
|
||||
// The LDAP write happens FIRST and is allowed to throw. Marking a request
|
||||
// approved without the group add would show the user a grant they do not
|
||||
// actually have -- a pending row is recoverable, a lying one is not.
|
||||
const group = await Group.get(request.groupCn);
|
||||
const user = await User.get({ uid: request.uid });
|
||||
try {
|
||||
await group.addMember(user);
|
||||
} catch (err) {
|
||||
// "already a member" is the goal state, not a failure. This happens
|
||||
// routinely: groupOfNames requires at least one member, so creating a
|
||||
// resource seeds its auto-created groups with the creator's DN, and an
|
||||
// admin may also grant access by hand while a request sits pending.
|
||||
// Without this the request would 500 and stay pending forever.
|
||||
const alreadyMember = err.name === 'TypeOrValueExistsError' || err.code === 20;
|
||||
if (!alreadyMember) throw err;
|
||||
}
|
||||
User.clearCache(); // membership feeds cached isAdmin / group-gated nav
|
||||
|
||||
const updated = await request.update({
|
||||
status: STATUS.APPROVED,
|
||||
decidedBy: req.user.uid,
|
||||
decidedOn: Date.now(),
|
||||
decisionNote: req.body.decisionNote || '',
|
||||
});
|
||||
|
||||
await notify(
|
||||
request.uid,
|
||||
`Access approved: ${resource ? resource.name : request.groupCn}`,
|
||||
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was approved by ${req.user.uid}.</p>` +
|
||||
`<p>You may need to sign out and back in for the change to take effect everywhere.</p>`
|
||||
);
|
||||
|
||||
res.json(envelope(updated));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/access-requests/:id/deny { decisionNote? }
|
||||
router.post('/:id/deny', async (req, res, next) => {
|
||||
try {
|
||||
const request = await AccessRequest.get(req.params.id);
|
||||
if (!request) throw httpError(404, 'Request not found');
|
||||
if (request.status !== STATUS.PENDING) {
|
||||
throw httpError(409, `This request was already ${request.status}.`);
|
||||
}
|
||||
|
||||
const resource = await Resource.get(request.resourceId);
|
||||
const callerGroups = await groupCns(req.user);
|
||||
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||
throw httpError(403, 'You do not have permission to decide this request.');
|
||||
}
|
||||
|
||||
const updated = await request.update({
|
||||
status: STATUS.DENIED,
|
||||
decidedBy: req.user.uid,
|
||||
decidedOn: Date.now(),
|
||||
decisionNote: req.body.decisionNote || '',
|
||||
});
|
||||
|
||||
await notify(
|
||||
request.uid,
|
||||
`Access request declined: ${resource ? resource.name : request.groupCn}`,
|
||||
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was declined.</p>` +
|
||||
(req.body.decisionNote ? `<p>Reason: ${req.body.decisionNote}</p>` : '')
|
||||
);
|
||||
|
||||
res.json(envelope(updated));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/access-requests/:id — requester withdraws their own pending request.
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const request = await AccessRequest.get(req.params.id);
|
||||
if (!request) throw httpError(404, 'Request not found');
|
||||
if (request.uid !== req.user.uid) {
|
||||
throw httpError(403, 'You can only withdraw your own requests.');
|
||||
}
|
||||
if (request.status !== STATUS.PENDING) {
|
||||
throw httpError(409, `This request was already ${request.status}.`);
|
||||
}
|
||||
const updated = await request.update({ status: STATUS.CANCELLED, decidedOn: Date.now() });
|
||||
res.json(envelope(updated));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,8 +3,32 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||
const { Group } = require('../models/group_ldap');
|
||||
const { User } = require('../models/user_ldap');
|
||||
const { cnFromDn } = require('../utils/user_groups');
|
||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||
|
||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
||||
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||
// caller's real work.
|
||||
async function nestGroup(childCn, parentCn) {
|
||||
try {
|
||||
const parent = await Group.get(parentCn);
|
||||
const child = await Group.get(childCn);
|
||||
if (await Group.wouldCycle(parentCn, child.dn)) {
|
||||
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
|
||||
return;
|
||||
}
|
||||
await parent.addMember({ dn: child.dn });
|
||||
} catch (err) {
|
||||
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
|
||||
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Require the admin group
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
@@ -43,25 +67,35 @@ router.post('/resources', async (req, res, next) => {
|
||||
}
|
||||
|
||||
req.body.owner = req.body.owner || req.user.uid;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
req.body.created_by = req.body.created_by || req.user.uid;
|
||||
req.body.created_on = now;
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = now;
|
||||
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
// Pass created_by explicitly for the wrapper
|
||||
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||
req.body.created_by = req.body.owner;
|
||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||
r = await OAuthClient.add(req.body);
|
||||
} else {
|
||||
r = await Resource.create(req.body);
|
||||
}
|
||||
|
||||
|
||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
|
||||
|
||||
if (r.kind === 'host' || r.kind === 'service') {
|
||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
||||
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = `${r.slug}_${suffix}`;
|
||||
const cn = groupCn(suffix);
|
||||
try {
|
||||
await Group.add({
|
||||
name: cn,
|
||||
@@ -79,6 +113,21 @@ router.post('/resources', async (req, res, next) => {
|
||||
};
|
||||
await createGroup('access', 'member');
|
||||
await createGroup('admin', 'owner');
|
||||
|
||||
// Wire up the two standing relationships every resource has, as nesting
|
||||
// rather than as membership that has to be maintained per resource:
|
||||
//
|
||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
||||
// every resource, automatically
|
||||
// <slug>_admin -> <slug>_access administering something implies
|
||||
// being able to use it
|
||||
//
|
||||
// Before nesting, both of these could only be expressed by adding every
|
||||
// super admin to every new group by hand -- which nobody does, so the
|
||||
// groups drifted. A failure here must not fail resource creation: the
|
||||
// resource and its groups already exist and the nesting is repairable.
|
||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
||||
}
|
||||
|
||||
res.json({ results: r });
|
||||
@@ -95,15 +144,8 @@ router.post('/resources', async (req, res, next) => {
|
||||
|
||||
router.put('/resources/:id', async (req, res, next) => {
|
||||
try {
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
r = await OAuthClient.get(req.params.id);
|
||||
} else {
|
||||
r = await Resource.get(req.params.id);
|
||||
}
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
// Validate before loading anything -- a rejected body should never have
|
||||
// touched the store.
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
@@ -113,14 +155,20 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||
}
|
||||
|
||||
let updated;
|
||||
if (req.body.kind === 'oauth') {
|
||||
updated = await r.update(req.body);
|
||||
} else {
|
||||
updated = await r.update(req.body);
|
||||
}
|
||||
|
||||
|
||||
// OAuthClient is a wrapper over the same `resource` row, but its .update()
|
||||
// handles the oauth-specific body fields (redirect_uris, scopes,
|
||||
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
|
||||
const r = await model.get(req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
|
||||
const updated = await r.update(req.body);
|
||||
|
||||
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||
for (const e of existingEdges) {
|
||||
@@ -152,13 +200,18 @@ router.delete('/resources/:id', async (req, res, next) => {
|
||||
try {
|
||||
const r = await Resource.get(req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
await r.delete();
|
||||
// Also delete edges and groups involving this resource
|
||||
// Clear the dependents FIRST. There is no transaction here, so ordering is
|
||||
// the only thing protecting us: if a dependent delete throws after the
|
||||
// resource row is gone, the leftovers are edges/links pointing at a
|
||||
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
|
||||
// with the resource still present is the recoverable direction (retry the
|
||||
// delete); the caller sees the error either way.
|
||||
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||
for (const g of groups) await g.delete();
|
||||
await r.delete();
|
||||
res.json({ results: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -211,19 +264,138 @@ router.delete('/groups/:id', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Access visibility ---
|
||||
//
|
||||
// The two questions an access-control pane has to answer, neither of which the
|
||||
// directory could answer before: "who can reach this resource" (a column on the
|
||||
// table, rather than three clicks into a modal) and "what can this user reach"
|
||||
// (which had no UI at all). Both are joins of the same two sets, so both are
|
||||
// served from one cached Group.listDetail() rather than a lookup per row.
|
||||
|
||||
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
|
||||
async function dnToUidMap() {
|
||||
const users = await User.listDetail();
|
||||
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
|
||||
}
|
||||
|
||||
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
|
||||
router.get('/access-summary', async (req, res, next) => {
|
||||
try {
|
||||
const [links, groups, uidByDn] = await Promise.all([
|
||||
ResourceGroup.list(),
|
||||
Group.listDetail(),
|
||||
dnToUidMap(),
|
||||
]);
|
||||
|
||||
const groupByCn = new Map(groups.map(g => [g.cn, g]));
|
||||
const summary = {};
|
||||
|
||||
for (const link of links) {
|
||||
const group = groupByCn.get(link.groupCn);
|
||||
// A link whose LDAP group has been deleted out from under it: report it
|
||||
// rather than skipping, since a dangling link grants nothing and the
|
||||
// admin needs to see that it is dead.
|
||||
//
|
||||
// Counts come from the transitive closure, not from `member`. Reading the
|
||||
// attribute would report only who is listed on the group, missing anyone
|
||||
// who reaches it through a nested group -- and since app_super_admin is
|
||||
// nested into every resource's _admin group, that is not an edge case.
|
||||
let members = [];
|
||||
if (group) {
|
||||
const eff = await Group.effectiveMembers(link.groupCn);
|
||||
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
|
||||
}
|
||||
|
||||
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
|
||||
entry.groups.push({
|
||||
cn: link.groupCn,
|
||||
accessLevel: link.accessLevel,
|
||||
exists: !!group,
|
||||
memberCount: members.length,
|
||||
});
|
||||
for (const uid of members) {
|
||||
if (!entry.members.includes(uid)) entry.members.push(uid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Object.keys(summary)) {
|
||||
summary[id].memberCount = summary[id].members.length;
|
||||
}
|
||||
|
||||
res.json({ results: summary });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /user-access/:uid — every resource a given user can reach, and via which
|
||||
// group. This is the reverse lookup; previously an admin could only see their
|
||||
// own access, via /api/discovery/me.
|
||||
router.get('/user-access/:uid', async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.get({ uid: req.params.uid });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const dn = String(user.dn).toLowerCase();
|
||||
const groups = await Group.listDetail();
|
||||
const memberOf = groups
|
||||
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
|
||||
.map(g => g.cn);
|
||||
|
||||
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
|
||||
const byId = new Map(resources.map(r => [r.id, r]));
|
||||
|
||||
const results = [];
|
||||
for (const link of links) {
|
||||
if (!memberOf.includes(link.groupCn)) continue;
|
||||
const resource = byId.get(link.resourceId);
|
||||
if (!resource) continue;
|
||||
results.push({
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
slug: resource.slug,
|
||||
kind: resource.kind,
|
||||
groupCn: link.groupCn,
|
||||
accessLevel: link.accessLevel,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Tail the last `lines` lines of a log file without shelling out. Reads at most
|
||||
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
|
||||
// heap. A missing/unreadable file is normal (the log only exists once slapd has
|
||||
// written to it), so it yields '' rather than an error.
|
||||
const MAX_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
async function tailFile(filePath, lines = 100) {
|
||||
const fs = require('fs/promises');
|
||||
let fh;
|
||||
try {
|
||||
fh = await fs.open(filePath, 'r');
|
||||
const { size } = await fh.stat();
|
||||
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
||||
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
|
||||
await fh.read(buf, 0, buf.length, start);
|
||||
const text = buf.toString('utf8');
|
||||
// A partial first line when we started mid-file; drop it.
|
||||
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
||||
return rows.slice(-lines).join('\n');
|
||||
} catch (err) {
|
||||
return '';
|
||||
} finally {
|
||||
if (fh) await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/audit-logs', async (req, res, next) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
let ldapLogs = '';
|
||||
let oauthLogs = '';
|
||||
let auditLogs = '';
|
||||
|
||||
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
|
||||
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
|
||||
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
|
||||
|
||||
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
|
||||
const [ldap, oauth, audit] = await Promise.all([
|
||||
tailFile('/var/lib/ldap/slapd.log'),
|
||||
tailFile('/var/lib/ldap/oauth.log'),
|
||||
tailFile('/var/lib/ldap/auditlog.ldif'),
|
||||
]);
|
||||
res.json({ results: { ldap, oauth, audit } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const metrics = require('../utils/metrics');
|
||||
|
||||
// /api/metrics/executive
|
||||
router.get('/executive', async (req, res, next) => {
|
||||
// /api/metrics/overview
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
|
||||
|
||||
+28
-11
@@ -10,9 +10,14 @@
|
||||
// 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).
|
||||
//
|
||||
// Group CNs come from utils/user_groups — `req.user` has `memberOf` (DNs) and
|
||||
// no `.groups`, so reading `.groups` off it directly yields [] for every human
|
||||
// caller. See that file for what that silently broke.
|
||||
|
||||
const router = require('express').Router();
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
const { withGroups } = require('../utils/user_groups');
|
||||
const {
|
||||
envelope,
|
||||
projectResource,
|
||||
@@ -20,20 +25,29 @@ const {
|
||||
isDirectoryAdmin,
|
||||
} = require('@simpleworkjs/directory-schema');
|
||||
|
||||
// Resolve the caller's groups once per request and hand back the projection
|
||||
// flag. Every handler needs both, and both are wrong if taken off req.user raw.
|
||||
async function callerView(req) {
|
||||
const user = await withGroups(req.user);
|
||||
return { user, fullMetadata: isDirectoryAdmin(user) };
|
||||
}
|
||||
|
||||
// GET /api/discovery/resources[?kind=&group=&parent=]
|
||||
router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
const { fullMetadata } = await callerView(req);
|
||||
const resources = await Resource.search(req.query);
|
||||
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||
res.json(envelope(projectResources(resources, { fullMetadata })));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/discovery/resources/:slug
|
||||
router.get('/resources/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const { fullMetadata } = await callerView(req);
|
||||
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) });
|
||||
const projected = projectResource(resource, { fullMetadata });
|
||||
projected.parents = resource.parents;
|
||||
projected.children = resource.children;
|
||||
res.json(envelope(projected));
|
||||
@@ -43,9 +57,10 @@ router.get('/resources/:slug', async (req, res, next) => {
|
||||
// GET /api/discovery/graph
|
||||
router.get('/graph', async (req, res, next) => {
|
||||
try {
|
||||
const { fullMetadata } = await callerView(req);
|
||||
const graph = await Resource.getGraph();
|
||||
res.json(envelope({
|
||||
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
|
||||
resources: projectResources(graph.resources, { fullMetadata }),
|
||||
edges: graph.edges,
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
@@ -54,26 +69,28 @@ router.get('/graph', async (req, res, next) => {
|
||||
// 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.
|
||||
// anything flagged isPublic.
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const { user, fullMetadata } = await callerView(req);
|
||||
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 } } });
|
||||
if (user.groups.length) {
|
||||
const rgs = await ResourceGroup.list({ where: { groupCn: { in: user.groups } } });
|
||||
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) })));
|
||||
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
||||
// service inherits it from its host, so it must be computed here rather
|
||||
// than left to each caller to guess at address || ip.
|
||||
accessible = await Resource.withResolvedAddress(accessible);
|
||||
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -43,6 +43,87 @@ router.get('/:name', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// ── Nested groups ───────────────────────────────────────────────────────────
|
||||
// A groupOfNames `member` may be any DN, including another group's, which is
|
||||
// how nesting is stored. These routes are mounted before /:group/:uid so the
|
||||
// literal "nested"/"effective" path segments are not swallowed by that
|
||||
// wildcard, which would otherwise try to resolve them as a uid.
|
||||
|
||||
// GET /api/group/:group/effective — who this group actually grants, split into
|
||||
// directly-listed users, the groups nested into it, and the full transitive set
|
||||
// of users. The UI shows "3 direct, 12 effective"; a plain member read cannot
|
||||
// answer that, and on a server with nestgroup it silently returns the expanded
|
||||
// list with no indication which entries are direct.
|
||||
router.get('/:group/effective', async function(req, res, next){
|
||||
try{
|
||||
return res.json({ results: await Group.effectiveMembers(req.params.group) });
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/group/:group/nested/:child — nest :child inside :group.
|
||||
router.put('/:group/nested/:child', async function(req, res, next){
|
||||
try{
|
||||
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||
|
||||
const parent = await Group.get(req.params.group);
|
||||
const child = await Group.get(req.params.child);
|
||||
|
||||
if(parent.dn === child.dn){
|
||||
return res.status(400).json({message: 'A group cannot contain itself.'});
|
||||
}
|
||||
// Refuse rather than rely on the resolver's depth cap: a cycle makes
|
||||
// "who is in this group" unanswerable, and the cap would quietly return
|
||||
// a truncated answer instead of an error anyone would notice.
|
||||
if(await Group.wouldCycle(req.params.group, child.dn)){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.child}" already contains "${req.params.group}" — nesting them would create a loop.`
|
||||
});
|
||||
}
|
||||
|
||||
const results = await parent.addMember({dn: child.dn});
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results,
|
||||
message: `Nested ${req.params.child} inside ${req.params.group}.`
|
||||
});
|
||||
}catch(error){
|
||||
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||
return res.status(409).json({message: `"${req.params.child}" is already nested in "${req.params.group}".`});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/group/:group/nested/:child — un-nest.
|
||||
router.delete('/:group/nested/:child', async function(req, res, next){
|
||||
try{
|
||||
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||
|
||||
const parent = await Group.get(req.params.group);
|
||||
const child = await Group.get(req.params.child);
|
||||
const results = await parent.removeMember({dn: child.dn});
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results,
|
||||
message: `Removed ${req.params.child} from ${req.params.group}.`
|
||||
});
|
||||
}catch(error){
|
||||
// groupOfNames requires at least one member, so emptying a group is a
|
||||
// schema violation rather than a permission problem. Surfacing the raw
|
||||
// error as a 500 makes it look like a bug in the server; it is really a
|
||||
// "you cannot do that, and here is why" -- the same reason the last user
|
||||
// cannot be removed from a group either.
|
||||
if(error.name === 'ObjectClassViolationError' || error.code === 65){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.child}" is the only member of "${req.params.group}". A group must keep at least one member — add another first.`
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/owner/:group/:uid', async function(req, res, next){
|
||||
try{
|
||||
|
||||
@@ -92,6 +173,15 @@ router.put('/:group/:uid', async function(req, res, next){
|
||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
// Already a member -- surfaced as a plain 500 before, which read as a
|
||||
// server fault for what is really a no-op. Common in practice because
|
||||
// groupOfNames needs at least one member, so whoever creates a group is
|
||||
// seeded into it and is then "added" again by the obvious next click.
|
||||
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||
return res.status(409).json({
|
||||
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
+21
-5
@@ -17,6 +17,13 @@ const values ={
|
||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
// Connection conventions the catalog needs to render "how to reach this"
|
||||
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
|
||||
// port are public connection info, not credentials.
|
||||
directoryConf: {
|
||||
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
|
||||
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
|
||||
},
|
||||
...buildInfo,
|
||||
}
|
||||
|
||||
@@ -48,18 +55,27 @@ router.get('/tos', async function(req, res, next) {
|
||||
|
||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||
// (broadcast + history) merged into one page.
|
||||
router.get('/executive', function(req, res) {
|
||||
res.render('executive', {...values});
|
||||
router.get('/overview', function(req, res) {
|
||||
res.render('overview', {...values});
|
||||
});
|
||||
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||
|
||||
router.get('/directory', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||
// the matching resource's modal once the page's own data has loaded.
|
||||
router.get('/directory/:slug', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Route removed since it's now in directory
|
||||
|
||||
router.get('/onboarding', async function(req, res, next) {
|
||||
|
||||
+15
-7
@@ -4,6 +4,7 @@ const router = require('express').Router();
|
||||
const {User} = require('../models/user');
|
||||
const {Group} = require('../models/group_ldap');
|
||||
const permission = require('../utils/permission');
|
||||
const {groupCns} = require('../utils/user_groups');
|
||||
const {UserVerification} = require('../models/verification');
|
||||
const {InviteToken} = require('../models/token');
|
||||
|
||||
@@ -49,7 +50,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({results: user});
|
||||
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -78,11 +79,18 @@ router.get('/me', async function(req, res, next){
|
||||
|
||||
// The shared client framework gates the UI on a single effective-rights
|
||||
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||
// membership in app_sso_admin; group-level gating still reads memberOf.
|
||||
const groups = (user.memberOf || []).map(function(dn){
|
||||
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||
});
|
||||
user.isAdmin = groups.includes('app_sso_admin');
|
||||
// membership in app_sso_admin or the cross-app app_super_admin group.
|
||||
//
|
||||
// Resolved via groupCns rather than read off `memberOf` directly: with
|
||||
// nested groups, memberOf is only transitive when the directory carries
|
||||
// the nestgroup overlay. Against a server without it, an admin who holds
|
||||
// the group through nesting would get isAdmin=false here and silently
|
||||
// lose the whole admin UI -- while still passing every server-side
|
||||
// permission check, which resolves nesting properly. groupCns gives the
|
||||
// same answer in both modes.
|
||||
const groups = await groupCns(user);
|
||||
user.groups = groups;
|
||||
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
||||
|
||||
return res.json(user);
|
||||
}catch(error){
|
||||
@@ -107,7 +115,7 @@ router.put('/password', async function(req, res, next){
|
||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||
await verif.update({ password_must_change: false });
|
||||
User.clearCache();
|
||||
return res.json({results: result});
|
||||
return res.json({results: result, message: 'Password changed.'});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
// Self-service access requests, end to end: request -> approve -> the grant is
|
||||
// real (visible through /api/discovery/me), plus the guards that keep the flow
|
||||
// from being abused or double-applied.
|
||||
//
|
||||
// The seed `test` user is in app_sso_admin, so it is both the requester and an
|
||||
// eligible approver here. That is unusual in production but exactly what makes
|
||||
// a single-user test able to walk the whole loop.
|
||||
|
||||
const { login, request, app } = require('./setup');
|
||||
|
||||
let token;
|
||||
let siteSlug;
|
||||
let hostSlug;
|
||||
let hostId;
|
||||
let accessGroupCn;
|
||||
|
||||
// Unique per run: these create real LDAP groups and SQL rows, and a rerun must
|
||||
// not collide with the previous run's leftovers.
|
||||
const stamp = Date.now().toString(36);
|
||||
|
||||
beforeAll(async () => {
|
||||
token = await login();
|
||||
|
||||
siteSlug = `artest-site-${stamp}`;
|
||||
const site = await request(app)
|
||||
.post('/api/directory-admin/resources')
|
||||
.set('auth-token', token)
|
||||
.send({ name: `AR Test Site ${stamp}`, slug: siteSlug, kind: 'site' });
|
||||
expect(site.status).toBe(200);
|
||||
|
||||
hostSlug = `artest-host-${stamp}`;
|
||||
const host = await request(app)
|
||||
.post('/api/directory-admin/resources')
|
||||
.set('auth-token', token)
|
||||
.send({
|
||||
name: `AR Test Host ${stamp}`,
|
||||
slug: hostSlug,
|
||||
kind: 'host',
|
||||
parentSlug: siteSlug,
|
||||
metadata: { ip: '10.99.99.9' },
|
||||
});
|
||||
expect(host.status).toBe(200);
|
||||
hostId = host.body.results.id;
|
||||
|
||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
||||
|
||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||
// into _access, so membership of either grants access. A user who already
|
||||
// has access cannot request it (correctly), so step out of both to be a
|
||||
// legitimate requester. Removing only _access would leave the grant intact
|
||||
// through the nesting, which is exactly the kind of thing these tests exist
|
||||
// to catch.
|
||||
for (const cn of [adminGroupCn, accessGroupCn]) {
|
||||
await request(app)
|
||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||
.set('auth-token', token);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Access requests — the request half', () => {
|
||||
let requestId;
|
||||
|
||||
test('POST /api/access-requests creates a pending request on the member group', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/access-requests')
|
||||
.set('auth-token', token)
|
||||
.send({ slug: hostSlug, note: 'need it for testing' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toBeDefined();
|
||||
expect(res.body.results.status).toBe('pending');
|
||||
expect(res.body.results.uid).toBe('test');
|
||||
// Must target the _access group, never the _admin one: asking to use a
|
||||
// resource may not silently escalate to administering it.
|
||||
expect(res.body.results.groupCn).toBe(accessGroupCn);
|
||||
requestId = res.body.results.id;
|
||||
});
|
||||
|
||||
test('a second request for the same resource is rejected', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/access-requests')
|
||||
.set('auth-token', token)
|
||||
.send({ slug: hostSlug });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
test('GET /api/access-requests/mine lists it with the resource attached', async () => {
|
||||
const res = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
const found = res.body.results.find(r => r.id === requestId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.resource.slug).toBe(hostSlug);
|
||||
});
|
||||
|
||||
test('GET /api/access-requests shows it to an approver', async () => {
|
||||
const res = await request(app).get('/api/access-requests').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.some(r => r.id === requestId)).toBe(true);
|
||||
});
|
||||
|
||||
test('requesting an unknown resource is a 404', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/access-requests')
|
||||
.set('auth-token', token)
|
||||
.send({ slug: `no-such-resource-${stamp}` });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access requests — approval actually grants', () => {
|
||||
let requestId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mine = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||
const pending = mine.body.results.find(r => r.groupCn === accessGroupCn && r.status === 'pending');
|
||||
requestId = pending && pending.id;
|
||||
expect(requestId).toBeDefined();
|
||||
});
|
||||
|
||||
test('the resource is NOT in /api/discovery/me before approval', async () => {
|
||||
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.some(r => r.id === hostId)).toBe(false);
|
||||
});
|
||||
|
||||
test('POST /:id/approve marks it approved', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/access-requests/${requestId}/approve`)
|
||||
.set('auth-token', token)
|
||||
.send({ decisionNote: 'ok' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.status).toBe('approved');
|
||||
expect(res.body.results.decidedBy).toBe('test');
|
||||
});
|
||||
|
||||
test('approving twice is rejected', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/access-requests/${requestId}/approve`)
|
||||
.set('auth-token', token)
|
||||
.send({});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
// The payoff, and the regression guard for the user.groups bug: /me resolved
|
||||
// groups off req.user.groups, which does not exist on a User (it carries
|
||||
// memberOf), so this endpoint used to return only isPublic resources no
|
||||
// matter what the caller was actually a member of.
|
||||
test('the resource IS in /api/discovery/me after approval', async () => {
|
||||
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
const found = res.body.results.find(r => r.id === hostId);
|
||||
expect(found).toBeDefined();
|
||||
// And it answers "how do I reach it" rather than just naming the thing.
|
||||
expect(found.resolvedAddress).toBe('10.99.99.9');
|
||||
});
|
||||
|
||||
test('an already-granted resource cannot be requested again', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/access-requests')
|
||||
.set('auth-token', token)
|
||||
.send({ slug: hostSlug });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin access visibility', () => {
|
||||
test('GET /api/directory-admin/access-summary counts the host\'s groups + members', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/directory-admin/access-summary')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
const summary = res.body.results[hostId];
|
||||
expect(summary).toBeDefined();
|
||||
// _access and _admin were both auto-created and linked.
|
||||
expect(summary.groups.length).toBe(2);
|
||||
expect(summary.groups.every(g => g.exists)).toBe(true);
|
||||
// The approval above put `test` in the access group.
|
||||
expect(summary.memberCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('GET /api/directory-admin/user-access/:uid answers the reverse question', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/directory-admin/user-access/test')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.uid).toBe('test');
|
||||
const entry = res.body.results.resources.find(r => r.id === hostId);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.groupCn).toBe(accessGroupCn);
|
||||
});
|
||||
|
||||
test('user-access for an unknown uid is a 404', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/directory-admin/user-access/definitely-not-a-user')
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access requests — withdrawal', () => {
|
||||
test('a requester can withdraw their own pending request', async () => {
|
||||
// A second resource, so this does not disturb the approved one above.
|
||||
const slug = `artest-host2-${stamp}`;
|
||||
const host = await request(app)
|
||||
.post('/api/directory-admin/resources')
|
||||
.set('auth-token', token)
|
||||
.send({ name: `AR Test Host2 ${stamp}`, slug, kind: 'host', parentSlug: siteSlug });
|
||||
expect(host.status).toBe(200);
|
||||
|
||||
// Same as the top-level setup: step out of the auto-created groups the
|
||||
// creator is seeded into, or this is a request for access already held.
|
||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
||||
await request(app)
|
||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||
.set('auth-token', token);
|
||||
}
|
||||
|
||||
const created = await request(app)
|
||||
.post('/api/access-requests')
|
||||
.set('auth-token', token)
|
||||
.send({ slug });
|
||||
expect(created.status).toBe(200);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/access-requests/${created.body.results.id}`)
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
// Nested groups: the API for putting a group inside a group, the cycle guard,
|
||||
// and the thing that makes it worth doing -- membership resolving transitively
|
||||
// through the chain.
|
||||
//
|
||||
// Fixture note that is easy to get wrong: groupOfNames requires at least one
|
||||
// member, so whoever creates a group is seeded into it. `test` creates all
|
||||
// three groups here and would therefore be a *direct* member of each, which
|
||||
// would make "resolved via nesting" indistinguishable from "was already in it".
|
||||
// Setup below strips that back so test's only direct membership is the
|
||||
// innermost group -- and the strip has to happen after nesting, or removing the
|
||||
// sole member would violate the objectClass.
|
||||
|
||||
const { login, request, app } = require('./setup');
|
||||
|
||||
let token;
|
||||
const stamp = Date.now().toString(36);
|
||||
const A = `nesttest-a-${stamp}`; // outermost
|
||||
const B = `nesttest-b-${stamp}`; // middle
|
||||
const C = `nesttest-c-${stamp}`; // innermost, holds the user
|
||||
// A second group nested into A purely so that un-nesting B later does not
|
||||
// empty A -- groupOfNames requires at least one member, and the API correctly
|
||||
// refuses (409) rather than leaving an invalid entry behind.
|
||||
const D = `nesttest-d-${stamp}`;
|
||||
|
||||
async function nest(parent, child) {
|
||||
return request(app).put(`/api/group/${parent}/nested/${child}`).set('auth-token', token).send({});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
token = await login();
|
||||
|
||||
for (const cn of [A, B, C, D]) {
|
||||
const res = await request(app)
|
||||
.post('/api/group')
|
||||
.set('auth-token', token)
|
||||
.send({ name: cn, description: `nesting test ${cn}` });
|
||||
expect([200, 201]).toContain(res.status);
|
||||
}
|
||||
|
||||
expect((await nest(A, B)).status).toBe(200);
|
||||
expect((await nest(B, C)).status).toBe(200);
|
||||
expect((await nest(A, D)).status).toBe(200);
|
||||
|
||||
// Now that A holds B and B holds C, neither would be left memberless.
|
||||
for (const cn of [A, B]) {
|
||||
const res = await request(app).delete(`/api/group/${cn}/test`).set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Nested groups — API guards', () => {
|
||||
test('nesting the same pair twice is a 409, not a duplicate', async () => {
|
||||
const res = await nest(A, B);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
test('a group cannot contain itself', async () => {
|
||||
const res = await nest(A, A);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// The guard that matters: without it the resolver would silently return a
|
||||
// depth-capped answer instead of an error anyone would notice.
|
||||
test('a direct cycle is refused (A contains B, so B may not contain A)', async () => {
|
||||
const res = await nest(B, A);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.message).toMatch(/loop/i);
|
||||
});
|
||||
|
||||
test('an indirect cycle is refused too (A>B>C, so C may not contain A)', async () => {
|
||||
const res = await nest(C, A);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested groups — resolution', () => {
|
||||
test('membership resolves through the whole chain', async () => {
|
||||
const res = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toContain(C); // direct
|
||||
expect(res.body.results).toContain(B); // via C
|
||||
expect(res.body.results).toContain(A); // via B -> C
|
||||
});
|
||||
|
||||
test('GET /:group/effective separates direct members from nested ones', async () => {
|
||||
const res = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
const { direct, nestedGroups, effective } = res.body.results;
|
||||
|
||||
expect(nestedGroups.map(g => g.cn)).toContain(B);
|
||||
// `direct` is users only -- a nested group must never be reported as one.
|
||||
expect(direct.every(dn => !/,ou=groups,/i.test(dn))).toBe(true);
|
||||
// test is not listed on A at all, yet is effectively a member two levels down.
|
||||
expect(direct.some(dn => /cn=test,/i.test(dn))).toBe(false);
|
||||
expect(effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested groups — un-nesting', () => {
|
||||
test('DELETE removes the nesting and the membership it carried', async () => {
|
||||
// Before: A holds B (which holds C, which holds test) and D.
|
||||
const before = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||
expect(before.body.results.nestedGroups.map(g => g.cn)).toContain(B);
|
||||
expect(before.body.results.effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/group/${A}/nested/${B}`)
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const after = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||
expect(after.body.results.nestedGroups.map(g => g.cn)).not.toContain(B);
|
||||
expect(after.body.results.nestedGroups.map(g => g.cn)).toContain(D); // untouched
|
||||
|
||||
// test still resolves to B and C directly/through C; only the A path via
|
||||
// B is gone. It is deliberately NOT asserted that test loses A entirely:
|
||||
// D is also nested in A and test created D, so that path remains -- which
|
||||
// is itself a fair illustration of why "who can reach this" has to be
|
||||
// computed rather than eyeballed.
|
||||
const groups = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||
expect(groups.body.results).toContain(C);
|
||||
expect(groups.body.results).toContain(B);
|
||||
});
|
||||
|
||||
test('un-nesting the last member is refused rather than emptying the group', async () => {
|
||||
// B now holds only C. Removing it would leave B with no members at all,
|
||||
// which groupOfNames forbids.
|
||||
const res = await request(app)
|
||||
.delete(`/api/group/${B}/nested/${C}`)
|
||||
.set('auth-token', token);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.message).toMatch(/at least one member/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
|
||||
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
|
||||
// graph traversal against the ORM, so it's tested directly here rather than
|
||||
// through the (LDAP-gated) directory-admin HTTP routes.
|
||||
|
||||
const { initORM } = require('../models');
|
||||
const { Resource, ResourceEdge } = require('../models/resource');
|
||||
|
||||
const marker = 'test_site_slug_' + Date.now();
|
||||
const created = [];
|
||||
|
||||
async function makeResource(kind, name) {
|
||||
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
|
||||
created.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initORM();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const r of created) {
|
||||
try { await r.delete(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Resource.findAncestorSiteSlug', () => {
|
||||
test('returns the direct parent site\'s slug', async () => {
|
||||
const site = await makeResource('site', 'site-direct');
|
||||
const host = await makeResource('host', 'host-direct');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('walks up through an intermediate host to find the owning site', async () => {
|
||||
const site = await makeResource('site', 'site-nested');
|
||||
const host = await makeResource('host', 'host-nested');
|
||||
const service = await makeResource('service', 'service-nested');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('returns null for a top-level resource with no site ancestor', async () => {
|
||||
const host = await makeResource('host', 'host-orphan');
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('does not loop forever on a cyclic parent chain', async () => {
|
||||
const a = await makeResource('host', 'host-cycle-a');
|
||||
const b = await makeResource('host', 'host-cycle-b');
|
||||
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,30 @@
|
||||
|
||||
const {Group} = require('../models/group_ldap');
|
||||
|
||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||
|
||||
let byGroup = async function(user, groups, ownerOf){
|
||||
for(let group of groups){
|
||||
try{
|
||||
group = await Group.get(group);
|
||||
if(group.member.includes(user.dn)) return true
|
||||
}catch(error){
|
||||
// group not found, continue checking
|
||||
}
|
||||
// Membership is resolved once, transitively: a user placed in an admin group
|
||||
// through a nested group is as much a member as one listed on it directly.
|
||||
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||
// only ever sees the literal member list and would deny them.
|
||||
let memberOfCns = [];
|
||||
try{
|
||||
memberOfCns = await Group.list(user.dn);
|
||||
}catch(error){
|
||||
// Fall through to the per-group checks below rather than hard-failing;
|
||||
// they still catch direct membership if the resolver is unavailable.
|
||||
}
|
||||
|
||||
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||
|
||||
for(let group of groups){
|
||||
if(memberOfCns.includes(group)) return true;
|
||||
}
|
||||
|
||||
// `owner` is deliberately NOT transitive. It designates accountable people,
|
||||
// and inheriting ownership through a nested group would hand approval rights
|
||||
// to anyone transitively in it -- an escalation nobody asked for.
|
||||
for(let group of ownerOf || []){
|
||||
try{
|
||||
group = await Group.get(group);
|
||||
@@ -28,4 +42,4 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {byGroup};
|
||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||
|
||||
+8
-4
@@ -38,9 +38,13 @@ module.exports = {
|
||||
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||
nav: [
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||
// Ungated on purpose: the catalog is the one page that exists for
|
||||
// ordinary users. Before this, every nav item was admin-only and a
|
||||
// non-admin had no signposted destination at all.
|
||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
// Resolve a request user's LDAP group CNs.
|
||||
//
|
||||
// Why this exists: `req.user` is a `User.get()` result, which carries
|
||||
// `memberOf` -- a list of full group DNs -- and has no `groups` property at
|
||||
// all. Anything reading `req.user.groups` therefore silently sees an empty
|
||||
// list rather than failing, which is how GET /api/discovery/me came to return
|
||||
// only `isPublic` resources for every human caller, and how
|
||||
// isDirectoryAdmin() came to be false even for real directory admins.
|
||||
//
|
||||
// routes/user.js:83 already derives the admin gate from `memberOf` the same
|
||||
// way, so the overlay is known to be populated in production; the Group.list()
|
||||
// fallback covers a user object assembled without it (and costs an LDAP round
|
||||
// trip, so it is genuinely the fallback).
|
||||
|
||||
const { Group } = require('../models/group_ldap');
|
||||
|
||||
// 'cn=app_sso_admin,ou=groups,dc=example,dc=com' -> 'app_sso_admin'
|
||||
function cnFromDn(dn) {
|
||||
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||
}
|
||||
|
||||
async function groupCns(user) {
|
||||
if (!user || user.isMachine) return [];
|
||||
|
||||
// Group.list(dn) resolves nested groups transitively. `memberOf` cannot: the
|
||||
// memberof overlay records only direct membership, so a user who reaches a
|
||||
// resource group through a nested group is absent from it entirely. That
|
||||
// makes memberOf a fallback for when there is no DN to query with, never the
|
||||
// preferred source -- reading it first would silently drop every nested grant.
|
||||
if (user.dn) {
|
||||
try {
|
||||
return await Group.list(user.dn);
|
||||
} catch (err) {
|
||||
console.error(`groupCns: LDAP lookup failed for ${user.uid}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(user.memberOf)) return user.memberOf.map(cnFromDn);
|
||||
// memberOf is single-valued when the user is in exactly one group.
|
||||
if (user.memberOf) return [cnFromDn(user.memberOf)];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// The shape @simpleworkjs/directory-schema's isDirectoryAdmin() expects: it
|
||||
// matches against `.groups`, which the raw request user does not have.
|
||||
async function withGroups(user) {
|
||||
if (!user) return user;
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(user) || Object.prototype), user, {
|
||||
groups: await groupCns(user),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { groupCns, withGroups, cnFromDn };
|
||||
+625
-344
File diff suppressed because it is too large
Load Diff
+110
-7
@@ -4,9 +4,38 @@
|
||||
var userlist;
|
||||
var allGroups = [];
|
||||
|
||||
// A member DN under the groups base is a nested group, not a person. Both
|
||||
// live in the same `member` attribute, so they have to be told apart here --
|
||||
// otherwise a nested group renders as a user whose name happens to be the
|
||||
// group's, and its remove button calls the user endpoint and 404s.
|
||||
function isGroupDn(dn){
|
||||
return /,ou=groups,/i.test(String(dn));
|
||||
}
|
||||
|
||||
function processGroup(value){
|
||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
||||
|
||||
// Split before anything else consumes `member`.
|
||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
||||
return {
|
||||
dn: dn,
|
||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
||||
groupCN: value.cn
|
||||
};
|
||||
});
|
||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
||||
value.nestedCount = value.nested.length;
|
||||
value.hasNested = value.nestedCount > 0;
|
||||
|
||||
// Candidates to nest: every other group not already nested here. Self is
|
||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
||||
// which is the only place that can see the whole graph.
|
||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
||||
value.toNest = allGroups.filter(function(g){
|
||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
||||
|
||||
value.toAdd = userlist.filter(function(user){
|
||||
return !value.member.includes(user.dn);
|
||||
});
|
||||
@@ -64,9 +93,7 @@
|
||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||
setTimeout(function(group){
|
||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||
}, 400, group);
|
||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
@@ -91,10 +118,51 @@
|
||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
||||
}
|
||||
|
||||
async function tableAJAX() {
|
||||
async function tableAJAX(revealCn) {
|
||||
let data = await app.group.list();
|
||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
||||
// it has to see the full set before the map runs -- assigning only the
|
||||
// mapped result would leave every dropdown empty on first load (and one
|
||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
||||
allGroups = data.results;
|
||||
allGroups = data.results.map(processGroup);
|
||||
applyFilters();
|
||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||
}
|
||||
|
||||
function addNestedClick(event, groupCN, childCN, el){
|
||||
event.preventDefault();
|
||||
const $card = $('#group-card-' + groupCN);
|
||||
(async function(){
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $card, 'success');
|
||||
} catch(e) {
|
||||
// 409 here is the cycle guard or an already-nested group -- both
|
||||
// carry a specific server message worth showing verbatim.
|
||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function removeNested(groupCN, childCN, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.messages.confirm(
|
||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
||||
$item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(groupCN, uid, btn) {
|
||||
@@ -141,7 +209,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
app.auth.forceLogin('app_sso_admin');
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
$(document).ready(async function(){
|
||||
userlist = (await app.user.list()).results;
|
||||
@@ -150,7 +218,7 @@
|
||||
</script>
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
||||
<div class="input-group" style="flex: 1 1 200px;">
|
||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
||||
@@ -173,7 +241,7 @@
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')">
|
||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
||||
@@ -204,6 +272,12 @@
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
||||
<i class="fa-solid fa-user-tie"></i>
|
||||
@@ -250,6 +324,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
||||
<p class="text-muted small mb-2">
|
||||
Everyone in a nested group is a member of this one, at any depth.
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
{{ #nested }}
|
||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /nested }}
|
||||
{{ ^hasNested }}
|
||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
||||
{{ /hasNested }}
|
||||
</ul>
|
||||
<div class="dropdown mt-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
||||
</button>
|
||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
||||
{{ #toNest }}
|
||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
||||
{{ /toNest }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
||||
<p>
|
||||
<ul class="list-group">
|
||||
|
||||
+344
-109
@@ -1,136 +1,371 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<style>
|
||||
/* App Portal styling using Bootstrap defaults */
|
||||
.portal-banner {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 3rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.portal-banner h1 {
|
||||
font-weight: 700;
|
||||
}
|
||||
.carousel-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.service-card {
|
||||
min-width: 280px;
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
||||
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||
.service-card .card-body { flex: 1; }
|
||||
.card-icon {
|
||||
font-size: 1.4rem;
|
||||
width: 1.8rem;
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.service-card .card-body {
|
||||
flex: 1;
|
||||
.card-icon-img {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
.howto code {
|
||||
display: block;
|
||||
background: var(--bs-tertiary-bg, #f1f3f5);
|
||||
color: var(--bs-body-color);
|
||||
padding: .4rem .6rem;
|
||||
border-radius: .25rem;
|
||||
font-size: .8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-note { color: var(--bs-secondary-color, #6c757d); font-style: italic; }
|
||||
</style>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="portal-banner text-center">
|
||||
<h1>SSO Portal</h1>
|
||||
<p class="lead">Explore and access all your services in one place.</p>
|
||||
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
||||
placeholder="Search services and hosts..." onkeyup="renderAll()">
|
||||
</div>
|
||||
<div class="col-md-4 mt-2 mt-md-0">
|
||||
<select id="catalog-kind" class="form-select shadow-sm" onchange="renderAll()">
|
||||
<option value="">All kinds</option>
|
||||
<option value="service">Services & apps</option>
|
||||
<option value="host">Hosts</option>
|
||||
<option value="site">Sites</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
||||
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
||||
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
||||
<div class="card shadow-sm service-card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-success">Access Granted</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div id="my-requests-section" style="display:none;">
|
||||
<h3 class="mb-3"><i class="fa-solid fa-hourglass-half text-warning"></i> My Requests</h3>
|
||||
<ul class="list-group mb-5 shadow-sm" id="my-requests"></ul>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
||||
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-secondary">Request Access</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
||||
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
||||
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
||||
<div id="approvals-section" style="display:none;">
|
||||
<h3 class="mb-3"><i class="fa-solid fa-user-check text-danger"></i> Awaiting My Approval</h3>
|
||||
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
||||
</div>
|
||||
|
||||
<!-- My Access section with tabs -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
|
||||
</div>
|
||||
<div class="px-3 pt-3 border-bottom">
|
||||
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
|
||||
<i class="fa-solid fa-cube"></i> Services
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
|
||||
<i class="fa-solid fa-server"></i> Hosts
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
|
||||
<div class="catalog-grid" id="my-services"></div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
|
||||
<div class="catalog-grid" id="my-hosts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
||||
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
||||
<div class="catalog-grid mb-5" id="other-services"></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin();
|
||||
|
||||
$(document).ready(async function() {
|
||||
try {
|
||||
let res = await app.api.get('discovery/me');
|
||||
let allAccessible = res.results || [];
|
||||
|
||||
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
||||
|
||||
let myServices = [];
|
||||
let otherServices = [];
|
||||
let hosts = [];
|
||||
|
||||
allAccessible.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
||||
if (r.kind === 'host') hosts.push(r);
|
||||
});
|
||||
|
||||
if (allRes && allRes.results) {
|
||||
allRes.results.forEach(r => {
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
||||
otherServices.push(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...myServices);
|
||||
|
||||
$.scope.otherservices.empty();
|
||||
$.scope.otherservices.push(...otherServices);
|
||||
|
||||
$.scope.hosts.empty();
|
||||
$.scope.hosts.push(...hosts);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load discovery data:', e);
|
||||
}
|
||||
});
|
||||
|
||||
function requestAccess(id) {
|
||||
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
||||
|
||||
// Connection conventions from conf/base.js `directory`, injected server-side
|
||||
// so the "how to reach this" block renders the invocation that actually
|
||||
// works in this deployment rather than a guess.
|
||||
var DIRECTORY_CONF = <%- JSON.stringify(directoryConf) %>;
|
||||
|
||||
var state = { mine: [], others: [], requests: [], approvals: [], uid: null };
|
||||
|
||||
var KIND_ICONS = {
|
||||
site: 'fa-solid fa-city',
|
||||
host: 'fa-solid fa-server',
|
||||
service: 'fa-solid fa-cube',
|
||||
oauth: 'fa-solid fa-key'
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||
});
|
||||
}
|
||||
|
||||
// Render icon as either Font Awesome class or <img> for URL
|
||||
function renderIcon(icon, kind) {
|
||||
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
|
||||
// If it starts with http, treat it as an image URL
|
||||
if (/^https?:\/\//i.test(icon)) {
|
||||
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
|
||||
}
|
||||
// Otherwise it's a Font Awesome class
|
||||
return '<i class="' + esc(icon) + ' card-icon"></i>';
|
||||
}
|
||||
|
||||
// "How do I actually use this?" — the question the directory exists to
|
||||
// answer and the one the old portal never did. Everything here is derived
|
||||
// from directory metadata; nothing is hardcoded per-service.
|
||||
function howTo(r) {
|
||||
var md = r.metadata || {};
|
||||
var addr = r.resolvedAddress || md.address || md.ip;
|
||||
var lines = [];
|
||||
|
||||
if (r.kind === 'host') {
|
||||
var sshPort = md.sshPort || DIRECTORY_CONF.defaultSshPort;
|
||||
var portArg = String(sshPort) === '22' ? '' : ' -p ' + sshPort;
|
||||
if (DIRECTORY_CONF.jumpHost) {
|
||||
// The jump-host username grammar: one string, no interactive
|
||||
// menu, so it works in WinSCP/FileZilla as well as a terminal.
|
||||
lines.push('ssh ' + state.uid + '_-_' + r.slug + '@' + DIRECTORY_CONF.jumpHost + portArg);
|
||||
} else if (addr) {
|
||||
lines.push('ssh ' + state.uid + '@' + addr + portArg);
|
||||
}
|
||||
} else if (addr) {
|
||||
var isUrl = /^https?:\/\//i.test(addr);
|
||||
if (isUrl) {
|
||||
lines.push(addr);
|
||||
} else {
|
||||
var port = md.externalPort || md.port;
|
||||
lines.push(port ? 'https://' + addr + ':' + port : 'https://' + addr);
|
||||
}
|
||||
}
|
||||
|
||||
if (md.gitRepo) lines.push('Source: ' + md.gitRepo);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function linkFor(r) {
|
||||
var md = r.metadata || {};
|
||||
var addr = r.resolvedAddress || md.address || md.ip;
|
||||
if (!addr || r.kind === 'host') return null;
|
||||
if (/^https?:\/\//i.test(addr)) return addr;
|
||||
var port = md.externalPort || md.port;
|
||||
return port ? 'https://' + addr + ':' + port : 'https://' + addr;
|
||||
}
|
||||
|
||||
function cardHtml(r, accessible) {
|
||||
var md = r.metadata || {};
|
||||
var blurb = md.tagline || r.description || 'No description provided';
|
||||
var href = accessible ? linkFor(r) : null;
|
||||
var lines = accessible ? howTo(r) : [];
|
||||
|
||||
var badges = '';
|
||||
if (md.isProduction) badges += '<span class="badge bg-danger ms-1">Prod</span>';
|
||||
if (md.isExternalReachable) badges += '<span class="badge bg-info ms-1">External</span>';
|
||||
if (md.os) badges += '<span class="badge bg-light text-dark border ms-1">' + esc(md.os) + '</span>';
|
||||
|
||||
var footer;
|
||||
if (accessible) {
|
||||
footer = href
|
||||
? '<a class="btn btn-sm btn-success w-100" target="_blank" rel="noopener" href="' + esc(href) + '">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></a>'
|
||||
: '<span class="badge bg-success">Access granted</span>';
|
||||
} else if (md.requestable === false) {
|
||||
footer = '<span class="badge bg-secondary">Not requestable</span>';
|
||||
} else if (state.requests.some(function(q){ return q.resourceId === r.id && q.status === 'pending'; })) {
|
||||
footer = '<span class="badge bg-warning text-dark">Request pending</span>';
|
||||
} else {
|
||||
footer = '<button class="btn btn-sm btn-outline-primary w-100" onclick="requestAccess(\'' + esc(r.id) + '\')">'
|
||||
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
||||
}
|
||||
|
||||
var iconHtml = renderIcon(md.icon, r.kind);
|
||||
|
||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||
+ '<div class="card-body">'
|
||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||
+ iconHtml
|
||||
+ '<span>' + esc(r.name) + '</span>'
|
||||
+ '</h5>'
|
||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||
+ '<p class="card-text small text-muted">' + esc(blurb) + '</p>'
|
||||
+ (lines.length
|
||||
? '<div class="howto small"><div class="text-muted mb-1">How to reach it</div>'
|
||||
+ lines.map(function(l){ return '<code>' + esc(l) + '</code>'; }).join('')
|
||||
+ '</div>'
|
||||
: '')
|
||||
+ '</div>'
|
||||
+ '<div class="card-footer bg-transparent border-top-0">' + footer + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function matchesFilter(r) {
|
||||
var q = ($('#catalog-search').val() || '').toLowerCase();
|
||||
var kind = $('#catalog-kind').val() || '';
|
||||
if (kind && r.kind !== kind) return false;
|
||||
if (!q) return true;
|
||||
var md = r.metadata || {};
|
||||
return [r.name, r.slug, r.description, md.tagline, md.subType, md.ip, md.address]
|
||||
.filter(Boolean).join(' ').toLowerCase().indexOf(q) !== -1;
|
||||
}
|
||||
|
||||
function renderGrid(elId, list, accessible) {
|
||||
var items = list.filter(matchesFilter);
|
||||
var el = document.getElementById(elId);
|
||||
if (!items.length) {
|
||||
el.innerHTML = '<p class="empty-note">Nothing to show here.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = items.map(function(r){ return cardHtml(r, accessible); }).join('');
|
||||
}
|
||||
|
||||
function renderRequests() {
|
||||
var open = state.requests.filter(function(q){ return q.status === 'pending'; });
|
||||
document.getElementById('my-requests-section').style.display = open.length ? '' : 'none';
|
||||
document.getElementById('my-requests').innerHTML = open.map(function(q){
|
||||
var label = q.resource ? q.resource.name : q.groupCn;
|
||||
return '<li class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
+ '<span><strong>' + esc(label) + '</strong> '
|
||||
+ '<small class="text-muted">via <code>' + esc(q.groupCn) + '</code></small></span>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="withdraw(\'' + esc(q.id) + '\')">Withdraw</button>'
|
||||
+ '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
document.getElementById('approvals-section').style.display = state.approvals.length ? '' : 'none';
|
||||
document.getElementById('approvals').innerHTML = state.approvals.map(function(q){
|
||||
var label = q.resource ? q.resource.name : q.groupCn;
|
||||
return '<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">'
|
||||
+ '<span><strong>' + esc(q.uid) + '</strong> requests <strong>' + esc(label) + '</strong> '
|
||||
+ '<small class="text-muted">(<code>' + esc(q.groupCn) + '</code>)</small>'
|
||||
+ (q.note ? '<br><small class="text-muted">' + esc(q.note) + '</small>' : '')
|
||||
+ '</span>'
|
||||
+ '<span class="d-flex gap-2">'
|
||||
+ '<button class="btn btn-sm btn-success" onclick="decide(\'' + esc(q.id) + '\',\'approve\')">Approve</button>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="decide(\'' + esc(q.id) + '\',\'deny\')">Deny</button>'
|
||||
+ '</span></li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
// Split mine into services and hosts
|
||||
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
|
||||
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
|
||||
|
||||
renderGrid('my-services', myServices, true);
|
||||
renderGrid('my-hosts', myHosts, true);
|
||||
renderGrid('other-services', state.others, false);
|
||||
renderRequests();
|
||||
renderApprovals();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var me = await app.auth.asyncUser;
|
||||
state.uid = me.uid;
|
||||
|
||||
// Both endpoints are the *discovery* API, not directory-admin. The old
|
||||
// portal called directory-admin/resources and swallowed the 403, so
|
||||
// "Discover More" was permanently empty for every non-admin — i.e. for
|
||||
// exactly the people it was built for.
|
||||
var mineRes = await app.api.get('discovery/me');
|
||||
var allRes = await app.api.get('discovery/resources');
|
||||
|
||||
state.mine = (mineRes.results || []).filter(function(r){ return r.kind !== 'site'; });
|
||||
var mineIds = {};
|
||||
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
||||
state.others = (allRes.results || []).filter(function(r){
|
||||
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth';
|
||||
});
|
||||
|
||||
// Requests are best-effort: a failure here must not blank the catalog.
|
||||
try {
|
||||
var mineReq = await app.api.get('access-requests/mine');
|
||||
state.requests = mineReq.results || [];
|
||||
} catch (e) { state.requests = []; }
|
||||
try {
|
||||
var pending = await app.api.get('access-requests');
|
||||
state.approvals = pending.results || [];
|
||||
} catch (e) { state.approvals = []; }
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function requestAccess(id) {
|
||||
var resource = state.others.find(function(r){ return r.id === id; });
|
||||
if (!resource) return;
|
||||
app.modal.open({
|
||||
title: 'Request access to ' + resource.name,
|
||||
bodyHtml: '<div class="actionMessage" style="display:none"></div>'
|
||||
+ '<p class="text-muted small">Your request goes to the resource owner for approval.</p>'
|
||||
+ '<label class="form-label">Why do you need it? <span class="text-muted">(optional)</span></label>'
|
||||
+ '<textarea id="req-note" class="form-control" rows="3"></textarea>',
|
||||
footer: {
|
||||
buttonsHtml: '<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||
+ '<button class="btn btn-primary ms-2" onclick="submitRequest(\'' + esc(id) + '\')">Send request</button>'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function submitRequest(id) {
|
||||
try {
|
||||
await app.api.post('access-requests', { resourceId: id, note: $('#req-note').val() });
|
||||
app.modal.close();
|
||||
app.messages.toast('Request sent', 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.action((err && err.message) || 'Could not send request', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(id) {
|
||||
try {
|
||||
await app.api.delete('access-requests/' + id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.toast((err && err.message) || 'Could not withdraw', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(id, action) {
|
||||
try {
|
||||
await app.api.post('access-requests/' + id + '/' + action, {});
|
||||
app.messages.toast('Request ' + (action === 'approve' ? 'approved' : 'denied'), 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.toast((err && err.message) || 'Could not update request', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
load().catch(function(e){
|
||||
console.error('Failed to load catalog:', e);
|
||||
app.messages.toast('Could not load the catalog', 'danger');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<%- include("bottom") %>
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||
// exactly what happens when another app's "Log in with SSO" button sends
|
||||
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||
// a mystery.
|
||||
$(document).ready(function(){
|
||||
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||
if(redirect){
|
||||
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||
var message = isOauth
|
||||
? 'Log in to continue — an application is requesting access to your account.'
|
||||
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||
app.messages.action(message, $('.card').first(), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
function setOtpMethod(method) {
|
||||
$('#otpMethodInput').val(method);
|
||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin('app_sso_admin');
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||
async function loadDashboard() {
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const data = await app.api.get('metrics/executive');
|
||||
const data = await app.api.get('metrics/overview');
|
||||
if (data && data.results) {
|
||||
const renderList = (items, id) => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -213,7 +213,7 @@
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Overview</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+410
-482
File diff suppressed because it is too large
Load Diff
@@ -82,16 +82,24 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||
// (fixed navbar, plus the update banner while it's shown), so any
|
||||
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||
// instead of colliding with them at the viewport's true top:0.
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||
$('#spa-shell').css('margin-top', offset + 'px');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||
}
|
||||
|
||||
function dismissUpdateBanner(){
|
||||
$('#update-banner').hide();
|
||||
$('#spa-shell').css('margin-top', '');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
}
|
||||
|
||||
(async function(){
|
||||
await app.auth.forceLogin('app_sso_admin');
|
||||
await app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
$(document).ready(function(){
|
||||
renderUsers();
|
||||
@@ -301,7 +301,7 @@
|
||||
{{mail}}
|
||||
</td>
|
||||
<td>
|
||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||
{{#hasSshKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/hasSshKey}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
|
||||
Reference in New Issue
Block a user