Compare commits

..

3 Commits

14 changed files with 325 additions and 91 deletions
+119 -40
View File
@@ -13,6 +13,21 @@ import "codit/internal/util"
var ErrSSHServerHasSessionHistory error = errors.New("ssh server has session history")
type SSHServerDeleteReferenceError struct {
Kind string
Count int64
}
func (e *SSHServerDeleteReferenceError) Error() string {
var suffix string
suffix = ""
if e.Count != 1 {
suffix = "s"
}
return fmt.Sprintf("cannot delete ssh server because it is used by %d %s%s", e.Count, e.Kind, suffix)
}
func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
var rows *sql.Rows
var err error
@@ -20,7 +35,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
var item models.SSHServer
var tagsJSON string
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers
ORDER BY name, host, port`)
if err != nil {
@@ -29,7 +44,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
@@ -52,10 +67,10 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
var tagsJSON string
var err error
row = s.QueryRow(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
row = s.QueryRow(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers
WHERE public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
@@ -75,9 +90,9 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
var trimmedUserID string
trimmedUserID = strings.TrimSpace(userID)
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers
WHERE created_by_kind = 'user' AND created_by_subject_id = ?
WHERE owner_scope = 'user' AND owner_user_public_id = ?
ORDER BY name, host, port`, trimmedUserID)
if err != nil {
return nil, err
@@ -85,7 +100,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
@@ -93,7 +108,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
if err != nil {
return nil, err
}
item.Editable = item.CreatedByKind == "user" && item.CreatedBySubjectID == trimmedUserID
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
items = append(items, item)
}
err = rows.Err()
@@ -133,7 +148,7 @@ func (s *Store) GetOwnedSSHServerForUser(userID string, id string) (models.SSHSe
if err != nil {
return item, err
}
item.Editable = item.CreatedByKind == "user" && item.CreatedBySubjectID == trimmedUserID
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
if !item.Editable {
return item, sql.ErrNoRows
}
@@ -152,6 +167,14 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
if item.Port <= 0 { item.Port = 22 }
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
item.Tags = normalizeStringList(item.Tags)
item.OwnerScope = strings.TrimSpace(item.OwnerScope)
if item.OwnerScope != "user" {
item.OwnerScope = "admin"
item.OwnerUserID = ""
}
if item.OwnerScope == "user" && strings.TrimSpace(item.OwnerUserID) == "" {
return item, errors.New("owner_user_id is required")
}
tagsJSON, err = encodeStringList(item.Tags)
if err != nil { return item, err }
now = time.Now().UTC().Unix()
@@ -167,12 +190,14 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
tags_json,
enabled,
host_key_policy,
owner_scope,
owner_user_public_id,
created_by_kind,
created_by_subject_id,
created_by_subject_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Host),
@@ -181,6 +206,8 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
tagsJSON,
item.Enabled,
item.HostKeyPolicy,
strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedBySubjectName),
@@ -224,14 +251,28 @@ func (s *Store) DeleteSSHServer(id string) error {
var err error
var row *sql.Row
var count int64
var trimmedID string
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_sessions WHERE server_public_id = ?`, strings.TrimSpace(id))
trimmedID = strings.TrimSpace(id)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_access_profiles WHERE server_public_id = ?`, trimmedID)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 {
return ErrSSHServerHasSessionHistory
return &SSHServerDeleteReferenceError{Kind: "SSH access profile", Count: count}
}
_, err = s.Exec(`DELETE FROM ssh_servers WHERE public_id = ?`, strings.TrimSpace(id))
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_server_group_members WHERE server_public_id = ?`, trimmedID)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 {
return &SSHServerDeleteReferenceError{Kind: "SSH server group membership", Count: count}
}
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_sessions WHERE server_public_id = ?`, trimmedID)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 {
return &SSHServerDeleteReferenceError{Kind: "SSH session history record", Count: count}
}
_, err = s.Exec(`DELETE FROM ssh_servers WHERE public_id = ?`, trimmedID)
return err
}
@@ -272,8 +313,10 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_valid_seconds,
p.max_valid_seconds,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
p.valid_after,
p.valid_before,
p.created_by_kind,
p.created_by_subject_id,
p.created_by_subject_name,
@@ -287,6 +330,8 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
s.tags_json,
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
s.created_by_kind,
s.created_by_subject_id,
s.created_by_subject_name,
@@ -330,8 +375,10 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
&item.AuthPublicKeyFingerprint,
&item.SSHUserCAID,
&grantIDsJSON,
&item.DefaultValidSeconds,
&item.MaxValidSeconds,
&item.DefaultCertValidSeconds,
&item.MaxCertValidSeconds,
&item.ValidAfter,
&item.ValidBefore,
&item.CreatedByKind,
&item.CreatedBySubjectID,
&item.CreatedBySubjectName,
@@ -345,6 +392,8 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
&serverTagsJSON,
&item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.OwnerScope,
&item.Server.OwnerUserID,
&item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName,
@@ -408,8 +457,10 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_valid_seconds,
p.max_valid_seconds,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
p.valid_after,
p.valid_before,
p.created_by_kind,
p.created_by_subject_id,
p.created_by_subject_name,
@@ -423,6 +474,8 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
s.tags_json,
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
s.created_by_kind,
s.created_by_subject_id,
s.created_by_subject_name,
@@ -462,8 +515,10 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
&item.AuthPublicKeyFingerprint,
&item.SSHUserCAID,
&grantIDsJSON,
&item.DefaultValidSeconds,
&item.MaxValidSeconds,
&item.DefaultCertValidSeconds,
&item.MaxCertValidSeconds,
&item.ValidAfter,
&item.ValidBefore,
&item.CreatedByKind,
&item.CreatedBySubjectID,
&item.CreatedBySubjectName,
@@ -477,6 +532,8 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
&serverTagsJSON,
&item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.OwnerScope,
&item.Server.OwnerUserID,
&item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName,
@@ -531,8 +588,10 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
item.ID, err = util.NewID()
if err != nil { return item, err }
}
if item.DefaultValidSeconds <= 0 { item.DefaultValidSeconds = 3600 }
if item.MaxValidSeconds <= 0 { item.MaxValidSeconds = item.DefaultValidSeconds }
if item.DefaultCertValidSeconds <= 0 { item.DefaultCertValidSeconds = 3600 }
if item.MaxCertValidSeconds <= 0 { item.MaxCertValidSeconds = item.DefaultCertValidSeconds }
if item.ValidAfter < 0 { item.ValidAfter = 0 }
if item.ValidBefore < 0 { item.ValidBefore = 0 }
item.SSHPrincipalGrantIDs = normalizeStringList(item.SSHPrincipalGrantIDs)
grantIDsJSON, err = encodeStringList(item.SSHPrincipalGrantIDs)
@@ -593,14 +652,16 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
auth_public_key_fingerprint,
ssh_user_ca_public_id,
ssh_principal_grant_ids_json,
default_valid_seconds,
max_valid_seconds,
default_cert_valid_seconds,
max_cert_valid_seconds,
valid_after,
valid_before,
created_by_kind,
created_by_subject_id,
created_by_subject_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerTargetType),
@@ -620,8 +681,10 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
strings.TrimSpace(item.AuthPublicKeyFingerprint),
emptyStringToNil(item.SSHUserCAID),
grantIDsJSON,
item.DefaultValidSeconds,
item.MaxValidSeconds,
item.DefaultCertValidSeconds,
item.MaxCertValidSeconds,
item.ValidAfter,
item.ValidBefore,
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedBySubjectName),
@@ -674,8 +737,10 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
return item, errors.New("auth_method is required")
}
if item.DefaultValidSeconds <= 0 { item.DefaultValidSeconds = 3600 }
if item.MaxValidSeconds <= 0 { item.MaxValidSeconds = item.DefaultValidSeconds }
if item.DefaultCertValidSeconds <= 0 { item.DefaultCertValidSeconds = 3600 }
if item.MaxCertValidSeconds <= 0 { item.MaxCertValidSeconds = item.DefaultCertValidSeconds }
if item.ValidAfter < 0 { item.ValidAfter = 0 }
if item.ValidBefore < 0 { item.ValidBefore = 0 }
item.SSHPrincipalGrantIDs = normalizeStringList(item.SSHPrincipalGrantIDs)
grantIDsJSON, err = encodeStringList(item.SSHPrincipalGrantIDs)
@@ -723,7 +788,7 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
item.SecretID = secret.ID
}
_, err = tx.Exec(`UPDATE ssh_access_profiles
SET server_public_id = ?, server_target_type = ?, server_group_public_id = ?, name = ?, description = ?, remote_username = ?, auth_method = ?, second_factor_mode = ?, owner_scope = ?, owner_user_public_id = ?, allow_user_edit = ?, enabled = ?, secret_public_id = ?, ssh_credential_public_id = ?, auth_public_key = ?, auth_public_key_fingerprint = ?, ssh_user_ca_public_id = ?, ssh_principal_grant_ids_json = ?, default_valid_seconds = ?, max_valid_seconds = ?, updated_at = ?
SET server_public_id = ?, server_target_type = ?, server_group_public_id = ?, name = ?, description = ?, remote_username = ?, auth_method = ?, second_factor_mode = ?, owner_scope = ?, owner_user_public_id = ?, allow_user_edit = ?, enabled = ?, secret_public_id = ?, ssh_credential_public_id = ?, auth_public_key = ?, auth_public_key_fingerprint = ?, ssh_user_ca_public_id = ?, ssh_principal_grant_ids_json = ?, default_cert_valid_seconds = ?, max_cert_valid_seconds = ?, valid_after = ?, valid_before = ?, updated_at = ?
WHERE public_id = ?`,
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerTargetType),
@@ -743,8 +808,10 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
strings.TrimSpace(item.AuthPublicKeyFingerprint),
emptyStringToNil(item.SSHUserCAID),
grantIDsJSON,
item.DefaultValidSeconds,
item.MaxValidSeconds,
item.DefaultCertValidSeconds,
item.MaxCertValidSeconds,
item.ValidAfter,
item.ValidBefore,
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
@@ -852,8 +919,10 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_valid_seconds,
p.max_valid_seconds,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
p.valid_after,
p.valid_before,
p.created_by_kind,
p.created_by_subject_id,
p.created_by_subject_name,
@@ -867,6 +936,8 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
s.tags_json,
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
s.created_by_kind,
s.created_by_subject_id,
s.created_by_subject_name,
@@ -880,13 +951,15 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.public_id = t.target_public_id
LEFT JOIN user_group_members gm ON t.target_type = 'group' AND gm.group_id = ug.id
LEFT JOIN users gu ON gm.user_id = gu.id
WHERE p.enabled = 1
AND s.enabled = 1
AND (
WHERE (
(p.owner_scope = 'user' AND p.owner_user_public_id = ?)
OR
(
p.owner_scope = 'admin_shared'
AND p.enabled = 1
AND s.enabled = 1
AND (p.valid_after = 0 OR p.valid_after <= strftime('%s','now'))
AND (p.valid_before = 0 OR p.valid_before >= strftime('%s','now'))
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
OR
@@ -930,8 +1003,10 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
&item.AuthPublicKeyFingerprint,
&item.SSHUserCAID,
&grantIDsJSON,
&item.DefaultValidSeconds,
&item.MaxValidSeconds,
&item.DefaultCertValidSeconds,
&item.MaxCertValidSeconds,
&item.ValidAfter,
&item.ValidBefore,
&item.CreatedByKind,
&item.CreatedBySubjectID,
&item.CreatedBySubjectName,
@@ -945,6 +1020,8 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
&serverTagsJSON,
&item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.OwnerScope,
&item.Server.OwnerUserID,
&item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName,
@@ -1401,6 +1478,8 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
payload,
password,
metadata_json,
owner_scope,
owner_user_public_id,
created_by_kind,
created_by_subject_id,
created_by_subject_name,
+2 -2
View File
@@ -72,7 +72,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
var tagsJSON string
var err error
rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.host_key_policy, s.created_by_kind, s.created_by_subject_id, s.created_by_subject_name, s.created_at, s.updated_at
rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.host_key_policy, s.owner_scope, s.owner_user_public_id, s.created_by_kind, s.created_by_subject_id, s.created_by_subject_name, s.created_at, s.updated_at
FROM ssh_server_group_members gm
JOIN ssh_servers s ON s.public_id = gm.server_public_id
WHERE gm.group_public_id = ?
@@ -80,7 +80,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { return nil, err }
item.Tags, err = decodeStringList(tagsJSON)
if err != nil { return nil, err }
+39 -11
View File
@@ -62,8 +62,10 @@ type sshAccessProfileRequest struct {
PasswordText string `json:"password_text"`
SSHUserCAID string `json:"ssh_user_ca_id"`
SSHPrincipalGrantIDs []string `json:"ssh_principal_grant_ids"`
DefaultValidSeconds int64 `json:"default_valid_seconds"`
MaxValidSeconds int64 `json:"max_valid_seconds"`
DefaultCertValidSeconds int64 `json:"default_cert_valid_seconds"`
MaxCertValidSeconds int64 `json:"max_cert_valid_seconds"`
ValidAfter int64 `json:"valid_after"`
ValidBefore int64 `json:"valid_before"`
Targets []sshAccessProfileTargetRequest `json:"targets"`
}
@@ -344,6 +346,8 @@ func (api *API) CreateSSHServerAdmin(w http.ResponseWriter, r *http.Request, _ m
Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
OwnerScope: "admin",
OwnerUserID: "",
CreatedByKind: actorKind,
CreatedBySubjectID: actorID,
CreatedBySubjectName: actorName,
@@ -410,12 +414,17 @@ func (api *API) UpdateSSHServerAdmin(w http.ResponseWriter, r *http.Request, par
func (api *API) DeleteSSHServerAdmin(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
var referenceErr *db.SSHServerDeleteReferenceError
if !api.requireAdmin(w, r) {
return
}
err = api.store(r).DeleteSSHServer(params["id"])
if err != nil {
if errors.As(err, &referenceErr) {
WriteJSON(w, http.StatusConflict, map[string]string{"error": err.Error() + "; remove the references first or disable the server instead"})
return
}
if err == db.ErrSSHServerHasSessionHistory {
WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete ssh server while session history exists; disable it instead"})
return
@@ -1114,6 +1123,8 @@ func (api *API) CreateSSHServerForSelf(w http.ResponseWriter, r *http.Request, _
Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
OwnerScope: "user",
OwnerUserID: user.ID,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
@@ -1189,6 +1200,7 @@ func (api *API) DeleteSSHServerForSelf(w http.ResponseWriter, r *http.Request, p
var user models.User
var ok bool
var err error
var referenceErr *db.SSHServerDeleteReferenceError
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
@@ -1206,6 +1218,10 @@ func (api *API) DeleteSSHServerForSelf(w http.ResponseWriter, r *http.Request, p
}
err = api.store(r).DeleteSSHServer(params["id"])
if err != nil {
if errors.As(err, &referenceErr) {
WriteJSON(w, http.StatusConflict, map[string]string{"error": err.Error() + "; remove the references first or disable the server instead"})
return
}
if err == db.ErrSSHServerHasSessionHistory {
WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete ssh server while session history exists; disable it instead"})
return
@@ -2060,14 +2076,24 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
publicKey = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
fingerprint = strings.TrimSpace(ssh.FingerprintSHA256(signer.PublicKey()))
}
if req.DefaultValidSeconds <= 0 {
req.DefaultValidSeconds = 3600
if req.DefaultCertValidSeconds <= 0 {
req.DefaultCertValidSeconds = 3600
}
if req.MaxValidSeconds <= 0 {
req.MaxValidSeconds = req.DefaultValidSeconds
if req.MaxCertValidSeconds <= 0 {
req.MaxCertValidSeconds = req.DefaultCertValidSeconds
}
if req.MaxValidSeconds < req.DefaultValidSeconds {
return item, errors.New("max_valid_seconds must be greater than or equal to default_valid_seconds")
if req.MaxCertValidSeconds < req.DefaultCertValidSeconds {
return item, errors.New("max_cert_valid_seconds must be greater than or equal to default_cert_valid_seconds")
}
if options.SelfService {
req.ValidAfter = 0
req.ValidBefore = 0
}
if req.ValidAfter < 0 || req.ValidBefore < 0 {
return item, errors.New("valid_after and valid_before must be zero or positive")
}
if req.ValidAfter > 0 && req.ValidBefore > 0 && req.ValidAfter > req.ValidBefore {
return item, errors.New("valid_after must be less than or equal to valid_before")
}
if req.ServerTargetType == "group" {
var group models.SSHServerGroup
@@ -2121,8 +2147,10 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
item.Enabled = req.Enabled
item.SSHUserCAID = req.SSHUserCAID
item.SSHPrincipalGrantIDs = req.SSHPrincipalGrantIDs
item.DefaultValidSeconds = req.DefaultValidSeconds
item.MaxValidSeconds = req.MaxValidSeconds
item.DefaultCertValidSeconds = req.DefaultCertValidSeconds
item.MaxCertValidSeconds = req.MaxCertValidSeconds
item.ValidAfter = req.ValidAfter
item.ValidBefore = req.ValidBefore
item.Targets = targets
if !isUpdate {
item.CreatedByKind = actorKind
@@ -3336,7 +3364,7 @@ func (api *API) buildSSHSessionAuth(store *db.Store, profile models.SSHAccessPro
principals, err = api.resolveSSHAccessProfilePrincipals(store, profile)
if err != nil { return nil, err }
signed, err = api.signSSHUserKeyWithCA(store, ca, profile.AuthPublicKey, "", principals, profile.DefaultValidSeconds, profile.MaxValidSeconds)
signed, err = api.signSSHUserKeyWithCA(store, ca, profile.AuthPublicKey, "", principals, profile.DefaultCertValidSeconds, profile.MaxCertValidSeconds)
if err != nil { return nil, err }
certKey, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(signed.Certificate)))
+6 -2
View File
@@ -527,6 +527,8 @@ type SSHServer struct {
Tags []string `json:"tags"`
Enabled bool `json:"enabled"`
HostKeyPolicy string `json:"host_key_policy"`
OwnerScope string `json:"owner_scope"`
OwnerUserID string `json:"owner_user_id"`
CreatedByKind string `json:"created_by_kind"`
CreatedBySubjectID string `json:"created_by_subject_id"`
CreatedBySubjectName string `json:"created_by_subject_name"`
@@ -614,8 +616,10 @@ type SSHAccessProfile struct {
AuthPublicKeyFingerprint string `json:"auth_public_key_fingerprint"`
SSHUserCAID string `json:"ssh_user_ca_id"`
SSHPrincipalGrantIDs []string `json:"ssh_principal_grant_ids"`
DefaultValidSeconds int64 `json:"default_valid_seconds"`
MaxValidSeconds int64 `json:"max_valid_seconds"`
DefaultCertValidSeconds int64 `json:"default_cert_valid_seconds"`
MaxCertValidSeconds int64 `json:"max_cert_valid_seconds"`
ValidAfter int64 `json:"valid_after"`
ValidBefore int64 `json:"valid_before"`
CreatedByKind string `json:"created_by_kind"`
CreatedBySubjectID string `json:"created_by_subject_id"`
CreatedBySubjectName string `json:"created_by_subject_name"`
@@ -0,0 +1,14 @@
ALTER TABLE ssh_servers ADD COLUMN owner_scope TEXT NOT NULL DEFAULT 'admin';
ALTER TABLE ssh_servers ADD COLUMN owner_user_public_id TEXT NOT NULL DEFAULT '';
UPDATE ssh_servers
SET owner_scope = CASE
WHEN created_by_kind = 'user' AND created_by_subject_id IN (SELECT public_id FROM users WHERE is_admin = 0) THEN 'user'
ELSE 'admin'
END,
owner_user_public_id = CASE
WHEN created_by_kind = 'user' AND created_by_subject_id IN (SELECT public_id FROM users WHERE is_admin = 0) THEN created_by_subject_id
ELSE ''
END;
CREATE INDEX IF NOT EXISTS idx_ssh_servers_owner ON ssh_servers(owner_scope, owner_user_public_id);
@@ -0,0 +1,4 @@
ALTER TABLE ssh_access_profiles RENAME COLUMN default_valid_seconds TO default_cert_valid_seconds;
ALTER TABLE ssh_access_profiles RENAME COLUMN max_valid_seconds TO max_cert_valid_seconds;
ALTER TABLE ssh_access_profiles ADD COLUMN valid_after INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ssh_access_profiles ADD COLUMN valid_before INTEGER NOT NULL DEFAULT 0;
+2 -2
View File
@@ -66,8 +66,8 @@ func TestSSHAccessProfileVisibilityForUserAndGroupTargets(t *testing.T) {
SecretPayload: "PRIVATE KEY",
AuthPublicKey: "ssh-ed25519 AAAA",
AuthPublicKeyFingerprint: "SHA256:test",
DefaultValidSeconds: 3600,
MaxValidSeconds: 3600,
DefaultCertValidSeconds: 3600,
MaxCertValidSeconds: 3600,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
+5 -2
View File
@@ -547,9 +547,11 @@ Important fields:
- `description`, `tags_json`
- `enabled`
- `host_key_policy`: `strict` requires an existing pinned host key; `trust_on_first_use` allows the first connection to pin the observed host key.
- `owner_scope`: `admin` for centrally managed servers or `user` for self-service personal servers.
- `owner_user_public_id`: owner user when `owner_scope = 'user'`.
- `created_by_kind`, `created_by_subject_id`, `created_by_subject_name`
`created_by_kind = 'user'` indicates a personal server; admin-created servers are shared through access profiles and targets.
Self-service server lists use `owner_scope = 'user'` and `owner_user_public_id`, not creator attribution. Admin-created servers stay centrally managed even when the admin actor is a normal user subject.
When `host_key_policy = 'trust_on_first_use'`, the first successful SSH connection writes the observed key into `ssh_server_host_keys`; later connections use the pinned-key check.
### `ssh_server_host_keys`
@@ -634,7 +636,8 @@ Important fields:
- `auth_public_key`, `auth_public_key_fingerprint`: public-key metadata.
- `ssh_user_ca_public_id`: SSH CA used for managed cert auth.
- `ssh_principal_grant_ids_json`: grants used for managed cert auth.
- `default_valid_seconds`, `max_valid_seconds`: cert/session validity bounds.
- `default_cert_valid_seconds`, `max_cert_valid_seconds`: SSH certificate validity bounds for managed-cert connections.
- `valid_after`, `valid_before`: optional profile availability window. `0` means unbounded.
- `enabled`, `allow_user_edit`: availability/editing controls.
Stored private-key and stored-password profiles ultimately reference `ssh_secrets`. Imported SSH credential private keys are not returned by the API; only public key metadata and fingerprints are exposed.
+19
View File
@@ -10107,6 +10107,13 @@ components:
enum:
- strict
- trust_on_first_use
owner_scope:
type: string
enum:
- admin
- user
owner_user_id:
type: string
editable:
type: boolean
additionalProperties: true
@@ -10293,6 +10300,18 @@ components:
type: string
ssh_user_ca_id:
type: string
default_cert_valid_seconds:
type: integer
format: int64
max_cert_valid_seconds:
type: integer
format: int64
valid_after:
type: integer
format: int64
valid_before:
type: integer
format: int64
enabled:
type: boolean
editable:
+12 -6
View File
@@ -912,6 +912,8 @@ export interface SSHServer {
tags: string[]
enabled: boolean
host_key_policy: 'strict' | 'trust_on_first_use'
owner_scope: 'admin' | 'user'
owner_user_id: string
created_by_kind: string
created_by_subject_id: string
created_by_subject_name: string
@@ -974,8 +976,10 @@ export interface SSHAccessProfile {
auth_public_key_fingerprint: string
ssh_user_ca_id: string
ssh_principal_grant_ids: string[]
default_valid_seconds: number
max_valid_seconds: number
default_cert_valid_seconds: number
max_cert_valid_seconds: number
valid_after: number
valid_before: number
created_by_kind: string
created_by_subject_id: string
created_by_subject_name: string
@@ -1015,8 +1019,10 @@ export type SSHAccessProfileAdminUpsertPayload = {
password_text?: string
ssh_user_ca_id?: string
ssh_principal_grant_ids?: string[]
default_valid_seconds: number
max_valid_seconds: number
default_cert_valid_seconds: number
max_cert_valid_seconds: number
valid_after: number
valid_before: number
targets: SSHAccessProfileTargetPayload[]
}
@@ -1033,8 +1039,8 @@ export type SSHAccessProfileSelfUpsertPayload = {
ssh_credential_id?: string
private_key_pem?: string
password_text?: string
default_valid_seconds: number
max_valid_seconds: number
default_cert_valid_seconds: number
max_cert_valid_seconds: number
}
export interface SSHCredential {
@@ -57,10 +57,12 @@ export default function SSHAccessProfileDetailsDialog(props: SSHAccessProfileDet
<>
<TextField label="SSH User CA" value={item?.ssh_user_ca_id || ''} InputProps={{ readOnly: true }} />
<TextField label="Principal Grants" value={(item?.ssh_principal_grant_ids || []).join(', ') || '-'} InputProps={{ readOnly: true }} />
<TextField label="Default Validity Seconds" value={String(item?.default_valid_seconds || 0)} InputProps={{ readOnly: true }} />
<TextField label="Max Validity Seconds" value={String(item?.max_valid_seconds || 0)} InputProps={{ readOnly: true }} />
<TextField label="Default Cert Validity Seconds" value={String(item?.default_cert_valid_seconds || 0)} InputProps={{ readOnly: true }} />
<TextField label="Max Cert Validity Seconds" value={String(item?.max_cert_valid_seconds || 0)} InputProps={{ readOnly: true }} />
</>
) : null}
<TextField label="Profile Valid After" value={fmt(item?.valid_after || 0)} InputProps={{ readOnly: true }} />
<TextField label="Profile Valid Before" value={fmt(item?.valid_before || 0)} InputProps={{ readOnly: true }} />
{showAdminFields ? <TextField label="Targets" value={targetsValue || '-'} multiline minRows={2} InputProps={{ readOnly: true }} /> : null}
{showAdminFields ? <TextField label="Created By Kind" value={item?.created_by_kind || '-'} InputProps={{ readOnly: true }} /> : null}
{showAdminFields ? <TextField label="Created By Subject ID" value={item?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} /> : null}
@@ -29,8 +29,10 @@ export type SSHAccessProfileFormState = {
password_text: string
ssh_user_ca_id: string
ssh_principal_grant_ids: string[]
default_valid_seconds: number
max_valid_seconds: number
default_cert_valid_seconds: number
max_cert_valid_seconds: number
valid_after: string
valid_before: string
user_ids: string[]
group_ids: string[]
}
@@ -52,6 +54,7 @@ type SSHAccessProfileFormDialogProps = {
userOptions?: User[]
groupOptions?: UserGroup[]
showTargets?: boolean
showValidityWindow?: boolean
onClose: () => void
onSave: () => void
}
@@ -61,6 +64,7 @@ export default function SSHAccessProfileFormDialog(props: SSHAccessProfileFormDi
const selectedUserOptions: User[] = (props.userOptions || []).filter((item) => props.form.user_ids.includes(item.id))
const selectedGroupOptions: UserGroup[] = (props.groupOptions || []).filter((item) => props.form.group_ids.includes(item.id))
const showTargets: boolean = Boolean(props.showTargets)
const showValidityWindow: boolean = Boolean(props.showValidityWindow)
const enabledServerGroups: SSHServerGroup[] = (props.serverGroups || []).filter((item) => item.enabled)
const canUseServerGroups: boolean = enabledServerGroups.length > 0
@@ -166,16 +170,16 @@ export default function SSHAccessProfileFormDialog(props: SSHAccessProfileFormDi
renderInput={(params) => <TextField {...params} label="Principal Grants" />}
/>
<TextField
label="Default Validity Seconds"
label="Default Cert Validity Seconds"
type="number"
value={props.form.default_valid_seconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, default_valid_seconds: Number(event.target.value) || 0 }))}
value={props.form.default_cert_valid_seconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, default_cert_valid_seconds: Number(event.target.value) || 0 }))}
/>
<TextField
label="Max Validity Seconds"
label="Max Cert Validity Seconds"
type="number"
value={props.form.max_valid_seconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, max_valid_seconds: Number(event.target.value) || 0 }))}
value={props.form.max_cert_valid_seconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, max_cert_valid_seconds: Number(event.target.value) || 0 }))}
/>
<MonospaceTextField
label="Managed Private Key PEM"
@@ -221,6 +225,24 @@ export default function SSHAccessProfileFormDialog(props: SSHAccessProfileFormDi
helperText={props.editingID ? 'Leave blank to keep the existing password.' : 'Required for stored_password.'}
/>
) : null}
{showValidityWindow ? (
<TextField
label="Profile Valid After (optional)"
type="datetime-local"
value={props.form.valid_after}
onChange={(event) => props.setForm((prev) => ({ ...prev, valid_after: event.target.value }))}
InputLabelProps={{ shrink: true }}
/>
) : null}
{showValidityWindow ? (
<TextField
label="Profile Valid Before (optional)"
type="datetime-local"
value={props.form.valid_before}
onChange={(event) => props.setForm((prev) => ({ ...prev, valid_before: event.target.value }))}
InputLabelProps={{ shrink: true }}
/>
) : null}
{showTargets ? (
<Autocomplete<User, true, false, false>
multiple
@@ -29,6 +29,40 @@ import UserGroupDetailsDialog from '../components/UserGroupDetailsDialog'
import PageAlert from '../components/PageAlert'
import { api, SSHAccessProfile, SSHAccessProfileAdminUpsertPayload, SSHCredential, SSHPrincipalGrant, SSHServer, SSHServerGroup, SSHUserCA, User, UserGroup } from '../api'
function toDateTimeInput(value: number): string {
const date: Date = new Date(value * 1000)
const pad = (n: number): string => String(n).padStart(2, '0')
if (!value || value <= 0) {
return ''
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function parseDateTimeInput(value: string): number {
const ts: number = Date.parse(value)
if (!value) {
return 0
}
if (Number.isNaN(ts)) {
return 0
}
return Math.floor(ts / 1000)
}
function profileValidityState(item: SSHAccessProfile): 'Pending' | 'Expired' | null {
const now: number = Math.floor(Date.now() / 1000)
if (item.valid_after > 0 && item.valid_after > now) {
return 'Pending'
}
if (item.valid_before > 0 && item.valid_before < now) {
return 'Expired'
}
return null
}
const emptyForm = (): SSHAccessProfileFormState => ({
server_id: '',
server_target_type: 'server',
@@ -44,8 +78,10 @@ const emptyForm = (): SSHAccessProfileFormState => ({
password_text: '',
ssh_user_ca_id: '',
ssh_principal_grant_ids: [],
default_valid_seconds: 3600,
max_valid_seconds: 3600,
default_cert_valid_seconds: 3600,
max_cert_valid_seconds: 3600,
valid_after: '',
valid_before: '',
user_ids: [],
group_ids: []
})
@@ -218,8 +254,10 @@ export default function AdminSSHAccessProfilesPage() {
password_text: '',
ssh_user_ca_id: item.ssh_user_ca_id || '',
ssh_principal_grant_ids: item.ssh_principal_grant_ids || [],
default_valid_seconds: item.default_valid_seconds || 3600,
max_valid_seconds: item.max_valid_seconds || item.default_valid_seconds || 3600,
default_cert_valid_seconds: item.default_cert_valid_seconds || 3600,
max_cert_valid_seconds: item.max_cert_valid_seconds || item.default_cert_valid_seconds || 3600,
valid_after: toDateTimeInput(item.valid_after),
valid_before: toDateTimeInput(item.valid_before),
user_ids: (item.targets || []).filter((target) => target.target_type === 'user').map((target) => target.target_id),
group_ids: (item.targets || []).filter((target) => target.target_type === 'group').map((target) => target.target_id)
})
@@ -337,8 +375,10 @@ export default function AdminSSHAccessProfilesPage() {
password_text: form.password_text.trim() || undefined,
ssh_user_ca_id: form.auth_method === 'managed_ssh_cert' ? (form.ssh_user_ca_id || undefined) : undefined,
ssh_principal_grant_ids: form.auth_method === 'managed_ssh_cert' ? form.ssh_principal_grant_ids : [],
default_valid_seconds: Number(form.default_valid_seconds) || 0,
max_valid_seconds: Number(form.max_valid_seconds) || 0,
default_cert_valid_seconds: Number(form.default_cert_valid_seconds) || 0,
max_cert_valid_seconds: Number(form.max_cert_valid_seconds) || 0,
valid_after: parseDateTimeInput(form.valid_after),
valid_before: parseDateTimeInput(form.valid_before),
targets: [
...form.user_ids.map((id) => ({ target_type: 'user' as const, target_id: id })),
...form.group_ids.map((id) => ({ target_type: 'group' as const, target_id: id }))
@@ -446,6 +486,7 @@ export default function AdminSSHAccessProfilesPage() {
color={item.owner_scope === 'user' ? 'info' : 'default'}
/>
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
{profileValidityState(item) ? (<> <Chip size="small" color="warning" label={profileValidityState(item)} /></>) : null}
</Typography>
}
secondary={
@@ -571,6 +612,7 @@ export default function AdminSSHAccessProfilesPage() {
userOptions={userOptions}
groupOptions={groupOptions}
showTargets
showValidityWindow
onClose={() => setDialogOpen(false)}
onSave={handleSave}
/>
+21 -10
View File
@@ -1,5 +1,6 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Link from '@mui/material/Link'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
@@ -41,8 +42,10 @@ const emptyForm = (): SSHAccessProfileFormState => ({
password_text: '',
ssh_user_ca_id: '',
ssh_principal_grant_ids: [],
default_valid_seconds: 3600,
max_valid_seconds: 3600,
default_cert_valid_seconds: 3600,
max_cert_valid_seconds: 3600,
valid_after: '',
valid_before: '',
user_ids: [],
group_ids: []
})
@@ -259,8 +262,10 @@ export default function SSHServersPage() {
password_text: '',
ssh_user_ca_id: item.ssh_user_ca_id || '',
ssh_principal_grant_ids: item.ssh_principal_grant_ids || [],
default_valid_seconds: item.default_valid_seconds || 3600,
max_valid_seconds: item.max_valid_seconds || item.default_valid_seconds || 3600,
default_cert_valid_seconds: item.default_cert_valid_seconds || 3600,
max_cert_valid_seconds: item.max_cert_valid_seconds || item.default_cert_valid_seconds || 3600,
valid_after: '',
valid_before: '',
user_ids: [],
group_ids: []
})
@@ -479,8 +484,8 @@ export default function SSHServersPage() {
ssh_credential_id: form.auth_method === 'stored_private_key' ? (form.ssh_credential_id || undefined) : undefined,
private_key_pem: form.private_key_pem.trim() || undefined,
password_text: form.password_text.trim() || undefined,
default_valid_seconds: Number(form.default_valid_seconds) || 0,
max_valid_seconds: Number(form.max_valid_seconds) || 0
default_cert_valid_seconds: Number(form.default_cert_valid_seconds) || 0,
max_cert_valid_seconds: Number(form.max_cert_valid_seconds) || 0
}
if (!payload.server_id) {
@@ -681,7 +686,10 @@ export default function SSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>{item.name}</Typography>
<Typography>
{item.name}
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography>
}
secondary={
<Box sx={{ display: 'grid' }}>
@@ -745,7 +753,7 @@ export default function SSHServersPage() {
Delete
</ListRowActionButton>
) : null}
<ListRowActionButton onClick={() => void handleConnect(item)} disabled={connectingID === item.id}>
<ListRowActionButton onClick={() => void handleConnect(item)} disabled={connectingID === item.id || !item.enabled}>
{connectingID === item.id ? 'Connecting...' : 'Connect'}
</ListRowActionButton>
</ListRowActions>
@@ -767,12 +775,15 @@ export default function SSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>{item.name}</Typography>
<Typography>
{item.name}
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography>
}
secondary={
<Box sx={{ display: 'grid' }}>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'}
{item.host}:{item.port}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Tags: {(item.tags || []).join(', ') || '-'} · {item.id}