Compare commits

...

5 Commits

21 changed files with 406 additions and 113 deletions
+221 -21
View File
@@ -13,6 +13,16 @@ import "codit/internal/util"
var ErrSSHServerHasSessionHistory error = errors.New("ssh server has session history")
const SSHOwnerScopeUser = "user"
const SSHOwnerScopeAdmin = "admin"
const SSHOwnerScopeAdminShared = "admin_shared"
const SSHAccessProfileServerTargetTypeServer = "server"
const SSHAccessProfileServerTargetTypeGroup = "group"
const SSHAccessProfileTargetTypeUser = "user"
const SSHAccessProfileTargetTypeGroup = "group"
type SSHServerDeleteReferenceError struct {
Kind string
Count int64
@@ -73,13 +83,10 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
LEFT JOIN users ou ON ou.id = srv.owner_user_id
WHERE srv.public_id = ?`, strings.TrimSpace(id))
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
}
if err != nil { return item, err }
item.Tags, err = decodeStringList(tagsJSON)
if err != nil {
return item, err
}
if err != nil { return item, err }
return item, nil
}
@@ -111,7 +118,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
if err != nil {
return nil, err
}
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
item.Editable = item.OwnerScope == SSHOwnerScopeUser && item.OwnerUserID == trimmedUserID
items = append(items, item)
}
err = rows.Err()
@@ -151,7 +158,7 @@ func (s *Store) GetOwnedSSHServerForUser(userID string, id string) (models.SSHSe
if err != nil {
return item, err
}
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
item.Editable = item.OwnerScope == SSHOwnerScopeUser && item.OwnerUserID == trimmedUserID
if !item.Editable {
return item, sql.ErrNoRows
}
@@ -171,11 +178,11 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
item.Tags = normalizeStringList(item.Tags)
item.OwnerScope = strings.TrimSpace(item.OwnerScope)
if item.OwnerScope != "user" {
item.OwnerScope = "admin"
if item.OwnerScope != SSHOwnerScopeUser {
item.OwnerScope = SSHOwnerScopeAdmin
item.OwnerUserID = ""
}
if item.OwnerScope == "user" && strings.TrimSpace(item.OwnerUserID) == "" {
if item.OwnerScope == SSHOwnerScopeUser && strings.TrimSpace(item.OwnerUserID) == "" {
return item, errors.New("owner_user_id is required")
}
tagsJSON, err = encodeStringList(item.Tags)
@@ -1073,7 +1080,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
item.Targets, err = s.listSSHAccessProfileTargets(item.ID)
if err != nil { return nil, err }
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
item.Editable = item.OwnerScope == SSHOwnerScopeUser && item.OwnerUserID == trimmedUserID
items = append(items, item)
}
err = rows.Err()
@@ -1083,21 +1090,214 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
}
func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSHAccessProfile, error) {
var items []models.SSHAccessProfile
var row *sql.Row
var item models.SSHAccessProfile
var i int
var grantIDsJSON string
var serverTagsJSON string
var trimmedUserID string
var trimmedID string
var err error
items, err = s.ListSSHAccessProfilesForUser(userID)
// this function gates the profiles with s.enabled=1.
// if we want all profiles, we should remove the condition
trimmedUserID = strings.TrimSpace(userID)
trimmedID = strings.TrimSpace(id)
row = s.QueryRow(`SELECT DISTINCT
p.public_id,
COALESCE(s.public_id, ''),
COALESCE(p.server_target_type, 'server'),
COALESCE(g.public_id, ''),
COALESCE(g.name, ''),
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
COALESCE(g.created_by_kind, ''),
COALESCE(g.created_by_subject_id, ''),
COALESCE(g.created_by_subject_name, ''),
COALESCE(g.created_at, 0),
COALESCE(g.updated_at, 0),
p.name,
p.description,
p.remote_username,
p.auth_method,
p.second_factor_mode,
p.owner_scope,
COALESCE(pou.public_id, ''),
p.allow_user_edit,
p.enabled,
COALESCE(sec.public_id, ''),
COALESCE(c.public_id, ''),
COALESCE(c.name, ''),
p.auth_public_key,
p.auth_public_key_fingerprint,
COALESCE(ca.public_id, ''),
p.ssh_principal_grant_ids_json,
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,
p.created_at,
p.updated_at,
s.public_id,
s.name,
s.host,
s.port,
s.description,
s.tags_json,
s.enabled,
s.host_key_policy,
s.owner_scope,
COALESCE(sou.public_id, ''),
s.created_by_kind,
s.created_by_subject_id,
s.created_by_subject_name,
s.created_at,
s.updated_at
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_id
LEFT JOIN users sou ON sou.id = s.owner_user_id
LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id
LEFT JOIN users pou ON pou.id = p.owner_user_id
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id
LEFT JOIN ssh_access_profile_targets t ON t.profile_id = p.id
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.id = t.target_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
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE p.public_id = ?
AND (
(p.owner_scope = 'user' AND pou.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 tu.public_id = ?)
OR
(t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
)
)
)
LIMIT 1`, trimmedID, trimmedUserID, trimmedUserID, trimmedUserID)
err = row.Scan(
&item.ID,
&item.ServerID,
&item.ServerTargetType,
&item.ServerGroupID,
&item.ServerGroup.Name,
&item.ServerGroup.Description,
&item.ServerGroup.Enabled,
&item.ServerGroup.CreatedByKind,
&item.ServerGroup.CreatedBySubjectID,
&item.ServerGroup.CreatedBySubjectName,
&item.ServerGroup.CreatedAt,
&item.ServerGroup.UpdatedAt,
&item.Name,
&item.Description,
&item.RemoteUsername,
&item.AuthMethod,
&item.SecondFactorMode,
&item.OwnerScope,
&item.OwnerUserID,
&item.AllowUserEdit,
&item.Enabled,
&item.SecretID,
&item.SSHCredentialID,
&item.SSHCredentialName,
&item.AuthPublicKey,
&item.AuthPublicKeyFingerprint,
&item.SSHUserCAID,
&grantIDsJSON,
&item.DefaultCertValidSeconds,
&item.MaxCertValidSeconds,
&item.ValidAfter,
&item.ValidBefore,
&item.CreatedByKind,
&item.CreatedBySubjectID,
&item.CreatedBySubjectName,
&item.CreatedAt,
&item.UpdatedAt,
&item.Server.ID,
&item.Server.Name,
&item.Server.Host,
&item.Server.Port,
&item.Server.Description,
&serverTagsJSON,
&item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.OwnerScope,
&item.Server.OwnerUserID,
&item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName,
&item.Server.CreatedAt,
&item.Server.UpdatedAt,
)
if err != nil {
return item, err
}
for i = 0; i < len(items); i++ {
if items[i].ID == strings.TrimSpace(id) {
return items[i], nil
}
if item.ServerGroupID != "" {
item.ServerGroup.ID = item.ServerGroupID
}
return item, sql.ErrNoRows
item.SSHPrincipalGrantIDs, err = decodeStringList(grantIDsJSON)
if err != nil {
return item, err
}
item.Server.Tags, err = decodeStringList(serverTagsJSON)
if err != nil {
return item, err
}
item.Targets, err = s.listSSHAccessProfileTargets(item.ID)
if err != nil {
return item, err
}
item.Editable = item.OwnerScope == SSHOwnerScopeUser && item.OwnerUserID == trimmedUserID
return item, nil
}
func (s *Store) GetSSHAccessProfileServerForUser(userID string, id string, serverID string) (models.SSHServer, error) {
var profile models.SSHAccessProfile
var item models.SSHServer
var trimmedServerID string
var err error
// the ssh access profile must be found(id)
// the ssh access profile must be visible to the user(userID)
// the access profile must be bound to a server (serverID)
profile, err = s.GetSSHAccessProfileForUser(userID, id)
if err != nil { return item, err }
trimmedServerID = strings.TrimSpace(serverID)
if profile.ServerTargetType != SSHAccessProfileServerTargetTypeServer || profile.ServerID != trimmedServerID { return item, sql.ErrNoRows }
return profile.Server, nil
}
func (s *Store) ListSSHServersForAccessProfileServerGroupForUser(userID string, id string, serverGroupID string) ([]models.SSHServer, error) {
var profile models.SSHAccessProfile
var trimmedGroupID string
var err error
// the ssh access profile must be found(id)
// the ssh access profile must be visible to the user(userID)
// the access profile must be bound to a server group (serverGroupID)
profile, err = s.GetSSHAccessProfileForUser(userID, id)
if err != nil { return nil, err }
trimmedGroupID = strings.TrimSpace(serverGroupID)
if profile.ServerTargetType != SSHAccessProfileServerTargetTypeGroup || profile.ServerGroupID != trimmedGroupID { return nil, sql.ErrNoRows }
return s.ListSSHServersForGroup(trimmedGroupID)
}
func (s *Store) listSSHAccessProfileTargets(profileID string) ([]models.SSHAccessProfileTarget, error) {
@@ -1816,7 +2016,7 @@ func insertSSHAccessProfileTargetTx(tx *sql.Tx, profileID string, targetType str
targetType = strings.ToLower(strings.TrimSpace(targetType))
targetID = strings.TrimSpace(targetID)
if targetType != "user" && targetType != "group" {
if targetType != SSHAccessProfileTargetTypeUser && targetType != SSHAccessProfileTargetTypeGroup {
return errors.New("target_type must be user or group")
}
if targetID == "" {
+4 -4
View File
@@ -16,10 +16,10 @@ func normalizeSSHCredentialOwner(item models.SSHCredential) models.SSHCredential
item.OwnerScope = strings.TrimSpace(item.OwnerScope)
item.OwnerUserID = strings.TrimSpace(item.OwnerUserID)
if item.OwnerScope == "" {
item.OwnerScope = "admin"
item.OwnerScope = SSHOwnerScopeAdmin
}
if item.OwnerScope != "user" {
item.OwnerScope = "admin"
if item.OwnerScope != SSHOwnerScopeUser {
item.OwnerScope = SSHOwnerScopeAdmin
item.OwnerUserID = ""
}
return item
@@ -111,7 +111,7 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
if strings.TrimSpace(item.Type) == "" { item.Type = "private_key" }
if strings.TrimSpace(item.Type) != "private_key" { return item, errors.New("unsupported credential type") }
item = normalizeSSHCredentialOwner(item)
if item.OwnerScope == "user" && item.OwnerUserID == "" { return item, errors.New("owner_user_id is required") }
if item.OwnerScope == SSHOwnerScopeUser && item.OwnerUserID == "" { return item, errors.New("owner_user_id is required") }
if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID()
if err != nil { return item, err }
+83 -25
View File
@@ -353,7 +353,7 @@ func (api *API) CreateSSHServerAdmin(w http.ResponseWriter, r *http.Request, _ m
Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
OwnerScope: "admin",
OwnerScope: db.SSHOwnerScopeAdmin,
OwnerUserID: "",
CreatedByKind: actorKind,
CreatedBySubjectID: actorID,
@@ -632,7 +632,7 @@ func (api *API) CreateSSHCredentialAdmin(w http.ResponseWriter, r *http.Request,
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))),
Fingerprint: strings.TrimSpace(ssh.FingerprintSHA256(signer.PublicKey())),
Enabled: req.Enabled,
OwnerScope: "admin",
OwnerScope: db.SSHOwnerScopeAdmin,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
@@ -745,7 +745,7 @@ func (api *API) CreateSSHCredentialForSelf(w http.ResponseWriter, r *http.Reques
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))),
Fingerprint: strings.TrimSpace(ssh.FingerprintSHA256(signer.PublicKey())),
Enabled: req.Enabled,
OwnerScope: "user",
OwnerScope: db.SSHOwnerScopeUser,
OwnerUserID: user.ID,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
@@ -869,7 +869,7 @@ func (api *API) CreateSSHAccessProfileAdmin(w http.ResponseWriter, r *http.Reque
}
actorKind, actorID, actorName = currentSSHBrokerActor(r)
options = sshAccessProfileNormalizeOptions{
OwnerScope: "admin_shared",
OwnerScope: db.SSHOwnerScopeAdminShared,
OwnerUserID: "",
AllowUserEdit: false,
RequireTargets: true,
@@ -917,7 +917,7 @@ func (api *API) UpdateSSHAccessProfileAdmin(w http.ResponseWriter, r *http.Reque
}
actorKind, actorID, actorName = currentSSHBrokerActor(r)
options = sshAccessProfileNormalizeOptions{
OwnerScope: "admin_shared",
OwnerScope: db.SSHOwnerScopeAdminShared,
OwnerUserID: "",
AllowUserEdit: false,
RequireTargets: true,
@@ -1059,19 +1059,77 @@ func (api *API) ListSSHAccessProfileServersForSelf(w http.ResponseWriter, r *htt
}
profile, err = api.store(r).GetSSHAccessProfileForUser(user.ID, params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "ssh access profile not found")
var msg string = "ssh access profile not found"
if !errors.Is(err, sql.ErrNoRows) { msg = fmt.Sprintf("%s - %s", msg, err.Error()) }
WriteJSONWithErrorReason(w, r, http.StatusNotFound, msg)
return
}
if profile.ServerTargetType == "group" {
if profile.ServerTargetType == db.SSHAccessProfileServerTargetTypeGroup {
items, err = api.store(r).ListSSHServersForGroup(profile.ServerGroupID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
} else {
items = []models.SSHServer{profile.Server}
}
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
WriteJSON(w, http.StatusOK, items)
}
func (api *API) GetSSHAccessProfileServerForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var server models.SSHServer
var ok bool
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
WriteJSON(w, http.StatusOK, items)
// this function returns the server information of the server bound to the access profile.
// the server may not be owned by the caller in this context. it allows information
// retrieval if the access profile is shared with the caller.
server, err = api.store(r).GetSSHAccessProfileServerForUser(user.ID, params["id"], params["serverId"])
if err != nil {
var msg string = "ssh access profile server not found"
if !errors.Is(err, sql.ErrNoRows) { msg = fmt.Sprintf("%s - %s", msg, err.Error()) }
WriteJSONWithErrorReason(w, r, http.StatusNotFound, msg)
return
}
WriteJSON(w, http.StatusOK, server)
}
func (api *API) ListSSHAccessProfileServerGroupMembersForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var servers []models.SSHServer
var ok bool
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
// this function returns the list of servers in the server group bound to the access profile.
// the servers may not be owned by the caller in this context. it allows information
// retrieval if the access profile is shared with the caller.
servers, err = api.store(r).ListSSHServersForAccessProfileServerGroupForUser(user.ID, params["id"], params["serverGroupId"])
if err != nil {
var msg string = "ssh access profile server group not found"
if !errors.Is(err, sql.ErrNoRows) { msg = fmt.Sprintf("%s - %s", msg, err.Error()) }
WriteJSONWithErrorReason(w, r, http.StatusNotFound, msg)
return
}
WriteJSON(w, http.StatusOK, servers)
}
func (api *API) ListSSHServersForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -1133,7 +1191,7 @@ func (api *API) CreateSSHServerForSelf(w http.ResponseWriter, r *http.Request, _
Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
OwnerScope: "user",
OwnerScope: db.SSHOwnerScopeUser,
OwnerUserID: user.ID,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
@@ -1389,7 +1447,7 @@ func (api *API) CreateSSHAccessProfileForSelf(w http.ResponseWriter, r *http.Req
return
}
options = sshAccessProfileNormalizeOptions{
OwnerScope: "user",
OwnerScope: db.SSHOwnerScopeUser,
OwnerUserID: user.ID,
AllowUserEdit: true,
RequireTargets: false,
@@ -1431,7 +1489,7 @@ func (api *API) UpdateSSHAccessProfileForSelf(w http.ResponseWriter, r *http.Req
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if existing.OwnerScope != "user" || existing.OwnerUserID != user.ID {
if existing.OwnerScope != db.SSHOwnerScopeUser || existing.OwnerUserID != user.ID {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -1441,7 +1499,7 @@ func (api *API) UpdateSSHAccessProfileForSelf(w http.ResponseWriter, r *http.Req
return
}
options = sshAccessProfileNormalizeOptions{
OwnerScope: "user",
OwnerScope: db.SSHOwnerScopeUser,
OwnerUserID: user.ID,
AllowUserEdit: true,
RequireTargets: false,
@@ -1480,7 +1538,7 @@ func (api *API) DeleteSSHAccessProfileForSelf(w http.ResponseWriter, r *http.Req
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if item.OwnerScope != "user" || item.OwnerUserID != user.ID {
if item.OwnerScope != db.SSHOwnerScopeUser || item.OwnerUserID != user.ID {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -1654,7 +1712,7 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
map[string]string{"error": fmt.Sprintf("otp_code is required for %s", sshSecondFactorPromptedTOTP)})
return
}
if profile.ServerTargetType == "group" {
if profile.ServerTargetType == db.SSHAccessProfileServerTargetTypeGroup {
req.ServerID = strings.TrimSpace(req.ServerID)
if req.ServerID == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "server_id is required for server group profiles")
@@ -2003,7 +2061,7 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
req.ServerTargetType = strings.TrimSpace(req.ServerTargetType)
req.ServerGroupID = strings.TrimSpace(req.ServerGroupID)
if req.ServerTargetType == "" {
req.ServerTargetType = "server"
req.ServerTargetType = db.SSHAccessProfileServerTargetTypeServer
}
req.Name = strings.TrimSpace(req.Name)
req.Description = strings.TrimSpace(req.Description)
@@ -2018,11 +2076,11 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
if err != nil { return item, err }
if req.Name == "" { return item, errors.New("name is required") }
if req.ServerTargetType != "server" && req.ServerTargetType != "group" {
if req.ServerTargetType != db.SSHAccessProfileServerTargetTypeServer && req.ServerTargetType != db.SSHAccessProfileServerTargetTypeGroup {
return item, errors.New("server_target_type must be server or group")
}
if req.ServerTargetType == "server" && req.ServerID == "" { return item, errors.New("server_id is required") }
if req.ServerTargetType == "group" && req.ServerGroupID == "" { return item, errors.New("server_group_id is required") }
if req.ServerTargetType == db.SSHAccessProfileServerTargetTypeServer && req.ServerID == "" { return item, errors.New("server_id is required") }
if req.ServerTargetType == db.SSHAccessProfileServerTargetTypeGroup && req.ServerGroupID == "" { return item, errors.New("server_group_id is required") }
if req.RemoteUsername == "" { return item, errors.New("remote_username is required") }
if req.AuthMethod == "" {
@@ -2095,10 +2153,10 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
return item, errors.New("ssh_credential_id is disabled")
}
if options.SelfService {
if credential.OwnerScope != "user" || credential.OwnerUserID != options.OwnerUserID {
if credential.OwnerScope != db.SSHOwnerScopeUser || credential.OwnerUserID != options.OwnerUserID {
return item, errors.New("ssh_credential_id not available to user")
}
} else if credential.OwnerScope == "user" {
} else if credential.OwnerScope == db.SSHOwnerScopeUser {
return item, errors.New("user-owned ssh credential is not allowed for admin access profiles")
}
item.SSHCredentialID = credential.ID
@@ -2163,7 +2221,7 @@ func (api *API) normalizeSSHAccessProfileRequest(r *http.Request, req sshAccessP
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" {
if req.ServerTargetType == db.SSHAccessProfileServerTargetTypeGroup {
var group models.SSHServerGroup
group, err = api.store(r).GetSSHServerGroup(req.ServerGroupID)
if err != nil {
@@ -2300,7 +2358,7 @@ func normalizeSSHAccessProfileTargets(raw []sshAccessProfileTargetRequest, allow
for i = 0; i < len(raw); i++ {
targetType = strings.ToLower(strings.TrimSpace(raw[i].TargetType))
targetID = strings.TrimSpace(raw[i].TargetID)
if targetType != "user" && targetType != "group" {
if targetType != db.SSHAccessProfileTargetTypeUser && targetType != db.SSHAccessProfileTargetTypeGroup {
return nil, errors.New("target_type must be user or group")
}
if targetID == "" {
@@ -2327,7 +2385,7 @@ func validateSSHAccessProfileTargets(store *db.Store, targets []models.SSHAccess
return nil
}
for i = 0; i < len(targets); i++ {
if targets[i].TargetType == "user" {
if targets[i].TargetType == db.SSHAccessProfileTargetTypeUser {
_, err = store.GetUserByID(targets[i].TargetID)
} else {
_, err = store.GetUserGroup(targets[i].TargetID)
+41 -38
View File
@@ -12,14 +12,12 @@ type Handler interface {
}
type route struct {
method string
pattern string
//handler HandlerFunc
parts []string // pre-split pattern segments
handler Handler
}
type Router struct {
routes []route
index map[string]map[int][]route // method -> segment count -> routes
}
func (hf HandlerFunc) Handle(w http.ResponseWriter, req *http.Request, params Params) {
@@ -27,18 +25,21 @@ func (hf HandlerFunc) Handle(w http.ResponseWriter, req *http.Request, params Pa
}
func NewRouter() *Router {
return &Router{}
return &Router{
index: make(map[string]map[int][]route),
}
}
func (r *Router) AddHandler(method string, pattern string, handler Handler) {
var parts []string
var entry route
// TODO: can I split the pattern a tree structure for faster lookup later?
// TODO: duplication check?
entry.method = method
entry.pattern = pattern
entry.handler = handler
r.routes = append(r.routes, entry)
parts = split(pattern)
entry = route{parts: parts, handler: handler}
if r.index[method] == nil {
r.index[method] = make(map[int][]route)
}
r.index[method][len(parts)] = append(r.index[method][len(parts)], entry)
}
func (r *Router) Handle(method string, pattern string, handler HandlerFunc) {
@@ -46,58 +47,60 @@ func (r *Router) Handle(method string, pattern string, handler HandlerFunc) {
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var i int
var pathParts []string
var byDepth map[int][]route
var routes []route
var rt route
var params Params
var ok bool
for i = 0; i < len(r.routes); i++ {
rt = r.routes[i]
if rt.method != req.Method {
continue
}
params, ok = match(rt.pattern, req.URL.Path)
if !ok { continue }
pathParts = split(req.URL.Path)
byDepth, ok = r.index[req.Method]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
routes, ok = byDepth[len(pathParts)]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
for _, rt = range routes {
params, ok = match(rt.parts, pathParts)
if !ok { continue }
//rt.handler(w, req, params) <-- if Handler is HandlerFunc
rt.handler.Handle(w, req, params) // <-- handler is the Handler interface
return
}
w.WriteHeader(http.StatusNotFound)
}
func match(pattern, path string) (Params, bool) {
var pParts []string
var pathParts []string
// match assumes pParts and pathParts are the same length (guaranteed by the index).
func match(pParts, pathParts []string) (Params, bool) {
var params Params
var i int
var p string
var seg string
pParts = split(pattern)
pathParts = split(path)
if len(pParts) != len(pathParts) {
return nil, false
}
params = Params{}
for i = 0; i < len(pParts); i++ {
p = pParts[i]
seg = pathParts[i]
for i, p = range pParts {
seg = pathParts[i]
if strings.HasPrefix(p, ":") {
if params == nil { params = make(Params) }
params[p[1:]] = seg
continue
}
if p != seg {
return nil, false
}
if p != seg { return nil, false }
}
if params == nil { params = Params{} }
return params, true
}
func split(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return []string{}
}
if path == "" { return []string{} }
return strings.Split(path, "/")
}
+4
View File
@@ -779,11 +779,15 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("POST", "/api/ssh/credentials", api.CreateSSHCredentialForSelf)
router.Handle("PATCH", "/api/ssh/credentials/:id", api.UpdateSSHCredentialForSelf)
router.Handle("DELETE", "/api/ssh/credentials/:id", api.DeleteSSHCredentialForSelf)
router.Handle("GET", "/api/ssh/access-profiles", api.ListSSHAccessProfilesForSelf)
router.Handle("POST", "/api/ssh/access-profiles", api.CreateSSHAccessProfileForSelf)
router.Handle("GET", "/api/ssh/access-profiles/:id", api.GetSSHAccessProfileForSelf)
router.Handle("GET", "/api/ssh/access-profiles/:id/credential", api.GetSSHAccessProfileCredentialForSelf)
router.Handle("GET", "/api/ssh/access-profiles/:id/servers", api.ListSSHAccessProfileServersForSelf)
router.Handle("GET", "/api/ssh/access-profiles/:id/servers/:serverId", api.GetSSHAccessProfileServerForSelf)
router.Handle("GET", "/api/ssh/access-profiles/:id/server-groups/:serverGroupId/servers", api.ListSSHAccessProfileServerGroupMembersForSelf)
router.Handle("PATCH", "/api/ssh/access-profiles/:id", api.UpdateSSHAccessProfileForSelf)
router.Handle("DELETE", "/api/ssh/access-profiles/:id", api.DeleteSSHAccessProfileForSelf)
router.Handle("POST", "/api/ssh/access-profiles/:id/connect", api.CreateSSHSessionForSelf)
+4
View File
@@ -1604,6 +1604,10 @@ export const api = {
getSSHAccessProfileForSelf: (id: string, options?: RequestInit) => request<SSHAccessProfile>(`/api/ssh/access-profiles/${id}`),
getSSHAccessProfileCredentialForSelf: (id: string) => request<SSHCredential>(`/api/ssh/access-profiles/${id}/credential`),
listSSHAccessProfileServersForSelf: (id: string) => request<SSHServer[]>(`/api/ssh/access-profiles/${id}/servers`),
getSSHAccessProfileServerForSelf: (id: string, serverID: string) => request<SSHServer>(`/api/ssh/access-profiles/${id}/servers/${serverID}`),
listSSHAccessProfileServerGroupMembersForSelf: (id: string, serverGroupID: string) => request<SSHServer[]>(`/api/ssh/access-profiles/${id}/server-groups/${serverGroupID}/servers`),
updateSSHAccessProfileForSelf: (id: string, payload: SSHAccessProfileSelfUpsertPayload) =>
request<SSHAccessProfile>(`/api/ssh/access-profiles/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteSSHAccessProfileForSelf: (id: string) => request<void>(`/api/ssh/access-profiles/${id}`, { method: 'DELETE' }),
@@ -7,5 +7,20 @@ const compactSx: SxProps<Theme> = { minWidth: 0, m: 0 }
export default function CompactListItemText(props: ListItemTextProps) {
const sx: SxProps<Theme> = (props.sx ? [compactSx, props.sx] : compactSx) as SxProps<Theme>
return <ListItemText {...props} sx={sx} />
// <ListItemText primary="Users" secondary="42 members" />
// is roughly becomes
// <span class="MuiListItemText-primary">Users</span>
// <p class="MuiListItemText-secondary">42 members</p>
// If we use some structural items that can be translated to <div>,
// i get something like "Warning: validateDOMNesting(...): <div> cannot appear as a descendant of <p>".
//return <ListItemText {...props} disableTypography sx={sx} />
return <ListItemText
{...props}
sx={sx}
secondaryTypographyProps={{
component: 'div', // defaults to div
...props.secondaryTypographyProps, // the caller still can override component
}}
/>
}
@@ -17,6 +17,7 @@ type SSHServerGroupDetailsDialogProps = {
item: SSHServerGroup | null
onClose: () => void
showAdminFields?: boolean
accessProfileID?: string | null
}
function fmt(value: number): string {
@@ -49,7 +50,12 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
setMembersLoading(true)
setMembersError(null)
api.listSSHServerGroupMembersAdmin(item.id).then((list: SSHServer[]) => {
const request: Promise<SSHServer[]> = props.accessProfileID?
api.listSSHAccessProfileServerGroupMembersForSelf(props.accessProfileID, item.id):
api.listSSHServerGroupMembersAdmin(item.id)
request.then((list: SSHServer[]) => {
if (cancelled) { return }
setMemberServers(Array.isArray(list) ? list : [])
}).catch((err: unknown) => {
@@ -62,7 +68,7 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
return () => {
cancelled = true
}
}, [item])
}, [item, props.accessProfileID])
return (
<Dialog open={Boolean(item)} onClose={props.onClose} maxWidth="sm" fullWidth>
+1 -1
View File
@@ -225,7 +225,7 @@ export default function AdminApiKeysPage() {
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{key.name}({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
@@ -292,7 +292,7 @@ export default function AdminPKIClientProfilesPage() {
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
@@ -455,7 +455,7 @@ export default function AdminSSHAccessProfilesPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}{' '}
<Chip
size="small"
@@ -148,7 +148,7 @@ export default function AdminSSHCredentialsPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
+2 -2
View File
@@ -490,7 +490,7 @@ export default function AdminSSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
@@ -560,7 +560,7 @@ export default function AdminSSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography>
+2 -2
View File
@@ -302,7 +302,7 @@ export default function AdminSSHSessionsPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{sessionTitle(item)}{' '}
<Chip
label={item.status || 'unknown'}
@@ -476,7 +476,7 @@ export default function AdminSSHSessionsPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{formatTransferOperation(item.operation)} · {item.server_name || item.server_id || '-'}
{item.target_server_name ? `${item.target_server_name}` : ''}{' '}
<Chip
+1 -1
View File
@@ -327,7 +327,7 @@ export default function AdminSSHUserCAPage() {
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
+1 -1
View File
@@ -370,7 +370,7 @@ export default function AdminUserGroupsPage() {
>
<CompactListItemText
primary={
<Typography noWrap>
<Typography component="div">
{group.name}
{group.disabled? (<> <Chip size="small" label="Disabled" color="error" sx={{ mt: 0.25, flexShrink: 0 }} /></>): null}
</Typography>
+1 -1
View File
@@ -270,7 +270,7 @@ export default function AdminUsersPage() {
>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{u.display_name || u.username}({u.username})
{u.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
+1 -1
View File
@@ -198,7 +198,7 @@ export default function ApiKeysPage() {
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{key.name}({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
@@ -425,7 +425,7 @@ export default function ClientCertificatesPage() {
>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.common_name} ({item.cert_id})
{' '}
<Chip
+1 -1
View File
@@ -148,7 +148,7 @@ export default function SSHCredentialsPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
+11 -8
View File
@@ -109,6 +109,7 @@ export default function SSHServersPage() {
const [viewServerItem, setViewServerItem] = useState<SSHServer | null>(null)
const [viewSharedServerItem, setViewSharedServerItem] = useState<SSHServer | null>(null)
const [viewServerGroupItem, setViewServerGroupItem] = useState<SSHServerGroup | null>(null)
const [accessProfileIdForServerGroup, setAccessProfileIdForServerGroup] = useState<string | null>(null)
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
const [hostKeysLoading, setHostKeysLoading] = useState(false)
@@ -208,9 +209,10 @@ export default function SSHServersPage() {
}
}
const openServerGroupView = (item: SSHServerGroup | null) => {
const openServerGroupView = (profile_id: string, group: SSHServerGroup | null) => {
// open the ssh server group details dialog
setViewServerGroupItem(item)
setViewServerGroupItem(group)
setAccessProfileIdForServerGroup(profile_id)
}
const openCreate = () => {
@@ -664,7 +666,7 @@ export default function SSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
<Typography component="div">
{item.name}
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography>
@@ -675,7 +677,7 @@ export default function SSHServersPage() {
{item.server_target_type === 'group' ? (
<Link
underline="hover"
onClick={() => void openServerGroupView(item.server_group)}
onClick={() => void openServerGroupView(item.id, item.server_group)}
sx={{ cursor: 'default' }}
>
Group: {item.server_group?.name || item.server_group_id || '-'}
@@ -753,8 +755,8 @@ export default function SSHServersPage() {
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
{item.name}
<Typography component="div">
{item.name}({item.id})
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography>
}
@@ -764,9 +766,9 @@ export default function SSHServersPage() {
{item.host}:{item.port}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Tags: {(item.tags || []).join(', ') || '-'} · {item.id}
Tags: {(item.tags || []).join(', ') || '-'}
</Typography>
{item.description ? (
{item.description? (
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.description}
</Typography>
@@ -920,6 +922,7 @@ export default function SSHServersPage() {
<SSHServerGroupDetailsDialog
item={viewServerGroupItem}
onClose={() => setViewServerGroupItem(null) }
accessProfileID={accessProfileIdForServerGroup}
/>
<ConfirmDeleteDialog