Files
codit/backend/internal/handlers/ssh_principal_grant.go

390 lines
14 KiB
Go

package handlers
import "database/sql"
import "errors"
import "net/http"
import "strings"
import "time"
import "codit/internal/middleware"
import "codit/internal/models"
import "codit/internal/util"
type sshPrincipalGrantTargetRequest struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
}
type sshPrincipalGrantRequest struct {
Name string `json:"name"`
Principal string `json:"principal"`
Principals []string `json:"principals"`
ValidAfter int64 `json:"valid_after"`
ValidBefore int64 `json:"valid_before"`
MaxCertValidSeconds int64 `json:"max_cert_valid_seconds"`
MaxUses int64 `json:"max_uses"`
Disabled bool `json:"disabled"`
Reason string `json:"reason"`
Targets []sshPrincipalGrantTargetRequest `json:"targets"`
}
type sshPrincipalGrantTargetSummary struct {
GrantID string `json:"grant_id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
TargetActive bool `json:"target_active"`
CreatedAt int64 `json:"created_at"`
}
type sshPrincipalGrantSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Principal string `json:"principal"`
Principals []string `json:"principals"`
ValidAfter int64 `json:"valid_after"`
ValidBefore int64 `json:"valid_before"`
MaxCertValidSeconds int64 `json:"max_cert_valid_seconds"`
MaxUses int64 `json:"max_uses"`
UsedCount int64 `json:"used_count"`
Disabled bool `json:"disabled"`
Reason string `json:"reason"`
CreatedByUserID string `json:"created_by_user_id"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastUsedAt int64 `json:"last_used_at"`
Targets []sshPrincipalGrantTargetSummary `json:"targets"`
}
func (api *API) ListSSHPrincipalGrants(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var targetType string
var targetID string
var items []models.SSHPrincipalGrant
var out []sshPrincipalGrantSummary
var i int
var err error
if !api.requireAdmin(w, r) {
return
}
targetType = strings.TrimSpace(r.URL.Query().Get("target_type"))
targetID = strings.TrimSpace(r.URL.Query().Get("target_id"))
items, err = api.store(r).ListSSHPrincipalGrants(targetType, targetID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
for i = 0; i < len(items); i++ {
out = append(out, buildSSHPrincipalGrantSummary(items[i]))
}
WriteJSON(w, http.StatusOK, out)
}
func (api *API) ListSSHPrincipalGrantsForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var user models.User
var ok bool
var items []models.SSHPrincipalGrant
var out []sshPrincipalGrantSummary
var now int64
var i int
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
now = time.Now().UTC().Unix()
items, err = api.store(r).ListActiveSSHPrincipalGrantsForUser(user.ID, now)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
for i = 0; i < len(items); i++ {
out = append(out, buildSSHPrincipalGrantSummary(items[i]))
}
WriteJSON(w, http.StatusOK, out)
}
func (api *API) CreateSSHPrincipalGrant(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req sshPrincipalGrantRequest
var currentUser models.User
var hasCurrentUser bool
var name string
var principals []string
var maxCertValidSeconds int64
var maxUses int64
var targets []models.SSHPrincipalGrantTarget
var item models.SSHPrincipalGrant
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
name, principals, err = normalizeSSHPrincipalGrantIdentity(req.Name, req.Principal, req.Principals)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
maxCertValidSeconds, err = normalizeSSHPrincipalGrantMaxCertValidSeconds(req.MaxCertValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
maxUses, err = normalizeSSHPrincipalGrantMaxUses(req.MaxUses)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
err = validateSSHPrincipalGrantWindow(req.ValidAfter, req.ValidBefore)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
targets, err = normalizeSSHPrincipalGrantTargets(req.Targets)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
currentUser, hasCurrentUser = middleware.UserFromContext(r.Context())
item = models.SSHPrincipalGrant{
Name: name,
Principals: principals,
ValidAfter: req.ValidAfter,
ValidBefore: req.ValidBefore,
MaxCertValidSeconds: maxCertValidSeconds,
MaxUses: maxUses,
Disabled: req.Disabled,
Reason: strings.TrimSpace(req.Reason),
Targets: targets,
}
if hasCurrentUser {
item.CreatedByUserID = currentUser.ID
}
item, err = api.store(r).CreateSSHPrincipalGrant(item)
if err != nil {
api.Logger.Write(logIDSSHCA, util.LOG_WARN, "grant create failed name=%s err=%v", name, err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
api.Logger.Write(logIDSSHCA, util.LOG_INFO, "grant create success id=%s name=%s principals=%d targets=%d", item.ID, item.Name, len(item.Principals), len(item.Targets))
WriteJSON(w, http.StatusCreated, buildSSHPrincipalGrantSummary(item))
}
func (api *API) UpdateSSHPrincipalGrant(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req sshPrincipalGrantRequest
var existing models.SSHPrincipalGrant
var name string
var principals []string
var maxCertValidSeconds int64
var maxUses int64
var targets []models.SSHPrincipalGrantTarget
var item models.SSHPrincipalGrant
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
existing, err = api.store(r).GetSSHPrincipalGrant(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "principal grant not found"})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
name, principals, err = normalizeSSHPrincipalGrantIdentity(req.Name, req.Principal, req.Principals)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
maxCertValidSeconds, err = normalizeSSHPrincipalGrantMaxCertValidSeconds(req.MaxCertValidSeconds)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
maxUses, err = normalizeSSHPrincipalGrantMaxUses(req.MaxUses)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
err = validateSSHPrincipalGrantWindow(req.ValidAfter, req.ValidBefore)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
targets, err = normalizeSSHPrincipalGrantTargets(req.Targets)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
item = existing
item.Name = name
item.Principals = principals
item.ValidAfter = req.ValidAfter
item.ValidBefore = req.ValidBefore
item.MaxCertValidSeconds = maxCertValidSeconds
item.MaxUses = maxUses
item.Disabled = req.Disabled
item.Reason = strings.TrimSpace(req.Reason)
item.Targets = targets
item, err = api.store(r).UpdateSSHPrincipalGrant(item)
if err != nil {
api.Logger.Write(logIDSSHCA, util.LOG_WARN, "grant update failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
api.Logger.Write(logIDSSHCA, util.LOG_INFO, "grant update success id=%s name=%s principals=%d targets=%d", item.ID, item.Name, len(item.Principals), len(item.Targets))
WriteJSON(w, http.StatusOK, buildSSHPrincipalGrantSummary(item))
}
func (api *API) DeleteSSHPrincipalGrant(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
err = api.store(r).DeleteSSHPrincipalGrant(params["id"])
if err != nil {
api.Logger.Write(logIDSSHCA, util.LOG_WARN, "grant delete failed id=%s err=%v", params["id"], err)
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
api.Logger.Write(logIDSSHCA, util.LOG_INFO, "grant delete success id=%s", params["id"])
WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func buildSSHPrincipalGrantSummary(item models.SSHPrincipalGrant) sshPrincipalGrantSummary {
var outTargets []sshPrincipalGrantTargetSummary
var i int
for i = 0; i < len(item.Targets); i++ {
outTargets = append(outTargets, sshPrincipalGrantTargetSummary{
GrantID: item.Targets[i].GrantID,
TargetType: item.Targets[i].TargetType,
TargetID: item.Targets[i].TargetID,
TargetName: item.Targets[i].TargetName,
TargetActive: item.Targets[i].TargetActive,
CreatedAt: item.Targets[i].CreatedAt,
})
}
return sshPrincipalGrantSummary{
ID: item.ID,
Name: item.Name,
Principal: item.Name,
Principals: item.Principals,
ValidAfter: item.ValidAfter,
ValidBefore: item.ValidBefore,
MaxCertValidSeconds: item.MaxCertValidSeconds,
MaxUses: item.MaxUses,
UsedCount: item.UsedCount,
Disabled: item.Disabled,
Reason: item.Reason,
CreatedByUserID: item.CreatedByUserID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
LastUsedAt: item.LastUsedAt,
Targets: outTargets,
}
}
func normalizeSSHPrincipalGrantIdentity(rawName string, rawPrincipal string, rawPrincipals []string) (string, []string, error) {
var name string
var principals []string
var i int
name = strings.TrimSpace(rawName)
if name == "" {
name = strings.TrimSpace(rawPrincipal)
}
principals = normalizeSSHPrincipals(rawPrincipals)
if len(principals) == 0 {
if strings.TrimSpace(rawPrincipal) != "" {
principals = []string{strings.TrimSpace(rawPrincipal)}
}
}
if name == "" {
return "", nil, errors.New("name is required")
}
if len(principals) == 0 {
return "", nil, errors.New("at least one principal is required")
}
if strings.ContainsAny(name, "\t\r\n") {
return "", nil, errors.New("name must not contain tabs or newlines")
}
for i = 0; i < len(principals); i++ {
if strings.ContainsAny(principals[i], " \t\r\n,") {
return "", nil, errors.New("principal names must not contain spaces or commas")
}
}
return name, principals, nil
}
func normalizeSSHPrincipalGrantMaxCertValidSeconds(raw int64) (int64, error) {
var value int64
value = raw
if value < 0 {
return 0, errors.New("max_cert_valid_seconds must be 0 or between 60 and 604800")
}
if value == 0 {
return 0, nil
}
if value < 60 || value > 604800 {
return 0, errors.New("max_cert_valid_seconds must be 0 or between 60 and 604800")
}
return value, nil
}
func normalizeSSHPrincipalGrantMaxUses(raw int64) (int64, error) {
var value int64
value = raw
if value < 0 {
return 0, errors.New("max_uses must be 0 or greater")
}
return value, nil
}
func validateSSHPrincipalGrantWindow(validAfter int64, validBefore int64) error {
if validAfter > 0 && validBefore > 0 && validAfter > validBefore {
return errors.New("valid_after must be less than or equal to valid_before")
}
return nil
}
func normalizeSSHPrincipalGrantTargets(raw []sshPrincipalGrantTargetRequest) ([]models.SSHPrincipalGrantTarget, error) {
var out []models.SSHPrincipalGrantTarget
var dedupe map[string]bool
var key string
var targetType string
var targetID string
var i int
dedupe = map[string]bool{}
for i = 0; i < len(raw); i++ {
targetType = strings.ToLower(strings.TrimSpace(raw[i].TargetType))
targetID = strings.TrimSpace(raw[i].TargetID)
if targetType != "user" && targetType != "group" {
return nil, errors.New("target_type must be user or group")
}
if targetID == "" {
return nil, errors.New("target_id is required")
}
key = targetType + ":" + targetID
if dedupe[key] {
continue
}
dedupe[key] = true
out = append(out, models.SSHPrincipalGrantTarget{
TargetType: targetType,
TargetID: targetID,
})
}
if len(out) == 0 {
return nil, errors.New("at least one target is required")
}
return out, nil
}