Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupData contains all exportable data from the database
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Users []map[string]interface{} `json:"users"`
|
||||
SSHKeys []map[string]interface{} `json:"ssh_keys"`
|
||||
Servers []map[string]interface{} `json:"servers"`
|
||||
ServerGroups []map[string]interface{} `json:"server_groups"`
|
||||
GroupMembers []map[string]interface{} `json:"server_group_members"`
|
||||
KeyDeployments []map[string]interface{} `json:"key_deployments"`
|
||||
AuditLog []map[string]interface{} `json:"audit_log"`
|
||||
Settings []map[string]interface{} `json:"settings"`
|
||||
AccessAssign []map[string]interface{} `json:"access_assignments"`
|
||||
CronJobs []map[string]interface{} `json:"cron_jobs"`
|
||||
}
|
||||
|
||||
// ExportAll exports all database tables to a BackupData struct
|
||||
func (d *DB) ExportAll() (*BackupData, error) {
|
||||
backup := &BackupData{
|
||||
Version: "1",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
tables := []struct {
|
||||
query string
|
||||
dest *[]map[string]interface{}
|
||||
}{
|
||||
{`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY id`, &backup.Users},
|
||||
{`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, passphrase_enc, created_at FROM ssh_keys ORDER BY id`, &backup.SSHKeys},
|
||||
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
|
||||
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
|
||||
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
|
||||
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
|
||||
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
|
||||
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
|
||||
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
|
||||
{`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action, created_at FROM cron_jobs ORDER BY id`, &backup.CronJobs},
|
||||
}
|
||||
|
||||
for _, t := range tables {
|
||||
rows, err := d.Query(t.query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query table: %w", err)
|
||||
}
|
||||
data, err := rowsToMaps(rows)
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read rows: %w", err)
|
||||
}
|
||||
*t.dest = data
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// ImportAll restores all database tables from a BackupData struct.
|
||||
// It clears existing data and replaces it with the backup data.
|
||||
func (d *DB) ImportAll(backup *BackupData) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Disable foreign key checks during import
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("failed to disable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Clear all tables in dependency order
|
||||
clearOrder := []string{
|
||||
"cron_jobs",
|
||||
"access_assignments",
|
||||
"key_deployments",
|
||||
"server_group_members",
|
||||
"server_groups",
|
||||
"ssh_keys",
|
||||
"servers",
|
||||
"audit_log",
|
||||
"settings",
|
||||
"users",
|
||||
}
|
||||
for _, table := range clearOrder {
|
||||
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
|
||||
return fmt.Errorf("failed to clear table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Import tables in reverse dependency order (parents first)
|
||||
importOrder := []struct {
|
||||
table string
|
||||
columns []string
|
||||
data []map[string]interface{}
|
||||
}{
|
||||
{"users", []string{"id", "username", "email", "password_hash", "role", "mfa_enabled", "mfa_secret", "theme", "email_notify_login", "avatar_base64", "must_change_password", "failed_login_attempts", "locked_until", "last_login_at", "created_at", "updated_at"}, backup.Users},
|
||||
{"ssh_keys", []string{"id", "user_id", "name", "key_type", "bits", "fingerprint", "public_key", "private_key_enc", "passphrase_enc", "created_at"}, backup.SSHKeys},
|
||||
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
|
||||
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
|
||||
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
|
||||
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
|
||||
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
|
||||
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
|
||||
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
|
||||
{"cron_jobs", []string{"id", "user_id", "name", "ssh_key_id", "server_id", "group_id", "schedule", "scheduled_at", "next_run", "last_run", "remove_after_min", "status", "message", "timezone", "time_of_day", "day_of_week", "day_of_month", "minute_of_hour", "target_user_id", "system_user", "sudo", "create_user", "initial_password", "expiry_action", "created_at"}, backup.CronJobs},
|
||||
}
|
||||
|
||||
for _, imp := range importOrder {
|
||||
if len(imp.data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build INSERT statement
|
||||
placeholders := ""
|
||||
for i := range imp.columns {
|
||||
if i > 0 {
|
||||
placeholders += ", "
|
||||
}
|
||||
placeholders += "?"
|
||||
}
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", imp.table, joinColumns(imp.columns), placeholders)
|
||||
|
||||
stmt, err := tx.Prepare(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare insert for %s: %w", imp.table, err)
|
||||
}
|
||||
|
||||
for _, row := range imp.data {
|
||||
args := make([]interface{}, len(imp.columns))
|
||||
for i, col := range imp.columns {
|
||||
args[i] = row[col]
|
||||
}
|
||||
if _, err := stmt.Exec(args...); err != nil {
|
||||
stmt.Close()
|
||||
return fmt.Errorf("failed to insert into %s: %w", imp.table, err)
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("failed to re-enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptBackup encrypts JSON backup data with AES-256-GCM using the given password
|
||||
func EncryptBackup(data []byte, password string) ([]byte, error) {
|
||||
key := sha256.Sum256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Prepend a magic header so we can identify backup files
|
||||
magic := []byte("KWBAK1") // Keywarden Backup v1
|
||||
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
||||
result := append(magic, ciphertext...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptBackup decrypts an AES-256-GCM encrypted backup with the given password
|
||||
func DecryptBackup(encrypted []byte, password string) ([]byte, error) {
|
||||
// Check magic header
|
||||
magic := []byte("KWBAK1")
|
||||
if len(encrypted) < len(magic) {
|
||||
return nil, fmt.Errorf("invalid backup file: too short")
|
||||
}
|
||||
if string(encrypted[:len(magic)]) != string(magic) {
|
||||
return nil, fmt.Errorf("invalid backup file: wrong format")
|
||||
}
|
||||
encrypted = encrypted[len(magic):]
|
||||
|
||||
key := sha256.Sum256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(encrypted) < nonceSize {
|
||||
return nil, fmt.Errorf("invalid backup file: ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decryption failed: wrong password or corrupted file")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// rowsToMaps converts sql.Rows to a slice of maps
|
||||
func rowsToMaps(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
// Convert byte slices to strings for JSON serialization
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// joinColumns joins column names with commas
|
||||
func joinColumns(cols []string) string {
|
||||
result := ""
|
||||
for i, col := range cols {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += col
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseBackupJSON parses decrypted JSON data into a BackupData struct
|
||||
func ParseBackupJSON(data []byte) (*BackupData, error) {
|
||||
var backup BackupData
|
||||
if err := json.Unmarshal(data, &backup); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse backup data: %w", err)
|
||||
}
|
||||
if backup.Version == "" {
|
||||
return nil, fmt.Errorf("invalid backup: missing version")
|
||||
}
|
||||
return &backup, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptBackup(t *testing.T) {
|
||||
password := "TestP@ssw0rd123!"
|
||||
original := &BackupData{
|
||||
Version: "1",
|
||||
CreatedAt: "2026-04-05T12:00:00Z",
|
||||
Users: []map[string]interface{}{
|
||||
{"id": float64(1), "username": "admin", "email": "admin@test.local"},
|
||||
},
|
||||
SSHKeys: []map[string]interface{}{},
|
||||
Servers: []map[string]interface{}{},
|
||||
ServerGroups: []map[string]interface{}{},
|
||||
GroupMembers: []map[string]interface{}{},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
encrypted, err := EncryptBackup(jsonData, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Check magic header
|
||||
if string(encrypted[:6]) != "KWBAK1" {
|
||||
t.Error("Missing KWBAK1 magic header")
|
||||
}
|
||||
|
||||
// Decrypt with correct password
|
||||
decrypted, err := DecryptBackup(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify content matches
|
||||
var restored BackupData
|
||||
if err := json.Unmarshal(decrypted, &restored); err != nil {
|
||||
t.Fatalf("Failed to unmarshal decrypted data: %v", err)
|
||||
}
|
||||
|
||||
if restored.Version != original.Version {
|
||||
t.Errorf("Version mismatch: got %s, want %s", restored.Version, original.Version)
|
||||
}
|
||||
if len(restored.Users) != 1 {
|
||||
t.Errorf("Expected 1 user, got %d", len(restored.Users))
|
||||
}
|
||||
|
||||
// Wrong password should fail
|
||||
_, err = DecryptBackup(encrypted, "wrong-password")
|
||||
if err == nil {
|
||||
t.Error("Expected error with wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptBackupInvalidFile(t *testing.T) {
|
||||
_, err := DecryptBackup([]byte("invalid data"), "password")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid file, got nil")
|
||||
}
|
||||
|
||||
_, err = DecryptBackup([]byte("short"), "password")
|
||||
if err == nil {
|
||||
t.Error("Expected error for too-short data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBackupJSON(t *testing.T) {
|
||||
valid := `{"version":"1","created_at":"2026-04-05T12:00:00Z","users":[],"ssh_keys":[],"servers":[],"server_groups":[],"server_group_members":[],"key_deployments":[],"audit_log":[],"settings":[],"access_assignments":[],"cron_jobs":[]}`
|
||||
backup, err := ParseBackupJSON([]byte(valid))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse valid JSON: %v", err)
|
||||
}
|
||||
if backup.Version != "1" {
|
||||
t.Errorf("Version mismatch: got %s, want 1", backup.Version)
|
||||
}
|
||||
|
||||
// Missing version
|
||||
invalid := `{"created_at":"2026-04-05T12:00:00Z"}`
|
||||
_, err = ParseBackupJSON([]byte(invalid))
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing version, got nil")
|
||||
}
|
||||
|
||||
// Invalid JSON
|
||||
_, err = ParseBackupJSON([]byte("not json"))
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB wraps the sql.DB connection
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// New creates a new database connection and runs migrations
|
||||
func New(dbPath string) (*DB, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
d := &DB{db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// migrate creates all required tables
|
||||
func (d *DB) migrate() error {
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
bits INTEGER,
|
||||
fingerprint TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc TEXT NOT NULL,
|
||||
passphrase_enc TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS key_deployments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ssh_key_id INTEGER NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
message TEXT,
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
// Migration: add MFA columns to users if not present
|
||||
`CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS server_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS server_group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (group_id) REFERENCES server_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE(group_id, server_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS access_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
ssh_key_id INTEGER NOT NULL,
|
||||
server_id INTEGER DEFAULT 0,
|
||||
group_id INTEGER DEFAULT 0,
|
||||
system_user TEXT NOT NULL,
|
||||
desired_state TEXT NOT NULL DEFAULT 'present',
|
||||
sudo INTEGER NOT NULL DEFAULT 0,
|
||||
create_user INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_sync_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
ssh_key_id INTEGER NOT NULL,
|
||||
server_id INTEGER DEFAULT 0,
|
||||
group_id INTEGER DEFAULT 0,
|
||||
schedule TEXT NOT NULL DEFAULT 'once',
|
||||
scheduled_at DATETIME NOT NULL,
|
||||
next_run DATETIME NOT NULL,
|
||||
last_run DATETIME,
|
||||
remove_after_min INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := d.Exec(m); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional migrations (ALTER TABLE)
|
||||
alterMigrations := map[string]string{
|
||||
"add_mfa_enabled": `ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_mfa_secret": `ALTER TABLE users ADD COLUMN mfa_secret TEXT DEFAULT ''`,
|
||||
"add_is_master_key": `ALTER TABLE ssh_keys ADD COLUMN is_master INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_user_theme": `ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto'`,
|
||||
"add_email_notify_login": `ALTER TABLE users ADD COLUMN email_notify_login INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_avatar_base64": `ALTER TABLE users ADD COLUMN avatar_base64 TEXT NOT NULL DEFAULT ''`,
|
||||
"add_cron_auth_key_id": `ALTER TABLE cron_jobs ADD COLUMN auth_key_id INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_initial_password": `ALTER TABLE access_assignments ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
|
||||
"add_cron_timezone": `ALTER TABLE cron_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`,
|
||||
"add_cron_time_of_day": `ALTER TABLE cron_jobs ADD COLUMN time_of_day TEXT NOT NULL DEFAULT '00:00'`,
|
||||
"add_cron_day_of_week": `ALTER TABLE cron_jobs ADD COLUMN day_of_week INTEGER NOT NULL DEFAULT -1`,
|
||||
"add_cron_day_of_month": `ALTER TABLE cron_jobs ADD COLUMN day_of_month INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_minute_of_hour": `ALTER TABLE cron_jobs ADD COLUMN minute_of_hour INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_target_user_id": `ALTER TABLE cron_jobs ADD COLUMN target_user_id INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_assignment_id": `ALTER TABLE cron_jobs ADD COLUMN assignment_id INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_system_user": `ALTER TABLE cron_jobs ADD COLUMN system_user TEXT NOT NULL DEFAULT ''`,
|
||||
"add_cron_sudo": `ALTER TABLE cron_jobs ADD COLUMN sudo INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_create_user": `ALTER TABLE cron_jobs ADD COLUMN create_user INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_cron_init_password": `ALTER TABLE cron_jobs ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
|
||||
"add_cron_expiry_action": `ALTER TABLE cron_jobs ADD COLUMN expiry_action TEXT NOT NULL DEFAULT 'remove_key'`,
|
||||
"add_must_change_password": `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_failed_login_attempts": `ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0`,
|
||||
"add_locked_until": `ALTER TABLE users ADD COLUMN locked_until DATETIME`,
|
||||
"add_last_login_at": `ALTER TABLE users ADD COLUMN last_login_at DATETIME`,
|
||||
}
|
||||
|
||||
// Invitation tokens table (created via migration to avoid altering initial schema)
|
||||
inviteTableMigration := map[string]string{
|
||||
"create_invitation_tokens": `CREATE TABLE IF NOT EXISTS invitation_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
}
|
||||
|
||||
for name, sql := range inviteTableMigration {
|
||||
var count int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
|
||||
if count == 0 {
|
||||
if _, err := d.Exec(sql); err != nil {
|
||||
return fmt.Errorf("migration %s failed: %w", name, err)
|
||||
}
|
||||
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
|
||||
}
|
||||
}
|
||||
|
||||
for name, sql := range alterMigrations {
|
||||
var count int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
|
||||
if count == 0 {
|
||||
d.Exec(sql) // ignore error if column already exists
|
||||
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Role model migration: promote first admin to owner if no owner exists yet
|
||||
{
|
||||
var migCount int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'promote_admin_to_owner'`).Scan(&migCount)
|
||||
if migCount == 0 {
|
||||
var ownerCount int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'owner'`).Scan(&ownerCount)
|
||||
if ownerCount == 0 {
|
||||
// Find the first admin (by ID) and promote to owner
|
||||
var firstAdminID int64
|
||||
err := d.QueryRow(`SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1`).Scan(&firstAdminID)
|
||||
if err == nil && firstAdminID > 0 {
|
||||
d.Exec(`UPDATE users SET role = 'owner' WHERE id = ?`, firstAdminID)
|
||||
}
|
||||
}
|
||||
d.Exec(`INSERT INTO _migrations (name) VALUES ('promote_admin_to_owner')`)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//go:build integration
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDatabase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Verify file was created
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Fatal("Database file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsCreateTables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
tables := []string{"users", "ssh_keys", "servers", "key_deployments", "audit_log", "settings", "_migrations"}
|
||||
for _, table := range tables {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check table %s: %v", table, err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("Table %q was not created", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsIdempotent(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Run migrations twice (opening creates + migrates)
|
||||
db1, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() first call failed: %v", err)
|
||||
}
|
||||
db1.Close()
|
||||
|
||||
db2, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() second call failed (migrations should be idempotent): %v", err)
|
||||
}
|
||||
defer db2.Close()
|
||||
}
|
||||
|
||||
func TestAlterMigrationsTracked(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check that alter migrations are tracked
|
||||
expectedMigrations := []string{"add_mfa_enabled", "add_mfa_secret", "add_is_master_key"}
|
||||
for _, name := range expectedMigrations {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check migration %s: %v", name, err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("Migration %q was not tracked", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAndQueryUser(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash123", "user",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert user failed: %v", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
if id == 0 {
|
||||
t.Fatal("Expected non-zero insert ID")
|
||||
}
|
||||
|
||||
var username string
|
||||
err = db.QueryRow(`SELECT username FROM users WHERE id = ?`, id).Scan(&username)
|
||||
if err != nil {
|
||||
t.Fatalf("Query user failed: %v", err)
|
||||
}
|
||||
if username != "testuser" {
|
||||
t.Fatalf("Expected username 'testuser', got %q", username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKeyWithMasterFlag(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create a user first
|
||||
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash", "user")
|
||||
|
||||
// Insert a master key
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, is_master)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, "Master Key", "ed25519", 256, "SHA256:test", "pubkey", "encpriv", 1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert master key failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify is_master flag
|
||||
var isMaster int
|
||||
err = db.QueryRow(`SELECT is_master FROM ssh_keys WHERE user_id = 1 AND is_master = 1`).Scan(&isMaster)
|
||||
if err != nil {
|
||||
t.Fatalf("Query master key failed: %v", err)
|
||||
}
|
||||
if isMaster != 1 {
|
||||
t.Fatalf("Expected is_master=1, got %d", isMaster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeyCascade(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert user and key
|
||||
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash", "user")
|
||||
db.Exec(`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, 1, "Test", "ed25519", 256, "fp", "pub", "priv")
|
||||
|
||||
// Delete user — key should cascade
|
||||
db.Exec(`DELETE FROM users WHERE id = 1`)
|
||||
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM ssh_keys WHERE user_id = 1`).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("Expected 0 keys after user deletion (cascade), got %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user