Compare commits

...

4 Commits

29 changed files with 2601 additions and 1161 deletions
+28 -21
View File
@@ -40,7 +40,7 @@ func (s *Store) GetACMEProfile(id string) (models.ACMEProfile, error) {
var item models.ACMEProfile
row = s.QueryRow(`SELECT public_id, name, directory_url, email, account_url, account_key_pem, solver_type, acme_dns_api_url, acme_dns_user, acme_dns_key, acme_dns_subdomain, acme_dns_full_domain, enabled, last_error, created_at, updated_at
FROM acme_profiles
WHERE public_id = ?`, strings.TrimSpace(id))
WHERE o.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Name, &item.DirectoryURL, &item.Email, &item.AccountURL, &item.AccountKeyPEM, &item.SolverType, &item.ACMEDNSAPIURL, &item.ACMEDNSUser, &item.ACMEDNSKey, &item.ACMEDNSSubdomain, &item.ACMEDNSFullDomain, &item.Enabled, &item.LastError, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
@@ -118,14 +118,18 @@ func (s *Store) ListACMEOrders(profileID string) ([]models.ACMEOrder, error) {
var item models.ACMEOrder
var challengesJSON string
if strings.TrimSpace(profileID) == "" {
rows, err = s.Query(`SELECT public_id, profile_public_id, common_name, san_dns, order_url, finalize_url, status, last_error, cert_public_id, challenges_json, created_at, updated_at
FROM acme_orders
ORDER BY created_at DESC`)
rows, err = s.Query(`SELECT o.public_id, COALESCE(p.public_id, ''), o.common_name, o.san_dns, o.order_url, o.finalize_url, o.status, o.last_error, COALESCE(c.public_id, ''), o.challenges_json, o.created_at, o.updated_at
FROM acme_orders o
LEFT JOIN acme_profiles p ON p.id = o.profile_id
LEFT JOIN pki_certs c ON c.id = o.cert_id
ORDER BY o.created_at DESC`)
} else {
rows, err = s.Query(`SELECT public_id, profile_public_id, common_name, san_dns, order_url, finalize_url, status, last_error, cert_public_id, challenges_json, created_at, updated_at
FROM acme_orders
WHERE profile_public_id = ?
ORDER BY created_at DESC`, strings.TrimSpace(profileID))
rows, err = s.Query(`SELECT o.public_id, COALESCE(p.public_id, ''), o.common_name, o.san_dns, o.order_url, o.finalize_url, o.status, o.last_error, COALESCE(c.public_id, ''), o.challenges_json, o.created_at, o.updated_at
FROM acme_orders o
LEFT JOIN acme_profiles p ON p.id = o.profile_id
LEFT JOIN pki_certs c ON c.id = o.cert_id
WHERE p.public_id = ?
ORDER BY o.created_at DESC`, strings.TrimSpace(profileID))
}
if err != nil {
return nil, err
@@ -154,9 +158,11 @@ func (s *Store) GetACMEOrder(id string) (models.ACMEOrder, error) {
var err error
var item models.ACMEOrder
var challengesJSON string
row = s.QueryRow(`SELECT public_id, profile_public_id, common_name, san_dns, order_url, finalize_url, status, last_error, cert_public_id, challenges_json, csr_pem, key_pem, created_at, updated_at
FROM acme_orders
WHERE public_id = ?`, strings.TrimSpace(id))
row = s.QueryRow(`SELECT o.public_id, COALESCE(p.public_id, ''), o.common_name, o.san_dns, o.order_url, o.finalize_url, o.status, o.last_error, COALESCE(c.public_id, ''), o.challenges_json, o.csr_pem, o.key_pem, o.created_at, o.updated_at
FROM acme_orders o
LEFT JOIN acme_profiles p ON p.id = o.profile_id
LEFT JOIN pki_certs c ON c.id = o.cert_id
WHERE o.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.ProfileID, &item.CommonName, &item.SANDNS, &item.OrderURL, &item.FinalizeURL, &item.Status, &item.LastError, &item.CertID, &challengesJSON, &item.CSRPEM, &item.KeyPEM, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
@@ -185,9 +191,9 @@ func (s *Store) CreateACMEOrder(item models.ACMEOrder) (models.ACMEOrder, error)
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.Exec(`INSERT INTO acme_orders (public_id, profile_public_id, common_name, san_dns, order_url, finalize_url, status, last_error, cert_public_id, challenges_json, csr_pem, key_pem, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.ProfileID, item.CommonName, item.SANDNS, item.OrderURL, item.FinalizeURL, item.Status, item.LastError, item.CertID, challengesJSON, item.CSRPEM, item.KeyPEM, item.CreatedAt, item.UpdatedAt)
_, err = s.Exec(`INSERT INTO acme_orders (public_id, profile_id, common_name, san_dns, order_url, finalize_url, status, last_error, cert_id, challenges_json, csr_pem, key_pem, created_at, updated_at)
VALUES (?, (SELECT id FROM acme_profiles WHERE public_id = ?), ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_certs WHERE public_id = ?) END, ?, ?, ?, ?, ?)`,
item.ID, item.ProfileID, item.CommonName, item.SANDNS, item.OrderURL, item.FinalizeURL, item.Status, item.LastError, strings.TrimSpace(item.CertID), strings.TrimSpace(item.CertID), challengesJSON, item.CSRPEM, item.KeyPEM, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
@@ -203,9 +209,9 @@ func (s *Store) UpdateACMEOrder(item models.ACMEOrder) (models.ACMEOrder, error)
}
item.UpdatedAt = time.Now().UTC().Unix()
_, err = s.Exec(`UPDATE acme_orders
SET status = ?, last_error = ?, cert_public_id = ?, challenges_json = ?, updated_at = ?
SET status = ?, last_error = ?, cert_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_certs WHERE public_id = ?) END, challenges_json = ?, updated_at = ?
WHERE public_id = ?`,
item.Status, item.LastError, item.CertID, challengesJSON, item.UpdatedAt, item.ID)
item.Status, item.LastError, strings.TrimSpace(item.CertID), strings.TrimSpace(item.CertID), challengesJSON, item.UpdatedAt, item.ID)
if err != nil {
return item, err
}
@@ -226,10 +232,11 @@ func (s *Store) GetLatestACMEOrderByCertID(certID string) (models.ACMEOrder, err
var row *sql.Row
var item models.ACMEOrder
var err error
row = s.QueryRow(`SELECT public_id, profile_public_id, common_name, san_dns
FROM acme_orders
WHERE cert_public_id = ?
ORDER BY updated_at DESC, created_at DESC
row = s.QueryRow(`SELECT o.public_id, COALESCE(p.public_id, ''), o.common_name, o.san_dns
FROM acme_orders o
LEFT JOIN acme_profiles p ON p.id = o.profile_id
WHERE o.cert_id = (SELECT id FROM pki_certs WHERE public_id = ?)
ORDER BY o.updated_at DESC, o.created_at DESC
LIMIT 1`, strings.TrimSpace(certID))
err = row.Scan(&item.ID, &item.ProfileID, &item.CommonName, &item.SANDNS)
if err != nil {
@@ -245,7 +252,7 @@ func (s *Store) HasACMEOrderForCert(certID string) (bool, error) {
row = s.QueryRow(`SELECT 1
FROM acme_orders
WHERE cert_public_id = ?
WHERE cert_id = (SELECT id FROM pki_certs WHERE public_id = ?)
LIMIT 1`, strings.TrimSpace(certID))
err = row.Scan(&found)
if err == sql.ErrNoRows {
+12 -12
View File
@@ -130,17 +130,17 @@ func (s *Store) DeleteUserGroup(groupID string) error {
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'group' AND subject_public_id = ?`, strings.TrimSpace(groupID))
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'group' AND subject_id = (SELECT id FROM user_groups WHERE public_id = ?)`, strings.TrimSpace(groupID))
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'group' AND target_public_id = ?`, strings.TrimSpace(groupID))
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'group' AND target_id = (SELECT id FROM user_groups WHERE public_id = ?)`, strings.TrimSpace(groupID))
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'group' AND subject_public_id = ?`, strings.TrimSpace(groupID))
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'group' AND subject_id = (SELECT id FROM user_groups WHERE public_id = ?)`, strings.TrimSpace(groupID))
if err != nil {
rollbackIfOwned(tx, owned)
return err
@@ -243,7 +243,7 @@ func (s *Store) ListProjectGroupRoles(projectID string) ([]models.ProjectGroupRo
rows, err = s.Query(`SELECT p.public_id, g.public_id, b.role, b.created_at
FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id
JOIN user_groups g ON g.public_id = b.subject_public_id
JOIN user_groups g ON g.id = b.subject_id
WHERE p.public_id = ? AND b.subject_type = 'group'
ORDER BY g.name`, strings.TrimSpace(projectID))
if err != nil {
@@ -271,11 +271,11 @@ func (s *Store) UpsertProjectGroupRole(projectID string, groupID string, role st
var rowsAffected int64
var err error
now = time.Now().UTC().Unix()
result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_public_id, role, created_at, updated_at)
SELECT (SELECT id FROM projects WHERE public_id = ?), 'group', g.public_id, ?, ?, ?
result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at)
SELECT (SELECT id FROM projects WHERE public_id = ?), 'group', g.id, ?, ?, ?
FROM user_groups g
WHERE g.public_id = ?
ON CONFLICT(project_id, subject_type, subject_public_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
ON CONFLICT(project_id, subject_type, subject_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
strings.TrimSpace(projectID), strings.TrimSpace(role), now, now, strings.TrimSpace(groupID))
if err != nil {
return item, err
@@ -299,7 +299,7 @@ func (s *Store) RemoveProjectGroupRole(projectID string, groupID string) error {
_, err = s.Exec(`DELETE FROM project_role_bindings
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'group'
AND subject_public_id = ?`,
AND subject_id = (SELECT id FROM user_groups WHERE public_id = ?)`,
strings.TrimSpace(projectID),
strings.TrimSpace(groupID),
)
@@ -315,10 +315,10 @@ func (s *Store) GetProjectRoleForUser(projectID string, userID string) (string,
FROM project_role_bindings b
WHERE b.project_id = (SELECT id FROM projects WHERE public_id = ?)
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -368,7 +368,7 @@ func (s *Store) GetProjectRoleForPrincipal(projectID string, principalID string)
FROM project_role_bindings
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'principal'
AND subject_public_id = ?`,
AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`,
strings.TrimSpace(projectID),
strings.TrimSpace(principalID),
)
+39 -24
View File
@@ -38,8 +38,11 @@ func (s *Store) ListPKIClientProfiles(targetType string, targetID string) ([]mod
JOIN pki_cas ca ON ca.id = p.ca_id
LEFT JOIN users u ON u.id = p.created_by_user_id
JOIN pki_client_profile_targets t ON t.profile_id = p.id
WHERE t.target_type = ? AND t.target_public_id = ?
ORDER BY p.name`, targetType, targetID)
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
LEFT JOIN user_groups tg ON t.target_type = 'group' AND tg.id = t.target_id
WHERE t.target_type = ?
AND ((t.target_type = 'user' AND tu.public_id = ?) OR (t.target_type = 'group' AND tg.public_id = ?))
ORDER BY p.name`, targetType, targetID, targetID)
} else {
rows, err = s.Query(`SELECT
p.public_id,
@@ -130,13 +133,14 @@ func (s *Store) ListActivePKIClientProfilesForUser(userID string) ([]models.PKIC
JOIN pki_cas ca ON ca.id = p.ca_id
LEFT JOIN users u ON u.id = p.created_by_user_id
JOIN pki_client_profile_targets t ON t.profile_id = p.id
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.public_id = t.target_public_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.enabled = 1
AND ca.status = 'active'
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
(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 = ?))
)
@@ -264,14 +268,15 @@ func (s *Store) GetActivePKIClientProfileForUser(profileID string, userID string
JOIN pki_cas ca ON ca.id = p.ca_id
LEFT JOIN users u ON u.id = p.created_by_user_id
JOIN pki_client_profile_targets t ON t.profile_id = p.id
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.public_id = t.target_public_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.enabled = 1
AND ca.status = 'active'
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
(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 = ?))
)`,
@@ -503,10 +508,10 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
rows, err = s.Query(`SELECT
i.public_id,
i.cert_public_id,
i.user_public_id,
COALESCE(c.public_id, ''),
COALESCE(u.public_id, ''),
i.username,
i.profile_public_id,
COALESCE(p.public_id, ''),
i.profile_name,
i.serial_hex,
i.common_name,
@@ -520,8 +525,10 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
COALESCE(c.revocation_reason, ''),
i.created_at
FROM pki_client_issuances i
LEFT JOIN pki_certs c ON c.public_id = i.cert_public_id
WHERE i.user_public_id = ?
LEFT JOIN pki_certs c ON c.id = i.cert_id
LEFT JOIN users u ON u.id = i.user_id
LEFT JOIN pki_client_profiles p ON p.id = i.profile_id
WHERE u.public_id = ?
ORDER BY i.created_at DESC`, strings.TrimSpace(userID))
if err != nil {
return nil, err
@@ -573,10 +580,10 @@ func (s *Store) CreatePKIClientIssuance(item models.PKIClientIssuance) (models.P
}
_, err = s.Exec(`INSERT INTO pki_client_issuances (
public_id,
cert_public_id,
user_public_id,
cert_id,
user_id,
username,
profile_public_id,
profile_id,
profile_name,
serial_hex,
common_name,
@@ -586,7 +593,7 @@ func (s *Store) CreatePKIClientIssuance(item models.PKIClientIssuance) (models.P
not_before,
not_after,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, (SELECT id FROM pki_certs WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?, (SELECT id FROM pki_client_profiles WHERE public_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(item.ID),
strings.TrimSpace(item.CertID),
strings.TrimSpace(item.UserID),
@@ -614,7 +621,8 @@ func (s *Store) UserOwnsPKIClientCert(userID string, certID string) (bool, error
var err error
row = s.QueryRow(`SELECT COUNT(*) FROM pki_client_issuances
WHERE user_public_id = ? AND cert_public_id = ?`,
WHERE user_id = (SELECT id FROM users WHERE public_id = ?)
AND cert_id = (SELECT id FROM pki_certs WHERE public_id = ?)`,
strings.TrimSpace(userID),
strings.TrimSpace(certID),
)
@@ -634,10 +642,10 @@ func (s *Store) listPKIClientProfileTargets(profileID string) ([]models.PKIClien
rows, err = s.Query(`SELECT
p.public_id,
t.target_type,
t.target_public_id,
CASE WHEN t.target_type = 'user' THEN COALESCE(u.public_id, '') ELSE COALESCE(g.public_id, '') END,
CASE
WHEN t.target_type = 'user' THEN COALESCE(CASE WHEN u.display_name != '' THEN u.display_name || ' (' || u.username || ')' ELSE u.username END, t.target_public_id)
ELSE COALESCE(g.name, t.target_public_id)
WHEN t.target_type = 'user' THEN COALESCE(CASE WHEN u.display_name != '' THEN u.display_name || ' (' || u.username || ')' ELSE u.username END, '')
ELSE COALESCE(g.name, '')
END,
CASE
WHEN t.target_type = 'user' THEN CASE WHEN u.public_id IS NOT NULL AND u.disabled = 0 THEN 1 ELSE 0 END
@@ -646,10 +654,10 @@ func (s *Store) listPKIClientProfileTargets(profileID string) ([]models.PKIClien
t.created_at
FROM pki_client_profile_targets t
JOIN pki_client_profiles p ON p.id = t.profile_id
LEFT JOIN users u ON t.target_type = 'user' AND u.public_id = t.target_public_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.public_id = t.target_public_id
LEFT JOIN users u ON t.target_type = 'user' AND u.id = t.target_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.id = t.target_id
WHERE p.public_id = ?
ORDER BY t.target_type, t.target_public_id`, strings.TrimSpace(profileID))
ORDER BY t.target_type, target_id`, strings.TrimSpace(profileID))
if err != nil {
return nil, err
}
@@ -689,16 +697,23 @@ func insertPKIClientProfileTargetTx(tx *sql.Tx, profileID string, targetType str
_, err = tx.Exec(`INSERT INTO pki_client_profile_targets (
profile_id,
target_type,
target_public_id,
target_id,
created_at
) VALUES (
(SELECT id FROM pki_client_profiles WHERE public_id = ?),
?,
?,
CASE
WHEN ? = 'user' THEN (SELECT id FROM users WHERE public_id = ?)
WHEN ? = 'group' THEN (SELECT id FROM user_groups WHERE public_id = ?)
ELSE NULL
END,
?
)`,
strings.TrimSpace(profileID),
targetType,
targetType,
targetID,
targetType,
targetID,
createdAt,
)
+7 -7
View File
@@ -80,12 +80,12 @@ func (s *Store) DeleteServicePrincipal(id string) error {
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'principal' AND subject_public_id = ?`, id)
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'principal' AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'principal' AND subject_public_id = ?`, id)
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'principal' AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
@@ -182,7 +182,7 @@ func (s *Store) ListPrincipalProjectRoles(principalID string) ([]models.Principa
FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id
WHERE b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
ORDER BY p.public_id`, principalID, principalID)
if err != nil {
return nil, err
@@ -207,9 +207,9 @@ func (s *Store) UpsertPrincipalProjectRole(item models.PrincipalProjectRole) (mo
var err error
now = time.Now().UTC().Unix()
item.CreatedAt = now
_, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_public_id, role, created_at, updated_at)
VALUES ((SELECT id FROM projects WHERE public_id = ?), 'principal', ?, ?, ?, ?)
ON CONFLICT(project_id, subject_type, subject_public_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
_, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at)
VALUES ((SELECT id FROM projects WHERE public_id = ?), 'principal', (SELECT id FROM service_principals WHERE public_id = ?), ?, ?, ?)
ON CONFLICT(project_id, subject_type, subject_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
item.ProjectID, item.PrincipalID, item.Role, item.CreatedAt, item.CreatedAt)
if err != nil {
return item, err
@@ -222,7 +222,7 @@ func (s *Store) DeletePrincipalProjectRole(principalID string, projectID string)
_, err = s.Exec(`DELETE FROM project_role_bindings
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'principal'
AND subject_public_id = ?`, projectID, principalID)
AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`, projectID, principalID)
return err
}
+381 -91
View File
@@ -35,9 +35,10 @@ 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, 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`)
rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv
LEFT JOIN users ou ON ou.id = srv.owner_user_id
ORDER BY srv.name, srv.host, srv.port`)
if err != nil {
return nil, err
}
@@ -67,9 +68,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, 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))
row = s.QueryRow(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv
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
@@ -90,10 +92,11 @@ 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, 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 owner_scope = 'user' AND owner_user_public_id = ?
ORDER BY name, host, port`, trimmedUserID)
rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv
LEFT JOIN users ou ON ou.id = srv.owner_user_id
WHERE srv.owner_scope = 'user' AND ou.public_id = ?
ORDER BY srv.name, srv.host, srv.port`, trimmedUserID)
if err != nil {
return nil, err
}
@@ -191,13 +194,13 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
enabled,
host_key_policy,
owner_scope,
owner_user_public_id,
owner_user_id,
created_by_kind,
created_by_subject_id,
created_by_subject_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Host),
@@ -208,6 +211,7 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
item.HostKeyPolicy,
strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedBySubjectName),
@@ -254,19 +258,19 @@ func (s *Store) DeleteSSHServer(id string) error {
var trimmedID string
trimmedID = strings.TrimSpace(id)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_access_profiles WHERE server_public_id = ?`, trimmedID)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_access_profiles WHERE server_id = (SELECT id FROM ssh_servers WHERE public_id = ?)`, trimmedID)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 {
return &SSHServerDeleteReferenceError{Kind: "SSH access profile", Count: count}
}
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_server_group_members WHERE server_public_id = ?`, trimmedID)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_server_group_members WHERE server_id = (SELECT id FROM ssh_servers WHERE 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)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_sessions WHERE server_id = (SELECT id FROM ssh_servers WHERE public_id = ?)`, trimmedID)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 {
@@ -286,9 +290,9 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
rows, err = s.Query(`SELECT
p.public_id,
p.server_public_id,
COALESCE(s.public_id, ''),
COALESCE(p.server_target_type, 'server'),
COALESCE(p.server_group_public_id, ''),
COALESCE(g.public_id, ''),
COALESCE(g.name, ''),
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
@@ -303,15 +307,15 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
p.auth_method,
p.second_factor_mode,
p.owner_scope,
p.owner_user_public_id,
COALESCE(pou.public_id, ''),
p.allow_user_edit,
p.enabled,
COALESCE(p.secret_public_id, ''),
COALESCE(p.ssh_credential_public_id, ''),
COALESCE(sec.public_id, ''),
COALESCE(c.public_id, ''),
COALESCE(c.name, ''),
p.auth_public_key,
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
COALESCE(ca.public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
@@ -331,16 +335,20 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
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.public_id = p.server_public_id
LEFT JOIN ssh_server_groups g ON g.public_id = p.server_group_public_id
LEFT JOIN ssh_credentials c ON c.public_id = p.ssh_credential_public_id
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
ORDER BY s.name, p.name`)
if err != nil { return nil, err }
defer rows.Close()
@@ -430,9 +438,9 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
row = s.QueryRow(`SELECT
p.public_id,
p.server_public_id,
COALESCE(s.public_id, ''),
COALESCE(p.server_target_type, 'server'),
COALESCE(p.server_group_public_id, ''),
COALESCE(g.public_id, ''),
COALESCE(g.name, ''),
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
@@ -447,15 +455,15 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
p.auth_method,
p.second_factor_mode,
p.owner_scope,
p.owner_user_public_id,
COALESCE(pou.public_id, ''),
p.allow_user_edit,
p.enabled,
COALESCE(p.secret_public_id, ''),
COALESCE(p.ssh_credential_public_id, ''),
COALESCE(sec.public_id, ''),
COALESCE(c.public_id, ''),
COALESCE(c.name, ''),
p.auth_public_key,
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
COALESCE(ca.public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
@@ -475,16 +483,20 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
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.public_id = p.server_public_id
LEFT JOIN ssh_server_groups g ON g.public_id = p.server_group_public_id
LEFT JOIN ssh_credentials c ON c.public_id = p.ssh_credential_public_id
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
WHERE p.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(
&item.ID,
@@ -634,23 +646,23 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
}
_, err = tx.Exec(`INSERT INTO ssh_access_profiles (
public_id,
server_public_id,
server_id,
server_target_type,
server_group_public_id,
server_group_id,
name,
description,
remote_username,
auth_method,
second_factor_mode,
owner_scope,
owner_user_public_id,
owner_user_id,
allow_user_edit,
enabled,
secret_public_id,
ssh_credential_public_id,
secret_id,
ssh_credential_id,
auth_public_key,
auth_public_key_fingerprint,
ssh_user_ca_public_id,
ssh_user_ca_id,
ssh_principal_grant_ids_json,
default_cert_valid_seconds,
max_cert_valid_seconds,
@@ -661,10 +673,11 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
created_by_subject_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, (SELECT id FROM ssh_servers WHERE public_id = ?), ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_server_groups WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_secrets WHERE public_id = ?) END, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_credentials WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_user_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerTargetType),
strings.TrimSpace(item.ServerGroupID),
emptyStringToNil(item.ServerGroupID),
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description),
@@ -673,12 +686,16 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
strings.TrimSpace(item.SecondFactorMode),
strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.OwnerUserID),
item.AllowUserEdit,
item.Enabled,
strings.TrimSpace(item.SecretID),
emptyStringToNil(item.SecretID),
strings.TrimSpace(item.SSHCredentialID),
emptyStringToNil(item.SSHCredentialID),
strings.TrimSpace(item.AuthPublicKey),
strings.TrimSpace(item.AuthPublicKeyFingerprint),
strings.TrimSpace(item.SSHUserCAID),
emptyStringToNil(item.SSHUserCAID),
grantIDsJSON,
item.DefaultCertValidSeconds,
@@ -788,10 +805,11 @@ 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_cert_valid_seconds = ?, max_cert_valid_seconds = ?, valid_after = ?, valid_before = ?, updated_at = ?
SET server_id = (SELECT id FROM ssh_servers WHERE public_id = ?), server_target_type = ?, server_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_server_groups WHERE public_id = ?) END, name = ?, description = ?, remote_username = ?, auth_method = ?, second_factor_mode = ?, owner_scope = ?, owner_user_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, allow_user_edit = ?, enabled = ?, secret_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_secrets WHERE public_id = ?) END, ssh_credential_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_credentials WHERE public_id = ?) END, auth_public_key = ?, auth_public_key_fingerprint = ?, ssh_user_ca_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_user_cas WHERE public_id = ?) END, 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),
strings.TrimSpace(item.ServerGroupID),
emptyStringToNil(item.ServerGroupID),
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description),
@@ -800,12 +818,16 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
strings.TrimSpace(item.SecondFactorMode),
strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.OwnerUserID),
item.AllowUserEdit,
item.Enabled,
strings.TrimSpace(item.SecretID),
emptyStringToNil(item.SecretID),
strings.TrimSpace(item.SSHCredentialID),
emptyStringToNil(item.SSHCredentialID),
strings.TrimSpace(item.AuthPublicKey),
strings.TrimSpace(item.AuthPublicKeyFingerprint),
strings.TrimSpace(item.SSHUserCAID),
emptyStringToNil(item.SSHUserCAID),
grantIDsJSON,
item.DefaultCertValidSeconds,
@@ -819,7 +841,7 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
rollbackIfOwned(tx, owned)
return item, err
}
_, err = tx.Exec(`DELETE FROM ssh_access_profile_targets WHERE profile_public_id = ?`, strings.TrimSpace(item.ID))
_, err = tx.Exec(`DELETE FROM ssh_access_profile_targets WHERE profile_id = (SELECT id FROM ssh_access_profiles WHERE public_id = ?)`, strings.TrimSpace(item.ID))
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
@@ -855,7 +877,11 @@ func (s *Store) DeleteSSHAccessProfile(id string) error {
if err != nil {
return err
}
row = tx.QueryRow(`SELECT secret_public_id, ssh_credential_public_id FROM ssh_access_profiles WHERE public_id = ?`, strings.TrimSpace(id))
row = tx.QueryRow(`SELECT COALESCE(sec.public_id, ''), COALESCE(c.public_id, '')
FROM ssh_access_profiles p
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
WHERE p.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&secretID, &credentialID)
if err != nil {
rollbackIfOwned(tx, owned)
@@ -892,9 +918,9 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
trimmedUserID = strings.TrimSpace(userID)
rows, err = s.Query(`SELECT DISTINCT
p.public_id,
p.server_public_id,
COALESCE(s.public_id, ''),
COALESCE(p.server_target_type, 'server'),
COALESCE(p.server_group_public_id, ''),
COALESCE(g.public_id, ''),
COALESCE(g.name, ''),
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
@@ -909,15 +935,15 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
p.auth_method,
p.second_factor_mode,
p.owner_scope,
p.owner_user_public_id,
COALESCE(pou.public_id, ''),
p.allow_user_edit,
p.enabled,
COALESCE(p.secret_public_id, ''),
COALESCE(p.ssh_credential_public_id, ''),
COALESCE(sec.public_id, ''),
COALESCE(c.public_id, ''),
COALESCE(c.name, ''),
p.auth_public_key,
p.auth_public_key_fingerprint,
COALESCE(p.ssh_user_ca_public_id, ''),
COALESCE(ca.public_id, ''),
p.ssh_principal_grant_ids_json,
p.default_cert_valid_seconds,
p.max_cert_valid_seconds,
@@ -937,22 +963,27 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
s.enabled,
s.host_key_policy,
s.owner_scope,
s.owner_user_public_id,
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.public_id = p.server_public_id
LEFT JOIN ssh_server_groups g ON g.public_id = p.server_group_public_id
LEFT JOIN ssh_credentials c ON c.public_id = p.ssh_credential_public_id
LEFT JOIN ssh_access_profile_targets t ON t.profile_public_id = p.public_id
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.public_id = t.target_public_id
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.owner_scope = 'user' AND p.owner_user_public_id = ?)
(p.owner_scope = 'user' AND pou.public_id = ?)
OR
(
p.owner_scope = 'admin_shared'
@@ -961,7 +992,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
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 = ?)
(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 = ?))
)
@@ -1078,10 +1109,10 @@ func (s *Store) listSSHAccessProfileTargets(profileID string) ([]models.SSHAcces
rows, err = s.Query(`SELECT
p.public_id,
t.target_type,
t.target_public_id,
CASE WHEN t.target_type = 'user' THEN COALESCE(u.public_id, '') ELSE COALESCE(g.public_id, '') END,
CASE
WHEN t.target_type = 'user' THEN COALESCE(CASE WHEN u.display_name != '' THEN u.display_name || ' (' || u.username || ')' ELSE u.username END, t.target_public_id)
ELSE COALESCE(g.name, t.target_public_id)
WHEN t.target_type = 'user' THEN COALESCE(CASE WHEN u.display_name != '' THEN u.display_name || ' (' || u.username || ')' ELSE u.username END, '')
ELSE COALESCE(g.name, '')
END,
CASE
WHEN t.target_type = 'user' THEN CASE WHEN u.public_id IS NOT NULL AND u.disabled = 0 THEN 1 ELSE 0 END
@@ -1089,11 +1120,11 @@ func (s *Store) listSSHAccessProfileTargets(profileID string) ([]models.SSHAcces
END,
t.created_at
FROM ssh_access_profile_targets t
JOIN ssh_access_profiles p ON p.public_id = t.profile_public_id
LEFT JOIN users u ON t.target_type = 'user' AND u.public_id = t.target_public_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.public_id = t.target_public_id
JOIN ssh_access_profiles p ON p.id = t.profile_id
LEFT JOIN users u ON t.target_type = 'user' AND u.id = t.target_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.id = t.target_id
WHERE p.public_id = ?
ORDER BY t.target_type, t.target_public_id`, strings.TrimSpace(profileID))
ORDER BY t.target_type, target_id`, strings.TrimSpace(profileID))
if err != nil {
return nil, err
}
@@ -1132,9 +1163,10 @@ func (s *Store) ListSSHServerHostKeys(serverID string) ([]models.SSHServerHostKe
var item models.SSHServerHostKey
var err error
rows, err = s.Query(`SELECT public_id, server_public_id, algorithm, public_key, fingerprint, created_at
FROM ssh_server_host_keys
WHERE server_public_id = ?
rows, err = s.Query(`SELECT hk.public_id, s.public_id, hk.algorithm, hk.public_key, hk.fingerprint, hk.created_at
FROM ssh_server_host_keys hk
JOIN ssh_servers s ON s.id = hk.server_id
WHERE s.public_id = ?
ORDER BY created_at, fingerprint`, strings.TrimSpace(serverID))
if err != nil {
return nil, err
@@ -1166,8 +1198,8 @@ func (s *Store) CreateSSHServerHostKey(item models.SSHServerHostKey) (models.SSH
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
_, err = s.Exec(`INSERT INTO ssh_server_host_keys (public_id, server_public_id, algorithm, public_key, fingerprint, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
_, err = s.Exec(`INSERT INTO ssh_server_host_keys (public_id, server_id, algorithm, public_key, fingerprint, created_at)
VALUES (?, (SELECT id FROM ssh_servers WHERE public_id = ?), ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.Algorithm),
@@ -1184,7 +1216,7 @@ func (s *Store) CreateSSHServerHostKey(item models.SSHServerHostKey) (models.SSH
func (s *Store) DeleteSSHServerHostKey(serverID string, hostKeyID string) error {
var err error
_, err = s.Exec(`DELETE FROM ssh_server_host_keys WHERE server_public_id = ? AND public_id = ?`, strings.TrimSpace(serverID), strings.TrimSpace(hostKeyID))
_, err = s.Exec(`DELETE FROM ssh_server_host_keys WHERE server_id = (SELECT id FROM ssh_servers WHERE public_id = ?) AND public_id = ?`, strings.TrimSpace(serverID), strings.TrimSpace(hostKeyID))
return err
}
@@ -1214,9 +1246,9 @@ func (s *Store) CreateSSHSession(item models.SSHSession) (models.SSHSession, err
item.StartedAt = now
_, err = s.Exec(`INSERT INTO ssh_sessions (
public_id,
profile_public_id,
server_public_id,
user_public_id,
profile_id,
server_id,
user_id,
username,
remote_username,
auth_method,
@@ -1234,7 +1266,7 @@ func (s *Store) CreateSSHSession(item models.SSHSession) (models.SSHSession, err
remote_addr,
user_agent,
error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, (SELECT id FROM ssh_access_profiles WHERE public_id = ?), (SELECT id FROM ssh_servers WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.ProfileID),
strings.TrimSpace(item.ServerID),
@@ -1283,7 +1315,7 @@ func (s *Store) CountPendingSSHSessionsForUser(userID string) (int, error) {
row = s.QueryRow(`SELECT COUNT(*)
FROM ssh_sessions
WHERE user_public_id = ? AND status = 'pending'`, strings.TrimSpace(userID))
WHERE user_id = (SELECT id FROM users WHERE public_id = ?) AND status = 'pending'`, strings.TrimSpace(userID))
err = row.Scan(&count)
if err != nil {
return 0, err
@@ -1296,9 +1328,11 @@ func (s *Store) GetSSHSession(id string) (models.SSHSession, error) {
var item models.SSHSession
var err error
row = s.QueryRow(`SELECT ss.public_id, ss.profile_public_id, ss.server_public_id, COALESCE(s.name, ''), ss.user_public_id, ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
row = s.QueryRow(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_servers s ON s.public_id = ss.server_public_id
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id
WHERE ss.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
@@ -1341,20 +1375,22 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
}
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
whereParts = append(whereParts, "ss.user_public_id = ?")
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, strings.TrimSpace(userID))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR ss.profile_public_id LIKE ? OR ss.server_public_id LIKE ? OR COALESCE(s.name, '') LIKE ? OR ss.user_public_id LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT ss.public_id, ss.profile_public_id, ss.server_public_id, COALESCE(s.name, ''), ss.user_public_id, ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
sqlQuery = fmt.Sprintf(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_servers s ON s.public_id = ss.server_public_id
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id
WHERE %s
ORDER BY ss.started_at DESC
LIMIT ? OFFSET ?`, strings.Join(whereParts, " AND "))
@@ -1405,16 +1441,18 @@ func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, sta
status = strings.ToLower(strings.TrimSpace(status))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR ss.profile_public_id LIKE ? OR ss.server_public_id LIKE ? OR COALESCE(s.name, '') LIKE ? OR ss.user_public_id LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = `SELECT ss.public_id, ss.profile_public_id, ss.server_public_id, COALESCE(s.name, ''), ss.user_public_id, ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
sqlQuery = `SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_servers s ON s.public_id = ss.server_public_id`
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id`
if len(whereParts) > 0 {
sqlQuery += "\n\t\tWHERE " + strings.Join(whereParts, " AND ")
}
@@ -1459,6 +1497,257 @@ func (s *Store) UpdateSSHSessionStatus(id string, status string, hostKeyFingerpr
return err
}
func (s *Store) CreateSSHFileTransfer(item models.SSHFileTransfer) (models.SSHFileTransfer, error) {
var pathsJSON string
var now int64
var err error
if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID()
if err != nil { return item, err }
}
pathsJSON, err = encodeStringList(item.Paths)
if err != nil { return item, err }
now = time.Now().UTC().Unix()
if item.StartedAt <= 0 {
item.StartedAt = now
}
if strings.TrimSpace(item.Status) == "" {
item.Status = "running"
}
_, err = s.Exec(`INSERT INTO ssh_file_transfers (
public_id,
session_id,
user_id,
username,
profile_id,
server_id,
server_name,
remote_username,
operation,
source_session_id,
target_session_id,
target_server_id,
target_server_name,
target_dir,
paths_json,
bytes_transferred,
status,
error,
started_at,
finished_at,
duration_ms,
remote_addr,
user_agent
) VALUES (?, (SELECT id FROM ssh_sessions WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?, (SELECT id FROM ssh_access_profiles WHERE public_id = ?), (SELECT id FROM ssh_servers WHERE public_id = ?), ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_sessions WHERE public_id = ?) END, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_sessions WHERE public_id = ?) END, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_servers WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(item.ID),
strings.TrimSpace(item.SessionID),
strings.TrimSpace(item.UserID),
strings.TrimSpace(item.Username),
strings.TrimSpace(item.ProfileID),
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerName),
strings.TrimSpace(item.RemoteUsername),
strings.TrimSpace(item.Operation),
strings.TrimSpace(item.SourceSessionID),
strings.TrimSpace(item.SourceSessionID),
strings.TrimSpace(item.TargetSessionID),
strings.TrimSpace(item.TargetSessionID),
strings.TrimSpace(item.TargetServerID),
strings.TrimSpace(item.TargetServerID),
strings.TrimSpace(item.TargetServerName),
strings.TrimSpace(item.TargetDir),
pathsJSON,
item.BytesTransferred,
strings.TrimSpace(item.Status),
strings.TrimSpace(item.Error),
item.StartedAt,
item.FinishedAt,
item.DurationMS,
strings.TrimSpace(item.RemoteAddr),
strings.TrimSpace(item.UserAgent),
)
return item, err
}
func (s *Store) FinishSSHFileTransfer(id string, status string, paths []string, bytesTransferred int64, errText string) error {
var row *sql.Row
var startedAt int64
var finishedAt int64
var durationMS int64
var pathsJSON string
var err error
pathsJSON, err = encodeStringList(paths)
if err != nil { return err }
finishedAt = time.Now().UTC().Unix()
row = s.QueryRow(`SELECT started_at FROM ssh_file_transfers WHERE public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&startedAt)
if err != nil { return err }
durationMS = 0
if startedAt > 0 && finishedAt >= startedAt {
durationMS = (finishedAt - startedAt) * 1000
}
_, err = s.Exec(`UPDATE ssh_file_transfers
SET status = ?, paths_json = ?, bytes_transferred = ?, error = ?, finished_at = ?, duration_ms = ?
WHERE public_id = ?`,
strings.TrimSpace(status),
pathsJSON,
bytesTransferred,
strings.TrimSpace(errText),
finishedAt,
durationMS,
strings.TrimSpace(id),
)
return err
}
func scanSSHFileTransferRows(rows *sql.Rows, limit int) ([]models.SSHFileTransfer, bool, error) {
var items []models.SSHFileTransfer
var item models.SSHFileTransfer
var pathsJSON string
var err error
for rows.Next() {
err = rows.Scan(
&item.ID,
&item.SessionID,
&item.UserID,
&item.Username,
&item.ProfileID,
&item.ServerID,
&item.ServerName,
&item.RemoteUsername,
&item.Operation,
&item.SourceSessionID,
&item.TargetSessionID,
&item.TargetServerID,
&item.TargetServerName,
&item.TargetDir,
&pathsJSON,
&item.BytesTransferred,
&item.Status,
&item.Error,
&item.StartedAt,
&item.FinishedAt,
&item.DurationMS,
&item.RemoteAddr,
&item.UserAgent,
)
if err != nil { return nil, false, err }
item.Paths, err = decodeStringList(pathsJSON)
if err != nil { return nil, false, err }
items = append(items, item)
}
err = rows.Err()
if err != nil { return nil, false, err }
if len(items) > limit {
items = items[:limit]
return items, true, nil
}
return items, false, nil
}
func (s *Store) ListSSHFileTransfersForUserFiltered(userID string, limit int, offset int, query string, status string, sessionID string) ([]models.SSHFileTransfer, bool, error) {
var rows *sql.Rows
var whereParts []string
var args []any
var sqlQuery string
var like string
var err error
if limit <= 0 { limit = 20 }
if limit > 200 { limit = 200 }
if offset < 0 { offset = 0 }
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
sessionID = strings.TrimSpace(sessionID)
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, strings.TrimSpace(userID))
if sessionID != "" {
whereParts = append(whereParts, "sess.public_id = ?")
args = append(args, sessionID)
}
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ft.public_id LIKE ? OR COALESCE(sess.public_id, '') LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR ft.server_name LIKE ? OR ft.username LIKE ? OR ft.remote_username LIKE ? OR ft.operation LIKE ? OR COALESCE(tsess.public_id, '') LIKE ? OR COALESCE(ts.public_id, '') LIKE ? OR ft.target_server_name LIKE ? OR ft.target_dir LIKE ? OR ft.paths_json LIKE ? OR ft.status LIKE ? OR ft.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ft.status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT ft.public_id, COALESCE(sess.public_id, ''), COALESCE(u.public_id, ''), ft.username, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), ft.server_name, ft.remote_username, ft.operation, COALESCE(ssess.public_id, ''), COALESCE(tsess.public_id, ''), COALESCE(ts.public_id, ''), ft.target_server_name, ft.target_dir, ft.paths_json, ft.bytes_transferred, ft.status, ft.error, ft.started_at, ft.finished_at, ft.duration_ms, ft.remote_addr, ft.user_agent
FROM ssh_file_transfers ft
LEFT JOIN ssh_sessions sess ON sess.id = ft.session_id
LEFT JOIN users u ON u.id = ft.user_id
LEFT JOIN ssh_access_profiles p ON p.id = ft.profile_id
LEFT JOIN ssh_servers s ON s.id = ft.server_id
LEFT JOIN ssh_sessions ssess ON ssess.id = ft.source_session_id
LEFT JOIN ssh_sessions tsess ON tsess.id = ft.target_session_id
LEFT JOIN ssh_servers ts ON ts.id = ft.target_server_id
WHERE %s
ORDER BY ft.started_at DESC, ft.id DESC
LIMIT ? OFFSET ?`, strings.Join(whereParts, " AND "))
args = append(args, limit + 1, offset)
rows, err = s.Query(sqlQuery, args...)
if err != nil { return nil, false, err }
defer rows.Close()
return scanSSHFileTransferRows(rows, limit)
}
func (s *Store) ListSSHFileTransfersFiltered(limit int, offset int, query string, status string, sessionID string, userID string) ([]models.SSHFileTransfer, bool, error) {
var rows *sql.Rows
var whereParts []string
var args []any
var sqlQuery string
var like string
var err error
if limit <= 0 { limit = 20 }
if limit > 200 { limit = 200 }
if offset < 0 { offset = 0 }
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
sessionID = strings.TrimSpace(sessionID)
userID = strings.TrimSpace(userID)
if sessionID != "" {
whereParts = append(whereParts, "sess.public_id = ?")
args = append(args, sessionID)
}
if userID != "" {
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, userID)
}
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ft.public_id LIKE ? OR COALESCE(sess.public_id, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ft.username LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR ft.server_name LIKE ? OR ft.remote_username LIKE ? OR ft.operation LIKE ? OR COALESCE(tsess.public_id, '') LIKE ? OR COALESCE(ts.public_id, '') LIKE ? OR ft.target_server_name LIKE ? OR ft.target_dir LIKE ? OR ft.paths_json LIKE ? OR ft.status LIKE ? OR ft.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ft.status = ?")
args = append(args, status)
}
sqlQuery = `SELECT ft.public_id, COALESCE(sess.public_id, ''), COALESCE(u.public_id, ''), ft.username, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), ft.server_name, ft.remote_username, ft.operation, COALESCE(ssess.public_id, ''), COALESCE(tsess.public_id, ''), COALESCE(ts.public_id, ''), ft.target_server_name, ft.target_dir, ft.paths_json, ft.bytes_transferred, ft.status, ft.error, ft.started_at, ft.finished_at, ft.duration_ms, ft.remote_addr, ft.user_agent
FROM ssh_file_transfers ft
LEFT JOIN ssh_sessions sess ON sess.id = ft.session_id
LEFT JOIN users u ON u.id = ft.user_id
LEFT JOIN ssh_access_profiles p ON p.id = ft.profile_id
LEFT JOIN ssh_servers s ON s.id = ft.server_id
LEFT JOIN ssh_sessions ssess ON ssess.id = ft.source_session_id
LEFT JOIN ssh_sessions tsess ON tsess.id = ft.target_session_id
LEFT JOIN ssh_servers ts ON ts.id = ft.target_server_id`
if len(whereParts) > 0 {
sqlQuery += "\n\t\tWHERE " + strings.Join(whereParts, " AND ")
}
sqlQuery += "\n\t\tORDER BY ft.started_at DESC, ft.id DESC\n\t\tLIMIT ? OFFSET ?"
args = append(args, limit + 1, offset)
rows, err = s.Query(sqlQuery, args...)
if err != nil { return nil, false, err }
defer rows.Close()
return scanSSHFileTransferRows(rows, limit)
}
func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) {
var err error
var now int64
@@ -1478,8 +1767,6 @@ 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,
@@ -1541,14 +1828,17 @@ func insertSSHAccessProfileTargetTx(tx *sql.Tx, profileID string, targetType str
}
_, err = tx.Exec(`INSERT INTO ssh_access_profile_targets (
public_id,
profile_public_id,
profile_id,
target_type,
target_public_id,
target_id,
created_at
) VALUES (?, ?, ?, ?, ?)`,
) VALUES (?, (SELECT id FROM ssh_access_profiles WHERE public_id = ?), ?, CASE WHEN ? = 'user' THEN (SELECT id FROM users WHERE public_id = ?) WHEN ? = 'group' THEN (SELECT id FROM user_groups WHERE public_id = ?) ELSE NULL END, ?)`,
id,
strings.TrimSpace(profileID),
targetType,
targetType,
targetID,
targetType,
targetID,
createdAt,
)
+26 -15
View File
@@ -31,8 +31,10 @@ func (s *Store) ListSSHCredentials() ([]models.SSHCredential, error) {
var item models.SSHCredential
var err error
rows, err = s.Query(`SELECT public_id, name, description, type, secret_public_id, public_key, fingerprint, enabled, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_credentials
rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE owner_scope = 'admin'
ORDER BY name`)
if err != nil { return nil, err }
@@ -53,10 +55,12 @@ func (s *Store) ListSSHCredentialsForUser(userID string) ([]models.SSHCredential
var item models.SSHCredential
var err error
rows, err = s.Query(`SELECT public_id, name, description, type, secret_public_id, public_key, fingerprint, enabled, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_credentials
WHERE owner_scope = 'user' AND owner_user_public_id = ?
ORDER BY name`, strings.TrimSpace(userID))
rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.owner_scope = 'user' AND u.public_id = ?
ORDER BY c.name`, strings.TrimSpace(userID))
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
@@ -74,9 +78,11 @@ func (s *Store) GetSSHCredential(id string) (models.SSHCredential, error) {
var item models.SSHCredential
var err error
row = s.QueryRow(`SELECT public_id, name, description, type, secret_public_id, public_key, fingerprint, enabled, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_credentials
WHERE public_id = ?`, strings.TrimSpace(id))
row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.public_id = ?`, strings.TrimSpace(id))
err = scanSSHCredential(row, &item)
return item, err
}
@@ -86,9 +92,11 @@ func (s *Store) GetSSHCredentialForUser(userID string, id string) (models.SSHCre
var item models.SSHCredential
var err error
row = s.QueryRow(`SELECT public_id, name, description, type, secret_public_id, public_key, fingerprint, enabled, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_credentials
WHERE public_id = ? AND owner_scope = 'user' AND owner_user_public_id = ?`, strings.TrimSpace(id), strings.TrimSpace(userID))
row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.public_id = ? AND c.owner_scope = 'user' AND u.public_id = ?`, strings.TrimSpace(id), strings.TrimSpace(userID))
err = scanSSHCredential(row, &item)
return item, err
}
@@ -123,8 +131,8 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
item.CreatedAt = now
item.UpdatedAt = now
item.SecretID = secret.ID
_, err = tx.Exec(`INSERT INTO ssh_credentials (public_id, name, description, type, secret_public_id, public_key, fingerprint, enabled, owner_scope, owner_user_public_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
_, err = tx.Exec(`INSERT INTO ssh_credentials (public_id, name, description, type, secret_id, public_key, fingerprint, enabled, owner_scope, owner_user_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT id FROM ssh_secrets WHERE public_id = ?), ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description),
@@ -174,7 +182,10 @@ func (s *Store) DeleteSSHCredential(id string) error {
var err error
id = strings.TrimSpace(id)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_access_profiles WHERE ssh_credential_public_id = ?`, id)
row = s.QueryRow(`SELECT COUNT(*)
FROM ssh_access_profiles p
JOIN ssh_credentials c ON c.id = p.ssh_credential_id
WHERE c.public_id = ?`, id)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 { return errors.New("ssh credential is used by access profiles") }
+34 -24
View File
@@ -29,14 +29,18 @@ func (s *Store) ListSSHPrincipalGrants(targetType string, targetID string) ([]mo
g.used_count,
g.disabled,
g.reason,
COALESCE(g.created_by_user_public_id, ''),
COALESCE(cu.public_id, ''),
g.created_at,
g.updated_at,
g.last_used_at
FROM ssh_principal_grants g
JOIN ssh_principal_grant_targets t ON t.grant_public_id = g.public_id
WHERE t.target_type = ? AND t.target_public_id = ?
ORDER BY g.principal, g.created_at`, targetType, targetID)
LEFT JOIN users cu ON cu.id = g.created_by_user_id
JOIN ssh_principal_grant_targets t ON t.grant_id = g.id
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
LEFT JOIN user_groups tg ON t.target_type = 'group' AND tg.id = t.target_id
WHERE t.target_type = ?
AND ((t.target_type = 'user' AND tu.public_id = ?) OR (t.target_type = 'group' AND tg.public_id = ?))
ORDER BY g.principal, g.created_at`, targetType, targetID, targetID)
} else {
rows, err = s.Query(`SELECT
g.public_id,
@@ -49,11 +53,12 @@ func (s *Store) ListSSHPrincipalGrants(targetType string, targetID string) ([]mo
g.used_count,
g.disabled,
g.reason,
COALESCE(g.created_by_user_public_id, ''),
COALESCE(cu.public_id, ''),
g.created_at,
g.updated_at,
g.last_used_at
FROM ssh_principal_grants g
LEFT JOIN users cu ON cu.id = g.created_by_user_id
ORDER BY g.principal, g.created_at`)
}
if err != nil {
@@ -114,21 +119,23 @@ func (s *Store) ListActiveSSHPrincipalGrantsForUser(userID string, now int64) ([
g.used_count,
g.disabled,
g.reason,
COALESCE(g.created_by_user_public_id, ''),
COALESCE(cu.public_id, ''),
g.created_at,
g.updated_at,
g.last_used_at
FROM ssh_principal_grants g
JOIN ssh_principal_grant_targets t ON t.grant_public_id = g.public_id
LEFT JOIN user_groups ug ON t.target_type = 'group' AND ug.public_id = t.target_public_id
LEFT JOIN users cu ON cu.id = g.created_by_user_id
JOIN ssh_principal_grant_targets t ON t.grant_id = g.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 g.disabled = 0
AND (g.valid_after = 0 OR g.valid_after <= ?)
AND (g.valid_before = 0 OR g.valid_before >= ?)
AND (g.max_uses = 0 OR g.used_count < g.max_uses)
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
(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 = ?))
)
@@ -195,11 +202,12 @@ func (s *Store) GetSSHPrincipalGrant(id string) (models.SSHPrincipalGrant, error
g.used_count,
g.disabled,
g.reason,
COALESCE(g.created_by_user_public_id, ''),
COALESCE(cu.public_id, ''),
g.created_at,
g.updated_at,
g.last_used_at
FROM ssh_principal_grants g
LEFT JOIN users cu ON cu.id = g.created_by_user_id
WHERE g.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(
&item.ID,
@@ -283,11 +291,11 @@ func (s *Store) CreateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.S
used_count,
disabled,
reason,
created_by_user_public_id,
created_by_user_id,
created_at,
updated_at,
last_used_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, ?)`,
strings.TrimSpace(item.ID),
strings.TrimSpace(item.Name),
principalsJSON,
@@ -298,7 +306,8 @@ func (s *Store) CreateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.S
item.UsedCount,
item.Disabled,
strings.TrimSpace(item.Reason),
nullableTrimmedString(item.CreatedByUserID),
strings.TrimSpace(item.CreatedByUserID),
strings.TrimSpace(item.CreatedByUserID),
item.CreatedAt,
item.UpdatedAt,
item.LastUsedAt,
@@ -388,7 +397,7 @@ func (s *Store) UpdateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.S
rollbackIfOwned(tx, owned)
return item, err
}
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE grant_public_id = ?`, strings.TrimSpace(item.ID))
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE grant_id = (SELECT id FROM ssh_principal_grants WHERE public_id = ?)`, strings.TrimSpace(item.ID))
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
@@ -482,9 +491,9 @@ func (s *Store) listSSHPrincipalGrantTargets(grantID string) ([]models.SSHPrinci
var items []models.SSHPrincipalGrantTarget
var item models.SSHPrincipalGrantTarget
rows, err = s.Query(`SELECT
t.grant_public_id,
gnt.public_id,
t.target_type,
t.target_public_id,
CASE WHEN t.target_type = 'user' THEN COALESCE(u.public_id, '') ELSE COALESCE(g.public_id, '') END,
CASE
WHEN t.target_type = 'user' THEN COALESCE(u.username, '')
WHEN t.target_type = 'group' THEN COALESCE(g.name, '')
@@ -497,10 +506,11 @@ func (s *Store) listSSHPrincipalGrantTargets(grantID string) ([]models.SSHPrinci
END AS target_active,
t.created_at
FROM ssh_principal_grant_targets t
LEFT JOIN users u ON t.target_type = 'user' AND u.public_id = t.target_public_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.public_id = t.target_public_id
WHERE t.grant_public_id = ?
ORDER BY t.target_type, target_name, t.target_public_id`,
JOIN ssh_principal_grants gnt ON gnt.id = t.grant_id
LEFT JOIN users u ON t.target_type = 'user' AND u.id = t.target_id
LEFT JOIN user_groups g ON t.target_type = 'group' AND g.id = t.target_id
WHERE gnt.public_id = ?
ORDER BY t.target_type, target_name, target_id`,
strings.TrimSpace(grantID),
)
if err != nil {
@@ -541,8 +551,8 @@ func insertSSHPrincipalGrantTargetTx(tx *sql.Tx, grantID string, targetType stri
return errors.New("target id is required")
}
if normalizedType == "user" {
result, err = tx.Exec(`INSERT INTO ssh_principal_grant_targets (grant_public_id, target_type, target_public_id, created_at)
SELECT ?, 'user', u.public_id, ?
result, err = tx.Exec(`INSERT INTO ssh_principal_grant_targets (grant_id, target_type, target_id, created_at)
SELECT (SELECT id FROM ssh_principal_grants WHERE public_id = ?), 'user', u.id, ?
FROM users u
WHERE u.public_id = ?`,
strings.TrimSpace(grantID),
@@ -550,8 +560,8 @@ func insertSSHPrincipalGrantTargetTx(tx *sql.Tx, grantID string, targetType stri
strings.TrimSpace(targetID),
)
} else {
result, err = tx.Exec(`INSERT INTO ssh_principal_grant_targets (grant_public_id, target_type, target_public_id, created_at)
SELECT ?, 'group', g.public_id, ?
result, err = tx.Exec(`INSERT INTO ssh_principal_grant_targets (grant_id, target_type, target_id, created_at)
SELECT (SELECT id FROM ssh_principal_grants WHERE public_id = ?), 'group', g.id, ?
FROM user_groups g
WHERE g.public_id = ?`,
strings.TrimSpace(grantID),
+26 -8
View File
@@ -18,7 +18,12 @@ func (s *Store) listSSHServerGroupMemberIDs(groupID string) ([]string, error) {
var id string
var err error
rows, err = s.Query(`SELECT server_public_id FROM ssh_server_group_members WHERE group_public_id = ? ORDER BY server_public_id`, strings.TrimSpace(groupID))
rows, err = s.Query(`SELECT s.public_id
FROM ssh_server_group_members gm
JOIN ssh_servers s ON s.id = gm.server_id
JOIN ssh_server_groups g ON g.id = gm.group_id
WHERE g.public_id = ?
ORDER BY s.public_id`, strings.TrimSpace(groupID))
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
@@ -72,10 +77,12 @@ 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.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
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, COALESCE(u.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 = ?
JOIN ssh_servers s ON s.id = gm.server_id
JOIN ssh_server_groups g ON g.id = gm.group_id
LEFT JOIN users u ON u.id = s.owner_user_id
WHERE g.public_id = ?
ORDER BY s.name`, strings.TrimSpace(groupID))
if err != nil { return nil, err }
defer rows.Close()
@@ -137,7 +144,10 @@ func (s *Store) DeleteSSHServerGroup(id string) error {
var err error
id = strings.TrimSpace(id)
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_access_profiles WHERE server_group_public_id = ?`, id)
row = s.QueryRow(`SELECT COUNT(*)
FROM ssh_access_profiles p
JOIN ssh_server_groups g ON g.id = p.server_group_id
WHERE g.public_id = ?`, id)
err = row.Scan(&count)
if err != nil { return err }
if count > 0 { return errors.New("ssh server group is used by access profiles") }
@@ -150,14 +160,20 @@ func (s *Store) AddSSHServerGroupMember(groupID string, serverID string) error {
var err error
now = time.Now().UTC().Unix()
_, err = s.Exec(`INSERT OR IGNORE INTO ssh_server_group_members (group_public_id, server_public_id, created_at) VALUES (?, ?, ?)`, strings.TrimSpace(groupID), strings.TrimSpace(serverID), now)
_, err = s.Exec(`INSERT OR IGNORE INTO ssh_server_group_members (group_id, server_id, created_at)
SELECT g.id, srv.id, ?
FROM ssh_server_groups g
JOIN ssh_servers srv ON srv.public_id = ?
WHERE g.public_id = ?`, now, strings.TrimSpace(serverID), strings.TrimSpace(groupID))
return err
}
func (s *Store) DeleteSSHServerGroupMember(groupID string, serverID string) error {
var err error
_, err = s.Exec(`DELETE FROM ssh_server_group_members WHERE group_public_id = ? AND server_public_id = ?`, strings.TrimSpace(groupID), strings.TrimSpace(serverID))
_, err = s.Exec(`DELETE FROM ssh_server_group_members
WHERE group_id = (SELECT id FROM ssh_server_groups WHERE public_id = ?)
AND server_id = (SELECT id FROM ssh_servers WHERE public_id = ?)`, strings.TrimSpace(groupID), strings.TrimSpace(serverID))
return err
}
@@ -166,7 +182,9 @@ func (s *Store) SSHServerBelongsToGroup(groupID string, serverID string) (bool,
var count int
var err error
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_server_group_members WHERE group_public_id = ? AND server_public_id = ?`, strings.TrimSpace(groupID), strings.TrimSpace(serverID))
row = s.QueryRow(`SELECT COUNT(*) FROM ssh_server_group_members
WHERE group_id = (SELECT id FROM ssh_server_groups WHERE public_id = ?)
AND server_id = (SELECT id FROM ssh_servers WHERE public_id = ?)`, strings.TrimSpace(groupID), strings.TrimSpace(serverID))
err = row.Scan(&count)
if err != nil { return false, err }
return count > 0, nil
+96 -87
View File
@@ -215,8 +215,8 @@ func (s *Store) CreateSSHUserCAIssuance(item models.SSHUserCAIssuance) (models.S
}
_, err = s.Exec(`INSERT INTO ssh_user_ca_issuances (
public_id,
ca_public_id,
issuer_user_public_id,
ca_id,
issuer_user_id,
issuer_username,
issuer_kind,
source_public_key,
@@ -232,10 +232,11 @@ func (s *Store) CreateSSHUserCAIssuance(item models.SSHUserCAIssuance) (models.S
remote_addr,
user_agent,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, (SELECT id FROM ssh_user_cas WHERE public_id = ?), CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(item.ID),
strings.TrimSpace(item.CAID),
nullableTrimmedString(item.IssuerUserID),
strings.TrimSpace(item.IssuerUserID),
strings.TrimSpace(item.IssuerUserID),
strings.TrimSpace(item.IssuerUsername),
strings.TrimSpace(item.IssuerKind),
strings.TrimSpace(item.SourcePublicKey),
@@ -268,27 +269,29 @@ func (s *Store) ListSSHUserCAIssuancesByCA(caID string, limit int) ([]models.SSH
var grantNamesJSON string
limit = normalizeSSHUserCAIssuanceLimit(limit)
rows, err = s.Query(`SELECT
public_id,
ca_public_id,
COALESCE(issuer_user_public_id, ''),
issuer_username,
issuer_kind,
source_public_key,
source_public_key_fingerprint,
certificate,
key_id,
principals_json,
grant_ids_json,
grant_names_json,
valid_after,
valid_before,
serial,
remote_addr,
user_agent,
created_at
FROM ssh_user_ca_issuances
WHERE ca_public_id = ?
ORDER BY created_at DESC
i.public_id,
COALESCE(ca.public_id, ''),
COALESCE(u.public_id, ''),
i.issuer_username,
i.issuer_kind,
i.source_public_key,
i.source_public_key_fingerprint,
i.certificate,
i.key_id,
i.principals_json,
i.grant_ids_json,
i.grant_names_json,
i.valid_after,
i.valid_before,
i.serial,
i.remote_addr,
i.user_agent,
i.created_at
FROM ssh_user_ca_issuances i
LEFT JOIN ssh_user_cas ca ON ca.id = i.ca_id
LEFT JOIN users u ON u.id = i.issuer_user_id
WHERE ca.public_id = ?
ORDER BY i.created_at DESC
LIMIT ?`,
strings.TrimSpace(caID),
limit,
@@ -354,52 +357,56 @@ func (s *Store) ListSSHUserCAIssuances(limit int, caID string) ([]models.SSHUser
caID = strings.TrimSpace(caID)
if caID == "" {
rows, err = s.Query(`SELECT
public_id,
ca_public_id,
COALESCE(issuer_user_public_id, ''),
issuer_username,
issuer_kind,
source_public_key,
source_public_key_fingerprint,
certificate,
key_id,
principals_json,
grant_ids_json,
grant_names_json,
valid_after,
valid_before,
serial,
remote_addr,
user_agent,
created_at
FROM ssh_user_ca_issuances
ORDER BY created_at DESC
i.public_id,
COALESCE(ca.public_id, ''),
COALESCE(u.public_id, ''),
i.issuer_username,
i.issuer_kind,
i.source_public_key,
i.source_public_key_fingerprint,
i.certificate,
i.key_id,
i.principals_json,
i.grant_ids_json,
i.grant_names_json,
i.valid_after,
i.valid_before,
i.serial,
i.remote_addr,
i.user_agent,
i.created_at
FROM ssh_user_ca_issuances i
LEFT JOIN ssh_user_cas ca ON ca.id = i.ca_id
LEFT JOIN users u ON u.id = i.issuer_user_id
ORDER BY i.created_at DESC
LIMIT ?`,
limit,
)
} else {
rows, err = s.Query(`SELECT
public_id,
ca_public_id,
COALESCE(issuer_user_public_id, ''),
issuer_username,
issuer_kind,
source_public_key,
source_public_key_fingerprint,
certificate,
key_id,
principals_json,
grant_ids_json,
grant_names_json,
valid_after,
valid_before,
serial,
remote_addr,
user_agent,
created_at
FROM ssh_user_ca_issuances
WHERE ca_public_id = ?
ORDER BY created_at DESC
i.public_id,
COALESCE(ca.public_id, ''),
COALESCE(u.public_id, ''),
i.issuer_username,
i.issuer_kind,
i.source_public_key,
i.source_public_key_fingerprint,
i.certificate,
i.key_id,
i.principals_json,
i.grant_ids_json,
i.grant_names_json,
i.valid_after,
i.valid_before,
i.serial,
i.remote_addr,
i.user_agent,
i.created_at
FROM ssh_user_ca_issuances i
LEFT JOIN ssh_user_cas ca ON ca.id = i.ca_id
LEFT JOIN users u ON u.id = i.issuer_user_id
WHERE ca.public_id = ?
ORDER BY i.created_at DESC
LIMIT ?`,
caID,
limit,
@@ -464,27 +471,29 @@ func (s *Store) ListSSHUserCAIssuancesForSelf(userID string, limit int) ([]model
var grantNamesJSON string
limit = normalizeSSHUserCAIssuanceLimit(limit)
rows, err = s.Query(`SELECT
public_id,
ca_public_id,
COALESCE(issuer_user_public_id, ''),
issuer_username,
issuer_kind,
source_public_key,
source_public_key_fingerprint,
certificate,
key_id,
principals_json,
grant_ids_json,
grant_names_json,
valid_after,
valid_before,
serial,
remote_addr,
user_agent,
created_at
FROM ssh_user_ca_issuances
WHERE issuer_user_public_id = ? AND issuer_kind = 'self'
ORDER BY created_at DESC
i.public_id,
COALESCE(ca.public_id, ''),
COALESCE(u.public_id, ''),
i.issuer_username,
i.issuer_kind,
i.source_public_key,
i.source_public_key_fingerprint,
i.certificate,
i.key_id,
i.principals_json,
i.grant_ids_json,
i.grant_names_json,
i.valid_after,
i.valid_before,
i.serial,
i.remote_addr,
i.user_agent,
i.created_at
FROM ssh_user_ca_issuances i
LEFT JOIN ssh_user_cas ca ON ca.id = i.ca_id
LEFT JOIN users u ON u.id = i.issuer_user_id
WHERE u.public_id = ? AND i.issuer_kind = 'self'
ORDER BY i.created_at DESC
LIMIT ?`,
strings.TrimSpace(userID),
limit,
+35 -35
View File
@@ -143,17 +143,17 @@ func (s *Store) DeleteUser(id string) error {
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'user' AND subject_public_id = ?`, id)
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'user' AND target_public_id = ?`, id)
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'user' AND target_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'user' AND subject_public_id = ?`, id)
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
@@ -950,8 +950,8 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
rollbackIfOwned(tx, owned)
return project, err
}
_, err = tx.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_public_id, role, created_at, updated_at)
VALUES ((SELECT id FROM projects WHERE public_id = ?), 'user', ?, ?, ?, ?)`,
_, err = tx.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at)
VALUES ((SELECT id FROM projects WHERE public_id = ?), 'user', (SELECT id FROM users WHERE public_id = ?), ?, ?, ?)`,
project.ID, project.CreatedBy, "admin", project.CreatedAt, project.UpdatedAt)
if err != nil {
rollbackIfOwned(tx, owned)
@@ -1102,10 +1102,10 @@ func (s *Store) ListProjectsForUser(userID string) ([]models.Project, error) {
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -1167,7 +1167,7 @@ func (s *Store) ListProjectsForPrincipal(principalID string) ([]models.Project,
FROM project_role_bindings b
WHERE b.project_id = p.id
AND b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
)
ORDER BY p.name
`, principalID)
@@ -1289,10 +1289,10 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -1327,10 +1327,10 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -1406,7 +1406,7 @@ func (s *Store) ListProjectsFilteredForPrincipal(principalID string, limit int,
FROM project_role_bindings b
WHERE b.project_id = p.id
AND b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
)
ORDER BY p.name LIMIT ? OFFSET ?`,
principalID,
@@ -1427,7 +1427,7 @@ func (s *Store) ListProjectsFilteredForPrincipal(principalID string, limit int,
FROM project_role_bindings b
WHERE b.project_id = p.id
AND b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
) AND (p.name LIKE ? OR p.slug LIKE ?)
ORDER BY p.name LIMIT ? OFFSET ?`,
principalID,
@@ -1498,10 +1498,10 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -1528,10 +1528,10 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
(b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
(b.subject_type = 'group' AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -1573,7 +1573,7 @@ func (s *Store) CountProjectsFilteredForPrincipal(principalID string, query stri
FROM project_role_bindings b
WHERE b.project_id = p.id
AND b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
)`,
principalID,
)
@@ -1586,7 +1586,7 @@ func (s *Store) CountProjectsFilteredForPrincipal(principalID string, query stri
FROM project_role_bindings b
WHERE b.project_id = p.id
AND b.subject_type = 'principal'
AND b.subject_public_id = ?
AND b.subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
) AND (p.name LIKE ? OR p.slug LIKE ?)`,
principalID,
"%"+query+"%",
@@ -1614,11 +1614,11 @@ func (s *Store) AddProjectMember(projectID, userID, role string) (models.Project
var rowsAffected int64
now = time.Now().UTC().Unix()
member = models.ProjectMember{ProjectID: projectID, UserID: userID, Role: role, CreatedAt: now}
result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_public_id, role, created_at, updated_at)
SELECT (SELECT id FROM projects WHERE public_id = ?), 'user', u.public_id, ?, ?, ?
result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at)
SELECT (SELECT id FROM projects WHERE public_id = ?), 'user', u.id, ?, ?, ?
FROM users u
WHERE u.public_id = ?
ON CONFLICT(project_id, subject_type, subject_public_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
ON CONFLICT(project_id, subject_type, subject_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
member.ProjectID, member.Role, now, now, member.UserID)
if err != nil {
return member, err
@@ -1639,7 +1639,7 @@ func (s *Store) UpdateProjectMemberRole(projectID, userID, role string) error {
SET role = ?, updated_at = ?
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'user'
AND subject_public_id = ?`, role, time.Now().UTC().Unix(), projectID, userID)
AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, role, time.Now().UTC().Unix(), projectID, userID)
return err
}
@@ -1648,7 +1648,7 @@ func (s *Store) RemoveProjectMember(projectID, userID string) error {
_, err = s.Exec(`DELETE FROM project_role_bindings
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'user'
AND subject_public_id = ?`, projectID, userID)
AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, projectID, userID)
return err
}
@@ -1660,7 +1660,7 @@ func (s *Store) ListProjectMembers(projectID string) ([]models.ProjectMember, er
rows, err = s.Query(`SELECT p.public_id, u.public_id, b.role, b.created_at
FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id
JOIN users u ON u.public_id = b.subject_public_id
JOIN users u ON u.id = b.subject_id
WHERE b.project_id = (SELECT id FROM projects WHERE public_id = ?)
AND b.subject_type = 'user'
ORDER BY b.role, u.username`, projectID)
@@ -1685,7 +1685,7 @@ func (s *Store) GetProjectMemberRole(projectID, userID string) (string, error) {
row = s.QueryRow(`SELECT role FROM project_role_bindings
WHERE project_id = (SELECT id FROM projects WHERE public_id = ?)
AND subject_type = 'user'
AND subject_public_id = ?`, projectID, userID)
AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, projectID, userID)
err = row.Scan(&role)
return role, err
}
@@ -1929,11 +1929,11 @@ func (s *Store) ListProjectIDsForUser(userID string) ([]string, error) {
rows, err = s.Query(`SELECT DISTINCT p.public_id
FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id
WHERE (b.subject_type = 'user' AND b.subject_public_id = ?)
WHERE (b.subject_type = 'user' AND b.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR (
b.subject_type = 'group'
AND b.subject_public_id IN (
SELECT g.public_id
AND b.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
+47 -27
View File
@@ -18,17 +18,22 @@ func (s *Store) ListSubjectPermissions(permission string, subjectType string, su
rows, err = s.Query(`SELECT
sp.permission,
sp.subject_type,
sp.subject_public_id,
CASE
WHEN sp.subject_type = 'user' THEN COALESCE(u.public_id, '')
WHEN sp.subject_type = 'group' THEN COALESCE(g.public_id, '')
WHEN sp.subject_type = 'principal' THEN COALESCE(p.public_id, '')
ELSE ''
END AS subject_public_id,
CASE
WHEN sp.subject_type = 'user' THEN
CASE
WHEN u.public_id IS NULL THEN sp.subject_public_id
WHEN u.public_id IS NULL THEN ''
WHEN u.display_name <> '' THEN u.display_name || ' (' || u.username || ')'
ELSE u.username
END
WHEN sp.subject_type = 'group' THEN COALESCE(g.name, sp.subject_public_id)
WHEN sp.subject_type = 'principal' THEN COALESCE(p.name, sp.subject_public_id)
ELSE sp.subject_public_id
WHEN sp.subject_type = 'group' THEN COALESCE(g.name, '')
WHEN sp.subject_type = 'principal' THEN COALESCE(p.name, '')
ELSE ''
END AS subject_name,
CASE
WHEN sp.subject_type = 'user' THEN CASE WHEN u.public_id IS NOT NULL AND u.disabled = 0 THEN 1 ELSE 0 END
@@ -41,23 +46,28 @@ func (s *Store) ListSubjectPermissions(permission string, subjectType string, su
FROM subject_permissions sp
LEFT JOIN users u
ON sp.subject_type = 'user'
AND u.public_id = sp.subject_public_id
AND u.id = sp.subject_id
LEFT JOIN user_groups g
ON sp.subject_type = 'group'
AND g.public_id = sp.subject_public_id
AND g.id = sp.subject_id
LEFT JOIN service_principals p
ON sp.subject_type = 'principal'
AND p.public_id = sp.subject_public_id
AND p.id = sp.subject_id
WHERE (? = '' OR sp.permission = ?)
AND (? = '' OR sp.subject_type = ?)
AND (? = '' OR sp.subject_public_id = ?)
ORDER BY sp.permission, sp.subject_type, subject_name, sp.subject_public_id`,
AND (? = ''
OR (sp.subject_type = 'user' AND u.public_id = ?)
OR (sp.subject_type = 'group' AND g.public_id = ?)
OR (sp.subject_type = 'principal' AND p.public_id = ?))
ORDER BY sp.permission, sp.subject_type, subject_name, subject_public_id`,
permission,
permission,
subjectType,
subjectType,
subjectID,
subjectID,
subjectID,
subjectID,
)
if err != nil {
return nil, err
@@ -112,11 +122,11 @@ func (s *Store) UpsertSubjectPermission(item models.SubjectPermission) (models.S
item.UpdatedAt = now
switch item.SubjectType {
case "user":
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_public_id, created_at, updated_at)
SELECT ?, 'user', u.public_id, ?, ?
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_id, created_at, updated_at)
SELECT ?, 'user', u.id, ?, ?
FROM users u
WHERE u.public_id = ?
ON CONFLICT(permission, subject_type, subject_public_id)
ON CONFLICT(permission, subject_type, subject_id)
DO UPDATE SET updated_at = excluded.updated_at`,
item.Permission,
item.CreatedAt,
@@ -124,11 +134,11 @@ func (s *Store) UpsertSubjectPermission(item models.SubjectPermission) (models.S
item.SubjectID,
)
case "group":
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_public_id, created_at, updated_at)
SELECT ?, 'group', g.public_id, ?, ?
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_id, created_at, updated_at)
SELECT ?, 'group', g.id, ?, ?
FROM user_groups g
WHERE g.public_id = ?
ON CONFLICT(permission, subject_type, subject_public_id)
ON CONFLICT(permission, subject_type, subject_id)
DO UPDATE SET updated_at = excluded.updated_at`,
item.Permission,
item.CreatedAt,
@@ -136,11 +146,11 @@ func (s *Store) UpsertSubjectPermission(item models.SubjectPermission) (models.S
item.SubjectID,
)
case "principal":
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_public_id, created_at, updated_at)
SELECT ?, 'principal', p.public_id, ?, ?
result, err = s.Exec(`INSERT INTO subject_permissions (permission, subject_type, subject_id, created_at, updated_at)
SELECT ?, 'principal', p.id, ?, ?
FROM service_principals p
WHERE p.public_id = ?
ON CONFLICT(permission, subject_type, subject_public_id)
ON CONFLICT(permission, subject_type, subject_id)
DO UPDATE SET updated_at = excluded.updated_at`,
item.Permission,
item.CreatedAt,
@@ -168,9 +178,19 @@ func (s *Store) DeleteSubjectPermission(permission string, subjectType string, s
_, err = s.Exec(`DELETE FROM subject_permissions
WHERE permission = ?
AND subject_type = ?
AND subject_public_id = ?`,
AND subject_id = CASE
WHEN ? = 'user' THEN (SELECT id FROM users WHERE public_id = ?)
WHEN ? = 'group' THEN (SELECT id FROM user_groups WHERE public_id = ?)
WHEN ? = 'principal' THEN (SELECT id FROM service_principals WHERE public_id = ?)
ELSE -1
END`,
strings.TrimSpace(permission),
strings.ToLower(strings.TrimSpace(subjectType)),
strings.ToLower(strings.TrimSpace(subjectType)),
strings.TrimSpace(subjectID),
strings.ToLower(strings.TrimSpace(subjectType)),
strings.TrimSpace(subjectID),
strings.ToLower(strings.TrimSpace(subjectType)),
strings.TrimSpace(subjectID),
)
return err
@@ -184,11 +204,11 @@ func (s *Store) ListUserPermissions(userID string) ([]string, error) {
userID = strings.TrimSpace(userID)
rows, err = s.Query(`SELECT DISTINCT sp.permission
FROM subject_permissions sp
WHERE (sp.subject_type = 'user' AND sp.subject_public_id = ?)
WHERE (sp.subject_type = 'user' AND sp.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR (
sp.subject_type = 'group'
AND sp.subject_public_id IN (
SELECT g.public_id
AND sp.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -234,12 +254,12 @@ func (s *Store) UserHasPermission(userID string, permission string) (bool, error
FROM subject_permissions sp
WHERE sp.permission = ?
AND (
(sp.subject_type = 'user' AND sp.subject_public_id = ?)
(sp.subject_type = 'user' AND sp.subject_id = (SELECT id FROM users WHERE public_id = ?))
OR
(
sp.subject_type = 'group'
AND sp.subject_public_id IN (
SELECT g.public_id
AND sp.subject_id IN (
SELECT g.id
FROM user_groups g
WHERE g.disabled = 0
AND (
@@ -279,7 +299,7 @@ func (s *Store) PrincipalHasPermission(principalID string, permission string) (b
FROM subject_permissions
WHERE permission = ?
AND subject_type = 'principal'
AND subject_public_id = ?
AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)
LIMIT 1`,
permission,
principalID,
File diff suppressed because it is too large Load Diff
@@ -133,7 +133,7 @@ func (api *API) ListPKIClientProfiles(w http.ResponseWriter, r *http.Request, _
targetID = strings.TrimSpace(r.URL.Query().Get("target_id"))
items, err = api.store(r).ListPKIClientProfiles(targetType, targetID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -158,38 +158,38 @@ func (api *API) CreatePKIClientProfile(w http.ResponseWriter, r *http.Request, _
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
ca, err = api.store(r).GetPKICA(strings.TrimSpace(req.CAID))
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "CA not found")
return
}
if ca.Status != "active" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected CA is not active"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "selected CA is not active")
return
}
permissions, permissionsValue, err = normalizePKIClientProfilePermissions(req.AuthzPermissions)
_ = permissions
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
targets, err = normalizePKIClientProfileTargets(req.Targets)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
err = validatePKIClientProfileWindow(req.DefaultValidSeconds, req.MaxValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverId())
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
currentUser, hasCurrentUser = middleware.UserFromContext(r.Context())
@@ -207,7 +207,7 @@ func (api *API) CreatePKIClientProfile(w http.ResponseWriter, r *http.Request, _
Targets: targets,
}
if strings.TrimSpace(item.Name) == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
if hasCurrentUser {
@@ -216,7 +216,7 @@ func (api *API) CreatePKIClientProfile(w http.ResponseWriter, r *http.Request, _
item, err = api.store(r).CreatePKIClientProfile(item)
if err != nil {
api.Logger.Write(logIDPKI, codit_logger.LOG_WARN, "client profile create failed name=%s err=%v", item.Name, err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
api.Logger.Write(logIDPKI, codit_logger.LOG_INFO, "client profile create success id=%s name=%s targets=%d", item.ID, item.Name, len(item.Targets))
@@ -237,47 +237,47 @@ func (api *API) UpdatePKIClientProfile(w http.ResponseWriter, r *http.Request, p
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
existing, err = api.store(r).GetPKIClientProfile(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "profile not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
ca, err = api.store(r).GetPKICA(strings.TrimSpace(req.CAID))
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "CA not found")
return
}
if ca.Status != "active" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected CA is not active"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "selected CA is not active")
return
}
permissions, permissionsValue, err = normalizePKIClientProfilePermissions(req.AuthzPermissions)
_ = permissions
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
targets, err = normalizePKIClientProfileTargets(req.Targets)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
err = validatePKIClientProfileWindow(req.DefaultValidSeconds, req.MaxValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverId())
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
existing.Name = strings.TrimSpace(req.Name)
@@ -293,13 +293,13 @@ func (api *API) UpdatePKIClientProfile(w http.ResponseWriter, r *http.Request, p
existing.Enabled = req.Enabled
existing.Targets = targets
if strings.TrimSpace(existing.Name) == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
existing, err = api.store(r).UpdatePKIClientProfile(existing)
if err != nil {
api.Logger.Write(logIDPKI, codit_logger.LOG_WARN, "client profile update failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
api.Logger.Write(logIDPKI, codit_logger.LOG_INFO, "client profile update success id=%s name=%s", existing.ID, existing.Name)
@@ -315,7 +315,7 @@ func (api *API) DeletePKIClientProfile(w http.ResponseWriter, r *http.Request, p
err = api.store(r).DeletePKIClientProfile(params["id"])
if err != nil {
api.Logger.Write(logIDPKI, codit_logger.LOG_WARN, "client profile delete failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
api.Logger.Write(logIDPKI, codit_logger.LOG_INFO, "client profile delete success id=%s", params["id"])
@@ -337,7 +337,7 @@ func (api *API) ListPKIClientProfilesForSelf(w http.ResponseWriter, r *http.Requ
}
items, err = api.store(r).ListActivePKIClientProfilesForUser(user.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -361,7 +361,7 @@ func (api *API) ListPKIClientIssuancesForSelf(w http.ResponseWriter, r *http.Req
}
items, err = api.store(r).ListPKIClientIssuancesForUser(user.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -404,27 +404,27 @@ func (api *API) IssuePKIClientCertForSelf(w http.ResponseWriter, r *http.Request
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
profile, err = api.store(r).GetActivePKIClientProfileForUser(strings.TrimSpace(req.ProfileID), user.ID)
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found or not allowed"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "profile not found or not allowed")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
ca, err = api.store(r).GetPKICA(profile.CAID)
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "issuer CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "issuer CA not found")
return
}
permissions = splitPKIClientPermissionList(profile.AuthzPermissions)
validSeconds, err = normalizePKIClientRequestedValidSeconds(req.ValidSeconds, profile.DefaultValidSeconds, profile.MaxValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
commonName = strings.TrimSpace(req.CommonName)
@@ -432,12 +432,12 @@ func (api *API) IssuePKIClientCertForSelf(w http.ResponseWriter, r *http.Request
sanIPs = trimPKIClientStringList(req.SANIPs)
if profile.AllowServerAuth {
if commonName == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "common_name is required for profiles that allow server authentication"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "common_name is required for profiles that allow server authentication")
return
}
} else {
if commonName != "" || len(sanDNS) > 0 || len(sanIPs) > 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "common_name, san_dns, and san_ips are only allowed when the profile allows server authentication"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "common_name, san_dns, and san_ips are only allowed when the profile allows server authentication")
return
}
commonName = buildPKIClientCommonName(user)
@@ -445,19 +445,19 @@ func (api *API) IssuePKIClientCertForSelf(w http.ResponseWriter, r *http.Request
sanURI = profile.SANURIPrefix + user.ID
_, err = url.ParseRequestURI(sanURI)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "generated SAN URI is invalid"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "generated SAN URI is invalid")
return
}
serial, err = api.store(r).NextPKICASerial(ca.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
serialHex = strconv.FormatInt(serial, 16)
certPEM, keyPEM, notBefore, notAfter, err = issuePKIClientCertFromProfile(ca, serial, user, profile, permissions, commonName, sanDNS, sanIPs, sanURI, validSeconds)
if err != nil {
api.Logger.Write(logIDPKI, codit_logger.LOG_WARN, "client cert issue failed user=%s profile=%s err=%v", user.Username, profile.ID, err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
createdByKind, createdBySubjectID, createdBySubjectName, issuanceSource = pkiCertProvenanceFromRequest(r, "client_profile")
@@ -482,7 +482,7 @@ func (api *API) IssuePKIClientCertForSelf(w http.ResponseWriter, r *http.Request
}
cert, err = api.store(r).CreatePKICert(cert)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
issuance = models.PKIClientIssuance{
@@ -504,12 +504,12 @@ func (api *API) IssuePKIClientCertForSelf(w http.ResponseWriter, r *http.Request
}
issuance, err = api.store(r).CreatePKIClientIssuance(issuance)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
caPEMs, err = api.pkiCertChainPEMs(r.Context(), cert.CAID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDPKI, codit_logger.LOG_INFO, "client cert issue success user=%s profile=%s cert=%s perms=%s scope=%s", user.Username, profile.ID, cert.ID, profile.AuthzPermissions, profile.AuthzScope)
@@ -537,26 +537,26 @@ func (api *API) DownloadPKIClientCertBundleForSelf(w http.ResponseWriter, r *htt
}
owns, err = api.store(r).UserOwnsPKIClientCert(user.ID, params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !owns {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
cert, err = api.store(r).GetPKICert(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
caPEMs, err = api.pkiCertChainPEMs(r.Context(), cert.CAID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
data, err = buildPKIZipBundle(cert.CommonName, cert.CertPEM, cert.KeyPEM, false, caPEMs)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/zip")
@@ -579,21 +579,21 @@ func (api *API) GetPKIClientCertForSelf(w http.ResponseWriter, r *http.Request,
}
owns, err = api.store(r).UserOwnsPKIClientCert(user.ID, params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !owns {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
cert, err = api.store(r).GetPKICert(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
caPEMs, err = api.pkiCertChainPEMs(r.Context(), cert.CAID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
cert.CACertPEM = joinPKIPEMs(caPEMs)
@@ -616,21 +616,21 @@ func (api *API) GetPKIClientCertInspectForSelf(w http.ResponseWriter, r *http.Re
}
owns, err = api.store(r).UserOwnsPKIClientCert(user.ID, params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !owns {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
cert, err = api.store(r).GetPKICert(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
parsed, err = parseCertificateFromPEM(cert.CertPEM)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid certificate"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid certificate")
return
}
dump = buildX509CertificateDump(parsed)
@@ -651,16 +651,16 @@ func (api *API) RevokePKIClientCertForSelf(w http.ResponseWriter, r *http.Reques
}
owns, err = api.store(r).UserOwnsPKIClientCert(user.ID, params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !owns {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
cert, err = api.store(r).GetPKICert(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return
}
if cert.Status == "revoked" {
@@ -669,7 +669,7 @@ func (api *API) RevokePKIClientCertForSelf(w http.ResponseWriter, r *http.Reques
}
err = api.store(r).RevokePKICert(cert.ID, "revoked by owner")
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDPKI, codit_logger.LOG_INFO, "client cert revoke success user=%s cert=%s", user.Username, cert.ID)
+9
View File
@@ -3,12 +3,21 @@ package handlers
import "encoding/json"
import "net/http"
import "codit/internal/middleware"
func WriteJSON(w http.ResponseWriter, status int, value interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func WriteJSONWithErrorReason(w http.ResponseWriter, r* http.Request, status int, reason string) {
middleware.RememberHttpStatusReason(r.Context(), reason)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": reason})
}
func DecodeJSON(r *http.Request, target interface{}) error {
return json.NewDecoder(r.Body).Decode(target)
}
+240 -5
View File
@@ -65,6 +65,7 @@ type sshSessionFileDownloadItem struct {
RemotePath string
Name string
Size int64
Audit *sshFileTransferAudit
}
type sshSessionFileCopyItem struct {
@@ -80,6 +81,15 @@ type sshSessionFileCopyResponse struct {
Failed int `json:"failed"`
}
type sshFileTransferAudit struct {
Store *db.Store
Item models.SSHFileTransfer
Paths []string
Bytes int64
ErrorText string
Finished bool
}
type sshFileTransferContextReader struct {
Context context.Context
Reader io.Reader
@@ -104,6 +114,92 @@ func (w *sshFileTransferContextWriter) Write(p []byte) (int, error) {
return w.Writer.Write(p)
}
func (api *API) startSSHFileTransferAudit(store *db.Store, r *http.Request, operation string, sessionItem models.SSHSession, targetSession models.SSHSession, targetDir string) *sshFileTransferAudit {
var audit *sshFileTransferAudit
var item models.SSHFileTransfer
var err error
item = models.SSHFileTransfer{
SessionID: sessionItem.ID,
UserID: sessionItem.UserID,
Username: sessionItem.Username,
ProfileID: sessionItem.ProfileID,
ServerID: sessionItem.ServerID,
ServerName: sessionItem.ServerName,
RemoteUsername: sessionItem.RemoteUsername,
Operation: operation,
TargetDir: strings.TrimSpace(targetDir),
Status: "running",
RemoteAddr: requestRemoteAddr(r),
UserAgent: strings.TrimSpace(r.UserAgent()),
}
if operation == "copy_to_session" {
item.SourceSessionID = sessionItem.ID
item.TargetSessionID = targetSession.ID
item.TargetServerID = targetSession.ServerID
item.TargetServerName = targetSession.ServerName
}
item, err = store.CreateSSHFileTransfer(item)
if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file transfer audit create failed op=%s session=%s err=%s", operation, sessionItem.ID, err.Error())
return nil
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
"ssh file transfer start id=%s op=%s session=%s user=%s server=%s target_session=%s target_server=%s target_dir=%q",
item.ID,
operation,
sessionItem.ID,
sessionItem.Username,
sessionItem.ServerName,
targetSession.ID,
targetSession.ServerName,
targetDir)
audit = &sshFileTransferAudit{Store: store, Item: item}
return audit
}
func (api *API) finishSSHFileTransferAudit(audit *sshFileTransferAudit, status string) {
var err error
if audit == nil || audit.Finished {
return
}
audit.Finished = true
err = audit.Store.FinishSSHFileTransfer(audit.Item.ID, status, audit.Paths, audit.Bytes, audit.ErrorText)
if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file transfer audit finish failed id=%s op=%s err=%s", audit.Item.ID, audit.Item.Operation, err.Error())
return
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
"ssh file transfer %s id=%s op=%s session=%s user=%s server=%s target_session=%s target_server=%s bytes=%d error=%q",
status,
audit.Item.ID,
audit.Item.Operation,
audit.Item.SessionID,
audit.Item.Username,
audit.Item.ServerName,
audit.Item.TargetSessionID,
audit.Item.TargetServerName,
audit.Bytes,
audit.ErrorText)
}
func (api *API) requireSSHFileTransferPermission(w http.ResponseWriter, store *db.Store, user models.User, permission string) bool {
var allowed bool
var err error
allowed, err = store.UserHasPermission(user.ID, permission)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return false
}
if allowed {
return true
}
WriteJSON(w, http.StatusForbidden, map[string]string{"error": "ssh file transfer permission denied"})
return false
}
func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
@@ -124,6 +220,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
var password string
var otpCode string
var seenUploadPaths map[string]bool
var audit *sshFileTransferAudit
var uploadCount int
var opened bool
var copied int64
@@ -134,6 +231,10 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
w.WriteHeader(http.StatusUnauthorized)
return
}
store = api.store(r)
if !api.requireSSHFileTransferPermission(w, store, user, permissionSSHFileUpload) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, sshFileTransferMaxUploadBytes)
multipartReader, err = r.MultipartReader()
if err != nil {
@@ -197,15 +298,23 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
return
}
if !opened {
store = api.store(r)
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
if err != nil {
_ = part.Close()
api.writeSSHSessionFileTransferError(w, err)
return
}
audit = api.startSSHFileTransferAudit(store, r, "upload", sessionItem, models.SSHSession{}, targetDir)
if audit != nil {
audit.Paths = append(audit.Paths, remotePath)
}
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, password, otpCode)
if err != nil {
if audit != nil {
audit.ErrorText = err.Error()
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
_ = part.Close()
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -215,6 +324,11 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
defer sshClient.Close()
sftpClient, err = sftp.NewClient(sshClient)
if err != nil {
if audit != nil {
audit.ErrorText = "sftp subsystem unavailable: " + err.Error()
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
_ = part.Close()
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sftp subsystem unavailable: " + err.Error()})
return
@@ -223,16 +337,41 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
opened = true
}
item.Path = remotePath
if audit == nil {
audit = api.startSSHFileTransferAudit(store, r, "upload", sessionItem, models.SSHSession{}, targetDir)
}
if audit != nil {
if len(audit.Paths) == 0 {
audit.Paths = append(audit.Paths, remotePath)
}
}
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
_ = part.Close()
item.Size = copied
if err != nil {
if r.Context().Err() != nil {
if audit != nil {
audit.ErrorText = r.Context().Err().Error()
api.finishSSHFileTransferAudit(audit, "cancelled")
audit = nil
}
return
}
item.Error = err.Error()
if audit != nil && audit.ErrorText == "" {
audit.ErrorText = err.Error()
}
response.Failed += 1
if audit != nil {
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
} else {
if audit != nil {
audit.Bytes += copied
api.finishSSHFileTransferAudit(audit, "success")
audit = nil
}
response.Uploaded += 1
}
response.Items = append(response.Items, item)
@@ -257,6 +396,7 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
var item sshSessionFileDownloadItem
var remotePath string
var seenPaths map[string]bool
var audit *sshFileTransferAudit
var pathCount int
var totalSize int64
var contentDisposition string
@@ -269,6 +409,10 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
w.WriteHeader(http.StatusUnauthorized)
return
}
store = api.store(r)
if !api.requireSSHFileTransferPermission(w, store, user, permissionSSHFileDownload) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -279,7 +423,6 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
return
}
seenPaths = make(map[string]bool)
store = api.store(r)
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
if err != nil {
api.writeSSHSessionFileTransferError(w, err)
@@ -319,11 +462,27 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
}
item, err = api.sftpDownloadMetadata(sftpClient, remotePath)
if err != nil {
audit = api.startSSHFileTransferAudit(store, r, "download", sessionItem, models.SSHSession{}, "")
if audit != nil {
audit.Paths = append(audit.Paths, remotePath)
}
if audit != nil && audit.ErrorText == "" {
audit.ErrorText = fmt.Sprintf("%s: %s", remotePath, err.Error())
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("%s: %s", remotePath, err.Error())})
return
}
totalSize += item.Size
if totalSize > sshFileTransferMaxDownloadBytes {
audit = api.startSSHFileTransferAudit(store, r, "download", sessionItem, models.SSHSession{}, "")
if audit != nil {
audit.Paths = append(audit.Paths, item.RemotePath)
audit.ErrorText = "download size exceeds limit"
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "download size exceeds limit"})
return
}
@@ -334,9 +493,29 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", contentDisposition)
w.Header().Set("Content-Length", fmt.Sprintf("%d", items[0].Size))
items[0].Audit = api.startSSHFileTransferAudit(store, r, "download", sessionItem, models.SSHSession{}, "")
if items[0].Audit != nil {
items[0].Audit.Paths = append(items[0].Audit.Paths, items[0].RemotePath)
}
err = api.sftpDownloadFile(r.Context(), sftpClient, items[0].RemotePath, w)
if err != nil {
if items[0].Audit != nil {
items[0].Audit.ErrorText = err.Error()
if r.Context().Err() != nil {
items[0].Audit.ErrorText = r.Context().Err().Error()
api.finishSSHFileTransferAudit(items[0].Audit, "cancelled")
} else {
api.finishSSHFileTransferAudit(items[0].Audit, "failed")
}
items[0].Audit = nil
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file download failed session=%s path=%q err=%s", sessionItem.ID, items[0].RemotePath, err.Error())
} else {
if items[0].Audit != nil {
items[0].Audit.Bytes = items[0].Size
api.finishSSHFileTransferAudit(items[0].Audit, "success")
items[0].Audit = nil
}
}
return
}
@@ -344,7 +523,7 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": archiveName})
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", contentDisposition)
err = api.writeSSHSessionFileZip(r.Context(), sftpClient, items, w)
err = api.writeSSHSessionFileZip(r.Context(), store, r, sessionItem, sftpClient, items, w)
if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file zip download failed session=%s err=%s", sessionItem.ID, err.Error())
}
@@ -369,6 +548,7 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
var remotePath string
var targetDir string
var seenPaths map[string]bool
var audit *sshFileTransferAudit
var pathCount int
var totalSize int64
var startedAt time.Time
@@ -381,6 +561,10 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusUnauthorized)
return
}
store = api.store(r)
if !api.requireSSHFileTransferPermission(w, store, user, permissionSSHFileCopyToSession) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -407,7 +591,6 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
return
}
seenPaths = make(map[string]bool)
store = api.store(r)
sourceSession, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
if err != nil {
api.writeSSHSessionFileTransferError(w, err)
@@ -472,6 +655,13 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
metadata, err = api.sftpDownloadMetadata(sourceSFTPClient, remotePath)
if err != nil {
item.Error = err.Error()
audit = api.startSSHFileTransferAudit(store, r, "copy_to_session", sourceSession, targetSession, targetDir)
if audit != nil {
audit.Paths = append(audit.Paths, remotePath)
audit.ErrorText = fmt.Sprintf("%s: %s", remotePath, err.Error())
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
response.Failed += 1
response.Items = append(response.Items, item)
continue
@@ -479,6 +669,13 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
totalSize += metadata.Size
if totalSize > sshFileTransferMaxDownloadBytes {
item.Error = "copy size exceeds limit"
audit = api.startSSHFileTransferAudit(store, r, "copy_to_session", sourceSession, targetSession, targetDir)
if audit != nil {
audit.Paths = append(audit.Paths, metadata.RemotePath)
audit.ErrorText = item.Error
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
response.Failed += 1
response.Items = append(response.Items, item)
break
@@ -494,10 +691,19 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
metadata.RemotePath,
item.TargetPath,
metadata.Size)
audit = api.startSSHFileTransferAudit(store, r, "copy_to_session", sourceSession, targetSession, targetDir)
if audit != nil {
audit.Paths = append(audit.Paths, metadata.RemotePath)
}
item.Size, err = api.sftpCopyFile(r.Context(), sourceSFTPClient, targetSFTPClient, metadata.RemotePath, targetDir, metadata.Name, req.Overwrite)
duration = time.Since(startedAt)
if err != nil {
if r.Context().Err() != nil {
if audit != nil {
audit.ErrorText = r.Context().Err().Error()
api.finishSSHFileTransferAudit(audit, "cancelled")
audit = nil
}
return
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
@@ -512,8 +718,18 @@ func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Reques
duration.Milliseconds(),
err.Error())
item.Error = err.Error()
if audit != nil {
audit.ErrorText = err.Error()
api.finishSSHFileTransferAudit(audit, "failed")
audit = nil
}
response.Failed += 1
} else {
if audit != nil {
audit.Bytes += item.Size
api.finishSSHFileTransferAudit(audit, "success")
audit = nil
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
"ssh file copy done source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d dur_ms=%d",
sourceSession.ID,
@@ -772,7 +988,7 @@ func (api *API) sftpCopyFile(ctx context.Context, sourceClient *sftp.Client, tar
return copied, err
}
func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
func (api *API) writeSSHSessionFileZip(ctx context.Context, store *db.Store, r *http.Request, sessionItem models.SSHSession, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
var zipWriter *zip.Writer
var zipFile io.Writer
var closeErr error
@@ -785,10 +1001,29 @@ func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client,
err = ctx.Err()
break
}
items[i].Audit = api.startSSHFileTransferAudit(store, r, "download", sessionItem, models.SSHSession{}, "")
if items[i].Audit != nil {
items[i].Audit.Paths = append(items[i].Audit.Paths, items[i].RemotePath)
}
zipFile, err = zipWriter.Create(sshDownloadEntryName(items[i], i))
if err != nil { break }
err = api.sftpDownloadFile(ctx, client, items[i].RemotePath, zipFile)
if err != nil { break }
if items[i].Audit != nil {
items[i].Audit.Bytes = items[i].Size
api.finishSSHFileTransferAudit(items[i].Audit, "success")
items[i].Audit = nil
}
}
if err != nil && i < len(items) && items[i].Audit != nil {
items[i].Audit.ErrorText = err.Error()
if ctx.Err() != nil {
items[i].Audit.ErrorText = ctx.Err().Error()
api.finishSSHFileTransferAudit(items[i].Audit, "cancelled")
} else {
api.finishSSHFileTransferAudit(items[i].Audit, "failed")
}
items[i].Audit = nil
}
closeErr = zipWriter.Close()
if err == nil && closeErr != nil {
+65
View File
@@ -118,6 +118,13 @@ type sshSessionListResponse struct {
HasMore bool `json:"has_more"`
}
type sshFileTransferListResponse struct {
Items []models.SSHFileTransfer `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}
type sshSessionStreamMessage struct {
SessionID string `json:"session_id"`
Type string `json:"type"`
@@ -1849,6 +1856,64 @@ func (api *API) ListSSHSessionsAdmin(w http.ResponseWriter, r *http.Request, _ m
WriteJSON(w, http.StatusOK, sshSessionListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
}
func (api *API) ListSSHFileTransfersForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var user models.User
var ok bool
var limit int
var offset int
var query string
var status string
var sessionID string
var items []models.SSHFileTransfer
var hasMore bool
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
limit = parseSSHSessionListLimit(r.URL.Query().Get("limit"))
offset = parseSSHSessionListOffset(r.URL.Query().Get("offset"))
query = strings.TrimSpace(r.URL.Query().Get("q"))
status = strings.TrimSpace(r.URL.Query().Get("status"))
sessionID = strings.TrimSpace(r.URL.Query().Get("session_id"))
items, hasMore, err = api.store(r).ListSSHFileTransfersForUserFiltered(user.ID, limit, offset, query, status, sessionID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
}
func (api *API) ListSSHFileTransfersAdmin(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var limit int
var offset int
var query string
var status string
var sessionID string
var userID string
var items []models.SSHFileTransfer
var hasMore bool
var err error
if !api.requireAdmin(w, r) {
return
}
limit = parseSSHSessionListLimit(r.URL.Query().Get("limit"))
offset = parseSSHSessionListOffset(r.URL.Query().Get("offset"))
query = strings.TrimSpace(r.URL.Query().Get("q"))
status = strings.TrimSpace(r.URL.Query().Get("status"))
sessionID = strings.TrimSpace(r.URL.Query().Get("session_id"))
userID = strings.TrimSpace(r.URL.Query().Get("user_id"))
items, hasMore, err = api.store(r).ListSSHFileTransfersFiltered(limit, offset, query, status, sessionID, userID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
}
func (api *API) DisconnectSSHSessionForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
+58 -59
View File
@@ -117,7 +117,7 @@ func (api *API) ListSSHUserCAs(w http.ResponseWriter, r *http.Request, _ map[str
}
items, err = api.store(r).ListSSHUserCAs()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -135,10 +135,10 @@ func (api *API) GetSSHUserCA(w http.ResponseWriter, r *http.Request, params map[
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, buildSSHUserCASummary(item))
@@ -160,34 +160,34 @@ func (api *API) CreateSSHUserCA(w http.ResponseWriter, r *http.Request, _ map[st
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
algorithm, err = normalizeSSHCAAlgorithm(req.Algorithm)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
maxUserValidSeconds, err = normalizeSSHUserCAMaxValidSeconds(req.MaxUserValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.PrivateKeyPEM) == "" {
privateKeyPEM, publicKey, fingerprint, err = generateSSHUserCAKeyPair(algorithm)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
} else {
signer, err = parseSSHSignerFromPEM(strings.TrimSpace(req.PrivateKeyPEM))
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
privateKeyPEM = strings.TrimSpace(req.PrivateKeyPEM)
@@ -208,7 +208,7 @@ func (api *API) CreateSSHUserCA(w http.ResponseWriter, r *http.Request, _ map[st
item, err = api.store(r).CreateSSHUserCA(item)
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "create failed name=%s err=%v", req.Name, err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
summary = buildSSHUserCASummary(item)
@@ -226,26 +226,26 @@ func (api *API) UpdateSSHUserCA(w http.ResponseWriter, r *http.Request, params m
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
maxUserValidSeconds, err = normalizeSSHUserCAMaxValidSeconds(req.MaxUserValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
item.Name = req.Name
@@ -255,7 +255,7 @@ func (api *API) UpdateSSHUserCA(w http.ResponseWriter, r *http.Request, params m
item, err = api.store(r).UpdateSSHUserCA(item)
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "update failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
api.Logger.Write(logIDSSHCA, codit_logger.LOG_INFO, "update success id=%s name=%s enabled=%t", item.ID, item.Name, item.Enabled)
@@ -270,7 +270,7 @@ func (api *API) DeleteSSHUserCA(w http.ResponseWriter, r *http.Request, params m
err = api.store(r).DeleteSSHUserCA(params["id"])
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "delete failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDSSHCA, codit_logger.LOG_INFO, "delete success id=%s", params["id"])
@@ -285,7 +285,7 @@ func (api *API) DownloadSSHUserCAPublicKey(w http.ResponseWriter, r *http.Reques
}
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -305,7 +305,7 @@ func (api *API) DownloadSSHUserCAPublicKeyForSelf(w http.ResponseWriter, r *http
}
item, err = api.store(r).GetSSHUserCAForUser(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -321,7 +321,7 @@ func (api *API) DownloadSSHUserCAPrivateKey(w http.ResponseWriter, r *http.Reque
}
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -350,43 +350,43 @@ func (api *API) SignSSHUserKey(w http.ResponseWriter, r *http.Request, params ma
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !item.Enabled {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "SSH user CA is disabled"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "SSH user CA is disabled")
return
}
principals = normalizeSSHPrincipals(req.Principals)
if len(principals) == 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one principal is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "at least one principal is required")
return
}
sourcePublicKey = strings.TrimSpace(req.PublicKey)
sourcePublicKeyFingerprint, err = sshAuthorizedKeyFingerprint(sourcePublicKey)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
validSeconds = req.ValidSeconds
response, err = api.signSSHUserKeyWithCA(api.store(r), item, sourcePublicKey, strings.TrimSpace(req.KeyID), principals, validSeconds, 604800)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
err = api.recordSSHUserCAIssuance(r, item, user, "admin", sourcePublicKey, sourcePublicKeyFingerprint, nil, nil, response)
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "audit write failed ca_id=%s serial=%d err=%v", item.ID, response.Serial, err)
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record issuance audit"})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to record issuance audit")
return
}
api.Logger.Write(logIDSSHCA, codit_logger.LOG_INFO, "sign success ca_id=%s serial=%d key_id=%s principals=%s", item.ID, response.Serial, response.KeyID, strings.Join(principals, ","))
@@ -407,7 +407,7 @@ func (api *API) ListSSHUserCAsForSelf(w http.ResponseWriter, r *http.Request, _
}
items, err = api.store(r).ListSSHUserCAsForUser()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -429,10 +429,10 @@ func (api *API) GetSSHUserCAForSelf(w http.ResponseWriter, r *http.Request, para
item, err = api.store(r).GetSSHUserCAForUser(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, buildSSHUserCASummary(item))
@@ -445,22 +445,21 @@ func (api *API) ListSSHUserCAIssuances(w http.ResponseWriter, r *http.Request, p
var out []sshUserCAIssuanceSummary
var i int
var err error
if !api.requireAdmin(w, r) {
return
}
if !api.requireAdmin(w, r) { return }
ca, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit"))
items, err = api.store(r).ListSSHUserCAIssuancesByCA(ca.ID, limit)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
for i = 0; i < len(items); i++ {
@@ -480,19 +479,18 @@ func (api *API) ListSSHUserCAIssuancesAll(w http.ResponseWriter, r *http.Request
var ok bool
var i int
var err error
if !api.requireAdmin(w, r) {
return
}
if !api.requireAdmin(w, r) { return }
limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit"))
caID = strings.TrimSpace(r.URL.Query().Get("ca_id"))
items, err = api.store(r).ListSSHUserCAIssuances(limit, caID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
cas, err = api.store(r).ListSSHUserCAs()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
caByID = map[string]string{}
@@ -520,6 +518,7 @@ func (api *API) ListSSHUserCAIssuancesForSelf(w http.ResponseWriter, r *http.Req
var caName string
var i int
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
@@ -528,7 +527,7 @@ func (api *API) ListSSHUserCAIssuancesForSelf(w http.ResponseWriter, r *http.Req
limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit"))
items, err = api.store(r).ListSSHUserCAIssuancesForSelf(user.ID, limit)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
caByID = map[string]string{}
@@ -563,17 +562,17 @@ func (api *API) InspectSSHCertificate(w http.ResponseWriter, r *http.Request, _
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
key, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(req.Certificate)))
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid SSH certificate"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid SSH certificate")
return
}
cert, ok = key.(*ssh.Certificate)
if !ok {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "not an SSH certificate"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "not an SSH certificate")
return
}
dump = buildSSHCertificateDump(cert)
@@ -614,36 +613,36 @@ func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, pa
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
item, err = api.store(r).GetSSHUserCA(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"})
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "SSH user CA not found")
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !item.Enabled {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "SSH user CA is disabled"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "SSH user CA is disabled")
return
}
if !item.AllowUserSign {
WriteJSON(w, http.StatusForbidden, map[string]string{"error": "self-sign is not allowed for this CA"})
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "self-sign is not allowed for this CA")
return
}
now = time.Now().UTC().Unix()
grants, err = api.store(r).ListActiveSSHPrincipalGrantsForUser(user.ID, now)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
sourcePublicKey = strings.TrimSpace(req.PublicKey)
sourcePublicKeyFingerprint, err = sshAuthorizedKeyFingerprint(sourcePublicKey)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
requestedPrincipals = normalizeSSHPrincipals(req.Principals)
@@ -669,7 +668,7 @@ func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, pa
for i = 0; i < len(requestedPrincipals); i++ {
grantID, ok = grantsByPrincipal[requestedPrincipals[i]]
if !ok {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected principal is not granted"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "selected principal is not granted")
return
}
selectedGrantIDs[grantID] = true
@@ -680,7 +679,7 @@ func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, pa
}
}
if len(selectedGrantIDs) == 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one granted principal is required"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "at least one granted principal is required")
return
}
for grantID = range selectedGrantIDs {
@@ -690,7 +689,7 @@ func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, pa
for i = 0; i < len(selectedIDs); i++ {
grant, ok = grantsByID[selectedIDs[i]]
if !ok {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected grant is not active"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "selected grant is not active")
return
}
for j = 0; j < len(grant.Principals); j++ {
@@ -714,21 +713,21 @@ func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, pa
response, err = api.signSSHUserKeyWithCA(api.store(r), item, sourcePublicKey, strings.TrimSpace(req.KeyID), principals, validSeconds, item.MaxUserValidSeconds)
}
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
for grantID = range selectedGrantIDs {
err = api.store(r).MarkSSHPrincipalGrantUsed(grantID)
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "self-sign grant consume failed ca_id=%s user=%s grant_id=%s err=%v", item.ID, user.Username, grantID, err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected principal grant is not active"})
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "selected principal grant is not active")
return
}
}
err = api.recordSSHUserCAIssuance(r, item, user, "self", sourcePublicKey, sourcePublicKeyFingerprint, selectedIDs, selectedGrantNames, response)
if err != nil {
api.Logger.Write(logIDSSHCA, codit_logger.LOG_WARN, "self-sign audit write failed ca_id=%s user=%s serial=%d err=%v", item.ID, user.Username, response.Serial, err)
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record issuance audit"})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to record issuance audit")
return
}
api.Logger.Write(logIDSSHCA, codit_logger.LOG_INFO, "self-sign success ca_id=%s user=%s serial=%d key_id=%s", item.ID, user.Username, response.Serial, response.KeyID)
@@ -8,6 +8,9 @@ import "codit/internal/middleware"
import "codit/internal/models"
const permissionProjectCreate string = "project.create"
const permissionSSHFileUpload string = "ssh.file.upload"
const permissionSSHFileDownload string = "ssh.file.download"
const permissionSSHFileCopyToSession string = "ssh.file.copy_to_session"
type meResponse struct {
ID string `json:"id"`
@@ -194,6 +197,12 @@ func normalizeSubjectPermissionName(raw string) (string, error) {
switch value {
case permissionProjectCreate:
return value, nil
case permissionSSHFileUpload:
return value, nil
case permissionSSHFileDownload:
return value, nil
case permissionSSHFileCopyToSession:
return value, nil
default:
return "", errors.New("unsupported permission")
}
+20 -9
View File
@@ -91,28 +91,39 @@ func AccessLog(logger codit_logger.Logger, next http.Handler) http.Handler {
var userLabel string
var mtlsInfo ClientCertLogInfo
var authReason string
var httpStatusReason string
var user models.User
var logLevel codit_logger.LogLevel
var statusCode int
var ok bool
if logger == nil {
next.ServeHTTP(w, r)
return
}
recorder = NewStatusRecorder(w, http.StatusOK)
start = time.Now()
next.ServeHTTP(recorder, r)
duration = time.Since(start)
userLabel = "-"
user, ok = UserFromContext(r.Context())
if ok && user.Username != "" {
userLabel = user.Username
}
if ok && user.Username != "" { userLabel = user.Username }
mtlsInfo = ClientCertLogFields(r)
authReason = "-"
authReason, ok = AuthReasonFromContext(r.Context())
if !ok || authReason == "" {
authReason = "-"
}
logger.Write(logIDAPI, codit_logger.LOG_INFO, "method=%s path=%s remote=%s user=%s mtls_cn=%q mtls_issuer_cn=%q mtls_serial=%q mtls_user_id=%q mtls_username=%q mtls_profile_id=%q mtls_permissions=%q mtls_scope=%q status=%d dur_ms=%d auth_reason=%s",
r.Method, r.URL.Path, r.RemoteAddr, userLabel, mtlsInfo.SubjectCN, mtlsInfo.IssuerCN, mtlsInfo.Serial, mtlsInfo.UserID, mtlsInfo.Username, mtlsInfo.ProfileID, mtlsInfo.Permissions, mtlsInfo.Scope, recorder.Status(), duration.Milliseconds(), authReason)
if !ok || authReason == "" { authReason = "-" }
httpStatusReason, ok = HttpStatusReasonFromContext(r.Context())
if !ok || httpStatusReason == "" { httpStatusReason = "-" }
logLevel = codit_logger.LOG_INFO
statusCode = recorder.Status()
if statusCode >= http.StatusInternalServerError { logLevel = codit_logger.LOG_ERROR }
logger.Write(logIDAPI, logLevel, "method=%s path=%s remote=%s user=%s mtls_cn=%q mtls_issuer_cn=%q mtls_serial=%q mtls_user_id=%q mtls_username=%q mtls_profile_id=%q mtls_permissions=%q mtls_scope=%q status=%d dur_ms=%d auth_reason=%s reason=%q",
r.Method, r.URL.Path, r.RemoteAddr, userLabel, mtlsInfo.SubjectCN, mtlsInfo.IssuerCN, mtlsInfo.Serial, mtlsInfo.UserID, mtlsInfo.Username, mtlsInfo.ProfileID, mtlsInfo.Permissions, mtlsInfo.Scope, statusCode, duration.Milliseconds(), authReason, httpStatusReason)
})
}
+44 -18
View File
@@ -17,6 +17,7 @@ const userHolderKey ctxKey = "user_holder"
const principalHolderKey ctxKey = "principal_holder"
const afterCommitKey ctxKey = "after_commit_holder"
const authReasonHolderKey ctxKey = "auth_reason_holder"
const httpStatusReasonHolderKey ctxKey = "http_status_reason"
type userHolder struct {
User models.User
@@ -37,6 +38,11 @@ type authReasonHolder struct {
OK bool
}
type httpStatusReasonHolder struct {
Reason string
OK bool
}
type bufferedResponseWriter struct {
header http.Header
body bytes.Buffer
@@ -53,31 +59,27 @@ type passThroughStatusWriter struct {
func WithRequestStore(store *db.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ctx context.Context
var holder *userHolder
var principal *principalHolder
var afterHolder *afterCommitHolder
var authHolder *authReasonHolder
ctx = r.Context()
if _, ok := StoreFromContext(ctx); !ok {
ctx = context.WithValue(ctx, storeKey, store)
}
if _, ok := ctx.Value(userHolderKey).(*userHolder); !ok {
holder = &userHolder{}
ctx = context.WithValue(ctx, userHolderKey, holder)
ctx = context.WithValue(ctx, userHolderKey, &userHolder{})
}
if _, ok := ctx.Value(principalHolderKey).(*principalHolder); !ok {
principal = &principalHolder{}
ctx = context.WithValue(ctx, principalHolderKey, principal)
ctx = context.WithValue(ctx, principalHolderKey, &principalHolder{})
}
if _, ok := ctx.Value(afterCommitKey).(*afterCommitHolder); !ok {
afterHolder = &afterCommitHolder{}
ctx = context.WithValue(ctx, afterCommitKey, afterHolder)
ctx = context.WithValue(ctx, afterCommitKey, &afterCommitHolder{})
}
if _, ok := ctx.Value(authReasonHolderKey).(*authReasonHolder); !ok {
authHolder = &authReasonHolder{}
ctx = context.WithValue(ctx, authReasonHolderKey, authHolder)
ctx = context.WithValue(ctx, authReasonHolderKey, &authReasonHolder{})
}
if _, ok := ctx.Value(httpStatusReasonHolderKey).(*httpStatusReasonHolder); !ok {
ctx = context.WithValue(ctx, httpStatusReasonHolderKey, &httpStatusReasonHolder{})
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -269,9 +271,8 @@ func RememberAuthReason(ctx context.Context, reason string) {
var holder *authReasonHolder
var ok bool
if ctx == nil {
return
}
if ctx == nil { return }
holder, ok = ctx.Value(authReasonHolderKey).(*authReasonHolder)
if ok && holder != nil {
holder.Reason = reason
@@ -283,9 +284,8 @@ func AuthReasonFromContext(ctx context.Context) (string, bool) {
var holder *authReasonHolder
var ok bool
if ctx == nil {
return "", false
}
if ctx == nil { return "", false }
holder, ok = ctx.Value(authReasonHolderKey).(*authReasonHolder)
if ok && holder != nil && holder.OK {
return holder.Reason, true
@@ -293,6 +293,32 @@ func AuthReasonFromContext(ctx context.Context) (string, bool) {
return "", false
}
func RememberHttpStatusReason(ctx context.Context, reason string) {
var holder *httpStatusReasonHolder
var ok bool
if ctx == nil { return }
holder, ok = ctx.Value(httpStatusReasonHolderKey).(*httpStatusReasonHolder)
if ok && holder != nil {
holder.Reason = reason
holder.OK = strings.TrimSpace(reason) != ""
}
}
func HttpStatusReasonFromContext(ctx context.Context) (string, bool) {
var holder *httpStatusReasonHolder
var ok bool
if ctx == nil { return "", false }
holder, ok = ctx.Value(httpStatusReasonHolderKey).(*httpStatusReasonHolder)
if ok && holder != nil && holder.OK {
return holder.Reason, true
}
return "", false
}
func isReadOnlyMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
}
+26
View File
@@ -664,6 +664,32 @@ type SSHSession struct {
TranscriptAvailable bool `json:"transcript_available"`
}
type SSHFileTransfer struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
ProfileID string `json:"profile_id"`
ServerID string `json:"server_id"`
ServerName string `json:"server_name"`
RemoteUsername string `json:"remote_username"`
Operation string `json:"operation"`
SourceSessionID string `json:"source_session_id"`
TargetSessionID string `json:"target_session_id"`
TargetServerID string `json:"target_server_id"`
TargetServerName string `json:"target_server_name"`
TargetDir string `json:"target_dir"`
Paths []string `json:"paths"`
BytesTransferred int64 `json:"bytes_transferred"`
Status string `json:"status"`
Error string `json:"error"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at"`
DurationMS int64 `json:"duration_ms"`
RemoteAddr string `json:"remote_addr"`
UserAgent string `json:"user_agent"`
}
type ServicePrincipal struct {
ID string `json:"id"`
Name string `json:"name"`
+105 -77
View File
@@ -87,22 +87,22 @@ CREATE TABLE IF NOT EXISTS project_role_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
subject_type TEXT NOT NULL,
subject_public_id TEXT NOT NULL,
subject_id INTEGER NOT NULL,
role TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
UNIQUE (project_id, subject_type, subject_public_id)
UNIQUE (project_id, subject_type, subject_id)
);
CREATE TABLE IF NOT EXISTS subject_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
permission TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_public_id TEXT NOT NULL,
subject_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (permission, subject_type, subject_public_id)
UNIQUE (permission, subject_type, subject_id)
);
CREATE TABLE IF NOT EXISTS repos (
@@ -265,19 +265,19 @@ CREATE TABLE IF NOT EXISTS pki_client_profiles (
CREATE TABLE IF NOT EXISTS pki_client_profile_targets (
profile_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_public_id TEXT NOT NULL,
target_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, target_type, target_public_id),
PRIMARY KEY (profile_id, target_type, target_id),
FOREIGN KEY (profile_id) REFERENCES pki_client_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS pki_client_issuances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
cert_public_id TEXT NOT NULL,
user_public_id TEXT NOT NULL,
cert_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
username TEXT NOT NULL DEFAULT '',
profile_public_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
profile_name TEXT NOT NULL DEFAULT '',
serial_hex TEXT NOT NULL DEFAULT '',
common_name TEXT NOT NULL DEFAULT '',
@@ -308,8 +308,8 @@ CREATE TABLE IF NOT EXISTS ssh_user_cas (
CREATE TABLE IF NOT EXISTS ssh_user_ca_issuances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
ca_public_id TEXT NOT NULL,
issuer_user_public_id TEXT,
ca_id INTEGER NOT NULL,
issuer_user_id INTEGER,
issuer_username TEXT NOT NULL DEFAULT '',
issuer_kind TEXT NOT NULL DEFAULT 'unknown',
source_public_key TEXT NOT NULL,
@@ -325,9 +325,7 @@ CREATE TABLE IF NOT EXISTS ssh_user_ca_issuances (
remote_addr TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY (ca_public_id) REFERENCES ssh_user_cas(public_id) ON DELETE CASCADE,
FOREIGN KEY (issuer_user_public_id) REFERENCES users(public_id) ON DELETE SET NULL,
UNIQUE (ca_public_id, serial)
UNIQUE (ca_id, serial)
);
CREATE TABLE IF NOT EXISTS ssh_principal_grants (
@@ -342,20 +340,20 @@ CREATE TABLE IF NOT EXISTS ssh_principal_grants (
used_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
created_by_user_public_id TEXT,
created_by_user_id INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (created_by_user_public_id) REFERENCES users(public_id) ON DELETE SET NULL
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ssh_principal_grant_targets (
grant_public_id TEXT NOT NULL,
grant_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_public_id TEXT NOT NULL,
target_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (grant_public_id, target_type, target_public_id),
FOREIGN KEY (grant_public_id) REFERENCES ssh_principal_grants(public_id) ON DELETE CASCADE
PRIMARY KEY (grant_id, target_type, target_id),
FOREIGN KEY (grant_id) REFERENCES ssh_principal_grants(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_servers (
@@ -369,24 +367,25 @@ CREATE TABLE IF NOT EXISTS ssh_servers (
enabled INTEGER NOT NULL DEFAULT 1,
host_key_policy TEXT NOT NULL DEFAULT 'strict',
owner_scope TEXT NOT NULL DEFAULT 'admin',
owner_user_public_id TEXT NOT NULL DEFAULT '',
owner_user_id INTEGER,
created_by_kind TEXT NOT NULL DEFAULT 'user',
created_by_subject_id TEXT NOT NULL DEFAULT '',
created_by_subject_name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
updated_at INTEGER NOT NULL,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ssh_server_host_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
server_public_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
algorithm TEXT NOT NULL,
public_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(server_public_id, fingerprint),
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE
UNIQUE(server_id, fingerprint),
FOREIGN KEY (server_id) REFERENCES ssh_servers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_server_groups (
@@ -403,12 +402,12 @@ CREATE TABLE IF NOT EXISTS ssh_server_groups (
);
CREATE TABLE IF NOT EXISTS ssh_server_group_members (
group_public_id TEXT NOT NULL,
server_public_id TEXT NOT NULL,
group_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (group_public_id, server_public_id),
FOREIGN KEY (group_public_id) REFERENCES ssh_server_groups(public_id) ON DELETE CASCADE,
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE
PRIMARY KEY (group_id, server_id),
FOREIGN KEY (group_id) REFERENCES ssh_server_groups(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES ssh_servers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_secrets (
@@ -431,40 +430,41 @@ CREATE TABLE IF NOT EXISTS ssh_credentials (
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'private_key',
secret_public_id TEXT NOT NULL,
secret_id INTEGER NOT NULL,
public_key TEXT NOT NULL DEFAULT '',
fingerprint TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
owner_scope TEXT NOT NULL DEFAULT 'admin',
owner_user_public_id TEXT NOT NULL DEFAULT '',
owner_user_id INTEGER,
created_by_kind TEXT NOT NULL DEFAULT 'user',
created_by_subject_id TEXT NOT NULL DEFAULT '',
created_by_subject_name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (secret_public_id) REFERENCES ssh_secrets(public_id) ON DELETE CASCADE
FOREIGN KEY (secret_id) REFERENCES ssh_secrets(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ssh_access_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
server_public_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
server_target_type TEXT NOT NULL DEFAULT 'server',
server_group_public_id TEXT DEFAULT '',
server_group_id INTEGER,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
remote_username TEXT NOT NULL,
auth_method TEXT NOT NULL,
second_factor_mode TEXT NOT NULL DEFAULT 'none',
owner_scope TEXT NOT NULL DEFAULT 'user',
owner_user_public_id TEXT DEFAULT '',
owner_user_id INTEGER,
allow_user_edit INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
secret_public_id TEXT DEFAULT '',
ssh_credential_public_id TEXT DEFAULT '',
secret_id INTEGER,
ssh_credential_id INTEGER,
auth_public_key TEXT NOT NULL DEFAULT '',
auth_public_key_fingerprint TEXT NOT NULL DEFAULT '',
ssh_user_ca_public_id TEXT DEFAULT '',
ssh_user_ca_id INTEGER,
ssh_principal_grant_ids_json TEXT NOT NULL DEFAULT '[]',
default_cert_valid_seconds INTEGER NOT NULL DEFAULT 3600,
max_cert_valid_seconds INTEGER NOT NULL DEFAULT 3600,
@@ -475,30 +475,31 @@ CREATE TABLE IF NOT EXISTS ssh_access_profiles (
created_by_subject_name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE,
FOREIGN KEY (server_group_public_id) REFERENCES ssh_server_groups(public_id) ON DELETE SET NULL,
FOREIGN KEY (secret_public_id) REFERENCES ssh_secrets(public_id) ON DELETE SET NULL,
FOREIGN KEY (ssh_credential_public_id) REFERENCES ssh_credentials(public_id) ON DELETE SET NULL,
FOREIGN KEY (ssh_user_ca_public_id) REFERENCES ssh_user_cas(public_id) ON DELETE SET NULL
FOREIGN KEY (server_id) REFERENCES ssh_servers(id) ON DELETE CASCADE,
FOREIGN KEY (server_group_id) REFERENCES ssh_server_groups(id) ON DELETE SET NULL,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (secret_id) REFERENCES ssh_secrets(id) ON DELETE SET NULL,
FOREIGN KEY (ssh_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL,
FOREIGN KEY (ssh_user_ca_id) REFERENCES ssh_user_cas(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ssh_access_profile_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
profile_public_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_public_id TEXT NOT NULL,
target_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(profile_public_id, target_type, target_public_id),
FOREIGN KEY (profile_public_id) REFERENCES ssh_access_profiles(public_id) ON DELETE CASCADE
UNIQUE(profile_id, target_type, target_id),
FOREIGN KEY (profile_id) REFERENCES ssh_access_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
profile_public_id TEXT NOT NULL,
server_public_id TEXT NOT NULL,
user_public_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
remote_username TEXT NOT NULL,
auth_method TEXT NOT NULL,
@@ -515,10 +516,34 @@ CREATE TABLE IF NOT EXISTS ssh_sessions (
ended_at INTEGER NOT NULL DEFAULT 0,
remote_addr TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ssh_file_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
session_id INTEGER,
user_id INTEGER,
username TEXT NOT NULL DEFAULT '',
profile_id INTEGER,
server_id INTEGER,
server_name TEXT NOT NULL DEFAULT '',
remote_username TEXT NOT NULL DEFAULT '',
operation TEXT NOT NULL,
source_session_id INTEGER,
target_session_id INTEGER,
target_server_id INTEGER,
target_server_name TEXT NOT NULL DEFAULT '',
target_dir TEXT NOT NULL DEFAULT '',
paths_json TEXT NOT NULL DEFAULT '[]',
bytes_transferred INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
error TEXT NOT NULL DEFAULT '',
FOREIGN KEY (profile_public_id) REFERENCES ssh_access_profiles(public_id) ON DELETE CASCADE,
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE,
FOREIGN KEY (user_public_id) REFERENCES users(public_id) ON DELETE CASCADE
started_at INTEGER NOT NULL,
finished_at INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
remote_addr TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS acme_profiles (
@@ -544,20 +569,19 @@ CREATE TABLE IF NOT EXISTS acme_profiles (
CREATE TABLE IF NOT EXISTS acme_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
profile_public_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
common_name TEXT NOT NULL,
san_dns TEXT NOT NULL DEFAULT '',
order_url TEXT NOT NULL DEFAULT '',
finalize_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
cert_public_id TEXT NOT NULL DEFAULT '',
cert_id INTEGER,
challenges_json TEXT NOT NULL DEFAULT '[]',
csr_pem TEXT NOT NULL DEFAULT '',
key_pem TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (profile_public_id) REFERENCES acme_profiles(public_id) ON DELETE CASCADE
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tls_listeners (
@@ -712,40 +736,44 @@ CREATE INDEX IF NOT EXISTS idx_user_groups_name ON user_groups(name);
CREATE UNIQUE INDEX IF NOT EXISTS user_groups_one_all_users ON user_groups(scope) WHERE scope = 'all_users';
CREATE INDEX IF NOT EXISTS idx_user_group_members_user ON user_group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_project_role_bindings_project ON project_role_bindings(project_id);
CREATE INDEX IF NOT EXISTS idx_project_role_bindings_subject ON project_role_bindings(subject_type, subject_public_id, project_id);
CREATE INDEX IF NOT EXISTS idx_subject_permissions_lookup ON subject_permissions(permission, subject_type, subject_public_id);
CREATE INDEX IF NOT EXISTS idx_project_role_bindings_subject ON project_role_bindings(subject_type, subject_id, project_id);
CREATE INDEX IF NOT EXISTS idx_subject_permissions_lookup ON subject_permissions(permission, subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_pki_cas_parent ON pki_cas(parent_ca_id);
CREATE INDEX IF NOT EXISTS idx_pki_certs_ca ON pki_certs(ca_id);
CREATE INDEX IF NOT EXISTS idx_pki_client_profiles_ca_id ON pki_client_profiles(ca_id);
CREATE INDEX IF NOT EXISTS idx_pki_client_profile_targets_target ON pki_client_profile_targets(target_type, target_public_id);
CREATE INDEX IF NOT EXISTS idx_pki_client_issuances_user_created_at ON pki_client_issuances(user_public_id, created_at);
CREATE INDEX IF NOT EXISTS idx_pki_client_issuances_cert_public_id ON pki_client_issuances(cert_public_id);
CREATE INDEX IF NOT EXISTS idx_pki_client_profile_targets_target ON pki_client_profile_targets(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_pki_client_issuances_user_created_at ON pki_client_issuances(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_pki_client_issuances_cert_id ON pki_client_issuances(cert_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_user_cas_fingerprint ON ssh_user_cas(fingerprint);
CREATE INDEX IF NOT EXISTS idx_ssh_user_ca_issuances_ca_created_at ON ssh_user_ca_issuances(ca_public_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ssh_user_ca_issuances_issuer_created_at ON ssh_user_ca_issuances(issuer_user_public_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ssh_user_ca_issuances_ca_created_at ON ssh_user_ca_issuances(ca_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ssh_user_ca_issuances_issuer_created_at ON ssh_user_ca_issuances(issuer_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ssh_principal_grants_principal ON ssh_principal_grants(principal);
CREATE INDEX IF NOT EXISTS idx_ssh_principal_grants_active ON ssh_principal_grants(disabled, valid_after, valid_before);
CREATE INDEX IF NOT EXISTS idx_ssh_principal_grant_targets_target ON ssh_principal_grant_targets(target_type, target_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_principal_grant_targets_target ON ssh_principal_grant_targets(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_ssh_servers_name ON ssh_servers(name);
CREATE INDEX IF NOT EXISTS idx_ssh_servers_host_port ON ssh_servers(host, port);
CREATE INDEX IF NOT EXISTS idx_ssh_servers_owner ON ssh_servers(owner_scope, owner_user_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_server_host_keys_server ON ssh_server_host_keys(server_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_servers_owner ON ssh_servers(owner_scope, owner_user_id);
CREATE INDEX IF NOT EXISTS idx_ssh_server_host_keys_server ON ssh_server_host_keys(server_id);
CREATE INDEX IF NOT EXISTS idx_ssh_server_groups_name ON ssh_server_groups(name);
CREATE INDEX IF NOT EXISTS idx_ssh_server_groups_enabled ON ssh_server_groups(enabled);
CREATE INDEX IF NOT EXISTS idx_ssh_server_group_members_server ON ssh_server_group_members(server_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_server_group_members_server ON ssh_server_group_members(server_id);
CREATE INDEX IF NOT EXISTS idx_ssh_secrets_kind ON ssh_secrets(kind);
CREATE INDEX IF NOT EXISTS idx_ssh_credentials_enabled ON ssh_credentials(enabled);
CREATE INDEX IF NOT EXISTS idx_ssh_credentials_fingerprint ON ssh_credentials(fingerprint);
CREATE INDEX IF NOT EXISTS idx_ssh_credentials_owner ON ssh_credentials(owner_scope, owner_user_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_server ON ssh_access_profiles(server_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_server_group ON ssh_access_profiles(server_group_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_credentials_owner ON ssh_credentials(owner_scope, owner_user_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_server ON ssh_access_profiles(server_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_server_group ON ssh_access_profiles(server_group_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_owner_scope ON ssh_access_profiles(owner_scope);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_owner_user ON ssh_access_profiles(owner_user_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_owner_user ON ssh_access_profiles(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_enabled ON ssh_access_profiles(enabled);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_profile ON ssh_access_profile_targets(profile_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_target ON ssh_access_profile_targets(target_type, target_public_id);
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_user ON ssh_sessions(user_public_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_profile ON ssh_sessions(profile_public_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_profile ON ssh_access_profile_targets(profile_id);
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_target ON ssh_access_profile_targets(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_user ON ssh_sessions(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_profile ON ssh_sessions(profile_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_status ON ssh_sessions(status);
CREATE INDEX IF NOT EXISTS idx_ssh_file_transfers_session ON ssh_file_transfers(session_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_file_transfers_user ON ssh_file_transfers(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_file_transfers_server ON ssh_file_transfers(server_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ssh_file_transfers_status ON ssh_file_transfers(status, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_cert_principal_bindings_principal_id ON cert_principal_bindings(principal_id);
CREATE INDEX IF NOT EXISTS idx_principal_project_roles_project_id ON principal_project_roles(project_id);
+2
View File
@@ -760,6 +760,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("PATCH", "/api/admin/ssh/access-profiles/:id", api.UpdateSSHAccessProfileAdmin)
router.Handle("DELETE", "/api/admin/ssh/access-profiles/:id", api.DeleteSSHAccessProfileAdmin)
router.Handle("GET", "/api/admin/ssh/sessions", api.ListSSHSessionsAdmin)
router.Handle("GET", "/api/admin/ssh/file-transfers", api.ListSSHFileTransfersAdmin)
router.Handle("GET", "/api/admin/ssh/sessions/:id/transcript", api.GetSSHSessionTranscriptAdmin)
router.Handle("GET", "/api/ssh/user-cas", api.ListSSHUserCAsForSelf)
router.Handle("GET", "/api/ssh/user-cas/:id", api.GetSSHUserCAForSelf)
@@ -787,6 +788,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("DELETE", "/api/ssh/access-profiles/:id", api.DeleteSSHAccessProfileForSelf)
router.Handle("POST", "/api/ssh/access-profiles/:id/connect", api.CreateSSHSessionForSelf)
router.Handle("GET", "/api/ssh/sessions", api.ListSSHSessionsForSelf)
router.Handle("GET", "/api/ssh/file-transfers", api.ListSSHFileTransfersForSelf)
router.Handle("GET", "/api/ssh/stream", api.StreamSSHWorkspaceForSelf)
router.Handle("GET", "/api/ssh/sessions/:id", api.GetSSHSessionForSelf)
router.Handle("GET", "/api/ssh/sessions/:id/transcript", api.GetSSHSessionTranscriptForSelf)
+32 -31
View File
@@ -5,7 +5,8 @@ The current baseline is a single clean initialization migration. Future schema c
## Conventions
- Most durable user-facing IDs are stored as `public_id` in the database and exposed as `id` in JSON APIs.
- Durable user-facing IDs are stored only on owning entity rows as `public_id` and exposed as `id` in JSON APIs.
- Internal relationships use integer `id` columns. Store code translates between public IDs at the API boundary and internal row IDs at persistence boundaries.
- Internal integer `id` columns are local database primary keys and should not be exposed unless a handler explicitly needs them.
- `created_at`, `updated_at`, `started_at`, `ended_at`, and similar integer timestamp columns store Unix seconds unless the schema explicitly uses `TIMESTAMP`.
- Boolean values are stored as `INTEGER` with `0`/`1` in SQLite.
@@ -14,7 +15,7 @@ The current baseline is a single clean initialization migration. Future schema c
## Relationship Diagrams
The diagrams below are intentionally split by domain. They show the main logical relationships; a few relationships are implemented through `public_id` string columns rather than strict integer foreign keys.
The diagrams below are intentionally split by domain. They show the main logical relationships.
### Identity And Authorization
@@ -210,10 +211,10 @@ Stores global permissions assigned directly to users, groups, or service princip
| --- | --- |
| `permission` | Permission string. |
| `subject_type` | `user`, `group`, or `principal`. |
| `subject_public_id` | External ID of the subject. |
| `subject_id` | Internal row ID of the subject table selected by `subject_type`. |
| `created_at`, `updated_at` | Lifecycle timestamps. |
Unique key: `(permission, subject_type, subject_public_id)`.
Unique key: `(permission, subject_type, subject_id)`.
### `service_principals`
@@ -307,11 +308,11 @@ Assigns project roles to subjects such as groups or principals.
| --- | --- |
| `project_id` | Internal project FK. |
| `subject_type` | Subject kind. |
| `subject_public_id` | External subject ID. |
| `subject_id` | Internal row ID of the subject table selected by `subject_type`. |
| `role` | Project role. |
| `created_at`, `updated_at` | Lifecycle timestamps. |
Unique key: `(project_id, subject_type, subject_public_id)`.
Unique key: `(project_id, subject_type, subject_id)`.
### `repos`
@@ -437,7 +438,7 @@ Important fields:
Maps client certificate profiles to users or groups.
Primary key: `(profile_id, target_type, target_public_id)`.
Primary key: `(profile_id, target_type, target_id)`.
### `pki_client_issuances`
@@ -445,9 +446,9 @@ Records self-service client certificate issuance history.
Important fields:
- `cert_public_id`: issued cert ID.
- `user_public_id`, `username`: requesting user.
- `profile_public_id`, `profile_name`: profile snapshot.
- `cert_id`: internal issued cert FK.
- `user_id`, `username`: internal requesting user FK and username snapshot.
- `profile_id`, `profile_name`: internal profile FK and profile name snapshot.
- `serial_hex`, `common_name`, `san_uri`: issued cert identity data.
- `authz_permissions`, `authz_scope`: authorization snapshot.
- `not_before`, `not_after`, `created_at`: validity/history timestamps.
@@ -472,11 +473,11 @@ Tracks ACME orders and DNS challenges.
Important fields:
- `profile_public_id`
- `profile_id`
- `common_name`, `san_dns`
- `order_url`, `finalize_url`
- `status`, `last_error`
- `cert_public_id`
- `cert_id`
- `challenges_json`
- `csr_pem`, `key_pem`
@@ -505,8 +506,8 @@ Records SSH user certificate signing history.
Important fields:
- `ca_public_id`: SSH user CA ID.
- `issuer_user_public_id`, `issuer_username`, `issuer_kind`: issuer attribution.
- `ca_id`: internal SSH user CA FK.
- `issuer_user_id`, `issuer_username`, `issuer_kind`: internal issuer user FK and issuer attribution.
- `source_public_key`, `source_public_key_fingerprint`: key that was signed.
- `certificate`: issued SSH certificate.
- `key_id`, `principals_json`: certificate identity data.
@@ -528,14 +529,14 @@ Defines grant-only SSH principal authorization.
| `max_uses`, `used_count` | Use limit and usage count. |
| `disabled` | Disables the grant. |
| `reason` | Administrative note. |
| `created_by_user_public_id` | Creator user ID. |
| `created_by_user_id` | Internal creator user FK. |
| `last_used_at` | Last successful use. |
### `ssh_principal_grant_targets`
Maps SSH principal grants to users or groups.
Primary key: `(grant_public_id, target_type, target_public_id)`.
Primary key: `(grant_id, target_type, target_id)`.
### `ssh_servers`
@@ -548,10 +549,10 @@ Important fields:
- `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'`.
- `owner_user_id`: internal owner user FK when `owner_scope = 'user'`.
- `created_by_kind`, `created_by_subject_id`, `created_by_subject_name`
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.
Self-service server lists use `owner_scope = 'user'` and `owner_user_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`
@@ -560,12 +561,12 @@ Stores pinned/known SSH server host keys.
Important fields:
- `server_public_id`
- `server_id`
- `algorithm`
- `public_key`
- `fingerprint`
Unique key: `(server_public_id, fingerprint)`.
Unique key: `(server_id, fingerprint)`.
### `ssh_server_groups`
@@ -584,7 +585,7 @@ Server groups let a single SSH access profile target multiple servers. Users cho
Maps SSH server groups to member servers.
Primary key: `(group_public_id, server_public_id)`.
Primary key: `(group_id, server_id)`.
### `ssh_credentials`
@@ -594,11 +595,11 @@ Important fields:
- `name`, `description`
- `type`: currently `private_key`.
- `secret_public_id`: encrypted secret reference in `ssh_secrets`.
- `secret_id`: internal encrypted secret FK in `ssh_secrets`.
- `public_key`, `fingerprint`: non-secret public key metadata shown in the UI/API.
- `enabled`: disabled credentials must not be used for new SSH connections.
- `owner_scope`: `admin` or `user`.
- `owner_user_public_id`: owner for user-owned credentials.
- `owner_user_id`: internal owner user FK for user-owned credentials.
- creator attribution fields.
The private key PEM itself is not stored in this table and is not returned by the API. It is encrypted into the referenced `ssh_secrets` row.
@@ -624,17 +625,17 @@ Defines how a user may connect to an SSH server.
Important fields:
- `server_target_type`: `server` or `group`.
- `server_public_id`: target server when `server_target_type = 'server'`.
- `server_group_public_id`: target group when `server_target_type = 'group'`.
- `server_id`: target server when `server_target_type = 'server'`.
- `server_group_id`: internal target group FK when `server_target_type = 'group'`.
- `remote_username`: remote account name.
- `auth_method`: `managed_ssh_cert`, `prompted_password`, `stored_password`, or `stored_private_key`.
- `second_factor_mode`: `none` or `prompted_totp`.
- `owner_scope`: `admin_shared` or `user`.
- `owner_user_public_id`: owner for self-service profiles.
- `secret_public_id`: stored credential reference.
- `ssh_credential_public_id`: imported SSH credential reference when using a stored private key credential.
- `owner_user_id`: internal owner user FK for self-service profiles.
- `secret_id`: internal stored secret FK.
- `ssh_credential_id`: internal imported SSH credential FK when using a stored private key credential.
- `auth_public_key`, `auth_public_key_fingerprint`: public-key metadata.
- `ssh_user_ca_public_id`: SSH CA used for managed cert auth.
- `ssh_user_ca_id`: internal SSH CA FK used for managed cert auth.
- `ssh_principal_grant_ids_json`: grants used for managed cert auth.
- `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.
@@ -646,7 +647,7 @@ Stored private-key and stored-password profiles ultimately reference `ssh_secret
Maps admin-shared SSH access profiles to users or groups.
Unique key: `(profile_public_id, target_type, target_public_id)`.
Unique key: `(profile_id, target_type, target_id)`.
### `ssh_sessions`
@@ -654,7 +655,7 @@ Stores SSH web terminal session history.
Important fields:
- `profile_public_id`, `server_public_id`, `user_public_id`: session ownership/context.
- `profile_id`, `server_id`, `user_id`: internal session ownership/context FKs.
- `username`, `remote_username`: local and remote usernames.
- `auth_method`, `second_factor_mode`: auth method snapshot.
- `host`, `port`: target snapshot.
+52
View File
@@ -1196,6 +1196,39 @@ export interface SSHSessionFileCopyResponse {
failed: number
}
export interface SSHFileTransfer {
id: string
session_id: string
user_id: string
username: string
profile_id: string
server_id: string
server_name: string
remote_username: string
operation: 'upload' | 'download' | 'copy_to_session' | string
source_session_id: string
target_session_id: string
target_server_id: string
target_server_name: string
target_dir: string
paths: string[]
bytes_transferred: number
status: string
error: string
started_at: number
finished_at: number
duration_ms: number
remote_addr: string
user_agent: string
}
export interface SSHFileTransferListResponse {
items: SSHFileTransfer[]
limit: number
offset: number
has_more: boolean
}
export interface BinaryDownload {
data: ArrayBuffer
filename: string
@@ -1592,6 +1625,25 @@ export const api = {
if (params?.status) search.set('status', params.status)
return request<SSHSessionListResponse>(`/api/admin/ssh/sessions${search.toString() ? `?${search.toString()}` : ''}`, options)
},
listSSHFileTransfersForSelf: (params?: { limit?: number; offset?: number; q?: string; status?: string; session_id?: string }, options?: RequestInit) => {
const search: URLSearchParams = new URLSearchParams()
if (params?.limit && params.limit > 0) search.set('limit', String(params.limit))
if (params?.offset && params.offset > 0) search.set('offset', String(params.offset))
if (params?.q) search.set('q', params.q)
if (params?.status) search.set('status', params.status)
if (params?.session_id) search.set('session_id', params.session_id)
return request<SSHFileTransferListResponse>(`/api/ssh/file-transfers${search.toString() ? `?${search.toString()}` : ''}`, options)
},
listSSHFileTransfersAdmin: (params?: { limit?: number; offset?: number; q?: string; status?: string; session_id?: string; user_id?: string }, options?: RequestInit) => {
const search: URLSearchParams = new URLSearchParams()
if (params?.limit && params.limit > 0) search.set('limit', String(params.limit))
if (params?.offset && params.offset > 0) search.set('offset', String(params.offset))
if (params?.q) search.set('q', params.q)
if (params?.status) search.set('status', params.status)
if (params?.session_id) search.set('session_id', params.session_id)
if (params?.user_id) search.set('user_id', params.user_id)
return request<SSHFileTransferListResponse>(`/api/admin/ssh/file-transfers${search.toString() ? `?${search.toString()}` : ''}`, options)
},
getSSHSessionForSelf: (id: string, options?: RequestInit) => request<SSHSession>(`/api/ssh/sessions/${id}`, options),
getSSHSessionTranscriptForSelf: (id: string, options?: RequestInit) => requestText(`/api/ssh/sessions/${id}/transcript`, options),
getSSHSessionTranscriptAdmin: (id: string, options?: RequestInit) => requestText(`/api/admin/ssh/sessions/${id}/transcript`, options),
@@ -0,0 +1,80 @@
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import { Dispatch, SetStateAction } from 'react'
import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert'
import SelectField from './SelectField'
export type UserGroupFormState = {
name: string
description: string
disabled: boolean
totpRequired: boolean
scope: 'explicit' | 'all_users'
}
type UserGroupFormDialogProps = {
open: boolean
editID: string
dialogError: string | null
saving: boolean
form: UserGroupFormState
setForm: Dispatch<SetStateAction<UserGroupFormState>>
onClose: () => void
onSave: () => void
onClearError: () => void
}
export default function UserGroupFormDialog(props: UserGroupFormDialogProps) {
const title: string = props.editID ? 'Edit Group' : 'New Group'
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<FormDialogContent>
<PageAlert message={props.dialogError} onClose={props.onClearError} />
<TextField
label="Name"
value={props.form.name}
onChange={(event) => props.setForm((prev: UserGroupFormState) => ({ ...prev, name: event.target.value }))}
/>
<TextField
label="Description"
value={props.form.description}
onChange={(event) => props.setForm((prev: UserGroupFormState) => ({ ...prev, description: event.target.value }))}
multiline
minRows={2}
/>
<SelectField
select
label="Scope"
value={props.form.scope}
onChange={(event) => props.setForm((prev: UserGroupFormState) => ({ ...prev, scope: event.target.value === 'all_users' ? 'all_users' : 'explicit' }))}
>
<MenuItem value="explicit">Explicit members</MenuItem>
<MenuItem value="all_users">All users</MenuItem>
</SelectField>
<FormControlLabel
control={<Checkbox checked={props.form.disabled} onChange={(event) => props.setForm((prev: UserGroupFormState) => ({ ...prev, disabled: event.target.checked }))} />}
label="Disabled"
/>
<FormControlLabel
control={<Checkbox checked={props.form.totpRequired} onChange={(event) => props.setForm((prev: UserGroupFormState) => ({ ...prev, totpRequired: event.target.checked }))} />}
label="Require TOTP for members"
/>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={props.saving || !props.form.name.trim()}>
{props.saving ? 'Saving...' : 'Save'}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
+226 -2
View File
@@ -16,7 +16,7 @@ import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetails
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import PageAlert from '../components/PageAlert'
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
function fmt(value: number): string {
if (!value || value <= 0) {
@@ -29,6 +29,28 @@ function sessionTitle(item: SSHSession): string {
return `${item.remote_username}@${item.host}:${item.port}`
}
function formatTransferBytes(value: number): string {
if (!value || value < 0) { return '0 B' }
if (value < 1024) { return `${value} B` }
if (value < 1024 * 1024) { return `${(value / 1024).toFixed(1)} KB` }
if (value < 1024 * 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MB` }
return `${(value / (1024 * 1024 * 1024)).toFixed(3)} GB`
}
function formatTransferOperation(value: string): string {
if (value === 'copy_to_session') { return 'Copy' }
if (value === 'upload') { return 'Upload' }
if (value === 'download') { return 'Download' }
return value || '-'
}
function transferPathText(item: SSHFileTransfer): string {
if (Array.isArray(item.paths) && item.paths.length > 0) {
return item.paths.join(', ')
}
return '-'
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
@@ -59,6 +81,16 @@ export default function AdminSSHSessionsPage() {
const [limit, setLimit] = useState(25)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [transferItems, setTransferItems] = useState<SSHFileTransfer[]>([])
const [loadingTransfers, setLoadingTransfers] = useState(false)
const [transferError, setTransferError] = useState<string | null>(null)
const [transferQuery, setTransferQuery] = useState('')
const [transferStatus, setTransferStatus] = useState('')
const [transferSessionID, setTransferSessionID] = useState('')
const [transferUserID, setTransferUserID] = useState('')
const [transferLimit, setTransferLimit] = useState(25)
const [transferOffset, setTransferOffset] = useState(0)
const [transferHasMore, setTransferHasMore] = useState(false)
const load = useCallback(async (signal?: AbortSignal) => {
let profileList: SSHAccessProfile[]
@@ -108,6 +140,34 @@ export default function AdminSSHSessionsPage() {
}
}, [limit, offset, query, status])
const loadTransfers = useCallback(async (signal?: AbortSignal) => {
let response: SSHFileTransferListResponse
let nextQuery: string
setLoadingTransfers(true)
setTransferError(null)
try {
nextQuery = transferQuery.trim()
response = await api.listSSHFileTransfersAdmin({
limit: transferLimit,
offset: transferOffset,
q: nextQuery || undefined,
status: transferStatus || undefined,
session_id: transferSessionID.trim() || undefined,
user_id: transferUserID.trim() || undefined
}, signal ? { signal } : undefined)
setTransferItems(Array.isArray(response.items) ? response.items : [])
setTransferHasMore(Boolean(response.has_more))
} catch (err) {
if (signal?.aborted) { return }
setTransferError(err instanceof Error ? err.message : 'Failed to load SSH transfer history')
setTransferItems([])
setTransferHasMore(false)
} finally {
if (!signal?.aborted) setLoadingTransfers(false)
}
}, [transferLimit, transferOffset, transferQuery, transferSessionID, transferStatus, transferUserID])
useEffect(() => {
const ac: AbortController = new AbortController()
@@ -117,6 +177,15 @@ export default function AdminSSHSessionsPage() {
}
}, [load])
useEffect(() => {
const ac: AbortController = new AbortController()
void loadTransfers(ac.signal)
return () => {
ac.abort()
}
}, [loadTransfers])
const openTranscript = useCallback(async (item: SSHSession) => {
let text: string
@@ -161,7 +230,9 @@ export default function AdminSSHSessionsPage() {
<Typography variant="h5">Admin: SSH Sessions</Typography>
<PageAlert message={error} onClose={() => setError(null)} />
<SectionCard
title="SSH Session History"
collapsible
collapseStorageKey="admin-ssh-sessions-session-history"
title="Session History"
subtitle="All SSH sessions across users."
actions={(
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
@@ -305,6 +376,159 @@ export default function AdminSSHSessionsPage() {
</Box>
</Box>
</SectionCard>
<PageAlert message={transferError} onClose={() => setTransferError(null)} />
<SectionCard
collapsible
collapseStorageKey="admin-ssh-sessions-transfer-history"
title="Transfer History"
subtitle="SSH file uploads, downloads, and copy-to-session operations across users."
actions={(
<Button variant="outlined" size="small" onClick={() => void loadTransfers()} disabled={loadingTransfers}>
{loadingTransfers ? 'Refreshing...' : 'Refresh'}
</Button>
)}
>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Search"
value={transferQuery}
onChange={(event) => {
setTransferQuery(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 240, maxWidth: 360 }}
/>
<TextField
select
size="small"
label="Status"
value={transferStatus}
onChange={(event) => {
setTransferStatus(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 150 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="success">success</MenuItem>
<MenuItem value="failed">failed</MenuItem>
<MenuItem value="cancelled">cancelled</MenuItem>
<MenuItem value="running">running</MenuItem>
</TextField>
<TextField
size="small"
label="Session ID"
value={transferSessionID}
onChange={(event) => {
setTransferSessionID(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 220 }}
/>
<TextField
size="small"
label="User ID"
value={transferUserID}
onChange={(event) => {
setTransferUserID(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 220 }}
/>
<TextField
select
size="small"
label="Page Size"
value={String(transferLimit)}
onChange={(event) => {
setTransferLimit(Number(event.target.value) || 25)
setTransferOffset(0)
}}
sx={{ minWidth: 130 }}
>
<MenuItem value="10">10</MenuItem>
<MenuItem value="25">25</MenuItem>
<MenuItem value="50">50</MenuItem>
<MenuItem value="100">100</MenuItem>
</TextField>
</Box>
{loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary">
No SSH transfer history found.
</Typography>
) : null}
{transferItems.length ? (
<List>
{transferItems.map((item: SSHFileTransfer) => {
const statusColor: 'success' | 'error' | 'warning' | 'default' =
item.status === 'success' ? 'success' :
item.status === 'failed' ? 'error' :
item.status === 'cancelled' ? 'warning' :
'default'
return (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography>
{formatTransferOperation(item.operation)} · {item.server_name || item.server_id || '-'}
{item.target_server_name ? `${item.target_server_name}` : ''}{' '}
<Chip
label={item.status || 'unknown'}
size="small"
color={statusColor}
variant={item.status === 'success' ? 'filled' : 'outlined'}
/>
</Typography>
}
secondary={
<Box sx={{ display: 'grid' }}>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Path: {transferPathText(item)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
User: {item.username || item.user_id} · Bytes: {formatTransferBytes(item.bytes_transferred)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Started: {fmt(item.started_at)} · Finished: {fmt(item.finished_at)} · Duration: {item.duration_ms || 0} ms
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Transfer: {item.id} · Session: {item.session_id}
</Typography>
{item.error ? (
<Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}>
{item.error}
</Typography>
) : null}
</Box>
}
disableTypography
/>
</ListItem>
)
})}
</List>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
Showing {transferItems.length ? transferOffset + 1 : 0}-{transferOffset + transferItems.length}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" onClick={() => setTransferOffset((prev: number) => Math.max(0, prev - transferLimit))} disabled={loadingTransfers || transferOffset <= 0}>
Previous
</Button>
<Button variant="outlined" size="small" onClick={() => setTransferOffset((prev: number) => prev + transferLimit)} disabled={loadingTransfers || !transferHasMore}>
Next
</Button>
</Box>
</Box>
</SectionCard>
<SSHAccessProfileDetailsDialog
item={viewProfile}
onClose={() => setViewProfile(null)}
+120 -85
View File
@@ -1,25 +1,18 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import {
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
List,
ListItem,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
@@ -29,16 +22,34 @@ import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert'
import { api, SubjectPermission, User, UserGroup, UserGroupMember, UserGroupUpsertPayload } from '../api'
import SelectField from '../components/SelectField'
import UserGroupFormDialog, { UserGroupFormState } from '../components/UserGroupFormDialog'
const projectCreatePermission = 'project.create'
const projectCreatePermission: string = 'project.create'
const sshFileUploadPermission: string = 'ssh.file.upload'
const sshFileDownloadPermission: string = 'ssh.file.download'
const sshFileCopyToSessionPermission: string = 'ssh.file.copy_to_session'
const groupOptionPermissions: { permission: string, label: string, chip: string }[] = [
{ permission: projectCreatePermission, label: 'Allow project creation for members', chip: projectCreatePermission },
{ permission: sshFileUploadPermission, label: 'Allow SSH file upload', chip: sshFileUploadPermission },
{ permission: sshFileDownloadPermission, label: 'Allow SSH file download', chip:sshFileDownloadPermission },
{ permission: sshFileCopyToSessionPermission, label: 'Allow SSH file copy between sessions', chip: sshFileCopyToSessionPermission }
]
function fmt(value: number): string {
if (!value || value <= 0) {
return '-'
}
if (!value || value <= 0) return '-'
return new Date(value * 1000).toLocaleString()
}
function defaultUserGroupForm(): UserGroupFormState {
return {
name: '',
description: '',
disabled: false,
totpRequired: false,
scope: 'explicit'
}
}
export default function AdminUserGroupsPage() {
const [groups, setGroups] = useState<UserGroup[]>([])
const [users, setUsers] = useState<User[]>([])
@@ -54,11 +65,7 @@ export default function AdminUserGroupsPage() {
const [editOpen, setEditOpen] = useState(false)
const [editID, setEditID] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [disabled, setDisabled] = useState(false)
const [totpRequired, setTOTPRequired] = useState<boolean>(false)
const [scope, setScope] = useState<'explicit' | 'all_users'>('explicit')
const [form, setForm] = useState<UserGroupFormState>(() => defaultUserGroupForm())
const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
@@ -92,7 +99,7 @@ export default function AdminUserGroupsPage() {
try {
;[list, permissions] = await Promise.all([
api.listUserGroups(),
api.listSubjectPermissions(projectCreatePermission, 'group')
api.listSubjectPermissions('', 'group')
])
setGroups(Array.isArray(list) ? list : [])
setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
@@ -149,22 +156,20 @@ export default function AdminUserGroupsPage() {
const openCreate = () => {
setDialogError(null)
setEditID('')
setName('')
setDescription('')
setDisabled(false)
setTOTPRequired(false)
setScope('explicit')
setForm(defaultUserGroupForm())
setEditOpen(true)
}
const openEdit = (item: UserGroup) => {
setDialogError(null)
setEditID(item.id)
setName(item.name)
setDescription(item.description || '')
setDisabled(item.disabled)
setTOTPRequired(Boolean(item.totp_required))
setScope(item.scope === 'all_users' ? 'all_users' : 'explicit')
setForm({
name: item.name,
description: item.description || '',
disabled: item.disabled,
totpRequired: Boolean(item.totp_required),
scope: item.scope === 'all_users' ? 'all_users' : 'explicit'
})
setEditOpen(true)
}
@@ -174,7 +179,13 @@ export default function AdminUserGroupsPage() {
setDialogError(null)
setError(null)
try {
payload = { name: name.trim(), description: description.trim(), disabled, totp_required: totpRequired, scope }
payload = {
name: form.name.trim(),
description: form.description.trim(),
disabled: form.disabled,
totp_required: form.totpRequired,
scope: form.scope
}
if (editID) {
await api.updateUserGroup(editID, payload)
} else {
@@ -263,11 +274,19 @@ export default function AdminUserGroupsPage() {
return member.username || member.user_id
}
const hasDirectProjectCreate = (groupID: string) => {
return subjectPermissions.some((item) => item.permission === projectCreatePermission && item.subject_id === groupID)
const groupOptionCaption = (groupID: string) => {
const labels: string[] = groupOptionPermissions
.filter((item: { permission: string, label: string, chip: string }) => hasGroupOption(groupID, item.permission))
.map((item: { permission: string, label: string, chip: string }) => item.chip)
return labels.length > 0 ? ` · ${labels.join(', ')}` : ''
}
const toggleSelectedGroupProjectCreate = async (next: boolean) => {
const hasGroupOption = (groupID: string, permission: string) => {
return subjectPermissions.some((item: SubjectPermission) => item.permission === permission && item.subject_id === groupID)
}
const toggleSelectedGroupOption = async (permission: string, next: boolean) => {
if (!selectedGroupID) {
return
}
@@ -276,15 +295,40 @@ export default function AdminUserGroupsPage() {
try {
if (next) {
await api.createSubjectPermissions({
permission: projectCreatePermission,
permission,
targets: [{ subject_type: 'group', subject_id: selectedGroupID }]
})
} else {
await api.deleteSubjectPermission(projectCreatePermission, 'group', selectedGroupID)
await api.deleteSubjectPermission(permission, 'group', selectedGroupID)
}
await loadGroups()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update permissions')
setError(err instanceof Error ? err.message : 'Failed to update group options')
} finally {
setBusy(false)
}
}
const toggleSelectedGroupTOTPRequired = async (next: boolean) => {
let payload: UserGroupUpsertPayload
if (!selectedGroup) {
return
}
setBusy(true)
setError(null)
try {
payload = {
name: selectedGroup.name,
description: selectedGroup.description || '',
disabled: selectedGroup.disabled,
totp_required: next,
scope: selectedGroup.scope === 'all_users' ? 'all_users' : 'explicit'
}
await api.updateUserGroup(selectedGroup.id, payload)
await loadGroups()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group options')
} finally {
setBusy(false)
}
@@ -334,7 +378,7 @@ export default function AdminUserGroupsPage() {
}
secondary={
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'normal', wordBreak: 'break-all' }}>
{group.id} · {group.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(group.updated_at)}{group.totp_required ? ' · TOTP required' : ''}{hasDirectProjectCreate(group.id) ? ' · project.create' : ''}
{group.id} · {group.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(group.updated_at)}{group.totp_required ? ' · TOTP required' : ''}{groupOptionCaption(group.id)}
</Typography>
}
/>
@@ -381,9 +425,11 @@ export default function AdminUserGroupsPage() {
color={selectedGroup.disabled ? 'error' : 'success'}
label={selectedGroup.disabled ? 'Disabled' : 'Active'}
/>
{hasDirectProjectCreate(selectedGroup.id) ? (
<Chip size="small" variant="outlined" label="project.create" />
) : null}
{groupOptionPermissions.map((item: { permission: string, label: string, chip: string }) => (
hasGroupOption(selectedGroup.id, item.permission) ? (
<Chip key={item.permission} size="small" variant="outlined" label={item.chip} />
) : null
))}
{selectedGroup.totp_required ? (
<Chip size="small" variant="outlined" label="TOTP required" />
) : null}
@@ -478,18 +524,33 @@ export default function AdminUserGroupsPage() {
)}
</SectionCard>
<SectionCard title="Global Permissions">
<SectionCard title="Member Options">
{selectedGroup ? (
<FormControlLabel
control={
<Checkbox
checked={hasDirectProjectCreate(selectedGroupID)}
onChange={(event) => toggleSelectedGroupProjectCreate(event.target.checked)}
disabled={busy}
<Box sx={{ display: 'grid' }}>
<FormControlLabel
control={
<Checkbox
checked={Boolean(selectedGroup.totp_required)}
onChange={(event) => toggleSelectedGroupTOTPRequired(event.target.checked)}
disabled={busy}
/>
}
label="Require TOTP for members"
/>
{groupOptionPermissions.map((item: { permission: string, label: string, chip: string }) => (
<FormControlLabel
key={item.permission}
control={
<Checkbox
checked={hasGroupOption(selectedGroupID, item.permission)}
onChange={(event) => toggleSelectedGroupOption(item.permission, event.target.checked)}
disabled={busy}
/>
}
label={item.label}
/>
}
label="Allow project creation for members"
/>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Select a group.
@@ -499,43 +560,17 @@ export default function AdminUserGroupsPage() {
</Box>
</Box>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editID ? 'Edit Group' : 'New Group'}</DialogTitle>
<FormDialogContent>
<PageAlert message={dialogError} onClose={() => setDialogError(null)} />
<TextField label="Name" value={name} onChange={(event) => setName(event.target.value)} />
<TextField
label="Description"
value={description}
onChange={(event) => setDescription(event.target.value)}
multiline
minRows={2}
/>
<SelectField
select
label="Scope"
value={scope}
onChange={(event) => setScope(event.target.value === 'all_users' ? 'all_users' : 'explicit')}
>
<MenuItem value="explicit">Explicit members</MenuItem>
<MenuItem value="all_users">All users</MenuItem>
</SelectField>
<FormControlLabel
control={<Checkbox checked={disabled} onChange={(event) => setDisabled(event.target.checked)} />}
label="Disabled"
/>
<FormControlLabel
control={<Checkbox checked={totpRequired} onChange={(event) => setTOTPRequired(event.target.checked)} />}
label="Require TOTP for members"
/>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={saveGroup} disabled={busy || !name.trim()}>
{busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<UserGroupFormDialog
open={editOpen}
editID={editID}
dialogError={dialogError}
saving={busy}
form={form}
setForm={setForm}
onClose={() => setEditOpen(false)}
onSave={saveGroup}
onClearError={() => setDialogError(null)}
/>
<ConfirmDeleteDialog
open={!!deleteItem}
+261 -3
View File
@@ -28,7 +28,7 @@ import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert'
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
type SSHSessionStreamMessage = {
session_id?: string
@@ -121,6 +121,28 @@ function formatTimestamp(value: number): string {
return new Date(value * 1000).toLocaleString()
}
function formatTransferBytes(value: number): string {
if (!value || value < 0) { return '0 B' }
if (value < 1024) { return `${value} B` }
if (value < 1024 * 1024) { return `${(value / 1024).toFixed(1)} KB` }
if (value < 1024 * 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MB` }
return `${(value / (1024 * 1024 * 1024)).toFixed(3)} GB`
}
function formatTransferOperation(value: string): string {
if (value === 'copy_to_session') { return 'Copy' }
if (value === 'upload') { return 'Upload' }
if (value === 'download') { return 'Download' }
return value || '-'
}
function transferPathText(item: SSHFileTransfer): string {
if (Array.isArray(item.paths) && item.paths.length > 0) {
return item.paths.join(', ')
}
return '-'
}
function buildHistorySessionTitle(session: SSHSession): string {
if (session.server_name) { return `${session.server_name} (${session.remote_username}@${session.host}:${session.port})` }
return `${session.remote_username}@${session.host}:${session.port}`
@@ -750,6 +772,16 @@ export default function SSHSessionPage() {
const [historyOffset, setHistoryOffset] = useState(0)
const [historyHasMore, setHistoryHasMore] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [transferItems, setTransferItems] = useState<SSHFileTransfer[]>([])
const [loadingTransfers, setLoadingTransfers] = useState(false)
const [transferError, setTransferError] = useState<string | null>(null)
const [transferQuery, setTransferQuery] = useState('')
const [transferStatus, setTransferStatus] = useState('')
const [transferSessionFilter, setTransferSessionFilter] = useState('')
const [transferLimit, setTransferLimit] = useState(25)
const [transferOffset, setTransferOffset] = useState(0)
const [transferHasMore, setTransferHasMore] = useState(false)
const [transfersOpen, setTransfersOpen] = useState(false)
const [historyConnectItem, setHistoryConnectItem] = useState<SSHSession | null>(null)
const [historyConnectPassword, setHistoryConnectPassword] = useState('')
const [historyConnectOTPCode, setHistoryConnectOTPCode] = useState('')
@@ -864,6 +896,33 @@ export default function SSHSessionPage() {
}
}, [historyLimit, historyOffset, historyQuery, historyStatus])
const loadTransfers = useCallback(async (signal?: AbortSignal) => {
let response: SSHFileTransferListResponse
let nextQuery: string
setLoadingTransfers(true)
setTransferError(null)
try {
nextQuery = transferQuery.trim()
response = await api.listSSHFileTransfersForSelf({
limit: transferLimit,
offset: transferOffset,
q: nextQuery || undefined,
status: transferStatus || undefined,
session_id: transferSessionFilter || undefined
}, signal ? { signal } : undefined)
setTransferItems(Array.isArray(response.items) ? response.items : [])
setTransferHasMore(Boolean(response.has_more))
} catch (err) {
if (signal?.aborted) { return }
setTransferError(err instanceof Error ? err.message : 'Failed to load SSH file transfer history')
setTransferItems([])
setTransferHasMore(false)
} finally {
if (!signal?.aborted) setLoadingTransfers(false)
}
}, [transferLimit, transferOffset, transferQuery, transferSessionFilter, transferStatus])
const loadProfiles = useCallback(async (signal?: AbortSignal) => {
let items: SSHAccessProfile[]
@@ -886,6 +945,12 @@ export default function SSHSessionPage() {
return () => { ac.abort() }
}, [historyOpen, loadHistory, openSessionIDs.length])
useEffect(() => {
const ac: AbortController = new AbortController()
if (transfersOpen) void loadTransfers(ac.signal)
return () => { ac.abort() }
}, [loadTransfers, openSessionIDs.length, transfersOpen])
const clampSplitRatio = useCallback((value: number) => {
return Math.max(0.25, Math.min(0.75, value))
}, [])
@@ -982,6 +1047,14 @@ export default function SSHSessionPage() {
setHistoryOpen(false)
}, [])
const handleOpenTransfers = useCallback(() => {
setTransfersOpen(true)
}, [])
const handleCloseTransfers = useCallback(() => {
setTransfersOpen(false)
}, [])
const closeHistoryConnectPrompt = useCallback(() => {
if (connectingHistorySession) {
return
@@ -1079,6 +1152,15 @@ export default function SSHSessionPage() {
setHistoryOffset((prev) => prev + historyLimit)
}, [historyHasMore, historyLimit])
const handleTransferPrev = useCallback(() => {
setTransferOffset((prev: number) => Math.max(0, prev - transferLimit))
}, [transferLimit])
const handleTransferNext = useCallback(() => {
if (!transferHasMore) { return }
setTransferOffset((prev: number) => prev + transferLimit)
}, [transferHasMore, transferLimit])
const queueWorkspaceMessage = useCallback((message: SSHSessionStreamMessage) => {
const queueLimit: number = 500
@@ -1469,7 +1551,7 @@ export default function SSHSessionPage() {
const historyContent = (
<Box sx={{ display: 'grid', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 420, maxWidth: '100%', height: '100%', overflow: 'hidden' }}>
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="h6">SSH Session History</Typography>
<Typography variant="h6">Session History</Typography>
<Typography variant="body2" color="text.secondary">
Recent SSH sessions for your account.
</Typography>
@@ -1613,6 +1695,156 @@ export default function SSHSessionPage() {
</Box>
)
const transfersContent = (
<Box sx={{ display: 'grid', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 480, maxWidth: '100%', height: '100%', overflow: 'hidden' }}>
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="h6">Transfer History</Typography>
<Typography variant="body2" color="text.secondary">
Recent upload, download, and copy activity for your account.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Search"
value={transferQuery}
onChange={(event) => {
setTransferQuery(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 220, maxWidth: historyDialogMode ? undefined : 320, flex: '1 1 220px' }}
/>
<TextField
select
size="small"
label="Status"
value={transferStatus}
onChange={(event) => {
setTransferStatus(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 140 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="success">success</MenuItem>
<MenuItem value="failed">failed</MenuItem>
<MenuItem value="cancelled">cancelled</MenuItem>
<MenuItem value="running">running</MenuItem>
</TextField>
<TextField
select
size="small"
label="Session"
value={transferSessionFilter}
onChange={(event) => {
setTransferSessionFilter(event.target.value)
setTransferOffset(0)
}}
sx={{ minWidth: 170 }}
>
<MenuItem value="">All sessions</MenuItem>
{openSessionIDs.map((id: string) => (
<MenuItem key={id} value={id}>
{sessionSummaries[id]?.title || id}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label="Page Size"
value={String(transferLimit)}
onChange={(event) => {
setTransferLimit(Number(event.target.value) || 25)
setTransferOffset(0)
}}
sx={{ minWidth: 130 }}
>
<MenuItem value="10">10</MenuItem>
<MenuItem value="25">25</MenuItem>
<MenuItem value="50">50</MenuItem>
</TextField>
<Button variant="outlined" size="small" onClick={() => void loadTransfers()} disabled={loadingTransfers}>
{loadingTransfers ? 'Refreshing...' : 'Refresh'}
</Button>
</Box>
<PageAlert message={transferError} onClose={() => setTransferError(null)} />
<Box sx={{ display: 'grid', gap: 0, minHeight: 0, flex: '1 1 auto', overflowY: 'auto' }}>
{loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary">
No SSH file transfer history found.
</Typography>
) : null}
{transferItems.map((item: SSHFileTransfer) => {
const statusColor: 'success' | 'error' | 'warning' | 'default' =
item.status === 'success' ? 'success' :
item.status === 'failed' ? 'error' :
item.status === 'cancelled' ? 'warning' :
'default'
return (
<Box
key={item.id}
sx={{
display: 'grid',
gap: 0.5,
py: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
{formatTransferOperation(item.operation)} · {item.server_name || item.server_id || '-'}
{item.target_server_name ? `${item.target_server_name}` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Path: {transferPathText(item)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Bytes: {formatTransferBytes(item.bytes_transferred)} · Started: {formatTimestamp(item.started_at)} · Finished: {formatTimestamp(item.finished_at)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Session: {item.session_id} · User: {item.username || item.user_id}
</Typography>
{item.error ? (
<Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}>
{item.error}
</Typography>
) : null}
</Box>
<Chip
label={item.status}
size="small"
color={statusColor}
variant={item.status === 'success' ? 'filled' : 'outlined'}
/>
</Box>
</Box>
)
})}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
Showing {transferItems.length ? transferOffset + 1 : 0}-{transferOffset + transferItems.length}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" onClick={handleTransferPrev} disabled={loadingTransfers || transferOffset <= 0}>
Previous
</Button>
<Button variant="outlined" size="small" onClick={handleTransferNext} disabled={loadingTransfers || !transferHasMore}>
Next
</Button>
</Box>
</Box>
</Box>
)
return (
<Box sx={{ display: 'grid', gridTemplateRows: 'auto auto minmax(0, 1fr)', gap: 1, height: 'calc(100vh - 76px)', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
@@ -1622,7 +1854,10 @@ export default function SSHSessionPage() {
New Session
</Button>
<Button variant="outlined" onClick={handleOpenHistory}>
History
Session History
</Button>
<Button variant="outlined" onClick={handleOpenTransfers}>
Transfer History
</Button>
<Button variant="outlined" onClick={handleLeave}>
Close
@@ -1850,6 +2085,29 @@ export default function SSHSessionPage() {
</Drawer>
)}
{historyDialogMode ? (
<Dialog open={transfersOpen} onClose={handleCloseTransfers} fullScreen>
{transfersContent}
<DialogActions>
<Button onClick={handleCloseTransfers}>Close</Button>
</DialogActions>
</Dialog>
) : (
<Drawer
anchor="right"
open={transfersOpen}
onClose={handleCloseTransfers}
PaperProps={{
sx: {
top: { xs: '56px', sm: '64px' },
height: { xs: 'calc(100% - 56px)', sm: 'calc(100% - 64px)' }
}
}}
>
{transfersContent}
</Drawer>
)}
<SSHCredentialsPromptDialog
open={Boolean(historyConnectItem)}
targetText={historyConnectItem ? buildHistorySessionTitle(historyConnectItem) : 'Enter the password for this SSH access profile.'}