mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89222bed85 | ||
|
|
ad46f42ab1 | ||
|
|
34a9c77114 | ||
|
|
2c785fe35b |
No files matched your search
@@ -14,6 +14,11 @@
|
|||||||
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
||||||
- Browse shows plugins a page at a time
|
- Browse shows plugins a page at a time
|
||||||
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
|
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
|
||||||
|
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
|
||||||
|
- A plugin can block adding hosts by hand, so every host comes from one place
|
||||||
|
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
|
||||||
|
- A user who must have a second factor but has none can sign in only to set one up
|
||||||
|
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -57,48 +57,50 @@ What every install has, with or without plugins:
|
|||||||
|
|
||||||
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
|
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
|
||||||
|
|
||||||
| Plugin | What it does |
|
| Plugin | What it does |
|
||||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
||||||
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
||||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||||
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||||
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
||||||
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
|
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
|
||||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||||
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
||||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||||
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||||
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
|
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
|
||||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
|
||||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||||
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
||||||
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
||||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
|
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||||
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
|
||||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
||||||
|
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||||
|
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||||
|
|
||||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `idle_timeout` int;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -526,6 +526,13 @@
|
|||||||
"when": 1792374489419,
|
"when": 1792374489419,
|
||||||
"tag": "0074_mature_puff_adder",
|
"tag": "0074_mature_puff_adder",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792430772041,
|
||||||
|
"tag": "0075_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -526,6 +526,13 @@
|
|||||||
"when": 1792374488837,
|
"when": 1792374488837,
|
||||||
"tag": "0074_bouncy_sersi",
|
"tag": "0074_bouncy_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792430770990,
|
||||||
|
"tag": "0075_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `idle_timeout` integer;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -491,6 +491,13 @@
|
|||||||
"when": 1792374488196,
|
"when": 1792374488196,
|
||||||
"tag": "0069_normal_ozymandias",
|
"tag": "0069_normal_ozymandias",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 70,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792430769899,
|
||||||
|
"tag": "0070_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.15
|
||||||
|
|
||||||
|
Plugin API 1.7.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
|
||||||
|
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
|
||||||
|
- `AuthEnrollmentSections` takes `factorsOnly`
|
||||||
|
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
|
||||||
|
|
||||||
|
## 1.0.14
|
||||||
|
|
||||||
|
Plugin API 1.6.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
|
||||||
|
- `ctx.hosts.setShares` to replace every grant on a host in one call
|
||||||
|
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
|
||||||
|
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
|
||||||
|
|
||||||
## 1.0.13
|
## 1.0.13
|
||||||
|
|
||||||
Plugin API 1.5.0.
|
Plugin API 1.5.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.13 | Plugin API 1.5.0
|
SDK 1.0.15 | Plugin API 1.7.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -226,6 +226,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
|||||||
| `sync` | none | Sync your tables to a linked desktop |
|
| `sync` | none | Sync your tables to a linked desktop |
|
||||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||||
|
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||||
| `users.getLocale` | `users:read` | A user's UI language |
|
| `users.getLocale` | `users:read` | A user's UI language |
|
||||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||||
@@ -300,6 +301,11 @@ router.post("/notes", async (req, res) => {
|
|||||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||||
`hosts.edit` checks a core permission.
|
`hosts.edit` checks a core permission.
|
||||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||||
|
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||||
|
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
||||||
|
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
||||||
|
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
|
||||||
|
`ctx.credentials.list()` finds the acting user's credentials by name.
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
@@ -694,6 +700,13 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
|
|||||||
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
||||||
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
||||||
|
|
||||||
|
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
|
||||||
|
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
|
||||||
|
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
|
||||||
|
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
|
||||||
|
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
||||||
|
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -890,6 +903,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
|
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||||
| `device:serial` | high | Open serial and USB ports on the server |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||||
| `hosts:export` | high | Export hosts with their saved logins |
|
| `hosts:export` | high | Export hosts with their saved logins |
|
||||||
@@ -898,6 +912,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `hosts:write` | medium | Add and edit hosts |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||||
|
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||||
| `network:probe` | medium | Check that a host answers on a TCP port |
|
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `credentials:write` | medium | Save new credentials |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.13",
|
"version": "1.0.15",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@
|
|||||||
"process:spawn",
|
"process:spawn",
|
||||||
"users:write",
|
"users:write",
|
||||||
"auth:provide",
|
"auth:provide",
|
||||||
|
"auth:policy",
|
||||||
"system:tls",
|
"system:tls",
|
||||||
"device:serial",
|
"device:serial",
|
||||||
"hosts:write",
|
"hosts:write",
|
||||||
@@ -167,6 +168,7 @@
|
|||||||
"network:probe",
|
"network:probe",
|
||||||
"hosts:status-provide",
|
"hosts:status-provide",
|
||||||
"hosts:activity-provide",
|
"hosts:activity-provide",
|
||||||
|
"hosts:lock",
|
||||||
"desktop:window",
|
"desktop:window",
|
||||||
"desktop:local",
|
"desktop:local",
|
||||||
"users:impersonate",
|
"users:impersonate",
|
||||||
|
|||||||
@@ -698,6 +698,36 @@ export interface PluginHostCreateInput {
|
|||||||
sshOptions?: HostSshOptions | null;
|
sshOptions?: HostSshOptions | null;
|
||||||
/** Host settings by plugin id, the shape a Termix export carries. */
|
/** Host settings by plugin id, the shape a Termix export carries. */
|
||||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* Each plugin's own host switch (its manifest enableKey), by plugin id.
|
||||||
|
* A plugin that is not running is skipped and logged; one with no switch
|
||||||
|
* is refused.
|
||||||
|
*/
|
||||||
|
pluginEnable?: Record<string, boolean>;
|
||||||
|
/**
|
||||||
|
* Secrets, stored encrypted with the owner's data key and never returned
|
||||||
|
* in a PluginHostRecord. An empty string or null clears the stored value.
|
||||||
|
*/
|
||||||
|
password?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
keyPassword?: string | null;
|
||||||
|
keyType?: string | null;
|
||||||
|
sudoPassword?: string | null;
|
||||||
|
/**
|
||||||
|
* Logins for plugin protocols (contributes.protocols), keyed by protocol
|
||||||
|
* id. null removes that protocol's login. Unknown protocol ids are skipped.
|
||||||
|
*/
|
||||||
|
protocolAuth?: Record<string, PluginHostProtocolAuthInput | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One protocol login on a host write. Absent keys keep what is stored. */
|
||||||
|
export interface PluginHostProtocolAuthInput {
|
||||||
|
authType?: "direct" | "credential" | "none";
|
||||||
|
credentialId?: number | null;
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
/** The protocol's declared credentialFields, secret ones included. */
|
||||||
|
fields?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fields a plugin may change on a host the acting user owns. */
|
/** Fields a plugin may change on a host the acting user owns. */
|
||||||
@@ -725,6 +755,13 @@ export const PLUGIN_HOST_INPUT_KEYS = [
|
|||||||
"forceKeyboardInteractive",
|
"forceKeyboardInteractive",
|
||||||
"sshOptions",
|
"sshOptions",
|
||||||
"pluginSettings",
|
"pluginSettings",
|
||||||
|
"pluginEnable",
|
||||||
|
"password",
|
||||||
|
"key",
|
||||||
|
"keyPassword",
|
||||||
|
"keyType",
|
||||||
|
"sudoPassword",
|
||||||
|
"protocolAuth",
|
||||||
] as const satisfies readonly (keyof PluginHostCreateInput)[];
|
] as const satisfies readonly (keyof PluginHostCreateInput)[];
|
||||||
|
|
||||||
/** The same shape canAccessHost returns internally, without secrets. */
|
/** The same shape canAccessHost returns internally, without secrets. */
|
||||||
@@ -761,6 +798,34 @@ export interface PluginShareableRole {
|
|||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One grant in ctx.hosts.setShares. */
|
||||||
|
export interface PluginHostShareGrant {
|
||||||
|
target: PluginShareTarget;
|
||||||
|
permissionLevel: PluginHostShareLevel;
|
||||||
|
/** Omit for a grant that never expires. */
|
||||||
|
durationHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts a plugin manages itself, for example from a config file. Core refuses
|
||||||
|
* edits, deletes and share changes on a locked host from the UI, the API and
|
||||||
|
* other plugins, and marks it in host reads so a layout can show it read only.
|
||||||
|
* Pins and order stay with the user. Only the plugin that locks a host may
|
||||||
|
* still write it.
|
||||||
|
*/
|
||||||
|
export interface PluginHostLockProvider {
|
||||||
|
isLocked: (hostId: number) => boolean;
|
||||||
|
/** A short line shown under the managed note, or null. */
|
||||||
|
note: (hostId: number) => string | null;
|
||||||
|
/** False blocks adding hosts by hand (editor, import, other plugins). */
|
||||||
|
allowManualCreate: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostLocksApi {
|
||||||
|
/** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */
|
||||||
|
provide: (provider: PluginHostLockProvider) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
|
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
|
||||||
* needs to build a feature (like fleets) on top of hosts it does not own.
|
* needs to build a feature (like fleets) on top of hosts it does not own.
|
||||||
@@ -823,6 +888,15 @@ export interface PluginHosts {
|
|||||||
permissionLevel: PluginHostShareLevel,
|
permissionLevel: PluginHostShareLevel,
|
||||||
durationHours?: number,
|
durationHours?: number,
|
||||||
) => Promise<PluginHostShareResult>;
|
) => Promise<PluginHostShareResult>;
|
||||||
|
/**
|
||||||
|
* Replaces every grant on a host the acting user owns with this list.
|
||||||
|
* Grants that stay keep their row and counters, missing ones are revoked.
|
||||||
|
* Needs hosts:write.
|
||||||
|
*/
|
||||||
|
setShares: (
|
||||||
|
hostId: number,
|
||||||
|
grants: PluginHostShareGrant[],
|
||||||
|
) => Promise<PluginHostShareResult>;
|
||||||
/** Users the caller may pick as a share target, for building a picker UI. */
|
/** Users the caller may pick as a share target, for building a picker UI. */
|
||||||
listUsers: () => Promise<PluginShareableUser[]>;
|
listUsers: () => Promise<PluginShareableUser[]>;
|
||||||
/** Non-system roles the caller may pick as a share target. */
|
/** Non-system roles the caller may pick as a share target. */
|
||||||
@@ -896,6 +970,8 @@ export interface PluginHosts {
|
|||||||
) => () => void;
|
) => () => void;
|
||||||
/** The recent activity list on the dashboard. */
|
/** The recent activity list on the dashboard. */
|
||||||
activity: PluginHostActivityApi;
|
activity: PluginHostActivityApi;
|
||||||
|
/** Read only hosts this plugin manages. */
|
||||||
|
locks: PluginHostLocksApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginHostImportOptions {
|
export interface PluginHostImportOptions {
|
||||||
@@ -1685,6 +1761,54 @@ export interface PluginSecondFactor {
|
|||||||
reset?: (userId: string) => Promise<void>;
|
reset?: (userId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A login core is about to turn into a session. */
|
||||||
|
export interface PluginAuthPolicyInput {
|
||||||
|
userId: string;
|
||||||
|
/** Login method id, `"proxy"` for a trusted proxy login. */
|
||||||
|
methodId: string;
|
||||||
|
/** The method signs in through an outside identity provider (SSO, LDAP). */
|
||||||
|
external: boolean;
|
||||||
|
deviceType: "web" | "desktop" | "mobile";
|
||||||
|
/** The device passed a second factor before and was remembered. */
|
||||||
|
trustedDevice: boolean;
|
||||||
|
/** The method itself proved a second factor, like a verified passkey. */
|
||||||
|
mfaSatisfied: boolean;
|
||||||
|
/** Every second factor core has, and whether this user set it up. */
|
||||||
|
secondFactors: { id: string; pluginId: string; enrolled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAuthPolicyDecision {
|
||||||
|
/** False turns remember me off and ignores remembered devices. */
|
||||||
|
allowRememberDevice?: boolean;
|
||||||
|
/** Caps how long the session lasts. */
|
||||||
|
maxSessionSeconds?: number;
|
||||||
|
/** Ends the session after this many seconds without a request. */
|
||||||
|
idleTimeoutSeconds?: number;
|
||||||
|
/**
|
||||||
|
* Requires a second factor even for users who have none: they get a
|
||||||
|
* session that can only set one up. `"new-device"` skips it on a
|
||||||
|
* remembered device.
|
||||||
|
*/
|
||||||
|
secondFactor?: "always" | "new-device";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login rules from a plugin. Core asks every provider and the strictest
|
||||||
|
* answer wins. A provider that throws refuses the login.
|
||||||
|
*/
|
||||||
|
export interface PluginAuthPolicyProvider {
|
||||||
|
evaluate: (
|
||||||
|
input: PluginAuthPolicyInput,
|
||||||
|
) => PluginAuthPolicyDecision | Promise<PluginAuthPolicyDecision>;
|
||||||
|
/** Whether the login page should offer remember me. */
|
||||||
|
allowRememberDevice?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAuthPolicyApi {
|
||||||
|
/** Needs auth:policy. Removed on deactivate. */
|
||||||
|
provide: (provider: PluginAuthPolicyProvider) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginUserTokenOptions {
|
export interface PluginUserTokenOptions {
|
||||||
/** Why the token exists, kept in the audit log and on the session. */
|
/** Why the token exists, kept in the audit log and on the session. */
|
||||||
purpose: string;
|
purpose: string;
|
||||||
@@ -1774,6 +1898,10 @@ export interface PluginAuth {
|
|||||||
userId: string,
|
userId: string,
|
||||||
options: PluginUserTokenOptions,
|
options: PluginUserTokenOptions,
|
||||||
) => Promise<PluginUserToken>;
|
) => Promise<PluginUserToken>;
|
||||||
|
/** Login rules: idle timeout, session length, remember me, second factors. */
|
||||||
|
policy: PluginAuthPolicyApi;
|
||||||
|
/** Second factors from every running plugin. Needs no capability. */
|
||||||
|
listSecondFactors: () => { id: string; pluginId: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a plugin may learn about a user. Needs users:read. */
|
/** What a plugin may learn about a user. Needs users:read. */
|
||||||
@@ -2011,6 +2139,15 @@ export interface PluginSshKeyCredential {
|
|||||||
publicKey: string | null;
|
publicKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A saved credential without its secrets. */
|
||||||
|
export interface PluginCredentialSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
username: string | null;
|
||||||
|
authType: string;
|
||||||
|
folder: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A key pair to save as a new credential for the acting user. */
|
/** A key pair to save as a new credential for the acting user. */
|
||||||
export interface PluginSshKeyCredentialInput {
|
export interface PluginSshKeyCredentialInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2032,6 +2169,12 @@ export interface PluginCredentials {
|
|||||||
* Needs credentials:use and the core credentials.view permission. Audited.
|
* Needs credentials:use and the core credentials.view permission. Audited.
|
||||||
*/
|
*/
|
||||||
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
|
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
|
||||||
|
/**
|
||||||
|
* The acting user's own credentials by name, with no secret material, so a
|
||||||
|
* plugin can point a host at one. Needs credentials:use and the core
|
||||||
|
* credentials.view permission. Audited.
|
||||||
|
*/
|
||||||
|
list: () => Promise<PluginCredentialSummary[]>;
|
||||||
/**
|
/**
|
||||||
* Saves a key pair as a new encrypted credential owned by the acting user
|
* Saves a key pair as a new encrypted credential owned by the acting user
|
||||||
* and returns its id. Needs credentials:write, the core credentials.create
|
* and returns its id. Needs credentials:write, the core credentials.create
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("users:impersonate", "high"),
|
entry("users:impersonate", "high"),
|
||||||
entry("db:core-refs", "high"),
|
entry("db:core-refs", "high"),
|
||||||
entry("auth:provide", "high"),
|
entry("auth:provide", "high"),
|
||||||
|
entry("auth:policy", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
entry("ui:layout", "high"),
|
entry("ui:layout", "high"),
|
||||||
@@ -61,6 +62,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("network:probe", "medium"),
|
entry("network:probe", "medium"),
|
||||||
entry("hosts:status-provide", "medium"),
|
entry("hosts:status-provide", "medium"),
|
||||||
entry("hosts:activity-provide", "medium"),
|
entry("hosts:activity-provide", "medium"),
|
||||||
|
entry("hosts:lock", "medium"),
|
||||||
entry("users:read", "medium"),
|
entry("users:read", "medium"),
|
||||||
entry("events:core", "medium"),
|
entry("events:core", "medium"),
|
||||||
entry("notify:send", "medium"),
|
entry("notify:send", "medium"),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* version by the server.
|
||||||
*/
|
*/
|
||||||
export const PLUGIN_API_VERSION = "1.5.0";
|
export const PLUGIN_API_VERSION = "1.7.0";
|
||||||
|
|
||||||
/** The API major, the default engine.api for a new plugin. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ import type {
|
|||||||
PluginHostProbeTarget,
|
PluginHostProbeTarget,
|
||||||
PluginHostStatusEntry,
|
PluginHostStatusEntry,
|
||||||
PluginActivityProvider,
|
PluginActivityProvider,
|
||||||
|
PluginHostLockProvider,
|
||||||
|
PluginAuthPolicyProvider,
|
||||||
|
PluginCredentialSummary,
|
||||||
|
PluginHostShareGrant,
|
||||||
PluginHostStatusProvider,
|
PluginHostStatusProvider,
|
||||||
PluginNotification,
|
PluginNotification,
|
||||||
PluginNotificationChannel,
|
PluginNotificationChannel,
|
||||||
@@ -140,6 +144,8 @@ export interface FakeContextOptions {
|
|||||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||||
/** What ctx.credentials.listSshKeys answers. */
|
/** What ctx.credentials.listSshKeys answers. */
|
||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
|
/** What ctx.credentials.list answers. */
|
||||||
|
credentialSummaries?: PluginCredentialSummary[];
|
||||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||||
@@ -190,6 +196,8 @@ export interface FakeContextOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
||||||
linkedUsers?: Record<string, number>;
|
linkedUsers?: Record<string, number>;
|
||||||
|
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
|
||||||
|
secondFactors?: { id: string; pluginId: string }[];
|
||||||
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||||
@@ -269,6 +277,8 @@ export interface FakeAuthRegistrations {
|
|||||||
loginFailures: Map<string, number>;
|
loginFailures: Map<string, number>;
|
||||||
/** Every ctx.auth.issueUserToken call, in order. */
|
/** Every ctx.auth.issueUserToken call, in order. */
|
||||||
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||||
|
/** Providers passed to ctx.auth.policy.provide that are still registered. */
|
||||||
|
policies: PluginAuthPolicyProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
@@ -285,6 +295,8 @@ export interface FakePluginContext {
|
|||||||
hostStatusProviders: PluginHostStatusProvider[];
|
hostStatusProviders: PluginHostStatusProvider[];
|
||||||
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||||
activityProviders: PluginActivityProvider[];
|
activityProviders: PluginActivityProvider[];
|
||||||
|
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||||
|
hostLockProviders: PluginHostLockProvider[];
|
||||||
/** Topics emitted through ctx.events.emit, in order. */
|
/** Topics emitted through ctx.events.emit, in order. */
|
||||||
emitted: Array<{ topic: string; payload: unknown }>;
|
emitted: Array<{ topic: string; payload: unknown }>;
|
||||||
/** Backing store behind ctx.kv. */
|
/** Backing store behind ctx.kv. */
|
||||||
@@ -317,6 +329,8 @@ export interface FakePluginContext {
|
|||||||
permissionLevel: string;
|
permissionLevel: string;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
}>;
|
}>;
|
||||||
|
/** Every ctx.hosts.setShares call, in order. */
|
||||||
|
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
|
||||||
/** Everything registered through ctx.auth. */
|
/** Everything registered through ctx.auth. */
|
||||||
auth: FakeAuthRegistrations;
|
auth: FakeAuthRegistrations;
|
||||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||||
@@ -488,7 +502,14 @@ function applyHostInput(
|
|||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
pluginSettings: _settings,
|
pluginSettings: _settings,
|
||||||
|
pluginEnable: _pluginEnable,
|
||||||
forceKeyboardInteractive: _fki,
|
forceKeyboardInteractive: _fki,
|
||||||
|
password: _password,
|
||||||
|
key: _key,
|
||||||
|
keyPassword: _keyPassword,
|
||||||
|
keyType: _keyType,
|
||||||
|
sudoPassword: _sudoPassword,
|
||||||
|
protocolAuth: _protocolAuth,
|
||||||
...rest
|
...rest
|
||||||
} = input;
|
} = input;
|
||||||
const next: PluginHostRecord = { ...record };
|
const next: PluginHostRecord = { ...record };
|
||||||
@@ -517,6 +538,7 @@ export function createFakeContext(
|
|||||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||||
const activityProviders: PluginActivityProvider[] = [];
|
const activityProviders: PluginActivityProvider[] = [];
|
||||||
|
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
const syncEntities: SyncEntityRegistration[] = [];
|
||||||
@@ -534,6 +556,7 @@ export function createFakeContext(
|
|||||||
}>();
|
}>();
|
||||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||||
const hostShares: FakePluginContext["hostShares"] = [];
|
const hostShares: FakePluginContext["hostShares"] = [];
|
||||||
|
const hostShareSets: FakePluginContext["hostShareSets"] = [];
|
||||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||||
[];
|
[];
|
||||||
@@ -606,6 +629,7 @@ export function createFakeContext(
|
|||||||
revokedSessions: [],
|
revokedSessions: [],
|
||||||
loginFailures: new Map(),
|
loginFailures: new Map(),
|
||||||
userTokens: [],
|
userTokens: [],
|
||||||
|
policies: [],
|
||||||
};
|
};
|
||||||
const sshClient = options.sshClient ?? {};
|
const sshClient = options.sshClient ?? {};
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
@@ -1004,6 +1028,10 @@ export function createFakeContext(
|
|||||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||||
return { hostId, shared: true };
|
return { hostId, shared: true };
|
||||||
},
|
},
|
||||||
|
setShares: async (hostId, grants) => {
|
||||||
|
hostShareSets.push({ hostId, grants: [...grants] });
|
||||||
|
return { hostId, shared: true };
|
||||||
|
},
|
||||||
listUsers: async () => options.shareableUsers ?? [],
|
listUsers: async () => options.shareableUsers ?? [],
|
||||||
listRoles: async () => options.shareableRoles ?? [],
|
listRoles: async () => options.shareableRoles ?? [],
|
||||||
trackSession: (hostId) => {
|
trackSession: (hostId) => {
|
||||||
@@ -1034,6 +1062,15 @@ export function createFakeContext(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
locks: {
|
||||||
|
provide: (provider) => {
|
||||||
|
hostLockProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = hostLockProviders.indexOf(provider);
|
||||||
|
if (at >= 0) hostLockProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
provide: (provider) => {
|
provide: (provider) => {
|
||||||
hostStatusProviders.push(provider);
|
hostStatusProviders.push(provider);
|
||||||
@@ -1266,6 +1303,22 @@ export function createFakeContext(
|
|||||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
policy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
auth.policies.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = auth.policies.indexOf(provider);
|
||||||
|
if (at >= 0) auth.policies.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listSecondFactors: () => [
|
||||||
|
...auth.secondFactors.map((factor) => ({
|
||||||
|
id: factor.id,
|
||||||
|
pluginId,
|
||||||
|
})),
|
||||||
|
...(options.secondFactors ?? []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
@@ -1286,6 +1339,7 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
|
list: async () => [...(options.credentialSummaries ?? [])],
|
||||||
listSshKeys: async () => [
|
listSshKeys: async () => [
|
||||||
...(options.sshKeyCredentials ?? []),
|
...(options.sshKeyCredentials ?? []),
|
||||||
...createdSshKeys.map((key) => ({
|
...createdSshKeys.map((key) => ({
|
||||||
@@ -1468,6 +1522,7 @@ export function createFakeContext(
|
|||||||
disposals,
|
disposals,
|
||||||
hostStatusProviders,
|
hostStatusProviders,
|
||||||
activityProviders,
|
activityProviders,
|
||||||
|
hostLockProviders,
|
||||||
emitted,
|
emitted,
|
||||||
kv,
|
kv,
|
||||||
tables,
|
tables,
|
||||||
@@ -1478,6 +1533,7 @@ export function createFakeContext(
|
|||||||
coreSettings,
|
coreSettings,
|
||||||
sshConnections,
|
sshConnections,
|
||||||
hostShares,
|
hostShares,
|
||||||
|
hostShareSets,
|
||||||
auth,
|
auth,
|
||||||
desktopWindows,
|
desktopWindows,
|
||||||
externalClientLaunches,
|
externalClientLaunches,
|
||||||
@@ -1560,6 +1616,8 @@ export interface MockContextOptions {
|
|||||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||||
/** What ctx.credentials.listSshKeys serves. */
|
/** What ctx.credentials.listSshKeys serves. */
|
||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
|
/** What ctx.credentials.list serves. */
|
||||||
|
credentialSummaries?: PluginCredentialSummary[];
|
||||||
/** What ctx.desktop.available() answers. */
|
/** What ctx.desktop.available() answers. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
/** What ctx.desktop.local is. See FakeContextOptions. */
|
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||||
@@ -1598,6 +1656,8 @@ export interface MockContextOptions {
|
|||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
|
secondFactors?: FakeContextOptions["secondFactors"];
|
||||||
|
/** See FakeContextOptions. */
|
||||||
locales?: Record<string, string>;
|
locales?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1699,6 +1759,7 @@ export function createMockCtx(
|
|||||||
services: options.services,
|
services: options.services,
|
||||||
protocolTargets: options.protocolTargets,
|
protocolTargets: options.protocolTargets,
|
||||||
sshKeyCredentials: options.sshKeyCredentials,
|
sshKeyCredentials: options.sshKeyCredentials,
|
||||||
|
credentialSummaries: options.credentialSummaries,
|
||||||
desktopAvailable: options.desktopAvailable,
|
desktopAvailable: options.desktopAvailable,
|
||||||
desktopLocal: options.desktopLocal,
|
desktopLocal: options.desktopLocal,
|
||||||
auditEntries: options.auditEntries,
|
auditEntries: options.auditEntries,
|
||||||
@@ -1716,6 +1777,7 @@ export function createMockCtx(
|
|||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
linkedUsers: options.linkedUsers,
|
linkedUsers: options.linkedUsers,
|
||||||
loginAttemptLimit: options.loginAttemptLimit,
|
loginAttemptLimit: options.loginAttemptLimit,
|
||||||
|
secondFactors: options.secondFactors,
|
||||||
locales: options.locales,
|
locales: options.locales,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
@@ -1900,6 +1962,10 @@ export function createMockCtx(
|
|||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||||
},
|
},
|
||||||
|
setShares: async (hostId, grants) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.setShares(hostId, grants);
|
||||||
|
},
|
||||||
listUsers: async () => {
|
listUsers: async () => {
|
||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.listUsers();
|
return ctx.hosts.listUsers();
|
||||||
@@ -1930,6 +1996,12 @@ export function createMockCtx(
|
|||||||
return ctx.hosts.activity.provide(provider);
|
return ctx.hosts.activity.provide(provider);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
locks: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:lock");
|
||||||
|
return ctx.hosts.locks.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
provide: (provider) => {
|
provide: (provider) => {
|
||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
@@ -2057,6 +2129,13 @@ export function createMockCtx(
|
|||||||
require("users:impersonate");
|
require("users:impersonate");
|
||||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||||
},
|
},
|
||||||
|
policy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("auth:policy");
|
||||||
|
return ctx.auth.policy.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listSecondFactors: () => ctx.auth.listSecondFactors(),
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
@@ -2098,6 +2177,10 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
|
list: async () => {
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.credentials.list();
|
||||||
|
},
|
||||||
listSshKeys: async () => {
|
listSshKeys: async () => {
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
return ctx.credentials.listSshKeys();
|
return ctx.credentials.listSshKeys();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -121,6 +122,28 @@ export function pluginVitestConfig(
|
|||||||
// Vite must be allowed to read it, and React must come from the plugin.
|
// Vite must be allowed to read it, and React must come from the plugin.
|
||||||
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
||||||
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
||||||
|
// dedupe alone is not enough: react-dom or the testing library loaded from
|
||||||
|
// the linked SDK's own node_modules pull in a second React, and hooks then
|
||||||
|
// see no renderer.
|
||||||
|
if (!sdkDir.startsWith(pluginDir)) {
|
||||||
|
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
|
||||||
|
for (const name of [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"@testing-library/react",
|
||||||
|
"react-i18next",
|
||||||
|
"i18next",
|
||||||
|
]) {
|
||||||
|
if (alias[name]) continue;
|
||||||
|
try {
|
||||||
|
alias[name] = path.dirname(
|
||||||
|
pluginRequire.resolve(`${name}/package.json`),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Not installed in the plugin: leave it to normal resolution.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const resolve = {
|
const resolve = {
|
||||||
alias,
|
alias,
|
||||||
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<H
|
|||||||
ApiKey (type): ApiKey
|
ApiKey (type): ApiKey
|
||||||
AuditLog (type): AuditLog
|
AuditLog (type): AuditLog
|
||||||
AuditLogFilters (type): AuditLogFilters
|
AuditLogFilters (type): AuditLogFilters
|
||||||
AuthEnrollmentSections (value): () => Element
|
AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
|
||||||
AuthOverrideProtocol (type): string
|
AuthOverrideProtocol (type): string
|
||||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||||
@@ -151,7 +151,9 @@ HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
|||||||
HostFolder (type): HostFolder
|
HostFolder (type): HostFolder
|
||||||
HostGroupKey (type): HostGroupKey
|
HostGroupKey (type): HostGroupKey
|
||||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||||
|
HostLock (type): HostLock
|
||||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||||
|
HostPolicy (type): HostPolicy
|
||||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||||
HostProtocolDef (type): HostProtocolDef
|
HostProtocolDef (type): HostProtocolDef
|
||||||
HostProtocols (type): HostProtocols
|
HostProtocols (type): HostProtocols
|
||||||
@@ -159,6 +161,7 @@ HostRowFields (type): HostRowFields
|
|||||||
HostSidebarFilterState (type): HostSidebarFilterState
|
HostSidebarFilterState (type): HostSidebarFilterState
|
||||||
HostSidebarPreferences (type): HostSidebarPreferences
|
HostSidebarPreferences (type): HostSidebarPreferences
|
||||||
HostTrayTrigger (type): HostTrayTrigger
|
HostTrayTrigger (type): HostTrayTrigger
|
||||||
|
HttpsSettings (type): HttpsSettings
|
||||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||||
@@ -411,8 +414,10 @@ getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId
|
|||||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||||
|
getHostPolicy (value): () => Promise<HostPolicy>
|
||||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||||
getHostTags (value): () => Promise<string[]>
|
getHostTags (value): () => Promise<string[]>
|
||||||
|
getHttpsSettings (value): () => Promise<HttpsSettings>
|
||||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||||
getLogLevel (value): () => Promise<{ level: string; }>
|
getLogLevel (value): () => Promise<{ level: string; }>
|
||||||
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||||
@@ -570,6 +575,7 @@ updateCredential (value): (credentialId: number, credentialData: Record<string,
|
|||||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||||
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
||||||
|
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
|
||||||
updateLogLevel (value): (level: string) => Promise<void>
|
updateLogLevel (value): (level: string) => Promise<void>
|
||||||
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ const ALLOWED = [
|
|||||||
"HostDefaultsTarget",
|
"HostDefaultsTarget",
|
||||||
"HostDensity",
|
"HostDensity",
|
||||||
"HostFeatureFields",
|
"HostFeatureFields",
|
||||||
|
"HostLock",
|
||||||
|
"HostPolicy",
|
||||||
|
"HttpsSettings",
|
||||||
"HostFeatureFieldsProps",
|
"HostFeatureFieldsProps",
|
||||||
"HostFolder",
|
"HostFolder",
|
||||||
"HostGroupKey",
|
"HostGroupKey",
|
||||||
@@ -432,9 +435,11 @@ const ALLOWED = [
|
|||||||
"getHostDefaultsLevel",
|
"getHostDefaultsLevel",
|
||||||
"getHostEditorSection",
|
"getHostEditorSection",
|
||||||
"getHostPassword",
|
"getHostPassword",
|
||||||
|
"getHostPolicy",
|
||||||
"getHostSidebarPreferences",
|
"getHostSidebarPreferences",
|
||||||
"getHostTags",
|
"getHostTags",
|
||||||
"getLiveHostStatus",
|
"getLiveHostStatus",
|
||||||
|
"getHttpsSettings",
|
||||||
"getLogLevel",
|
"getLogLevel",
|
||||||
"getNotificationPrivateEndpoints",
|
"getNotificationPrivateEndpoints",
|
||||||
"getPanel",
|
"getPanel",
|
||||||
@@ -602,6 +607,7 @@ const ALLOWED = [
|
|||||||
"updateSSHHost",
|
"updateSSHHost",
|
||||||
"updateSecondFactorAfterExternalLogin",
|
"updateSecondFactorAfterExternalLogin",
|
||||||
"updateSessionTimeout",
|
"updateSessionTimeout",
|
||||||
|
"updateHttpsSettings",
|
||||||
"updateStatusCheckSettings",
|
"updateStatusCheckSettings",
|
||||||
"updateSyncSettings",
|
"updateSyncSettings",
|
||||||
"uploadTlsCertificate",
|
"uploadTlsCertificate",
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import {
|
|||||||
isSecondFactorAfterExternalLoginEnabled,
|
isSecondFactorAfterExternalLoginEnabled,
|
||||||
} from "./core-auth.js";
|
} from "./core-auth.js";
|
||||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||||
|
import {
|
||||||
|
SECOND_FACTOR_SETUP,
|
||||||
|
evaluateAuthPolicy,
|
||||||
|
hasAuthPolicies,
|
||||||
|
rememberDeviceAllowed,
|
||||||
|
} from "./policy.js";
|
||||||
import {
|
import {
|
||||||
getLoginMethod,
|
getLoginMethod,
|
||||||
getSecondFactor,
|
getSecondFactor,
|
||||||
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
|
|||||||
* governs external methods (SSO, LDAP) alone.
|
* governs external methods (SSO, LDAP) alone.
|
||||||
*/
|
*/
|
||||||
function shouldRunSecondFactors(methodId: string): boolean {
|
function shouldRunSecondFactors(methodId: string): boolean {
|
||||||
ensureCoreLoginProviders();
|
if (!isExternalMethod(methodId)) return true;
|
||||||
const method = getLoginMethod(methodId);
|
|
||||||
if (!method?.external) return true;
|
|
||||||
return isSecondFactorAfterExternalLoginEnabled();
|
return isSecondFactorAfterExternalLoginEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExternalMethod(methodId: string): boolean {
|
||||||
|
ensureCoreLoginProviders();
|
||||||
|
return !!getLoginMethod(methodId)?.external;
|
||||||
|
}
|
||||||
|
|
||||||
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
||||||
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
@@ -294,9 +303,40 @@ export async function runLogin(
|
|||||||
`${context.methodId}_role_shared_credentials`,
|
`${context.methodId}_role_shared_credentials`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const factors = shouldRunSecondFactors(context.methodId)
|
// A login policy may want a factor where core would skip it, so it always
|
||||||
? await evaluateSecondFactors(user.id)
|
// sees the full picture.
|
||||||
: { required: [], blocked: [] };
|
const runFactors = shouldRunSecondFactors(context.methodId);
|
||||||
|
const factors =
|
||||||
|
runFactors || hasAuthPolicies()
|
||||||
|
? await evaluateSecondFactors(user.id)
|
||||||
|
: { required: [], blocked: [] };
|
||||||
|
const trusted =
|
||||||
|
factors.required.length > 0 || hasAuthPolicies()
|
||||||
|
? await isTrustedDevice(req, user.id)
|
||||||
|
: false;
|
||||||
|
const policy = await evaluateAuthPolicy({
|
||||||
|
userId: user.id,
|
||||||
|
methodId: context.methodId,
|
||||||
|
external: isExternalMethod(context.methodId),
|
||||||
|
deviceType,
|
||||||
|
trustedDevice: trusted,
|
||||||
|
mfaSatisfied: !!identity.mfaSatisfied,
|
||||||
|
secondFactors: listSecondFactors().map((factor) => ({
|
||||||
|
id: factor.id,
|
||||||
|
pluginId: factor.pluginId,
|
||||||
|
enrolled: factors.required.some(
|
||||||
|
(required) =>
|
||||||
|
required.id === factor.id && required.pluginId === factor.pluginId,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!runFactors && !policy.secondFactor) {
|
||||||
|
factors.required = [];
|
||||||
|
factors.blocked = [];
|
||||||
|
}
|
||||||
|
const rememberMe = context.rememberMe && policy.allowRememberDevice;
|
||||||
|
const skipByDevice =
|
||||||
|
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
|
||||||
if (factors.blocked.length > 0) {
|
if (factors.blocked.length > 0) {
|
||||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||||
operation: "second_factor_unavailable",
|
operation: "second_factor_unavailable",
|
||||||
@@ -324,11 +364,7 @@ export async function runLogin(
|
|||||||
|
|
||||||
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
||||||
|
|
||||||
if (
|
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
|
||||||
factors.required.length > 0 &&
|
|
||||||
!identity.mfaSatisfied &&
|
|
||||||
!(await isTrustedDevice(req, user.id))
|
|
||||||
) {
|
|
||||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||||
user.id,
|
user.id,
|
||||||
{ pendingTOTP: true, expiresIn: "10m" },
|
{ pendingTOTP: true, expiresIn: "10m" },
|
||||||
@@ -337,14 +373,17 @@ export async function runLogin(
|
|||||||
pendingLogins.set(pendingKey(tempToken), {
|
pendingLogins.set(pendingKey(tempToken), {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
methodId: context.methodId,
|
methodId: context.methodId,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
|
allowRememberDevice: policy.allowRememberDevice,
|
||||||
|
maxSessionSeconds: policy.maxSessionSeconds,
|
||||||
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||||
...ssoClaims,
|
...ssoClaims,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
kind: "second-factor",
|
kind: "second-factor",
|
||||||
tempToken,
|
tempToken,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
factors: factors.required.map((factor) => ({
|
factors: factors.required.map((factor) => ({
|
||||||
id: factor.id,
|
id: factor.id,
|
||||||
pluginId: factor.pluginId,
|
pluginId: factor.pluginId,
|
||||||
@@ -353,11 +392,22 @@ export async function runLogin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required but nothing set up yet: a session that can only set one up.
|
||||||
|
const needsSetup =
|
||||||
|
!!policy.secondFactor &&
|
||||||
|
factors.required.length === 0 &&
|
||||||
|
!identity.mfaSatisfied &&
|
||||||
|
!skipByDevice;
|
||||||
|
|
||||||
const session = await issueSession(req, user, {
|
const session = await issueSession(req, user, {
|
||||||
methodId: context.methodId,
|
methodId: context.methodId,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||||
longLivedForApps: identity.kind === "external",
|
longLivedForApps:
|
||||||
|
identity.kind === "external" && policy.allowRememberDevice,
|
||||||
|
maxAgeSeconds: policy.maxSessionSeconds,
|
||||||
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||||
|
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
|
||||||
...ssoClaims,
|
...ssoClaims,
|
||||||
});
|
});
|
||||||
return { kind: "session", ...session };
|
return { kind: "session", ...session };
|
||||||
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
|
|||||||
}
|
}
|
||||||
loginRateLimiter.resetTOTPAttempts(user.id);
|
loginRateLimiter.resetTOTPAttempts(user.id);
|
||||||
|
|
||||||
|
// A pending entry lost to a restart falls back to the current policy.
|
||||||
|
const allowRemember = lookup.pending
|
||||||
|
? lookup.pending.allowRememberDevice !== false
|
||||||
|
: rememberDeviceAllowed();
|
||||||
const rememberMe =
|
const rememberMe =
|
||||||
typeof req.body?.rememberMe === "boolean"
|
allowRemember &&
|
||||||
|
(typeof req.body?.rememberMe === "boolean"
|
||||||
? req.body.rememberMe
|
? req.body.rememberMe
|
||||||
: (lookup.pending?.rememberMe ?? false);
|
: (lookup.pending?.rememberMe ?? false));
|
||||||
|
|
||||||
let trustToken: string | null = null;
|
let trustToken: string | null = null;
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
|
|||||||
methodId: pending?.methodId ?? "password",
|
methodId: pending?.methodId ?? "password",
|
||||||
rememberMe,
|
rememberMe,
|
||||||
externalSession: pending?.externalSession ?? null,
|
externalSession: pending?.externalSession ?? null,
|
||||||
|
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
|
||||||
|
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (trustToken && isNativeAppRequest(req)) {
|
if (trustToken && isNativeAppRequest(req)) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Login policy from plugins through ctx.auth.policy.provide: idle timeout,
|
||||||
|
* session length, remember me and a required second factor. Core asks every
|
||||||
|
* provider and the strictest answer wins. A provider that throws at login
|
||||||
|
* refuses the login, since skipping a security rule is worse than a failed
|
||||||
|
* sign in. The idle timeout is stored on the session at login. Desktop
|
||||||
|
* loopback, sync link and plugin minted sessions are device credentials, not
|
||||||
|
* logins, so the policy never reaches them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginAuthPolicyDecision,
|
||||||
|
PluginAuthPolicyInput,
|
||||||
|
PluginAuthPolicyProvider,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
import { authLogger } from "../utils/logger.js";
|
||||||
|
import { LoginMethodError } from "./types.js";
|
||||||
|
|
||||||
|
/** sessions.restriction of a session that may only set up a second factor. */
|
||||||
|
export const SECOND_FACTOR_SETUP = "second-factor-setup";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
pluginId: string;
|
||||||
|
provider: PluginAuthPolicyProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = new Map<symbol, Entry>();
|
||||||
|
|
||||||
|
export function provideAuthPolicy(
|
||||||
|
pluginId: string,
|
||||||
|
provider: PluginAuthPolicyProvider,
|
||||||
|
): () => void {
|
||||||
|
const key = Symbol(pluginId);
|
||||||
|
providers.set(key, { pluginId, provider });
|
||||||
|
return () => {
|
||||||
|
providers.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function clearAuthPolicies(): void {
|
||||||
|
providers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuthPolicies(): boolean {
|
||||||
|
return providers.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPolicy {
|
||||||
|
allowRememberDevice: boolean;
|
||||||
|
maxSessionSeconds: number | null;
|
||||||
|
idleTimeoutSeconds: number | null;
|
||||||
|
secondFactor: "always" | "new-device" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_POLICY: AuthPolicy = {
|
||||||
|
allowRememberDevice: true,
|
||||||
|
maxSessionSeconds: null,
|
||||||
|
idleTimeoutSeconds: null,
|
||||||
|
secondFactor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortest(current: number | null, next: unknown): number | null {
|
||||||
|
if (typeof next !== "number" || !(next > 0)) return current;
|
||||||
|
const value = Math.floor(next);
|
||||||
|
return current === null ? value : Math.min(current, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy {
|
||||||
|
let secondFactor = policy.secondFactor;
|
||||||
|
if (next.secondFactor === "always") secondFactor = "always";
|
||||||
|
else if (next.secondFactor === "new-device" && secondFactor !== "always") {
|
||||||
|
secondFactor = "new-device";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowRememberDevice:
|
||||||
|
policy.allowRememberDevice && next.allowRememberDevice !== false,
|
||||||
|
maxSessionSeconds: shortest(
|
||||||
|
policy.maxSessionSeconds,
|
||||||
|
next.maxSessionSeconds,
|
||||||
|
),
|
||||||
|
idleTimeoutSeconds: shortest(
|
||||||
|
policy.idleTimeoutSeconds,
|
||||||
|
next.idleTimeoutSeconds,
|
||||||
|
),
|
||||||
|
secondFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateAuthPolicy(
|
||||||
|
input: PluginAuthPolicyInput,
|
||||||
|
): Promise<AuthPolicy> {
|
||||||
|
let policy = NO_POLICY;
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
let decision: PluginAuthPolicyDecision;
|
||||||
|
try {
|
||||||
|
decision = (await entry.provider.evaluate(input)) ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.warn("Login policy failed, refusing the login", {
|
||||||
|
operation: "auth_policy",
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw new LoginMethodError(
|
||||||
|
"Sign in is not available right now. Contact an admin.",
|
||||||
|
503,
|
||||||
|
"auth_policy_unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
policy = merge(policy, decision);
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the login page should offer remember me. */
|
||||||
|
export function rememberDeviceAllowed(): boolean {
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
try {
|
||||||
|
if (entry.provider.allowRememberDevice?.() === false) return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ export interface IssueSessionOptions {
|
|||||||
externalSession?: ExternalSessionRef | null;
|
externalSession?: ExternalSessionRef | null;
|
||||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||||
longLivedForApps?: boolean;
|
longLivedForApps?: boolean;
|
||||||
|
/** Login policy cap on the session length. */
|
||||||
|
maxAgeSeconds?: number | null;
|
||||||
|
/** See SECOND_FACTOR_SETUP. */
|
||||||
|
restriction?: string | null;
|
||||||
|
idleTimeoutSeconds?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedSession {
|
export interface IssuedSession {
|
||||||
@@ -93,6 +98,9 @@ export async function issueSession(
|
|||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
externalSession: options.externalSession ?? null,
|
externalSession: options.externalSession ?? null,
|
||||||
|
maxAgeSeconds: options.maxAgeSeconds ?? null,
|
||||||
|
restriction: options.restriction ?? null,
|
||||||
|
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.rateLimitUsername) {
|
if (options.rateLimitUsername) {
|
||||||
@@ -123,11 +131,14 @@ export async function issueSession(
|
|||||||
method: options.methodId,
|
method: options.methodId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxAge =
|
let maxAge =
|
||||||
options.longLivedForApps &&
|
options.longLivedForApps &&
|
||||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||||
? THIRTY_DAYS_MS
|
? THIRTY_DAYS_MS
|
||||||
: await sessionCookieMaxAge(options.rememberMe);
|
: await sessionCookieMaxAge(options.rememberMe);
|
||||||
|
if (options.maxAgeSeconds) {
|
||||||
|
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
@@ -142,6 +153,7 @@ export async function issueSession(
|
|||||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||||
user.id,
|
user.id,
|
||||||
),
|
),
|
||||||
|
...(options.restriction ? { restriction: options.restriction } : {}),
|
||||||
...(isNativeAppRequest(req) ? { token } : {}),
|
...(isNativeAppRequest(req) ? { token } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ export interface PendingLogin {
|
|||||||
methodId: string;
|
methodId: string;
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
externalSession?: ExternalSessionRef | null;
|
externalSession?: ExternalSessionRef | null;
|
||||||
|
/** Login policy that applied when the login paused. */
|
||||||
|
allowRememberDevice?: boolean;
|
||||||
|
maxSessionSeconds?: number | null;
|
||||||
|
idleTimeoutSeconds?: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
} from "../plugins/user-data.js";
|
} from "../plugins/user-data.js";
|
||||||
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||||
|
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
@@ -105,6 +106,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set("trust proxy", "loopback");
|
app.set("trust proxy", "loopback");
|
||||||
|
app.use(httpsPolicyMiddleware);
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|||||||
@@ -1021,6 +1021,8 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
enforceUniqueUsernames();
|
enforceUniqueUsernames();
|
||||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "restriction", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export const sessions = mysqlTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction"),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: int("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export const sessions = pgTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction"),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: integer("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export const sessions = sqliteTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction", { length: 32 }),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: integer("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ export class SessionRepository {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearRestrictionForUser(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessions)
|
||||||
|
.set({ restriction: null })
|
||||||
|
.where(eq(sessions.userId, userId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
async updateToken(
|
async updateToken(
|
||||||
id: string,
|
id: string,
|
||||||
jwtToken: string,
|
jwtToken: string,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "../../auth/registry.js";
|
} from "../../auth/registry.js";
|
||||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||||
|
import { rememberDeviceAllowed } from "../../auth/policy.js";
|
||||||
|
|
||||||
export interface PublicLoginMethod {
|
export interface PublicLoginMethod {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void {
|
|||||||
* /users/auth/methods:
|
* /users/auth/methods:
|
||||||
* get:
|
* get:
|
||||||
* summary: List login methods
|
* summary: List login methods
|
||||||
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
|
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
|
||||||
* tags:
|
* tags:
|
||||||
* - Auth
|
* - Auth
|
||||||
* responses:
|
* responses:
|
||||||
@@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void {
|
|||||||
*/
|
*/
|
||||||
router.get("/auth/methods", async (_req, res) => {
|
router.get("/auth/methods", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ methods: await listPublicLoginMethods() });
|
res.json({
|
||||||
|
methods: await listPublicLoginMethods(),
|
||||||
|
rememberDevice: rememberDeviceAllowed(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error("Failed to list login methods", error);
|
authLogger.error("Failed to list login methods", error);
|
||||||
res.status(500).json({ error: "Failed to list login methods" });
|
res.status(500).json({ error: "Failed to list login methods" });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { sshLogger } from "../../utils/logger.js";
|
|||||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
|
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||||
|
|
||||||
import { setHostPluginEnabled } from "./host-plugin-settings.js";
|
import { setHostPluginEnabled } from "./host-plugin-settings.js";
|
||||||
|
|
||||||
@@ -141,6 +142,15 @@ export function registerHostBulkRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ownedIds = ownedHosts.map((h) => h.id);
|
const ownedIds = ownedHosts.map((h) => h.id);
|
||||||
|
// Pins stay the user's own on a managed host.
|
||||||
|
if (Object.keys(updates).some((key) => key !== "pin")) {
|
||||||
|
try {
|
||||||
|
assertHostsUnlocked(ownedIds);
|
||||||
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const unauthorizedIds = hostIds.filter(
|
const unauthorizedIds = hostIds.filter(
|
||||||
(id: number) => !ownedIds.includes(id),
|
(id: number) => !ownedIds.includes(id),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "../../hosts/defaults/service.js";
|
} from "../../hosts/defaults/service.js";
|
||||||
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||||
import { readDefaultsReset } from "./host-bulk-routes.js";
|
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||||
|
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||||
|
|
||||||
type HostDefaultsRoutesDeps = {
|
type HostDefaultsRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
@@ -678,6 +679,7 @@ export function registerHostDefaultsRoutes(
|
|||||||
const reset = readDefaultsReset(req.body);
|
const reset = readDefaultsReset(req.body);
|
||||||
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||||
try {
|
try {
|
||||||
|
assertHostsUnlocked([hostId]);
|
||||||
const access = await PermissionManager.getInstance().canAccessHost(
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
@@ -700,6 +702,7 @@ export function registerHostDefaultsRoutes(
|
|||||||
await changeHostOverrides([hostId], reset);
|
await changeHostOverrides([hostId], reset);
|
||||||
res.json({ message: "Host reset to its defaults" });
|
res.json({ message: "Host reset to its defaults" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
databaseLogger.error("Failed to reset host defaults", error);
|
databaseLogger.error("Failed to reset host defaults", error);
|
||||||
res.status(500).json({ error: "Failed to reset host defaults" });
|
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ import {
|
|||||||
folderCredentialIds,
|
folderCredentialIds,
|
||||||
setFolderCredentialDefault,
|
setFolderCredentialDefault,
|
||||||
} from "../../hosts/defaults/service.js";
|
} from "../../hosts/defaults/service.js";
|
||||||
|
import {
|
||||||
|
assertHostsUnlocked,
|
||||||
|
hasHostLocks,
|
||||||
|
sendHostLocked,
|
||||||
|
} from "../../hosts/locks.js";
|
||||||
|
|
||||||
|
/** Refuses a folder change that would touch a locked host. */
|
||||||
|
async function folderHostsUnlocked(
|
||||||
|
userId: string,
|
||||||
|
folderName: string,
|
||||||
|
res: Response,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!hasHostLocks()) return true;
|
||||||
|
const inFolder = await createCurrentHostFolderRepository().listHostsInFolder(
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
assertHostsUnlocked(inFolder.map((host) => host.id));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type HostFolderRoutesDeps = {
|
type HostFolderRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
@@ -86,6 +111,7 @@ export function registerHostFolderRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!(await folderHostsUnlocked(userId, oldName, res))) return;
|
||||||
const { updatedHosts, updatedCredentials } =
|
const { updatedHosts, updatedCredentials } =
|
||||||
await createCurrentHostFolderRepository().renameFolder(
|
await createCurrentHostFolderRepository().renameFolder(
|
||||||
userId,
|
userId,
|
||||||
@@ -417,6 +443,7 @@ export function registerHostFolderRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!(await folderHostsUnlocked(userId, folderName, res))) return;
|
||||||
const hostFolderRepository = createCurrentHostFolderRepository();
|
const hostFolderRepository = createCurrentHostFolderRepository();
|
||||||
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
|
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "../../hosts/ssh-options.js";
|
} from "../../hosts/ssh-options.js";
|
||||||
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
|
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
|
||||||
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
|
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
|
||||||
|
import { hostLock } from "../../hosts/locks.js";
|
||||||
|
|
||||||
export function isNonEmptyString(value: unknown): value is string {
|
export function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
@@ -337,6 +338,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
|||||||
"sshPort",
|
"sshPort",
|
||||||
"protocolAuth",
|
"protocolAuth",
|
||||||
"jumpHosts",
|
"jumpHosts",
|
||||||
|
"lock",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
]);
|
]);
|
||||||
@@ -439,8 +441,10 @@ export function transformHostResponse(
|
|||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const shared = parseSharedSource(host.sharedSource);
|
const shared = parseSharedSource(host.sharedSource);
|
||||||
|
const lock = typeof host.id === "number" ? hostLock(host.id) : null;
|
||||||
return {
|
return {
|
||||||
...host,
|
...host,
|
||||||
|
...(lock ? { lock } : {}),
|
||||||
sharedSource: undefined,
|
sharedSource: undefined,
|
||||||
localOnly: !!host.localOnly,
|
localOnly: !!host.localOnly,
|
||||||
sharedCopy: !!shared,
|
sharedCopy: !!shared,
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ import {
|
|||||||
type PlannedProtocolAuth,
|
type PlannedProtocolAuth,
|
||||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||||
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||||
|
import {
|
||||||
|
manualCreateBlockedBy,
|
||||||
|
requireManualCreate,
|
||||||
|
requireUnlockedHost,
|
||||||
|
} from "../../hosts/locks.js";
|
||||||
import {
|
import {
|
||||||
mergeStoredSshOptions,
|
mergeStoredSshOptions,
|
||||||
parseTerminalConfig,
|
parseTerminalConfig,
|
||||||
@@ -157,6 +162,7 @@ router.post(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requirePermission("hosts.create"),
|
permissionManager.requirePermission("hosts.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
|
requireManualCreate,
|
||||||
requireHostEnrollmentAccessForPath,
|
requireHostEnrollmentAccessForPath,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
@@ -736,6 +742,7 @@ router.put(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requirePermission("hosts.edit"),
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
|
requireUnlockedHost,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = Array.isArray(req.params.id)
|
const hostId = Array.isArray(req.params.id)
|
||||||
@@ -1205,6 +1212,27 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/policy:
|
||||||
|
* get:
|
||||||
|
* summary: Host creation policy
|
||||||
|
* description: Whether hosts can be added by hand, or a plugin manages them all.
|
||||||
|
* tags:
|
||||||
|
* - SSH
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name."
|
||||||
|
*/
|
||||||
|
router.get("/policy", authenticateJWT, (_req: Request, res: Response) => {
|
||||||
|
const blocked = manualCreateBlockedBy();
|
||||||
|
res.json(
|
||||||
|
blocked
|
||||||
|
? { manualCreate: false, blockedBy: blocked.pluginName }
|
||||||
|
: { manualCreate: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /host/db/host:
|
* /host/db/host:
|
||||||
@@ -1702,6 +1730,7 @@ router.delete(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requirePermission("hosts.delete"),
|
permissionManager.requirePermission("hosts.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
|
requireUnlockedHost,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostId = Array.isArray(req.params.id)
|
const hostId = Array.isArray(req.params.id)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
validateSettingsSave,
|
validateSettingsSave,
|
||||||
type PluginSettingsScope,
|
type PluginSettingsScope,
|
||||||
} from "../../plugins/settings.js";
|
} from "../../plugins/settings.js";
|
||||||
|
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||||
import {
|
import {
|
||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
@@ -1878,6 +1879,18 @@ router.put(
|
|||||||
const inherit = Array.isArray(inheritRaw)
|
const inherit = Array.isArray(inheritRaw)
|
||||||
? inheritRaw.filter((key): key is string => typeof key === "string")
|
? inheritRaw.filter((key): key is string => typeof key === "string")
|
||||||
: [];
|
: [];
|
||||||
|
// A managed host keeps its settings; only personal ones stay per user.
|
||||||
|
const changesShared = [...Object.keys(written), ...inherit].some(
|
||||||
|
(key) => !findField(manifest, "host", key)?.personal,
|
||||||
|
);
|
||||||
|
if (changesShared) {
|
||||||
|
try {
|
||||||
|
assertHostsUnlocked([hostId]);
|
||||||
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const errors = await applySettings(
|
const errors = await applySettings(
|
||||||
userId,
|
userId,
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
logAudit,
|
logAudit,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../utils/audit-logger.js";
|
||||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||||
|
import {
|
||||||
|
assertHostsUnlocked,
|
||||||
|
hasHostLocks,
|
||||||
|
requireUnlockedHost,
|
||||||
|
sendHostLocked,
|
||||||
|
} from "../../hosts/locks.js";
|
||||||
import {
|
import {
|
||||||
SharedHostAuthOverrideService,
|
SharedHostAuthOverrideService,
|
||||||
SharedHostAuthOverrideServiceError,
|
SharedHostAuthOverrideServiceError,
|
||||||
@@ -229,6 +235,7 @@ router.post(
|
|||||||
"/host/:id/share",
|
"/host/:id/share",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requirePermission("hosts.share"),
|
permissionManager.requirePermission("hosts.share"),
|
||||||
|
requireUnlockedHost,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const hostId = parseInt(id, 10);
|
const hostId = parseInt(id, 10);
|
||||||
@@ -451,6 +458,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (hasHostLocks()) {
|
||||||
|
const inFolder =
|
||||||
|
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||||
|
userId,
|
||||||
|
folder,
|
||||||
|
);
|
||||||
|
assertHostsUnlocked(inFolder.map((host) => host.id));
|
||||||
|
}
|
||||||
const targets = parseShareTargets(req.body ?? {});
|
const targets = parseShareTargets(req.body ?? {});
|
||||||
if (!targets) {
|
if (!targets) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -613,6 +628,7 @@ router.post(
|
|||||||
hostResults,
|
hostResults,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
databaseLogger.error("Failed to share folder", error, {
|
databaseLogger.error("Failed to share folder", error, {
|
||||||
operation: "share_folder",
|
operation: "share_folder",
|
||||||
folder,
|
folder,
|
||||||
@@ -786,6 +802,7 @@ router.delete(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/host/:id/access/:accessId",
|
"/host/:id/access/:accessId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireUnlockedHost,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const accessIdParam = Array.isArray(req.params.accessId)
|
const accessIdParam = Array.isArray(req.params.accessId)
|
||||||
@@ -908,6 +925,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/host/:id/access/:accessId",
|
"/host/:id/access/:accessId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireUnlockedHost,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const accessIdParam = Array.isArray(req.params.accessId)
|
const accessIdParam = Array.isArray(req.params.accessId)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { UserRecord } from "../repositories/user-repository.js";
|
import type { UserRecord } from "../repositories/user-repository.js";
|
||||||
|
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
|
||||||
|
import { getTlsConfig } from "../../tls/tls-service.js";
|
||||||
|
|
||||||
async function getAdminActor(
|
async function getAdminActor(
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
@@ -187,6 +189,90 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/https-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Get the HTTPS redirect setting (admin only)
|
||||||
|
* description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: forceHttps and tlsEnabled.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
*/
|
||||||
|
router.get("/https-settings", authenticateJWT, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
forceHttps: await isForceHttpsEnabled(),
|
||||||
|
tlsEnabled: getTlsConfig().enabled,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get HTTPS settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to get HTTPS settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/https-settings:
|
||||||
|
* patch:
|
||||||
|
* summary: Update the HTTPS redirect setting (admin only)
|
||||||
|
* description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* forceHttps:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Setting updated.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid value.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
*/
|
||||||
|
router.patch("/https-settings", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { forceHttps } = req.body ?? {};
|
||||||
|
if (typeof forceHttps !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "forceHttps must be a boolean" });
|
||||||
|
}
|
||||||
|
await setForceHttps(forceHttps);
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_force_https",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ forceHttps }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to set HTTPS settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to set HTTPS settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
||||||
* One route pair per private endpoint allowlist, each with its own setting key.
|
* One route pair per private endpoint allowlist, each with its own setting key.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
syncSharedCredentialsForUserRoles,
|
syncSharedCredentialsForUserRoles,
|
||||||
} from "../../auth/session-issuer.js";
|
} from "../../auth/session-issuer.js";
|
||||||
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
|
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
|
||||||
|
import { evaluateAuthPolicy } from "../../auth/policy.js";
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
@@ -420,9 +421,21 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
userRecord.id,
|
userRecord.id,
|
||||||
"trusted_proxy_login_role_shared_credentials",
|
"trusted_proxy_login_role_shared_credentials",
|
||||||
);
|
);
|
||||||
|
// The proxy owns second factors, so only the session length applies.
|
||||||
|
const policy = await evaluateAuthPolicy({
|
||||||
|
userId: userRecord.id,
|
||||||
|
methodId: "proxy",
|
||||||
|
external: true,
|
||||||
|
deviceType: deviceInfo.type,
|
||||||
|
trustedDevice: false,
|
||||||
|
mfaSatisfied: false,
|
||||||
|
secondFactors: [],
|
||||||
|
});
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
|
maxAgeSeconds: policy.maxSessionSeconds,
|
||||||
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||||
});
|
});
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
@@ -443,7 +456,16 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return res
|
return res
|
||||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req))
|
.cookie(
|
||||||
|
"jwt",
|
||||||
|
token,
|
||||||
|
authManager.getSecureCookieOptions(
|
||||||
|
req,
|
||||||
|
policy.maxSessionSeconds
|
||||||
|
? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000)
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
.json({
|
.json({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -614,6 +636,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
user.id,
|
user.id,
|
||||||
),
|
),
|
||||||
linked: await describeDesktopLink(user.id),
|
linked: await describeDesktopLink(user.id),
|
||||||
|
// "second-factor-setup" while the login policy wants a factor set up.
|
||||||
|
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get username", err);
|
authLogger.error("Failed to get username", err);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
isValidPort,
|
isValidPort,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
} from "../../database/routes/host-normalizers.js";
|
} from "../../database/routes/host-normalizers.js";
|
||||||
|
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||||
|
|
||||||
/** A request the caller got wrong. Routes answer it with a 400. */
|
/** A request the caller got wrong. Routes answer it with a 400. */
|
||||||
export class ImportError extends Error {
|
export class ImportError extends Error {
|
||||||
@@ -263,6 +264,19 @@ export function importResponse(results: ImportResults) {
|
|||||||
|
|
||||||
export const MAX_IMPORT_HOSTS = 100;
|
export const MAX_IMPORT_HOSTS = 100;
|
||||||
|
|
||||||
|
/** Why an import row can't be written while a plugin manages hosts, if it can't. */
|
||||||
|
function importLockError(existingId: number | undefined): string | null {
|
||||||
|
if (!hasHostLocks()) return null;
|
||||||
|
if (existingId === undefined) {
|
||||||
|
const blocked = manualCreateBlockedBy();
|
||||||
|
return blocked
|
||||||
|
? `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const lock = hostLock(existingId);
|
||||||
|
return lock ? `This host is managed by ${lock.pluginName}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Deletes what an atomic import created, newest first. */
|
/** Deletes what an atomic import created, newest first. */
|
||||||
async function rollBackCreated(
|
async function rollBackCreated(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -446,6 +460,12 @@ export async function runBulkImport(
|
|||||||
results.skipped++;
|
results.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const locked = importLockError(existing?.id);
|
||||||
|
if (locked) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: ${locked}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
effectiveConnectionType === "ssh" &&
|
effectiveConnectionType === "ssh" &&
|
||||||
@@ -745,6 +765,12 @@ export async function runSshConfigImport(
|
|||||||
results.skipped++;
|
results.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const locked = importLockError(existing?.id);
|
||||||
|
if (locked) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host "${parsed[i].name}": ${locked}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Host locks: hosts a plugin manages itself (for example from a config file)
|
||||||
|
* through ctx.hosts.locks.provide. Core refuses edits, deletes and share
|
||||||
|
* changes on a locked host everywhere, except from the plugin that locks it,
|
||||||
|
* and a provider can block adding hosts by hand. Pins and order stay with the
|
||||||
|
* user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginHostLockProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
export interface HostLockInfo {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
provider: PluginHostLockProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = new Map<symbol, Entry>();
|
||||||
|
|
||||||
|
export class HostLockedError extends Error {
|
||||||
|
readonly status = 403;
|
||||||
|
readonly code = "HOST_LOCKED";
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly lock: { pluginId: string; pluginName: string },
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HostLockedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provideHostLocks(
|
||||||
|
pluginId: string,
|
||||||
|
pluginName: string,
|
||||||
|
provider: PluginHostLockProvider,
|
||||||
|
): () => void {
|
||||||
|
const key = Symbol(pluginId);
|
||||||
|
providers.set(key, { pluginId, pluginName, provider });
|
||||||
|
return () => {
|
||||||
|
providers.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function clearHostLocks(): void {
|
||||||
|
providers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider that throws never locks: a broken plugin must not freeze hosts.
|
||||||
|
function safely<T>(entry: Entry, fallback: T, read: () => T): T {
|
||||||
|
try {
|
||||||
|
return read();
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.warn("Host lock provider failed", {
|
||||||
|
operation: "host_lock",
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostLock(
|
||||||
|
hostId: number,
|
||||||
|
exemptPluginId?: string,
|
||||||
|
): HostLockInfo | null {
|
||||||
|
if (!Number.isInteger(hostId)) return null;
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
if (entry.pluginId === exemptPluginId) continue;
|
||||||
|
if (!safely(entry, false, () => entry.provider.isLocked(hostId))) continue;
|
||||||
|
return {
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
pluginName: entry.pluginName,
|
||||||
|
note: safely(entry, null, () => entry.provider.note(hostId) ?? null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHostLocks(): boolean {
|
||||||
|
return providers.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws HostLockedError for the first locked host. */
|
||||||
|
export function assertHostsUnlocked(
|
||||||
|
hostIds: Iterable<number>,
|
||||||
|
exemptPluginId?: string,
|
||||||
|
): void {
|
||||||
|
if (providers.size === 0) return;
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
const lock = hostLock(Number(hostId), exemptPluginId);
|
||||||
|
if (lock) {
|
||||||
|
throw new HostLockedError(
|
||||||
|
`This host is managed by ${lock.pluginName} and can't be changed here`,
|
||||||
|
lock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugin that blocks adding hosts by hand, if any. */
|
||||||
|
export function manualCreateBlockedBy(
|
||||||
|
exemptPluginId?: string,
|
||||||
|
): { pluginId: string; pluginName: string } | null {
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
if (entry.pluginId === exemptPluginId) continue;
|
||||||
|
if (!safely(entry, true, () => entry.provider.allowManualCreate())) {
|
||||||
|
return { pluginId: entry.pluginId, pluginName: entry.pluginName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertManualCreateAllowed(exemptPluginId?: string): void {
|
||||||
|
const blocked = manualCreateBlockedBy(exemptPluginId);
|
||||||
|
if (blocked) {
|
||||||
|
throw new HostLockedError(
|
||||||
|
`Hosts are managed by ${blocked.pluginName}, so new hosts can't be added here`,
|
||||||
|
blocked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Res = { status: (code: number) => { json: (body: unknown) => unknown } };
|
||||||
|
type Next = () => void;
|
||||||
|
|
||||||
|
/** Route guard for creating hosts by hand. */
|
||||||
|
export function requireManualCreate(_req: unknown, res: Res, next: Next) {
|
||||||
|
try {
|
||||||
|
assertManualCreateAllowed();
|
||||||
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route guard for routes that change the host named by :id. */
|
||||||
|
export function requireUnlockedHost(
|
||||||
|
req: { params: Record<string, string | string[]> },
|
||||||
|
res: Res,
|
||||||
|
next: Next,
|
||||||
|
) {
|
||||||
|
const raw = req.params.id;
|
||||||
|
const hostId = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||||
|
try {
|
||||||
|
assertHostsUnlocked([hostId]);
|
||||||
|
} catch (error) {
|
||||||
|
if (sendHostLocked(res, error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a HostLockedError as a 403. False when the error is something else. */
|
||||||
|
export function sendHostLocked(
|
||||||
|
res: { status: (code: number) => { json: (body: unknown) => unknown } },
|
||||||
|
error: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (!(error instanceof HostLockedError)) return false;
|
||||||
|
res.status(403).json({
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
lockedBy: error.lock.pluginId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -150,6 +150,37 @@ export function createPluginCredentials({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
list: async () => {
|
||||||
|
try {
|
||||||
|
await assertCapability(pluginId, "credentials:use", declared);
|
||||||
|
const userId = actingUser();
|
||||||
|
await requireCorePermission(userId, "credentials.view");
|
||||||
|
const { createCurrentCredentialRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const rows =
|
||||||
|
await createCurrentCredentialRepository().listDecryptedByUserId(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const list = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username ?? null,
|
||||||
|
authType: String(row.authType ?? ""),
|
||||||
|
folder: row.folder ?? null,
|
||||||
|
}));
|
||||||
|
await audit("credentials_list", `${list.length} credentials`, {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
} catch (error) {
|
||||||
|
await audit("credentials_list", "saved credentials", {
|
||||||
|
success: false,
|
||||||
|
errorMessage: errorText(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createSshKey: async (input) => {
|
createSshKey: async (input) => {
|
||||||
const details = `SSH key "${input?.name ?? ""}"`;
|
const details = `SSH key "${input?.name ?? ""}"`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
PluginShareableUser,
|
PluginShareableUser,
|
||||||
PluginShareableRole,
|
PluginShareableRole,
|
||||||
PluginHostJumpHost,
|
PluginHostJumpHost,
|
||||||
|
PluginHostShareGrant,
|
||||||
} from "@termix-ssh/plugin-sdk/backend";
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
|
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
|
||||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
@@ -35,6 +36,11 @@ import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
|||||||
import type { DisposableBag } from "./disposables.js";
|
import type { DisposableBag } from "./disposables.js";
|
||||||
import { pluginEvents, TOPICS } from "./events.js";
|
import { pluginEvents, TOPICS } from "./events.js";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
assertHostsUnlocked,
|
||||||
|
assertManualCreateAllowed,
|
||||||
|
provideHostLocks,
|
||||||
|
} from "../hosts/locks.js";
|
||||||
|
|
||||||
type AuditFn = (
|
type AuditFn = (
|
||||||
action: string,
|
action: string,
|
||||||
@@ -207,6 +213,8 @@ void inputKeysComplete;
|
|||||||
export function toHostWrite(input: PluginHostUpdateInput): {
|
export function toHostWrite(input: PluginHostUpdateInput): {
|
||||||
row: Record<string, unknown>;
|
row: Record<string, unknown>;
|
||||||
settings: Record<string, Record<string, unknown>>;
|
settings: Record<string, Record<string, unknown>>;
|
||||||
|
protocolAuth: Record<string, unknown> | null;
|
||||||
|
pluginEnable: Record<string, boolean>;
|
||||||
} {
|
} {
|
||||||
const unknown = Object.keys(input ?? {}).filter(
|
const unknown = Object.keys(input ?? {}).filter(
|
||||||
(key) => !INPUT_KEYS.has(key),
|
(key) => !INPUT_KEYS.has(key),
|
||||||
@@ -268,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
|||||||
? null
|
? null
|
||||||
: JSON.stringify(parseSshOptions(input.sshOptions)),
|
: JSON.stringify(parseSshOptions(input.sshOptions)),
|
||||||
);
|
);
|
||||||
|
for (const key of [
|
||||||
|
"password",
|
||||||
|
"key",
|
||||||
|
"keyPassword",
|
||||||
|
"keyType",
|
||||||
|
"sudoPassword",
|
||||||
|
] as const) {
|
||||||
|
const value = input[key];
|
||||||
|
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||||
|
throw new PluginHostInputError(`${key} must be a string`);
|
||||||
|
}
|
||||||
|
set(key, value === undefined ? undefined : value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocolAuth: Record<string, unknown> | null = null;
|
||||||
|
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
|
||||||
|
if (
|
||||||
|
typeof input.protocolAuth !== "object" ||
|
||||||
|
Array.isArray(input.protocolAuth)
|
||||||
|
) {
|
||||||
|
throw new PluginHostInputError(
|
||||||
|
"protocolAuth must map protocol ids to their logins",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protocolAuth = input.protocolAuth as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const settings: Record<string, Record<string, unknown>> = {};
|
const settings: Record<string, Record<string, unknown>> = {};
|
||||||
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
||||||
@@ -288,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
|||||||
settings[pluginId] = values;
|
settings[pluginId] = values;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { row, settings };
|
const pluginEnable: Record<string, boolean> = {};
|
||||||
|
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
|
||||||
|
if (
|
||||||
|
typeof input.pluginEnable !== "object" ||
|
||||||
|
Array.isArray(input.pluginEnable)
|
||||||
|
) {
|
||||||
|
throw new PluginHostInputError(
|
||||||
|
"pluginEnable must map plugin ids to true or false",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
throw new PluginHostInputError(
|
||||||
|
`pluginEnable.${pluginId} must be true or false`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pluginEnable[pluginId] = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row, settings, protocolAuth, pluginEnable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folds pluginEnable into each plugin's settings under its enableKey. */
|
||||||
|
async function foldPluginEnable(
|
||||||
|
settings: Record<string, Record<string, unknown>>,
|
||||||
|
pluginEnable: Record<string, boolean>,
|
||||||
|
): Promise<Record<string, Record<string, unknown>>> {
|
||||||
|
const ids = Object.keys(pluginEnable);
|
||||||
|
if (ids.length === 0) return settings;
|
||||||
|
const { hostSettingsPlugins } =
|
||||||
|
await import("../database/routes/host-plugin-settings.js");
|
||||||
|
const running = hostSettingsPlugins();
|
||||||
|
const out = { ...settings };
|
||||||
|
for (const pluginId of ids) {
|
||||||
|
const manifest = running.find((m) => m.id === pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
|
||||||
|
out[pluginId] = { ...out[pluginId] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const enableKey = manifest.contributes?.settings?.host?.enableKey;
|
||||||
|
if (!enableKey) {
|
||||||
|
throw new PluginHostInputError(`${pluginId} has no host switch`);
|
||||||
|
}
|
||||||
|
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
|
||||||
|
async function writeHostProtocolAuth(
|
||||||
|
ownerId: string,
|
||||||
|
hostId: number,
|
||||||
|
protocolAuth: Record<string, unknown> | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!protocolAuth) return;
|
||||||
|
const { readProtocolAuthPayload, writeProtocolAuth } =
|
||||||
|
await import("../hosts/protocol-auth/protocol-auth.js");
|
||||||
|
const patch = readProtocolAuthPayload({ protocolAuth });
|
||||||
|
if (!patch) return;
|
||||||
|
const { findUsableCredential } =
|
||||||
|
await import("../hosts/usable-credential.js");
|
||||||
|
for (const [protocol, login] of patch) {
|
||||||
|
const credentialId = Number(login?.credentialId);
|
||||||
|
if (
|
||||||
|
Number.isInteger(credentialId) &&
|
||||||
|
credentialId > 0 &&
|
||||||
|
!(await findUsableCredential(credentialId, ownerId))
|
||||||
|
) {
|
||||||
|
throw new PluginHostInputError(
|
||||||
|
`protocolAuth.${protocol}: credential ${credentialId} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
|
||||||
|
|
||||||
|
/** Upserts one grant and snapshots the host's secrets for it. */
|
||||||
|
async function grantHostShare(
|
||||||
|
hostId: number,
|
||||||
|
ownerId: string,
|
||||||
|
grantedBy: string,
|
||||||
|
target: PluginShareTarget,
|
||||||
|
permissionLevel: PluginHostShareLevel,
|
||||||
|
expiresAt: string | null,
|
||||||
|
): Promise<number> {
|
||||||
|
const { createCurrentRbacAccessRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
|
||||||
|
hostId,
|
||||||
|
grantedBy,
|
||||||
|
permissionLevel,
|
||||||
|
expiresAt,
|
||||||
|
...(target.type === "user"
|
||||||
|
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||||
|
: { targetType: "role" as const, targetRoleId: target.id as number }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
|
try {
|
||||||
|
if (target.type === "user") {
|
||||||
|
await secretsManager.snapshotForUser(
|
||||||
|
grant.id,
|
||||||
|
hostId,
|
||||||
|
target.id as string,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await secretsManager.snapshotForRole(
|
||||||
|
grant.id,
|
||||||
|
hostId,
|
||||||
|
target.id as number,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A snapshot failure never blocks the grant: the recipient can
|
||||||
|
// still be prompted, or the owner can retry sharing later.
|
||||||
|
}
|
||||||
|
return grant.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursFromNow(durationHours: number | undefined): string | null {
|
||||||
|
return durationHours && durationHours > 0
|
||||||
|
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function actingUser(): string {
|
function actingUser(): string {
|
||||||
@@ -476,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
|
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
|
||||||
try {
|
try {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
|
assertManualCreateAllowed(pluginId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -484,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const userId = actingUser();
|
const userId = actingUser();
|
||||||
const { row, settings } = toHostWrite(host);
|
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
|
||||||
const writes = await checkSettings(settings);
|
const writes = await checkSettings(
|
||||||
|
await foldPluginEnable(settings, pluginEnable),
|
||||||
|
);
|
||||||
const { createCurrentHostRepository } =
|
const { createCurrentHostRepository } =
|
||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||||
@@ -504,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
userId,
|
userId,
|
||||||
columns,
|
columns,
|
||||||
);
|
);
|
||||||
|
await writeHostProtocolAuth(userId, created.id, protocolAuth);
|
||||||
await applyDefaultsAfterHostWrite(created.id);
|
await applyDefaultsAfterHostWrite(created.id);
|
||||||
await writeSettings(created.id, writes);
|
await writeSettings(created.id, writes);
|
||||||
await markSettingsOwn(created.id, writes);
|
await markSettingsOwn(created.id, writes);
|
||||||
@@ -520,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
): Promise<PluginHostRecord | null> => {
|
): Promise<PluginHostRecord | null> => {
|
||||||
try {
|
try {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
|
assertHostsUnlocked([hostId], pluginId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await audit("hosts_update", `host ${hostId}`, {
|
await audit("hosts_update", `host ${hostId}`, {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -528,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const userId = actingUser();
|
const userId = actingUser();
|
||||||
const { row, settings } = toHostWrite(patch);
|
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
|
||||||
const writes = await checkSettings(settings);
|
const writes = await checkSettings(
|
||||||
|
await foldPluginEnable(settings, pluginEnable),
|
||||||
|
);
|
||||||
const { createCurrentHostRepository } =
|
const { createCurrentHostRepository } =
|
||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||||
@@ -560,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
columns,
|
columns,
|
||||||
);
|
);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
await writeHostProtocolAuth(userId, hostId, protocolAuth);
|
||||||
await writeSettings(hostId, writes);
|
await writeSettings(hostId, writes);
|
||||||
await markSettingsOwn(hostId, writes);
|
await markSettingsOwn(hostId, writes);
|
||||||
await applyDefaultsAfterHostWrite(hostId, {
|
await applyDefaultsAfterHostWrite(hostId, {
|
||||||
@@ -584,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
delete: async (hostId: number): Promise<boolean> => {
|
delete: async (hostId: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
|
assertHostsUnlocked([hostId], pluginId);
|
||||||
const userId = actingUser();
|
const userId = actingUser();
|
||||||
const { PermissionManager } =
|
const { PermissionManager } =
|
||||||
await import("../utils/permission-manager.js");
|
await import("../utils/permission-manager.js");
|
||||||
@@ -631,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
): Promise<PluginHostShareResult> => {
|
): Promise<PluginHostShareResult> => {
|
||||||
try {
|
try {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
|
assertHostsUnlocked([hostId], pluginId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await audit("hosts_share", `host ${hostId}`, {
|
await audit("hosts_share", `host ${hostId}`, {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -660,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { createCurrentHostResolutionRepository } =
|
||||||
createCurrentHostResolutionRepository,
|
await import("../database/repositories/factory.js");
|
||||||
createCurrentRbacAccessRepository,
|
|
||||||
} = await import("../database/repositories/factory.js");
|
|
||||||
const ownerId =
|
const ownerId =
|
||||||
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||||
hostId,
|
hostId,
|
||||||
@@ -677,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
return { hostId, shared: false, reason: "owner" };
|
return { hostId, shared: false, reason: "owner" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt =
|
const expiresAt = hoursFromNow(durationHours);
|
||||||
durationHours && durationHours > 0
|
|
||||||
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
|
||||||
const { SharedHostSecretsManager } =
|
|
||||||
await import("../utils/shared-host-secrets-manager.js");
|
|
||||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const grant = await rbacAccessRepository.upsertHostAccess({
|
await grantHostShare(
|
||||||
hostId,
|
hostId,
|
||||||
grantedBy: userId,
|
ownerId,
|
||||||
|
userId,
|
||||||
|
target,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
...(target.type === "user"
|
);
|
||||||
? { targetType: "user" as const, targetUserId: target.id as string }
|
|
||||||
: {
|
|
||||||
targetType: "role" as const,
|
|
||||||
targetRoleId: target.id as number,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (target.type === "user") {
|
|
||||||
await secretsManager.snapshotForUser(
|
|
||||||
grant.id,
|
|
||||||
hostId,
|
|
||||||
target.id as string,
|
|
||||||
ownerId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await secretsManager.snapshotForRole(
|
|
||||||
grant.id,
|
|
||||||
hostId,
|
|
||||||
target.id as number,
|
|
||||||
ownerId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// A snapshot failure never blocks the grant: the recipient can
|
|
||||||
// still be prompted, or the owner can retry sharing later.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await audit("hosts_share", `host ${hostId}`, { success: true });
|
await audit("hosts_share", `host ${hostId}`, { success: true });
|
||||||
return { hostId, shared: true };
|
return { hostId, shared: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setShares: async (
|
||||||
|
hostId: number,
|
||||||
|
grants: PluginHostShareGrant[],
|
||||||
|
): Promise<PluginHostShareResult> => {
|
||||||
|
const fail = async (reason: string): Promise<PluginHostShareResult> => {
|
||||||
|
await audit("hosts_set_shares", `host ${hostId}`, {
|
||||||
|
success: false,
|
||||||
|
errorMessage: reason,
|
||||||
|
});
|
||||||
|
return { hostId, shared: false, reason };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await requireWrite();
|
||||||
|
assertHostsUnlocked([hostId], pluginId);
|
||||||
|
} catch (error) {
|
||||||
|
await fail(error instanceof Error ? error.message : String(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(grants)) {
|
||||||
|
throw new PluginHostInputError("grants must be a list");
|
||||||
|
}
|
||||||
|
for (const grant of grants) {
|
||||||
|
const type = grant?.target?.type;
|
||||||
|
if (
|
||||||
|
(type !== "user" && type !== "role") ||
|
||||||
|
!SHARE_LEVELS.includes(grant.permissionLevel)
|
||||||
|
) {
|
||||||
|
throw new PluginHostInputError(
|
||||||
|
"Each grant needs a user or role target and a share level",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = actingUser();
|
||||||
|
const {
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
} = await import("../database/repositories/factory.js");
|
||||||
|
const ownerId =
|
||||||
|
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
|
||||||
|
if (ownerId !== userId) return fail("forbidden");
|
||||||
|
if (
|
||||||
|
grants.some((g) => g.target.type === "user" && g.target.id === userId)
|
||||||
|
) {
|
||||||
|
return fail("owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = new Set<number>();
|
||||||
|
for (const grant of grants) {
|
||||||
|
kept.add(
|
||||||
|
await grantHostShare(
|
||||||
|
hostId,
|
||||||
|
ownerId,
|
||||||
|
userId,
|
||||||
|
grant.target,
|
||||||
|
grant.permissionLevel,
|
||||||
|
hoursFromNow(grant.durationHours),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const repository = createCurrentRbacAccessRepository();
|
||||||
|
for (const existing of await repository.listHostAccess(hostId)) {
|
||||||
|
if (!kept.has(existing.id)) {
|
||||||
|
await repository.revokeHostAccess(existing.id, hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { pruneHostGrants } = await import("../database/routes/rbac.js");
|
||||||
|
await pruneHostGrants(hostId);
|
||||||
|
|
||||||
|
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
|
||||||
|
return { hostId, shared: true };
|
||||||
|
},
|
||||||
|
|
||||||
listUsers: async (): Promise<PluginShareableUser[]> => {
|
listUsers: async (): Promise<PluginShareableUser[]> => {
|
||||||
try {
|
try {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
@@ -810,6 +1020,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
return hostSessionStatus.hasActiveSession(hostId);
|
return hostSessionStatus.hasActiveSession(hostId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
locks: {
|
||||||
|
provide: (provider) => {
|
||||||
|
if (!declared.includes("hosts:lock")) {
|
||||||
|
throw capabilityRefused(pluginId, "hosts:lock");
|
||||||
|
}
|
||||||
|
const remove = provideHostLocks(
|
||||||
|
pluginId,
|
||||||
|
manifest.name || pluginId,
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
void audit("host_locks_provide", `${pluginId} manages hosts`, {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
const drop = bag?.add(remove, "host lock provider");
|
||||||
|
return () => {
|
||||||
|
drop?.();
|
||||||
|
remove();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
activity: {
|
activity: {
|
||||||
provide: (provider) => {
|
provide: (provider) => {
|
||||||
if (!declared.includes("hosts:activity-provide")) {
|
if (!declared.includes("hosts:activity-provide")) {
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
|
|||||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||||
import {
|
import {
|
||||||
getLoginMethod,
|
getLoginMethod,
|
||||||
|
listSecondFactors,
|
||||||
registerLoginMethod,
|
registerLoginMethod,
|
||||||
registerSecondFactor,
|
registerSecondFactor,
|
||||||
} from "../auth/registry.js";
|
} from "../auth/registry.js";
|
||||||
|
import { provideAuthPolicy } from "../auth/policy.js";
|
||||||
import type { VerifiedIdentity } from "../auth/types.js";
|
import type { VerifiedIdentity } from "../auth/types.js";
|
||||||
import type {
|
import type {
|
||||||
MutableConnectConfig,
|
MutableConnectConfig,
|
||||||
@@ -856,6 +858,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
|||||||
getActor() === userId ? getActorSessionId() : undefined,
|
getActor() === userId ? getActorSessionId() : undefined,
|
||||||
);
|
);
|
||||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||||
|
// The session that set it up may have been a setup only one.
|
||||||
|
await createCurrentSessionRepository().clearRestrictionForUser(userId);
|
||||||
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
|
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
@@ -934,5 +938,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
|||||||
},
|
},
|
||||||
issueUserToken: (userId, options) =>
|
issueUserToken: (userId, options) =>
|
||||||
issueUserToken(manifest, userId, options),
|
issueUserToken(manifest, userId, options),
|
||||||
|
|
||||||
|
policy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
if (!declared.includes("auth:policy")) {
|
||||||
|
throw capabilityRefused(pluginId, "auth:policy");
|
||||||
|
}
|
||||||
|
const remove = provideAuthPolicy(pluginId, provider);
|
||||||
|
void audit("auth_policy_provide", `${pluginId} sets login rules`, {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
const drop = bag.add(remove, "login policy");
|
||||||
|
return () => {
|
||||||
|
drop();
|
||||||
|
remove();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
listSecondFactors: () =>
|
||||||
|
listSecondFactors().map((factor) => ({
|
||||||
|
id: factor.id,
|
||||||
|
pluginId: factor.pluginId,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
|||||||
import { getEntity } from "../../plugins/sync-registry.js";
|
import { getEntity } from "../../plugins/sync-registry.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { syncLogger } from "../../utils/logger.js";
|
import { syncLogger } from "../../utils/logger.js";
|
||||||
|
import {
|
||||||
|
hasHostLocks,
|
||||||
|
hostLock,
|
||||||
|
manualCreateBlockedBy,
|
||||||
|
} from "../../hosts/locks.js";
|
||||||
|
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
createResolvers,
|
createResolvers,
|
||||||
deleteStoredRow,
|
deleteStoredRow,
|
||||||
@@ -48,6 +54,22 @@ export type PushResult =
|
|||||||
|
|
||||||
export const MAX_PUSH_OPS = 500;
|
export const MAX_PUSH_OPS = 500;
|
||||||
|
|
||||||
|
/** A host a plugin manages can't be changed from a desktop either. */
|
||||||
|
async function hostPushLocked(
|
||||||
|
userId: string,
|
||||||
|
syncId: string,
|
||||||
|
exists: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!hasHostLocks()) return false;
|
||||||
|
if (!exists) return manualCreateBlockedBy() !== null;
|
||||||
|
const hostId =
|
||||||
|
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
|
||||||
|
syncId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function isPushOp(value: unknown): value is PushOp {
|
function isPushOp(value: unknown): value is PushOp {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const op = value as Record<string, unknown>;
|
const op = value as Record<string, unknown>;
|
||||||
@@ -166,6 +188,13 @@ export async function applyPush(
|
|||||||
results.set(key, { ...base, status: "rejected", reason: "permission" });
|
results.set(key, { ...base, status: "rejected", reason: "permission" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
op.entityType === "hosts" &&
|
||||||
|
(await hostPushLocked(userId, op.syncId, exists))
|
||||||
|
) {
|
||||||
|
results.set(key, { ...base, status: "rejected", reason: "locked" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (op.op === "delete") {
|
if (op.op === "delete") {
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
|
|||||||
const { registerLoginMethod, registerSecondFactor } =
|
const { registerLoginMethod, registerSecondFactor } =
|
||||||
await import("../../auth/registry.js");
|
await import("../../auth/registry.js");
|
||||||
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
|
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
|
||||||
|
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
|
||||||
|
await import("../../auth/policy.js");
|
||||||
|
|
||||||
const PASSWORD = "correct-horse";
|
const PASSWORD = "correct-horse";
|
||||||
// Stands in for the totp plugin's factor: core only sees the registration.
|
// Stands in for the totp plugin's factor: core only sees the registration.
|
||||||
@@ -137,6 +139,7 @@ beforeEach(() => {
|
|||||||
delete process.env.ALLOW_PASSWORD_LOGIN;
|
delete process.env.ALLOW_PASSWORD_LOGIN;
|
||||||
disposeTotp?.();
|
disposeTotp?.();
|
||||||
registerTotpFixture();
|
registerTotpFixture();
|
||||||
|
clearAuthPolicies();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
|
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
|
||||||
@@ -891,3 +894,219 @@ describe("lockout guard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("login policy", () => {
|
||||||
|
function enrolTotp() {
|
||||||
|
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedLogin(token: string) {
|
||||||
|
const req = fakeRequest({
|
||||||
|
body: { username: "alice", password: PASSWORD },
|
||||||
|
cookies: { [TRUST_DEVICE_COOKIE]: token },
|
||||||
|
});
|
||||||
|
return runLogin(
|
||||||
|
req as never,
|
||||||
|
{ kind: "user", userId: "u1" },
|
||||||
|
{ methodId: "password", rememberMe: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hands the provider the user's factors and the device", async () => {
|
||||||
|
addUser();
|
||||||
|
enrolTotp();
|
||||||
|
const evaluate = vi.fn(() => ({}));
|
||||||
|
provideAuthPolicy("policy", { evaluate });
|
||||||
|
await passwordLogin({ username: "alice", password: PASSWORD });
|
||||||
|
expect(evaluate).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
methodId: "password",
|
||||||
|
external: false,
|
||||||
|
deviceType: "web",
|
||||||
|
trustedDevice: false,
|
||||||
|
mfaSatisfied: false,
|
||||||
|
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns remember me off and caps the session", async () => {
|
||||||
|
addUser();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
|
||||||
|
});
|
||||||
|
const res = await passwordLogin({
|
||||||
|
username: "alice",
|
||||||
|
password: PASSWORD,
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
|
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
|
||||||
|
);
|
||||||
|
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
|
||||||
|
600 * 1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the idle timeout on the session", async () => {
|
||||||
|
addUser();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ idleTimeoutSeconds: 900 }),
|
||||||
|
});
|
||||||
|
provideAuthPolicy("other", {
|
||||||
|
evaluate: () => ({ idleTimeoutSeconds: 300 }),
|
||||||
|
});
|
||||||
|
await passwordLogin({ username: "alice", password: PASSWORD });
|
||||||
|
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ idleTimeoutSeconds: 300 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a remembered device when remember me is off", async () => {
|
||||||
|
addUser();
|
||||||
|
enrolTotp();
|
||||||
|
const token = "c".repeat(64);
|
||||||
|
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ allowRememberDevice: false }),
|
||||||
|
});
|
||||||
|
const result = await trustedLogin(token);
|
||||||
|
expect(result.kind).toBe("second-factor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the factor on a remembered device with always, not with new-device", async () => {
|
||||||
|
addUser();
|
||||||
|
enrolTotp();
|
||||||
|
const token = "d".repeat(64);
|
||||||
|
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
|
||||||
|
|
||||||
|
const remove = provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ secondFactor: "always" }),
|
||||||
|
});
|
||||||
|
expect((await trustedLogin(token)).kind).toBe("second-factor");
|
||||||
|
remove();
|
||||||
|
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ secondFactor: "new-device" }),
|
||||||
|
});
|
||||||
|
expect((await trustedLogin(token)).kind).toBe("session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a user without a factor a setup only session", async () => {
|
||||||
|
addUser();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ secondFactor: "always" }),
|
||||||
|
});
|
||||||
|
const res = await passwordLogin({ username: "alice", password: PASSWORD });
|
||||||
|
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
|
||||||
|
);
|
||||||
|
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a verified passkey satisfy a required factor", async () => {
|
||||||
|
addUser();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ secondFactor: "always" }),
|
||||||
|
});
|
||||||
|
const result = await runLogin(
|
||||||
|
fakeRequest() as never,
|
||||||
|
{ kind: "user", userId: "u1", mfaSatisfied: true },
|
||||||
|
{ methodId: "passkey", rememberMe: false },
|
||||||
|
);
|
||||||
|
expect(result.kind).toBe("session");
|
||||||
|
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ restriction: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs factors for SSO logins when the policy requires one", async () => {
|
||||||
|
const dispose = registerLoginMethod({
|
||||||
|
id: "oidc",
|
||||||
|
pluginId: "sso",
|
||||||
|
labelKey: "loginWithSso",
|
||||||
|
kind: "redirect",
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
addUser({ passwordHash: "" });
|
||||||
|
enrolTotp();
|
||||||
|
h.state.identities.push({
|
||||||
|
id: 1,
|
||||||
|
userId: "u1",
|
||||||
|
providerId: "3",
|
||||||
|
subject: "sub-1",
|
||||||
|
email: null,
|
||||||
|
});
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
|
||||||
|
});
|
||||||
|
const result = await runLogin(
|
||||||
|
fakeRequest() as never,
|
||||||
|
{
|
||||||
|
kind: "external",
|
||||||
|
provider: "3",
|
||||||
|
subject: "sub-1",
|
||||||
|
email: null,
|
||||||
|
name: "Alice",
|
||||||
|
} as never,
|
||||||
|
{ methodId: "oidc", rememberMe: false },
|
||||||
|
);
|
||||||
|
expect(result.kind).toBe("second-factor");
|
||||||
|
} finally {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
|
||||||
|
addUser();
|
||||||
|
enrolTotp();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
|
||||||
|
});
|
||||||
|
const first = await passwordLogin({
|
||||||
|
username: "alice",
|
||||||
|
password: PASSWORD,
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
|
const res = fakeResponse();
|
||||||
|
await verifySecondFactorAndRespond(
|
||||||
|
fakeRequest({
|
||||||
|
body: {
|
||||||
|
temp_token: (first.body as { temp_token: string }).temp_token,
|
||||||
|
totp_code: TOTP_CODE,
|
||||||
|
rememberMe: true,
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
res as never,
|
||||||
|
"totp",
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
|
||||||
|
expect(h.state.trustedAdded).toEqual([]);
|
||||||
|
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the login when a provider throws", async () => {
|
||||||
|
addUser();
|
||||||
|
provideAuthPolicy("policy", {
|
||||||
|
evaluate: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
runLogin(
|
||||||
|
fakeRequest() as never,
|
||||||
|
{ kind: "user", userId: "u1" },
|
||||||
|
{ methodId: "password", rememberMe: false },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
|
||||||
|
expect(h.state.audits).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
authLogger: { warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
clearAuthPolicies,
|
||||||
|
evaluateAuthPolicy,
|
||||||
|
hasAuthPolicies,
|
||||||
|
provideAuthPolicy,
|
||||||
|
rememberDeviceAllowed,
|
||||||
|
} = await import("../../auth/policy.js");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
userId: "u1",
|
||||||
|
methodId: "password",
|
||||||
|
external: false,
|
||||||
|
deviceType: "web" as const,
|
||||||
|
trustedDevice: false,
|
||||||
|
mfaSatisfied: false,
|
||||||
|
secondFactors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => clearAuthPolicies());
|
||||||
|
|
||||||
|
describe("auth policy", () => {
|
||||||
|
it("changes nothing without a provider", async () => {
|
||||||
|
expect(hasAuthPolicies()).toBe(false);
|
||||||
|
expect(await evaluateAuthPolicy(input)).toEqual({
|
||||||
|
allowRememberDevice: true,
|
||||||
|
maxSessionSeconds: null,
|
||||||
|
idleTimeoutSeconds: null,
|
||||||
|
secondFactor: null,
|
||||||
|
});
|
||||||
|
expect(rememberDeviceAllowed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the strictest answer from every provider", async () => {
|
||||||
|
provideAuthPolicy("a", {
|
||||||
|
evaluate: () => ({
|
||||||
|
maxSessionSeconds: 3600,
|
||||||
|
idleTimeoutSeconds: 900,
|
||||||
|
secondFactor: "new-device",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
provideAuthPolicy("b", {
|
||||||
|
evaluate: async () => ({
|
||||||
|
allowRememberDevice: false,
|
||||||
|
maxSessionSeconds: 7200,
|
||||||
|
idleTimeoutSeconds: 300,
|
||||||
|
secondFactor: "always",
|
||||||
|
}),
|
||||||
|
allowRememberDevice: () => false,
|
||||||
|
});
|
||||||
|
expect(await evaluateAuthPolicy(input)).toEqual({
|
||||||
|
allowRememberDevice: false,
|
||||||
|
maxSessionSeconds: 3600,
|
||||||
|
idleTimeoutSeconds: 300,
|
||||||
|
secondFactor: "always",
|
||||||
|
});
|
||||||
|
expect(rememberDeviceAllowed()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores zero and negative limits", async () => {
|
||||||
|
provideAuthPolicy("a", {
|
||||||
|
evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }),
|
||||||
|
});
|
||||||
|
expect(await evaluateAuthPolicy(input)).toMatchObject({
|
||||||
|
maxSessionSeconds: null,
|
||||||
|
idleTimeoutSeconds: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the login when a provider throws, until it is removed", async () => {
|
||||||
|
const remove = provideAuthPolicy("a", {
|
||||||
|
evaluate: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({
|
||||||
|
status: 503,
|
||||||
|
code: "auth_policy_unavailable",
|
||||||
|
});
|
||||||
|
remove();
|
||||||
|
await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({
|
||||||
|
secondFactor: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides remember me when the hint throws", () => {
|
||||||
|
provideAuthPolicy("a", {
|
||||||
|
evaluate: () => ({}),
|
||||||
|
allowRememberDevice: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(rememberDeviceAllowed()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express, { type RequestHandler } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
updates: [] as Array<{ ids: number[]; update: Record<string, unknown> }>,
|
||||||
|
renamed: 0,
|
||||||
|
folderHosts: [] as Array<{ id: number }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||||
|
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||||
|
pluginLogger: { warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: { batched: (fn: RequestHandler) => fn },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||||
|
changeHostOverrides: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/recompute.js", () => ({
|
||||||
|
recompute: async () => ({ changedHostIds: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/service.js", () => ({
|
||||||
|
folderCredentialIds: async () => [],
|
||||||
|
setFolderCredentialDefault: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||||
|
setHostPluginEnabled: async () => true,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostRepository: () => ({
|
||||||
|
listBulkUpdateState: async (_userId: string, ids: number[]) =>
|
||||||
|
ids.map((id) => ({ id })),
|
||||||
|
updateManyForUser: async (
|
||||||
|
_userId: string,
|
||||||
|
ids: number[],
|
||||||
|
update: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
state.updates.push({ ids, update });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentHostFolderRepository: () => ({
|
||||||
|
listHostsInFolder: async () => state.folderHosts,
|
||||||
|
renameFolder: async () => {
|
||||||
|
state.renamed++;
|
||||||
|
return { updatedHosts: 1, updatedCredentials: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentFolderAccessRepository: () => ({
|
||||||
|
renameFolder: async () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { registerHostBulkRoutes } =
|
||||||
|
await import("../../../database/routes/host-bulk-routes.js");
|
||||||
|
const { registerHostFolderRoutes } =
|
||||||
|
await import("../../../database/routes/host-folder-routes.js");
|
||||||
|
const { clearHostLocks, provideHostLocks } =
|
||||||
|
await import("../../../hosts/locks.js");
|
||||||
|
|
||||||
|
const pass: RequestHandler = (req, _res, next) => {
|
||||||
|
(req as unknown as { userId: string }).userId = "owner";
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
const router = express.Router();
|
||||||
|
registerHostBulkRoutes(router, pass, pass, pass);
|
||||||
|
registerHostFolderRoutes(router, {
|
||||||
|
authenticateJWT: pass,
|
||||||
|
requireViewPermission: pass,
|
||||||
|
requireEditPermission: pass,
|
||||||
|
requireDeletePermission: pass,
|
||||||
|
requireCredentialEditPermission: pass,
|
||||||
|
requireDataAccess: pass,
|
||||||
|
});
|
||||||
|
const server = express();
|
||||||
|
server.use(express.json());
|
||||||
|
server.use(router);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.updates = [];
|
||||||
|
state.renamed = 0;
|
||||||
|
state.folderHosts = [];
|
||||||
|
provideHostLocks("cfg", "Config", {
|
||||||
|
isLocked: (hostId) => hostId === 2,
|
||||||
|
note: () => null,
|
||||||
|
allowManualCreate: () => true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => clearHostLocks());
|
||||||
|
|
||||||
|
describe("routes on locked hosts", () => {
|
||||||
|
it("still lets a user pin a locked host", async () => {
|
||||||
|
await request(app())
|
||||||
|
.patch("/bulk-update")
|
||||||
|
.send({ hostIds: [1, 2], updates: { pin: true } })
|
||||||
|
.expect(200);
|
||||||
|
expect(state.updates).toEqual([{ ids: [1, 2], update: { pin: true } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bulk change that touches a locked host", async () => {
|
||||||
|
const res = await request(app())
|
||||||
|
.patch("/bulk-update")
|
||||||
|
.send({ hostIds: [1, 2], updates: { folder: "Moved" } })
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body).toMatchObject({ code: "HOST_LOCKED" });
|
||||||
|
expect(state.updates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses renaming a folder that holds a locked host", async () => {
|
||||||
|
state.folderHosts = [{ id: 1 }, { id: 2 }];
|
||||||
|
await request(app())
|
||||||
|
.put("/folders/rename")
|
||||||
|
.send({ oldName: "Prod", newName: "Live" })
|
||||||
|
.expect(403);
|
||||||
|
expect(state.renamed).toBe(0);
|
||||||
|
|
||||||
|
state.folderHosts = [{ id: 1 }];
|
||||||
|
await request(app())
|
||||||
|
.put("/folders/rename")
|
||||||
|
.send({ oldName: "Prod", newName: "Live" })
|
||||||
|
.expect(200);
|
||||||
|
expect(state.renamed).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
|||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
createCurrentSettingsRepository: () => ({
|
createCurrentSettingsRepository: () => ({
|
||||||
get: async (key: string) => state.settings[key] ?? null,
|
get: async (key: string) => state.settings[key] ?? null,
|
||||||
|
getBoolean: async (key: string, fallback: boolean) =>
|
||||||
|
key in state.settings ? state.settings[key] === "true" : fallback,
|
||||||
set: async (key: string, value: string) => {
|
set: async (key: string, value: string) => {
|
||||||
state.settings[key] = value;
|
state.settings[key] = value;
|
||||||
},
|
},
|
||||||
@@ -39,6 +41,12 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../tls/tls-service.js", () => ({
|
||||||
|
getTlsConfig: () => ({ enabled: true, port: 8443 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resetHttpsPolicyForTests } =
|
||||||
|
await import("../../../tls/https-policy.js");
|
||||||
const { registerUserSettingsRoutes } =
|
const { registerUserSettingsRoutes } =
|
||||||
await import("../../../database/routes/user-settings-routes.js");
|
await import("../../../database/routes/user-settings-routes.js");
|
||||||
|
|
||||||
@@ -59,6 +67,7 @@ function app() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.settings = {};
|
state.settings = {};
|
||||||
state.admin = true;
|
state.admin = true;
|
||||||
|
resetHttpsPolicyForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/users/host-defaults", () => {
|
describe("/users/host-defaults", () => {
|
||||||
@@ -77,3 +86,35 @@ describe("/users/host-defaults", () => {
|
|||||||
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
|
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/users/https-settings", () => {
|
||||||
|
it("is off by default and saves a change", async () => {
|
||||||
|
const read = await request(app()).get("/users/https-settings");
|
||||||
|
expect(read.body).toEqual({ forceHttps: false, tlsEnabled: true });
|
||||||
|
|
||||||
|
const write = await request(app())
|
||||||
|
.patch("/users/https-settings")
|
||||||
|
.send({ forceHttps: true });
|
||||||
|
expect(write.status).toBe(200);
|
||||||
|
expect(state.settings.force_https).toBe("true");
|
||||||
|
const after = await request(app()).get("/users/https-settings");
|
||||||
|
expect(after.body.forceHttps).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is admin only and checks the value", async () => {
|
||||||
|
const bad = await request(app())
|
||||||
|
.patch("/users/https-settings")
|
||||||
|
.send({ forceHttps: "yes" });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
|
||||||
|
state.admin = false;
|
||||||
|
expect((await request(app()).get("/users/https-settings")).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
const write = await request(app())
|
||||||
|
.patch("/users/https-settings")
|
||||||
|
.send({ forceHttps: true });
|
||||||
|
expect(write.status).toBe(403);
|
||||||
|
expect(state.settings.force_https).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: { warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
assertHostsUnlocked,
|
||||||
|
assertManualCreateAllowed,
|
||||||
|
clearHostLocks,
|
||||||
|
hasHostLocks,
|
||||||
|
hostLock,
|
||||||
|
HostLockedError,
|
||||||
|
manualCreateBlockedBy,
|
||||||
|
provideHostLocks,
|
||||||
|
requireManualCreate,
|
||||||
|
requireUnlockedHost,
|
||||||
|
} = await import("../../hosts/locks.js");
|
||||||
|
|
||||||
|
const provider = (locked: number[], allowManualCreate = true) => ({
|
||||||
|
isLocked: (hostId: number) => locked.includes(hostId),
|
||||||
|
note: (hostId: number) => `id ${hostId}`,
|
||||||
|
allowManualCreate: () => allowManualCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeRes() {
|
||||||
|
const res = {
|
||||||
|
code: 0,
|
||||||
|
body: null as unknown,
|
||||||
|
status(code: number) {
|
||||||
|
res.code = code;
|
||||||
|
return { json: (body: unknown) => (res.body = body) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => clearHostLocks());
|
||||||
|
|
||||||
|
describe("host locks", () => {
|
||||||
|
it("reports nothing without providers", () => {
|
||||||
|
expect(hasHostLocks()).toBe(false);
|
||||||
|
expect(hostLock(1)).toBeNull();
|
||||||
|
expect(() => assertHostsUnlocked([1, 2])).not.toThrow();
|
||||||
|
expect(manualCreateBlockedBy()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the plugin and note for a locked host", () => {
|
||||||
|
provideHostLocks("cfg", "Config", provider([3]));
|
||||||
|
expect(hostLock(3)).toEqual({
|
||||||
|
pluginId: "cfg",
|
||||||
|
pluginName: "Config",
|
||||||
|
note: "id 3",
|
||||||
|
});
|
||||||
|
expect(hostLock(4)).toBeNull();
|
||||||
|
expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the locking plugin through", () => {
|
||||||
|
provideHostLocks("cfg", "Config", provider([3], false));
|
||||||
|
expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow();
|
||||||
|
expect(() => assertManualCreateAllowed("cfg")).not.toThrow();
|
||||||
|
expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a provider when removed", () => {
|
||||||
|
const remove = provideHostLocks("cfg", "Config", provider([3], false));
|
||||||
|
remove();
|
||||||
|
expect(hostLock(3)).toBeNull();
|
||||||
|
expect(manualCreateBlockedBy()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never locks when a provider throws", () => {
|
||||||
|
provideHostLocks("cfg", "Config", {
|
||||||
|
isLocked: () => {
|
||||||
|
throw new Error("broken");
|
||||||
|
},
|
||||||
|
note: () => null,
|
||||||
|
allowManualCreate: () => {
|
||||||
|
throw new Error("broken");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(hostLock(1)).toBeNull();
|
||||||
|
expect(manualCreateBlockedBy()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lock route guards", () => {
|
||||||
|
it("answers 403 HOST_LOCKED for a locked :id", () => {
|
||||||
|
provideHostLocks("cfg", "Config", provider([7]));
|
||||||
|
const res = fakeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
requireUnlockedHost({ params: { id: "7" } }, res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.code).toBe(403);
|
||||||
|
expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" });
|
||||||
|
|
||||||
|
requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks manual creates only while a provider says so", () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
requireManualCreate({}, fakeRes(), next);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
provideHostLocks("cfg", "Config", provider([], false));
|
||||||
|
const res = fakeRes();
|
||||||
|
requireManualCreate({}, res, next);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.code).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ctx.credentials.list", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.userCredentials.set("owner", [
|
||||||
|
{ id: 3, name: "pw", authType: "password", password: "secret" },
|
||||||
|
{ id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses without credentials:use", async () => {
|
||||||
|
grants.set("demo", []);
|
||||||
|
const ctx = contextFor([]);
|
||||||
|
await expect(
|
||||||
|
runAsActor("owner", "request", () => ctx.credentials.list()),
|
||||||
|
).rejects.toThrow(/credentials:use/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the actor's credentials without secrets", async () => {
|
||||||
|
grants.set("demo", ["credentials:use"]);
|
||||||
|
const ctx = contextFor(["credentials:use"]);
|
||||||
|
const list = await runAsActor("owner", "request", () =>
|
||||||
|
ctx.credentials.list(),
|
||||||
|
);
|
||||||
|
expect(list).toEqual([
|
||||||
|
{ id: 3, name: "pw", username: null, authType: "password", folder: null },
|
||||||
|
{ id: 4, name: "key", username: null, authType: "key", folder: null },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/);
|
||||||
|
expect(auditEntries.at(-1)).toMatchObject({
|
||||||
|
action: "plugin_credentials_list",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ctx.credentials.listSshKeys", () => {
|
describe("ctx.credentials.listSshKeys", () => {
|
||||||
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
|
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
|
||||||
const derived = (() => {
|
const derived = (() => {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const h = vi.hoisted(() => ({
|
|||||||
statusPortsRemoved: [] as string[],
|
statusPortsRemoved: [] as string[],
|
||||||
userPermissions: new Set<string>(),
|
userPermissions: new Set<string>(),
|
||||||
deleted: [] as Array<{ userId: string; hostId: number }>,
|
deleted: [] as Array<{ userId: string; hostId: number }>,
|
||||||
|
existingAccess: [] as Array<{ id: number }>,
|
||||||
|
revoked: [] as number[],
|
||||||
|
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
|
||||||
roles: [] as Array<{
|
roles: [] as Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -85,6 +88,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
h.upserts.push(input);
|
h.upserts.push(input);
|
||||||
return { id: h.upserts.length, created: true };
|
return { id: h.upserts.length, created: true };
|
||||||
},
|
},
|
||||||
|
listHostAccess: async () => h.existingAccess,
|
||||||
|
revokeHostAccess: async (accessId: number) => {
|
||||||
|
h.revoked.push(accessId);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
createCurrentUserRepository: () => ({
|
createCurrentUserRepository: () => ({
|
||||||
listAll: async () => h.users,
|
listAll: async () => h.users,
|
||||||
@@ -145,6 +152,13 @@ vi.mock("../../plugins/host-status-provider.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||||
|
hostSettingsPlugins: () => [
|
||||||
|
{
|
||||||
|
id: "metrics",
|
||||||
|
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
|
||||||
|
},
|
||||||
|
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
|
||||||
|
],
|
||||||
loadHostPluginSettings: async (hostIds: number[]) =>
|
loadHostPluginSettings: async (hostIds: number[]) =>
|
||||||
new Map(
|
new Map(
|
||||||
hostIds.map((id) => [
|
hostIds.map((id) => [
|
||||||
@@ -198,6 +212,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({
|
|||||||
changeHostOverrides: async () => {},
|
changeHostOverrides: async () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/routes/rbac.js", () => ({
|
||||||
|
pruneHostGrants: async () => 0,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async (credentialId: number) =>
|
||||||
|
credentialId === 5 ? { id: 5 } : null,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
|
||||||
|
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
|
||||||
|
new Map(Object.entries(data.protocolAuth)),
|
||||||
|
writeProtocolAuth: async (
|
||||||
|
_ownerId: string,
|
||||||
|
hostId: number,
|
||||||
|
patch: Map<string, unknown>,
|
||||||
|
) => {
|
||||||
|
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hosts/delete-host.js", () => ({
|
vi.mock("../../hosts/delete-host.js", () => ({
|
||||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||||
h.deleted.push({ userId, hostId });
|
h.deleted.push({ userId, hostId });
|
||||||
@@ -209,6 +242,8 @@ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
|||||||
const { PluginCapabilityError } =
|
const { PluginCapabilityError } =
|
||||||
await import("@termix-ssh/plugin-sdk/backend");
|
await import("@termix-ssh/plugin-sdk/backend");
|
||||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||||
|
const { clearHostLocks, hostLock, provideHostLocks } =
|
||||||
|
await import("../../hosts/locks.js");
|
||||||
|
|
||||||
function manifest(capabilities: string[]) {
|
function manifest(capabilities: string[]) {
|
||||||
return {
|
return {
|
||||||
@@ -236,6 +271,10 @@ beforeEach(() => {
|
|||||||
h.roles = [];
|
h.roles = [];
|
||||||
h.userPermissions = new Set();
|
h.userPermissions = new Set();
|
||||||
h.deleted = [];
|
h.deleted = [];
|
||||||
|
h.existingAccess = [];
|
||||||
|
h.revoked = [];
|
||||||
|
h.protocolWrites = [];
|
||||||
|
clearHostLocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ctx.hosts", () => {
|
describe("ctx.hosts", () => {
|
||||||
@@ -705,3 +744,220 @@ describe("ctx.hosts.status", () => {
|
|||||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ctx.hosts secrets and protocol logins", () => {
|
||||||
|
it("stores secrets on create and writes protocol logins", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const created = await hosts.create({
|
||||||
|
name: "rdp",
|
||||||
|
ip: "10.0.0.9",
|
||||||
|
port: 3389,
|
||||||
|
username: "",
|
||||||
|
authType: "password",
|
||||||
|
password: "s3cret",
|
||||||
|
sudoPassword: "",
|
||||||
|
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
|
||||||
|
});
|
||||||
|
expect(h.created[0]).toMatchObject({
|
||||||
|
password: "s3cret",
|
||||||
|
sudoPassword: null,
|
||||||
|
});
|
||||||
|
expect(created).not.toHaveProperty("password");
|
||||||
|
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a protocol credential the owner cannot use", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
hosts.create({
|
||||||
|
name: "vnc",
|
||||||
|
ip: "10.0.0.9",
|
||||||
|
port: 5900,
|
||||||
|
username: "",
|
||||||
|
authType: "password",
|
||||||
|
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/credential 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a secret that is not a string", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
|
||||||
|
/password/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.hosts pluginEnable", () => {
|
||||||
|
it("writes each plugin's switch under its enableKey", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.importRows = [];
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await hosts.create({
|
||||||
|
name: "n",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
pluginEnable: { metrics: false },
|
||||||
|
});
|
||||||
|
expect(h.importRows).toEqual([
|
||||||
|
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plugin with no switch", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
hosts.update(5, { pluginEnable: { noswitch: true } }),
|
||||||
|
).rejects.toThrow(/no host switch/);
|
||||||
|
expect(h.updates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.hosts.setShares", () => {
|
||||||
|
it("upserts the listed grants and revokes the rest", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.existingAccess = [{ id: 1 }, { id: 7 }];
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const result = await hosts.setShares(3, [
|
||||||
|
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({ hostId: 3, shared: true });
|
||||||
|
expect(h.upserts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
hostId: 3,
|
||||||
|
targetType: "role",
|
||||||
|
targetRoleId: 2,
|
||||||
|
permissionLevel: "connect",
|
||||||
|
expiresAt: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(h.revoked).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only works for the host owner", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.ownerId = "someone-else";
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
expect(await hosts.setShares(3, [])).toEqual({
|
||||||
|
hostId: 3,
|
||||||
|
shared: false,
|
||||||
|
reason: "forbidden",
|
||||||
|
});
|
||||||
|
expect(h.revoked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad level", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
hosts.setShares(3, [
|
||||||
|
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/share level/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.hosts.locks", () => {
|
||||||
|
const provider = (locked: number[], allowManualCreate = true) => ({
|
||||||
|
isLocked: (hostId: number) => locked.includes(hostId),
|
||||||
|
note: () => "From hosts.yaml",
|
||||||
|
allowManualCreate: () => allowManualCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs hosts:lock", () => {
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest([]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
expect(() => hosts.locks.provide(provider([1]))).toThrow(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks hosts until the plugin removes its provider", () => {
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:lock"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const remove = hosts.locks.provide(provider([4]));
|
||||||
|
expect(hostLock(4)).toEqual({
|
||||||
|
pluginId: "fixture",
|
||||||
|
pluginName: "Fixture",
|
||||||
|
note: "From hosts.yaml",
|
||||||
|
});
|
||||||
|
expect(hostLock(5)).toBeNull();
|
||||||
|
remove();
|
||||||
|
expect(hostLock(4)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.updateResult = { id: 4, name: "x" };
|
||||||
|
h.userPermissions = new Set(["hosts.delete"]);
|
||||||
|
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
|
||||||
|
const other = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await expect(other.update(4, { name: "y" })).rejects.toThrow(
|
||||||
|
/managed by Hosts File/,
|
||||||
|
);
|
||||||
|
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
|
||||||
|
await expect(
|
||||||
|
other.share(4, [{ type: "user", id: "u2" }], "view"),
|
||||||
|
).rejects.toThrow(/managed by Hosts File/);
|
||||||
|
await expect(
|
||||||
|
other.create({
|
||||||
|
name: "n",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/new hosts can't be added/);
|
||||||
|
expect(h.updates).toHaveLength(0);
|
||||||
|
expect(h.deleted).toHaveLength(0);
|
||||||
|
|
||||||
|
const owner = createPluginHosts({
|
||||||
|
manifest: {
|
||||||
|
id: "hosts-file",
|
||||||
|
name: "Hosts File",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["hosts:write"],
|
||||||
|
} as never,
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
await owner.update(4, { name: "y" });
|
||||||
|
expect(h.updates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({
|
|||||||
sessionId: undefined as string | undefined,
|
sessionId: undefined as string | undefined,
|
||||||
revoked: [] as Array<{ userId: string; except?: string }>,
|
revoked: [] as Array<{ userId: string; except?: string }>,
|
||||||
trustedCleared: [] as string[],
|
trustedCleared: [] as string[],
|
||||||
|
unrestricted: [] as string[],
|
||||||
policyError: null as Error | null,
|
policyError: null as Error | null,
|
||||||
logins: [] as Array<{ hostId: number; outcome: unknown }>,
|
logins: [] as Array<{ hostId: number; outcome: unknown }>,
|
||||||
connectError: null as Error | null,
|
connectError: null as Error | null,
|
||||||
@@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
h.revoked.push({ userId, except });
|
h.revoked.push({ userId, except });
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
|
clearRestrictionForUser: async (userId: string) => {
|
||||||
|
h.unrestricted.push(userId);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
createCurrentTrustedDeviceRepository: () => ({
|
createCurrentTrustedDeviceRepository: () => ({
|
||||||
deleteByUserId: async (userId: string) => {
|
deleteByUserId: async (userId: string) => {
|
||||||
@@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } =
|
|||||||
await import("../../auth/registry.js");
|
await import("../../auth/registry.js");
|
||||||
const { PluginCapabilityError } =
|
const { PluginCapabilityError } =
|
||||||
await import("@termix-ssh/plugin-sdk/backend");
|
await import("@termix-ssh/plugin-sdk/backend");
|
||||||
|
const { hasAuthPolicies, clearAuthPolicies } =
|
||||||
|
await import("../../auth/policy.js");
|
||||||
|
|
||||||
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -655,6 +661,7 @@ describe("ctx.auth", () => {
|
|||||||
h.factors = [];
|
h.factors = [];
|
||||||
h.revoked = [];
|
h.revoked = [];
|
||||||
h.trustedCleared = [];
|
h.trustedCleared = [];
|
||||||
|
h.unrestricted = [];
|
||||||
h.policyError = null;
|
h.policyError = null;
|
||||||
h.actor = "user-1";
|
h.actor = "user-1";
|
||||||
h.sessionId = "session-a";
|
h.sessionId = "session-a";
|
||||||
@@ -666,10 +673,65 @@ describe("ctx.auth", () => {
|
|||||||
await auth.recordEnrollment("user-1", "pin");
|
await auth.recordEnrollment("user-1", "pin");
|
||||||
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
|
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
|
||||||
expect(h.trustedCleared).toEqual(["user-1"]);
|
expect(h.trustedCleared).toEqual(["user-1"]);
|
||||||
|
expect(h.unrestricted).toEqual(["user-1"]);
|
||||||
h.actor = undefined;
|
h.actor = undefined;
|
||||||
h.sessionId = undefined;
|
h.sessionId = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
|
||||||
|
clearAuthPolicies();
|
||||||
|
const plain = createPluginAuth({
|
||||||
|
manifest: manifest(["auth:provide"]),
|
||||||
|
bag: new DisposableBag("fixture"),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bag = new DisposableBag("fixture");
|
||||||
|
const audit = vi.fn(async () => {});
|
||||||
|
const auth = createPluginAuth({
|
||||||
|
manifest: manifest(["auth:policy"]),
|
||||||
|
bag,
|
||||||
|
audit,
|
||||||
|
});
|
||||||
|
const remove = auth.policy.provide({ evaluate: () => ({}) });
|
||||||
|
expect(hasAuthPolicies()).toBe(true);
|
||||||
|
expect(audit).toHaveBeenCalledWith(
|
||||||
|
"auth_policy_provide",
|
||||||
|
"fixture sets login rules",
|
||||||
|
{ success: true },
|
||||||
|
);
|
||||||
|
remove();
|
||||||
|
expect(hasAuthPolicies()).toBe(false);
|
||||||
|
|
||||||
|
auth.policy.provide({ evaluate: () => ({}) });
|
||||||
|
await bag.disposeAll();
|
||||||
|
expect(hasAuthPolicies()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists second factors from every plugin", async () => {
|
||||||
|
h.granted = new Set(["auth:provide"]);
|
||||||
|
const bag = new DisposableBag("fixture");
|
||||||
|
const auth = createPluginAuth({
|
||||||
|
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||||
|
bag,
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
auth.registerSecondFactor({
|
||||||
|
id: "pin",
|
||||||
|
labelKey: "pin",
|
||||||
|
isEnrolled: async () => false,
|
||||||
|
verify: async () => true,
|
||||||
|
});
|
||||||
|
expect(auth.listSecondFactors()).toContainEqual({
|
||||||
|
id: "pin",
|
||||||
|
pluginId: "fixture",
|
||||||
|
});
|
||||||
|
await bag.disposeAll();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses enrolment when core policy forbids second factors", async () => {
|
it("refuses enrolment when core policy forbids second factors", async () => {
|
||||||
h.granted = new Set(["auth:provide"]);
|
h.granted = new Set(["auth:provide"]);
|
||||||
h.factors = [];
|
h.factors = [];
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const UNGATED: Record<string, string> = {
|
|||||||
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
|
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
|
||||||
"rbac.require": "route middleware for the plugin's own permissions",
|
"rbac.require": "route middleware for the plugin's own permissions",
|
||||||
"capabilities.has": "a yes/no about the plugin's own grants",
|
"capabilities.has": "a yes/no about the plugin's own grants",
|
||||||
|
"auth.listSecondFactors": "ids of the second factors, no user data",
|
||||||
"disposables.add": "the plugin's own cleanup",
|
"disposables.add": "the plugin's own cleanup",
|
||||||
"schedule.every": "the plugin's own timers",
|
"schedule.every": "the plugin's own timers",
|
||||||
"schedule.after": "the plugin's own timers",
|
"schedule.after": "the plugin's own timers",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
settings: new Map<string, string>(),
|
||||||
|
tlsEnabled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
getBoolean: async (key: string, fallback: boolean) =>
|
||||||
|
h.settings.has(key) ? h.settings.get(key) === "true" : fallback,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
h.settings.set(key, value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../tls/tls-service.js", () => ({
|
||||||
|
getTlsConfig: () => ({ enabled: h.tlsEnabled, port: 8443 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { httpsPolicyMiddleware, setForceHttps, resetHttpsPolicyForTests } =
|
||||||
|
await import("../../tls/https-policy.js");
|
||||||
|
|
||||||
|
function run(
|
||||||
|
path: string,
|
||||||
|
{ secure = false, headers = {} as Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
const all: Record<string, string> = { host: "termix.lan:8080", ...headers };
|
||||||
|
const req = {
|
||||||
|
secure,
|
||||||
|
path,
|
||||||
|
originalUrl: path,
|
||||||
|
url: path,
|
||||||
|
get: (name: string) => all[name.toLowerCase()],
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
redirected: null as null | { status: number; url: string },
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
res.headers[name] = value;
|
||||||
|
},
|
||||||
|
redirect(status: number, url: string) {
|
||||||
|
res.redirected = { status, url };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next = vi.fn();
|
||||||
|
return httpsPolicyMiddleware(req as never, res as never, next).then(() => ({
|
||||||
|
res,
|
||||||
|
next,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.settings.clear();
|
||||||
|
h.tlsEnabled = true;
|
||||||
|
resetHttpsPolicyForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("https policy", () => {
|
||||||
|
it("is off by default", async () => {
|
||||||
|
const { res, next } = await run("/users/me");
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirected).toBeNull();
|
||||||
|
expect(res.headers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while HTTPS is off, even when turned on", async () => {
|
||||||
|
await setForceHttps(true);
|
||||||
|
h.tlsEnabled = false;
|
||||||
|
const { res, next } = await run("/users/me");
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirected).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects plain HTTP to the HTTPS port", async () => {
|
||||||
|
await setForceHttps(true);
|
||||||
|
const { res, next } = await run("/users/me?x=1");
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.redirected).toEqual({
|
||||||
|
status: 308,
|
||||||
|
url: "https://termix.lan:8443/users/me?x=1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts X-Forwarded-Proto and sends HSTS over HTTPS", async () => {
|
||||||
|
await setForceHttps(true);
|
||||||
|
const forwarded = await run("/users/me", {
|
||||||
|
headers: { "x-forwarded-proto": "https, http" },
|
||||||
|
});
|
||||||
|
expect(forwarded.next).toHaveBeenCalled();
|
||||||
|
expect(forwarded.res.headers["Strict-Transport-Security"]).toBe(
|
||||||
|
"max-age=31536000",
|
||||||
|
);
|
||||||
|
|
||||||
|
const direct = await run("/users/me", { secure: true });
|
||||||
|
expect(direct.next).toHaveBeenCalled();
|
||||||
|
expect(direct.res.headers["Strict-Transport-Security"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves certificate checks and health probes on HTTP", async () => {
|
||||||
|
await setForceHttps(true);
|
||||||
|
for (const path of ["/.well-known/acme-challenge/abc", "/health"]) {
|
||||||
|
const { res, next } = await run(path);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.redirected).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
hasUserDEK: vi.fn(() => false),
|
hasUserDEK: vi.fn(() => false),
|
||||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||||
|
revoke: vi.fn(async () => true),
|
||||||
|
created: [] as Array<Record<string, unknown>>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/db/index.js", () => ({
|
vi.mock("../../database/db/index.js", () => ({
|
||||||
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
|
|||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||||
createCurrentSessionRepository: () => ({
|
createCurrentSessionRepository: () => ({
|
||||||
findById: async (id: string) =>
|
findById: async (id: string) => {
|
||||||
id === "expired"
|
const minutesAgo = (n: number) =>
|
||||||
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
|
new Date(Date.now() - n * 60_000).toISOString();
|
||||||
: { id },
|
if (id === "expired") {
|
||||||
|
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
|
||||||
|
}
|
||||||
|
if (id === "idle") {
|
||||||
|
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
|
||||||
|
}
|
||||||
|
if (id === "active") {
|
||||||
|
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
|
||||||
|
}
|
||||||
|
if (id === "setup") return { id, restriction: "second-factor-setup" };
|
||||||
|
return { id };
|
||||||
|
},
|
||||||
|
create: async (row: Record<string, unknown>) => {
|
||||||
|
mocks.created.push(row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
revoke: mocks.revoke,
|
||||||
|
touch: async () => true,
|
||||||
}),
|
}),
|
||||||
createCurrentUserRepository: () => ({}),
|
createCurrentUserRepository: () => ({}),
|
||||||
createCurrentApiKeyRepository: () => ({}),
|
createCurrentApiKeyRepository: () => ({}),
|
||||||
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
const { AuthManager, allowsSetupSession } =
|
||||||
|
await import("../../utils/auth-manager.js");
|
||||||
|
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
function makeLegacyDataKeyWrap(
|
function makeLegacyDataKeyWrap(
|
||||||
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.created.length = 0;
|
||||||
|
mocks.revoke.mockClear();
|
||||||
mocks.hasUserDEK.mockReset().mockReturnValue(false);
|
mocks.hasUserDEK.mockReset().mockReturnValue(false);
|
||||||
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
|
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
@@ -209,3 +232,97 @@ describe("AuthManager token handling", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("login policy on sessions", () => {
|
||||||
|
const sign = (sessionId: string) =>
|
||||||
|
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
it("caps the lifetime and stores the idle timeout and restriction", async () => {
|
||||||
|
const token = await authManager.generateJWTToken("user-1", {
|
||||||
|
rememberMe: true,
|
||||||
|
deviceType: "web",
|
||||||
|
deviceInfo: "Firefox",
|
||||||
|
maxAgeSeconds: 600,
|
||||||
|
idleTimeoutSeconds: 300,
|
||||||
|
restriction: "second-factor-setup",
|
||||||
|
});
|
||||||
|
const payload = jwt.decode(token) as { iat: number; exp: number };
|
||||||
|
expect(payload.exp - payload.iat).toBe(600);
|
||||||
|
expect(mocks.created[0]).toMatchObject({
|
||||||
|
idleTimeout: 300,
|
||||||
|
restriction: "second-factor-setup",
|
||||||
|
});
|
||||||
|
const lifetime =
|
||||||
|
new Date(mocks.created[0].expiresAt as string).getTime() -
|
||||||
|
new Date(mocks.created[0].createdAt as string).getTime();
|
||||||
|
expect(lifetime).toBe(600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends a session idle past its timeout and keeps an active one", async () => {
|
||||||
|
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
|
||||||
|
expect(mocks.revoke).toHaveBeenCalledWith("idle");
|
||||||
|
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only lets a setup session reach setup routes", async () => {
|
||||||
|
const middleware = authManager.createAuthMiddleware();
|
||||||
|
const call = async (url: string) => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: undefined as unknown,
|
||||||
|
status(code: number) {
|
||||||
|
res.statusCode = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json(body: unknown) {
|
||||||
|
res.body = body;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
clearCookie() {
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next = vi.fn();
|
||||||
|
await middleware(
|
||||||
|
{
|
||||||
|
cookies: { jwt: sign("setup") },
|
||||||
|
headers: {},
|
||||||
|
originalUrl: url,
|
||||||
|
url,
|
||||||
|
} as never,
|
||||||
|
res as never,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
return { res, next };
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocked = await call("/host/db/host");
|
||||||
|
expect(blocked.res.statusCode).toBe(403);
|
||||||
|
expect(blocked.res.body).toMatchObject({
|
||||||
|
code: "SECOND_FACTOR_SETUP_REQUIRED",
|
||||||
|
});
|
||||||
|
expect(blocked.next).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const me = await call("/users/me");
|
||||||
|
expect(me.next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows second factor plugins' routes and nothing else", () => {
|
||||||
|
const dispose = registerSecondFactor({
|
||||||
|
id: "key",
|
||||||
|
pluginId: "keys",
|
||||||
|
labelKey: "key",
|
||||||
|
isEnrolled: async () => false,
|
||||||
|
verify: async () => true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
|
||||||
|
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
|
||||||
|
expect(allowsSetupSession("/users/logout")).toBe(true);
|
||||||
|
expect(allowsSetupSession("/users/me/token")).toBe(false);
|
||||||
|
expect(allowsSetupSession("/credentials")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Admin option force_https: when HTTPS is on, plain HTTP requests are sent to
|
||||||
|
* the HTTPS port and HTTPS responses carry HSTS. Off by default, so a reverse
|
||||||
|
* proxy that terminates TLS itself keeps working. A request counts as HTTPS
|
||||||
|
* when it came in over TLS or a proxy says so in X-Forwarded-Proto.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||||
|
import { getTlsConfig } from "./tls-service.js";
|
||||||
|
|
||||||
|
export const FORCE_HTTPS_KEY = "force_https";
|
||||||
|
const HSTS = "max-age=31536000";
|
||||||
|
// Certificate checks and health probes must still work over plain HTTP.
|
||||||
|
const EXEMPT = [/^\/\.well-known\/acme-challenge\//, /^\/health$/];
|
||||||
|
|
||||||
|
let forceHttps: boolean | null = null;
|
||||||
|
|
||||||
|
export async function isForceHttpsEnabled(): Promise<boolean> {
|
||||||
|
if (forceHttps === null) {
|
||||||
|
forceHttps = await createCurrentSettingsRepository()
|
||||||
|
.getBoolean(FORCE_HTTPS_KEY, false)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
return forceHttps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setForceHttps(enabled: boolean): Promise<void> {
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
FORCE_HTTPS_KEY,
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
forceHttps = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetHttpsPolicyForTests(): void {
|
||||||
|
forceHttps = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecureRequest(req: Request): boolean {
|
||||||
|
if (req.secure) return true;
|
||||||
|
const forwarded = req.get("x-forwarded-proto");
|
||||||
|
return forwarded?.split(",")[0].trim().toLowerCase() === "https";
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpsUrl(req: Request, port: number): string {
|
||||||
|
const host = (req.get("host") || "localhost").replace(/:\d+$/, "");
|
||||||
|
const suffix = port === 443 ? "" : `:${port}`;
|
||||||
|
return `https://${host}${suffix}${req.originalUrl || req.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpsPolicyMiddleware(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tls = getTlsConfig();
|
||||||
|
if (!tls.enabled || !(await isForceHttpsEnabled())) return next();
|
||||||
|
if (isSecureRequest(req)) {
|
||||||
|
res.setHeader("Strict-Transport-Security", HSTS);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (EXEMPT.some((pattern) => pattern.test(req.path))) return next();
|
||||||
|
res.redirect(308, httpsUrl(req, tls.port));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { nanoid } from "nanoid";
|
|||||||
import { inArray, isNotNull } from "drizzle-orm";
|
import { inArray, isNotNull } from "drizzle-orm";
|
||||||
import type { ExternalSessionRef } from "../auth/types.js";
|
import type { ExternalSessionRef } from "../auth/types.js";
|
||||||
import { listSecondFactors } from "../auth/registry.js";
|
import { listSecondFactors } from "../auth/registry.js";
|
||||||
|
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
|
||||||
import type { DeviceType } from "./user-agent-parser.js";
|
import type { DeviceType } from "./user-agent-parser.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sessions } from "../database/db/schema.js";
|
import { sessions } from "../database/db/schema.js";
|
||||||
@@ -50,6 +51,8 @@ interface JWTPayload {
|
|||||||
* from the token, so a refreshed token stays marked.
|
* from the token, so a refreshed token stays marked.
|
||||||
*/
|
*/
|
||||||
pluginSession?: boolean;
|
pluginSession?: boolean;
|
||||||
|
/** sessions.restriction, read from the row on verify. */
|
||||||
|
restriction?: string | null;
|
||||||
dataKeyWrap?: WrappedDataKey;
|
dataKeyWrap?: WrappedDataKey;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
@@ -69,6 +72,7 @@ interface AuthenticatedRequest extends Request {
|
|||||||
pendingTOTP?: boolean;
|
pendingTOTP?: boolean;
|
||||||
dataKey?: Buffer;
|
dataKey?: Buffer;
|
||||||
actingAdminUserId?: string;
|
actingAdminUserId?: string;
|
||||||
|
sessionRestriction?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestWithHeaders extends Request {
|
interface RequestWithHeaders extends Request {
|
||||||
@@ -129,6 +133,28 @@ export function allowsPluginSession(path: string): boolean {
|
|||||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a session that must set up a second factor first may call: who am I,
|
||||||
|
// sign out, the login and factor routes, plugin bundles, and the plugins that
|
||||||
|
// provide a second factor.
|
||||||
|
const SETUP_SESSION_ALLOWLIST = [
|
||||||
|
/^\/users\/(me|logout)$/,
|
||||||
|
/^\/users\/auth\//,
|
||||||
|
/^\/user-preferences\/look(\/|$)/,
|
||||||
|
/^\/plugins(\/|$)/,
|
||||||
|
/^\/plugin-assets\//,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whether a session restricted to second factor setup may call this path. */
|
||||||
|
export function allowsSetupSession(path: string): boolean {
|
||||||
|
if (SETUP_SESSION_ALLOWLIST.some((pattern) => pattern.test(path))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||||
|
return (
|
||||||
|
!!plugin && listSecondFactors().some((factor) => factor.pluginId === plugin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||||
|
|
||||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||||
@@ -336,6 +362,11 @@ class AuthManager {
|
|||||||
deviceType?: DeviceType;
|
deviceType?: DeviceType;
|
||||||
deviceInfo?: string;
|
deviceInfo?: string;
|
||||||
externalSession?: ExternalSessionRef | null;
|
externalSession?: ExternalSessionRef | null;
|
||||||
|
/** Caps the lifetime, from the login policy. */
|
||||||
|
maxAgeSeconds?: number | null;
|
||||||
|
/** Stored on the session, see SECOND_FACTOR_SETUP. */
|
||||||
|
restriction?: string | null;
|
||||||
|
idleTimeoutSeconds?: number | null;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
@@ -355,6 +386,12 @@ class AuthManager {
|
|||||||
} else if (!expiresIn) {
|
} else if (!expiresIn) {
|
||||||
expiresIn = defaultExpiry;
|
expiresIn = defaultExpiry;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
options.maxAgeSeconds &&
|
||||||
|
options.maxAgeSeconds * 1000 < this.parseExpiresIn(expiresIn)
|
||||||
|
) {
|
||||||
|
expiresIn = `${Math.floor(options.maxAgeSeconds)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: JWTPayload = { userId };
|
const payload: JWTPayload = { userId };
|
||||||
if (options.pendingTOTP) {
|
if (options.pendingTOTP) {
|
||||||
@@ -385,6 +422,8 @@ class AuthManager {
|
|||||||
createdAt,
|
createdAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
lastActiveAt: createdAt,
|
lastActiveAt: createdAt,
|
||||||
|
restriction: options.restriction ?? null,
|
||||||
|
idleTimeout: options.idleTimeoutSeconds ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to create session", error, {
|
databaseLogger.error("Failed to create session", error, {
|
||||||
@@ -500,6 +539,19 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
payload.pluginSession =
|
payload.pluginSession =
|
||||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||||
|
payload.restriction = sessionRecord.restriction ?? null;
|
||||||
|
|
||||||
|
if (this.idleExpired(sessionRecord)) {
|
||||||
|
databaseLogger.info("Session ended after idle timeout", {
|
||||||
|
operation: "session_idle_timeout",
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
userId: payload.userId,
|
||||||
|
});
|
||||||
|
await createCurrentSessionRepository()
|
||||||
|
.revoke(payload.sessionId)
|
||||||
|
.catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.migrateDataKeyFromPayload(payload);
|
await this.migrateDataKeyFromPayload(payload);
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
@@ -531,6 +583,15 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private idleExpired(session: {
|
||||||
|
idleTimeout: number | null;
|
||||||
|
lastActiveAt: string;
|
||||||
|
}): boolean {
|
||||||
|
if (!session.idleTimeout) return false;
|
||||||
|
const idleMs = Date.now() - new Date(session.lastActiveAt).getTime();
|
||||||
|
return idleMs > session.idleTimeout * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
async refreshSessionToken(
|
async refreshSessionToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -921,6 +982,7 @@ class AuthManager {
|
|||||||
authReq.userId = payload.userId;
|
authReq.userId = payload.userId;
|
||||||
authReq.sessionId = payload.sessionId;
|
authReq.sessionId = payload.sessionId;
|
||||||
authReq.pendingTOTP = payload.pendingTOTP;
|
authReq.pendingTOTP = payload.pendingTOTP;
|
||||||
|
authReq.sessionRestriction = payload.restriction ?? null;
|
||||||
|
|
||||||
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
|
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
|
||||||
const handled = await this.applyAdminImpersonation(
|
const handled = await this.applyAdminImpersonation(
|
||||||
@@ -1022,6 +1084,16 @@ class AuthManager {
|
|||||||
payload: JWTPayload,
|
payload: JWTPayload,
|
||||||
): boolean {
|
): boolean {
|
||||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||||
|
if (
|
||||||
|
payload.restriction === SECOND_FACTOR_SETUP &&
|
||||||
|
!allowsSetupSession(path)
|
||||||
|
) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "Set up a second factor before using Termix",
|
||||||
|
code: "SECOND_FACTOR_SETUP_REQUIRED",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: "A session a plugin issued cannot use this route",
|
error: "A session a plugin issued cannot use this route",
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export interface JumpHost {
|
|||||||
hostId: number;
|
hostId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The plugin that manages a host, from ctx.hosts.locks. */
|
||||||
|
export interface HostLock {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type Host = {
|
export type Host = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -116,6 +123,8 @@ export type Host = {
|
|||||||
ownerUsername?: string;
|
ownerUsername?: string;
|
||||||
/** A read-only copy of a host shared with the linked account. */
|
/** A read-only copy of a host shared with the linked account. */
|
||||||
sharedCopy?: boolean;
|
sharedCopy?: boolean;
|
||||||
|
/** Set when a plugin manages this host: read only, pins and order still work. */
|
||||||
|
lock?: HostLock;
|
||||||
/** Desktop only: kept on this device, never synced to the server. */
|
/** Desktop only: kept on this device, never synced to the server. */
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
|
|
||||||
@@ -319,6 +328,8 @@ export interface AuthenticatedRequest extends Request {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
apiKeyId?: string;
|
apiKeyId?: string;
|
||||||
actingAdminUserId?: string;
|
actingAdminUserId?: string;
|
||||||
|
/** Set while the session may only set up a second factor. */
|
||||||
|
sessionRestriction?: string | null;
|
||||||
user?: {
|
user?: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HostLock,
|
||||||
HostProtocolAuthSummary,
|
HostProtocolAuthSummary,
|
||||||
HostSshOptions,
|
HostSshOptions,
|
||||||
HostTerminalConfig,
|
HostTerminalConfig,
|
||||||
@@ -104,6 +105,8 @@ export type Host = {
|
|||||||
* linked to. It arrives through sync and is managed on the server.
|
* linked to. It arrives through sync and is managed on the server.
|
||||||
*/
|
*/
|
||||||
sharedCopy?: boolean;
|
sharedCopy?: boolean;
|
||||||
|
/** Set when a plugin manages this host: read only, pins and order still work. */
|
||||||
|
lock?: HostLock;
|
||||||
/** Desktop only: kept on this device, never synced to the server. */
|
/** Desktop only: kept on this device, never synced to the server. */
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** False when the login policy turned remember me off. Public. */
|
||||||
|
export async function getRememberDeviceAllowed(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/auth/methods");
|
||||||
|
return response.data?.rememberDevice !== false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitLoginMethod(
|
export async function submitLoginMethod(
|
||||||
methodId: string,
|
methodId: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ export async function updateSessionTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HttpsSettings {
|
||||||
|
forceHttps: boolean;
|
||||||
|
tlsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHttpsSettings(): Promise<HttpsSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/https-settings");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch HTTPS settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHttpsSettings(
|
||||||
|
forceHttps: boolean,
|
||||||
|
): Promise<HttpsSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/https-settings", {
|
||||||
|
forceHttps,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update HTTPS settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// BRANDING SETTINGS
|
// BRANDING SETTINGS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ export async function bulkUpdateSSHHosts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostPolicy {
|
||||||
|
manualCreate: boolean;
|
||||||
|
/** The plugin that manages hosts, when hosts can't be added by hand. */
|
||||||
|
blockedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostPolicy(): Promise<HostPolicy> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get("/policy");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "load host policy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function reorderSSHHosts(
|
export async function reorderSSHHosts(
|
||||||
positions: { id: number; sortOrder: number }[],
|
positions: { id: number; sortOrder: number }[],
|
||||||
): Promise<{ updated: number }> {
|
): Promise<{ updated: number }> {
|
||||||
|
|||||||
+41
-27
@@ -42,6 +42,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
challengeSecondFactor,
|
challengeSecondFactor,
|
||||||
getLoginMethods,
|
getLoginMethods,
|
||||||
|
getRememberDeviceAllowed,
|
||||||
startLoginRedirect,
|
startLoginRedirect,
|
||||||
submitLoginMethod,
|
submitLoginMethod,
|
||||||
verifySecondFactor,
|
verifySecondFactor,
|
||||||
@@ -232,6 +233,7 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [rememberAllowed, setRememberAllowed] = useState(true);
|
||||||
const [rememberMe, setRememberMe] = useState(() => {
|
const [rememberMe, setRememberMe] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("rememberMe") === "true";
|
return localStorage.getItem("rememberMe") === "true";
|
||||||
@@ -395,6 +397,12 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
void startPreLoginPlugins()
|
void startPreLoginPlugins()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setAuthPluginsLoading(false));
|
.finally(() => setAuthPluginsLoading(false));
|
||||||
|
getRememberDeviceAllowed()
|
||||||
|
.then((allowed) => {
|
||||||
|
setRememberAllowed(allowed);
|
||||||
|
if (!allowed) setRememberMe(false);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
getLoginMethods()
|
getLoginMethods()
|
||||||
.then((methods) => setAuthMethods(methods))
|
.then((methods) => setAuthMethods(methods))
|
||||||
.catch(() => setAuthMethods([]))
|
.catch(() => setAuthMethods([]))
|
||||||
@@ -1525,19 +1533,21 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
|
|
||||||
{view === "external" && (
|
{view === "external" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
{rememberAllowed && (
|
||||||
<Checkbox
|
<div className="flex items-center gap-2">
|
||||||
id="rememberSSO"
|
<Checkbox
|
||||||
checked={rememberMe}
|
id="rememberSSO"
|
||||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
checked={rememberMe}
|
||||||
/>
|
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||||
<label
|
/>
|
||||||
htmlFor="rememberSSO"
|
<label
|
||||||
className="text-xs text-muted-foreground cursor-pointer"
|
htmlFor="rememberSSO"
|
||||||
>
|
className="text-xs text-muted-foreground cursor-pointer"
|
||||||
{t("auth.rememberMe")}
|
>
|
||||||
</label>
|
{t("auth.rememberMe")}
|
||||||
</div>
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{externalMethods.map((method) =>
|
{externalMethods.map((method) =>
|
||||||
@@ -1573,20 +1583,24 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
{rememberAllowed ? (
|
||||||
<Checkbox
|
<div className="flex items-center gap-2">
|
||||||
id="rememberMe"
|
<Checkbox
|
||||||
checked={rememberMe}
|
id="rememberMe"
|
||||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
checked={rememberMe}
|
||||||
disabled={loading}
|
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||||
/>
|
disabled={loading}
|
||||||
<label
|
/>
|
||||||
htmlFor="rememberMe"
|
<label
|
||||||
className="text-xs text-muted-foreground cursor-pointer"
|
htmlFor="rememberMe"
|
||||||
>
|
className="text-xs text-muted-foreground cursor-pointer"
|
||||||
{t("auth.rememberMe")}
|
>
|
||||||
</label>
|
{t("auth.rememberMe")}
|
||||||
</div>
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
{passwordResetAllowed && (
|
{passwordResetAllowed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -548,6 +548,9 @@
|
|||||||
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
|
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
|
||||||
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
|
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
|
||||||
"failedToMoveHosts": "Failed to move hosts",
|
"failedToMoveHosts": "Failed to move hosts",
|
||||||
|
"managedBanner": "This host is managed by {{plugin}}. Change it there. You can still pin and reorder it.",
|
||||||
|
"managedTooltip": "Managed by {{plugin}}",
|
||||||
|
"addBlocked": "Hosts are managed by {{plugin}}, so new hosts can't be added here.",
|
||||||
"failedToReorder": "Failed to reorder",
|
"failedToReorder": "Failed to reorder",
|
||||||
"expandAll": "Expand all folders",
|
"expandAll": "Expand all folders",
|
||||||
"collapseAll": "Collapse all folders",
|
"collapseAll": "Collapse all folders",
|
||||||
@@ -1144,6 +1147,10 @@
|
|||||||
"title": "Handle sign in",
|
"title": "Handle sign in",
|
||||||
"consequence": "It can add a login method, a second factor or an SSH sign-in type."
|
"consequence": "It can add a login method, a second factor or an SSH sign-in type."
|
||||||
},
|
},
|
||||||
|
"auth:policy": {
|
||||||
|
"title": "Set sign in rules",
|
||||||
|
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
|
||||||
|
},
|
||||||
"system:tls": {
|
"system:tls": {
|
||||||
"title": "Manage certificates",
|
"title": "Manage certificates",
|
||||||
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
||||||
@@ -1212,6 +1219,10 @@
|
|||||||
"title": "Store recent activity",
|
"title": "Store recent activity",
|
||||||
"consequence": "Becomes the store for the recent activity list of every user."
|
"consequence": "Becomes the store for the recent activity list of every user."
|
||||||
},
|
},
|
||||||
|
"hosts:lock": {
|
||||||
|
"title": "Manage hosts as read only",
|
||||||
|
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
|
||||||
|
},
|
||||||
"desktop:window": {
|
"desktop:window": {
|
||||||
"title": "Open desktop windows",
|
"title": "Open desktop windows",
|
||||||
"consequence": "Only in the Termix desktop app."
|
"consequence": "Only in the Termix desktop app."
|
||||||
|
|||||||
@@ -879,6 +879,8 @@ export {
|
|||||||
updateSSHHost,
|
updateSSHHost,
|
||||||
bulkUpdateSSHHosts,
|
bulkUpdateSSHHosts,
|
||||||
reorderSSHHosts,
|
reorderSSHHosts,
|
||||||
|
getHostPolicy,
|
||||||
|
type HostPolicy,
|
||||||
deleteSSHHost,
|
deleteSSHHost,
|
||||||
getSSHHostById,
|
getSSHHostById,
|
||||||
testProxyConnection,
|
testProxyConnection,
|
||||||
@@ -909,6 +911,9 @@ export {
|
|||||||
updateLogLevel,
|
updateLogLevel,
|
||||||
getSessionTimeout,
|
getSessionTimeout,
|
||||||
updateSessionTimeout,
|
updateSessionTimeout,
|
||||||
|
getHttpsSettings,
|
||||||
|
updateHttpsSettings,
|
||||||
|
type HttpsSettings,
|
||||||
} from "@/api/settings-api";
|
} from "@/api/settings-api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ export {
|
|||||||
} from "@/types/host-sidebar-preferences";
|
} from "@/types/host-sidebar-preferences";
|
||||||
export type {
|
export type {
|
||||||
HostData,
|
HostData,
|
||||||
|
HostLock,
|
||||||
HostProtocolAuthInput,
|
HostProtocolAuthInput,
|
||||||
ProxyNode,
|
ProxyNode,
|
||||||
SSHHost,
|
SSHHost,
|
||||||
@@ -656,7 +657,11 @@ export {
|
|||||||
getHostAccess,
|
getHostAccess,
|
||||||
getHostAuthOverride,
|
getHostAuthOverride,
|
||||||
getHostPassword,
|
getHostPassword,
|
||||||
|
getHostPolicy,
|
||||||
|
type HostPolicy,
|
||||||
getHostSidebarPreferences,
|
getHostSidebarPreferences,
|
||||||
|
getHttpsSettings,
|
||||||
|
type HttpsSettings,
|
||||||
getLogLevel,
|
getLogLevel,
|
||||||
getPasswordLoginAllowed,
|
getPasswordLoginAllowed,
|
||||||
getPasswordResetAllowed,
|
getPasswordResetAllowed,
|
||||||
@@ -701,6 +706,7 @@ export {
|
|||||||
updateExternalAutoProvision,
|
updateExternalAutoProvision,
|
||||||
updateFolderMetadata,
|
updateFolderMetadata,
|
||||||
updateHostAccess,
|
updateHostAccess,
|
||||||
|
updateHttpsSettings,
|
||||||
updateLogLevel,
|
updateLogLevel,
|
||||||
updatePasswordLoginAllowed,
|
updatePasswordLoginAllowed,
|
||||||
updatePasswordResetAllowed,
|
updatePasswordResetAllowed,
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
|
|||||||
* Enrolment sections login methods and second factors register, such as
|
* Enrolment sections login methods and second factors register, such as
|
||||||
* adding a passkey or setting up an authenticator app.
|
* adding a passkey or setting up an authenticator app.
|
||||||
*/
|
*/
|
||||||
export function AuthEnrollmentSections() {
|
export function AuthEnrollmentSections({
|
||||||
|
factorsOnly = false,
|
||||||
|
}: {
|
||||||
|
/** Only second factors, for a login policy that requires one. */
|
||||||
|
factorsOnly?: boolean;
|
||||||
|
} = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sections = [...useLoginMethods(), ...useSecondFactors()].filter(
|
const methods = useLoginMethods();
|
||||||
|
const factors = useSecondFactors();
|
||||||
|
const sections = [...(factorsOnly ? [] : methods), ...factors].filter(
|
||||||
(entry) => entry.enrollment,
|
(entry) => entry.enrollment,
|
||||||
);
|
);
|
||||||
if (sections.length === 0) return null;
|
if (sections.length === 0) return null;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
connectionOrigin: h.connectionOrigin ?? null,
|
connectionOrigin: h.connectionOrigin ?? null,
|
||||||
localOnly: !!h.localOnly,
|
localOnly: !!h.localOnly,
|
||||||
sharedCopy: !!h.sharedCopy,
|
sharedCopy: !!h.sharedCopy,
|
||||||
|
...(h.lock ? { lock: h.lock } : {}),
|
||||||
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
||||||
sshPort:
|
sshPort:
|
||||||
h.sshPort ??
|
h.sshPort ??
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const mainAxios = vi.hoisted(() => ({
|
|||||||
|
|
||||||
const authMethodsApi = vi.hoisted(() => ({
|
const authMethodsApi = vi.hoisted(() => ({
|
||||||
getLoginMethods: vi.fn(),
|
getLoginMethods: vi.fn(),
|
||||||
|
getRememberDeviceAllowed: vi.fn(async () => true),
|
||||||
submitLoginMethod: vi.fn(),
|
submitLoginMethod: vi.fn(),
|
||||||
startLoginRedirect: vi.fn(),
|
startLoginRedirect: vi.fn(),
|
||||||
verifySecondFactor: vi.fn(),
|
verifySecondFactor: vi.fn(),
|
||||||
@@ -150,6 +151,18 @@ async function openExternalTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("login methods on the login screen", () => {
|
describe("login methods on the login screen", () => {
|
||||||
|
it("offers remember me unless the login policy turned it off", async () => {
|
||||||
|
await openExternalTab();
|
||||||
|
expect(screen.getByText("auth.rememberMe")).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
authMethodsApi.getRememberDeviceAllowed.mockResolvedValueOnce(false);
|
||||||
|
await openExternalTab();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("auth.rememberMe")).toBeNull(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists every enabled method", async () => {
|
it("lists every enabled method", async () => {
|
||||||
await openExternalTab();
|
await openExternalTab();
|
||||||
// A redirect method whose plugin UI is not loaded gets a plain button.
|
// A redirect method whose plugin UI is not loaded gets a plain button.
|
||||||
|
|||||||
Reference in new issue
Block a user