package storage import ( "context" "database/sql" "errors" "fmt" "strings" "time" "tug/internal/platform/sshkey" ) var ( ErrSSHKeyTaken = sshkey.ErrTaken ErrSSHKeyOwned = sshkey.ErrOwned ErrSSHKeyLimit = sshkey.ErrLimit ErrSSHKeyNotFound = errors.New("storage: the account holds no such SSH key") ErrSSHKeyDisabled = errors.New("storage: an SSH key cannot be added to a disabled account") ErrSSHKeyPending = errors.New("storage: an SSH key cannot be added to an account waiting for its confirmation") ) type SSHKey struct { ID string UserID string Fingerprint string Type string Data []byte Label string AddedAt time.Time } func (k SSHKey) Authorized() string { return sshkey.Authorized(k.Type, k.Data) } type AddSSHKeyInput struct { UserID string Key sshkey.Key Label string SessionID string } const sshKeyColumns = `id, user_id, fingerprint, key_type, key_data, label, added_at` func CheckSSHKeyLabel(value string) error { if err := validateDisplayName(value); err != nil { return fmt.Errorf("%w: %w", sshkey.ErrLabel, err) } return nil } func (s *Store) AddSSHKeyAudited(ctx context.Context, input AddSSHKeyInput, actor string) (SSHKey, error) { const operation = "add ssh key" if err := requireValue("user id", input.UserID); err != nil { return SSHKey{}, invalid(operation, err) } if err := sshkey.Check(input.Key.Type, input.Key.Data); err != nil { return SSHKey{}, invalid(operation, err) } key := sshkey.Key{Type: input.Key.Type, Data: input.Key.Data, Fingerprint: sshkey.Fingerprint(input.Key.Data)} label := strings.TrimSpace(input.Label) if err := CheckSSHKeyLabel(label); err != nil { return SSHKey{}, invalid(operation, err) } if label == "" && CheckSSHKeyLabel(input.Key.Comment) == nil { label = input.Key.Comment } id, err := s.newID() if err != nil { return SSHKey{}, fmt.Errorf("%s: %w", operation, err) } var added SSHKey err = s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { username, err := sshKeyHolder(ctx, tx, operation, input.UserID) if err != nil { return err } if input.SessionID != "" { if err := requireLiveSession(ctx, tx, operation, input.SessionID, input.UserID, at); err != nil { return err } } var owner string err = tx.QueryRowContext(ctx, `SELECT user_id FROM ssh_keys WHERE fingerprint = ?`, key.Fingerprint).Scan(&owner) switch { case err == nil && owner == input.UserID: return fmt.Errorf("%s: %w", operation, ErrSSHKeyOwned) case err == nil: return fmt.Errorf("%s: %w", operation, ErrSSHKeyTaken) case !errors.Is(err, sql.ErrNoRows): return classify(operation, err) } var held int if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM ssh_keys WHERE user_id = ?`, input.UserID).Scan(&held); err != nil { return classify(operation, err) } if held >= sshkey.MaxPerAccount { return fmt.Errorf("%s: %w", operation, ErrSSHKeyLimit) } added = SSHKey{ID: id, UserID: input.UserID, Fingerprint: key.Fingerprint, Type: key.Type, Data: key.Data, Label: label, AddedAt: at} if _, err := tx.ExecContext(ctx, `INSERT INTO ssh_keys (`+sshKeyColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`, added.ID, added.UserID, added.Fingerprint, added.Type, added.Data, added.Label, timeToInt(at)); err != nil { return classify(operation, err) } return writeAudit(ctx, tx, operation, actor, "sshkey.add", input.UserID, at, sshKeyFields(username, added)) }) if err != nil { return SSHKey{}, err } return added, nil } func (s *Store) RemoveSSHKeyAudited(ctx context.Context, userID, keyID, sessionID, actor string) (SSHKey, error) { const operation = "remove ssh key" var removed SSHKey err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { username, _, err := userNameAndUID(ctx, tx, operation, userID) if err != nil { return err } if sessionID != "" { if err := requireLiveSession(ctx, tx, operation, sessionID, userID, at); err != nil { return err } } row := tx.QueryRowContext(ctx, `DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING `+sshKeyColumns, keyID, userID) removed, err = scanSSHKey(row) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%s: %w", operation, ErrSSHKeyNotFound) case err != nil: return classify(operation, err) } return writeAudit(ctx, tx, operation, actor, "sshkey.remove", userID, at, sshKeyFields(username, removed)) }) if err != nil { return SSHKey{}, err } return removed, nil } func (s *Store) SSHKeysOf(ctx context.Context, userID string) ([]SSHKey, error) { return s.listSSHKeys(ctx, "list ssh keys of user", `SELECT `+sshKeyColumns+` FROM ssh_keys WHERE user_id = ? ORDER BY added_at, id`, userID) } func (s *Store) ListSSHKeys(ctx context.Context) ([]SSHKey, error) { return s.listSSHKeys(ctx, "list ssh keys", `SELECT `+sshKeyColumns+` FROM ssh_keys ORDER BY user_id, added_at, id`) } func (s *Store) listSSHKeys(ctx context.Context, operation, query string, args ...any) ([]SSHKey, error) { if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, query, args...) if err != nil { return nil, classify(operation, err) } defer rows.Close() var keys []SSHKey for rows.Next() { k, err := scanSSHKey(rows) if err != nil { return nil, classify(operation, err) } keys = append(keys, k) } if err := rows.Err(); err != nil { return nil, classify(operation, err) } return keys, nil } func scanSSHKey(row interface{ Scan(...any) error }) (SSHKey, error) { var k SSHKey var added int64 if err := row.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Type, &k.Data, &k.Label, &added); err != nil { return SSHKey{}, err } k.AddedAt = intToTime(added) return k, nil } func sshKeyFields(username string, k SSHKey) map[string]any { return map[string]any{"username": username, "fingerprint": k.Fingerprint, "key_type": k.Type} } func sshKeyHolder(ctx context.Context, q execer, operation, userID string) (string, error) { var username string var disabled, pending int err := q.QueryRowContext(ctx, `SELECT username, disabled, EXISTS (SELECT 1 FROM pending_confirmations WHERE user_id = users.id) FROM users WHERE id = ?`, userID).Scan(&username, &disabled, &pending) switch { case errors.Is(err, sql.ErrNoRows): return "", fmt.Errorf("%s: %w", operation, ErrNotFound) case err != nil: return "", classify(operation, err) case pending != 0: return "", fmt.Errorf("%s: %w", operation, ErrSSHKeyPending) case disabled != 0: return "", fmt.Errorf("%s: %w", operation, ErrSSHKeyDisabled) } return username, nil }