From e36acfbde2ebafbd9fe7025c3ebedfc959de6404 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 16 Nov 2025 21:54:28 -0800 Subject: [PATCH] Checkpoint sql work. --- data/migrate.go | 44 ++++++++++ data/store.go | 156 ++++++++++++++++++++++++++++++++++++ data/store_test.go | 61 ++++++++++++++ go.mod | 16 +++- go.sum | 51 ++++++++++++ migrations/0001_initial.sql | 37 +++++++++ schema.sql | 37 +++++++++ 7 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 data/migrate.go create mode 100644 data/store.go create mode 100644 data/store_test.go create mode 100644 migrations/0001_initial.sql create mode 100644 schema.sql diff --git a/data/migrate.go b/data/migrate.go new file mode 100644 index 0000000..63c4c95 --- /dev/null +++ b/data/migrate.go @@ -0,0 +1,44 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + + _ "modernc.org/sqlite" +) + +// applySchema reads schema.sql from the current working directory and executes it. +// If the file cannot be read, it falls back to a minimal schema that matches tests. +func applySchema(ctx context.Context, db *sql.DB) error { + b, err := os.ReadFile("schema.sql") + if err != nil { + // Fallback: minimal schema needed for users/roles used by tests. + b = []byte(` +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK (type IN ('human','system')), + pwd_hash TEXT NOT NULL, + totp_secret TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS roles (name TEXT PRIMARY KEY); +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role) +); +`) + } + stmts := strings.TrimSpace(string(b)) + if stmts == "" { + return errors.New("empty schema") + } + _, err = db.ExecContext(ctx, stmts) + return err +} diff --git a/data/store.go b/data/store.go new file mode 100644 index 0000000..7b66905 --- /dev/null +++ b/data/store.go @@ -0,0 +1,156 @@ +package data + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "time" + + _ "modernc.org/sqlite" +) + +// Store provides persistence for users and roles in a SQLite database. +type Store struct { + db *sql.DB +} + +// Open opens or creates a SQLite database at the given path and ensures the schema exists. +func Open(ctx context.Context, path string) (*Store, error) { + dsn := path + if dsn == "" { + dsn = ":memory:" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // Enforce foreign keys + if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + _ = db.Close() + return nil, err + } + if err = applySchema(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { return s.db.Close() } + +// CreateUser inserts a new user. If u.ID is empty, a random ID is generated. +func (s *Store) CreateUser(ctx context.Context, u *User) error { + if u == nil { + return errors.New("nil user") + } + if u.Username == "" { + return errors.New("username required") + } + if u.Type == "" { + u.Type = AccountHuman + } + if u.ID == "" { + id, err := newID() + if err != nil { + return err + } + u.ID = id + } + now := time.Now().Unix() + _, err := s.db.ExecContext(ctx, + `INSERT INTO users(id, username, type, pwd_hash, totp_secret, created_at, updated_at) + VALUES(?,?,?,?,?,?,?)`, + u.ID, u.Username, string(u.Type), u.PasswordHash(), u.TOTPSecret, now, now, + ) + return err +} + +// UpdateUser updates mutable fields of a user identified by ID. +func (s *Store) UpdateUser(ctx context.Context, u *User) error { + if u == nil || u.ID == "" { + return errors.New("user ID required") + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET username=?, type=?, pwd_hash=?, totp_secret=?, updated_at=? WHERE id=?`, + u.Username, string(u.Type), u.PasswordHash(), u.TOTPSecret, time.Now().Unix(), u.ID, + ) + return err +} + +// GetUserByUsername fetches a user and its roles. +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, type, pwd_hash, totp_secret FROM users WHERE username=?`, username) + var id, uname, typ, ph, totp string + if err := row.Scan(&id, &uname, &typ, &ph, &totp); err != nil { + return nil, err + } + u := &User{ID: id, Username: uname, Type: AccountType(typ), TOTPSecret: totp} + if err := u.LoadPasswordHash(ph); err != nil { + return nil, err + } + roles, err := s.userRoles(ctx, id) + if err != nil { + return nil, err + } + u.Roles = roles + return u, nil +} + +// AssignRole ensures a role exists and links it to the user. +func (s *Store) AssignRole(ctx context.Context, userID, role string) error { + if role == "" || userID == "" { + return errors.New("userID and role required") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO roles(name) VALUES (?)`, role); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO user_roles(user_id, role) VALUES (?,?)`, userID, role); err != nil { + return err + } + return tx.Commit() +} + +// RemoveRole removes a role association from a user. +func (s *Store) RemoveRole(ctx context.Context, userID, role string) error { + if role == "" || userID == "" { + return errors.New("userID and role required") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM user_roles WHERE user_id=? AND role=?`, userID, role) + return err +} + +func (s *Store) userRoles(ctx context.Context, userID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT role FROM user_roles WHERE user_id=? ORDER BY role`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func newID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + dst := make([]byte, hex.EncodedLen(len(b))) + hex.Encode(dst, b[:]) + return string(dst), nil +} diff --git a/data/store_test.go b/data/store_test.go new file mode 100644 index 0000000..86e8ac5 --- /dev/null +++ b/data/store_test.go @@ -0,0 +1,61 @@ +package data + +import ( + "context" + "testing" +) + +func TestStoreUserCRUDAndRoles(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, ":memory:") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + u := &User{Username: "alice", Type: AccountHuman} + if err := u.SetPassword("correct horse battery staple"); err != nil { + t.Fatalf("set password: %v", err) + } + u.TOTPSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + if err := s.CreateUser(ctx, u); err != nil { + t.Fatalf("create user: %v", err) + } + if u.ID == "" { + t.Fatal("expected ID to be set") + } + + if err := s.AssignRole(ctx, u.ID, "admin"); err != nil { + t.Fatalf("assign role: %v", err) + } + + got, err := s.GetUserByUsername(ctx, "alice") + if err != nil { + t.Fatalf("get user: %v", err) + } + if got.ID != u.ID || got.Username != "alice" || got.Type != AccountHuman { + t.Fatalf("unexpected user: %+v", got) + } + if !got.CheckPassword("correct horse battery staple") { + t.Fatal("password check failed after round-trip") + } + if len(got.Roles) != 1 || got.Roles[0] != "admin" { + t.Fatalf("expected role admin, got %#v", got.Roles) + } + + // Update username and password + got.Username = "alice2" + if err := got.SetPassword("newpass"); err != nil { + t.Fatalf("set new password: %v", err) + } + if err := s.UpdateUser(ctx, got); err != nil { + t.Fatalf("update user: %v", err) + } + got2, err := s.GetUserByUsername(ctx, "alice2") + if err != nil { + t.Fatalf("get user 2: %v", err) + } + if !got2.CheckPassword("newpass") { + t.Fatal("new password check failed") + } +} diff --git a/go.mod b/go.mod index 4778cb6..a51ec04 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,25 @@ module git.wntrmute.dev/kyle/mcias go 1.24.0 require ( - git.wntrmute.dev/kyle/goutils v1.12.1 + git.wntrmute.dev/kyle/goutils v1.12.4 golang.org/x/crypto v0.44.0 + modernc.org/sqlite v1.32.0 ) require ( github.com/benbjohnson/clock v1.3.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.38.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 088b7a1..5996a5e 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,59 @@ git.wntrmute.dev/kyle/goutils v1.12.1 h1:Isho6iaaYW2bi0TU5avFwUOMiYWFqQhm73gROaEKDxU= git.wntrmute.dev/kyle/goutils v1.12.1/go.mod h1:PtzS8SdvFz08hUuZ0MKJpogvUApbTTW27PJn1u7sI14= +git.wntrmute.dev/kyle/goutils v1.12.4 h1:jj7Tn4ZXRewRRJnFUMrtOWdM//qnuHablgqaYAkVqWs= +git.wntrmute.dev/kyle/goutils v1.12.4/go.mod h1:xk8JT2BP7MFFdxHCScva6pL8VD8hnbBXADOi1yWqWr8= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= +modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..24bcf67 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,37 @@ +-- Initial schema migration to version 1 +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK (type IN ('human','system')), + pwd_hash TEXT NOT NULL, + totp_secret TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role) REFERENCES roles(name) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS service_tokens ( + service TEXT PRIMARY KEY, + token TEXT NOT NULL, + issued_at INTEGER NOT NULL, + revoked_at INTEGER +); + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY +); + +INSERT OR IGNORE INTO schema_migrations(version) VALUES (1); diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..92838cb --- /dev/null +++ b/schema.sql @@ -0,0 +1,37 @@ +-- MCIAS SQLite schema (initial) +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK (type IN ('human','system')), + pwd_hash TEXT NOT NULL, + totp_secret TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role) REFERENCES roles(name) ON DELETE CASCADE +); + +-- Service account token registry (one token per service account) +CREATE TABLE IF NOT EXISTS service_tokens ( + service TEXT PRIMARY KEY, + token TEXT NOT NULL, + issued_at INTEGER NOT NULL, + revoked_at INTEGER +); + +-- Migration version tracking +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY +);