Skip to content

Commit 1287f2c

Browse files
committed
Merge pull request 'feat(web-ng): Teleport-style SSO certificate SSH console by default' (#4848) from feat/remote-access-sso-cert-ux into staging
Reviewed-on: https://code.carverauto.dev/carverauto/serviceradar/pulls/4848
2 parents 299f809 + 8ca4a7d commit 1287f2c

12 files changed

Lines changed: 965 additions & 145 deletions

File tree

docs/docs/remote-access.md

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,60 @@ Before enabling remote access, make sure these pieces are in place:
3434

3535
ServiceRadar supports several credential paths, but they are not equal.
3636

37-
Preferred path:
38-
39-
1. The user signs in through ServiceRadar with OIDC or SAML SSO. A local password login is deliberately ineligible for SSH certificate issuance, even if the account previously used SSO.
40-
2. ServiceRadar evaluates RBAC and the remote access target policy.
41-
3. ServiceRadar signs a short-lived OpenSSH user certificate with allowed principals, target restrictions, key ID, and TTL.
42-
4. The browser session and edge path use the short-lived certificate for this one remote access session.
43-
5. The target host trusts the ServiceRadar user CA and maps the certificate principal to a local or LDAP-backed Linux account.
44-
45-
Transitional paths:
46-
47-
- User-present password or key material can be used for a session when the operator allows it. The material should remain memory-only for that session.
48-
- Centrally brokered reusable credentials can be used for tightly scoped break-glass or legacy Proxmox host shell workflows. They must be encrypted centrally, released only for one approved session, and bound to the target, agent, gateway, protocol, and TTL.
49-
50-
Avoid:
37+
### Preferred path: Teleport-style SSO certificates (default in the UI)
38+
39+
This is the enterprise default. The browser SSH console no longer asks operators to
40+
paste private keys for certificate sessions.
41+
42+
1. The user signs in through ServiceRadar with OIDC or SAML SSO (Authentik in the
43+
demo/lab environment). A local password login is deliberately ineligible for
44+
SSH certificate issuance, even if the account previously used SSO.
45+
2. The operator opens **Devices → Remote Access → SSH**. The form defaults to
46+
**SSO certificate** mode. Unix account names come from certificate policy via
47+
`GET /api/remote-access/devices/:device_uid/ssh-options` (account names only;
48+
opaque principals are never sent to the browser). The preferred account is
49+
remembered per browser profile (Teleport-like default account pick).
50+
3. On Connect, the browser generates a one-session Ed25519 keypair with WebCrypto
51+
(`crypto.subtle`). The private key stays in tab memory only. The public key is
52+
sent with the session attach credential after RBAC and target policy succeed.
53+
4. ServiceRadar signs a short-lived OpenSSH user certificate with allowed
54+
principals, target restrictions, key ID, and TTL.
55+
5. Traffic still follows the edge path (never direct from the operator laptop to
56+
the host):
57+
58+
```text
59+
browser → web-ng → agent-gateway → edge agent → target sshd
60+
```
61+
62+
6. The target host trusts the ServiceRadar user CA and maps the certificate
63+
principal to a local Unix account, FreeIPA/LDAP account, or other NSS-backed
64+
identity. See [Unix identity (FreeIPA)](#unix-identity-freeipa).
65+
66+
### Why browser-mediated keys (not pasted keys)
67+
68+
Teleport and similar products generate short-lived keys client-side so operators
69+
never handle long-lived SSH private keys. ServiceRadar follows the same pattern
70+
for certificate mode: the private key is ephemeral, never written to disk by the
71+
product UI, and never returned from the control plane. Policy principals remain
72+
server-side only.
73+
74+
### Transitional paths
75+
76+
- User-present password or key material can be used for a session when the
77+
operator expands **Show advanced / legacy**. The material should remain
78+
memory-only for that session.
79+
- Centrally brokered reusable credentials can be used for tightly scoped
80+
break-glass or legacy Proxmox host shell workflows. They must be encrypted
81+
centrally, released only for one approved session, and bound to the target,
82+
agent, gateway, protocol, and TTL.
83+
84+
### Avoid
5185

5286
- Agent-local reusable SSH private keys.
5387
- Shared master accounts that can log in to every target.
5488
- Long-lived private keys stored in plugin parameters or local agent config.
89+
- Asking operators to paste CA private keys or session private keys for normal SSO
90+
certificate login.
5591

5692
## SSH CA Setup
5793

@@ -276,6 +312,116 @@ printf "srp_v1_6d8b1e49fbe24ad487ce2c5c\n" | \
276312
sudo chmod 0644 /etc/ssh/serviceradar/current/authorized-principals/mfreeman
277313
```
278314

315+
## Unix identity (FreeIPA)
316+
317+
SSH certificates authenticate the *session*. They do not create Unix accounts.
318+
The account named in the browser form (for example `mfreeman`) must already
319+
exist on the target through FreeIPA (preferred), a local user, or another
320+
NSS/PAM source.
321+
322+
**Decision:** FreeIPA is the fleet Unix IdM. It owns POSIX users/groups,
323+
**centralized sudo rules**, HBAC, host enrollment, and Kerberos. Prefer
324+
**2 VMs outside Kubernetes** for production FreeIPA (not Synology LDAP, not
325+
Authentik LDAP outpost as the sudo plane). Platform runbook:
326+
platform gitops `k8s/freeipa/README.md` (VM-primary; k8s StatefulSet is lab-only).
327+
328+
### Division of labor
329+
330+
| System | Role |
331+
|--------|------|
332+
| **Authentik** | Human SSO into ServiceRadar (OIDC/SAML). RBAC groups for who may open remote access. Already deployed at `https://auth.carverauto.dev`. Optional LDAP Source from FreeIPA for user/group sync. |
333+
| **ServiceRadar SSH CA** | Issues short-lived OpenSSH user certificates bound to opaque principals and certificate policy accounts. |
334+
| **FreeIPA** | Enterprise Unix identity: POSIX accounts, **sudo rules**, HBAC, host enrollment. Hosts join FreeIPA and resolve `mfreeman` via SSSD. |
335+
| **Edge agent path** | All interactive traffic still tunnels browser → web-ng → agent-gateway → edge agent → target. FreeIPA does not open a second path around the agent. |
336+
337+
Authentik is not a FreeIPA replacement for sudo/HBAC/host join. Certificate
338+
policy still maps ServiceRadar opaque principals → the same POSIX account names
339+
FreeIPA provides.
340+
341+
Until FreeIPA is online, lab hosts may use local accounts (as on `dusk01` /
342+
`192.168.2.22`) with the same CA + `AuthorizedPrincipalsFile` layout.
343+
344+
345+
### Authentik groups (Model A)
346+
347+
Authentik is the source of truth for people. FreeIPA only receives users who are
348+
explicitly gated:
349+
350+
| Authentik group | Meaning |
351+
|-----------------|---------|
352+
| `unix-users` | May have a FreeIPA POSIX account and host login (HBAC). |
353+
| `unix-sudo` | Subset of Unix users who receive FreeIPA sudo rules. |
354+
355+
Operator flow: create/invite user in Authentik → add to `unix-users` (and
356+
`unix-sudo` if needed) → run the provisioner in platform gitops
357+
`k8s/freeipa/PROVISIONING.md`. Username must match FreeIPA `uid` and the
358+
ServiceRadar certificate policy account name.
359+
360+
### FreeIPA + sudo (summary)
361+
362+
Manage privilege in IPA after clients enroll (`ipa sudorule-*`, `ipa hbacrule-*`,
363+
host groups / user groups). Do not scatter permanent sudoers on each host for
364+
fleet operators. See the platform FreeIPA README for install topology, client
365+
enrollment, Authentik LDAP Source, and example sudo/HBAC commands.
366+
367+
### FreeIPA platform docs
368+
369+
```text
370+
# platform gitops repo
371+
k8s/freeipa/README.md # VM topology, sudo/HBAC, Authentik, checklist
372+
k8s/freeipa/argocd-application.yaml
373+
k8s/freeipa/base/ # optional lab StatefulSet only
374+
```
375+
376+
## Lab target: dusk01 (192.168.2.22)
377+
378+
Use a disposable lab host for end-to-end SSH certificate tests. Prefer
379+
**dusk01** (`192.168.2.22`) over Kubernetes worker nodes so enrollment mistakes
380+
cannot break the control plane.
381+
382+
Demo certificate policy already includes target keys `192.168.2.22`, `dusk01`,
383+
and the inventory device UID when present, with Unix account `mfreeman` and an
384+
opaque principal of the form `srp_v1_...`.
385+
386+
Enrollment (once per host, with the **public** CA key only):
387+
388+
```bash
389+
# On an operator workstation with SSH to dusk01 as a sudo user:
390+
CA_PUB='ssh-ed25519 AAAA... serviceradar-demo-remote-access-ca-...'
391+
PRINCIPAL='srp_v1_...' # from certificate-policy for this target/account
392+
393+
ssh mfreeman@192.168.2.22 bash -s <<EOF
394+
set -euo pipefail
395+
sudo install -d -o root -g root -m 0755 /etc/ssh/serviceradar/current/authorized-principals
396+
printf '%s\n' "\$CA_PUB" | sudo tee /etc/ssh/serviceradar/current/trusted-user-ca-keys.pub >/dev/null
397+
printf '%s\n' "\$PRINCIPAL" | sudo tee /etc/ssh/serviceradar/current/authorized-principals/mfreeman >/dev/null
398+
sudo tee /etc/ssh/sshd_config.d/60-serviceradar-user-ca.conf >/dev/null <<'CONF'
399+
PubkeyAuthentication yes
400+
TrustedUserCAKeys /etc/ssh/serviceradar/current/trusted-user-ca-keys.pub
401+
AuthorizedPrincipalsFile /etc/ssh/serviceradar/current/authorized-principals/%u
402+
CONF
403+
sudo sshd -t && sudo systemctl reload ssh
404+
EOF
405+
```
406+
407+
Derive the public key from the demo CA secret only in a secure temporary
408+
workspace (never commit the private key):
409+
410+
```bash
411+
kubectl get secret -n demo serviceradar-ssh-ca -o jsonpath='{.data.ca-key}' \
412+
| base64 -d > /tmp/sr-ca && chmod 600 /tmp/sr-ca
413+
ssh-keygen -y -f /tmp/sr-ca
414+
shred -u /tmp/sr-ca
415+
```
416+
417+
UI test path after enrollment:
418+
419+
1. Sign in to demo web-ng with Authentik SSO.
420+
2. Open the dusk01 inventory device → Remote Access → SSH.
421+
3. Confirm default mode is SSO certificate, account `mfreeman` (or policy
422+
dropdown), no private-key paste fields.
423+
4. Connect. Expect a short-lived certificate session through the agent path.
424+
279425
## Ansible Enrollment
280426

281427
You can automate SSH CA enrollment with a small Ansible playbook that installs the public CA key and SSH server configuration on each target.

elixir/serviceradar_core/lib/serviceradar/edge/remote_access_sessions.ex

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,103 @@ defmodule ServiceRadar.Edge.RemoteAccessSessions do
122122
end
123123
end
124124

125+
@doc """
126+
Returns browser-safe SSH console options for a device.
127+
128+
Account names come from the configured certificate policy. Opaque principals
129+
are never returned to the client.
130+
"""
131+
@spec ssh_console_options(String.t() | map() | Device.t(), keyword()) ::
132+
{:ok, map()} | {:error, term()}
133+
def ssh_console_options(device_or_uid, opts \\ [])
134+
135+
def ssh_console_options(device_uid, opts) when is_binary(device_uid) do
136+
case resolve_device(device_uid) do
137+
{:ok, %Device{} = device} ->
138+
ssh_console_options(device, opts)
139+
140+
{:ok, nil} ->
141+
{:error, :device_not_found}
142+
143+
{:error, error} = result ->
144+
if not_found_error?(error), do: {:error, :device_not_found}, else: result
145+
146+
other ->
147+
other
148+
end
149+
end
150+
151+
def ssh_console_options(%Device{} = device, _opts) do
152+
policy = configured_ssh_certificate_policy(device)
153+
154+
accounts =
155+
policy
156+
|> policy_value("accounts")
157+
|> account_list()
158+
|> List.wrap()
159+
|> Enum.flat_map(&public_ssh_account/1)
160+
161+
{:ok,
162+
%{
163+
"default_credential_mode" => "ssh_certificate",
164+
"accounts" => accounts,
165+
"ttl_seconds" => positive_int(policy_value(policy, "ttl_seconds")),
166+
"device_uid" => value_string(device, [:uid, "uid"])
167+
}}
168+
end
169+
170+
def ssh_console_options(device, opts) when is_map(device) do
171+
case value_string(device, [:uid, "uid"]) do
172+
uid when is_binary(uid) and uid != "" ->
173+
ssh_console_options(uid, opts)
174+
175+
_ ->
176+
# Allow tests/stubs to pass a map shaped like a device without a DB round-trip.
177+
policy = configured_ssh_certificate_policy(device)
178+
179+
accounts =
180+
policy
181+
|> policy_value("accounts")
182+
|> account_list()
183+
|> List.wrap()
184+
|> Enum.flat_map(&public_ssh_account/1)
185+
186+
{:ok,
187+
%{
188+
"default_credential_mode" => "ssh_certificate",
189+
"accounts" => accounts,
190+
"ttl_seconds" => positive_int(policy_value(policy, "ttl_seconds")),
191+
"device_uid" => nil
192+
}}
193+
end
194+
end
195+
196+
def ssh_console_options(_device, _opts), do: {:error, :device_not_found}
197+
198+
defp public_ssh_account(account) when is_map(account) do
199+
name =
200+
account
201+
|> normalize_policy_map()
202+
|> policy_value("name")
203+
|> case do
204+
value when is_binary(value) -> String.trim(value)
205+
_ -> ""
206+
end
207+
208+
if name == "" do
209+
[]
210+
else
211+
[%{"name" => name}]
212+
end
213+
end
214+
215+
defp public_ssh_account(name) when is_binary(name) do
216+
trimmed = String.trim(name)
217+
if trimmed == "", do: [], else: [%{"name" => trimmed}]
218+
end
219+
220+
defp public_ssh_account(_account), do: []
221+
125222
@doc """
126223
Consumes a single-use browser attach ticket and marks the session attached.
127224
"""

elixir/serviceradar_core/test/serviceradar/edge/remote_access_sessions_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,36 @@ defmodule ServiceRadar.Edge.RemoteAccessSessionsTest do
369369
assert denial_audit[:details][:failure_reason] == "ssh_principal_policy_required"
370370
end
371371

372+
test "ssh_console_options returns account names without opaque principals" do
373+
uid = unique_uid("ssh-options")
374+
375+
Application.put_env(:serviceradar_core, :remote_access_ssh_certificate_policy, %{
376+
"accounts" => [
377+
%{"name" => "mfreeman", "principals" => [@principal]},
378+
%{"name" => "deploy", "principals" => [@target_principal]}
379+
],
380+
"ttl_seconds" => 900,
381+
"targets" => %{
382+
uid => %{
383+
"accounts" => [
384+
%{"name" => "mfreeman", "principals" => [@target_principal]}
385+
],
386+
"ttl_seconds" => 600
387+
}
388+
}
389+
})
390+
391+
insert_device!(uid, agent_id: "agent-options", gateway_id: "gateway-options")
392+
393+
assert {:ok, options} = RemoteAccessSessions.ssh_console_options(uid)
394+
assert options["default_credential_mode"] == "ssh_certificate"
395+
assert options["ttl_seconds"] == 600
396+
assert options["device_uid"] == uid
397+
assert options["accounts"] == [%{"name" => "mfreeman"}]
398+
refute inspect(options) =~ "srp_v1_"
399+
refute inspect(options) =~ "principals"
400+
end
401+
372402
test "SSH certificate sessions materialize only trusted account policy from deployment config" do
373403
uid = unique_uid("ssh-cert-policy")
374404

0 commit comments

Comments
 (0)