This commit is contained in:
Tomas Dvorak
2026-04-14 18:04:48 +02:00
parent 94f7302972
commit 355a97bab4
453 changed files with 81845 additions and 1243 deletions
+236
View File
@@ -0,0 +1,236 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"apwhy/internal/auth"
"apwhy/internal/config"
"apwhy/internal/rbac"
)
type Store struct {
DB *sql.DB
Cfg config.Config
}
func NowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
func Open(cfg config.Config) (*Store, error) {
db, err := sql.Open("sqlite", cfg.SQLitePath)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schemaSQL); err != nil {
return nil, fmt.Errorf("failed to apply schema: %w", err)
}
s := &Store{DB: db, Cfg: cfg}
if err := s.seedAccessControl(context.Background()); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
return s.DB.Close()
}
func (s *Store) seedAccessControl(ctx context.Context) error {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := NowISO()
for _, permission := range rbac.PermissionSeeds {
id, _ := auth.RandomID("perm")
_, err := tx.ExecContext(ctx, `
INSERT INTO permissions (id, code, name, description, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET name=excluded.name, description=excluded.description
`, id, permission.Code, permission.Name, permission.Description, now)
if err != nil {
return err
}
}
type roleSeed struct {
Name string
Slug string
Description string
System bool
PermCodes []string
}
roles := []roleSeed{
{Name: "Owner", Slug: "owner", Description: "Primary administrator with all permissions.", System: true, PermCodes: rbac.OwnerPermissionCodes},
{Name: "Admin", Slug: "admin", Description: "Operational admin with management permissions.", System: true, PermCodes: rbac.AdminPermissionCodes},
{Name: "Viewer", Slug: "viewer", Description: "Read-only dashboard access.", System: true, PermCodes: rbac.ViewerPermissionCodes},
}
for _, role := range roles {
roleID := ""
_ = tx.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, role.Slug).Scan(&roleID)
if roleID == "" {
roleID, _ = auth.RandomID("role")
_, err := tx.ExecContext(ctx, `
INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
`, roleID, role.Name, role.Slug, role.Description, boolToInt(role.System), now, now)
if err != nil {
return err
}
} else {
_, err := tx.ExecContext(ctx, `
UPDATE roles SET name = ?, description = ?, is_system = ?, updated_at = ? WHERE id = ?
`, role.Name, role.Description, boolToInt(role.System), now, roleID)
if err != nil {
return err
}
}
if role.System {
_, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID)
if err != nil {
return err
}
for _, code := range role.PermCodes {
permID := ""
if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, code).Scan(&permID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)
ON CONFLICT(role_id, permission_id) DO NOTHING
`, roleID, permID, now)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
func monthPeriod(t time.Time) string {
return t.UTC().Format("2006-01")
}
func slugify(value string, fallback string) string {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" {
v = fallback
}
out := strings.Builder{}
lastDash := false
for _, r := range v {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
out.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
out.WriteRune('-')
lastDash = true
}
}
result := strings.Trim(out.String(), "-")
if result == "" {
return fallback
}
return result
}
func normalizePathPrefix(value, fallback string) string {
v := strings.TrimSpace(value)
if v == "" {
v = fallback
}
if !strings.HasPrefix(v, "/") {
v = "/" + v
}
if len(v) > 1 && strings.HasSuffix(v, "/") {
v = strings.TrimSuffix(v, "/")
}
return v
}
func normalizeHealthPath(value string) string {
return normalizePathPrefix(value, "/health")
}
func parseAllowedServiceIDs(value string) []string {
if strings.TrimSpace(value) == "" {
return []string{}
}
var result []string
_ = json.Unmarshal([]byte(value), &result)
if result == nil {
return []string{}
}
return result
}
func mustJSON(v any) string {
bytes, _ := json.Marshal(v)
return string(bytes)
}
func minLimit(a, b sql.NullInt64) sql.NullInt64 {
if !a.Valid && !b.Valid {
return sql.NullInt64{}
}
if !a.Valid {
return b
}
if !b.Valid {
return a
}
if a.Int64 < b.Int64 {
return a
}
return b
}
func toNullInt(value *int) sql.NullInt64 {
if value == nil || *value <= 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Valid: true, Int64: int64(*value)}
}
func scanJSONText(value sql.NullString) string {
if !value.Valid {
return "[]"
}
if strings.TrimSpace(value.String) == "" {
return "[]"
}
return value.String
}
var ErrNotFound = errors.New("not found")
+194
View File
@@ -0,0 +1,194 @@
package storage
const schemaSQL = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
force_password_reset INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
access_token_hash TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL,
access_expires_at TEXT NOT NULL,
refresh_expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_access ON sessions(access_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token_hash);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS password_resets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS permissions (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id TEXT NOT NULL,
permission_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(role_id, permission_id),
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, role_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
upstream_url TEXT NOT NULL,
route_prefix TEXT NOT NULL UNIQUE,
health_path TEXT NOT NULL DEFAULT '/health',
upstream_auth_header TEXT,
upstream_auth_value TEXT,
internal_token TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
rpm_limit INTEGER,
monthly_quota INTEGER,
request_timeout_ms INTEGER,
last_validation_at TEXT,
last_validation_status TEXT,
last_validation_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS database_connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL,
connection_url TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_validation_at TEXT,
last_validation_status TEXT,
last_validation_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
plan TEXT NOT NULL,
allowed_service_ids TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
rpm_limit INTEGER,
monthly_quota INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS usage_counters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id TEXT NOT NULL,
service_id TEXT NOT NULL,
period_month TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
UNIQUE(api_key_id, service_id, period_month),
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE,
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS incident_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT,
api_key_id TEXT,
code TEXT NOT NULL,
message TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium',
http_status INTEGER,
count INTEGER NOT NULL DEFAULT 1,
occurred_at TEXT NOT NULL,
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL,
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS metrics_timeseries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metric TEXT NOT NULL,
value REAL NOT NULL,
labels_json TEXT,
occurred_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_metric_time ON metrics_timeseries(metric, occurred_at DESC);
CREATE TABLE IF NOT EXISTS umami_sync_cache (
cache_key TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id TEXT,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
payload_json TEXT,
occurred_at TEXT NOT NULL,
FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL
);
`